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 响应页面
|
* 生成 HTML 响应页面
|
||||||
* @param {boolean} isSuccess - 是否成功
|
* @param {boolean} isSuccess - 是否成功
|
||||||
* @param {string} message - 显示消息
|
* @param {string} message - 显示消息
|
||||||
|
* @param {string|null} provider - 提供商标识
|
||||||
* @returns {string} HTML 内容
|
* @returns {string} HTML 内容
|
||||||
*/
|
*/
|
||||||
function generateResponsePage(isSuccess, message) {
|
function generateResponsePage(isSuccess, message, provider = null) {
|
||||||
const title = isSuccess ? '授权成功!' : '授权失败';
|
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>
|
return `<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
@ -53,11 +85,34 @@ function generateResponsePage(isSuccess, message) {
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>${title}</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>${title}</h1>
|
<h1>${isSuccess ? '✅' : '❌'} ${title}</h1>
|
||||||
<p>${message}</p>
|
<p>${message}</p>
|
||||||
|
${countdownHtml}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|
@ -181,11 +236,19 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa
|
||||||
});
|
});
|
||||||
|
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
res.end(generateResponsePage(true, '您可以关闭此页面'));
|
res.end(generateResponsePage(true, '您可以关闭此页面', provider));
|
||||||
} catch (tokenError) {
|
} catch (tokenError) {
|
||||||
logger.error(`${config.logPrefix} 获取令牌失败:`, 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.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
res.end(generateResponsePage(false, `获取令牌失败: ${tokenError.message}`));
|
res.end(generateResponsePage(false, `获取令牌失败: ${tokenError.message}`, provider));
|
||||||
} finally {
|
} finally {
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
activeServers.delete(provider);
|
activeServers.delete(provider);
|
||||||
|
|
@ -196,8 +259,15 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa
|
||||||
const errorMessage = `授权失败。Google 返回错误: ${errorParam}`;
|
const errorMessage = `授权失败。Google 返回错误: ${errorParam}`;
|
||||||
logger.error(`${config.logPrefix}`, errorMessage);
|
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.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
res.end(generateResponsePage(false, errorMessage));
|
res.end(generateResponsePage(false, errorMessage, provider));
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
activeServers.delete(provider);
|
activeServers.delete(provider);
|
||||||
});
|
});
|
||||||
|
|
@ -210,7 +280,7 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa
|
||||||
clearPollTimer();
|
clearPollTimer();
|
||||||
logger.error(`${config.logPrefix} 处理回调时出错:`, error);
|
logger.error(`${config.logPrefix} 处理回调时出错:`, error);
|
||||||
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
|
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) {
|
if (server.listening) {
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,20 @@ async function fetchWithProxy(url, options = {}, providerType) {
|
||||||
*/
|
*/
|
||||||
function generateResponsePage(isSuccess, message) {
|
function generateResponsePage(isSuccess, message) {
|
||||||
const title = isSuccess ? '授权成功!' : '授权失败';
|
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>
|
return `<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
@ -117,11 +131,34 @@ function generateResponsePage(isSuccess, message) {
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>${title}</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>${title}</h1>
|
<h1>${isSuccess ? '✅' : '❌'} ${title}</h1>
|
||||||
<p>${message}</p>
|
<p>${message}</p>
|
||||||
|
${countdownHtml}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,20 @@ async function fetchWithProxy(url, options = {}, providerType) {
|
||||||
*/
|
*/
|
||||||
function generateResponsePage(isSuccess, message) {
|
function generateResponsePage(isSuccess, message) {
|
||||||
const title = isSuccess ? '授权成功!' : '授权失败';
|
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>
|
return `<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
@ -135,11 +149,34 @@ function generateResponsePage(isSuccess, message) {
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>${title}</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>${title}</h1>
|
<h1>${isSuccess ? '✅' : '❌'} ${title}</h1>
|
||||||
<p>${message}</p>
|
<p>${message}</p>
|
||||||
|
${countdownHtml}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@ import logger from '../../utils/logger.js';
|
||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import * as https from 'https';
|
import * as https from 'https';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
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 { getProviderModels } from '../provider-models.js';
|
||||||
import { configureAxiosProxy, configureTLSSidecar } from '../../utils/proxy-utils.js';
|
import { configureAxiosProxy, configureTLSSidecar } from '../../utils/proxy-utils.js';
|
||||||
|
import { MODEL_PROVIDER } from '../../utils/common.js';
|
||||||
import { ConverterFactory } from '../../converters/ConverterFactory.js';
|
import { ConverterFactory } from '../../converters/ConverterFactory.js';
|
||||||
import * as readline from 'readline';
|
import * as readline from 'readline';
|
||||||
import { getProviderPoolManager } from '../../services/service-manager.js';
|
import { getProviderPoolManager } from '../../services/service-manager.js';
|
||||||
|
|
@ -84,6 +85,34 @@ export class GrokApiService {
|
||||||
this.lastSyncAt = null;
|
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() {
|
async setupNsfw() {
|
||||||
if (this.nsfwSetupDone) return;
|
if (this.nsfwSetupDone) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -100,13 +129,15 @@ export class GrokApiService {
|
||||||
async acceptTos() {
|
async acceptTos() {
|
||||||
const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/app-chat/accept-tos`, headers: this.buildHeaders(), data: {}, httpAgent, httpsAgent, timeout: 15000 };
|
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);
|
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() {
|
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 };
|
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);
|
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() {
|
async enableNsfwAccount() {
|
||||||
|
|
@ -136,94 +167,14 @@ export class GrokApiService {
|
||||||
responseType: 'arraybuffer'
|
responseType: 'arraybuffer'
|
||||||
};
|
};
|
||||||
configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM);
|
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) {
|
_applySidecar(axiosConfig) {
|
||||||
return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM);
|
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() {
|
async initialize() {
|
||||||
if (this.isInitialized) return;
|
if (this.isInitialized) return;
|
||||||
this.isInitialized = true;
|
this.isInitialized = true;
|
||||||
|
|
@ -242,8 +193,10 @@ export class GrokApiService {
|
||||||
const payload = { "requestKind": "DEFAULT", "modelName": "grok-3" };
|
const payload = { "requestKind": "DEFAULT", "modelName": "grok-3" };
|
||||||
const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/rate-limits`, headers, data: payload, httpAgent, httpsAgent, timeout: 30000 };
|
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);
|
configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM);
|
||||||
|
this._applySidecar(axiosConfig);
|
||||||
try {
|
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);
|
let remaining = data.remainingTokens !== undefined ? data.remainingTokens : (data.remainingQueries !== undefined ? data.remainingQueries : data.totalQueries);
|
||||||
if (data.totalQueries > 0) {
|
if (data.totalQueries > 0) {
|
||||||
data.totalLimit = data.totalQueries;
|
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 };
|
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);
|
configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM);
|
||||||
|
this._applySidecar(axiosConfig);
|
||||||
try {
|
try {
|
||||||
const data = await this.callApi(axiosConfig);
|
const response = await axios(axiosConfig);
|
||||||
const postId = data?.post?.id;
|
const postId = response.data?.post?.id;
|
||||||
if (postId) logger.info(`[Grok Post] Media post created: ${postId} (type=${mediaType})`);
|
if (postId) logger.info(`[Grok Post] Media post created: ${postId} (type=${mediaType})`);
|
||||||
return postId;
|
return postId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -339,9 +293,10 @@ export class GrokApiService {
|
||||||
const videoId = idMatch[1];
|
const videoId = idMatch[1];
|
||||||
const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/media/video/upscale`, headers: this.buildHeaders(), data: { videoId }, httpAgent, httpsAgent, timeout: 30000 };
|
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);
|
configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM);
|
||||||
|
this._applySidecar(axiosConfig);
|
||||||
try {
|
try {
|
||||||
const data = await this.callApi(axiosConfig);
|
const response = await axios(axiosConfig);
|
||||||
return data?.hdMediaUrl || videoUrl;
|
return response.data?.hdMediaUrl || videoUrl;
|
||||||
} catch (error) { return videoUrl; }
|
} catch (error) { return videoUrl; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -365,9 +320,10 @@ export class GrokApiService {
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
};
|
};
|
||||||
configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM);
|
configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM);
|
||||||
|
this._applySidecar(axiosConfig);
|
||||||
try {
|
try {
|
||||||
const data = await this.callApi(axiosConfig);
|
const response = await axios(axiosConfig);
|
||||||
const shareLink = data?.shareLink;
|
const shareLink = response.data?.shareLink;
|
||||||
if (shareLink) {
|
if (shareLink) {
|
||||||
// 从 shareLink 中提取 ID (通常与输入的 postId 一致)
|
// 从 shareLink 中提取 ID (通常与输入的 postId 一致)
|
||||||
const idMatch = shareLink.match(/\/post\/([0-9a-fA-F-]{36}|[0-9a-fA-F]{32})/);
|
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;
|
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 };
|
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);
|
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.converter) {
|
||||||
if (this.uuid) this.converter.setUuid(this.uuid);
|
if (this.uuid) this.converter.setUuid(this.uuid);
|
||||||
if (requestBody._requestBaseUrl) this.converter.setRequestBaseUrl(requestBody._requestBaseUrl);
|
if (requestBody._requestBaseUrl) this.converter.setRequestBaseUrl(requestBody._requestBaseUrl);
|
||||||
|
|
@ -642,21 +603,69 @@ export class GrokApiService {
|
||||||
const payload = this.buildPayload(model, requestBody);
|
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 };
|
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);
|
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) {
|
for await (const line of rl) {
|
||||||
const status = error.response?.status;
|
const trimmed = line.trim();
|
||||||
if (status === 401 || status === 403) {
|
if (!trimmed) continue;
|
||||||
error.shouldSwitchCredential = true;
|
let dataStr = trimmed.startsWith('data: ') ? trimmed.slice(6).trim() : trimmed;
|
||||||
error.message = 'Grok authentication failed (SSO token invalid or expired)';
|
if (dataStr === '[DONE]') break;
|
||||||
} else if (isRetryableNetworkError(error)) {
|
try {
|
||||||
// 网络超时等可重试错误,触发凭证切换而不立即增加错误计数(避免因偶发波动导致节点下线)
|
const json = JSON.parse(dataStr);
|
||||||
error.shouldSwitchCredential = true;
|
if (json.result?.response) {
|
||||||
error.skipErrorCount = true;
|
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() {
|
async listModels() {
|
||||||
|
|
|
||||||
|
|
@ -2760,19 +2760,49 @@ function showAuthModal(authUrl, authInfo) {
|
||||||
`width=${width},height=${height},left=${left},top=${top},status=no,resizable=yes,scrollbars=yes`
|
`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 成功事件,自动关闭窗口和模态框
|
// 监听 OAuth 成功事件,自动关闭窗口和模态框
|
||||||
const handleOAuthSuccess = () => {
|
const handleOAuthSuccess = () => {
|
||||||
if (authWindow && !authWindow.closed) {
|
if (authWindow && !authWindow.closed) {
|
||||||
authWindow.close();
|
authWindow.close();
|
||||||
}
|
}
|
||||||
modal.remove();
|
modal.remove();
|
||||||
window.removeEventListener('oauth_success_event', handleOAuthSuccess);
|
cleanupAuthListeners();
|
||||||
|
|
||||||
// 授权成功后刷新配置和提供商列表
|
// 授权成功后刷新配置和提供商列表
|
||||||
loadProviders();
|
loadProviders();
|
||||||
loadConfigList();
|
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('oauth_success_event', handleOAuthSuccess);
|
||||||
|
window.addEventListener('message', handlePopupMessage);
|
||||||
|
|
||||||
if (authWindow) {
|
if (authWindow) {
|
||||||
showToast(t('common.info'), t('oauth.window.opened'), 'info');
|
showToast(t('common.info'), t('oauth.window.opened'), 'info');
|
||||||
|
|
@ -2806,7 +2836,10 @@ function showAuthModal(authUrl, authInfo) {
|
||||||
const url = new URL(cleanUrlStr);
|
const url = new URL(cleanUrlStr);
|
||||||
|
|
||||||
if (url.searchParams.has('code') || url.searchParams.has('token')) {
|
if (url.searchParams.has('code') || url.searchParams.has('token')) {
|
||||||
clearInterval(pollTimer);
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
// 构造本地可处理的 URL,只修改 hostname,保持原始 URL 的端口号不变
|
// 构造本地可处理的 URL,只修改 hostname,保持原始 URL 的端口号不变
|
||||||
const localUrl = new URL(url.href);
|
const localUrl = new URL(url.href);
|
||||||
localUrl.hostname = window.location.hostname;
|
localUrl.hostname = window.location.hostname;
|
||||||
|
|
@ -2816,10 +2849,6 @@ function showAuthModal(authUrl, authInfo) {
|
||||||
|
|
||||||
// 如果是手动输入,直接通过 fetch 请求处理,然后关闭子窗口
|
// 如果是手动输入,直接通过 fetch 请求处理,然后关闭子窗口
|
||||||
if (isManualInput) {
|
if (isManualInput) {
|
||||||
// 关闭子窗口
|
|
||||||
if (authWindow && !authWindow.closed) {
|
|
||||||
authWindow.close();
|
|
||||||
}
|
|
||||||
// 通过服务端API处理手动输入的回调URL
|
// 通过服务端API处理手动输入的回调URL
|
||||||
window.apiClient.post('/oauth/manual-callback', {
|
window.apiClient.post('/oauth/manual-callback', {
|
||||||
provider: authInfo.provider,
|
provider: authInfo.provider,
|
||||||
|
|
@ -2829,6 +2858,7 @@ function showAuthModal(authUrl, authInfo) {
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
console.log('OAuth 回调处理成功');
|
console.log('OAuth 回调处理成功');
|
||||||
|
handleOAuthSuccess();
|
||||||
showToast(t('common.success'), t('oauth.success.msg'), 'success');
|
showToast(t('common.success'), t('oauth.success.msg'), 'success');
|
||||||
} else {
|
} else {
|
||||||
console.error('OAuth 回调处理失败:', response.error);
|
console.error('OAuth 回调处理失败:', response.error);
|
||||||
|
|
@ -2874,10 +2904,10 @@ function showAuthModal(authUrl, authInfo) {
|
||||||
});
|
});
|
||||||
|
|
||||||
// 启动定时器轮询子窗口 URL
|
// 启动定时器轮询子窗口 URL
|
||||||
const pollTimer = setInterval(() => {
|
pollTimer = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
if (authWindow.closed) {
|
if (authWindow.closed) {
|
||||||
clearInterval(pollTimer);
|
cleanupAuthListeners();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 如果能读到说明回到了同域
|
// 如果能读到说明回到了同域
|
||||||
|
|
|
||||||
|
|
@ -131,20 +131,20 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.5rem 1rem;
|
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);
|
color: var(--white);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
transition: var(--transition);
|
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 {
|
.kiro-buy-link:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px var(--warning-40);
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
|
||||||
background: linear-gradient(135deg, #f97316 0%, var(--warning-color) 100%);
|
background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.kiro-buy-link:active {
|
.kiro-buy-link:active {
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@
|
||||||
<i class="fas fa-bars"></i>
|
<i class="fas fa-bars"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="header-controls" id="headerControls">
|
<div class="header-controls" id="headerControls">
|
||||||
<a href="https://pay.ldxp.cn/shop/N0IK02WR" target="_blank" rel="noopener noreferrer" class="kiro-buy-link" title="多渠道账号购买">
|
<a href="https://a001.hubtoday.app/" target="_blank" class="kiro-buy-link" title="AI账号购买">
|
||||||
<i class="fas fa-shopping-cart"></i> <span>多渠道账号购买</span>
|
<i class="fas fa-shopping-cart"></i> <span>AI账号购买</span>
|
||||||
</a>
|
</a>
|
||||||
<span class="status-badge" id="serverStatus">
|
<span class="status-badge" id="serverStatus">
|
||||||
<i class="fas fa-circle"></i> <span class="status-text" data-i18n="header.status.connecting">连接中...</span>
|
<i class="fas fa-circle"></i> <span class="status-text" data-i18n="header.status.connecting">连接中...</span>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue