feat: 优化OAuth授权流程并更新UI样式
- 在OAuth授权成功页面添加倒计时自动关闭功能,提升用户体验 - 改进授权弹窗通信机制,支持postMessage方式主动关闭窗口 - 更新Gemini OAuth回调页面,添加提供商标识和跨窗口通信 - 重构Grok API错误处理和重试逻辑,增强网络稳定性 - 修改头部组件购买链接为AI账号购买,并更新对应样式
This commit is contained in:
parent
2a3312df15
commit
46038a5459
8 changed files with 322 additions and 139 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
2.11.6
|
||||
2.11.7
|
||||
|
|
|
|||
|
|
@ -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 ? `
|
||||
<p>此窗口将在 <span id="countdown" style="font-weight: bold; color: #2196f3;">10</span> 秒后自动关闭。</p>
|
||||
<script>
|
||||
const notifyOpener = () => {
|
||||
try {
|
||||
if (window.opener && !window.opener.closed) {
|
||||
window.opener.postMessage({
|
||||
type: 'oauth-popup-complete',
|
||||
provider: ${JSON.stringify(provider)},
|
||||
success: true
|
||||
}, window.location.origin);
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
notifyOpener();
|
||||
setTimeout(() => {
|
||||
try {
|
||||
window.close();
|
||||
} catch (e) {}
|
||||
}, 300);
|
||||
let countdown = 10;
|
||||
const timer = setInterval(() => {
|
||||
countdown--;
|
||||
const el = document.getElementById('countdown');
|
||||
if (el) el.textContent = countdown;
|
||||
if (countdown <= 0) {
|
||||
clearInterval(timer);
|
||||
window.close();
|
||||
}
|
||||
}, 1000);
|
||||
</script>` : '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
|
@ -53,11 +85,34 @@ function generateResponsePage(isSuccess, message) {
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
}
|
||||
h1 { color: ${isSuccess ? '#4caf50' : '#f44336'}; margin-top: 0; }
|
||||
p { color: #666; line-height: 1.6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>${title}</h1>
|
||||
<h1>${isSuccess ? '✅' : '❌'} ${title}</h1>
|
||||
<p>${message}</p>
|
||||
${countdownHtml}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -110,6 +110,20 @@ async function fetchWithProxy(url, options = {}, providerType) {
|
|||
*/
|
||||
function generateResponsePage(isSuccess, message) {
|
||||
const title = isSuccess ? '授权成功!' : '授权失败';
|
||||
const countdownHtml = isSuccess ? `
|
||||
<p>此窗口将在 <span id="countdown" style="font-weight: bold; color: #2196f3;">10</span> 秒后自动关闭。</p>
|
||||
<script>
|
||||
let countdown = 10;
|
||||
const timer = setInterval(() => {
|
||||
countdown--;
|
||||
const el = document.getElementById('countdown');
|
||||
if (el) el.textContent = countdown;
|
||||
if (countdown <= 0) {
|
||||
clearInterval(timer);
|
||||
window.close();
|
||||
}
|
||||
}, 1000);
|
||||
</script>` : '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
|
@ -117,11 +131,34 @@ function generateResponsePage(isSuccess, message) {
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
}
|
||||
h1 { color: ${isSuccess ? '#4caf50' : '#f44336'}; margin-top: 0; }
|
||||
p { color: #666; line-height: 1.6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>${title}</h1>
|
||||
<h1>${isSuccess ? '✅' : '❌'} ${title}</h1>
|
||||
<p>${message}</p>
|
||||
${countdownHtml}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
|
|
|||
|
|
@ -128,6 +128,20 @@ async function fetchWithProxy(url, options = {}, providerType) {
|
|||
*/
|
||||
function generateResponsePage(isSuccess, message) {
|
||||
const title = isSuccess ? '授权成功!' : '授权失败';
|
||||
const countdownHtml = isSuccess ? `
|
||||
<p>此窗口将在 <span id="countdown" style="font-weight: bold; color: #2196f3;">10</span> 秒后自动关闭。</p>
|
||||
<script>
|
||||
let countdown = 10;
|
||||
const timer = setInterval(() => {
|
||||
countdown--;
|
||||
const el = document.getElementById('countdown');
|
||||
if (el) el.textContent = countdown;
|
||||
if (countdown <= 0) {
|
||||
clearInterval(timer);
|
||||
window.close();
|
||||
}
|
||||
}, 1000);
|
||||
</script>` : '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
|
@ -135,11 +149,34 @@ function generateResponsePage(isSuccess, message) {
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
}
|
||||
h1 { color: ${isSuccess ? '#4caf50' : '#f44336'}; margin-top: 0; }
|
||||
p { color: #666; line-height: 1.6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>${title}</h1>
|
||||
<h1>${isSuccess ? '✅' : '❌'} ${title}</h1>
|
||||
<p>${message}</p>
|
||||
${countdownHtml}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
<div class="header-controls" id="headerControls">
|
||||
<a href="https://pay.ldxp.cn/shop/N0IK02WR" target="_blank" rel="noopener noreferrer" class="kiro-buy-link" title="多渠道账号购买">
|
||||
<i class="fas fa-shopping-cart"></i> <span>多渠道账号购买</span>
|
||||
<a href="https://a001.hubtoday.app/" target="_blank" class="kiro-buy-link" title="AI账号购买">
|
||||
<i class="fas fa-shopping-cart"></i> <span>AI账号购买</span>
|
||||
</a>
|
||||
<span class="status-badge" id="serverStatus">
|
||||
<i class="fas fa-circle"></i> <span class="status-text" data-i18n="header.status.connecting">连接中...</span>
|
||||
|
|
|
|||
Loading…
Reference in a new issue