diff --git a/VERSION b/VERSION index 03b7bd6..34728b5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.11.6 +2.11.7 diff --git a/src/auth/gemini-oauth.js b/src/auth/gemini-oauth.js index e7896bb..4842efb 100644 --- a/src/auth/gemini-oauth.js +++ b/src/auth/gemini-oauth.js @@ -42,10 +42,42 @@ const activeServers = new Map(); * 生成 HTML 响应页面 * @param {boolean} isSuccess - 是否成功 * @param {string} message - 显示消息 + * @param {string|null} provider - 提供商标识 * @returns {string} HTML 内容 */ -function generateResponsePage(isSuccess, message) { +function generateResponsePage(isSuccess, message, provider = null) { const title = isSuccess ? '授权成功!' : '授权失败'; + const countdownHtml = isSuccess ? ` +

此窗口将在 10 秒后自动关闭。

+ ` : ''; return ` @@ -53,11 +85,34 @@ function generateResponsePage(isSuccess, message) { ${title} +
-

${title}

+

${isSuccess ? '✅' : '❌'} ${title}

${message}

+ ${countdownHtml}
`; @@ -181,11 +236,19 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa }); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(generateResponsePage(true, '您可以关闭此页面')); + res.end(generateResponsePage(true, '您可以关闭此页面', provider)); } catch (tokenError) { logger.error(`${config.logPrefix} 获取令牌失败:`, tokenError); + + // 广播授权失败事件 + broadcastEvent('oauth_error', { + provider: provider, + error: tokenError.message, + timestamp: new Date().toISOString() + }); + res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(generateResponsePage(false, `获取令牌失败: ${tokenError.message}`)); + res.end(generateResponsePage(false, `获取令牌失败: ${tokenError.message}`, provider)); } finally { server.close(() => { activeServers.delete(provider); @@ -196,8 +259,15 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa const errorMessage = `授权失败。Google 返回错误: ${errorParam}`; logger.error(`${config.logPrefix}`, errorMessage); + // 广播授权失败事件 + broadcastEvent('oauth_error', { + provider: provider, + error: errorMessage, + timestamp: new Date().toISOString() + }); + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(generateResponsePage(false, errorMessage)); + res.end(generateResponsePage(false, errorMessage, provider)); server.close(() => { activeServers.delete(provider); }); @@ -210,7 +280,7 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa clearPollTimer(); logger.error(`${config.logPrefix} 处理回调时出错:`, error); res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(generateResponsePage(false, `服务器错误: ${error.message}`)); + res.end(generateResponsePage(false, `服务器错误: ${error.message}`, provider)); if (server.listening) { server.close(() => { diff --git a/src/auth/iflow-oauth.js b/src/auth/iflow-oauth.js index c882624..bb1ce27 100644 --- a/src/auth/iflow-oauth.js +++ b/src/auth/iflow-oauth.js @@ -110,6 +110,20 @@ async function fetchWithProxy(url, options = {}, providerType) { */ function generateResponsePage(isSuccess, message) { const title = isSuccess ? '授权成功!' : '授权失败'; + const countdownHtml = isSuccess ? ` +

此窗口将在 10 秒后自动关闭。

+ ` : ''; return ` @@ -117,11 +131,34 @@ function generateResponsePage(isSuccess, message) { ${title} +
-

${title}

+

${isSuccess ? '✅' : '❌'} ${title}

${message}

+ ${countdownHtml}
`; diff --git a/src/auth/kiro-oauth.js b/src/auth/kiro-oauth.js index 2e1bba6..718f9b4 100644 --- a/src/auth/kiro-oauth.js +++ b/src/auth/kiro-oauth.js @@ -128,6 +128,20 @@ async function fetchWithProxy(url, options = {}, providerType) { */ function generateResponsePage(isSuccess, message) { const title = isSuccess ? '授权成功!' : '授权失败'; + const countdownHtml = isSuccess ? ` +

此窗口将在 10 秒后自动关闭。

+ ` : ''; return ` @@ -135,11 +149,34 @@ function generateResponsePage(isSuccess, message) { ${title} +
-

${title}

+

${isSuccess ? '✅' : '❌'} ${title}

${message}

+ ${countdownHtml}
`; diff --git a/src/providers/grok/grok-core.js b/src/providers/grok/grok-core.js index ae2c40f..970f20c 100644 --- a/src/providers/grok/grok-core.js +++ b/src/providers/grok/grok-core.js @@ -3,9 +3,10 @@ import logger from '../../utils/logger.js'; import * as http from 'http'; import * as https from 'https'; import { v4 as uuidv4 } from 'uuid'; -import { MODEL_PROTOCOL_PREFIX, MODEL_PROVIDER, isRetryableNetworkError } from '../../utils/common.js'; +import { MODEL_PROTOCOL_PREFIX, isRetryableNetworkError } from '../../utils/common.js'; import { getProviderModels } from '../provider-models.js'; import { configureAxiosProxy, configureTLSSidecar } from '../../utils/proxy-utils.js'; +import { MODEL_PROVIDER } from '../../utils/common.js'; import { ConverterFactory } from '../../converters/ConverterFactory.js'; import * as readline from 'readline'; import { getProviderPoolManager } from '../../services/service-manager.js'; @@ -84,6 +85,34 @@ export class GrokApiService { this.lastSyncAt = null; } + getMaxRequestRetries() { + const requestMaxRetries = Number.parseInt(this.config.REQUEST_MAX_RETRIES, 10); + if (Number.isFinite(requestMaxRetries) && requestMaxRetries > 0) { + return requestMaxRetries; + } + + return 3; + } + + classifyApiError(error) { + const status = error.response?.status; + const errorCode = error.code; + const errorMessage = error.message || ''; + const isNetworkError = isRetryableNetworkError(error); + + if (status === 401 || status === 403) { + error.shouldSwitchCredential = true; + error.message = 'Grok authentication failed (SSO token invalid or expired)'; + } else if (isNetworkError) { + // Network jitter or request timeout should not immediately degrade account health. + // Let the upper retry layer switch credential without incrementing the provider error count. + error.shouldSwitchCredential = true; + error.skipErrorCount = true; + } + + return { status, errorCode, errorMessage, isNetworkError }; + } + async setupNsfw() { if (this.nsfwSetupDone) return; try { @@ -100,13 +129,15 @@ export class GrokApiService { async acceptTos() { const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/app-chat/accept-tos`, headers: this.buildHeaders(), data: {}, httpAgent, httpsAgent, timeout: 15000 }; configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); - try { await this.callApi(axiosConfig); } catch (e) { logger.debug(`[Grok TOS] ${e.message}`); } + this._applySidecar(axiosConfig); + try { await axios(axiosConfig); } catch (e) { logger.debug(`[Grok TOS] ${e.message}`); } } async setBirthDate() { const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/app-chat/set-birth-date`, headers: this.buildHeaders(), data: { "birthDate": "1990-01-01" }, httpAgent, httpsAgent, timeout: 15000 }; configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); - try { await this.callApi(axiosConfig); } catch (e) { logger.debug(`[Grok Birth] ${e.message}`); } + this._applySidecar(axiosConfig); + try { await axios(axiosConfig); } catch (e) { logger.debug(`[Grok Birth] ${e.message}`); } } async enableNsfwAccount() { @@ -125,105 +156,25 @@ export class GrokApiService { headers['x-user-agent'] = 'connect-es/2.1.1'; headers['referer'] = `${this.baseUrl}/?_s=data`; - const axiosConfig = { - method: 'post', - url: `${this.baseUrl}/auth_mgmt.AuthManagement/UpdateUserFeatureControls`, - headers, - data: payload, - httpAgent, - httpsAgent, + const axiosConfig = { + method: 'post', + url: `${this.baseUrl}/auth_mgmt.AuthManagement/UpdateUserFeatureControls`, + headers, + data: payload, + httpAgent, + httpsAgent, timeout: 15000, - responseType: 'arraybuffer' + responseType: 'arraybuffer' }; configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); - try { await this.callApi(axiosConfig); } catch (e) { throw e; } + this._applySidecar(axiosConfig); + try { await axios(axiosConfig); } catch (e) { throw e; } } _applySidecar(axiosConfig) { return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); } - async callApi(axiosConfig, retryCount = 0) { - const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; - const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; - - try { - this._applySidecar(axiosConfig); - const response = await axios(axiosConfig); - return response.data; - } catch (error) { - const status = error.response?.status; - const isNetworkError = isRetryableNetworkError(error); - - if (status === 401 || status === 403) { - this.handleApiError(error); - } - - if ((status === 429 || (status >= 500 && status < 600) || isNetworkError) && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - const reason = status ? `Status ${status}` : (error.code || 'Network error'); - logger.info(`[Grok API] ${reason}. Retrying in ${delay}ms... (${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(axiosConfig, retryCount + 1); - } - this.handleApiError(error); - } - } - - async * _streamChat(axiosConfig, reqBaseUrl, payload, retryCount = 0) { - const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; - const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; - - try { - this._applySidecar(axiosConfig); - const response = await axios(axiosConfig); - const rl = readline.createInterface({ input: response.data, terminal: false }); - let lastResponseId = payload.responseMetadata?.requestModelDetails?.modelId || "final"; - - for await (const line of rl) { - const trimmed = line.trim(); - if (!trimmed) continue; - let dataStr = trimmed.startsWith('data: ') ? trimmed.slice(6).trim() : trimmed; - if (dataStr === '[DONE]') break; - try { - const json = JSON.parse(dataStr); - if (json.result?.response) { - const resp = json.result.response; - resp._requestBaseUrl = reqBaseUrl; - resp._uuid = this.uuid; - if (resp.responseId) lastResponseId = resp.responseId; - if (resp.streamingVideoGenerationResponse) { - const vid = resp.streamingVideoGenerationResponse; - if (vid.progress === 100 && vid.videoUrl && (payload.responseMetadata?.modelConfigOverride?.modelMap?.videoGenModelConfig?.resolutionName === "720p")) { - const hdUrl = await this.upscaleVideo(vid.videoUrl); - if (hdUrl) vid.videoUrl = hdUrl; - } - } - } - yield json; - } catch (e) {} - } - yield { result: { response: { isDone: true, responseId: lastResponseId, _requestBaseUrl: reqBaseUrl, _uuid: this.uuid } } }; - } catch (error) { - const status = error.response?.status; - const isNetworkError = isRetryableNetworkError(error); - - if (status === 401 || status === 403) { - this.handleApiError(error); - } - - if ((status === 429 || (status >= 500 && status < 600) || isNetworkError) && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - const reason = status ? `Status ${status}` : (error.code || 'Network error'); - logger.info(`[Grok API] ${reason} during stream. Retrying in ${delay}ms... (${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this._streamChat(axiosConfig, reqBaseUrl, payload, retryCount + 1); - return; - } - this.handleApiError(error); - } - } - async initialize() { if (this.isInitialized) return; this.isInitialized = true; @@ -242,8 +193,10 @@ export class GrokApiService { const payload = { "requestKind": "DEFAULT", "modelName": "grok-3" }; const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/rate-limits`, headers, data: payload, httpAgent, httpsAgent, timeout: 30000 }; configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); + this._applySidecar(axiosConfig); try { - const data = await this.callApi(axiosConfig); + const response = await axios(axiosConfig); + const data = response.data; let remaining = data.remainingTokens !== undefined ? data.remainingTokens : (data.remainingQueries !== undefined ? data.remainingQueries : data.totalQueries); if (data.totalQueries > 0) { data.totalLimit = data.totalQueries; @@ -320,9 +273,10 @@ export class GrokApiService { const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/media/post/create`, headers, data: payload, httpAgent, httpsAgent, timeout: 30000 }; configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); + this._applySidecar(axiosConfig); try { - const data = await this.callApi(axiosConfig); - const postId = data?.post?.id; + const response = await axios(axiosConfig); + const postId = response.data?.post?.id; if (postId) logger.info(`[Grok Post] Media post created: ${postId} (type=${mediaType})`); return postId; } catch (error) { @@ -339,9 +293,10 @@ export class GrokApiService { const videoId = idMatch[1]; const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/media/video/upscale`, headers: this.buildHeaders(), data: { videoId }, httpAgent, httpsAgent, timeout: 30000 }; configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); + this._applySidecar(axiosConfig); try { - const data = await this.callApi(axiosConfig); - return data?.hdMediaUrl || videoUrl; + const response = await axios(axiosConfig); + return response.data?.hdMediaUrl || videoUrl; } catch (error) { return videoUrl; } } @@ -365,9 +320,10 @@ export class GrokApiService { timeout: 15000 }; configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); + this._applySidecar(axiosConfig); try { - const data = await this.callApi(axiosConfig); - const shareLink = data?.shareLink; + const response = await axios(axiosConfig); + const shareLink = response.data?.shareLink; if (shareLink) { // 从 shareLink 中提取 ID (通常与输入的 postId 一致) const idMatch = shareLink.match(/\/post\/([0-9a-fA-F-]{36}|[0-9a-fA-F]{32})/); @@ -549,10 +505,15 @@ export class GrokApiService { if (!b64) return null; const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/app-chat/upload-file`, headers: this.buildHeaders(), data: { fileName: `file.${mime.split("/")[1] || "bin"}`, fileMimeType: mime, content: b64 }, httpAgent, httpsAgent, timeout: 30000 }; configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); - try { return await this.callApi(axiosConfig); } catch (error) { return null; } + this._applySidecar(axiosConfig); + try { return (await axios(axiosConfig)).data; } catch (error) { return null; } } - async * generateContentStream(model, requestBody) { + async * generateContentStream(model, requestBody, retryCount = 0) { + const maxRetries = this.getMaxRequestRetries(); + const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; + let hasYieldedData = false; + if (this.converter) { if (this.uuid) this.converter.setUuid(this.uuid); if (requestBody._requestBaseUrl) this.converter.setRequestBaseUrl(requestBody._requestBaseUrl); @@ -642,21 +603,69 @@ export class GrokApiService { const payload = this.buildPayload(model, requestBody); const axiosConfig = { method: 'post', url: this.chatApi, headers: this.buildHeaders(), data: payload, responseType: 'stream', httpAgent, httpsAgent, timeout: 60000, maxRedirects: 0 }; configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); + this._applySidecar(axiosConfig); - yield* this._streamChat(axiosConfig, reqBaseUrl, payload); - } + try { + const response = await axios(axiosConfig); + const rl = readline.createInterface({ input: response.data, terminal: false }); + let lastResponseId = payload.responseMetadata?.requestModelDetails?.modelId || "final"; - handleApiError(error) { - const status = error.response?.status; - if (status === 401 || status === 403) { - error.shouldSwitchCredential = true; - error.message = 'Grok authentication failed (SSO token invalid or expired)'; - } else if (isRetryableNetworkError(error)) { - // 网络超时等可重试错误,触发凭证切换而不立即增加错误计数(避免因偶发波动导致节点下线) - error.shouldSwitchCredential = true; - error.skipErrorCount = true; + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) continue; + let dataStr = trimmed.startsWith('data: ') ? trimmed.slice(6).trim() : trimmed; + if (dataStr === '[DONE]') break; + try { + const json = JSON.parse(dataStr); + if (json.result?.response) { + const resp = json.result.response; + resp._requestBaseUrl = reqBaseUrl; + resp._uuid = this.uuid; + if (resp.responseId) lastResponseId = resp.responseId; + if (resp.streamingVideoGenerationResponse) { + const vid = resp.streamingVideoGenerationResponse; + if (vid.progress === 100 && vid.videoUrl && (requestBody.videoGenModelConfig?.resolutionName === "720p")) { + const hdUrl = await this.upscaleVideo(vid.videoUrl); + if (hdUrl) vid.videoUrl = hdUrl; + } + } + } + hasYieldedData = true; + yield json; + } catch (e) {} + } + yield { result: { response: { isDone: true, responseId: lastResponseId, _requestBaseUrl: reqBaseUrl, _uuid: this.uuid } } }; + } catch (error) { + const { status, errorCode, errorMessage, isNetworkError } = this.classifyApiError(error); + const canRetryInRequest = !hasYieldedData && retryCount < maxRetries; + + if (status === 429 && canRetryInRequest) { + const delay = baseDelay * Math.pow(2, retryCount); + logger.info(`[Grok API] Received 429 during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + yield* this.generateContentStream(model, requestBody, retryCount + 1); + return; + } + + if (status >= 500 && status < 600 && canRetryInRequest) { + const delay = baseDelay * Math.pow(2, retryCount); + logger.info(`[Grok API] Received ${status} server error during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + yield* this.generateContentStream(model, requestBody, retryCount + 1); + return; + } + + if (isNetworkError && canRetryInRequest) { + const delay = baseDelay * Math.pow(2, retryCount); + const errorIdentifier = errorCode || errorMessage.substring(0, 50); + logger.info(`[Grok API] Network error (${errorIdentifier}) during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + yield* this.generateContentStream(model, requestBody, retryCount + 1); + return; + } + + throw error; } - throw error; } async listModels() { diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js index 1a60c14..1a9b12f 100644 --- a/static/app/provider-manager.js +++ b/static/app/provider-manager.js @@ -2759,20 +2759,50 @@ function showAuthModal(authUrl, authInfo) { 'OAuthAuthWindow', `width=${width},height=${height},left=${left},top=${top},status=no,resizable=yes,scrollbars=yes` ); - + + let pollTimer = null; + const cleanupAuthListeners = () => { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + window.removeEventListener('oauth_success_event', handleOAuthSuccess); + window.removeEventListener('message', handlePopupMessage); + }; + // 监听 OAuth 成功事件,自动关闭窗口和模态框 const handleOAuthSuccess = () => { if (authWindow && !authWindow.closed) { authWindow.close(); } modal.remove(); - window.removeEventListener('oauth_success_event', handleOAuthSuccess); + cleanupAuthListeners(); // 授权成功后刷新配置和提供商列表 loadProviders(); loadConfigList(); }; + + // 回调页主动 postMessage 时,优先使用父页面关闭子窗口 + const handlePopupMessage = (event) => { + if (event.origin !== window.location.origin) { + return; + } + + const data = event.data; + if (!data || data.type !== 'oauth-popup-complete') { + return; + } + + if (data.provider && data.provider !== authInfo.provider) { + return; + } + + handleOAuthSuccess(); + }; + window.addEventListener('oauth_success_event', handleOAuthSuccess); + window.addEventListener('message', handlePopupMessage); if (authWindow) { showToast(t('common.info'), t('oauth.window.opened'), 'info'); @@ -2806,7 +2836,10 @@ function showAuthModal(authUrl, authInfo) { const url = new URL(cleanUrlStr); if (url.searchParams.has('code') || url.searchParams.has('token')) { - clearInterval(pollTimer); + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } // 构造本地可处理的 URL,只修改 hostname,保持原始 URL 的端口号不变 const localUrl = new URL(url.href); localUrl.hostname = window.location.hostname; @@ -2816,10 +2849,6 @@ function showAuthModal(authUrl, authInfo) { // 如果是手动输入,直接通过 fetch 请求处理,然后关闭子窗口 if (isManualInput) { - // 关闭子窗口 - if (authWindow && !authWindow.closed) { - authWindow.close(); - } // 通过服务端API处理手动输入的回调URL window.apiClient.post('/oauth/manual-callback', { provider: authInfo.provider, @@ -2829,6 +2858,7 @@ function showAuthModal(authUrl, authInfo) { .then(response => { if (response.success) { console.log('OAuth 回调处理成功'); + handleOAuthSuccess(); showToast(t('common.success'), t('oauth.success.msg'), 'success'); } else { console.error('OAuth 回调处理失败:', response.error); @@ -2874,10 +2904,10 @@ function showAuthModal(authUrl, authInfo) { }); // 启动定时器轮询子窗口 URL - const pollTimer = setInterval(() => { + pollTimer = setInterval(() => { try { if (authWindow.closed) { - clearInterval(pollTimer); + cleanupAuthListeners(); return; } // 如果能读到说明回到了同域 @@ -3103,4 +3133,4 @@ export { handleGenerateAuthUrl, checkUpdate, performUpdate -}; \ No newline at end of file +}; diff --git a/static/components/header.css b/static/components/header.css index e06b213..5f42b75 100644 --- a/static/components/header.css +++ b/static/components/header.css @@ -131,20 +131,20 @@ align-items: center; gap: 0.5rem; padding: 0.5rem 1rem; - background: linear-gradient(135deg, var(--warning-color) 0%, #f97316 100%); + background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%); color: var(--white); text-decoration: none; border-radius: 9999px; font-size: 0.875rem; font-weight: 600; transition: var(--transition); - box-shadow: 0 2px 8px var(--warning-30); + box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3); } .kiro-buy-link:hover { transform: translateY(-2px); - box-shadow: 0 4px 12px var(--warning-40); - background: linear-gradient(135deg, #f97316 0%, var(--warning-color) 100%); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4); + background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); } .kiro-buy-link:active { diff --git a/static/components/header.html b/static/components/header.html index 40de0f1..6d12a81 100644 --- a/static/components/header.html +++ b/static/components/header.html @@ -7,8 +7,8 @@
- - 多渠道账号购买 + + AI账号购买 连接中...