diff --git a/README-JA.md b/README-JA.md index 529829a..d251d06 100644 --- a/README-JA.md +++ b/README-JA.md @@ -57,6 +57,16 @@ LingtrueAPIによる本プロジェクトへのスポンサーに感謝します!LingtrueAPIは世界的な大規模言語モデルAPI中継プラットフォームであり、Claude opus 4.6、GPT 5.4、Gemini 3.1 proなど各種モデルのAPI呼び出しサービスを提供しています。低コスト、高安定性で世界中のAI機能に接続し、生産性を最大化することを目指しています。LingtrueAPIは本ソフトウェアユーザー向けに特別優遇を提供しています。このリンクから登録し、初回チャージ時に「LingtrueAPI」のクーポンコードを入力すると、10%オフで利用できます。 + + + + Poixe AI Sponsor + + + + Poixe AI は信頼性の高い LLM API サービスを提供しています。プラットフォームが提供する API エンドポイントを活用して、AI 製品をシームレスに構築できます。また、AI API リソースをプラットフォームに提供するベンダーになり、収益を得ることも可能です。AIClient-2-API 専用リンクから登録すると、初回チャージ時に $5 USD のボーナスを受け取れます。 + + Sponsor Contact diff --git a/README-ZH.md b/README-ZH.md index 048bf2f..d4be818 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -56,6 +56,16 @@ 感谢 LingtrueAPI 对本项目的赞助!LingtrueAPI 是一家全球大模型API中转服务平台,提供Claude opus 4.6、GPT 5.4、Gemini 3.1 pro等多种模型API调用服务,致力于让用户以低成本、高稳定性链接全球AI能力,最大化生产效率。LingtrueAPI为本软件用户提供了特别优惠:通过此链接注册并在首次充值时输入 LingtrueAPI 优惠码即可享受 9折优惠。 + + + + Poixe AI Sponsor + + + + Poixe AI 提供可靠的 AI 模型接口服务,您可以使用平台提供的 LLM API 接口轻松构建 AI 产品,同时也可以成为供应商,为平台提供大模型资源以赚取收益。通过 AIClient-2-API 专属链接注册,充值额外赠送 $5 美金。 + + Sponsor Contact diff --git a/README.md b/README.md index 3aef936..8629d9c 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,16 @@ Thanks to LingtrueAPI for its sponsorship of this project! LingtrueAPI is a global large-model API intermediary service platform that offers API calling services for various models such as Claude opus 4.6, GPT 5.4, and Gemini 3.1 pro. It is committed to enabling users to connect to global AI capabilities at low cost and with high stability, maximizing production efficiency. LingtrueAPI provides special discounts for users of this software: register using this link and enter the LingtrueAPI promo code when making the first recharge to enjoy a 10% discount. + + + + Poixe AI Sponsor + + + + Poixe AI provides reliable LLM API services. You can leverage the platform's API endpoints to seamlessly build AI-powered products. Additionally, you can become a vendor by providing AI API resources to the platform and earn revenue. Register through the exclusive AIClient-2-API referral link and receive a bonus of $5 USD on your first top-up. + + Sponsor Contact diff --git a/VERSION b/VERSION index 5a5ee51..e464374 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.12.3 \ No newline at end of file +2.12.6 diff --git a/src/providers/gemini/antigravity-core.js b/src/providers/gemini/antigravity-core.js index e1da3dc..905a344 100644 --- a/src/providers/gemini/antigravity-core.js +++ b/src/providers/gemini/antigravity-core.js @@ -40,7 +40,6 @@ const ANTIGRAVITY_SYSTEM_PROMPT = `You are Antigravity, a powerful agentic AI co // Thinking 配置相关常量 const DEFAULT_THINKING_MIN = 1024; const DEFAULT_THINKING_MAX = 100000; -const FALLBACK_THINKING_SIGNATURE = "skip_thought_signature_validator_fallback"; // 获取 Antigravity 模型列表 const ANTIGRAVITY_MODELS = getProviderModels(MODEL_PROVIDER.ANTIGRAVITY); @@ -676,23 +675,6 @@ function ensureRolesInContents(requestBody, modelName) { content.role = 'user'; } - // [FIX] 修复历史记录中的思考块,确保有签名 (messages.1.content.0.thinking.signature 报错修复) - if (content.parts && Array.isArray(content.parts)) { - content.parts.forEach(part => { - if (part && part.thought === true) { - if (!part.thoughtSignature && !part.thought_signature) { - part.thoughtSignature = FALLBACK_THINKING_SIGNATURE; - } - - // [FIX] 额外增加一个 'thinking' 对象以适配某些 Antigravity 内部验证逻辑 - if (!part.thinking) { - part.thinking = { - signature: part.thoughtSignature || part.thought_signature || FALLBACK_THINKING_SIGNATURE - }; - } - } - }); - } }); } diff --git a/src/providers/gemini/gemini-core.js b/src/providers/gemini/gemini-core.js index 57abb77..c57fd49 100644 --- a/src/providers/gemini/gemini-core.js +++ b/src/providers/gemini/gemini-core.js @@ -287,8 +287,7 @@ export class GeminiApiService { maxFreeSockets: 5, timeout: 120000, }); - - this.authClient = new OAuth2Client(oauth2Options); + this.availableModels = []; this.isInitialized = false; @@ -327,6 +326,8 @@ export class GeminiApiService { logger.info('[Gemini] Using HTTP agent for OAuth2Client'); } } + + this.authClient = new OAuth2Client(oauth2Options); } async initialize() { diff --git a/src/providers/openai/qwen-core.js b/src/providers/openai/qwen-core.js index f84a679..919a42e 100644 --- a/src/providers/openai/qwen-core.js +++ b/src/providers/openai/qwen-core.js @@ -50,36 +50,131 @@ export const qwenOAuth2Events = new EventEmitter(); // --- Helper Functions --- -/** - * Qwen 默认系统提示词 - */ -const QWEN_DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant. You are Qwen, a large language model trained by Alibaba."; +// --- Rate Limiting & Quota --- +const qwenRateLimiter = { + requests: new Map(), // authID -> timestamps[] +}; +const QWEN_RATE_LIMIT_PER_MIN = 60; +const QWEN_RATE_LIMIT_WINDOW_MS = 60 * 1000; +const QWEN_QUOTA_CODES = new Set(['insufficient_quota', 'quota_exceeded']); /** - * 应用 Qwen 默认系统提示词逻辑 + * 检查 Qwen 速率限制 (60 requests/min) + * @param {string} authID + * @returns {Error|null} + */ +function checkQwenRateLimit(authID) { + if (!authID) return null; + const now = Date.now(); + const windowStart = now - QWEN_RATE_LIMIT_WINDOW_MS; + + let timestamps = qwenRateLimiter.requests.get(authID) || []; + timestamps = timestamps.filter(ts => ts > windowStart); + + if (timestamps.length >= QWEN_RATE_LIMIT_PER_MIN) { + const oldestInWindow = timestamps[0]; + const retryAfterMs = oldestInWindow + QWEN_RATE_LIMIT_WINDOW_MS - now; + const retryAfterSec = Math.max(1, Math.ceil(retryAfterMs / 1000)); + const error = new Error(`Qwen rate limit exceeded for ${authID.substring(0, 8)}, retry after ${retryAfterSec}s`); + error.status = 429; + error.data = { error: { code: "rate_limit_exceeded", message: error.message } }; + error.retryAfter = retryAfterMs; + return error; + } + + timestamps.push(now); + qwenRateLimiter.requests.set(authID, timestamps); + return null; +} + +/** + * 检查是否为配额错误 + */ +function isQwenQuotaError(body) { + if (!body || typeof body !== 'object') return false; + const error = body.error || {}; + const code = (error.code || '').toLowerCase(); + const type = (error.type || '').toLowerCase(); + const message = (error.message || '').toLowerCase(); + return QWEN_QUOTA_CODES.has(code) || QWEN_QUOTA_CODES.has(type) || + /insufficient_quota|quota exceeded|free allocated quota exceeded/i.test(message); +} + +/** + * 计算到北京时间次日凌晨的毫秒数 + */ +function timeUntilNextDayBeijing() { + const now = new Date(); + // UTC to Beijing (UTC+8) + const utcTime = now.getTime() + (now.getTimezoneOffset() * 60000); + const beijingNow = new Date(utcTime + (3600000 * 8)); + const tomorrow = new Date(beijingNow); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + return tomorrow.getTime() - beijingNow.getTime(); +} + +/** + * 确保 Qwen 系统消息在开头且唯一,合并多条系统消息并支持缓存控制 * @param {Object} requestBody - OpenAI 格式的请求体 * @returns {Object} 处理后的请求体 */ -function applyQwenDefaultSystemPrompt(requestBody) { +function ensureQwenSystemMessage(requestBody) { if (!requestBody || !requestBody.messages || !Array.isArray(requestBody.messages)) { return requestBody; } - // 检查是否已有系统提示词 (role 为 system 或 developer) - const hasSystemPrompt = requestBody.messages.some(msg => - msg.role === 'system' || msg.role === 'developer' - ); + const isInjectedSystemPart = (part) => { + if (!part || typeof part !== 'object') return false; + if (part.type !== 'text') return false; + if (part.cache_control?.type !== 'ephemeral') return false; + return part.text === "" || part.text === "You are Qwen Code."; + }; - // 如果没有系统提示词,则在消息列表最前面插入默认提示词 - if (!hasSystemPrompt) { - requestBody.messages.unshift({ - role: 'system', - content: QWEN_DEFAULT_SYSTEM_PROMPT - }); - logger.info('[Qwen Auth] 已应用默认系统提示词'); + let systemParts = []; + // 注入默认系统提示词部分 (带缓存控制) + systemParts.push({ + type: "text", + text: "You are Qwen Code.", + cache_control: { type: "ephemeral" } + }); + + const appendSystemContent = (content) => { + if (content === undefined || content === null) return; + + if (Array.isArray(content)) { + for (const part of content) { + if (typeof part === 'string') { + systemParts.push({ type: 'text', text: part }); + } else if (!isInjectedSystemPart(part)) { + systemParts.push(part); + } + } + } else if (typeof content === 'string') { + systemParts.push({ type: 'text', text: content }); + } else if (typeof content === 'object') { + if (!isInjectedSystemPart(content)) { + systemParts.push(content); + } + } + }; + + const nonSystemMessages = []; + for (const msg of requestBody.messages) { + if (msg.role === 'system' || msg.role === 'developer') { + appendSystemContent(msg.content); + } else { + nonSystemMessages.push(msg); + } } - return requestBody; + return { + ...requestBody, + messages: [ + { role: 'system', content: systemParts }, + ...nonSystemMessages + ] + }; } // 封装公共的 await fetch 方法 @@ -564,85 +659,87 @@ export class QwenApiService { } async callApiWithAuthAndRetry(endpoint, body, isStream = false, retryCount = 0) { + // 速率限制检查 + if (this.uuid) { + const limitErr = checkQwenRateLimit(this.uuid); + if (limitErr) throw limitErr; + } + const maxRetries = (this.config && this.config.REQUEST_MAX_RETRIES) || 3; const baseDelay = (this.config && this.config.REQUEST_BASE_DELAY) || 1000; - const version = "0.10.1"; + const version = "0.13.2"; const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; - logger.info(`[QwenApiService] User-Agent: ${userAgent}`); try { const { token, endpoint: qwenBaseUrl } = await this.getValidToken(); - // 配置 HTTP/HTTPS agent 限制连接池大小,避免资源泄漏 - const httpAgent = new http.Agent({ - keepAlive: true, - maxSockets: 100, - maxFreeSockets: 5, - timeout: 120000, - }); - const httpsAgent = new https.Agent({ - keepAlive: true, - maxSockets: 100, - maxFreeSockets: 5, - timeout: 120000, - }); + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + 'User-Agent': userAgent, + 'X-DashScope-UserAgent': userAgent, + 'X-Stainless-Runtime-Version': 'v22.17.0', + 'X-Stainless-Lang': 'js', + 'X-Stainless-Arch': process.arch === 'x64' ? 'x86_64' : process.arch, + 'X-Stainless-Package-Version': '5.11.0', + 'X-DashScope-CacheControl': 'enable', + 'X-DashScope-AuthType': 'qwen-oauth', + 'X-Stainless-Runtime': 'node', + 'Accept': isStream ? 'text/event-stream' : 'application/json', + }; const axiosConfig = { baseURL: qwenBaseUrl, - httpAgent, - httpsAgent, - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - 'X-DashScope-CacheControl': 'enable', - 'X-DashScope-UserAgent': userAgent, - 'X-DashScope-AuthType': 'qwen-oauth', - }, + headers, + // axios 默认不传 proxy 配置时会遵循环境变量,这里明确控制 + proxy: this.useSystemProxy ? undefined : false, }; - // 禁用系统代理 - if (!this.useSystemProxy) { - axiosConfig.proxy = false; - } - - // 配置自定义代理 + // 配置自定义代理 (如果 config.json 中有定义) configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API); - this.currentAxiosInstance = axios.create(axiosConfig); + const instance = axios.create(axiosConfig); - // Process message content before sending the request - const processedBody = applyQwenDefaultSystemPrompt(body); + // 处理消息和模型 + let processedBody = ensureQwenSystemMessage(body); - // Check if model in body is in QWEN_MODEL_LIST, if not, use the first model's id - if (processedBody.model && !QWEN_MODEL_LIST.some(model => model.id === processedBody.model)) { - logger.warn(`[QwenApiService] Model '${processedBody.model}' not found. Using default model: '${QWEN_MODEL_LIST[0].id}'`); - processedBody.model = QWEN_MODEL_LIST[0].id; + // 检查模型是否存在于列表中 + if (processedBody.model && !QWEN_MODELS.includes(processedBody.model)) { + logger.warn(`[QwenApiService] Model '${processedBody.model}' not found in supported list. Using default: '${QWEN_MODELS[0]}'`); + processedBody.model = QWEN_MODELS[0]; } - const defaultTools = [ - { - "type": "function", - "function": { - "name": "ext" - } - } - ]; + // Qwen3 兼容性补丁:针对 Qwen3 "Poisoning" 问题优化工具注入 + // 如果请求本身没有 tools,注入一个虚拟工具防止模型在流式响应中随机吐出 Token + const dummyTool = { + type: "function", + function: { + name: "ext", + description: "Internal extension tool" + } + }; - // Merge tools if requestBody already has tools defined - const mergedTools = processedBody.tools ? [...defaultTools, ...processedBody.tools] : defaultTools; + if (processedBody.tools) { + processedBody.tools = [dummyTool, ...processedBody.tools]; + } else { + processedBody.tools = [dummyTool]; + } - const requestBody = isStream ? { ...processedBody, stream: true, tools: mergedTools } : { ...processedBody, tools: mergedTools }; - - const axiosRequestConfig = { + if (isStream) { + processedBody.stream = true; + processedBody.stream_options = { include_usage: true }; + } + + const requestConfig = { method: 'post', url: endpoint, - data: requestBody, + data: processedBody, ...(isStream ? { responseType: 'stream' } : {}) }; - this._applySidecar(axiosRequestConfig); + this._applySidecar(requestConfig); - const response = await this.currentAxiosInstance.request(axiosRequestConfig); + const response = await instance.request(requestConfig); return response.data; } catch (error) { @@ -651,40 +748,38 @@ export class QwenApiService { const errorCode = error.code; const errorMessage = error.message || ''; - // 检查是否为可重试的网络错误 - const isNetworkError = isRetryableNetworkError(error); - - if (this.isAuthError(error) && retryCount === 0) { - logger.warn(`[QwenApiService] Auth error (${status}). Triggering background refresh via PoolManager...`); + // 检查配额错误 -> 映射为 429 并设置冷却时间 + if ((status === 403 || status === 429) && isQwenQuotaError(error.response?.data)) { + const cooldown = timeUntilNextDayBeijing(); + logger.warn(`[QwenApiService] Daily quota exceeded (http ${status} -> 429), cooling down until tomorrow (~${Math.round(cooldown / 3600000)} hours)`); + error.status = 429; + error.retryAfter = cooldown; - // 标记当前凭证为不健康(会自动进入刷新队列) + // 标记 unhealthy const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { - logger.info(`[Qwen] Marking credential ${this.uuid} as needs refresh. Reason: Auth Error ${status}`); - poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API, { - uuid: this.uuid - }); + poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API, { uuid: this.uuid }); + } + throw error; + } + + if (this.isAuthError(error) && retryCount === 0) { + logger.warn(`[QwenApiService] Auth error (${status}). Triggering background refresh...`); + + const poolManager = getProviderPoolManager(); + if (poolManager && this.uuid) { + poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API, { uuid: this.uuid }); error.credentialMarkedUnhealthy = true; } - // Mark error for credential switch without recording error count error.shouldSwitchCredential = true; error.skipErrorCount = true; throw error; } - if ((status === 429 || (status >= 500 && status < 600)) && retryCount < maxRetries) { + if ((status === 429 || (status >= 500 && status < 600) || isRetryableNetworkError(error)) && retryCount < maxRetries) { const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[QwenApiService] Status ${status}. Retrying in ${delay}ms...`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApiWithAuthAndRetry(endpoint, body, isStream, retryCount + 1); - } - - // Handle network errors (ECONNRESET, ETIMEDOUT, etc.) with exponential backoff - if (isNetworkError && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - const errorIdentifier = errorCode || errorMessage.substring(0, 50); - logger.info(`[QwenApiService] Network error (${errorIdentifier}). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + logger.info(`[QwenApiService] Request failed (${status || errorCode}). Retrying in ${delay}ms... (${retryCount + 1}/${maxRetries})`); await new Promise(resolve => setTimeout(resolve, delay)); return this.callApiWithAuthAndRetry(endpoint, body, isStream, retryCount + 1); } @@ -733,7 +828,7 @@ export class QwenApiService { const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { logger.info(`[Qwen] Token is near expiry, marking credential ${this.uuid} for refresh`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.QWEN_API, { + poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API, { uuid: this.uuid }); } diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index 334c9d3..ab87f0a 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -72,6 +72,7 @@ export class ProviderPoolManager { this.refreshBufferQueues = {}; // 按 providerType 分组的缓冲队列 this.refreshBufferTimers = {}; // 按 providerType 分组的定时器 this.bufferDelay = options.globalConfig?.REFRESH_BUFFER_DELAY ?? 5000; // 默认5秒缓冲延迟 + this.refreshTaskTimeoutMs = options.globalConfig?.REFRESH_TASK_TIMEOUT_MS ?? 60000; // 默认60秒刷新超时 // 用于并发选点时的原子排序辅助(自增序列) this._selectionSequence = 0; @@ -184,6 +185,12 @@ export class ProviderPoolManager { _enqueueRefresh(providerType, providerStatus, force = false) { const uuid = providerStatus.uuid; + // 如果节点被禁用,不进行刷新 + if (providerStatus.config.isDisabled) { + this._log('debug', `Skipping refresh for disabled node ${uuid}`); + return; + } + // 如果已经在刷新中,直接返回 if (this.refreshingUuids.has(uuid)) { this._log('debug', `Node ${uuid} is already in refresh queue.`); @@ -405,16 +412,18 @@ export class ProviderPoolManager { // 调用适配器的 refreshToken 方法(内部封装了具体的刷新逻辑) if (typeof serviceAdapter.refreshToken === 'function') { const startTime = Date.now(); + let refreshOperation; if (force) { if (typeof serviceAdapter.forceRefreshToken === 'function') { - await serviceAdapter.forceRefreshToken(); + refreshOperation = serviceAdapter.forceRefreshToken(); } else { this._log('warn', `forceRefreshToken not implemented for ${providerType}, falling back to refreshToken`); - await serviceAdapter.refreshToken(); + refreshOperation = serviceAdapter.refreshToken(); } } else { - await serviceAdapter.refreshToken(); + refreshOperation = serviceAdapter.refreshToken(); } + await this._awaitRefreshWithTimeout(refreshOperation, providerType, providerStatus.uuid); const duration = Date.now() - startTime; this._log('info', `Token refresh successful for node ${providerStatus.uuid} (Duration: ${duration}ms)`); @@ -422,6 +431,8 @@ export class ProviderPoolManager { config.needsRefresh = false; config.refreshCount = 0; config.lastRefreshTime = Date.now(); // 记录最后刷新成功时间 + + this._debouncedSave(providerType); } else { throw new Error(`refreshToken method not implemented for ${providerType}`); } @@ -433,6 +444,31 @@ export class ProviderPoolManager { } } + /** + * 为刷新任务附加超时保护,避免单个适配器调用无限挂起。 + * @private + */ + async _awaitRefreshWithTimeout(refreshOperation, providerType, uuid) { + if (this.refreshTaskTimeoutMs <= 0) { + return await refreshOperation; + } + + let timeoutId = null; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`Refresh timeout after ${this.refreshTaskTimeoutMs}ms for node ${uuid} (${providerType})`)); + }, this.refreshTaskTimeoutMs); + }); + + try { + return await Promise.race([Promise.resolve(refreshOperation), timeoutPromise]); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + } + /** * 计算节点的权重/评分,用于排序 * 分数越低,优先级越高 @@ -626,51 +662,69 @@ export class ProviderPoolManager { * Initially, all providers are considered healthy and have zero usage. */ initializeProviderStatus() { + const oldFullStatus = this.providerStatus || {}; + const isColdStart = Object.keys(oldFullStatus).length === 0; + this.providerStatus = {}; // Tracks health and usage for each provider instance for (const providerType in this.providerPools) { - const oldStatus = this.providerStatus[providerType] || []; + const oldStatus = oldFullStatus[providerType] || []; this.providerStatus[providerType] = []; this.roundRobinIndex[providerType] = 0; // Initialize round-robin index for each type // 只有在锁不存在时才初始化,避免在运行中被重置导致并发问题 if (!this._selectionLocks[providerType]) { this._selectionLocks[providerType] = Promise.resolve(); } - this.providerPools[providerType].forEach((providerConfig) => { - // 尝试从旧状态中恢复活跃请求计数和队列,避免重载配置时重置并发限制 - const existing = oldStatus.find(p => p.uuid === providerConfig.uuid); + + const pool = this.providerPools[providerType]; + + pool.forEach((providerConfig) => { + try { + // 尝试从旧状态中恢复活跃请求计数和队列,避免重载配置时重置并发限制 + const existing = oldStatus.find(p => p.uuid === providerConfig.uuid); - // Ensure initial health and usage stats are present in the config - providerConfig.isHealthy = providerConfig.isHealthy !== undefined ? providerConfig.isHealthy : true; - providerConfig.isDisabled = providerConfig.isDisabled !== undefined ? providerConfig.isDisabled : false; - providerConfig.lastUsed = providerConfig.lastUsed !== undefined ? providerConfig.lastUsed : null; - providerConfig.usageCount = providerConfig.usageCount !== undefined ? providerConfig.usageCount : 0; - providerConfig.errorCount = providerConfig.errorCount !== undefined ? providerConfig.errorCount : 0; - - // --- V2: 刷新监控字段 --- - providerConfig.needsRefresh = providerConfig.needsRefresh !== undefined ? providerConfig.needsRefresh : false; - providerConfig.refreshCount = providerConfig.refreshCount !== undefined ? providerConfig.refreshCount : 0; - - // 优化2: 简化 lastErrorTime 处理逻辑 - providerConfig.lastErrorTime = providerConfig.lastErrorTime instanceof Date - ? providerConfig.lastErrorTime.toISOString() - : (providerConfig.lastErrorTime || null); - - // 健康检测相关字段 - providerConfig.lastHealthCheckTime = providerConfig.lastHealthCheckTime || null; - providerConfig.lastHealthCheckModel = providerConfig.lastHealthCheckModel || null; - providerConfig.lastErrorMessage = providerConfig.lastErrorMessage || null; - providerConfig.customName = providerConfig.customName || null; - - this.providerStatus[providerType].push({ - config: providerConfig, - uuid: providerConfig.uuid, // Still keep uuid at the top level for easy access - type: providerType, // 保存 providerType 引用 - state: existing ? existing.state : { - activeCount: 0, - waitingCount: 0, - queue: [] + // Ensure initial health and usage stats are present in the config + providerConfig.isHealthy = providerConfig.isHealthy !== undefined ? providerConfig.isHealthy : true; + providerConfig.isDisabled = providerConfig.isDisabled !== undefined ? providerConfig.isDisabled : false; + providerConfig.lastUsed = providerConfig.lastUsed !== undefined ? providerConfig.lastUsed : null; + providerConfig.usageCount = providerConfig.usageCount !== undefined ? providerConfig.usageCount : 0; + providerConfig.errorCount = providerConfig.errorCount !== undefined ? providerConfig.errorCount : 0; + + // --- V2: 刷新监控字段 --- + const persistedNeedsRefresh = providerConfig.needsRefresh !== undefined ? providerConfig.needsRefresh : false; + const persistedRefreshCount = providerConfig.refreshCount !== undefined ? providerConfig.refreshCount : 0; + if (isColdStart && (persistedNeedsRefresh || persistedRefreshCount > 0)) { + this._log('info', `Resetting stale refresh state for provider ${providerConfig.uuid} (${providerType}) on startup.`); } - }); + providerConfig.needsRefresh = isColdStart ? false : persistedNeedsRefresh; + providerConfig.refreshCount = isColdStart ? 0 : persistedRefreshCount; + + // 优化2: 简化 lastErrorTime 处理逻辑 + providerConfig.lastErrorTime = providerConfig.lastErrorTime instanceof Date + ? providerConfig.lastErrorTime.toISOString() + : (providerConfig.lastErrorTime || null); + + // 健康检测相关字段 + providerConfig.lastHealthCheckTime = providerConfig.lastHealthCheckTime || null; + providerConfig.lastHealthCheckModel = providerConfig.lastHealthCheckModel || null; + providerConfig.lastErrorMessage = providerConfig.lastErrorMessage || null; + providerConfig.customName = providerConfig.customName || null; + + this.providerStatus[providerType].push({ + config: providerConfig, + uuid: providerConfig.uuid, // Still keep uuid at the top level for easy access + type: providerType, // 保存 providerType 引用 + state: existing ? existing.state : { + activeCount: 0, + waitingCount: 0, + queue: [] + } + }); + } catch (nodeError) { + logger.error(`[ProviderPoolManager] Error initializing node for ${providerType}: ${nodeError.message}`); + } }); + + // 确保初始化时的默认值补全也能写盘 + this._debouncedSave(providerType); } this._log('info', `Initialized provider statuses: ok (maxErrorCount: ${this.maxErrorCount})`); } diff --git a/src/services/service-manager.js b/src/services/service-manager.js index 47f1673..e574b54 100644 --- a/src/services/service-manager.js +++ b/src/services/service-manager.js @@ -269,14 +269,21 @@ async function scanProviderDirectory(dirPath, linkedPaths, newProviders, options */ export async function initApiService(config, isReady = false) { - if (config.providerPools && Object.keys(config.providerPools).length > 0) { - providerPoolManager = new ProviderPoolManager(config.providerPools, { + // Initialize or update ProviderPoolManager + if (providerPoolManager) { + providerPoolManager.providerPools = config.providerPools || {}; + providerPoolManager.initializeProviderStatus(); + logger.info('[Initialization] ProviderPoolManager existing instance updated.'); + } else { + providerPoolManager = new ProviderPoolManager(config.providerPools || {}, { globalConfig: config, - maxErrorCount: config.MAX_ERROR_COUNT ?? 3, + maxErrorCount: config.MAX_ERROR_COUNT ?? 10, providerFallbackChain: config.providerFallbackChain || {}, }); - logger.info('[Initialization] ProviderPoolManager initialized with configured pools.'); + logger.info('[Initialization] ProviderPoolManager initialized.'); + } + if (config.providerPools && Object.keys(config.providerPools).length > 0) { if(isReady){ // --- V2: 触发系统预热 --- // 预热逻辑是异步的,不会阻塞服务器启动 @@ -289,10 +296,8 @@ export async function initApiService(config, isReady = false) { logger.error(`[Initialization] Check and refresh expiring nodes failed: ${err.message}`); }); } - - // 健康检查将在服务器完全启动后执行 } else { - logger.info('[Initialization] No provider pools configured. Using single provider mode.'); + logger.info('[Initialization] Provider pools are currently empty.'); } // Initialize all provider pool nodes at startup @@ -302,9 +307,17 @@ export async function initApiService(config, isReady = false) { let totalFailed = 0; for (const [providerType, providerConfigs] of Object.entries(config.providerPools)) { - // 验证提供商类型是否在 DEFAULT_MODEL_PROVIDERS 中 - if (config.DEFAULT_MODEL_PROVIDERS && Array.isArray(config.DEFAULT_MODEL_PROVIDERS)) { - if (!config.DEFAULT_MODEL_PROVIDERS.includes(providerType)) { + // 验证提供商类型是否有效且被包含在 DEFAULT_MODEL_PROVIDERS 中 + // 如果没设置 DEFAULT_MODEL_PROVIDERS,则允许所有已注册的类型 + const isDefaultProvider = !config.DEFAULT_MODEL_PROVIDERS || + (Array.isArray(config.DEFAULT_MODEL_PROVIDERS) && config.DEFAULT_MODEL_PROVIDERS.includes(providerType)); + + if (!isDefaultProvider) { + // 进一步检查是否是注册提供商的变体(带后缀) + const isVariantOfDefault = Array.isArray(config.DEFAULT_MODEL_PROVIDERS) && + config.DEFAULT_MODEL_PROVIDERS.some(p => providerType.startsWith(p + '-')); + + if (!isVariantOfDefault) { logger.info(`[Initialization] Skipping provider type '${providerType}' (not in DEFAULT_MODEL_PROVIDERS).`); continue; } @@ -550,7 +563,8 @@ export async function getProviderStatus(config, options = {}) { 'customName', 'isHealthy', 'lastErrorTime', - 'lastErrorMessage' + 'lastErrorMessage', + 'needsRefresh' ]; // identify 字段映射表 const identifyFieldMap = { diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js index 2ae46d4..be4c9c6 100644 --- a/src/ui-modules/config-api.js +++ b/src/ui-modules/config-api.js @@ -114,7 +114,12 @@ export async function handleUpdateConfig(req, res, currentConfig) { // Update config values in memory(含类型校验) if (newConfig.REQUIRED_API_KEY !== undefined) { - if (typeof newConfig.REQUIRED_API_KEY === 'string' && newConfig.REQUIRED_API_KEY !== '******') currentConfig.REQUIRED_API_KEY = newConfig.REQUIRED_API_KEY; + if (typeof newConfig.REQUIRED_API_KEY === 'string') { + // 如果是脱敏后的字符串,则忽略更新,保留原值 + if (newConfig.REQUIRED_API_KEY !== '******') { + currentConfig.REQUIRED_API_KEY = newConfig.REQUIRED_API_KEY; + } + } } if (newConfig.HOST !== undefined) { if (typeof newConfig.HOST === 'string' && newConfig.HOST.length > 0) currentConfig.HOST = newConfig.HOST; @@ -401,13 +406,23 @@ export async function handleUpdateAdminPassword(req, res) { if (!password || password.trim() === '') { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Password cannot be empty' } })); + res.end(JSON.stringify({ + error: { + message: 'Password cannot be empty', + messageCode: 'common.passwordEmpty' + } + })); return true; } if (password.trim().length < PASSWORD.MIN_LENGTH) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: `Password must be at least ${PASSWORD.MIN_LENGTH} characters` } })); + res.end(JSON.stringify({ + error: { + message: `Password must be at least ${PASSWORD.MIN_LENGTH} characters`, + messageCode: 'common.passwordTooShort' + } + })); return true; } diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index 600f4d0..d6692ad 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -16,23 +16,27 @@ function sanitizeProviderData(provider, maskSensitive = false) { // 1. 过滤敏感字段(API Keys, Tokens 等) if (maskSensitive) { - const sensitiveKeys = [ - 'OPENAI_API_KEY', 'CLAUDE_API_KEY', 'FORWARD_API_KEY', - 'GROK_COOKIE_TOKEN', 'GROK_CF_CLEARANCE', - 'refreshToken', 'accessToken', 'clientSecret' - ]; - - sensitiveKeys.forEach(key => { - if (sanitized[key]) { + for (const key in sanitized) { + // 排除已知非敏感字段 + if (key === 'uuid' || key === 'customName' || key === 'isHealthy' || key === 'isDisabled' || key === 'needsRefresh') continue; + + const val = sanitized[key]; + if (typeof val !== 'string' || !val) continue; + + // 识别敏感字段:包含 KEY, TOKEN, SECRET, PASSWORD, CLEARANCE 等关键词 + // 同时排除包含 PATH, URL, DIR, ENDPOINT 等关键词的路径/地址字段 + const isSensitive = /API_KEY|TOKEN|SECRET|PASSWORD|CLEARANCE|ACCESS_KEY|credentials/i.test(key); + const isPath = /PATH|URL|DIR|ENDPOINT|REGION/i.test(key); + + if (isSensitive && !isPath) { // 对密钥进行脱敏显示(只保留前 4 位和后 4 位) - const val = sanitized[key]; - if (typeof val === 'string' && val.length > 10) { + if (val.length > 10) { sanitized[key] = val.substring(0, 4) + '****' + val.substring(val.length - 4); } else { sanitized[key] = '********'; } } - }); + } } // 2. 净化 customName 中的 HTML/脚本 @@ -60,6 +64,29 @@ function sanitizeProviderPools(pools, maskSensitive = false) { } return sanitized; } + +/** + * 过滤掉数据中的脱敏占位符,避免在保存时覆盖真实数据 + */ +function filterMaskedData(data) { + if (!data || typeof data !== 'object') return data; + const result = { ...data }; + + for (const key in result) { + const val = result[key]; + if (typeof val === 'string') { + // 匹配 ******** 或 XXXX****XXXX 格式 + // 如果值包含 **** 且长度符合脱敏特征,则认为它是脱敏后的回传值,应该忽略 + // 不再仅限于特定的 sensitiveKeys,而是检查所有字符串字段 + if (val === '********' || (val.includes('****') && val.length >= 10)) { + delete result[key]; + } + } + } + + return result; +} + // 使用 Promise 链式队列,确保文件操作顺序执行 let _fileLockChain = Promise.resolve(); @@ -88,24 +115,20 @@ function withFileLock(fn) { * 获取所有提供商的状态(包括支持的类型和号池组) */ export async function handleGetProviders(req, res, currentConfig, providerPoolManager) { - if (!providerPoolManager) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Provider pool manager not initialized' } })); - return true; - } - // 1. 获取支持的基础提供商类型 const registeredProviders = getRegisteredProviders(); let poolTypes = []; // 2. 从管理器获取当前所有池的状态 const providerStatus = {}; - for (const [type, providers] of Object.entries(providerPoolManager.providerStatus)) { - providerStatus[type] = providers.map(p => ({ - ...p.config, - activeRequests: p.state?.activeCount || 0, - waitingRequests: p.state?.waitingCount || 0 - })); + if (providerPoolManager) { + for (const [type, providers] of Object.entries(providerPoolManager.providerStatus)) { + providerStatus[type] = providers.map(p => ({ + ...p.config, + activeRequests: p.state?.activeCount || 0, + waitingRequests: p.state?.waitingCount || 0 + })); + } } // 3. 补全号池配置文件中的所有组 @@ -115,8 +138,18 @@ export async function handleGetProviders(req, res, currentConfig, providerPoolMa const poolsData = JSON.parse(readFileSync(filePath, 'utf-8')); poolTypes = Object.keys(poolsData); poolTypes.forEach(type => { - if (!providerStatus[type]) { - providerStatus[type] = []; + // 如果管理器中没有该组,或者该组是空的,则从文件中补全 + if (!providerStatus[type] || providerStatus[type].length === 0) { + const fileProviders = poolsData[type] || []; + if (fileProviders.length > 0) { + providerStatus[type] = fileProviders.map(p => ({ + ...p, + activeRequests: 0, + waitingRequests: 0 + })); + } else if (!providerStatus[type]) { + providerStatus[type] = []; + } } }); } @@ -156,7 +189,7 @@ export async function handleGetProviderType(req, res, currentConfig, providerPoo res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ providerType, - providers: providers.map(p => sanitizeProviderData(p, false)), // 详情页(用于编辑)不打码 + providers: providers.map(p => sanitizeProviderData(p, true)), // 详情页也进行打码,确保即便点击显示也是脱敏数据 totalCount: providers.length, healthyCount: providers.filter(p => p.isHealthy).length })); @@ -288,7 +321,10 @@ async function _handleAddProvider(req, res, currentConfig, providerPoolManager) if (!providerPools[providerType]) { providerPools[providerType] = []; } - providerPools[providerType].push(providerConfig); + + // 过滤掉脱敏字段 + const filteredConfig = filterMaskedData(providerConfig); + providerPools[providerType].push(filteredConfig); // Save to file writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); @@ -321,7 +357,7 @@ async function _handleAddProvider(req, res, currentConfig, providerPoolManager) res.end(JSON.stringify({ success: true, message: 'Provider added successfully', - provider: sanitizeProviderData(providerConfig), + provider: sanitizeProviderData(providerConfig, true), providerType })); return true; @@ -380,9 +416,13 @@ async function _handleUpdateProvider(req, res, currentConfig, providerPoolManage // Update provider while preserving certain fields const existingProvider = providers[providerIndex]; + + // 过滤掉传入配置中的脱敏占位符,避免覆盖真实数据 + const filteredConfig = filterMaskedData(providerConfig); + const updatedProvider = { ...existingProvider, - ...providerConfig, + ...filteredConfig, uuid: providerUuid, // Ensure UUID doesn't change lastUsed: existingProvider.lastUsed, // Preserve usage stats usageCount: existingProvider.usageCount, @@ -415,7 +455,7 @@ async function _handleUpdateProvider(req, res, currentConfig, providerPoolManage res.end(JSON.stringify({ success: true, message: 'Provider updated successfully', - provider: sanitizeProviderData(updatedProvider) + provider: sanitizeProviderData(updatedProvider, true) })); return true; } catch (error) { diff --git a/src/ui-modules/update-api.js b/src/ui-modules/update-api.js index ac8976d..b112dc1 100644 --- a/src/ui-modules/update-api.js +++ b/src/ui-modules/update-api.js @@ -6,6 +6,7 @@ import { exec } from 'child_process'; import { promisify } from 'util'; import { CONFIG } from '../core/config-manager.js'; import { parseProxyUrl } from '../utils/proxy-utils.js'; +import { getRequestBody } from '../utils/common.js'; const execAsync = promisify(exec); const GITHUB_REPO = 'justlovemaki/AIClient-2-API'; @@ -149,16 +150,16 @@ function compareVersions(v1, v2) { } /** - * 通过 GitHub API 获取最新版本 - * @returns {Promise} 最新版本号或 null + * 通过 GitHub API 获取最近的版本列表 + * @param {number} limit - 限制返回的版本数量 + * @returns {Promise} 版本列表 */ -async function getLatestVersionFromGitHub() { +async function getVersionsFromGitHub(limit = 10) { const candidates = buildGitHubApiCandidates(GITHUB_REPO); for (const candidate of candidates) { try { - logger.info(`[Update] Fetching latest version from GitHub API via ${candidate.name}...`); - logger.info(`[Update] Request URL: ${candidate.url}`); + logger.info(`[Update] Fetching versions from GitHub API via ${candidate.name}...`); const response = await fetchWithProxy(candidate.url, { headers: { 'Accept': 'application/vnd.github.v3+json', @@ -189,15 +190,23 @@ async function getLatestVersionFromGitHub() { } versions.sort((a, b) => compareVersions(b, a)); - logger.info(`[Update] Latest version fetched successfully via ${candidate.name}: ${versions[0]}`); - return versions[0]; + return versions.slice(0, limit); } catch (error) { - logger.warn(`[Update] Failed to fetch latest version via ${candidate.name}: ${error.message}`); + logger.warn(`[Update] Failed to fetch versions via ${candidate.name}: ${error.message}`); } } logger.warn('[Update] All GitHub API proxy attempts failed'); - return null; + return []; +} + +/** + * 通过 GitHub API 获取最新版本 + * @returns {Promise} 最新版本号或 null + */ +async function getLatestVersionFromGitHub() { + const versions = await getVersionsFromGitHub(1); + return versions.length > 0 ? versions[0] : null; } /** @@ -231,10 +240,11 @@ export async function checkForUpdates() { } let latestTag = null; + let availableVersions = []; let updateMethod = 'unknown'; if (isGitRepo) { - // Git 仓库模式:使用 git 命令 + // Git 仓库模式:使用 git命令 updateMethod = 'git'; // 获取远程 tags @@ -244,45 +254,33 @@ export async function checkForUpdates() { } catch (error) { logger.warn('[Update] Failed to fetch tags via git, falling back to GitHub API:', error.message); // 如果 git fetch 失败,回退到 GitHub API - latestTag = await getLatestVersionFromGitHub(); + availableVersions = await getVersionsFromGitHub(10); + latestTag = availableVersions.length > 0 ? availableVersions[0] : null; updateMethod = 'github_api'; } - // 如果 git fetch 成功,获取最新的 tag + // 如果 git fetch 成功,获取最新的 tag 和可用的 tags if (!latestTag && updateMethod === 'git') { - const isWindows = process.platform === 'win32'; - try { - if (isWindows) { - // Windows: 使用 git for-each-ref,这是跨平台兼容的方式 - const { stdout } = await execAsync('git for-each-ref --sort=-v:refname --format="%(refname:short)" refs/tags --count=1'); - latestTag = stdout.trim(); - } else { - // Linux/macOS: 使用 head 命令,更高效 - const { stdout } = await execAsync('git tag --sort=-v:refname | head -n 1'); - latestTag = stdout.trim(); + // 获取最近的 10 个 tag + const { stdout } = await execAsync('git tag --sort=-v:refname'); + const tags = stdout.trim().split('\n').filter(t => t); + if (tags.length > 0) { + availableVersions = tags.slice(0, 10); + latestTag = availableVersions[0]; } } catch (error) { - // 备用方案:获取所有 tags 并在 JavaScript 中排序 - try { - const { stdout } = await execAsync('git tag'); - const tags = stdout.trim().split('\n').filter(t => t); - if (tags.length > 0) { - // 按版本号排序(降序) - tags.sort((a, b) => compareVersions(b, a)); - latestTag = tags[0]; - } - } catch (e) { - logger.warn('[Update] Failed to get latest tag via git, falling back to GitHub API:', e.message); - latestTag = await getLatestVersionFromGitHub(); - updateMethod = 'github_api'; - } + logger.warn('[Update] Failed to get tags via git, falling back to GitHub API:', error.message); + availableVersions = await getVersionsFromGitHub(10); + latestTag = availableVersions.length > 0 ? availableVersions[0] : null; + updateMethod = 'github_api'; } } } else { // 非 Git 仓库模式(如 Docker 容器):使用 GitHub API updateMethod = 'github_api'; - latestTag = await getLatestVersionFromGitHub(); + availableVersions = await getVersionsFromGitHub(10); + latestTag = availableVersions.length > 0 ? availableVersions[0] : null; } if (!latestTag) { @@ -290,6 +288,7 @@ export async function checkForUpdates() { hasUpdate: false, localVersion, latestVersion: null, + availableVersions: [], updateMethod, error: 'Unable to get latest version information' }; @@ -305,6 +304,7 @@ export async function checkForUpdates() { hasUpdate, localVersion, latestVersion: latestTag, + availableVersions, updateMethod, error: null }; @@ -312,9 +312,10 @@ export async function checkForUpdates() { /** * 执行更新操作 + * @param {string} targetTag - 目标版本 tag,如果未提供则更新到最新版本 * @returns {Promise} 更新结果 */ -export async function performUpdate() { +export async function performUpdate(targetTag = null) { // 首先检查是否有更新 const updateInfo = await checkForUpdates(); @@ -322,7 +323,12 @@ export async function performUpdate() { throw new Error(updateInfo.error); } - if (!updateInfo.hasUpdate) { + // 如果未提供 targetTag,使用最新版本 + const latestTag = updateInfo.latestVersion; + const finalTag = targetTag || latestTag; + + // 如果是更新到最新版本,且当前已是最新版本 + if (!targetTag && !updateInfo.hasUpdate) { return { success: true, message: 'Already at the latest version', @@ -332,16 +338,25 @@ export async function performUpdate() { }; } - const latestTag = updateInfo.latestVersion; + // 如果指定了 tag,但与本地版本相同 + if (targetTag && (targetTag === updateInfo.localVersion || targetTag === `v${updateInfo.localVersion}`)) { + return { + success: true, + message: `Already at version ${targetTag}`, + localVersion: updateInfo.localVersion, + latestVersion: updateInfo.latestVersion, + updated: false + }; + } // 检查更新方式 - 如果是通过 GitHub API 获取的版本信息,说明不在 Git 仓库中 if (updateInfo.updateMethod === 'github_api') { // Docker/非 Git 环境,通过下载 tarball 更新 - logger.info('[Update] Running in Docker/non-Git environment, will download and extract tarball'); - return await performTarballUpdate(updateInfo.localVersion, latestTag); + logger.info(`[Update] Running in Docker/non-Git environment, will download and extract tarball for ${finalTag}`); + return await performTarballUpdate(updateInfo.localVersion, finalTag); } - logger.info(`[Update] Starting update to ${latestTag}...`); + logger.info(`[Update] Starting update to ${finalTag}...`); // 检查是否有未提交的更改 try { @@ -355,19 +370,19 @@ export async function performUpdate() { logger.warn('[Update] Failed to check git status:', error.message); } - // 执行 checkout 到最新 tag + // 执行 checkout 到目标 tag try { - logger.info(`[Update] Checking out to ${latestTag}...`); - await execAsync(`git checkout ${latestTag}`); + logger.info(`[Update] Checking out to ${finalTag}...`); + await execAsync(`git checkout ${finalTag}`); } catch (error) { logger.error('[Update] Failed to checkout:', error.message); - throw new Error('Failed to switch to new version: ' + error.message); + throw new Error(`Failed to switch to version ${finalTag}: ` + error.message); } // 更新 VERSION 文件(如果 tag 和 VERSION 文件不同步) const versionFilePath = path.join(process.cwd(), 'VERSION'); try { - const newVersion = latestTag.replace(/^v/, ''); + const newVersion = finalTag.replace(/^v/, ''); writeFileSync(versionFilePath, newVersion, 'utf-8'); logger.info(`[Update] VERSION file updated to ${newVersion}`); } catch (error) { @@ -379,7 +394,7 @@ export async function performUpdate() { try { // 确保本地版本号有 v 前缀,以匹配 git tag 格式 const localVersionTag = updateInfo.localVersion.startsWith('v') ? updateInfo.localVersion : `v${updateInfo.localVersion}`; - const { stdout: diffOutput } = await execAsync(`git diff ${localVersionTag}..${latestTag} --name-only`); + const { stdout: diffOutput } = await execAsync(`git diff ${localVersionTag}..${finalTag} --name-only`); if (diffOutput.includes('package.json') || diffOutput.includes('package-lock.json')) { logger.info('[Update] package.json changed, running npm install...'); await execAsync('npm install'); @@ -389,13 +404,14 @@ export async function performUpdate() { logger.warn('[Update] Failed to check package changes:', error.message); } - logger.info(`[Update] Update completed successfully to ${latestTag}`); + logger.info(`[Update] Update completed successfully to ${finalTag}`); return { success: true, - message: `Successfully updated to version ${latestTag}`, + message: `Successfully updated to version ${finalTag}`, localVersion: updateInfo.localVersion, latestVersion: latestTag, + targetVersion: finalTag, updated: true, updateMethod: 'git', needsRestart: needsRestart, @@ -626,7 +642,10 @@ export async function handleCheckUpdate(req, res) { */ export async function handlePerformUpdate(req, res) { try { - const updateResult = await performUpdate(); + const body = await getRequestBody(req); + const version = body?.version || null; + + const updateResult = await performUpdate(version); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(updateResult)); return true; @@ -640,4 +659,4 @@ export async function handlePerformUpdate(req, res) { })); return true; } -} \ No newline at end of file +} diff --git a/static/app/auth.js b/static/app/auth.js index c6a5083..a9fc412 100644 --- a/static/app/auth.js +++ b/static/app/auth.js @@ -1,4 +1,5 @@ // 认证模块 - 处理token管理和API调用封装 +import { t } from './i18n.js'; /** * 认证管理类 */ @@ -122,17 +123,45 @@ class ApiClient { // 如果是401错误,重定向到登录页 if (response.status === 401) { this.handleUnauthorized(); - throw new Error('未授权访问'); + throw new Error(t('common.unauthorized')); } const contentType = response.headers.get('content-type'); + let data; if (contentType && contentType.includes('application/json')) { - return await response.json(); + data = await response.json(); } else { - return await response.text(); + data = await response.text(); } + + // 如果响应状态码不是 2xx,抛出错误 + if (!response.ok) { + let errorMessage; + if (data && typeof data === 'object') { + // 优先使用错误代码进行翻译 + const code = (data.error && data.error.messageCode) || data.messageCode; + if (code) { + const translated = t(code); + if (translated !== code) { + errorMessage = translated; + } + } + + // 如果没有翻译,使用原始错误消息 + if (!errorMessage) { + errorMessage = (data.error && data.error.message) || data.message; + } + } + + if (!errorMessage) { + errorMessage = `${t('common.requestFailed')} (${t('common.status')}: ${response.status})`; + } + throw new Error(errorMessage); + } + + return data; } catch (error) { - if (error.message === '未授权访问') { + if (error.message === t('common.unauthorized')) { // 已经在handleUnauthorized中处理了重定向 throw error; } @@ -205,17 +234,28 @@ class ApiClient { // 如果是401错误,重定向到登录页 if (response.status === 401) { this.handleUnauthorized(); - throw new Error('未授权访问'); + throw new Error(t('common.unauthorized')); } const contentType = response.headers.get('content-type'); + let data; if (contentType && contentType.includes('application/json')) { - return await response.json(); + data = await response.json(); } else { - return await response.text(); + data = await response.text(); } + + // 如果响应状态码不是 2xx,抛出错误 + if (!response.ok) { + const errorMessage = (data && typeof data === 'object' && data.error && data.error.message) + || (data && typeof data === 'object' && data.message) + || `${t('common.uploadFailed')} (${t('common.status')}: ${response.status})`; + throw new Error(errorMessage); + } + + return data; } catch (error) { - if (error.message === '未授权访问') { + if (error.message === t('common.unauthorized')) { // 已经在handleUnauthorized中处理了重定向 throw error; } diff --git a/static/app/event-handlers.js b/static/app/event-handlers.js index 5377ecb..a8f2629 100644 --- a/static/app/event-handlers.js +++ b/static/app/event-handlers.js @@ -27,7 +27,7 @@ function initEventListeners() { try { const token = window.authManager.getToken(); if (!token) { - showToast(t('common.error'), '请先登录', 'error'); + showToast(t('common.error'), t('common.loginRequired'), 'error'); return; } @@ -41,7 +41,7 @@ function initEventListeners() { }); if (response.status === 401) { - showToast(t('common.error'), '认证失败,请重新登录', 'error'); + showToast(t('common.error'), t('common.unauthorized'), 'error'); window.authManager.clearToken(); window.location.href = '/login.html'; return; @@ -79,7 +79,7 @@ function initEventListeners() { try { const token = window.authManager.getToken(); if (!token) { - showToast(t('common.error'), '请先登录', 'error'); + showToast(t('common.error'), t('common.loginRequired'), 'error'); return; } @@ -93,43 +93,32 @@ function initEventListeners() { }); if (response.status === 401) { - showToast(t('common.error'), '认证失败,请重新登录', 'error'); + showToast(t('common.error'), t('common.unauthorized'), 'error'); window.authManager.clearToken(); window.location.href = '/login.html'; return; } if (!response.ok) { - const errorData = await response.json(); - showToast(t('common.error'), errorData.error?.message || '下载失败', 'error'); + const errorData = await response.json().catch(() => ({})); + showToast(t('common.error'), errorData.error?.message || t('common.downloadFailed'), 'error'); return; } - - // 获取文件名 - const contentDisposition = response.headers.get('Content-Disposition'); - let filename = 'app.log'; - if (contentDisposition) { - const matches = /filename="?([^"]+)"?/.exec(contentDisposition); - if (matches && matches[1]) { - filename = matches[1]; - } - } - - // 下载文件 + const blob = await response.blob(); const downloadUrl = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = downloadUrl; - a.download = filename; + a.download = `app-${new Date().toISOString().split('T')[0]}.log`; document.body.appendChild(a); a.click(); + window.URL.revokeObjectURL(downloadUrl); document.body.removeChild(a); - window.URL.revokeObjectURL(downloadUrl); - showToast(t('common.success'), '日志下载成功', 'success'); + showToast(t('common.success'), t('common.downloadSuccess'), 'success'); } catch (error) { console.error('下载日志失败:', error); - showToast(t('common.error'), '下载失败: ' + error.message, 'error'); + showToast(t('common.error'), t('common.downloadFailed') + ': ' + error.message, 'error'); } }); } diff --git a/static/app/i18n.js b/static/app/i18n.js index f4ee713..e51e1ad 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -47,6 +47,8 @@ const translations = { 'dashboard.update.performTitle': '更新到最新版本', 'dashboard.update.checking': '正在检查...', 'dashboard.update.upToDate': '已是最新', + 'dashboard.update.latest': '最新', + 'dashboard.update.current': '当前', 'dashboard.update.hasUpdate': '发现新版本: {version}', 'dashboard.update.updating': '正在更新...', 'dashboard.update.success': '更新成功', @@ -464,6 +466,7 @@ const translations = { 'providers.healthyProviders': '健康提供商', 'providers.status.healthy': '{healthy}/{total} 可用', 'providers.status.empty': '0/0 节点', + 'providers.status.needsRefresh': '刷新中', 'providers.stat.totalAccounts': '总账户', 'providers.stat.healthyAccounts': '健康账户', 'providers.stat.usageCount': '使用次数', @@ -832,6 +835,15 @@ const translations = { 'common.fileSize': '文件大小不能超过 5MB', 'common.uploadSuccess': '文件上传成功', 'common.uploadFailed': '文件上传失败', + 'common.requestFailed': '请求失败', + 'common.status': '状态码', + 'common.unauthorized': '未授权访问,请重新登录', + 'common.loginRequired': '请先登录以继续', + 'common.invalidSuffix': '请输入有效的后缀(仅限字母和数字)', + 'common.downloadSuccess': '下载成功', + 'common.downloadFailed': '下载失败', + 'common.passwordEmpty': '密码不能为空', + 'common.passwordTooShort': '密码长度不足', 'common.passwordUpdated': '后台密码已更新,下次登录生效', 'common.configSaved': '配置已保存', 'common.providerPoolRefreshed': '提供商池数据已刷新', @@ -905,6 +917,8 @@ const translations = { 'dashboard.update.performTitle': 'Update to latest version', 'dashboard.update.checking': 'Checking...', 'dashboard.update.upToDate': 'Up to date', + 'dashboard.update.latest': 'Latest', + 'dashboard.update.current': 'Current', 'dashboard.update.hasUpdate': 'New version available: {version}', 'dashboard.update.updating': 'Updating...', 'dashboard.update.success': 'Update successful', @@ -1323,6 +1337,7 @@ const translations = { 'providers.healthyProviders': 'Healthy Providers', 'providers.status.healthy': '{healthy}/{total} Available', 'providers.status.empty': '0/0 Nodes', + 'providers.status.needsRefresh': 'Refreshing', 'providers.stat.totalAccounts': 'Total Accounts', 'providers.stat.healthyAccounts': 'Healthy Accounts', 'providers.stat.usageCount': 'Usage Count', @@ -1692,6 +1707,15 @@ const translations = { 'common.fileSize': 'File size cannot exceed 5MB.', 'common.uploadSuccess': 'File uploaded successfully', 'common.uploadFailed': 'File upload failed', + 'common.requestFailed': 'Request failed', + 'common.status': 'Status code', + 'common.unauthorized': 'Unauthorized access, please login again', + 'common.loginRequired': 'Please login first to continue', + 'common.invalidSuffix': 'Please enter a valid suffix (letters and numbers only)', + 'common.downloadSuccess': 'Download successful', + 'common.downloadFailed': 'Download failed', + 'common.passwordEmpty': 'Password cannot be empty', + 'common.passwordTooShort': 'Password too short', 'common.passwordUpdated': 'Admin password updated, takes effect next login', 'common.configSaved': 'Configuration saved', 'common.providerPoolRefreshed': 'Provider pool data refreshed', diff --git a/static/app/modal.js b/static/app/modal.js index 60a1cd3..16132eb 100644 --- a/static/app/modal.js +++ b/static/app/modal.js @@ -370,6 +370,7 @@ function renderProviderList(providers) { const toggleButtonText = isDisabled ? t('modal.provider.enabled') : t('modal.provider.disabled'); const toggleButtonIcon = isDisabled ? 'fas fa-play' : 'fas fa-ban'; const toggleButtonClass = isDisabled ? 'btn-success' : 'btn-warning'; + const needsRefresh = !!provider.needsRefresh; // 构建错误信息显示 let errorInfoHtml = ''; @@ -388,7 +389,10 @@ function renderProviderList(providers) {
-
${provider.customName || provider.uuid}
+
+ ${provider.customName || provider.uuid} + ${needsRefresh ? ` ${t('providers.status.needsRefresh')}` : ''} +
diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js index 6238720..02087ac 100644 --- a/static/app/provider-manager.js +++ b/static/app/provider-manager.js @@ -375,7 +375,7 @@ function renderProviders(providers, supportedProviders = []) { async (suffix) => { const cleanSuffix = suffix.toLowerCase().replace(/[^a-z0-9]/g, ''); if (!cleanSuffix) { - showToast(t('common.warning'), '请输入有效的后缀(仅限字母和数字)', 'warning'); + showToast(t('common.warning'), t('common.invalidSuffix'), 'warning'); return; } @@ -3108,6 +3108,8 @@ async function checkUpdate(silent = false) { const updateBtn = document.getElementById('performUpdateBtn'); const updateBadge = document.getElementById('updateBadge'); const latestVersionText = document.getElementById('latestVersionText'); + const versionSelectWrapper = document.getElementById('versionSelectWrapper'); + const versionSelect = document.getElementById('versionSelect'); const checkBtnIcon = checkBtn?.querySelector('i'); const checkBtnText = checkBtn?.querySelector('span'); @@ -3120,16 +3122,46 @@ async function checkUpdate(silent = false) { const data = await window.apiClient.get('/check-update'); + // 处理版本列表 + if (versionSelect && data.availableVersions && data.availableVersions.length > 0) { + versionSelect.innerHTML = ''; + data.availableVersions.forEach(version => { + const option = document.createElement('option'); + option.value = version; + option.textContent = version; + // 如果是最新版本,增加标识 + if (version === data.latestVersion) { + option.textContent += ` (${t('dashboard.update.latest') || 'Latest'})`; + } + // 如果是当前版本,增加标识 + if (version === data.localVersion || version === `v${data.localVersion}`) { + option.textContent += ` (${t('dashboard.update.current') || 'Current'})`; + option.selected = true; + } + versionSelect.appendChild(option); + }); + + if (versionSelectWrapper) versionSelectWrapper.style.display = 'block'; + if (updateBtn) { + updateBtn.style.display = 'inline-flex'; + // 如果是回退,修改按钮文字 + updateBtn.querySelector('span').textContent = t('dashboard.update.perform'); + } + } + if (data.hasUpdate) { - if (updateBtn) updateBtn.style.display = 'inline-flex'; if (updateBadge) updateBadge.style.display = 'inline-flex'; if (latestVersionText) latestVersionText.textContent = data.latestVersion; + // 如果有新版本且未选择特定版本,默认选中最新 + if (versionSelect && data.latestVersion) { + versionSelect.value = data.latestVersion; + } + if (!silent) { showToast(t('common.info'), t('dashboard.update.hasUpdate', { version: data.latestVersion }), 'info'); } } else { - if (updateBtn) updateBtn.style.display = 'none'; if (updateBadge) updateBadge.style.display = 'none'; if (!silent) { showToast(t('common.info'), t('dashboard.update.upToDate'), 'success'); @@ -3154,10 +3186,10 @@ async function checkUpdate(silent = false) { */ async function performUpdate() { const updateBtn = document.getElementById('performUpdateBtn'); - const latestVersionText = document.getElementById('latestVersionText'); - const version = latestVersionText?.textContent || ''; + const versionSelect = document.getElementById('versionSelect'); + const selectedVersion = versionSelect?.value || ''; - if (!confirm(t('dashboard.update.confirmMsg', { version }))) { + if (!confirm(t('dashboard.update.confirmMsg', { version: selectedVersion }))) { return; } @@ -3173,7 +3205,7 @@ async function performUpdate() { showToast(t('common.info'), t('dashboard.update.updating'), 'info'); - const data = await window.apiClient.post('/update'); + const data = await window.apiClient.post('/update', { version: selectedVersion }); if (data.success) { if (data.updated) { @@ -3183,8 +3215,8 @@ async function performUpdate() { // 自动重启服务 await restartServiceAfterUpdate(); } else { - // 已是最新版本 - showToast(t('common.info'), t('dashboard.update.upToDate'), 'info'); + // 已是目标版本 + showToast(t('common.info'), data.message || t('dashboard.update.upToDate'), 'info'); } } } catch (error) { @@ -3337,7 +3369,7 @@ function showAddProviderGroupModal(defaultBaseType = null) { const suffix = suffixInput.value.trim().toLowerCase().replace(/[^a-z0-9]/g, ''); if (!suffix) { - showToast(t('common.warning'), '请输入有效的后缀(仅限字母和数字)', 'warning'); + showToast(t('common.warning'), t('common.invalidSuffix'), 'warning'); return; } diff --git a/static/components/section-dashboard.html b/static/components/section-dashboard.html index 83a1b93..67c2253 100644 --- a/static/components/section-dashboard.html +++ b/static/components/section-dashboard.html @@ -41,6 +41,11 @@

系统信息

+ diff --git a/static/components/section-providers.css b/static/components/section-providers.css index f8304de..f30523b 100644 --- a/static/components/section-providers.css +++ b/static/components/section-providers.css @@ -198,9 +198,14 @@ letter-spacing: 0.05em; } -.provider-badge.official { background: var(--info-bg); color: var(--info-text); } -.provider-badge.oauth { background: var(--success-bg); color: var(--success-text); } -.provider-badge.responses { background: var(--warning-bg); color: var(--warning-text); } +.badge-warning { + background: var(--warning-bg); + color: var(--warning-text); + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; +} .routing-card-content { padding: 1.5rem; diff --git a/static/poixeai.png b/static/poixeai.png new file mode 100644 index 0000000..aa89cb0 Binary files /dev/null and b/static/poixeai.png differ