From b6206a5d5c60a893046c6fbd5841a104bb551d60 Mon Sep 17 00:00:00 2001 From: Wenaixi Date: Mon, 30 Mar 2026 01:14:49 +0800 Subject: [PATCH 01/28] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现对供应商节点进行周期性健康测试,失败1次立即标记unhealthy Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/providers/provider-pool-manager.js | 59 ++++++++++++++++++++++++++ src/services/api-server.js | 17 ++++++++ 2 files changed, 76 insertions(+) diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index 4a323fe..7a81a22 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -1743,6 +1743,65 @@ export class ProviderPoolManager { } } + /** + * Performs scheduled health checks on all providers. + * This method is designed to be called periodically to proactively check provider health. + * It respects provider-level isDisabled and checkHealth flags. + */ + async performScheduledHealthChecks() { + const scheduledConfig = globalThis.CONFIG?.SCHEDULED_HEALTH_CHECK; + + // Check if scheduled health checks are disabled + if (scheduledConfig?.disabled) { + this._log('debug', '[ScheduledHealthCheck] Scheduled health checks are disabled via configuration'); + return; + } + + this._log('info', '[ScheduledHealthCheck] Starting scheduled health checks on all providers...'); + + for (const providerType in this.providerStatus) { + for (const provider of this.providerStatus[providerType]) { + // Skip manually disabled providers + if (provider.config.isDisabled === true) { + this._log('debug', `[ScheduledHealthCheck] Skipping ${provider.config.uuid} (${providerType}): manually disabled`); + continue; + } + + // Skip providers with checkHealth disabled + if (provider.config.checkHealth === false) { + this._log('debug', `[ScheduledHealthCheck] Skipping ${provider.config.uuid} (${providerType}): checkHealth is false`); + continue; + } + + try { + // Perform health check with forceCheck=false (respects checkHealth flag) + const result = await this._checkProviderHealth(providerType, provider.config, false); + + // result === null means checkHealth was false (already handled above) or not implemented + if (result === null) { + this._log('debug', `[ScheduledHealthCheck] Health check for ${provider.config.uuid} (${providerType}) skipped: not implemented`); + continue; + } + + if (!result.success) { + // Provider is unhealthy + this._log('warn', `[ScheduledHealthCheck] Health check failed for ${provider.config.uuid} (${providerType}): ${result.errorMessage || 'Provider is not responding correctly.'}`); + this.markProviderUnhealthyImmediately(providerType, provider.config, result.errorMessage); + } else { + // Provider is healthy + this._log('debug', `[ScheduledHealthCheck] Health check passed for ${provider.config.uuid} (${providerType})`); + this.markProviderHealthy(providerType, provider.config, true, result.modelName); + } + } catch (error) { + this._log('error', `[ScheduledHealthCheck] Health check exception for ${provider.config.uuid} (${providerType}): ${error.message}`); + this.markProviderUnhealthyImmediately(providerType, provider.config, error.message); + } + } + } + + this._log('info', '[ScheduledHealthCheck] Completed'); + } + /** * 构建健康检查请求(返回多种格式用于重试) * @private diff --git a/src/services/api-server.js b/src/services/api-server.js index f3b6360..bf3e66f 100644 --- a/src/services/api-server.js +++ b/src/services/api-server.js @@ -361,6 +361,23 @@ async function startServer() { poolManager.performHealthChecks(true); } + // 定时健康检查 + const scheduledConfig = CONFIG.SCHEDULED_HEALTH_CHECK; + if (scheduledConfig?.enabled) { + const interval = scheduledConfig.interval || CONFIG.CRON_NEAR_MINUTES * 60 * 1000; + + // 设置定时任务 + setInterval(async () => { + try { + await poolManager.performScheduledHealthChecks(); + } catch (error) { + logger.error('[ScheduledHealthCheck] Error:', error); + } + }, interval); + + logger.info(`[ScheduledHealthCheck] Scheduled every ${interval}ms`); + } + // 如果是子进程,通知主进程已就绪 if (IS_WORKER_PROCESS) { sendToMaster({ type: 'ready', pid: process.pid }); From 1e7e391b0da8c47cc67bc32a8d81bc2e410d572f Mon Sep 17 00:00:00 2001 From: Wenaixi Date: Mon, 30 Mar 2026 22:58:04 +0800 Subject: [PATCH 02/28] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20SCHEDULED=5F?= =?UTF-8?q?HEALTH=5FCHECK=20=E9=85=8D=E7=BD=AE=E7=AE=A1=E7=90=86=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 允许通过 UI API 查看和修改定时健康检查配置 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/ui-modules/config-api.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js index eb43add..178831f 100644 --- a/src/ui-modules/config-api.js +++ b/src/ui-modules/config-api.js @@ -115,6 +115,9 @@ export async function handleUpdateConfig(req, res, currentConfig) { if (newConfig.LOG_MAX_FILE_SIZE !== undefined) currentConfig.LOG_MAX_FILE_SIZE = newConfig.LOG_MAX_FILE_SIZE; if (newConfig.LOG_MAX_FILES !== undefined) currentConfig.LOG_MAX_FILES = newConfig.LOG_MAX_FILES; + // Scheduled Health Check settings + if (newConfig.SCHEDULED_HEALTH_CHECK !== undefined) currentConfig.SCHEDULED_HEALTH_CHECK = newConfig.SCHEDULED_HEALTH_CHECK; + // Handle system prompt update if (newConfig.systemPrompt !== undefined) { const promptPath = currentConfig.SYSTEM_PROMPT_FILE_PATH || 'configs/input_system_prompt.txt'; @@ -175,7 +178,8 @@ export async function handleUpdateConfig(req, res, currentConfig) { TLS_SIDECAR_ENABLED: currentConfig.TLS_SIDECAR_ENABLED, TLS_SIDECAR_ENABLED_PROVIDERS: currentConfig.TLS_SIDECAR_ENABLED_PROVIDERS, TLS_SIDECAR_PORT: currentConfig.TLS_SIDECAR_PORT, - TLS_SIDECAR_PROXY_URL: currentConfig.TLS_SIDECAR_PROXY_URL + TLS_SIDECAR_PROXY_URL: currentConfig.TLS_SIDECAR_PROXY_URL, + SCHEDULED_HEALTH_CHECK: currentConfig.SCHEDULED_HEALTH_CHECK }; writeFileSync(configPath, JSON.stringify(configToSave, null, 2), 'utf-8'); From 79d2441c239e58564414f771c93a1ba54d60cc09 Mon Sep 17 00:00:00 2001 From: Wenaixi Date: Mon, 30 Mar 2026 23:06:05 +0800 Subject: [PATCH 03/28] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在配置管理页面添加定时健康检查开关、间隔配置 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- static/app/config-manager.js | 22 ++++++++++++++++++++ static/components/section-config.html | 29 +++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/static/app/config-manager.js b/static/app/config-manager.js index 68e99cc..1e031f7 100644 --- a/static/app/config-manager.js +++ b/static/app/config-manager.js @@ -236,6 +236,21 @@ async function loadConfiguration() { }); } + // 定时健康检查配置 + const scheduledHealthCheckEnabledEl = document.getElementById('scheduledHealthCheckEnabled'); + const scheduledHealthCheckStartupRunEl = document.getElementById('scheduledHealthCheckStartupRun'); + const scheduledHealthCheckIntervalEl = document.getElementById('scheduledHealthCheckInterval'); + + if (data.SCHEDULED_HEALTH_CHECK) { + if (scheduledHealthCheckEnabledEl) scheduledHealthCheckEnabledEl.checked = data.SCHEDULED_HEALTH_CHECK.enabled !== false; + if (scheduledHealthCheckStartupRunEl) scheduledHealthCheckStartupRunEl.checked = data.SCHEDULED_HEALTH_CHECK.startupRun !== false; + if (scheduledHealthCheckIntervalEl) scheduledHealthCheckIntervalEl.value = data.SCHEDULED_HEALTH_CHECK.interval || 600000; + } else { + if (scheduledHealthCheckEnabledEl) scheduledHealthCheckEnabledEl.checked = true; + if (scheduledHealthCheckStartupRunEl) scheduledHealthCheckStartupRunEl.checked = true; + if (scheduledHealthCheckIntervalEl) scheduledHealthCheckIntervalEl.value = 600000; + } + } catch (error) { console.error('Failed to load configuration:', error); } @@ -346,6 +361,13 @@ async function saveConfiguration() { } else { config.TLS_SIDECAR_ENABLED_PROVIDERS = []; } + + // 定时健康检查配置 + config.SCHEDULED_HEALTH_CHECK = { + enabled: document.getElementById('scheduledHealthCheckEnabled')?.checked !== false, + startupRun: document.getElementById('scheduledHealthCheckStartupRun')?.checked !== false, + interval: parseInt(document.getElementById('scheduledHealthCheckInterval')?.value || 600000) + }; try { await window.apiClient.post('/config', config); diff --git a/static/components/section-config.html b/static/components/section-config.html index 37e0fe1..d5fd694 100644 --- a/static/components/section-config.html +++ b/static/components/section-config.html @@ -228,6 +228,35 @@ + +
+

定时健康检查

+
+
+ + +
+
+ + +
+
+
+ + + 设置定时健康检查的执行间隔 +
+
From ceb078c45567a90690bd80777ebcf82ec69993f8 Mon Sep 17 00:00:00 2001 From: Wenaixi Date: Mon, 30 Mar 2026 23:08:48 +0800 Subject: [PATCH 04/28] =?UTF-8?q?fix:=20=E6=B7=BB=E5=8A=A0=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5=20i18n=20?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E7=BF=BB=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- static/app/i18n.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/static/app/i18n.js b/static/app/i18n.js index 2d1739b..830a521 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -343,6 +343,11 @@ const translations = { 'config.proxy.tlsSidecarProxyUrl': 'Sidecar 上游代理', 'config.proxy.tlsSidecarEnabledProviders': '启用 TLS Sidecar 的提供商', 'config.proxy.tlsSidecarNote': '启用后选中的提供商请求将通过 Go uTLS sidecar 转发,完美模拟 Chrome TLS/H2 指纹绕过 Cloudflare(需重启服务)', + 'config.healthCheck.title': '定时健康检查', + 'config.healthCheck.enabled': '启用定时检查', + 'config.healthCheck.startupRun': '启动时运行', + 'config.healthCheck.interval': '检查间隔', + 'config.healthCheck.intervalNote': '设置定时健康检查的执行间隔', 'config.log.title': '日志设置', 'config.log.enabled': '启用日志', 'config.log.outputMode': '日志输出模式', From 94561f27504783bbc1c3b5832cef76e784efca40 Mon Sep 17 00:00:00 2001 From: Wenaixi Date: Mon, 30 Mar 2026 23:12:59 +0800 Subject: [PATCH 05/28] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5=E9=85=8D=E7=BD=AE?= =?UTF-8?q?UI=EF=BC=8C=E6=94=AF=E6=8C=81=E6=89=8B=E5=8A=A8=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E9=97=B4=E9=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改为数字输入框+快捷按钮,支持自定义任意间隔值 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- static/app/config-manager.js | 12 ++++++ static/app/i18n.js | 2 +- static/components/section-config.css | 59 +++++++++++++++++++++++++++ static/components/section-config.html | 15 ++++--- 4 files changed, 81 insertions(+), 7 deletions(-) diff --git a/static/app/config-manager.js b/static/app/config-manager.js index 1e031f7..e4b08ab 100644 --- a/static/app/config-manager.js +++ b/static/app/config-manager.js @@ -251,6 +251,18 @@ async function loadConfiguration() { if (scheduledHealthCheckIntervalEl) scheduledHealthCheckIntervalEl.value = 600000; } + // 定时健康检查间隔快捷按钮 + const intervalQuickBtns = document.querySelectorAll('#scheduledHealthCheckInterval + .quick-select-btns button'); + intervalQuickBtns.forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + const value = parseInt(btn.getAttribute('data-value')); + if (scheduledHealthCheckIntervalEl) { + scheduledHealthCheckIntervalEl.value = value; + } + }); + }); + } catch (error) { console.error('Failed to load configuration:', error); } diff --git a/static/app/i18n.js b/static/app/i18n.js index 830a521..5080210 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -347,7 +347,7 @@ const translations = { 'config.healthCheck.enabled': '启用定时检查', 'config.healthCheck.startupRun': '启动时运行', 'config.healthCheck.interval': '检查间隔', - 'config.healthCheck.intervalNote': '设置定时健康检查的执行间隔', + 'config.healthCheck.intervalNote': '单位毫秒,最小60000ms(1分钟),最大3600000ms(1小时),可手动输入或点击快捷按钮', 'config.log.title': '日志设置', 'config.log.enabled': '启用日志', 'config.log.outputMode': '日志输出模式', diff --git a/static/components/section-config.css b/static/components/section-config.css index 6cd16ca..e5ecaac 100644 --- a/static/components/section-config.css +++ b/static/components/section-config.css @@ -7,6 +7,65 @@ border: 1px solid var(--border-color); } +.form-group { + margin-bottom: 1.5rem; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + color: var(--text-primary); + font-size: 0.9rem; +} + +.optional-tag, .form-group label .optional-mark { + font-size: 0.75rem; + color: var(--text-tertiary); + font-weight: 400; + margin-left: 0.5rem; + background: var(--bg-tertiary); + padding: 0.125rem 0.375rem; + border-radius: var(--radius-sm); +} + +.form-control::placeholder { + color: var(--text-tertiary); +} + +textarea.form-control { + resize: vertical; + font-family: inherit; +} + +/* 带快捷选择的输入框 */ +.input-with-quick-select { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.input-with-quick-select .form-control { + width: 100%; +} + +.quick-select-btns { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.quick-select-btns .btn { + padding: 0.25rem 0.75rem; + font-size: 0.75rem; +} + .config-form { max-width: 800px; margin: 0 auto; diff --git a/static/components/section-config.html b/static/components/section-config.html index d5fd694..37d1eee 100644 --- a/static/components/section-config.html +++ b/static/components/section-config.html @@ -249,12 +249,15 @@
- - 设置定时健康检查的执行间隔 +
+ +
+ + + +
+
+ 单位毫秒,最小60000ms(1分钟),最大3600000ms(1小时)
From 129b4f688f1bfdbf7343a491d9e5bf01155f62bc Mon Sep 17 00:00:00 2001 From: Wenaixi Date: Mon, 30 Mar 2026 23:13:47 +0800 Subject: [PATCH 06/28] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5enabled=E6=A3=80?= =?UTF-8?q?=E6=9F=A5bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将scheduledConfig?.disabled改为!scheduledConfig?.enabled Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/providers/provider-pool-manager.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index 7a81a22..beee214 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -1752,7 +1752,7 @@ export class ProviderPoolManager { const scheduledConfig = globalThis.CONFIG?.SCHEDULED_HEALTH_CHECK; // Check if scheduled health checks are disabled - if (scheduledConfig?.disabled) { + if (!scheduledConfig?.enabled) { this._log('debug', '[ScheduledHealthCheck] Scheduled health checks are disabled via configuration'); return; } From 21c92ff2141ed546b8c8dc4967f4aff0ad61488d Mon Sep 17 00:00:00 2001 From: Wenaixi Date: Mon, 30 Mar 2026 23:20:07 +0800 Subject: [PATCH 07/28] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5=E4=B8=A4=E4=B8=AA?= =?UTF-8?q?bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. startupRun配置现在会在启动时立即运行健康检查 2. 修复快捷按钮重复绑定事件监听器的问题 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/services/api-server.js | 12 ++++++++++++ static/app/config-manager.js | 4 +++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/services/api-server.js b/src/services/api-server.js index bf3e66f..6ad8921 100644 --- a/src/services/api-server.js +++ b/src/services/api-server.js @@ -366,6 +366,18 @@ async function startServer() { if (scheduledConfig?.enabled) { const interval = scheduledConfig.interval || CONFIG.CRON_NEAR_MINUTES * 60 * 1000; + // 启动时运行健康检查 + if (scheduledConfig.startupRun !== false) { + logger.info('[ScheduledHealthCheck] Running scheduled health check on startup...'); + setTimeout(async () => { + try { + await poolManager.performScheduledHealthChecks(); + } catch (error) { + logger.error('[ScheduledHealthCheck] Startup run error:', error); + } + }, 100); // 延迟100ms确保服务已完全就绪 + } + // 设置定时任务 setInterval(async () => { try { diff --git a/static/app/config-manager.js b/static/app/config-manager.js index e4b08ab..712cdc0 100644 --- a/static/app/config-manager.js +++ b/static/app/config-manager.js @@ -251,9 +251,11 @@ async function loadConfiguration() { if (scheduledHealthCheckIntervalEl) scheduledHealthCheckIntervalEl.value = 600000; } - // 定时健康检查间隔快捷按钮 + // 定时健康检查间隔快捷按钮(防止重复绑定) const intervalQuickBtns = document.querySelectorAll('#scheduledHealthCheckInterval + .quick-select-btns button'); intervalQuickBtns.forEach(btn => { + if (btn.dataset.listenerAttached) return; // 防止重复绑定 + btn.dataset.listenerAttached = 'true'; btn.addEventListener('click', (e) => { e.preventDefault(); const value = parseInt(btn.getAttribute('data-value')); From 1bc193f5eb02605a4653b1243e5c511f056b0aef Mon Sep 17 00:00:00 2001 From: Wenaixi Date: Mon, 30 Mar 2026 23:28:19 +0800 Subject: [PATCH 08/28] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5=E4=BE=9B=E5=BA=94?= =?UTF-8?q?=E5=95=86=E7=B1=BB=E5=9E=8B=E9=80=89=E6=8B=A9=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 类似启用代理的提供商,现在可以选择对哪些供应商类型进行定时健康检查 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/providers/provider-pool-manager.js | 10 ++++++ static/app/config-manager.js | 24 +++++++++++++- static/app/i18n.js | 2 ++ static/components/section-config.html | 46 ++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index beee214..967d589 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -1759,7 +1759,17 @@ export class ProviderPoolManager { this._log('info', '[ScheduledHealthCheck] Starting scheduled health checks on all providers...'); + // Get selected provider types, if empty/undefined then check all + const selectedProviderTypes = scheduledConfig?.providerTypes; + const checkAllTypes = !selectedProviderTypes || selectedProviderTypes.length === 0; + for (const providerType in this.providerStatus) { + // Filter by selected provider types if specified + if (!checkAllTypes && !selectedProviderTypes.includes(providerType)) { + this._log('debug', `[ScheduledHealthCheck] Skipping provider type ${providerType}: not in selected types`); + continue; + } + for (const provider of this.providerStatus[providerType]) { // Skip manually disabled providers if (provider.config.isDisabled === true) { diff --git a/static/app/config-manager.js b/static/app/config-manager.js index 712cdc0..9c16a00 100644 --- a/static/app/config-manager.js +++ b/static/app/config-manager.js @@ -251,6 +251,21 @@ async function loadConfiguration() { if (scheduledHealthCheckIntervalEl) scheduledHealthCheckIntervalEl.value = 600000; } + // 加载定时健康检查的供应商选择 + const scheduledHealthCheckProvidersEl = document.getElementById('scheduledHealthCheckProviders'); + if (scheduledHealthCheckProvidersEl) { + const enabledProviders = data.SCHEDULED_HEALTH_CHECK?.providerTypes || []; + const tags = scheduledHealthCheckProvidersEl.querySelectorAll('.provider-tag'); + tags.forEach(tag => { + const value = tag.getAttribute('data-value'); + if (enabledProviders.includes(value)) { + tag.classList.add('selected'); + } else { + tag.classList.remove('selected'); + } + }); + } + // 定时健康检查间隔快捷按钮(防止重复绑定) const intervalQuickBtns = document.querySelectorAll('#scheduledHealthCheckInterval + .quick-select-btns button'); intervalQuickBtns.forEach(btn => { @@ -377,10 +392,17 @@ async function saveConfiguration() { } // 定时健康检查配置 + const scheduledHealthCheckProvidersEl = document.getElementById('scheduledHealthCheckProviders'); + const scheduledHealthCheckProviderTypes = scheduledHealthCheckProvidersEl + ? Array.from(scheduledHealthCheckProvidersEl.querySelectorAll('.provider-tag.selected')) + .map(tag => tag.getAttribute('data-value')) + : []; + config.SCHEDULED_HEALTH_CHECK = { enabled: document.getElementById('scheduledHealthCheckEnabled')?.checked !== false, startupRun: document.getElementById('scheduledHealthCheckStartupRun')?.checked !== false, - interval: parseInt(document.getElementById('scheduledHealthCheckInterval')?.value || 600000) + interval: parseInt(document.getElementById('scheduledHealthCheckInterval')?.value || 600000), + providerTypes: scheduledHealthCheckProviderTypes }; try { diff --git a/static/app/i18n.js b/static/app/i18n.js index 5080210..b5e1c3a 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -348,6 +348,8 @@ const translations = { 'config.healthCheck.startupRun': '启动时运行', 'config.healthCheck.interval': '检查间隔', 'config.healthCheck.intervalNote': '单位毫秒,最小60000ms(1分钟),最大3600000ms(1小时),可手动输入或点击快捷按钮', + 'config.healthCheck.providerTypes': '定时检查的供应商', + 'config.healthCheck.providerTypesNote': '选择需要进行定时健康检查的供应商类型,留空则检查所有供应商', 'config.log.title': '日志设置', 'config.log.enabled': '启用日志', 'config.log.outputMode': '日志输出模式', diff --git a/static/components/section-config.html b/static/components/section-config.html index 37d1eee..7bb35ea 100644 --- a/static/components/section-config.html +++ b/static/components/section-config.html @@ -259,6 +259,52 @@ 单位毫秒,最小60000ms(1分钟),最大3600000ms(1小时) +
+ +
+ + + + + + + + + + +
+ 选择需要进行定时健康检查的供应商类型 +
From df9a36291cc5446a64c53802d58e2a29a7737608 Mon Sep 17 00:00:00 2001 From: Wenaixi Date: Mon, 30 Mar 2026 23:41:49 +0800 Subject: [PATCH 09/28] =?UTF-8?q?fix:=20=E6=B7=BB=E5=8A=A0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=AA=8C=E8=AF=81=E9=98=B2=E6=AD=A2=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E7=9A=84=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. config-api.js: 添加 SCHEDULED_HEALTH_CHECK 结构验证 2. config-manager.js: 添加 interval 值范围验证 (60000-3600000ms) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/ui-modules/config-api.js | 13 ++++++++++++- static/app/config-manager.js | 6 +++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js index 178831f..8310a8e 100644 --- a/src/ui-modules/config-api.js +++ b/src/ui-modules/config-api.js @@ -116,7 +116,18 @@ export async function handleUpdateConfig(req, res, currentConfig) { if (newConfig.LOG_MAX_FILES !== undefined) currentConfig.LOG_MAX_FILES = newConfig.LOG_MAX_FILES; // Scheduled Health Check settings - if (newConfig.SCHEDULED_HEALTH_CHECK !== undefined) currentConfig.SCHEDULED_HEALTH_CHECK = newConfig.SCHEDULED_HEALTH_CHECK; + if (newConfig.SCHEDULED_HEALTH_CHECK !== undefined) { + const incoming = newConfig.SCHEDULED_HEALTH_CHECK; + currentConfig.SCHEDULED_HEALTH_CHECK = { + enabled: incoming?.enabled === true, + startupRun: incoming?.startupRun !== false, + interval: (() => { + const val = Number(incoming?.interval); + return isNaN(val) ? 600000 : Math.max(60000, Math.min(3600000, val)); + })(), + providerTypes: Array.isArray(incoming?.providerTypes) ? incoming.providerTypes : [] + }; + } // Handle system prompt update if (newConfig.systemPrompt !== undefined) { diff --git a/static/app/config-manager.js b/static/app/config-manager.js index 9c16a00..e093f3c 100644 --- a/static/app/config-manager.js +++ b/static/app/config-manager.js @@ -398,10 +398,14 @@ async function saveConfiguration() { .map(tag => tag.getAttribute('data-value')) : []; + // 验证并规范化 interval 值 + const rawInterval = parseInt(document.getElementById('scheduledHealthCheckInterval')?.value); + const validatedInterval = isNaN(rawInterval) ? 600000 : Math.max(60000, Math.min(3600000, rawInterval)); + config.SCHEDULED_HEALTH_CHECK = { enabled: document.getElementById('scheduledHealthCheckEnabled')?.checked !== false, startupRun: document.getElementById('scheduledHealthCheckStartupRun')?.checked !== false, - interval: parseInt(document.getElementById('scheduledHealthCheckInterval')?.value || 600000), + interval: validatedInterval, providerTypes: scheduledHealthCheckProviderTypes }; From 9172401a50432371c5a75df238a69c4d5c690e36 Mon Sep 17 00:00:00 2001 From: Wenaixi Date: Tue, 31 Mar 2026 01:14:44 +0800 Subject: [PATCH 10/28] =?UTF-8?q?fix:=20=E7=AE=80=E5=8C=96=E5=81=A5?= =?UTF-8?q?=E5=BA=B7=E6=A3=80=E6=9F=A5=E9=80=BB=E8=BE=91=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E7=83=AD=E6=9B=B4=E6=96=B0interval=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8Dprovider-tag=E7=82=B9=E5=87=BB=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除checkHealth per-instance flag,简化逻辑为只依赖providerTypes勾选 - performHealthChecks启动检查也遵循providerTypes过滤 - 优化健康检查日志:显示耗时、通过/失败计数 - 支持修改interval后热更新,无需重启(globalThis.reloadHealthCheckTimer) - 移除openai-iflow选项(未注册的provider) - 修复config-manager.js中scheduledHealthCheckProviders点击不生效问题 - providerTypes配置修改后下次检查自动生效 --- src/providers/provider-pool-manager.js | 123 ++++++++++++++++--------- src/services/api-server.js | 63 ++++++++++--- src/ui-modules/config-api.js | 26 ++++-- src/ui-modules/provider-api.js | 3 +- src/utils/provider-utils.js | 5 +- static/app/config-manager.js | 6 ++ static/components/section-config.css | 37 -------- static/components/section-config.html | 19 +--- 8 files changed, 160 insertions(+), 122 deletions(-) diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index 967d589..1663bb3 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -1674,6 +1674,10 @@ export class ProviderPoolManager { * This method would typically be called periodically (e.g., via cron job). */ async performHealthChecks(isInit = false) { + const scheduledConfig = this.globalConfig?.SCHEDULED_HEALTH_CHECK; + const selectedProviderTypes = scheduledConfig?.providerTypes || []; + const checkAllTypes = selectedProviderTypes.length === 0; + this._log('info', 'Performing health checks on all providers...'); const now = new Date(); @@ -1681,6 +1685,11 @@ export class ProviderPoolManager { this._checkAndRecoverScheduledProviders(); for (const providerType in this.providerStatus) { + // Filter by selected provider types if specified (same logic as scheduled health check) + if (!checkAllTypes && !selectedProviderTypes.includes(providerType)) { + continue; + } + for (const providerStatus of this.providerStatus[providerType]) { const providerConfig = providerStatus.config; @@ -1749,7 +1758,8 @@ export class ProviderPoolManager { * It respects provider-level isDisabled and checkHealth flags. */ async performScheduledHealthChecks() { - const scheduledConfig = globalThis.CONFIG?.SCHEDULED_HEALTH_CHECK; + const scheduledConfig = this.globalConfig?.SCHEDULED_HEALTH_CHECK; + const checkStartTime = Date.now(); // Check if scheduled health checks are disabled if (!scheduledConfig?.enabled) { @@ -1757,11 +1767,20 @@ export class ProviderPoolManager { return; } - this._log('info', '[ScheduledHealthCheck] Starting scheduled health checks on all providers...'); - // Get selected provider types, if empty/undefined then check all - const selectedProviderTypes = scheduledConfig?.providerTypes; - const checkAllTypes = !selectedProviderTypes || selectedProviderTypes.length === 0; + let selectedProviderTypes = scheduledConfig?.providerTypes; + + // Validate providerTypes is an array to prevent TypeError + if (!Array.isArray(selectedProviderTypes)) { + this._log('warn', '[ScheduledHealthCheck] providerTypes is not an array, treating as check all'); + selectedProviderTypes = []; + } + + const checkAllTypes = selectedProviderTypes.length === 0; + + // Count providers to be checked + let totalProviders = 0; + let providersToCheck = []; for (const providerType in this.providerStatus) { // Filter by selected provider types if specified @@ -1777,39 +1796,58 @@ export class ProviderPoolManager { continue; } - // Skip providers with checkHealth disabled - if (provider.config.checkHealth === false) { - this._log('debug', `[ScheduledHealthCheck] Skipping ${provider.config.uuid} (${providerType}): checkHealth is false`); - continue; - } - - try { - // Perform health check with forceCheck=false (respects checkHealth flag) - const result = await this._checkProviderHealth(providerType, provider.config, false); - - // result === null means checkHealth was false (already handled above) or not implemented - if (result === null) { - this._log('debug', `[ScheduledHealthCheck] Health check for ${provider.config.uuid} (${providerType}) skipped: not implemented`); - continue; - } - - if (!result.success) { - // Provider is unhealthy - this._log('warn', `[ScheduledHealthCheck] Health check failed for ${provider.config.uuid} (${providerType}): ${result.errorMessage || 'Provider is not responding correctly.'}`); - this.markProviderUnhealthyImmediately(providerType, provider.config, result.errorMessage); - } else { - // Provider is healthy - this._log('debug', `[ScheduledHealthCheck] Health check passed for ${provider.config.uuid} (${providerType})`); - this.markProviderHealthy(providerType, provider.config, true, result.modelName); - } - } catch (error) { - this._log('error', `[ScheduledHealthCheck] Health check exception for ${provider.config.uuid} (${providerType}): ${error.message}`); - this.markProviderUnhealthyImmediately(providerType, provider.config, error.message); - } + totalProviders++; + providersToCheck.push({ providerType, provider, uuid: provider.config.uuid, customName: provider.config.customName }); } } - this._log('info', '[ScheduledHealthCheck] Completed'); + this._log('info', `[ScheduledHealthCheck] Starting scheduled health checks: ${totalProviders} provider(s) to check (interval: ${scheduledConfig.interval}ms, types: ${checkAllTypes ? 'all' : selectedProviderTypes.join(', ')})`); + + let successCount = 0; + let failCount = 0; + + for (const { providerType, provider, uuid, customName } of providersToCheck) { + // Skip if provider became disabled during iteration + if (provider.config.isDisabled === true) { + continue; + } + + const checkStartTime = Date.now(); + const checkModelName = provider.config.checkModelName || ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType] || 'unknown'; + const displayName = customName || uuid.substring(0, 8); + + try { + // Perform health check (forceCheck=true to bypass per-instance checkHealth flag) + const result = await this._checkProviderHealth(providerType, provider.config); + const checkDuration = Date.now() - checkStartTime; + + // result === null means check not implemented for this provider type + if (result === null) { + this._log('info', `[ScheduledHealthCheck] ${displayName} (${providerType}): check skipped - not implemented (${checkDuration}ms)`); + continue; + } + + if (!result.success) { + // Provider is unhealthy + failCount++; + this._log('warn', `[ScheduledHealthCheck] ${displayName} (${providerType}) FAILED: ${result.errorMessage || 'Provider is not responding correctly.'} (${checkDuration}ms)`); + this.markProviderUnhealthyImmediately(providerType, provider.config, result.errorMessage); + } else { + // Provider is healthy + successCount++; + this._log('info', `[ScheduledHealthCheck] ${displayName} (${providerType}) PASSED: model=${result.modelName || checkModelName} (${checkDuration}ms)`); + this.markProviderHealthy(providerType, provider.config, false, result.modelName); + } + } catch (error) { + const checkDuration = Date.now() - checkStartTime; + failCount++; + this._log('error', `[ScheduledHealthCheck] ${displayName} (${providerType}) EXCEPTION: ${error.message} (${checkDuration}ms)`); + this.markProviderUnhealthyImmediately(providerType, provider.config, error.message); + } + } + + const totalDuration = Date.now() - checkStartTime; + this._log('info', `[ScheduledHealthCheck] Completed: ${successCount} passed, ${failCount} failed, ${totalDuration}ms total`); } /** @@ -1864,15 +1902,9 @@ export class ProviderPoolManager { * Performs an actual health check for a specific provider. * @param {string} providerType - The type of the provider. * @param {object} providerConfig - The configuration of the provider to check. - * @param {boolean} forceCheck - If true, ignore checkHealth config and force the check. * @returns {Promise<{success: boolean, modelName: string, errorMessage: string}|null>} - Health check result object or null if check not implemented. */ - async _checkProviderHealth(providerType, providerConfig, forceCheck = false) { - // 如果未启用健康检查且不是强制检查,返回 null(提前返回,避免不必要的计算) - if (!providerConfig.checkHealth && !forceCheck) { - return null; - } - + async _checkProviderHealth(providerType, providerConfig) { // 确定健康检查使用的模型名称 const modelName = providerConfig.checkModelName || ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType]; @@ -1907,8 +1939,6 @@ export class ProviderPoolManager { const timeoutId = setTimeout(() => abortController.abort(), healthCheckTimeout); try { - this._log('debug', `Health check attempt ${i + 1}/${healthCheckRequests.length} for ${modelName}: ${JSON.stringify(healthCheckRequest)}`); - // 尝试将 signal 注入请求体,供支持的适配器使用 const requestWithSignal = { ...healthCheckRequest, @@ -1918,16 +1948,17 @@ export class ProviderPoolManager { await serviceAdapter.generateContent(modelName, requestWithSignal); clearTimeout(timeoutId); + // 将健康检查计入使用量(resetUsageCount=false 只会递增,不会重置) + this.markProviderHealthy(providerType, providerConfig, false, modelName); return { success: true, modelName, errorMessage: null }; } catch (error) { clearTimeout(timeoutId); lastError = error; - this._log('debug', `Health check attempt ${i + 1} failed for ${providerType}: ${error.message}`); } } // 所有尝试都失败 - this._log('error', `Health check failed for ${providerType} after ${healthCheckRequests.length} attempts: ${lastError?.message}`); + this._log('warn', `[HealthCheck] ${providerType} failed after ${healthCheckRequests.length} attempts: ${lastError?.message}`); return { success: false, modelName, errorMessage: lastError?.message || 'All health check attempts failed' }; } diff --git a/src/services/api-server.js b/src/services/api-server.js index 6ad8921..d08c2d8 100644 --- a/src/services/api-server.js +++ b/src/services/api-server.js @@ -364,30 +364,71 @@ async function startServer() { // 定时健康检查 const scheduledConfig = CONFIG.SCHEDULED_HEALTH_CHECK; if (scheduledConfig?.enabled) { - const interval = scheduledConfig.interval || CONFIG.CRON_NEAR_MINUTES * 60 * 1000; + // Validate interval is within acceptable range (minimum 60000ms, no maximum) + const DEFAULT_INTERVAL = CONFIG.CRON_NEAR_MINUTES * 60 * 1000; + let interval = scheduledConfig.interval; + if (typeof interval !== 'number' || interval < 60000) { + logger.warn(`[ScheduledHealthCheck] Invalid interval ${interval}, using default ${DEFAULT_INTERVAL}`); + interval = DEFAULT_INTERVAL; + } // 启动时运行健康检查 if (scheduledConfig.startupRun !== false) { logger.info('[ScheduledHealthCheck] Running scheduled health check on startup...'); - setTimeout(async () => { + // 使用 setImmediate 确保在事件循环的下一阶段执行,此时服务器已完全就绪 + setImmediate(async () => { try { await poolManager.performScheduledHealthChecks(); } catch (error) { logger.error('[ScheduledHealthCheck] Startup run error:', error); } - }, 100); // 延迟100ms确保服务已完全就绪 + }); + } + + let isHealthCheckRunning = false; + let healthCheckTimerId = null; + + // 定时健康检查函数 + const runHealthCheckTimer = (interval) => { + // 清除旧的 timer + if (healthCheckTimerId) { + clearInterval(healthCheckTimerId); + } + // 设置定时任务 + healthCheckTimerId = setInterval(async () => { + if (isHealthCheckRunning) { + logger.debug('[ScheduledHealthCheck] Skipping - previous run still in progress'); + return; + } + isHealthCheckRunning = true; + try { + await poolManager.performScheduledHealthChecks(); + } catch (error) { + logger.error('[ScheduledHealthCheck] Error:', error); + } finally { + isHealthCheckRunning = false; + } + }, interval); + logger.info(`[ScheduledHealthCheck] Scheduled every ${interval}ms`); + }; + + // 启动时运行健康检查 + if (scheduledConfig.startupRun !== false) { + logger.info('[ScheduledHealthCheck] Running scheduled health check on startup...'); + setImmediate(async () => { + try { + await poolManager.performScheduledHealthChecks(); + } catch (error) { + logger.error('[ScheduledHealthCheck] Startup run error:', error); + } + }); } // 设置定时任务 - setInterval(async () => { - try { - await poolManager.performScheduledHealthChecks(); - } catch (error) { - logger.error('[ScheduledHealthCheck] Error:', error); - } - }, interval); + runHealthCheckTimer(interval); - logger.info(`[ScheduledHealthCheck] Scheduled every ${interval}ms`); + // 导出重载函数供外部调用 + globalThis.reloadHealthCheckTimer = runHealthCheckTimer; } // 如果是子进程,通知主进程已就绪 diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js index 8310a8e..92c4dac 100644 --- a/src/ui-modules/config-api.js +++ b/src/ui-modules/config-api.js @@ -117,16 +117,22 @@ export async function handleUpdateConfig(req, res, currentConfig) { // Scheduled Health Check settings if (newConfig.SCHEDULED_HEALTH_CHECK !== undefined) { - const incoming = newConfig.SCHEDULED_HEALTH_CHECK; - currentConfig.SCHEDULED_HEALTH_CHECK = { - enabled: incoming?.enabled === true, - startupRun: incoming?.startupRun !== false, - interval: (() => { - const val = Number(incoming?.interval); - return isNaN(val) ? 600000 : Math.max(60000, Math.min(3600000, val)); - })(), - providerTypes: Array.isArray(incoming?.providerTypes) ? incoming.providerTypes : [] - }; + const incoming = newConfig.SCHEDULED_HEALTH_CHECK; + const newInterval = (() => { + const val = Number(incoming?.interval); + return isNaN(val) ? 600000 : Math.max(60000, val); + })(); + currentConfig.SCHEDULED_HEALTH_CHECK = { + enabled: incoming?.enabled === true, + startupRun: incoming?.startupRun !== false, + interval: newInterval, + providerTypes: Array.isArray(incoming?.providerTypes) ? incoming.providerTypes : [] + }; + + // 如果定时器已存在且 enabled,重新加载 timer(interval 变化时) + if (globalThis.reloadHealthCheckTimer && currentConfig.SCHEDULED_HEALTH_CHECK.enabled) { + globalThis.reloadHealthCheckTimer(newInterval); + } } // Handle system prompt update diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index 722ce25..83ed8f8 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -710,8 +710,7 @@ export async function handleHealthCheck(req, res, currentConfig, providerPoolMan } try { - // 传递 forceCheck = true 强制执行健康检查,忽略 checkHealth 配置 - const healthResult = await providerPoolManager._checkProviderHealth(providerType, providerConfig, true); + const healthResult = await providerPoolManager._checkProviderHealth(providerType, providerConfig); if (healthResult === null) { results.push({ diff --git a/src/utils/provider-utils.js b/src/utils/provider-utils.js index 8f1f854..905b8e8 100644 --- a/src/utils/provider-utils.js +++ b/src/utils/provider-utils.js @@ -41,6 +41,7 @@ export const PROVIDER_MAPPINGS = [ providerType: 'openai-qwen-oauth', credPathKey: 'QWEN_OAUTH_CREDS_FILE_PATH', defaultCheckModel: 'qwen3-coder-plus', + defaultCheckHealth: true, displayName: 'Qwen OAuth', needsProjectId: false, urlKeys: ['QWEN_BASE_URL', 'QWEN_OAUTH_BASE_URL'] @@ -324,13 +325,13 @@ export async function isValidOAuthCredentials(filePath) { * @returns {Object} 新的提供商配置对象 */ export function createProviderConfig(options) { - const { credPathKey, credPath, defaultCheckModel, needsProjectId, urlKeys } = options; + const { credPathKey, credPath, defaultCheckModel, defaultCheckHealth, needsProjectId, urlKeys } = options; const newProvider = { [credPathKey]: credPath, uuid: generateUUID(), checkModelName: defaultCheckModel, - checkHealth: false, + checkHealth: defaultCheckHealth ?? false, isHealthy: true, isDisabled: false, lastUsed: null, diff --git a/static/app/config-manager.js b/static/app/config-manager.js index e093f3c..130bfdc 100644 --- a/static/app/config-manager.js +++ b/static/app/config-manager.js @@ -32,6 +32,12 @@ function updateConfigProviderConfigs(configs) { if (tlsSidecarProvidersEl) { renderProviderTags(tlsSidecarProvidersEl, configs, false); } + + // 渲染定时健康检查的提供商选择 + const scheduledHealthCheckProvidersEl = document.getElementById('scheduledHealthCheckProviders'); + if (scheduledHealthCheckProvidersEl) { + renderProviderTags(scheduledHealthCheckProvidersEl, configs, false); + } // 重新加载当前配置以恢复选中状态 loadConfiguration(); diff --git a/static/components/section-config.css b/static/components/section-config.css index e5ecaac..e16338b 100644 --- a/static/components/section-config.css +++ b/static/components/section-config.css @@ -71,43 +71,6 @@ textarea.form-control { margin: 0 auto; } -.form-group { - margin-bottom: 1.5rem; -} - -.form-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1.5rem; -} - -.form-group label { - display: block; - margin-bottom: 0.5rem; - font-weight: 600; - color: var(--text-primary); - font-size: 0.9rem; -} - -.optional-tag, .form-group label .optional-mark { - font-size: 0.75rem; - color: var(--text-tertiary); - font-weight: 400; - margin-left: 0.5rem; - background: var(--bg-tertiary); - padding: 0.125rem 0.375rem; - border-radius: var(--radius-sm); -} - -.form-control::placeholder { - color: var(--text-tertiary); -} - -textarea.form-control { - resize: vertical; - font-family: inherit; -} - /* 密码输入框样式 */ .password-input-group { position: relative; diff --git a/static/components/section-config.html b/static/components/section-config.html index 7bb35ea..3db7fcc 100644 --- a/static/components/section-config.html +++ b/static/components/section-config.html @@ -62,10 +62,7 @@ OpenAI Responses - + - + - + `).join(''); @@ -157,7 +158,7 @@ async function loadConfiguration() { if (cronNearMinutesEl) cronNearMinutesEl.value = data.CRON_NEAR_MINUTES || 1; if (cronRefreshTokenEl) cronRefreshTokenEl.checked = data.CRON_REFRESH_TOKEN || false; if (loginExpiryEl) loginExpiryEl.value = data.LOGIN_EXPIRY || 3600; - if (providerPoolsFilePathEl) providerPoolsFilePathEl.value = data.PROVIDER_POOLS_FILE_PATH; + if (providerPoolsFilePathEl) providerPoolsFilePathEl.value = data.PROVIDER_POOLS_FILE_PATH || ''; if (maxErrorCountEl) maxErrorCountEl.value = data.MAX_ERROR_COUNT || 10; if (warmupTargetEl) warmupTargetEl.value = data.WARMUP_TARGET || 0; if (refreshConcurrencyPerProviderEl) refreshConcurrencyPerProviderEl.value = data.REFRESH_CONCURRENCY_PER_PROVIDER || 1; @@ -248,7 +249,7 @@ async function loadConfiguration() { const scheduledHealthCheckIntervalEl = document.getElementById('scheduledHealthCheckInterval'); if (data.SCHEDULED_HEALTH_CHECK) { - if (scheduledHealthCheckEnabledEl) scheduledHealthCheckEnabledEl.checked = data.SCHEDULED_HEALTH_CHECK.enabled !== false; + if (scheduledHealthCheckEnabledEl) scheduledHealthCheckEnabledEl.checked = data.SCHEDULED_HEALTH_CHECK.enabled === true; if (scheduledHealthCheckStartupRunEl) scheduledHealthCheckStartupRunEl.checked = data.SCHEDULED_HEALTH_CHECK.startupRun !== false; if (scheduledHealthCheckIntervalEl) scheduledHealthCheckIntervalEl.value = data.SCHEDULED_HEALTH_CHECK.interval || 600000; } else { diff --git a/static/components/section-config.css b/static/components/section-config.css index e16338b..aebf6c4 100644 --- a/static/components/section-config.css +++ b/static/components/section-config.css @@ -173,7 +173,7 @@ textarea.form-control { width: 18px; left: 3px; bottom: 2px; - background-color: white; + background-color: var(--bg-primary, white); transition: var(--transition); border-radius: 50%; box-shadow: 0 1px 3px var(--neutral-shadow-30); From fca9413f264fc267095b032ef82b51b80e071416 Mon Sep 17 00:00:00 2001 From: Wenaixi Date: Thu, 2 Apr 2026 01:14:53 +0800 Subject: [PATCH 19/28] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E6=80=A7=E9=97=AE=E9=A2=98=E2=80=94=E2=80=94=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E8=AE=A1=E6=95=B0=E3=80=81=E6=96=87=E4=BB=B6=E9=94=81?= =?UTF-8?q?=E3=80=81=E8=BE=93=E5=85=A5=E6=A0=A1=E9=AA=8C=E3=80=81=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E5=93=88=E5=B8=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix: provider-pool-manager: activeProviderRefreshes 计数器修复,情况1(已有队列)不持有全局槽位,通过 ownsGlobalSlot 闭包变量精确控制递减时机,防止出现负值 - fix: provider-api: 为 handleAddProvider/handleUpdateProvider/handleDeleteProvider/handleDisableEnableProvider 添加模块级 Promise 链文件锁(withFileLock),防止并发读写同一 JSON 文件导致数据丢失 - fix: config-api: handleUpdateConfig 添加输入类型校验——SERVER_PORT 校验整数范围、REQUEST_MAX_RETRIES 校验数值范围、SYSTEM_PROMPT_FILE_PATH 禁止路径遍历(含..)、REQUIRED_API_KEY 限定字符串类型 - fix: config-api/auth: 密码改为 PBKDF2(SHA-512, 100000轮) 哈希存储,格式 pbkdf2:salt:hash,验证使用 timingSafeEqual 防时序攻击,兼容旧明文格式平滑迁移,并增加最小长度 8 位校验 - 所有修改均使用 Node.js 内置 crypto 模块,无新依赖 --- src/providers/provider-pool-manager.js | 24 ++++++++----- src/ui-modules/auth.js | 17 +++++++--- src/ui-modules/config-api.js | 47 +++++++++++++++++++------- src/ui-modules/provider-api.js | 19 +++++++++++ 4 files changed, 81 insertions(+), 26 deletions(-) diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index 5875f1f..348ddf6 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -283,6 +283,8 @@ export class ProviderPoolManager { } const queue = this.refreshQueues[providerType]; + // 记录此任务是否持有一个全局槽位(情况1追加的任务不持有) + let ownsGlobalSlot = false; const runTask = async () => { try { @@ -291,13 +293,13 @@ export class ProviderPoolManager { this._log('error', `Failed to process refresh for node ${uuid}: ${err.message}`); } finally { this.refreshingUuids.delete(uuid); - + // 再次获取当前队列引用 const currentQueue = this.refreshQueues[providerType]; if (!currentQueue) return; currentQueue.activeCount--; - + // 1. 尝试从当前提供商队列中取下一个任务 if (currentQueue.waitingTasks.length > 0) { const nextTask = currentQueue.waitingTasks.shift(); @@ -306,13 +308,14 @@ export class ProviderPoolManager { Promise.resolve().then(nextTask); } else if (currentQueue.activeCount === 0) { // 2. 如果当前提供商的所有任务都完成了,释放全局槽位 - // 只有在确定队列为空且没有新任务时才清理 - if (currentQueue.waitingTasks.length === 0 && + // 只有持有全局槽位的任务才能递减计数器,避免负值 + if (ownsGlobalSlot && + currentQueue.waitingTasks.length === 0 && this.refreshQueues[providerType] === currentQueue) { this.activeProviderRefreshes--; delete this.refreshQueues[providerType]; // 清理空队列 } - + // 3. 尝试启动下一个等待中的提供商队列 if (this.globalRefreshWaiters.length > 0) { const nextProviderStart = this.globalRefreshWaiters.shift(); @@ -333,15 +336,17 @@ export class ProviderPoolManager { // 检查全局并发限制(按提供商分组) // 情况1: 该提供商已经在运行,直接加入其队列(不占用新的全局槽位) - if (this.refreshQueues[providerType].activeCount > 0) { + const isExistingQueue = this.refreshQueues[providerType].activeCount > 0 || this.refreshQueues[providerType].waitingTasks.length > 0; + if (isExistingQueue) { tryStartProviderQueue(); } - // 情况2: 该提供商未运行,需要检查全局槽位 + // 情况2: 该提供商未运行,需要检查全局槽位,此路径持有全局槽位 else if (this.activeProviderRefreshes < this.refreshConcurrency.global) { + ownsGlobalSlot = true; this.activeProviderRefreshes++; tryStartProviderQueue(); } - // 情况3: 全局槽位已满,进入等待队列 + // 情况3: 全局槽位已满,进入等待队列,由等待回调负责标记持槽 else { this.globalRefreshWaiters.push(() => { // 重新获取最新的队列引用 @@ -351,7 +356,8 @@ export class ProviderPoolManager { waitingTasks: [] }; } - // 重要:从等待队列启动时需要增加全局计数 + // 从等待队列启动时持有全局槽位 + ownsGlobalSlot = true; this.activeProviderRefreshes++; tryStartProviderQueue(); }); diff --git a/src/ui-modules/auth.js b/src/ui-modules/auth.js index df3a6a6..219d3b0 100644 --- a/src/ui-modules/auth.js +++ b/src/ui-modules/auth.js @@ -48,10 +48,19 @@ export async function readPasswordFile() { */ export async function validateCredentials(password) { const storedPassword = await readPasswordFile(); - logger.info('[Auth] Validating password, stored password length:', storedPassword ? storedPassword.length : 0, ', input password length:', password ? password.length : 0); - const isValid = storedPassword && password === storedPassword; - logger.info('[Auth] Password validation result:', isValid); - return isValid; + if (!storedPassword || !password) return false; + + // 新格式:pbkdf2:salt:hash + if (storedPassword.startsWith('pbkdf2:')) { + const parts = storedPassword.split(':'); + if (parts.length !== 3) return false; + const [, salt, storedHash] = parts; + const inputHash = crypto.pbkdf2Sync(password.trim(), salt, 100000, 64, 'sha512').toString('hex'); + return crypto.timingSafeEqual(Buffer.from(inputHash, 'hex'), Buffer.from(storedHash, 'hex')); + } + + // 旧格式:明文(兼容迁移期,建议通过 UI 重新设置密码以升级为哈希格式) + return password === storedPassword; } /** diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js index f6f2b0e..729e685 100644 --- a/src/ui-modules/config-api.js +++ b/src/ui-modules/config-api.js @@ -2,6 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import logger from '../utils/logger.js'; import { promises as fs } from 'fs'; import path from 'path'; +import crypto from 'crypto'; import { CONFIG } from '../core/config-manager.js'; import { serviceInstances } from '../providers/adapter.js'; import { initApiService } from '../services/service-manager.js'; @@ -110,16 +111,30 @@ export async function handleUpdateConfig(req, res, currentConfig) { const body = await getRequestBody(req); const newConfig = body; - // Update config values in memory - if (newConfig.REQUIRED_API_KEY !== undefined) currentConfig.REQUIRED_API_KEY = newConfig.REQUIRED_API_KEY; - if (newConfig.HOST !== undefined) currentConfig.HOST = newConfig.HOST; - if (newConfig.SERVER_PORT !== undefined) currentConfig.SERVER_PORT = newConfig.SERVER_PORT; + // Update config values in memory(含类型校验) + if (newConfig.REQUIRED_API_KEY !== undefined) { + if (typeof newConfig.REQUIRED_API_KEY === 'string') 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; + } + if (newConfig.SERVER_PORT !== undefined) { + const port = Number(newConfig.SERVER_PORT); + if (Number.isInteger(port) && port > 0 && port < 65536) currentConfig.SERVER_PORT = port; + } if (newConfig.MODEL_PROVIDER !== undefined) currentConfig.MODEL_PROVIDER = newConfig.MODEL_PROVIDER; - if (newConfig.SYSTEM_PROMPT_FILE_PATH !== undefined) currentConfig.SYSTEM_PROMPT_FILE_PATH = newConfig.SYSTEM_PROMPT_FILE_PATH; + if (newConfig.SYSTEM_PROMPT_FILE_PATH !== undefined) { + const p = String(newConfig.SYSTEM_PROMPT_FILE_PATH); + // 防止路径遍历:只允许相对路径或限定目录 + if (!p.includes('..')) currentConfig.SYSTEM_PROMPT_FILE_PATH = p; + } if (newConfig.SYSTEM_PROMPT_MODE !== undefined) currentConfig.SYSTEM_PROMPT_MODE = newConfig.SYSTEM_PROMPT_MODE; if (newConfig.PROMPT_LOG_BASE_NAME !== undefined) currentConfig.PROMPT_LOG_BASE_NAME = newConfig.PROMPT_LOG_BASE_NAME; if (newConfig.PROMPT_LOG_MODE !== undefined) currentConfig.PROMPT_LOG_MODE = newConfig.PROMPT_LOG_MODE; - if (newConfig.REQUEST_MAX_RETRIES !== undefined) currentConfig.REQUEST_MAX_RETRIES = newConfig.REQUEST_MAX_RETRIES; + if (newConfig.REQUEST_MAX_RETRIES !== undefined) { + const v = Number(newConfig.REQUEST_MAX_RETRIES); + if (Number.isInteger(v) && v >= 0 && v <= 100) currentConfig.REQUEST_MAX_RETRIES = v; + } if (newConfig.REQUEST_BASE_DELAY !== undefined) currentConfig.REQUEST_BASE_DELAY = newConfig.REQUEST_BASE_DELAY; if (newConfig.CREDENTIAL_SWITCH_MAX_RETRIES !== undefined) currentConfig.CREDENTIAL_SWITCH_MAX_RETRIES = newConfig.CREDENTIAL_SWITCH_MAX_RETRIES; if (newConfig.CRON_NEAR_MINUTES !== undefined) currentConfig.CRON_NEAR_MINUTES = newConfig.CRON_NEAR_MINUTES; @@ -326,17 +341,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' } })); return true; } - // 写入密码到 pwd 文件 + if (password.trim().length < 8) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Password must be at least 8 characters' } })); + return true; + } + + // 使用 PBKDF2 哈希存储密码,避免明文写入文件 + const salt = crypto.randomBytes(16).toString('hex'); + const hash = crypto.pbkdf2Sync(password.trim(), salt, 100000, 64, 'sha512').toString('hex'); + const stored = `pbkdf2:${salt}:${hash}`; + const pwdFilePath = path.join(process.cwd(), 'configs', 'pwd'); - await fs.writeFile(pwdFilePath, password.trim(), 'utf-8'); + await fs.writeFile(pwdFilePath, stored, 'utf-8'); logger.info('[UI API] Admin password updated successfully'); diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index a2a02fc..8c7854d 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -6,6 +6,13 @@ import { generateUUID, createProviderConfig, formatSystemPath, detectProviderFro import { broadcastEvent } from './event-broadcast.js'; import { getRegisteredProviders } from '../providers/adapter.js'; +// 文件级互斥锁:防止并发读写导致数据丢失 +let _fileLockChain = Promise.resolve(); +function withFileLock(fn) { + const next = _fileLockChain.then(() => fn()); + _fileLockChain = next.catch(() => {}); + return next; +} /** * 获取提供商池摘要 */ @@ -93,6 +100,9 @@ export async function handleGetProviderTypeModels(req, res, providerType) { * 添加新的提供商配置 */ export async function handleAddProvider(req, res, currentConfig, providerPoolManager) { + return withFileLock(() => _handleAddProvider(req, res, currentConfig, providerPoolManager)); +} +async function _handleAddProvider(req, res, currentConfig, providerPoolManager) { try { const body = await getRequestBody(req); const { providerType, providerConfig } = body; @@ -180,6 +190,9 @@ export async function handleAddProvider(req, res, currentConfig, providerPoolMan * 更新特定提供商配置 */ export async function handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { + return withFileLock(() => _handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid)); +} +async function _handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { try { const body = await getRequestBody(req); const { providerConfig } = body; @@ -266,6 +279,9 @@ export async function handleUpdateProvider(req, res, currentConfig, providerPool * 删除特定提供商配置 */ export async function handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { + return withFileLock(() => _handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid)); +} +async function _handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { try { const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; let providerPools = {}; @@ -337,6 +353,9 @@ export async function handleDeleteProvider(req, res, currentConfig, providerPool * 禁用/启用特定提供商配置 */ export async function handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action) { + return withFileLock(() => _handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action)); +} +async function _handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action) { try { const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; let providerPools = {}; From 9d4864dfed33f35f0938b3ff8d2db977e19b7407 Mon Sep 17 00:00:00 2001 From: Wenaixi Date: Thu, 2 Apr 2026 23:27:10 +0800 Subject: [PATCH 20/28] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=AE=A1=E6=9F=A5=E5=8F=91=E7=8E=B0=E7=9A=845?= =?UTF-8?q?=E4=B8=AA=E5=AE=89=E5=85=A8=E4=B8=8E=E6=AD=A3=E7=A1=AE=E6=80=A7?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pbkdf2Sync 改为异步避免阻塞事件循环 (auth.js, config-api.js) - 路径遍历检查改用 path.resolve 验证绝对路径在 cwd 内 (config-api.js) - _activeInterval 移出配置对象避免序列化到 JSON (config-api.js, api-server.js) - 删除 performScheduledHealthChecks 中冗余的 isDisabled 二次检查 (provider-pool-manager.js) --- src/providers/provider-pool-manager.js | 5 ----- src/services/api-server.js | 5 +++-- src/ui-modules/auth.js | 6 +++++- src/ui-modules/config-api.js | 22 ++++++++++++++-------- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index 348ddf6..c3dc2ab 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -1834,11 +1834,6 @@ export class ProviderPoolManager { let failCount = 0; for (const { providerType, provider, uuid, customName } of providersToCheck) { - // Skip if provider became disabled during iteration - if (provider.config.isDisabled === true) { - continue; - } - const providerCheckStart = Date.now(); const checkModelName = provider.config.checkModelName || ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType] || 'unknown'; const displayName = customName || uuid.substring(0, 8); diff --git a/src/services/api-server.js b/src/services/api-server.js index c1a850f..4bc1b0c 100644 --- a/src/services/api-server.js +++ b/src/services/api-server.js @@ -420,9 +420,10 @@ async function startServer() { // 设置定时任务 runHealthCheckTimer(interval); - - // 导出重载函数供外部调用 + + // 注册重载函数和初始 interval 到 globalThis(供 config-api 热更新使用) globalThis.reloadHealthCheckTimer = runHealthCheckTimer; + globalThis._activeHealthCheckInterval = interval; } // 如果是子进程,通知主进程已就绪 diff --git a/src/ui-modules/auth.js b/src/ui-modules/auth.js index 219d3b0..534a399 100644 --- a/src/ui-modules/auth.js +++ b/src/ui-modules/auth.js @@ -55,7 +55,11 @@ export async function validateCredentials(password) { const parts = storedPassword.split(':'); if (parts.length !== 3) return false; const [, salt, storedHash] = parts; - const inputHash = crypto.pbkdf2Sync(password.trim(), salt, 100000, 64, 'sha512').toString('hex'); + const inputHash = await new Promise((resolve, reject) => + crypto.pbkdf2(password.trim(), salt, 100000, 64, 'sha512', (err, key) => + err ? reject(err) : resolve(key.toString('hex')) + ) + ); return crypto.timingSafeEqual(Buffer.from(inputHash, 'hex'), Buffer.from(storedHash, 'hex')); } diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js index 729e685..fd8e908 100644 --- a/src/ui-modules/config-api.js +++ b/src/ui-modules/config-api.js @@ -125,8 +125,11 @@ export async function handleUpdateConfig(req, res, currentConfig) { if (newConfig.MODEL_PROVIDER !== undefined) currentConfig.MODEL_PROVIDER = newConfig.MODEL_PROVIDER; if (newConfig.SYSTEM_PROMPT_FILE_PATH !== undefined) { const p = String(newConfig.SYSTEM_PROMPT_FILE_PATH); - // 防止路径遍历:只允许相对路径或限定目录 - if (!p.includes('..')) currentConfig.SYSTEM_PROMPT_FILE_PATH = p; + // 防止路径遍历:解析后的绝对路径必须在工作目录内 + const resolved = path.resolve(process.cwd(), p); + if (resolved.startsWith(process.cwd() + path.sep) || resolved === process.cwd()) { + currentConfig.SYSTEM_PROMPT_FILE_PATH = p; + } } if (newConfig.SYSTEM_PROMPT_MODE !== undefined) currentConfig.SYSTEM_PROMPT_MODE = newConfig.SYSTEM_PROMPT_MODE; if (newConfig.PROMPT_LOG_BASE_NAME !== undefined) currentConfig.PROMPT_LOG_BASE_NAME = newConfig.PROMPT_LOG_BASE_NAME; @@ -180,11 +183,10 @@ export async function handleUpdateConfig(req, res, currentConfig) { interval: newInterval, providerTypes: Array.isArray(incoming?.providerTypes) ? incoming.providerTypes : [] }; - - // 如果定时器已存在且 enabled,仅在 interval 实际变化时重新加载 timer - const previousInterval = currentConfig.SCHEDULED_HEALTH_CHECK._activeInterval; - if (globalThis.reloadHealthCheckTimer && currentConfig.SCHEDULED_HEALTH_CHECK.enabled && newInterval !== previousInterval) { - currentConfig.SCHEDULED_HEALTH_CHECK._activeInterval = newInterval; + + // 仅在 interval 实际变化时重新加载 timer(_activeInterval 存在内存变量中,不写入配置文件) + if (globalThis.reloadHealthCheckTimer && currentConfig.SCHEDULED_HEALTH_CHECK.enabled && newInterval !== globalThis._activeHealthCheckInterval) { + globalThis._activeHealthCheckInterval = newInterval; globalThis.reloadHealthCheckTimer(newInterval); } } @@ -353,7 +355,11 @@ export async function handleUpdateAdminPassword(req, res) { // 使用 PBKDF2 哈希存储密码,避免明文写入文件 const salt = crypto.randomBytes(16).toString('hex'); - const hash = crypto.pbkdf2Sync(password.trim(), salt, 100000, 64, 'sha512').toString('hex'); + const hash = await new Promise((resolve, reject) => + crypto.pbkdf2(password.trim(), salt, 100000, 64, 'sha512', (err, key) => + err ? reject(err) : resolve(key.toString('hex')) + ) + ); const stored = `pbkdf2:${salt}:${hash}`; const pwdFilePath = path.join(process.cwd(), 'configs', 'pwd'); From 31708f97c43cb55ca97506f23dff8a87cee1f754 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Wed, 1 Apr 2026 20:40:25 +0800 Subject: [PATCH 21/28] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=20AICodeMirror?= =?UTF-8?q?=20=E8=B5=9E=E5=8A=A9=E5=95=86=E4=BF=A1=E6=81=AF=E4=B8=8E?= =?UTF-8?q?=E8=B6=8B=E5=8A=BF=E5=BE=BD=E7=AB=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 README 中新增 AICodeMirror 赞助商区块,包含介绍、图片与注册链接 - 为所有语言版本的 README 添加 Trendshift 仓库趋势徽章 - 新增赞助商 logo 图片文件 static/aicodemirror.jpg --- README-JA.md | 12 ++++++++++++ README-ZH.md | 11 +++++++++++ README.md | 12 ++++++++++++ static/aicodemirror.jpg | Bin 0 -> 6245 bytes 4 files changed, 35 insertions(+) create mode 100644 static/aicodemirror.jpg diff --git a/README-JA.md b/README-JA.md index ff5eec3..25353f4 100644 --- a/README-JA.md +++ b/README-JA.md @@ -6,6 +6,8 @@ **複数のクライアント専用大規模言語モデルAPI(Gemini CLI、Antigravity、Qwen Code、Kiro ...)を模擬リクエストし、ローカルのOpenAI互換インターフェースに統一的にラッピングする強力なプロキシ。** +justlovemaki%2FAIClient-2-API | Trendshift +
@@ -35,6 +37,16 @@ PackyCode は信頼性が高く効率的な API リレーサービスプロバイダーであり、Claude Code、Codex、Gemini などのリレーサービス提供しています。PackyCode は当ソフトウェアユーザーに特别割引を提供しています:このリンクから登録し、チャージ時に AIClient2API プロモーションコードを入力すると 10% オフになります。 + + + + AICodeMirror Sponsor + + + + AICodeMirror の本プロジェクトへのスポンサーシップに感謝します!AICodeMirror は、Claude Code / Codex / Gemini CLI 向けに公式の高安定性リレーサービスを提供しており、企業レベルの同時実行性、迅速な請求書発行、24時間365日の専用技術サポートを備えています。Claude Code / Codex / Gemini の公式チャンネルを、元の価格の 38% / 2% / 9% で利用でき、チャージ時にはさらなる割引もあります!AICodeMirror は AIClient-2-API ユーザーに特別な特典を提供しています:このリンクから登録すると、初回チャージが 20% オフになり、法人のお客様は最大 25% オフになります! + + Sponsor Contact diff --git a/README-ZH.md b/README-ZH.md index 454b452..56b3d6e 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -6,6 +6,7 @@ **一个能将多种仅客户端内使用的大模型 API(Gemini CLI, Antigravity, Qwen Code, Kiro ...),模拟请求,统一封装为本地 OpenAI 兼容接口的强大代理。** +justlovemaki%2FAIClient-2-API | Trendshift
@@ -35,6 +36,16 @@ PackyCode 是一家可靠且高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等模型的中转服务。PackyCode 为本软件用户提供特别优惠:通过此链接注册并在充值时输入 AIClient2API 优惠码,即可享受 10% 的折扣。 + + + + AICodeMirror Sponsor + + + + 感谢 AICodeMirror 赞助本项目!AICodeMirror 为 Claude Code / Codex / Gemini CLI 提供官方高稳定性中转服务,具备企业级并发能力、快速开票和 7/24 专属技术支持。Claude Code / Codex / Gemini 官方渠道价格仅为原价的 38% / 2% / 9%,充值还有额外优惠!AICodeMirror 为 AIClient-2-API 用户提供专属福利:通过此链接注册即可享受首充 8折(20% off) 优惠,企业客户最高可享 75折(25% off)! + + Sponsor Contact diff --git a/README.md b/README.md index 34d4040..9bc84ed 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ **A powerful proxy that can unify the requests of various client-only large model APIs (Gemini CLI, Antigravity, Qwen Code, Kiro ...), simulate requests, and encapsulate them into a local OpenAI-compatible interface.** +justlovemaki%2FAIClient-2-API | Trendshift +
@@ -35,6 +37,16 @@ PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using this link and enter the AIClient2API promo code during recharge to get 10% off. + + + + AICodeMirror Sponsor + + + + Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for AIClient-2-API users: register via this link to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off! + + Sponsor Contact diff --git a/static/aicodemirror.jpg b/static/aicodemirror.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b11ee0b4cd9cdb107dae299aa77359a7c24bd3e9 GIT binary patch literal 6245 zcmcJTcT`i&o5wE%M4A*sFL`N#B25e+B@v~Fh>@azlqkg(5s@k#5fG3dT~X;BAtGH6 zq=usKQbQ3*5ik@n;5KhiNnVq=UnR`DsHy0N-9}myI{Q`Uf0{ndZ{DKEWg#{0Y9N^~{ z78e#d2o)0(6A+S+6o*QRLdBqe8sUJjzv1HM<>uyv3i1m=|Hp~-9uV0N+~sh9a3}(t zA{-DA4puJ!0{{+gwzYo@{`bVe$+mGH&wgG$es+aMVStkZ0^#I>{IQx{JAz#faEWk> zD(IN*J7{~AN6`;@EGDgRztX9?E-||?nzF9Py$8H};u4Zl(kh2k)eavy4%gE+IAM6& z%-q8AjMZ6thl`gSot!UwdU;>Fe&gmX|A4@t;E?;Fu@B?o6CNcdrDtSjW#>G7mRnR@ zQd(ACQCan-zM-+Hxux}OcTaC$KYn0vXnbOFYML-JJ4Yf@zAP=Te5J0^x4!@Q$@m3s z|Ng_p0YLr@>p#f;flGwV#mU75;o|wj#lacO7DR-LTR~@^sHrW_RlkFZ$71$FPo)*s zb@3|c+R?;3?v3$@D<3DR(Ep(QE7^Y!?7{zv>_5Q%lWPtTfN-!U47#G>>>~=LIYP)%W%*nSUei-yUId(-ToFMl!_XO-}c@tHhq9 z#l4L^UZfjwM0~Dw`?Fgud}U^vr4wTDMs&2KQizv6eOL z64)GcxhP4!fyAd)6#ah^Mqr{+=q%*#lz2<{)~^j1$#SgS#izzaAxV8oDjlIW^zQq- z5-bf@5ghOeDHU?I2QMu!=e>1Jf5|oC>pYJD(r`=H_#po30-UblSh?|KJC$)q6JCOo zD`|g*-{qyRcj(e^St0X{{D-!@RjY+a$eb;ljfth$mKY3odJ+r3=h&3r?04}|51M=&v7PD> zRkXu&UW6hJqr)vH8MWZ?nVCl#`>gJCJ(oj)gu-2MP(so5@*4B6$HfYwb935X=&z#z@=;zkCAdp6oyE!~{e^iWN5Gvd=;ZV?kezyHtUCdGH^2N2bK^AaH zx#UppW~2Ya=T5CopQ!l>GU`ExF(wiU`c$ekrIFU0#+l|#&MY7zZCOXPz-~3+lcSg7 z@;dhLaGL9$Sy6iq0}XcP%Q5$bIQAQLwY_WP4^8eL&Q$T1-1rUwJ=*=5d`3H_;3y*$ z3?$XKbOqNreRjX4;cGO!KNHIPL_3125>{kXAPnh>Hu{oWD*<-LR&Mvw$9CJu_$TdCBEE9{Y6oG42sA>{fVM z?CcrU-|&6ktkW&2wBwE2eiUEn$hpP(L(f?NuWu4u-B%*y-luB!!)LBxh0nnBGsPzT zf4x1RzJZ%XGG2@!Z^04{lLZmFo>eZ$*`FHlc?}vQ zNeLDey2p9yjMR^Em0=HuTKxFIB$@Kk@RuxrTLRo9Z$`s-15ARunkzjkKCKR8)CYy1 zNPakvkNsA_gi<6_hvMgw+7NZ4rH5?7XLkf5Clhzhg{xsHcDZnRWLC1zWiGeE=b*Rk zjDZy6gk1F$!Z7^GID$J|ixBSpYM|(IH|N+X7eV9lM-=qEncZVywnLMxPMH~wmXXq# zvV`~fwDb9M!(^ciIZD3lJouI=lgzJv<66T5$y58EEp$oAS#V-8a~%M4LS7xj(d+T< z0@KDk!K6LaQ%8M%p0H9F$hhG+0Eol1yG#TRXc|384})3Rnv1D$Lv2s~Q^B#^hEI~X z`em;}Q=Vw6U5$>wM1xuM>nIv`*~jipd%vA(Z{s~w!7B`*{FLlZxwNr_p2@z84iy#~ zxv+zQZbCM+thu!hDL#r~PFVgnVZVk@LjQ-E6jI8UofAIYdd87TW%uFlsxi4NpbsW$ zR_ngSxIQbOv*ICi^>4s1_vc4nh94HjC)^&>uZuQ*y;Fn$y#K4WzjdTR7+6(fl z{rnc$mi0sz^djIL{>UJ}M|zifqvp=^_OAU$uF${+n0fb&b4O;4zKUx|7dA#a{oXqQm;U@0>g zT}l6hitV%O-ScQ@B_ZQ}{D5nlsLk=*x{MJ>`W;I-U`bx-O!up+U}K@;gf>>@z|CF-E+oMyXM{mo0i4jwq2e(nCg&{TNwt_S?< zBFzz%yipVmr?J;ivXs@N@#%Y-wMD7fdh{1Z(wGx@CgbhTN!$q=FKJ;U<}*?%Ux?l~ z!K_hG`7pfw_I6rwIzB`>9kt@pZTz}*RQ1DpP(IBHm#8l>hwT2i12zQ@ z8*M_DSiL!1v+OB50w~`N)-!RWqTQpq(6x5oiBS)(Cw3*-IJ^}Zi%C|j>7NqG&7#)J zFe>SBD{)BTQ?%giS6_74t!9TyQFLR@z2-}Q>wOUoMh8#$(YTHz z42sEXO>aI!hrRzv6Zn;8N){6O^fB4-3ht<=k>T6eNzM=Lp$;ZjhAvyV?u5CH-EKE8 zcBh6uwQ5PO9FS|Mha7bgn_(nVjGuD3(JA;;o12Gy{NQt27Ub5(eL zm#2f5>bBC?nG~wk)d^ta)d{uarZ=x+O8J%Ijw}9iBNpmoQN+~WpP%{Dw0c9$>=ajn<|fl!Po9^pwv@c2RBM2rlwLv->wYs| zs>XN7gOMl1-2!XBX5Tn{$N?NxCHoy7HQ+Mt#XHPT2K(`V%(AB&m zB&WCT+k`Ya*PwWg_h}4aQw`Gk+c71Cha|D_fGOV$K4Jl)H7<@V$s3iV6w9OOg)$Fs zbH-UHJ@)c!6(cV;Urz|s;?ZBH1lC%RyL)Uea=zs* z^Jdbg9E*|CNWZakNrumdOWQ^OBVCC@6iN2Y)9i1 zc@_VnOUc#D`Th@O!mC}Iy%NOX$ccOy9Q81t>!-o4e(~22)T)rjwbr+NYKLYFTxkUY zd5tURx;yor%l1Mamp?8uc9+JS+ zid{YWaqcpJ$y6n^t(1lwqJ4MrG4=#K?((OLSNJGoe=n9Zl)5N9{Kd$|2Is zC7(^DEGw@&ZKi*Td2sv{i#<`B!Q{&!&%@**huk4_@#kvK&rkle4r_s#c!mXEi9&am zfJ-~#BgV1{VCBwvbQfvxj>N6CpgLs@H6C9LwJ(AT>e+jp#tLO1KLZN772Nne6q0BPG^KR1+8>s7Xc`dJwrnjDT1=K1KT53mv1iE;1z*zU$=T`YLJ3UW2_O55&8}IA zFQG#T3#d(rd8O&LQLMa1K4WjM{0A%^A=ai36Kp>|99F$JUpIS#shfuPliqXsfvffg zHA;3P_pt3Jh#SrqBWDYo8*9eg`savdrOI-kv24(xmpf9-!QWBqbu{MMx%Z&c~z^}lqgJl zRDT{=q5gI(XAUBz+tCyjFlS9hCj{06`J6s8-WxwRZGJ(qx6eYGgNlbQ!)P`}3BGgo zl=1A5oh;9v5(oK$cMpCee%wLt`q+0lA7TOH@l|;>O(H>ONZ}`TE;0W`XoK^V&4do2 zwj9@~>uuTdsGG%)&CATv<;F7}-_@&Fz#|%0kqyXXre5U7&bt0Ie;oLZ1+bmj-cv>? z=cm%${(d{u#UqL5Gdz&>kp*;_q@gx4=%q<1J)>QCBz+4ri6qv{!0PX0%uDZeGLv$j zH#gXdEh=4Cq51Z8Hsoxm_!D8!o6VG-MWKN0qkR*f-YWlYd5oL$Qj$`5blVg1A`rTm zxY)4R%VtMW$_i;Y#P~w-gfi;m!HSTFq-3tYjN(^#TZmV@e&VV-z1wVI>wUCk10W?e zp+=7w;K}!Wo6yrqvtFA=^D+O95;xX=<ZgsrhapQo2a-~u9dq`?`r&Uc>y1~>;J(rZ~yO8Va z8UlAC+3xiLarj01(qiFaDb@5S!(VA#?_ZVttb29*QG>0yX@RXd6!;^1Wdq1`zLWe3 zx=nLzIQ)Lqr?e)hip0+5t~!{T_|ZN&v&p<$v{GfidehZ#6qx2sG~u(npVi3wB>9G6 zwy*tHp3B<_-I3x1gnCH)wUoT;!HfP8`s$L1JzYYK+h`m@4AUvzC{CA1)Kq!%rUl_4 z5Wb#Ja3S(5t}tAS^isb&)KyQ~`;E¥K`IQSuAEg>tq$*8QH| zffXJxxK`zuy+ws7d$Ao1pAwJ=8Z)Rpop=a@hn40mRW+S{z^YmEwJV*eIfV)-swCp)gF`6nP1| zkXe==w4>K?G6`*)7tI3VA}$Rb@c$UH*~eb6cSCYN``DXZY?>ly4!k&uuZ_s<2$stk z5xZoxuLl11TWaml_sfBIH#I)M%a{_ouNd4$j3-vmg1Z(d_vLm9(KlJk*p!s;X6j)Y zMjEr17x45d4M9aFMBVyPx`sBjsH(AXz~&%OBBrG3vzae9_99jLVG``UHNu}_xnWzf zYeVNryq(_@>c!}ngIX;0YLl{!)3C1M$W7@T9Wl=fRqWu=J|p`9DXR5*IrYlHM$hNj z_z(F_g!PclM&ATu6T$3KpCXK**|)(!WA7|d;C9B6)M}MwIeaMGe=mi->*EsHR1Z$({e*aw|OKNTB3P>eT+yY$mz~KxfjX@oiQkTi%E2caNmxC(BDkGLlSA?zwv&jIEec?L#HMx%K55^H=WZoUUUlo5l&?v(`bxyXsaxp?8|x zCv560cyEV`(fOIY!?mD4{?})1P=Z1=<_-?sI64=wPUapQ5zCaV^^d#ax8}$cj_NXM zl#Enz2|o!AQ}n=CvW%m%#>NOE#!0r;nxi}Y%mv_M#0Mzkh5woa-W_`Vx!3XsT0zoLq~M4r z#^q4Xh9J-Gcc2z3(hs?Et@HFrNowD|+~;TClb^TJo7X3HID}@0Da=MnguZ(`r$HwsG4O~q>^j*=JDZSIayr+pNh@A}7os)EV_ zXQU8~8=Xx&>y>^x7Q2W4-W4mjrROe_VBL--BMlWjMM`cbMXOO{s;P>)R&hU|t)*dw zTh&g>9Wo*AD6SCxo(@><#g-iF2~z#d3wVW_D#HcA3Cg0(0*3K6 z6QO)xN~16(i@b0+(n`_?LoL@lS!9{x*d@_K3@)g`kv>HcE1adGl4LByG*ygTkS6)_K&jwVqTsJm_<&} zvIQ6Er%)2qTuef;P5o!_bF!qwi~M;fAga^slbG>xFPA{cd0){X`_(b%vux9Yu;7@v zV`F&PTSL$zdzMS?w3GO=rZJXAcSu7uo}(Qc;kx<6&2FRssJBelS{hrQp83>Ydg+uA zkx&T9J+~aK!=8d!E2lvAEx9{Mt$_2%I6E!?SnjmTO0TqA6W6rFMm^@R)QuYDV>Ok? zw&3*-M#Ssb7X-5t1uq Date: Wed, 1 Apr 2026 22:25:43 +0800 Subject: [PATCH 22/28] =?UTF-8?q?fix(grok):=20=E4=BF=AE=E5=A4=8D=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E5=93=8D=E5=BA=94=E4=B8=AD=E5=9B=BE=E7=89=87=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E5=92=8C=E6=80=9D=E8=80=83=E5=9D=97=E5=A4=84=E7=90=86?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复流式响应中图片 URL 被截断的问题,通过缓冲区累积完整 URL - 改进卡片附件处理,支持从 cardAttachmentsJson 解析并渲染图片 - 优化思考块逻辑,避免在正式内容开始后显示无意义的内部注释 - 修复思考块未正确关闭的问题,确保格式完整性 - 更新文档中的模型列表,将 Qwen Code 替换为 Codex --- README-JA.md | 6 +- README-ZH.md | 6 +- README.md | 6 +- VERSION | 2 +- src/converters/strategies/GrokConverter.js | 86 ++++++++++++++++++++-- src/providers/grok/grok-core.js | 52 ++++++++++++- 6 files changed, 140 insertions(+), 18 deletions(-) diff --git a/README-JA.md b/README-JA.md index 25353f4..227c362 100644 --- a/README-JA.md +++ b/README-JA.md @@ -4,7 +4,7 @@ # AIClient-2-API 🚀 -**複数のクライアント専用大規模言語モデルAPI(Gemini CLI、Antigravity、Qwen Code、Kiro ...)を模擬リクエストし、ローカルのOpenAI互換インターフェースに統一的にラッピングする強力なプロキシ。** +**複数のクライアント専用大規模言語モデルAPI(Gemini CLI、Antigravity、Codex, Grok、Kiro ...)を模擬リクエストし、ローカルのOpenAI互換インターフェースに統一的にラッピングする強力なプロキシ。** justlovemaki%2FAIClient-2-API | Trendshift
@@ -62,7 +62,7 @@ ## 🚀 概要 -`AIClient2API` はクライアント制限を突破するAPIプロキシサービスで、Gemini、Antigravity、Qwen Code、Kiroなど、元々クライアント内でのみ使用可能な無料大規模モデルを、あらゆるアプリケーションから呼び出せる標準OpenAI互換インターフェースに変換します。Node.jsをベースに構築され、OpenAI、Claude、Geminiの3大プロトコル間のインテリジェント変換をサポートし、Cherry-Studio、NextChat、Clineなどのツールで、Claude Opus 4.5、Gemini 3.0 Pro、Qwen3 Coder Plusなどの高度なモデルを大規模に無料で使用できるようにします。プロジェクトはストラテジーパターンとアダプターパターンに基づくモジュラーアーキテクチャを採用し、アカウントプール管理、インテリジェントポーリング、自動フェイルオーバー、ヘルスチェック機構を内蔵し、99.9%のサービス可用性を保証します。 +`AIClient2API` はクライアント制限を突破するAPIプロキシサービスで、Gemini、Antigravity、Codex, Grok、Kiroなど、元々クライアント内でのみ使用可能な無料大規模モデルを、あらゆるアプリケーションから呼び出せる標準OpenAI互換インターフェースに変換します。Node.jsをベースに構築され、OpenAI、Claude、Geminiの3大プロトコル間のインテリジェント変換をサポートし、Cherry-Studio、NextChat、Clineなどのツールで、Claude Opus 4.5、Gemini 3.0 Pro、Qwen3 Coder Plusなどの高度なモデルを大規模に無料で使用できるようにします。プロジェクトはストラテジーパターンとアダプターパターンに基づくモジュラーアーキテクチャを採用し、アカウントプール管理、インテリジェントポーリング、自動フェイルオーバー、ヘルスチェック機構を内蔵し、99.9%のサービス可用性を保証します。 > [!NOTE] > **🎉 重要なマイルストーン** @@ -100,7 +100,7 @@ ## 💡 コアアドバンテージ ### 🎯 統一アクセス、ワンストップ管理 -* **マルチモデル統一インターフェース**:標準OpenAI互換プロトコルを通じて、一度の設定でGemini、Claude、Grok、Qwen Code、Kimi K2、MiniMax M2などの主流大規模モデルにアクセス +* **マルチモデル統一インターフェース**:標準OpenAI互換プロトコルを通じて、一度の設定でGemini、Claude、Grok、Codex、 K2、MiniMax M2などの主流大規模モデルにアクセス * **柔軟な切り替えメカニズム**:Pathルーティング、起動パラメータ、環境変数の3つの方法で動的にモデルを切り替え、異なるシナリオのニーズに対応 * **ゼロコスト移行**:OpenAI API仕様と完全互換、Cherry-Studio、NextChat、Clineなどのツールを変更なしで使用可能 * **マルチプロトコルインテリジェント変換**:OpenAI、Claude、Geminiの3大プロトコル間のインテリジェント変換をサポートし、クロスプロトコルモデル呼び出しを実現 diff --git a/README-ZH.md b/README-ZH.md index 56b3d6e..e7e2ddf 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -4,7 +4,7 @@ # AIClient-2-API 🚀 -**一个能将多种仅客户端内使用的大模型 API(Gemini CLI, Antigravity, Qwen Code, Kiro ...),模拟请求,统一封装为本地 OpenAI 兼容接口的强大代理。** +**一个能将多种仅客户端内使用的大模型 API(Gemini CLI, Antigravity, Codex, Grok, Kiro ...),模拟请求,统一封装为本地 OpenAI 兼容接口的强大代理。** justlovemaki%2FAIClient-2-API | Trendshift @@ -61,7 +61,7 @@ ## 🚀 项目概览 -`AIClient2API` 是一个突破客户端限制的 API 代理服务,将 Gemini、Antigravity、Qwen Code、Kiro 等原本仅限客户端内使用的免费大模型,转换为可供任何应用调用的标准 OpenAI 兼容接口。基于 Node.js 构建,支持 OpenAI、Claude、Gemini 三大协议的智能互转,让 Cherry-Studio、NextChat、Cline 等工具能够免费大量使用 Claude Opus 4.5、Gemini 3.0 Pro、Qwen3 Coder Plus 等高级模型。项目采用策略模式和适配器模式的模块化架构,内置账号池管理、智能轮询、自动故障转移和健康检查机制,确保 99.9% 的服务可用性。 +`AIClient2API` 是一个突破客户端限制的 API 代理服务,将 Gemini、Antigravity、Codex, Grok、Kiro 等原本仅限客户端内使用的免费大模型,转换为可供任何应用调用的标准 OpenAI 兼容接口。基于 Node.js 构建,支持 OpenAI、Claude、Gemini 三大协议的智能互转,让 Cherry-Studio、NextChat、Cline 等工具能够免费大量使用 Claude Opus 4.5、Gemini 3.0 Pro、Qwen3 Coder Plus 等高级模型。项目采用策略模式和适配器模式的模块化架构,内置账号池管理、智能轮询、自动故障转移和健康检查机制,确保 99.9% 的服务可用性。 > [!NOTE] > **🎉 重要里程碑** @@ -98,7 +98,7 @@ ## 💡 核心优势 ### 🎯 统一接入,一站式管理 -* **多模型统一接口**:通过标准 OpenAI 兼容协议,一次配置即可接入 Gemini、Claude、Grok、Qwen Code、Kimi K2、MiniMax M2 等主流大模型 +* **多模型统一接口**:通过标准 OpenAI 兼容协议,一次配置即可接入 Gemini、Claude、Grok、Codex、Kimi K2、MiniMax M2 等主流大模型 * **灵活切换机制**:Path 路由、支持通过启动参数、环境变量三种方式动态切换模型,满足不同场景需求 * **零成本迁移**:完全兼容 OpenAI API 规范,Cherry-Studio、NextChat、Cline 等工具无需修改即可使用 * **多协议智能转换**:支持 OpenAI、Claude、Gemini 三大协议间的智能转换,实现跨协议模型调用 diff --git a/README.md b/README.md index 9bc84ed..8c0895e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # AIClient-2-API 🚀 -**A powerful proxy that can unify the requests of various client-only large model APIs (Gemini CLI, Antigravity, Qwen Code, Kiro ...), simulate requests, and encapsulate them into a local OpenAI-compatible interface.** +**A powerful proxy that can unify the requests of various client-only large model APIs (Gemini CLI, Antigravity, Codex, Grok, Kiro ...), simulate requests, and encapsulate them into a local OpenAI-compatible interface.** justlovemaki%2FAIClient-2-API | Trendshift @@ -62,7 +62,7 @@ ## 🚀 Overview -`AIClient2API` is an API proxy service that breaks through client limitations, converting free large models originally restricted to client use only (such as Gemini, Antigravity, Qwen Code, Kiro) into standard OpenAI-compatible interfaces that can be called by any application. Built on Node.js, it supports intelligent conversion between OpenAI, Claude, and Gemini protocols, enabling tools like Cherry-Studio, NextChat, and Cline to freely use advanced models such as Claude Opus 4.5, Gemini 3.0 Pro, and Qwen3 Coder Plus at scale. The project adopts a modular architecture based on strategy and adapter patterns, with built-in account pool management, intelligent polling, automatic failover, and health check mechanisms, ensuring 99.9% service availability. +`AIClient2API` is an API proxy service that breaks through client limitations, converting free large models originally restricted to client use only (such as Gemini, Antigravity, Codex, Grok, Kiro) into standard OpenAI-compatible interfaces that can be called by any application. Built on Node.js, it supports intelligent conversion between OpenAI, Claude, and Gemini protocols, enabling tools like Cherry-Studio, NextChat, and Cline to freely use advanced models such as Claude Opus 4.5, Gemini 3.0 Pro, and Qwen3 Coder Plus at scale. The project adopts a modular architecture based on strategy and adapter patterns, with built-in account pool management, intelligent polling, automatic failover, and health check mechanisms, ensuring 99.9% service availability. > [!NOTE] > **🎉 Important Milestone** @@ -100,7 +100,7 @@ ## 💡 Core Advantages ### 🎯 Unified Access, One-Stop Management -* **Multi-Model Unified Interface**: Through standard OpenAI-compatible protocol, configure once to access mainstream large models including Gemini, Claude, Grok, Qwen Code, Kimi K2, MiniMax M2 +* **Multi-Model Unified Interface**: Through standard OpenAI-compatible protocol, configure once to access mainstream large models including Gemini, Claude, Grok, Codex, Kimi K2, MiniMax M2 * **Flexible Switching Mechanism**: Path routing, support dynamic model switching via startup parameters or environment variables to meet different scenario requirements * **Zero-Cost Migration**: Fully compatible with OpenAI API specifications, tools like Cherry-Studio, NextChat, Cline can be used without modification * **Multi-Protocol Intelligent Conversion**: Support intelligent conversion between OpenAI, Claude, and Gemini protocols for cross-protocol model invocation diff --git a/VERSION b/VERSION index 371a952..500b629 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.12.2 +2.12.2.1 diff --git a/src/converters/strategies/GrokConverter.js b/src/converters/strategies/GrokConverter.js index 7fb48f4..38ebfb5 100644 --- a/src/converters/strategies/GrokConverter.js +++ b/src/converters/strategies/GrokConverter.js @@ -104,6 +104,7 @@ export class GrokConverter extends BaseConverter { has_tool_call: false, rollout_id: "", in_tool_call: false, // 是否处于 块内 + content_started: false, // 是否已经开始输出正式内容 requestBaseUrl: "", uuid: null, pending_text_buffer: "" // 用于处理流式输出中被截断的 URL @@ -352,6 +353,17 @@ export class GrokConverter extends BaseConverter { } continue; } + if (key === "cardAttachmentsJson" && Array.isArray(item)) { + item.forEach(jsonStr => { + if (typeof jsonStr !== 'string') return; + try { + const card = JSON.parse(jsonStr); + const url = card.image?.original; + if (url) add(url); + } catch (e) {} + }); + continue; + } walk(item); } } @@ -484,13 +496,55 @@ export class GrokConverter extends BaseConverter { content = this._filterToken(content, responseId); content = this._processGrokAssetsInText(content, state); - // 收集图片并追加 + // 处理 cardAttachmentsJson 中的图片,将其映射到卡片 ID + const cardMap = new Map(); + const modelResponse = grokResponse.modelResponse || {}; + + // 收集所有的卡片原始数据(可能是 cardAttachmentsJson 中的,或者是单独收集的 cardAttachments 数组) + const allCardSources = []; + if (Array.isArray(modelResponse.cardAttachmentsJson)) allCardSources.push(...modelResponse.cardAttachmentsJson); + if (Array.isArray(grokResponse.cardAttachments)) { + grokResponse.cardAttachments.forEach(card => card.jsonData && allCardSources.push(card.jsonData)); + } else if (grokResponse.cardAttachment?.jsonData) { + allCardSources.push(grokResponse.cardAttachment.jsonData); + } + + for (const raw of allCardSources) { + try { + const cardData = JSON.parse(raw); + const cardId = cardData.id; + const image = cardData.image || {}; + const original = image.original; + const title = image.title || "image"; + if (cardId && original) { + cardMap.set(cardId, { title, original }); + } + } catch (e) {} + } + + // 替换正文中的 标签为 Markdown 图片 + if (content && cardMap.size > 0) { + content = content.replace(/]*card_id="([^"]+)"[^>]*>.*?<\/grok:render>/gs, (match, cardId) => { + const item = cardMap.get(cardId); + if (!item) return ""; + return this._renderImage(item.original, item.title || "image", state); + }); + } + + // 收集未在正文中渲染的其他图片并追加 const imageUrls = this._collectImages(grokResponse); if (imageUrls.length > 0) { - content += "\n"; + // 已通过卡片 ID 渲染过的 URL 记录 + const handledUrls = new Set(); + for (const item of cardMap.values()) handledUrls.add(item.original); + + let appendContent = ""; for (const url of imageUrls) { - content += this._renderImage(url, "image", state) + "\n"; + if (!handledUrls.has(url)) { + appendContent += this._renderImage(url, "image", state) + "\n"; + } } + if (appendContent) content += "\n" + appendContent; } // 处理视频 (非流式模式) @@ -585,6 +639,13 @@ export class GrokConverter extends BaseConverter { // 处理结束标志 if (resp.isDone) { let finalContent = ""; + + // 如果思考块未关闭,在此关闭 + if (state.think_opened) { + finalContent += "\n\n"; + state.think_opened = false; + } + // 处理剩余的缓冲区 if (state.pending_text_buffer) { finalContent += this._processGrokAssetsInText(state.pending_text_buffer, state); @@ -722,11 +783,26 @@ export class GrokConverter extends BaseConverter { const token = resp.token; const filtered = this._filterToken(token, responseId); const isThinking = !!resp.isThinking; - const inThink = isThinking || state.image_think_active || state.video_think_active; + const hasStepId = !!resp.messageStepId; + const inThink = isThinking || hasStepId || state.image_think_active || state.video_think_active; - if (inThink) { + // 正式内容已开始后,丢弃中途插入的 Agent 思考(1-2 句内部注释,无用户价值) + if (state.content_started && inThink && !state.image_think_active && !state.video_think_active) { + // 跳过不展示 + } else if (inThink) { + if (!state.think_opened) { + deltaContent += "\n"; + state.think_opened = true; + } deltaReasoning += filtered; + deltaContent += filtered; } else { + if (state.think_opened) { + deltaContent += "\n\n"; + state.think_opened = false; + state.content_started = true; + } + // 将新 token 加入待处理缓冲区,解决 URL 被截断的问题 state.pending_text_buffer += filtered; diff --git a/src/providers/grok/grok-core.js b/src/providers/grok/grok-core.js index ab9f92c..815e07b 100644 --- a/src/providers/grok/grok-core.js +++ b/src/providers/grok/grok-core.js @@ -439,7 +439,20 @@ export class GrokApiService { async generateContent(model, requestBody) { logger.info(`[Grok] Starting generateContent (unified processing)`); const stream = this.generateContentStream(model, requestBody); - const collected = { message: "", responseId: "", postId: "", llmInfo: {}, rolloutId: "", modelResponse: null, cardAttachment: null, streamingImageGenerationResponse: null, streamingVideoGenerationResponse: null, finalVideoUrl: null, finalThumbnailUrl: null }; + const collected = { + message: "", + responseId: "", + postId: "", + llmInfo: {}, + rolloutId: "", + modelResponse: null, + cardAttachment: null, + cardAttachments: [], // 收集所有的卡片附件 + streamingImageGenerationResponse: null, + streamingVideoGenerationResponse: null, + finalVideoUrl: null, + finalThumbnailUrl: null + }; for await (const chunk of stream) { const resp = chunk.result?.response; @@ -450,8 +463,41 @@ export class GrokApiService { if (resp.rolloutId) collected.rolloutId = resp.rolloutId; if (resp._requestBaseUrl) collected._requestBaseUrl = resp._requestBaseUrl; if (resp._uuid) collected._uuid = resp._uuid; - if (resp.modelResponse) collected.modelResponse = resp.modelResponse; - if (resp.cardAttachment) collected.cardAttachment = resp.cardAttachment; + + if (resp.modelResponse) { + if (!collected.modelResponse) { + collected.modelResponse = resp.modelResponse; + } else { + // 合并 modelResponse 中的数据 + if (resp.modelResponse.message) collected.modelResponse.message = resp.modelResponse.message; + if (Array.isArray(resp.modelResponse.cardAttachmentsJson)) { + if (!collected.modelResponse.cardAttachmentsJson) { + collected.modelResponse.cardAttachmentsJson = resp.modelResponse.cardAttachmentsJson; + } else { + const currentIds = new Set(collected.modelResponse.cardAttachmentsJson.map(raw => { + try { return JSON.parse(raw).id; } catch (e) { return null; } + }).filter(id => id)); + + for (const raw of resp.modelResponse.cardAttachmentsJson) { + try { + const id = JSON.parse(raw).id; + if (!id || !currentIds.has(id)) { + collected.modelResponse.cardAttachmentsJson.push(raw); + if (id) currentIds.add(id); + } + } catch (e) { + collected.modelResponse.cardAttachmentsJson.push(raw); + } + } + } + } + } + } + + if (resp.cardAttachment) { + collected.cardAttachment = resp.cardAttachment; + collected.cardAttachments.push(resp.cardAttachment); + } if (resp.streamingImageGenerationResponse) { collected.streamingImageGenerationResponse = resp.streamingImageGenerationResponse; } From 617109f88748d41a3bf300583bebf0b6a6a907f5 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Wed, 1 Apr 2026 23:07:02 +0800 Subject: [PATCH 23/28] =?UTF-8?q?feat(grok):=20=E6=B7=BB=E5=8A=A0WebSocket?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E7=94=9F=E6=88=90=E6=94=AF=E6=8C=81=E4=B8=8E?= =?UTF-8?q?=E5=A4=9A=E5=9B=BE=E5=B9=B6=E5=8F=91=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 WebSocket 图片生成服务类,支持流式生成图片 - 在常规 API 失败时自动回退到 WebSocket 方式生成图片 - 支持单次生成超过2张图片时自动拆分为并发请求 - 改进图片生成参数处理,支持返回 base64 格式图片 - 更新版本号至 2.12.2.2 --- VERSION | 2 +- src/providers/grok/grok-core.js | 213 ++++++++++++++++++++++++++++++- src/providers/grok/ws-imagine.js | 114 +++++++++++++++++ 3 files changed, 321 insertions(+), 8 deletions(-) create mode 100644 src/providers/grok/ws-imagine.js diff --git a/VERSION b/VERSION index 500b629..d463c35 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.12.2.1 +2.12.2.2 diff --git a/src/providers/grok/grok-core.js b/src/providers/grok/grok-core.js index 815e07b..7fb962d 100644 --- a/src/providers/grok/grok-core.js +++ b/src/providers/grok/grok-core.js @@ -10,6 +10,7 @@ import { MODEL_PROVIDER } from '../../utils/common.js'; import { ConverterFactory } from '../../converters/ConverterFactory.js'; import * as readline from 'readline'; import { getProviderPoolManager } from '../../services/service-manager.js'; +import { ImagineWebSocketService } from './ws-imagine.js'; // Chrome 136 TLS cipher suites const CHROME_CIPHERS = [ @@ -416,28 +417,35 @@ export class GrokApiService { const isMediaModel = modelLower.includes('imagine') || modelLower.includes('video') || modelLower.includes('edit'); const isNsfw = isGrokNsfwModel(rawModelId) || requestBody.nsfw === true || requestBody.disableNsfwFilter === true; + // 处理生成图片数量,API 通常限制单次最多 2 张 + const imageGenerationCount = Math.min(parseInt(requestBody.n || requestBody.imageGenerationCount || (isMediaModel ? 2 : 0)), 2); + + // 处理响应格式 + const returnImageBytes = requestBody.response_format === 'b64_json' || requestBody.responseFormat === 'b64_json'; + const payload = { "deviceEnvInfo": { "darkModeEnabled": false, "devicePixelRatio": 2, "screenWidth": 2056, "screenHeight": 1329, "viewportWidth": 2056, "viewportHeight": 1083 }, "disableMemory": false, "disableNsfwFilter": isNsfw, "disableSearch": false, "disableSelfHarmShortCircuit": false, "disableTextFollowUps": false, "enableImageGeneration": isMediaModel, "enableImageStreaming": isMediaModel, "enableSideBySide": true, - "fileAttachments": fileAttachments, "forceConcise": false, "forceSideBySide": false, "imageAttachments": [], "imageGenerationCount": 2, + "fileAttachments": fileAttachments, "forceConcise": false, "forceSideBySide": false, "imageAttachments": [], + "imageGenerationCount": imageGenerationCount, "isAsyncChat": false, "isReasoning": false, "message": message, "modelMode": mapping.mode, "modelName": mapping.name, "responseMetadata": { "requestModelDetails": { "modelId": mapping.name }, "modelConfigOverride": modelConfigOverride }, - "returnImageBytes": false, "returnRawGrokInXaiRequest": false, "sendFinalMetadata": true, "temporary": true, "toolOverrides": toolOverrides, + "returnImageBytes": returnImageBytes, "returnRawGrokInXaiRequest": false, "sendFinalMetadata": true, "temporary": true, "toolOverrides": toolOverrides, }; if (isMediaModel && !modelLower.includes('video')) { payload.enable_nsfw = isNsfw; - if (requestBody.aspect_ratio || requestBody.aspectRatio) { - payload.aspect_ratio = requestBody.aspect_ratio || requestBody.aspectRatio; + const aspectRatio = requestBody.aspect_ratio || requestBody.aspectRatio; + if (aspectRatio) { + payload.aspect_ratio = aspectRatio; } } return payload; } - async generateContent(model, requestBody) { - logger.info(`[Grok] Starting generateContent (unified processing)`); + async _generateAndCollect(model, requestBody) { const stream = this.generateContentStream(model, requestBody); const collected = { message: "", @@ -510,9 +518,67 @@ export class GrokApiService { } } } + return collected; + } + + async generateContent(model, requestBody) { + logger.info(`[Grok] Starting generateContent (unified processing)`); + + const n = parseInt(requestBody.n || 1); + const isImagine = model.toLowerCase().includes('imagine'); + + let collected; + try { + if (n <= 2 || !isImagine) { + // 单次请求处理 + collected = await this._generateAndCollect(model, requestBody); + } else { + // 处理 n > 2 的情况,分批并发请求 + logger.info(`[Grok] Multi-image request detected (n=${n}), splitting into multiple tasks`); + const perCall = 2; + const callsNeeded = Math.ceil(n / perCall); + const tasks = []; + + for (let i = 0; i < callsNeeded; i++) { + const count = Math.min(perCall, n - i * perCall); + const subRequestBody = { ...requestBody, n: count }; + tasks.push(this._generateAndCollect(model, subRequestBody)); + } + + const results = await Promise.all(tasks); + + // 合并所有批次的结果 + collected = results[0]; + for (let i = 1; i < results.length; i++) { + const res = results[i]; + // 合并消息文本 + if (res.message) collected.message += "\n" + res.message; + // 合并卡片附件 + if (res.cardAttachments) collected.cardAttachments.push(...res.cardAttachments); + // 合并 modelResponse 中的卡片 JSON + if (res.modelResponse?.cardAttachmentsJson) { + if (!collected.modelResponse) collected.modelResponse = { cardAttachmentsJson: [] }; + if (!collected.modelResponse.cardAttachmentsJson) collected.modelResponse.cardAttachmentsJson = []; + collected.modelResponse.cardAttachmentsJson.push(...res.modelResponse.cardAttachmentsJson); + } + } + } + } catch (error) { + // 只有图片生成才支持 WebSocket Fallback + if (isImagine) { + logger.warn(`[Grok] app_chat image generation failed, trying ws_imagine fallback: ${error.message}`); + try { + return await this._generateAndCollectWS(model, requestBody); + } catch (wsError) { + logger.error(`[Grok] ws_imagine fallback also failed: ${wsError.message}`); + throw error; // 抛出原始错误 + } + } + throw error; + } logger.info(`[Grok] Finalizing collection. model: ${model}, respId: ${collected.responseId}, videoPostId: ${collected.postId}`); - + // 1. 仅针对视频进行 postId 提取和分享链接创建 const isVideo = !!(collected.finalVideoUrl || collected.streamingVideoGenerationResponse || model.toLowerCase().includes('video')); logger.info(`[Grok Decision] isVideo detected: ${isVideo}. (finalUrl: ${!!collected.finalVideoUrl}, streamResp: ${!!collected.streamingVideoGenerationResponse}, modelIncludeVideo: ${model.toLowerCase().includes('video')})`); @@ -551,6 +617,124 @@ export class GrokApiService { return collected; } + /** + * WebSocket 方式生成图片 (Fallback) + */ + async _generateAndCollectWS(model, requestBody) { + const n = parseInt(requestBody.n || 1); + // 提取 prompt + let prompt = requestBody.message || requestBody.videoGenPrompt; + if (!prompt && requestBody.messages?.length > 0) { + const lastMsg = requestBody.messages[requestBody.messages.length - 1]; + prompt = typeof lastMsg.content === 'string' ? lastMsg.content : (lastMsg.content?.find(p => p.type === 'text')?.text || ""); + } + prompt = prompt || "A beautiful image"; + + const aspectRatio = requestBody.aspect_ratio || requestBody.aspectRatio || "1:1"; + const enableNsfw = requestBody.nsfw !== false; + + logger.info(`[Grok WS] Starting fallback image generation for: ${prompt.substring(0, 50)}...`); + + const wsService = new ImagineWebSocketService(this.config); + const stream = wsService.stream(this.token, prompt, aspectRatio, n, enableNsfw); + + const collected = { + message: "", + responseId: `ws-${uuidv4()}`, + postId: "", + llmInfo: { modelHash: "ws-imagine" }, + rolloutId: "", + modelResponse: { cardAttachmentsJson: [] }, + cardAttachments: [] + }; + + for await (const item of stream) { + if (item.type === 'error') { + throw new Error(item.error || 'WebSocket generation failed'); + } + if (item.type === 'image' && item.stage === 'final') { + const cardData = { + id: item.image_id || uuidv4(), + image: { + original: item.blob.startsWith('data:') ? item.blob : `data:image/png;base64,${item.blob}`, + title: "Generated Image" + } + }; + const jsonStr = JSON.stringify(cardData); + collected.modelResponse.cardAttachmentsJson.push(jsonStr); + collected.cardAttachments.push({ jsonData: jsonStr }); + logger.info(`[Grok WS] Received image: ${cardData.id}`); + } + } + + if (collected.cardAttachments.length === 0) { + throw new Error("WebSocket generation returned no images"); + } + + return collected; + } + + /** + * WebSocket 方式流式生成图片 (Fallback) + */ + async * _generateContentStreamWS(model, requestBody) { + const n = parseInt(requestBody.n || 1); + let prompt = requestBody.message || requestBody.videoGenPrompt; + if (!prompt && requestBody.messages?.length > 0) { + const lastMsg = requestBody.messages[requestBody.messages.length - 1]; + prompt = typeof lastMsg.content === 'string' ? lastMsg.content : (lastMsg.content?.find(p => p.type === 'text')?.text || ""); + } + prompt = prompt || "A beautiful image"; + + const aspectRatio = requestBody.aspect_ratio || requestBody.aspectRatio || "1:1"; + const enableNsfw = requestBody.nsfw !== false; + + const wsService = new ImagineWebSocketService(this.config); + const stream = wsService.stream(this.token, prompt, aspectRatio, n, enableNsfw); + + const responseId = `ws-${uuidv4()}`; + + for await (const item of stream) { + if (item.type === 'error') { + throw new Error(item.error || 'WebSocket generation failed'); + } + if (item.type === 'image') { + yield { + result: { + response: { + responseId, + streamingImageGenerationResponse: { + imageIndex: 0, + progress: item.stage === 'final' ? 100 : (item.stage === 'medium' ? 50 : 10) + } + } + } + }; + + if (item.stage === 'final') { + const cardData = { + id: item.image_id || uuidv4(), + image: { + original: item.blob.startsWith('data:') ? item.blob : `data:image/png;base64,${item.blob}`, + title: "Generated Image" + } + }; + yield { + result: { + response: { + responseId, + cardAttachment: { + jsonData: JSON.stringify(cardData) + } + } + } + }; + } + } + } + yield { result: { response: { isDone: true, responseId } } }; + } + async uploadFile(fileInput) { let b64 = "", mime = "application/octet-stream"; if (fileInput.startsWith("data:")) { @@ -677,6 +861,9 @@ export class GrokApiService { resp._requestBaseUrl = reqBaseUrl; resp._uuid = this.uuid; if (resp.responseId) lastResponseId = resp.responseId; + if (resp.streamingImageGenerationResponse) { + // 图片生成进度通过流透传,暂无额外处理 + } if (resp.streamingVideoGenerationResponse) { const vid = resp.streamingVideoGenerationResponse; if (vid.progress === 100 && vid.videoUrl && (requestBody.videoGenModelConfig?.resolutionName === "720p")) { @@ -694,6 +881,18 @@ export class GrokApiService { const { status, errorCode, errorMessage, isNetworkError } = this.classifyApiError(error); const canRetryInRequest = !hasYieldedData && retryCount < maxRetries; + // 只有图片生成且未发送过数据时才尝试 WebSocket Fallback + const isImagine = modelLower.includes('imagine'); + if (isImagine && !hasYieldedData && retryCount === 0) { + logger.warn(`[Grok] app_chat stream failed, trying ws_imagine fallback: ${error.message}`); + try { + yield* this._generateContentStreamWS(model, requestBody); + return; + } catch (wsError) { + logger.error(`[Grok] ws_imagine fallback also failed: ${wsError.message}`); + } + } + 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})`); diff --git a/src/providers/grok/ws-imagine.js b/src/providers/grok/ws-imagine.js new file mode 100644 index 0000000..d113c3f --- /dev/null +++ b/src/providers/grok/ws-imagine.js @@ -0,0 +1,114 @@ +import WebSocket from 'ws'; +import logger from '../../utils/logger.js'; +import { getProxyConfigForProvider } from '../../utils/proxy-utils.js'; +import { MODEL_PROVIDER } from '../../utils/common.js'; + +/** + * Grok WebSocket Imagine Service + * Handles image generation via Grok's WebSocket endpoint. + */ +export class ImagineWebSocketService { + constructor(config) { + this.config = config; + this.baseUrl = (config.GROK_BASE_URL || 'https://grok.com').replace(/\/$/, ''); + this.wsUrl = this.baseUrl.replace(/^http/, 'ws') + '/rpc/imagine/streaming'; + } + + /** + * Start an image generation stream via WebSocket. + * + * @param {string} token - SSO token + * @param {string} prompt - Image prompt + * @param {string} aspectRatio - Aspect ratio (e.g. "1:1") + * @param {number} n - Number of images + * @param {boolean} enableNsfw - Enable NSFW filter + * @returns {AsyncGenerator} + */ + async *stream(token, prompt, aspectRatio = '1:1', n = 1, enableNsfw = true) { + const proxyConfig = getProxyConfigForProvider(this.config, MODEL_PROVIDER.GROK_CUSTOM); + const agent = proxyConfig?.httpsAgent; + + let ssoToken = token || ""; + if (ssoToken.startsWith("sso=")) ssoToken = ssoToken.substring(4); + const cookie = ssoToken ? `sso=${ssoToken}; sso-rw=${ssoToken}` : ""; + + const headers = { + 'Cookie': cookie, + 'Origin': this.baseUrl, + 'User-Agent': this.config.GROK_USER_AGENT || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36', + }; + + logger.debug(`[Grok WS] Connecting to ${this.wsUrl} for prompt: ${prompt.substring(0, 50)}...`); + + const ws = new WebSocket(this.wsUrl, { + headers, + agent, + handshakeTimeout: 15000, + rejectUnauthorized: false + }); + + const queue = []; + let done = false; + let resolveNext = null; + + ws.on('open', () => { + logger.debug(`[Grok WS] Connected. Sending imagine request.`); + ws.send(JSON.stringify({ + method: 'imagine', + params: { + prompt, + aspectRatio, + count: n, + enableNsfw + } + })); + }); + + ws.on('message', (data) => { + try { + const msg = JSON.parse(data.toString()); + queue.push(msg); + if (resolveNext) { + resolveNext(); + resolveNext = null; + } + } catch (e) { + logger.error(`[Grok WS] Failed to parse message: ${data.toString().substring(0, 100)}`); + } + }); + + ws.on('close', (code, reason) => { + logger.debug(`[Grok WS] Connection closed: ${code} ${reason}`); + done = true; + if (resolveNext) { + resolveNext(); + resolveNext = null; + } + }); + + ws.on('error', (err) => { + logger.error(`[Grok WS] WebSocket error: ${err.message}`); + queue.push({ type: 'error', error: err.message }); + done = true; + if (resolveNext) { + resolveNext(); + resolveNext = null; + } + }); + + try { + while (!done || queue.length > 0) { + if (queue.length === 0 && !done) { + await new Promise(r => resolveNext = r); + } + while (queue.length > 0) { + yield queue.shift(); + } + } + } finally { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(); + } + } + } +} From 740f930f343908d11fe32f4f103b9c4656e68893 Mon Sep 17 00:00:00 2001 From: Wenaixi Date: Fri, 3 Apr 2026 00:51:01 +0800 Subject: [PATCH 24/28] =?UTF-8?q?fix:=20=E6=B7=B1=E5=BA=A6=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=AE=A1=E6=9F=A5=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94?= =?UTF-8?q?=E7=A9=BA=E9=98=9F=E5=88=97=E6=B3=84=E6=BC=8F=E3=80=81XSS?= =?UTF-8?q?=E9=98=B2=E6=8A=A4=E3=80=81docker=E4=BB=A3=E7=90=86=E6=B8=85?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(provider-pool): 修复 ownsGlobalSlot=false 时空队列未清理的内存泄漏 - fix(provider-api): 新增 sanitizeProviderData/ProviderPools,对 customName 等用户输入字段做 HTML 转义,防止 XSS - fix(docker): 删除 docker-compose.yml 中的代理硬编码配置 - fix(api-server): 重构定时健康检查 timer 管理,支持热更新 enabled 状态(stopHealthCheckTimer + 状态变化追踪) - fix(constants): 提取 HEALTH_CHECK/PASSWORD/NETWORK/RETRY 常量到 constants.js - style(api-server): 移除日志中密码长度记录,防止敏感元信息泄露 --- docker/docker-compose.yml | 8 +-- src/providers/provider-pool-manager.js | 12 ++-- src/services/api-server.js | 81 +++++++++++++++----------- src/ui-modules/auth.js | 9 ++- src/ui-modules/config-api.js | 56 ++++++++++++++---- src/ui-modules/provider-api.js | 67 +++++++++++++++------ src/utils/constants.js | 44 ++++++++++++++ static/app/config-manager.js | 2 +- static/components/section-config.html | 38 +----------- 9 files changed, 203 insertions(+), 114 deletions(-) create mode 100644 src/utils/constants.js diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 8c08ef3..a9d2eaf 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -7,19 +7,13 @@ services: restart: unless-stopped ports: - "3000:3000" - - "8085-8087:8085-8087" + - "8085-8087:8085-8087" - "1455:1455" - "19876-19880:19876-19880" volumes: - ./configs:/app/configs environment: - ARGS= - - HTTP_PROXY=http://host.docker.internal:10801 - - http_proxy=http://host.docker.internal:10801 - - HTTPS_PROXY=http://host.docker.internal:10801 - - https_proxy=http://host.docker.internal:10801 - - NO_PROXY=localhost,127.0.0.1,host.docker.internal - - no_proxy=localhost,127.0.0.1,host.docker.internal healthcheck: test: ["CMD", "node", "healthcheck.js"] interval: 30s diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index c3dc2ab..15bf9f5 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -307,13 +307,15 @@ export class ProviderPoolManager { // 使用 Promise.resolve().then 避免过深的递归 Promise.resolve().then(nextTask); } else if (currentQueue.activeCount === 0) { - // 2. 如果当前提供商的所有任务都完成了,释放全局槽位 - // 只有持有全局槽位的任务才能递减计数器,避免负值 - if (ownsGlobalSlot && - currentQueue.waitingTasks.length === 0 && + // 清理空队列:无论是否持有全局槽位,都应删除已无任务的队列对象 + if (currentQueue.waitingTasks.length === 0 && this.refreshQueues[providerType] === currentQueue) { + delete this.refreshQueues[providerType]; + } + + // 只有持有全局槽位的任务才能递减计数器 + if (ownsGlobalSlot) { this.activeProviderRefreshes--; - delete this.refreshQueues[providerType]; // 清理空队列 } // 3. 尝试启动下一个等待中的提供商队列 diff --git a/src/services/api-server.js b/src/services/api-server.js index 4bc1b0c..6caf415 100644 --- a/src/services/api-server.js +++ b/src/services/api-server.js @@ -7,6 +7,7 @@ import { initializeAPIManagement } from './api-manager.js'; import { createRequestHandler } from '../handlers/request-handler.js'; import { discoverPlugins, getPluginManager } from '../core/plugin-manager.js'; import { getTLSSidecar } from '../utils/tls-sidecar.js'; +import { HEALTH_CHECK } from '../utils/constants.js'; /** * @license @@ -362,45 +363,30 @@ async function startServer() { } // 定时健康检查 + // 注意:无论初始 enabled 状态如何,都注册 reloadHealthCheckTimer, + // 使得热更新时(从 disabled→enabled)config-api 能调用它启动 timer。 const scheduledConfig = CONFIG.SCHEDULED_HEALTH_CHECK; - if (scheduledConfig?.enabled) { - // 设计决策:只验证最小值 60000ms,不设最大值。 - // 前端有 max=3600000 (1小时) 的 UI 限制,但后端允许更大值以支持特殊需求。 - // 如果用户需要超长的间隔,可以通过 API 直接设置。 + { const DEFAULT_INTERVAL = CONFIG.CRON_NEAR_MINUTES * 60 * 1000; - let interval = scheduledConfig.interval; - if (typeof interval !== 'number' || interval < 60000) { - logger.warn(`[ScheduledHealthCheck] Invalid interval ${interval}, using default ${DEFAULT_INTERVAL}`); - interval = DEFAULT_INTERVAL; - } - - // 启动时运行健康检查 - if (scheduledConfig.startupRun !== false) { - logger.info('[ScheduledHealthCheck] Running scheduled health check on startup...'); - // 使用 setImmediate 确保在事件循环的下一阶段执行,此时服务器已完全就绪 - setImmediate(async () => { - try { - await poolManager.performScheduledHealthChecks(); - } catch (error) { - logger.error('[ScheduledHealthCheck] Startup run error:', error); - } - }); - } - + let isHealthCheckRunning = false; let healthCheckTimerId = null; - - // 定时健康检查函数 + + // 定时健康检查函数(始终注册,无论初始 enabled 状态) const runHealthCheckTimer = (interval) => { // 清除旧的 timer if (healthCheckTimerId) { clearInterval(healthCheckTimerId); + healthCheckTimerId = null; } // 重置运行状态,允许新的 timer 立即触发 // 否则如果 reload 时正在运行,新 timer 的第一次触发会被跳过 isHealthCheckRunning = false; - + // 设置定时任务 + // 设计决策:只验证最小值,不设最大值。 + // 前端有 max=3600000 (1小时) 的 UI 限制,但后端允许更大值以支持特殊需求。 + const safeInterval = (typeof interval === 'number' && interval >= HEALTH_CHECK.MIN_INTERVAL_MS) ? interval : DEFAULT_INTERVAL; healthCheckTimerId = setInterval(async () => { if (isHealthCheckRunning) { logger.debug('[ScheduledHealthCheck] Skipping - previous run still in progress'); @@ -414,16 +400,45 @@ async function startServer() { } finally { isHealthCheckRunning = false; } - }, interval); - logger.info(`[ScheduledHealthCheck] Scheduled every ${interval}ms`); + }, safeInterval); + logger.info(`[ScheduledHealthCheck] Scheduled every ${safeInterval}ms`); + return safeInterval; }; - - // 设置定时任务 - runHealthCheckTimer(interval); - // 注册重载函数和初始 interval 到 globalThis(供 config-api 热更新使用) + // 注册重载/停止函数到 globalThis(供 config-api 热更新使用) + // 必须在 enabled 检查外注册,保证热更新时可访问 globalThis.reloadHealthCheckTimer = runHealthCheckTimer; - globalThis._activeHealthCheckInterval = interval; + globalThis.stopHealthCheckTimer = () => { + if (healthCheckTimerId) { + clearInterval(healthCheckTimerId); + healthCheckTimerId = null; + logger.info('[ScheduledHealthCheck] Timer stopped'); + } + }; + + if (scheduledConfig?.enabled) { + let interval = scheduledConfig.interval; + if (typeof interval !== 'number' || interval < HEALTH_CHECK.MIN_INTERVAL_MS) { + logger.warn(`[ScheduledHealthCheck] Invalid interval ${interval}, using default ${DEFAULT_INTERVAL}`); + interval = DEFAULT_INTERVAL; + } + + // 启动时运行健康检查 + if (scheduledConfig.startupRun !== false) { + logger.info('[ScheduledHealthCheck] Running scheduled health check on startup...'); + setImmediate(async () => { + try { + await poolManager.performScheduledHealthChecks(); + } catch (error) { + logger.error('[ScheduledHealthCheck] Startup run error:', error); + } + }); + } + + // 设置定时任务 + const activeInterval = runHealthCheckTimer(interval); + globalThis._activeHealthCheckInterval = activeInterval; + } } // 如果是子进程,通知主进程已就绪 diff --git a/src/ui-modules/auth.js b/src/ui-modules/auth.js index 534a399..b60995f 100644 --- a/src/ui-modules/auth.js +++ b/src/ui-modules/auth.js @@ -5,6 +5,7 @@ import path from 'path'; import crypto from 'crypto'; import { CONFIG } from '../core/config-manager.js'; import { getClientIp } from '../utils/common.js'; +import { PASSWORD } from '../utils/constants.js'; // Token存储到本地文件中 const TOKEN_STORE_FILE = path.join(process.cwd(), 'configs', 'token-store.json'); @@ -56,7 +57,7 @@ export async function validateCredentials(password) { if (parts.length !== 3) return false; const [, salt, storedHash] = parts; const inputHash = await new Promise((resolve, reject) => - crypto.pbkdf2(password.trim(), salt, 100000, 64, 'sha512', (err, key) => + crypto.pbkdf2(password.trim(), salt, PASSWORD.PBKDF2_ITERATIONS, PASSWORD.PBKDF2_KEYLEN, PASSWORD.PBKDF2_DIGEST, (err, key) => err ? reject(err) : resolve(key.toString('hex')) ) ); @@ -64,7 +65,11 @@ export async function validateCredentials(password) { } // 旧格式:明文(兼容迁移期,建议通过 UI 重新设置密码以升级为哈希格式) - return password === storedPassword; + // 使用 timingSafeEqual 防止时序攻击 + const a = Buffer.from(password.trim()); + const b = Buffer.from(storedPassword); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); } /** diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js index fd8e908..c62376a 100644 --- a/src/ui-modules/config-api.js +++ b/src/ui-modules/config-api.js @@ -8,6 +8,7 @@ import { serviceInstances } from '../providers/adapter.js'; import { initApiService } from '../services/service-manager.js'; import { getRequestBody } from '../utils/common.js'; import { broadcastEvent } from '../ui-modules/event-broadcast.js'; +import { HEALTH_CHECK, PASSWORD, NETWORK, RETRY } from '../utils/constants.js'; /** * 重载配置文件 @@ -120,14 +121,18 @@ export async function handleUpdateConfig(req, res, currentConfig) { } if (newConfig.SERVER_PORT !== undefined) { const port = Number(newConfig.SERVER_PORT); - if (Number.isInteger(port) && port > 0 && port < 65536) currentConfig.SERVER_PORT = port; + if (Number.isInteger(port) && port >= NETWORK.MIN_PORT && port <= NETWORK.MAX_PORT) currentConfig.SERVER_PORT = port; } if (newConfig.MODEL_PROVIDER !== undefined) currentConfig.MODEL_PROVIDER = newConfig.MODEL_PROVIDER; if (newConfig.SYSTEM_PROMPT_FILE_PATH !== undefined) { const p = String(newConfig.SYSTEM_PROMPT_FILE_PATH); // 防止路径遍历:解析后的绝对路径必须在工作目录内 const resolved = path.resolve(process.cwd(), p); - if (resolved.startsWith(process.cwd() + path.sep) || resolved === process.cwd()) { + const cwd = process.cwd(); + // Windows兼容:统一使用正斜杠进行比较 + const normalizedResolved = resolved.replace(/\\/g, '/'); + const normalizedCwd = cwd.replace(/\\/g, '/'); + if (normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd) { currentConfig.SYSTEM_PROMPT_FILE_PATH = p; } } @@ -136,7 +141,7 @@ export async function handleUpdateConfig(req, res, currentConfig) { if (newConfig.PROMPT_LOG_MODE !== undefined) currentConfig.PROMPT_LOG_MODE = newConfig.PROMPT_LOG_MODE; if (newConfig.REQUEST_MAX_RETRIES !== undefined) { const v = Number(newConfig.REQUEST_MAX_RETRIES); - if (Number.isInteger(v) && v >= 0 && v <= 100) currentConfig.REQUEST_MAX_RETRIES = v; + if (Number.isInteger(v) && v >= 0 && v <= RETRY.MAX_RETRIES) currentConfig.REQUEST_MAX_RETRIES = v; } if (newConfig.REQUEST_BASE_DELAY !== undefined) currentConfig.REQUEST_BASE_DELAY = newConfig.REQUEST_BASE_DELAY; if (newConfig.CREDENTIAL_SWITCH_MAX_RETRIES !== undefined) currentConfig.CREDENTIAL_SWITCH_MAX_RETRIES = newConfig.CREDENTIAL_SWITCH_MAX_RETRIES; @@ -164,7 +169,18 @@ export async function handleUpdateConfig(req, res, currentConfig) { if (newConfig.LOG_ENABLED !== undefined) currentConfig.LOG_ENABLED = newConfig.LOG_ENABLED; if (newConfig.LOG_OUTPUT_MODE !== undefined) currentConfig.LOG_OUTPUT_MODE = newConfig.LOG_OUTPUT_MODE; if (newConfig.LOG_LEVEL !== undefined) currentConfig.LOG_LEVEL = newConfig.LOG_LEVEL; - if (newConfig.LOG_DIR !== undefined) currentConfig.LOG_DIR = newConfig.LOG_DIR; + if (newConfig.LOG_DIR !== undefined) { + const p = String(newConfig.LOG_DIR); + // 防止路径遍历:解析后的绝对路径必须在工作目录内 + const resolved = path.resolve(process.cwd(), p); + const cwd = process.cwd(); + // Windows兼容:统一使用正斜杠进行比较 + const normalizedResolved = resolved.replace(/\\/g, '/'); + const normalizedCwd = cwd.replace(/\\/g, '/'); + if (normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd) { + currentConfig.LOG_DIR = p; + } + } if (newConfig.LOG_INCLUDE_REQUEST_ID !== undefined) currentConfig.LOG_INCLUDE_REQUEST_ID = newConfig.LOG_INCLUDE_REQUEST_ID; if (newConfig.LOG_INCLUDE_TIMESTAMP !== undefined) currentConfig.LOG_INCLUDE_TIMESTAMP = newConfig.LOG_INCLUDE_TIMESTAMP; if (newConfig.LOG_MAX_FILE_SIZE !== undefined) currentConfig.LOG_MAX_FILE_SIZE = newConfig.LOG_MAX_FILE_SIZE; @@ -175,7 +191,7 @@ export async function handleUpdateConfig(req, res, currentConfig) { const incoming = newConfig.SCHEDULED_HEALTH_CHECK; const newInterval = (() => { const val = Number(incoming?.interval); - return isNaN(val) ? 600000 : Math.max(60000, val); + return isNaN(val) ? HEALTH_CHECK.DEFAULT_INTERVAL_MS : Math.max(HEALTH_CHECK.MIN_INTERVAL_MS, val); })(); currentConfig.SCHEDULED_HEALTH_CHECK = { enabled: incoming?.enabled === true, @@ -184,10 +200,26 @@ export async function handleUpdateConfig(req, res, currentConfig) { providerTypes: Array.isArray(incoming?.providerTypes) ? incoming.providerTypes : [] }; - // 仅在 interval 实际变化时重新加载 timer(_activeInterval 存在内存变量中,不写入配置文件) - if (globalThis.reloadHealthCheckTimer && currentConfig.SCHEDULED_HEALTH_CHECK.enabled && newInterval !== globalThis._activeHealthCheckInterval) { - globalThis._activeHealthCheckInterval = newInterval; - globalThis.reloadHealthCheckTimer(newInterval); + // 检测 enabled 状态变化 + const wasEnabled = currentConfig.SCHEDULED_HEALTH_CHECK?.enabled === true; + const nowEnabled = incoming?.enabled === true; + + if (currentConfig.SCHEDULED_HEALTH_CHECK) { + // 当 enabled 从 true -> false 时,清除 timer + if (wasEnabled && !nowEnabled && globalThis.stopHealthCheckTimer) { + globalThis.stopHealthCheckTimer(); + globalThis._activeHealthCheckInterval = undefined; + } + // 当 enabled 从 false -> true 时,启动 timer + else if (!wasEnabled && nowEnabled && globalThis.reloadHealthCheckTimer) { + globalThis._activeHealthCheckInterval = newInterval; + globalThis.reloadHealthCheckTimer(newInterval); + } + // 当 enabled=true 且 interval 变化时,重启 timer + else if (nowEnabled && newInterval !== globalThis._activeHealthCheckInterval) { + globalThis._activeHealthCheckInterval = newInterval; + globalThis.reloadHealthCheckTimer(newInterval); + } } } @@ -347,16 +379,16 @@ export async function handleUpdateAdminPassword(req, res) { return true; } - if (password.trim().length < 8) { + 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 8 characters' } })); + res.end(JSON.stringify({ error: { message: `Password must be at least ${PASSWORD.MIN_LENGTH} characters` } })); return true; } // 使用 PBKDF2 哈希存储密码,避免明文写入文件 const salt = crypto.randomBytes(16).toString('hex'); const hash = await new Promise((resolve, reject) => - crypto.pbkdf2(password.trim(), salt, 100000, 64, 'sha512', (err, key) => + crypto.pbkdf2(password.trim(), salt, PASSWORD.PBKDF2_ITERATIONS, PASSWORD.PBKDF2_KEYLEN, PASSWORD.PBKDF2_DIGEST, (err, key) => err ? reject(err) : resolve(key.toString('hex')) ) ); diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index 8c7854d..534032a 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -7,10 +7,43 @@ import { broadcastEvent } from './event-broadcast.js'; import { getRegisteredProviders } from '../providers/adapter.js'; // 文件级互斥锁:防止并发读写导致数据丢失 +// HTML 脱敏:移除用户输入字段中的 HTML/JS,防止 XSS +function sanitizeProviderData(provider) { + if (!provider || typeof provider !== 'object') return provider; + const sanitized = { ...provider }; + // 允许在前端显示的纯文本字段做 HTML 转义 + if (typeof sanitized.customName === 'string') { + sanitized.customName = sanitized.customName + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + return sanitized; +} + +function sanitizeProviderPools(pools) { + if (!pools || typeof pools !== 'object') return pools; + const sanitized = {}; + for (const [type, providers] of Object.entries(pools)) { + sanitized[type] = Array.isArray(providers) + ? providers.map(sanitizeProviderData) + : providers; + } + return sanitized; +} +// 使用 Promise 链式队列,确保文件操作顺序执行 let _fileLockChain = Promise.resolve(); function withFileLock(fn) { - const next = _fileLockChain.then(() => fn()); - _fileLockChain = next.catch(() => {}); + const next = _fileLockChain + .then(() => fn()) + .catch(err => { + // 记录错误但继续链式执行,防止死锁 + logger.error('[FileLock] Operation failed:', err?.message || err); + return null; + }); + _fileLockChain = next.then(() => {}).catch(() => {}); return next; } /** @@ -31,7 +64,7 @@ export async function handleGetProviders(req, res, currentConfig, providerPoolMa } res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(providerPools)); + res.end(JSON.stringify(sanitizeProviderPools(providerPools))); return true; } @@ -66,7 +99,7 @@ export async function handleGetProviderType(req, res, currentConfig, providerPoo res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ providerType, - providers, + providers: providers.map(sanitizeProviderData), totalCount: providers.length, healthyCount: providers.filter(p => p.isHealthy).length })); @@ -159,7 +192,7 @@ async function _handleAddProvider(req, res, currentConfig, providerPoolManager) action: 'add', filePath: filePath, providerType, - providerConfig, + providerConfig: sanitizeProviderData(providerConfig), timestamp: new Date().toISOString() }); @@ -167,7 +200,7 @@ async function _handleAddProvider(req, res, currentConfig, providerPoolManager) broadcastEvent('provider_update', { action: 'add', providerType, - providerConfig, + providerConfig: sanitizeProviderData(providerConfig), timestamp: new Date().toISOString() }); @@ -175,7 +208,7 @@ async function _handleAddProvider(req, res, currentConfig, providerPoolManager) res.end(JSON.stringify({ success: true, message: 'Provider added successfully', - provider: providerConfig, + provider: sanitizeProviderData(providerConfig), providerType })); return true; @@ -257,7 +290,7 @@ async function _handleUpdateProvider(req, res, currentConfig, providerPoolManage action: 'update', filePath: filePath, providerType, - providerConfig: updatedProvider, + providerConfig: sanitizeProviderData(updatedProvider), timestamp: new Date().toISOString() }); @@ -265,7 +298,7 @@ async function _handleUpdateProvider(req, res, currentConfig, providerPoolManage res.end(JSON.stringify({ success: true, message: 'Provider updated successfully', - provider: updatedProvider + provider: sanitizeProviderData(updatedProvider) })); return true; } catch (error) { @@ -331,7 +364,7 @@ async function _handleDeleteProvider(req, res, currentConfig, providerPoolManage action: 'delete', filePath: filePath, providerType, - providerConfig: deletedProvider, + providerConfig: sanitizeProviderData(deletedProvider), timestamp: new Date().toISOString() }); @@ -339,7 +372,7 @@ async function _handleDeleteProvider(req, res, currentConfig, providerPoolManage res.end(JSON.stringify({ success: true, message: 'Provider deleted successfully', - deletedProvider + deletedProvider: sanitizeProviderData(deletedProvider) })); return true; } catch (error) { @@ -407,7 +440,7 @@ async function _handleDisableEnableProvider(req, res, currentConfig, providerPoo action: action, filePath: filePath, providerType, - providerConfig: provider, + providerConfig: sanitizeProviderData(provider), timestamp: new Date().toISOString() }); @@ -415,7 +448,7 @@ async function _handleDisableEnableProvider(req, res, currentConfig, providerPoo res.end(JSON.stringify({ success: true, message: `Provider ${action}d successfully`, - provider: provider + provider: sanitizeProviderData(provider) })); return true; } catch (error) { @@ -569,7 +602,7 @@ export async function handleDeleteUnhealthyProviders(req, res, currentConfig, pr filePath: filePath, providerType, deletedCount: unhealthyProviders.length, - deletedProviders: unhealthyProviders.map(p => ({ uuid: p.uuid, customName: p.customName })), + deletedProviders: unhealthyProviders.map(p => sanitizeProviderData({ uuid: p.uuid, customName: p.customName })), timestamp: new Date().toISOString() }); @@ -660,7 +693,7 @@ export async function handleRefreshUnhealthyUuids(req, res, currentConfig, provi filePath: filePath, providerType, refreshedCount: refreshedProviders.length, - refreshedProviders, + refreshedProviders: refreshedProviders.map(p => sanitizeProviderData(p)), timestamp: new Date().toISOString() }); @@ -806,7 +839,7 @@ export async function handleHealthCheck(req, res, currentConfig, providerPoolMan action: 'health_check', filePath: filePath, providerType, - results, + results: results.map(r => ({ ...r, message: sanitizeProviderData({ message: r.message }).message })), timestamp: new Date().toISOString() }); @@ -1046,7 +1079,7 @@ export async function handleRefreshProviderUuid(req, res, currentConfig, provide message: 'UUID refreshed successfully', oldUuid, newUuid, - provider: providerPools[providerType][providerIndex] + provider: sanitizeProviderData(providerPools[providerType][providerIndex]) })); return true; } catch (error) { diff --git a/src/utils/constants.js b/src/utils/constants.js new file mode 100644 index 0000000..20c0071 --- /dev/null +++ b/src/utils/constants.js @@ -0,0 +1,44 @@ +/** + * 共享常量定义 + * 集中管理各处使用的硬编码值 + */ + +// 定时健康检查相关常量 +export const HEALTH_CHECK = { + // 最小检查间隔:60秒(60000毫秒) + MIN_INTERVAL_MS: 60000, + // 默认检查间隔:10分钟(600000毫秒) + DEFAULT_INTERVAL_MS: 600000, + // 最大检查间隔:1小时(3600000毫秒)- 仅用于前端UI限制 + MAX_INTERVAL_MS: 3600000 +}; + +// 密码安全相关常量 +export const PASSWORD = { + // 最小密码长度 + MIN_LENGTH: 8, + // PBKDF2迭代次数 + PBKDF2_ITERATIONS: 100000, + // PBKDF2密钥长度(字节) + PBKDF2_KEYLEN: 64, + // PBKDF2哈希算法 + PBKDF2_DIGEST: 'sha512' +}; + +// 网络相关常量 +export const NETWORK = { + // 最小端口号 + MIN_PORT: 1, + // 最大端口号 + MAX_PORT: 65535, + // 默认服务器端口 + DEFAULT_PORT: 3000 +}; + +// 请求重试相关常量 +export const RETRY = { + // 最大重试次数 + MAX_RETRIES: 100, + // 默认重试次数 + DEFAULT_RETRIES: 3 +}; diff --git a/static/app/config-manager.js b/static/app/config-manager.js index 36ac1cb..8d942e8 100644 --- a/static/app/config-manager.js +++ b/static/app/config-manager.js @@ -53,7 +53,7 @@ function renderProviderTags(container, configs, isRequired) { // 过滤掉不可见的提供商 const visibleConfigs = configs.filter(c => c.visible !== false); - const escHtml = s => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + const escHtml = s => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); container.innerHTML = visibleConfigs.map(c => ` - - - - - - - - - + 选择需要进行定时健康检查的供应商类型 From 1018750388c6c51a52ccbee7e8bfc7f05539f56a Mon Sep 17 00:00:00 2001 From: Wenaixi Date: Fri, 3 Apr 2026 01:27:34 +0800 Subject: [PATCH 25/28] =?UTF-8?q?fix:=20=E6=B7=B1=E5=BA=A6review=E5=90=8E?= =?UTF-8?q?=E7=BB=AD=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E5=BC=BA=E5=8C=96=E3=80=81i18n=E8=A1=A5=E5=85=A8=E3=80=81?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 安全修复: - PBKDF2迭代次数从100k提升至310k(OWASP 2023 SHA-512标准) - 密码最小长度从8位提升至12位 - sanitizeProviderData正则加强:data:协议拒绝而非部分移除, on\w+事件处理器更严格,javascript:加单词边界防止误匹配 - withFileLock错误处理改为重新抛出,不再静默吞错误 - 后端interval上限校验(MAX_INTERVAL_MS)确保配置一致性 功能修复: - 重命名performHealthChecks/performScheduledHealthChecks方法, 明确区分初始化检查和定时检查的职责 - generateUUID回退方案兼容Node.js <14.17.0 - 凭据无expiry字段时强制刷新(安全措施) 代码清理: - 移除未使用的RETRY.DEFAULT_RETRIES常量 - 添加定时健康检查完整英文i18n翻译 --- src/providers/provider-pool-manager.js | 20 +++-- src/services/api-manager.js | 2 +- src/services/api-server.js | 8 +- src/ui-modules/config-api.js | 102 +++++++++++++++---------- src/ui-modules/provider-api.js | 48 ++++++++---- src/utils/constants.js | 12 ++- src/utils/provider-utils.js | 11 ++- static/app/i18n.js | 7 ++ 8 files changed, 136 insertions(+), 74 deletions(-) diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index 15bf9f5..f0cb257 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -116,8 +116,11 @@ export class ProviderPoolManager { const credData = JSON.parse(fileContent); const expiryTime = credData.expiry_date || credData.expiry || credData.expires_at; const nearExpiryMs = (currentConfig?.CRON_NEAR_MINUTES || 10) * 60 * 1000; - const isNearExpiry = expiryTime && (expiryTime - Date.now()) < nearExpiryMs; - if (isNearExpiry) { + if (!expiryTime) { + // 凭据文件缺少 expiry 字段,无法判断是否快过期,作为安全措施强制刷新 + this._log('warn', `Node ${providerStatus.uuid} (${providerType}) has no expiry field. Forcing refresh as safety measure...`); + this._enqueueRefresh(providerType, providerStatus); + } else if ((expiryTime - Date.now()) < nearExpiryMs) { this._log('warn', `Node ${providerStatus.uuid} (${providerType}) is near expiration. Enqueuing refresh...`); this._enqueueRefresh(providerType, providerStatus); } @@ -1692,14 +1695,15 @@ export class ProviderPoolManager { } /** - * Performs health checks on selected providers. + * Performs initial (startup) health checks on selected providers. * Respects SCHEDULED_HEALTH_CHECK.providerTypes configuration. - * + * Called once at server startup. + * * 设计决策:如果没有选择任何 provider types,则不进行检查任何 provider。 * 这是有意为之的设计 - 如果用户没有明确选择,则不需要自动健康检查。 * 区别于原来的逻辑(检查所有 provider),现在的行为更符合用户预期。 */ - async performHealthChecks() { + async performInitialHealthChecks() { const scheduledConfig = this.globalConfig?.SCHEDULED_HEALTH_CHECK; const selectedProviderTypes = scheduledConfig?.providerTypes; @@ -1788,7 +1792,7 @@ export class ProviderPoolManager { * This method is designed to be called periodically to proactively check provider health. * It respects provider-level isDisabled flag. */ - async performScheduledHealthChecks() { + async performHealthChecks() { const scheduledConfig = this.globalConfig?.SCHEDULED_HEALTH_CHECK; const checkStartTime = Date.now(); @@ -1920,7 +1924,7 @@ export class ProviderPoolManager { * Performs an actual health check for a specific provider. * * 设计决策:不检查 providerConfig.checkHealth 标志。 - * 健康检查是否执行由上层调用方(performScheduledHealthChecks / performHealthChecks) + * 健康检查是否执行由上层调用方(performHealthChecks / performInitialHealthChecks) * 通过 providerTypes 数组来决定,不在每个 provider 级别控制。 * 这样简化了逻辑,避免 per-provider 的 checkHealth flag 变得无用。 * @@ -1972,7 +1976,7 @@ export class ProviderPoolManager { await serviceAdapter.generateContent(modelName, requestWithSignal); clearTimeout(timeoutId); - // 注意:使用量计数由调用方处理(performScheduledHealthChecks/performHealthChecks) + // 注意:使用量计数由调用方处理(performHealthChecks/performInitialHealthChecks) // 这里只返回成功结果,让调用方统一处理状态更新和计数 return { success: true, modelName, errorMessage: null }; } catch (error) { diff --git a/src/services/api-manager.js b/src/services/api-manager.js index d9cb526..8cacc09 100644 --- a/src/services/api-manager.js +++ b/src/services/api-manager.js @@ -68,7 +68,7 @@ export function initializeAPIManagement(services) { logger.info(`[Heartbeat] Server is running. Current time: ${new Date().toLocaleString()}`, Object.keys(services)); // 循环遍历所有已初始化的服务适配器,并尝试刷新令牌 // if (getProviderPoolManager()) { - // await getProviderPoolManager().performHealthChecks(); // 定期执行健康检查 + // await getProviderPoolManager().performInitialHealthChecks(); // 定期执行健康检查 // } for (const providerKey in services) { const serviceAdapter = services[providerKey]; diff --git a/src/services/api-server.js b/src/services/api-server.js index 6caf415..4793776 100644 --- a/src/services/api-server.js +++ b/src/services/api-server.js @@ -359,7 +359,7 @@ async function startServer() { const poolManager = getProviderPoolManager(); if (poolManager) { logger.info('[Initialization] Performing initial health checks for provider pools...'); - poolManager.performHealthChecks(); + poolManager.performInitialHealthChecks(); } // 定时健康检查 @@ -394,7 +394,7 @@ async function startServer() { } isHealthCheckRunning = true; try { - await poolManager.performScheduledHealthChecks(); + await poolManager.performHealthChecks(); } catch (error) { logger.error('[ScheduledHealthCheck] Error:', error); } finally { @@ -426,13 +426,13 @@ async function startServer() { // 启动时运行健康检查 if (scheduledConfig.startupRun !== false) { logger.info('[ScheduledHealthCheck] Running scheduled health check on startup...'); - setImmediate(async () => { + setTimeout(async () => { try { await poolManager.performScheduledHealthChecks(); } catch (error) { logger.error('[ScheduledHealthCheck] Startup run error:', error); } - }); + }, 100); } // 设置定时任务 diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js index c62376a..b6a796c 100644 --- a/src/ui-modules/config-api.js +++ b/src/ui-modules/config-api.js @@ -129,11 +129,20 @@ export async function handleUpdateConfig(req, res, currentConfig) { // 防止路径遍历:解析后的绝对路径必须在工作目录内 const resolved = path.resolve(process.cwd(), p); const cwd = process.cwd(); - // Windows兼容:统一使用正斜杠进行比较 - const normalizedResolved = resolved.replace(/\\/g, '/'); - const normalizedCwd = cwd.replace(/\\/g, '/'); - if (normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd) { + + // 使用 path.relative 和 path.isAbsolute 进行更严格的校验 + const relativePath = path.relative(cwd, resolved); + const isInsideCwd = !path.isAbsolute(relativePath) && !relativePath.startsWith('..') && relativePath !== '..'; + + // Windows 大小写不敏感兼容:统一转换为小写比较 + const normalizedResolved = resolved.toLowerCase().replace(/\\/g, '/'); + const normalizedCwd = cwd.toLowerCase().replace(/\\/g, '/'); + const startsWithCwd = normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd; + + if (isInsideCwd && startsWithCwd) { currentConfig.SYSTEM_PROMPT_FILE_PATH = p; + } else { + logger.warn(`[UI API] Rejected SYSTEM_PROMPT_FILE_PATH traversal attempt: ${p}`); } } if (newConfig.SYSTEM_PROMPT_MODE !== undefined) currentConfig.SYSTEM_PROMPT_MODE = newConfig.SYSTEM_PROMPT_MODE; @@ -174,11 +183,20 @@ export async function handleUpdateConfig(req, res, currentConfig) { // 防止路径遍历:解析后的绝对路径必须在工作目录内 const resolved = path.resolve(process.cwd(), p); const cwd = process.cwd(); - // Windows兼容:统一使用正斜杠进行比较 - const normalizedResolved = resolved.replace(/\\/g, '/'); - const normalizedCwd = cwd.replace(/\\/g, '/'); - if (normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd) { + + // 使用 path.relative 和 path.isAbsolute 进行更严格的校验 + const relativePath = path.relative(cwd, resolved); + const isInsideCwd = !path.isAbsolute(relativePath) && !relativePath.startsWith('..') && relativePath !== '..'; + + // Windows 大小写不敏感兼容:统一转换为小写比较 + const normalizedResolved = resolved.toLowerCase().replace(/\\/g, '/'); + const normalizedCwd = cwd.toLowerCase().replace(/\\/g, '/'); + const startsWithCwd = normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd; + + if (isInsideCwd && startsWithCwd) { currentConfig.LOG_DIR = p; + } else { + logger.warn(`[UI API] Rejected LOG_DIR traversal attempt: ${p}`); } } if (newConfig.LOG_INCLUDE_REQUEST_ID !== undefined) currentConfig.LOG_INCLUDE_REQUEST_ID = newConfig.LOG_INCLUDE_REQUEST_ID; @@ -188,39 +206,45 @@ export async function handleUpdateConfig(req, res, currentConfig) { // Scheduled Health Check settings if (newConfig.SCHEDULED_HEALTH_CHECK !== undefined) { - const incoming = newConfig.SCHEDULED_HEALTH_CHECK; - const newInterval = (() => { - const val = Number(incoming?.interval); - return isNaN(val) ? HEALTH_CHECK.DEFAULT_INTERVAL_MS : Math.max(HEALTH_CHECK.MIN_INTERVAL_MS, val); - })(); - currentConfig.SCHEDULED_HEALTH_CHECK = { - enabled: incoming?.enabled === true, - startupRun: incoming?.startupRun !== false, - interval: newInterval, - providerTypes: Array.isArray(incoming?.providerTypes) ? incoming.providerTypes : [] - }; + const incoming = newConfig.SCHEDULED_HEALTH_CHECK; - // 检测 enabled 状态变化 - const wasEnabled = currentConfig.SCHEDULED_HEALTH_CHECK?.enabled === true; - const nowEnabled = incoming?.enabled === true; + // 检测 enabled 状态变化(在更新配置之前保存旧状态) + const prevConfig = currentConfig.SCHEDULED_HEALTH_CHECK || {}; + const wasEnabled = prevConfig.enabled === true; + const nowEnabled = incoming?.enabled === true; - if (currentConfig.SCHEDULED_HEALTH_CHECK) { - // 当 enabled 从 true -> false 时,清除 timer - if (wasEnabled && !nowEnabled && globalThis.stopHealthCheckTimer) { - globalThis.stopHealthCheckTimer(); - globalThis._activeHealthCheckInterval = undefined; - } - // 当 enabled 从 false -> true 时,启动 timer - else if (!wasEnabled && nowEnabled && globalThis.reloadHealthCheckTimer) { - globalThis._activeHealthCheckInterval = newInterval; - globalThis.reloadHealthCheckTimer(newInterval); - } - // 当 enabled=true 且 interval 变化时,重启 timer - else if (nowEnabled && newInterval !== globalThis._activeHealthCheckInterval) { - globalThis._activeHealthCheckInterval = newInterval; - globalThis.reloadHealthCheckTimer(newInterval); - } - } + const newInterval = (() => { + const val = Number(incoming?.interval); + return isNaN(val) ? HEALTH_CHECK.DEFAULT_INTERVAL_MS : Math.max(HEALTH_CHECK.MIN_INTERVAL_MS, Math.min(HEALTH_CHECK.MAX_INTERVAL_MS, val)); + })(); + + // 先保存旧的 interval 用于比较 + const oldInterval = globalThis._activeHealthCheckInterval; + + // 更新配置 + currentConfig.SCHEDULED_HEALTH_CHECK = { + enabled: nowEnabled, + startupRun: incoming?.startupRun !== false, + interval: newInterval, + providerTypes: Array.isArray(incoming?.providerTypes) ? incoming.providerTypes : [] + }; + + // 处理 timer 状态变化 + // 当 enabled 从 true -> false 时,清除 timer + if (wasEnabled && !nowEnabled && globalThis.stopHealthCheckTimer) { + globalThis.stopHealthCheckTimer(); + globalThis._activeHealthCheckInterval = undefined; + } + // 当 enabled 从 false -> true 时,启动 timer + else if (!wasEnabled && nowEnabled && globalThis.reloadHealthCheckTimer) { + globalThis._activeHealthCheckInterval = newInterval; + globalThis.reloadHealthCheckTimer(newInterval); + } + // 当 enabled=true 且 interval 变化时,重启 timer + else if (nowEnabled && newInterval !== oldInterval && globalThis.reloadHealthCheckTimer) { + globalThis._activeHealthCheckInterval = newInterval; + globalThis.reloadHealthCheckTimer(newInterval); + } } // Handle system prompt update diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index 534032a..09beaf4 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -7,18 +7,22 @@ import { broadcastEvent } from './event-broadcast.js'; import { getRegisteredProviders } from '../providers/adapter.js'; // 文件级互斥锁:防止并发读写导致数据丢失 -// HTML 脱敏:移除用户输入字段中的 HTML/JS,防止 XSS +// 安全净化:移除用户输入字段中的危险内容(script、事件处理器、javascript:协议等), +// 存储原始文本。HTML 转义统一由前端 escHtml() 负责,避免双编码问题。 function sanitizeProviderData(provider) { if (!provider || typeof provider !== 'object') return provider; const sanitized = { ...provider }; - // 允许在前端显示的纯文本字段做 HTML 转义 if (typeof sanitized.customName === 'string') { - sanitized.customName = sanitized.customName - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + let name = sanitized.customName; + // 拒绝包含 data: 协议(可能包含内嵌恶意内容) + if (/data\s*:/i.test(name)) return sanitized; + // 移除 (支持跨行匹配) + name = name.replace(/)<[^<]*)*<\/script>/gi, ''); + // 移除 HTML 事件处理器属性(onclick/onerror 等) + name = name.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, ''); + // 移除 javascript: 协议(更严格:要求独立单词边界,防止误匹配 "not javascript code") + name = name.replace(/\bjavascript\s*:/gi, ''); + sanitized.customName = name.trim(); } return sanitized; } @@ -39,9 +43,9 @@ function withFileLock(fn) { const next = _fileLockChain .then(() => fn()) .catch(err => { - // 记录错误但继续链式执行,防止死锁 + // 记录错误并抛出,中断操作 logger.error('[FileLock] Operation failed:', err?.message || err); - return null; + throw err; }); _fileLockChain = next.then(() => {}).catch(() => {}); return next; @@ -133,7 +137,11 @@ export async function handleGetProviderTypeModels(req, res, providerType) { * 添加新的提供商配置 */ export async function handleAddProvider(req, res, currentConfig, providerPoolManager) { - return withFileLock(() => _handleAddProvider(req, res, currentConfig, providerPoolManager)); + return withFileLock(() => _handleAddProvider(req, res, currentConfig, providerPoolManager)).catch(err => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'File operation failed: ' + err.message } })); + return true; + }); } async function _handleAddProvider(req, res, currentConfig, providerPoolManager) { try { @@ -223,7 +231,11 @@ async function _handleAddProvider(req, res, currentConfig, providerPoolManager) * 更新特定提供商配置 */ export async function handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { - return withFileLock(() => _handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid)); + return withFileLock(() => _handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid)).catch(err => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'File operation failed: ' + err.message } })); + return true; + }); } async function _handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { try { @@ -312,7 +324,11 @@ async function _handleUpdateProvider(req, res, currentConfig, providerPoolManage * 删除特定提供商配置 */ export async function handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { - return withFileLock(() => _handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid)); + return withFileLock(() => _handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid)).catch(err => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'File operation failed: ' + err.message } })); + return true; + }); } async function _handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { try { @@ -386,7 +402,11 @@ async function _handleDeleteProvider(req, res, currentConfig, providerPoolManage * 禁用/启用特定提供商配置 */ export async function handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action) { - return withFileLock(() => _handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action)); + return withFileLock(() => _handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action)).catch(err => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'File operation failed: ' + err.message } })); + return true; + }); } async function _handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action) { try { diff --git a/src/utils/constants.js b/src/utils/constants.js index 20c0071..2ff652c 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -15,10 +15,10 @@ export const HEALTH_CHECK = { // 密码安全相关常量 export const PASSWORD = { - // 最小密码长度 - MIN_LENGTH: 8, - // PBKDF2迭代次数 - PBKDF2_ITERATIONS: 100000, + // 最小密码长度(最少12位,与现代安全实践一致) + MIN_LENGTH: 12, + // PBKDF2迭代次数(OWASP 2023建议 SHA-512 ≥310,000次) + PBKDF2_ITERATIONS: 310000, // PBKDF2密钥长度(字节) PBKDF2_KEYLEN: 64, // PBKDF2哈希算法 @@ -38,7 +38,5 @@ export const NETWORK = { // 请求重试相关常量 export const RETRY = { // 最大重试次数 - MAX_RETRIES: 100, - // 默认重试次数 - DEFAULT_RETRIES: 3 + MAX_RETRIES: 100 }; diff --git a/src/utils/provider-utils.js b/src/utils/provider-utils.js index d7e9134..16c1eb7 100644 --- a/src/utils/provider-utils.js +++ b/src/utils/provider-utils.js @@ -94,10 +94,19 @@ export const PROVIDER_MAPPINGS = [ /** * 生成 UUID + * 兼容旧版 Node.js(<14.17.0):如果 crypto.randomUUID 不存在则使用 Math.random 回退方案 * @returns {string} UUID 字符串 */ export function generateUUID() { - return crypto.randomUUID(); + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + // 回退方案:使用 Math.random 生成标准 UUID v4 + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); } /** diff --git a/static/app/i18n.js b/static/app/i18n.js index 7512919..13a06ee 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -1195,6 +1195,13 @@ const translations = { 'config.proxy.tlsSidecarProxyUrl': 'Sidecar Upstream Proxy', 'config.proxy.tlsSidecarEnabledProviders': 'Providers Using TLS Sidecar', 'config.proxy.tlsSidecarNote': 'When enabled, requests for selected providers are routed through Go uTLS sidecar for perfect Chrome TLS/H2 fingerprint to bypass Cloudflare (requires restart)', + 'config.healthCheck.title': 'Scheduled Health Check', + 'config.healthCheck.enabled': 'Enable Scheduled Check', + 'config.healthCheck.startupRun': 'Run on Startup', + 'config.healthCheck.interval': 'Check Interval', + 'config.healthCheck.intervalNote': 'In milliseconds. Minimum 60000ms (1 min), maximum 3600000ms (1 hour). Enter manually or use quick select buttons', + 'config.healthCheck.providerTypes': 'Providers to Check', + 'config.healthCheck.providerTypesNote': 'Select provider types for scheduled health checks. Leave empty to skip all checks', 'config.log.title': 'Log Settings', 'config.log.enabled': 'Enable Logging', 'config.log.outputMode': 'Log Output Mode', From bc018cf16fba29955c810ac8946a8ff407f178c8 Mon Sep 17 00:00:00 2001 From: Wenaixi Date: Fri, 3 Apr 2026 01:37:53 +0800 Subject: [PATCH 26/28] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=AE=A1=E6=9F=A5=E4=B8=AD=E5=8F=91=E7=8E=B0=E7=9A=84?= =?UTF-8?q?=E5=85=B3=E9=94=AE=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 修复方法名错误 - 将 performScheduledHealthChecks() 改为 performHealthChecks() - 修复启动时健康检查调用错误 2. 改进路径遍历防护 - 仅在 Windows 平台执行小写转换 - 避免 Linux 平台路径大小写误判 3. 增强 XSS 防护 - 扩展危险协议检测(data/javascript/vbscript) - 使用更安全的 HTML 标签移除方式 - 添加 HTML 实体编码攻击防护 4. 为文件锁添加超时机制 - 防止操作永久挂起导致锁链阻塞 - 默认超时时间 30 秒 --- src/services/api-server.js | 2 +- src/ui-modules/config-api.js | 14 ++++++++------ src/ui-modules/provider-api.js | 33 ++++++++++++++++++++++++++------- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/src/services/api-server.js b/src/services/api-server.js index 4793776..5e0da81 100644 --- a/src/services/api-server.js +++ b/src/services/api-server.js @@ -428,7 +428,7 @@ async function startServer() { logger.info('[ScheduledHealthCheck] Running scheduled health check on startup...'); setTimeout(async () => { try { - await poolManager.performScheduledHealthChecks(); + await poolManager.performHealthChecks(); } catch (error) { logger.error('[ScheduledHealthCheck] Startup run error:', error); } diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js index b6a796c..d39b028 100644 --- a/src/ui-modules/config-api.js +++ b/src/ui-modules/config-api.js @@ -134,9 +134,10 @@ export async function handleUpdateConfig(req, res, currentConfig) { const relativePath = path.relative(cwd, resolved); const isInsideCwd = !path.isAbsolute(relativePath) && !relativePath.startsWith('..') && relativePath !== '..'; - // Windows 大小写不敏感兼容:统一转换为小写比较 - const normalizedResolved = resolved.toLowerCase().replace(/\\/g, '/'); - const normalizedCwd = cwd.toLowerCase().replace(/\\/g, '/'); + // Windows 大小写不敏感兼容:仅在 Windows 平台统一转换为小写比较 + const isWindows = process.platform === 'win32'; + const normalizedResolved = (isWindows ? resolved.toLowerCase() : resolved).replace(/\\/g, '/'); + const normalizedCwd = (isWindows ? cwd.toLowerCase() : cwd).replace(/\\/g, '/'); const startsWithCwd = normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd; if (isInsideCwd && startsWithCwd) { @@ -188,9 +189,10 @@ export async function handleUpdateConfig(req, res, currentConfig) { const relativePath = path.relative(cwd, resolved); const isInsideCwd = !path.isAbsolute(relativePath) && !relativePath.startsWith('..') && relativePath !== '..'; - // Windows 大小写不敏感兼容:统一转换为小写比较 - const normalizedResolved = resolved.toLowerCase().replace(/\\/g, '/'); - const normalizedCwd = cwd.toLowerCase().replace(/\\/g, '/'); + // Windows 大小写不敏感兼容:仅在 Windows 平台统一转换为小写比较 + const isWindows = process.platform === 'win32'; + const normalizedResolved = (isWindows ? resolved.toLowerCase() : resolved).replace(/\\/g, '/'); + const normalizedCwd = (isWindows ? cwd.toLowerCase() : cwd).replace(/\\/g, '/'); const startsWithCwd = normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd; if (isInsideCwd && startsWithCwd) { diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index 09beaf4..1d4cf3d 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -14,14 +14,22 @@ function sanitizeProviderData(provider) { const sanitized = { ...provider }; if (typeof sanitized.customName === 'string') { let name = sanitized.customName; - // 拒绝包含 data: 协议(可能包含内嵌恶意内容) - if (/data\s*:/i.test(name)) return sanitized; - // 移除 (支持跨行匹配) - name = name.replace(/)<[^<]*)*<\/script>/gi, ''); + + // 拒绝包含危险协议 + if (/(?:data|javascript|vbscript)\s*:/i.test(name)) { + sanitized.customName = ''; + return sanitized; + } + + // 移除所有 HTML 标签(更安全的方式) + name = name.replace(/<[^>]*>/g, ''); + // 移除 HTML 事件处理器属性(onclick/onerror 等) name = name.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, ''); - // 移除 javascript: 协议(更严格:要求独立单词边界,防止误匹配 "not javascript code") - name = name.replace(/\bjavascript\s*:/gi, ''); + + // 移除潜在的 HTML 实体编码攻击 + name = name.replace(/&[#\w]+;/g, ''); + sanitized.customName = name.trim(); } return sanitized; @@ -39,9 +47,20 @@ function sanitizeProviderPools(pools) { } // 使用 Promise 链式队列,确保文件操作顺序执行 let _fileLockChain = Promise.resolve(); + +// 超时包装函数:防止操作永久挂起导致锁链阻塞 +function withTimeout(promise, ms = 30000) { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Operation timeout after ${ms}ms`)), ms) + ) + ]); +} + function withFileLock(fn) { const next = _fileLockChain - .then(() => fn()) + .then(() => withTimeout(fn(), 30000)) .catch(err => { // 记录错误并抛出,中断操作 logger.error('[FileLock] Operation failed:', err?.message || err); From af3915311d14a487ef464c7e0545f017669022cc Mon Sep 17 00:00:00 2001 From: Wenaixi Date: Fri, 3 Apr 2026 02:20:51 +0800 Subject: [PATCH 27/28] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=AE=A1=E6=9F=A5=E5=8F=91=E7=8E=B0=E7=9A=8410?= =?UTF-8?q?=E4=B8=AA=E5=AE=89=E5=85=A8=E4=B8=8E=E6=AD=A3=E7=A1=AE=E6=80=A7?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix: provider-pool-manager: 移除 if(true) 占位符,改为读取凭据文件真实过期时间 - fix: provider-pool-manager: Math.min 展开大数组改为 reduce,防止栈溢出 - fix: provider-pool-manager: forceRefreshToken 调用前检查方法是否实现,不存在则 fallback - fix: provider-api: handleAddProvider 默认路径统一为 configs/provider_pools.json - fix: config-api: handleGetConfig 改为白名单字段过滤,REQUIRED_API_KEY 脱敏返回 - fix: api-server: 启动日志中 API Key 遮码处理 - fix: utils: generateUUID 改用 crypto.randomUUID() 替代 Math.random() - fix: config-manager: renderProviderTags innerHTML 加 escHtml 防 XSS 注入 - fix: config-manager: PROVIDER_POOLS_FILE_PATH 未定义时加 || '' 兜底 - fix: section-config.css: white 改为 var(--bg-primary, white) 支持暗黑模式 - chore: .gitignore 添加 AGENTS.md - chore: docker-compose.yml 添加代理环境变量 diff --git a/.gitignore b/.gitignore index f752bb4..c375cbc 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,5 @@ api-potluck-keys.json api-potluck-data.json # Codex credentials configs/codex/ +AGENTS.md diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 6977d13..8c08ef3 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -14,6 +14,12 @@ services: - ./configs:/app/configs environment: - ARGS= + - HTTP_PROXY=http://host.docker.internal:10801 + - http_proxy=http://host.docker.internal:10801 + - HTTPS_PROXY=http://host.docker.internal:10801 + - https_proxy=http://host.docker.internal:10801 + - NO_PROXY=localhost,127.0.0.1,host.docker.internal + - no_proxy=localhost,127.0.0.1,host.docker.internal healthcheck: test: ["CMD", "node", "healthcheck.js"] interval: 30s diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index f039e48..5875f1f 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -112,7 +112,12 @@ export class ProviderPoolManager { if (configPath && fs.existsSync(configPath)) { try { - if (true) { + const fileContent = fs.readFileSync(configPath, 'utf-8'); + const credData = JSON.parse(fileContent); + const expiryTime = credData.expiry_date || credData.expiry || credData.expires_at; + const nearExpiryMs = (currentConfig?.CRON_NEAR_MINUTES || 10) * 60 * 1000; + const isNearExpiry = expiryTime && (expiryTime - Date.now()) < nearExpiryMs; + if (isNearExpiry) { this._log('warn', `Node ${providerStatus.uuid} (${providerType}) is near expiration. Enqueuing refresh...`); this._enqueueRefresh(providerType, providerStatus); } @@ -389,7 +394,16 @@ export class ProviderPoolManager { // 调用适配器的 refreshToken 方法(内部封装了具体的刷新逻辑) if (typeof serviceAdapter.refreshToken === 'function') { const startTime = Date.now(); - force ? await serviceAdapter.forceRefreshToken() : await serviceAdapter.refreshToken() + if (force) { + if (typeof serviceAdapter.forceRefreshToken === 'function') { + await serviceAdapter.forceRefreshToken(); + } else { + this._log('warn', `forceRefreshToken not implemented for ${providerType}, falling back to refreshToken`); + await serviceAdapter.refreshToken(); + } + } else { + await serviceAdapter.refreshToken(); + } const duration = Date.now() - startTime; this._log('info', `Token refresh successful for node ${providerStatus.uuid} (Duration: ${duration}ms)`); @@ -452,7 +466,7 @@ export class ProviderPoolManager { const lastSelectionSeq = config._lastSelectionSeq || 0; if (minSeqInPool === -1) { const pool = this.providerStatus[providerStatus.type] || []; - minSeqInPool = Math.min(...pool.map(p => p.config._lastSelectionSeq || 0)); + minSeqInPool = pool.reduce((min, p) => Math.min(min, p.config._lastSelectionSeq || 0), Infinity); } const relativeSeq = Math.max(0, lastSelectionSeq - minSeqInPool); const cappedRelativeSeq = Math.min(relativeSeq, 100); @@ -1819,14 +1833,14 @@ export class ProviderPoolManager { continue; } - const checkStartTime = Date.now(); + const providerCheckStart = Date.now(); const checkModelName = provider.config.checkModelName || ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType] || 'unknown'; const displayName = customName || uuid.substring(0, 8); - + try { // Perform health check (health check is based on providerTypes configuration, not per-provider checkHealth flag) const result = await this._checkProviderHealth(providerType, provider.config); - const checkDuration = Date.now() - checkStartTime; + const checkDuration = Date.now() - providerCheckStart; if (!result.success) { // Provider is unhealthy @@ -1840,7 +1854,7 @@ export class ProviderPoolManager { this.markProviderHealthy(providerType, provider.config, false, result.modelName); } } catch (error) { - const checkDuration = Date.now() - checkStartTime; + const checkDuration = Date.now() - providerCheckStart; failCount++; this._log('error', `[ScheduledHealthCheck] ${displayName} (${providerType}) EXCEPTION: ${error.message} (${checkDuration}ms)`); this.markProviderUnhealthyImmediately(providerType, provider.config, error.message); diff --git a/src/services/api-server.js b/src/services/api-server.js index ad9bdc9..c1a850f 100644 --- a/src/services/api-server.js +++ b/src/services/api-server.js @@ -313,7 +313,7 @@ async function startServer() { logger.info(` System Prompt Mode: ${CONFIG.SYSTEM_PROMPT_MODE}`); logger.info(` Host: ${CONFIG.HOST}`); logger.info(` Port: ${CONFIG.SERVER_PORT}`); - logger.info(` Required API Key: ${CONFIG.REQUIRED_API_KEY}`); + logger.info(` Required API Key: ${CONFIG.REQUIRED_API_KEY ? CONFIG.REQUIRED_API_KEY.slice(0, 4) + '****' : '(none)'}`); logger.info(` Prompt Logging: ${CONFIG.PROMPT_LOG_MODE}${CONFIG.PROMPT_LOG_FILENAME ? ` (to ${CONFIG.PROMPT_LOG_FILENAME})` : ''}`); logger.info(`------------------------------------------`); logger.info(`\nUnified API Server running on http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}`); @@ -355,14 +355,14 @@ async function startServer() { setInterval(heartbeatAndRefreshToken, CONFIG.CRON_NEAR_MINUTES * 60 * 1000); } // 服务器完全启动后,执行初始健康检查 - const poolManager = getProviderPoolManager(); - if (poolManager) { - logger.info('[Initialization] Performing initial health checks for provider pools...'); - poolManager.performHealthChecks(); - } + const poolManager = getProviderPoolManager(); + if (poolManager) { + logger.info('[Initialization] Performing initial health checks for provider pools...'); + poolManager.performHealthChecks(); + } // 定时健康检查 - const scheduledConfig = CONFIG.SCHEDULED_HEALTH_CHECK; + const scheduledConfig = CONFIG.SCHEDULED_HEALTH_CHECK; if (scheduledConfig?.enabled) { // 设计决策:只验证最小值 60000ms,不设最大值。 // 前端有 max=3600000 (1小时) 的 UI 限制,但后端允许更大值以支持特殊需求。 diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js index 92c4dac..f6f2b0e 100644 --- a/src/ui-modules/config-api.js +++ b/src/ui-modules/config-api.js @@ -57,11 +57,48 @@ export async function handleGetConfig(req, res, currentConfig) { } } + // 白名单过滤:只返回前端需要的字段,避免泄露凭据路径、内部状态等敏感信息 + const safeConfig = { + HOST: currentConfig.HOST, + SERVER_PORT: currentConfig.SERVER_PORT, + MODEL_PROVIDER: currentConfig.MODEL_PROVIDER, + SYSTEM_PROMPT_FILE_PATH: currentConfig.SYSTEM_PROMPT_FILE_PATH, + SYSTEM_PROMPT_MODE: currentConfig.SYSTEM_PROMPT_MODE, + PROMPT_LOG_BASE_NAME: currentConfig.PROMPT_LOG_BASE_NAME, + PROMPT_LOG_MODE: currentConfig.PROMPT_LOG_MODE, + REQUEST_MAX_RETRIES: currentConfig.REQUEST_MAX_RETRIES, + REQUEST_BASE_DELAY: currentConfig.REQUEST_BASE_DELAY, + CREDENTIAL_SWITCH_MAX_RETRIES: currentConfig.CREDENTIAL_SWITCH_MAX_RETRIES, + CRON_NEAR_MINUTES: currentConfig.CRON_NEAR_MINUTES, + CRON_REFRESH_TOKEN: currentConfig.CRON_REFRESH_TOKEN, + LOGIN_EXPIRY: currentConfig.LOGIN_EXPIRY, + PROVIDER_POOLS_FILE_PATH: currentConfig.PROVIDER_POOLS_FILE_PATH, + MAX_ERROR_COUNT: currentConfig.MAX_ERROR_COUNT, + WARMUP_TARGET: currentConfig.WARMUP_TARGET, + REFRESH_CONCURRENCY_PER_PROVIDER: currentConfig.REFRESH_CONCURRENCY_PER_PROVIDER, + providerFallbackChain: currentConfig.providerFallbackChain, + modelFallbackMapping: currentConfig.modelFallbackMapping, + PROXY_URL: currentConfig.PROXY_URL, + PROXY_ENABLED_PROVIDERS: currentConfig.PROXY_ENABLED_PROVIDERS, + TLS_SIDECAR_ENABLED: currentConfig.TLS_SIDECAR_ENABLED, + TLS_SIDECAR_ENABLED_PROVIDERS: currentConfig.TLS_SIDECAR_ENABLED_PROVIDERS, + TLS_SIDECAR_PORT: currentConfig.TLS_SIDECAR_PORT, + TLS_SIDECAR_PROXY_URL: currentConfig.TLS_SIDECAR_PROXY_URL, + LOG_ENABLED: currentConfig.LOG_ENABLED, + LOG_OUTPUT_MODE: currentConfig.LOG_OUTPUT_MODE, + LOG_LEVEL: currentConfig.LOG_LEVEL, + LOG_DIR: currentConfig.LOG_DIR, + LOG_INCLUDE_REQUEST_ID: currentConfig.LOG_INCLUDE_REQUEST_ID, + LOG_INCLUDE_TIMESTAMP: currentConfig.LOG_INCLUDE_TIMESTAMP, + LOG_MAX_FILE_SIZE: currentConfig.LOG_MAX_FILE_SIZE, + LOG_MAX_FILES: currentConfig.LOG_MAX_FILES, + SCHEDULED_HEALTH_CHECK: currentConfig.SCHEDULED_HEALTH_CHECK, + // 脱敏:只返回是否设置了 API Key,不返回原文 + REQUIRED_API_KEY: currentConfig.REQUIRED_API_KEY ? '******' : '', + systemPrompt, + }; res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - ...currentConfig, - systemPrompt - })); + res.end(JSON.stringify(safeConfig)); return true; } @@ -129,8 +166,10 @@ export async function handleUpdateConfig(req, res, currentConfig) { providerTypes: Array.isArray(incoming?.providerTypes) ? incoming.providerTypes : [] }; - // 如果定时器已存在且 enabled,重新加载 timer(interval 变化时) - if (globalThis.reloadHealthCheckTimer && currentConfig.SCHEDULED_HEALTH_CHECK.enabled) { + // 如果定时器已存在且 enabled,仅在 interval 实际变化时重新加载 timer + const previousInterval = currentConfig.SCHEDULED_HEALTH_CHECK._activeInterval; + if (globalThis.reloadHealthCheckTimer && currentConfig.SCHEDULED_HEALTH_CHECK.enabled && newInterval !== previousInterval) { + currentConfig.SCHEDULED_HEALTH_CHECK._activeInterval = newInterval; globalThis.reloadHealthCheckTimer(newInterval); } } diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index 2789de3..a2a02fc 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -115,7 +115,7 @@ export async function handleAddProvider(req, res, currentConfig, providerPoolMan providerConfig.errorCount = providerConfig.errorCount || 0; providerConfig.lastErrorTime = providerConfig.lastErrorTime || null; - const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json'; + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; let providerPools = {}; // Load existing pools diff --git a/src/utils/provider-utils.js b/src/utils/provider-utils.js index 905b8e8..d7e9134 100644 --- a/src/utils/provider-utils.js +++ b/src/utils/provider-utils.js @@ -97,11 +97,7 @@ export const PROVIDER_MAPPINGS = [ * @returns {string} UUID 字符串 */ export function generateUUID() { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - const r = Math.random() * 16 | 0; - const v = c === 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); + return crypto.randomUUID(); } /** diff --git a/static/app/config-manager.js b/static/app/config-manager.js index 130bfdc..36ac1cb 100644 --- a/static/app/config-manager.js +++ b/static/app/config-manager.js @@ -53,10 +53,11 @@ function renderProviderTags(container, configs, isRequired) { // 过滤掉不可见的提供商 const visibleConfigs = configs.filter(c => c.visible !== false); + const escHtml = s => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); container.innerHTML = visibleConfigs.map(c => ` - `).join(''); @@ -157,7 +158,7 @@ async function loadConfiguration() { if (cronNearMinutesEl) cronNearMinutesEl.value = data.CRON_NEAR_MINUTES || 1; if (cronRefreshTokenEl) cronRefreshTokenEl.checked = data.CRON_REFRESH_TOKEN || false; if (loginExpiryEl) loginExpiryEl.value = data.LOGIN_EXPIRY || 3600; - if (providerPoolsFilePathEl) providerPoolsFilePathEl.value = data.PROVIDER_POOLS_FILE_PATH; + if (providerPoolsFilePathEl) providerPoolsFilePathEl.value = data.PROVIDER_POOLS_FILE_PATH || ''; if (maxErrorCountEl) maxErrorCountEl.value = data.MAX_ERROR_COUNT || 10; if (warmupTargetEl) warmupTargetEl.value = data.WARMUP_TARGET || 0; if (refreshConcurrencyPerProviderEl) refreshConcurrencyPerProviderEl.value = data.REFRESH_CONCURRENCY_PER_PROVIDER || 1; @@ -248,7 +249,7 @@ async function loadConfiguration() { const scheduledHealthCheckIntervalEl = document.getElementById('scheduledHealthCheckInterval'); if (data.SCHEDULED_HEALTH_CHECK) { - if (scheduledHealthCheckEnabledEl) scheduledHealthCheckEnabledEl.checked = data.SCHEDULED_HEALTH_CHECK.enabled !== false; + if (scheduledHealthCheckEnabledEl) scheduledHealthCheckEnabledEl.checked = data.SCHEDULED_HEALTH_CHECK.enabled === true; if (scheduledHealthCheckStartupRunEl) scheduledHealthCheckStartupRunEl.checked = data.SCHEDULED_HEALTH_CHECK.startupRun !== false; if (scheduledHealthCheckIntervalEl) scheduledHealthCheckIntervalEl.value = data.SCHEDULED_HEALTH_CHECK.interval || 600000; } else { diff --git a/static/components/section-config.css b/static/components/section-config.css index e16338b..aebf6c4 100644 --- a/static/components/section-config.css +++ b/static/components/section-config.css @@ -173,7 +173,7 @@ textarea.form-control { width: 18px; left: 3px; bottom: 2px; - background-color: white; + background-color: var(--bg-primary, white); transition: var(--transition); border-radius: 50%; box-shadow: 0 1px 3px var(--neutral-shadow-30); --- tests/security-fixes.test.js | 325 ++++++++++++++++++++++++++++++ tests/security-fixes.unit.test.js | 205 +++++++++++++++++++ 2 files changed, 530 insertions(+) create mode 100644 tests/security-fixes.test.js create mode 100644 tests/security-fixes.unit.test.js diff --git a/tests/security-fixes.test.js b/tests/security-fixes.test.js new file mode 100644 index 0000000..fe854f3 --- /dev/null +++ b/tests/security-fixes.test.js @@ -0,0 +1,325 @@ +/** + * Security Fixes Integration Test Suite + * + * 测试最近修复的安全问题: + * 1. XSS 防护 - sanitizeProviderData + * 2. 路径遍历防护 - 路径验证逻辑 + * 3. 文件锁超时机制 + * 4. 健康检查方法调用 + */ + +import { describe, test, expect } from '@jest/globals'; +import { fetch } from 'undici'; + +const TEST_SERVER_BASE_URL = process.env.TEST_SERVER_BASE_URL || 'http://localhost:3000'; +const TEST_API_KEY = process.env.TEST_API_KEY || '123456'; + +describe('Security Fixes Integration Tests', () => { + + describe('XSS Protection', () => { + test('should remove script tags from customName', async () => { + const maliciousName = 'TestProvider'; + const response = await fetch(`${TEST_SERVER_BASE_URL}/api/providers`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${TEST_API_KEY}` + }, + body: JSON.stringify({ + providerType: 'openai-custom', + providerConfig: { + customName: maliciousName, + OPENAI_CUSTOM_BASE_URL: 'https://api.example.com', + OPENAI_CUSTOM_API_KEY: 'test-key' + } + }) + }); + + const data = await response.json(); + expect(data.provider.customName).not.toContain(''); + expect(data.provider.customName).toContain('TestProvider'); + }); + + test('should reject javascript: protocol', async () => { + const maliciousName = 'javascript:alert("XSS")'; + const response = await fetch(`${TEST_SERVER_BASE_URL}/api/providers`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${TEST_API_KEY}` + }, + body: JSON.stringify({ + providerType: 'openai-custom', + providerConfig: { + customName: maliciousName, + OPENAI_CUSTOM_BASE_URL: 'https://api.example.com', + OPENAI_CUSTOM_API_KEY: 'test-key' + } + }) + }); + + const data = await response.json(); + expect(data.provider.customName).toBe(''); + }); + + test('should reject data: protocol', async () => { + const maliciousName = 'data:text/html,'; + const response = await fetch(`${TEST_SERVER_BASE_URL}/api/providers`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${TEST_API_KEY}` + }, + body: JSON.stringify({ + providerType: 'openai-custom', + providerConfig: { + customName: maliciousName, + OPENAI_CUSTOM_BASE_URL: 'https://api.example.com', + OPENAI_CUSTOM_API_KEY: 'test-key' + } + }) + }); + + const data = await response.json(); + expect(data.provider.customName).toBe(''); + }); + + test('should remove HTML event handlers', async () => { + const maliciousName = ''; + const response = await fetch(`${TEST_SERVER_BASE_URL}/api/providers`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${TEST_API_KEY}` + }, + body: JSON.stringify({ + providerType: 'openai-custom', + providerConfig: { + customName: maliciousName, + OPENAI_CUSTOM_BASE_URL: 'https://api.example.com', + OPENAI_CUSTOM_API_KEY: 'test-key' + } + }) + }); + + const data = await response.json(); + expect(data.provider.customName).not.toContain('onerror'); + expect(data.provider.customName).not.toContain(' { + const maliciousName = '<script>alert(1)</script>'; + const response = await fetch(`${TEST_SERVER_BASE_URL}/api/providers`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${TEST_API_KEY}` + }, + body: JSON.stringify({ + providerType: 'openai-custom', + providerConfig: { + customName: maliciousName, + OPENAI_CUSTOM_BASE_URL: 'https://api.example.com', + OPENAI_CUSTOM_API_KEY: 'test-key' + } + }) + }); + + const data = await response.json(); + expect(data.provider.customName).not.toContain('<'); + expect(data.provider.customName).not.toContain('>'); + }); + + test('should preserve normal text', async () => { + const normalName = 'My Test Provider 123'; + const response = await fetch(`${TEST_SERVER_BASE_URL}/api/providers`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${TEST_API_KEY}` + }, + body: JSON.stringify({ + providerType: 'openai-custom', + providerConfig: { + customName: normalName, + OPENAI_CUSTOM_BASE_URL: 'https://api.example.com', + OPENAI_CUSTOM_API_KEY: 'test-key' + } + }) + }); + + const data = await response.json(); + expect(data.provider.customName).toBe(normalName); + }); + }); + + describe('Path Traversal Protection', () => { + test('should reject paths with ..', async () => { + const maliciousPath = '../../../etc/passwd'; + const response = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${TEST_API_KEY}` + }, + body: JSON.stringify({ + SYSTEM_PROMPT_FILE_PATH: maliciousPath + }) + }); + + expect(response.status).toBe(200); + + const getResponse = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, { + headers: { + 'Authorization': `Bearer ${TEST_API_KEY}` + } + }); + const config = await getResponse.json(); + expect(config.SYSTEM_PROMPT_FILE_PATH).not.toBe(maliciousPath); + }); + + test('should accept valid paths within working directory', async () => { + const validPath = 'configs/my_prompt.txt'; + const response = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${TEST_API_KEY}` + }, + body: JSON.stringify({ + SYSTEM_PROMPT_FILE_PATH: validPath + }) + }); + + expect(response.status).toBe(200); + + const getResponse = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, { + headers: { + 'Authorization': `Bearer ${TEST_API_KEY}` + } + }); + const config = await getResponse.json(); + expect(config.SYSTEM_PROMPT_FILE_PATH).toBe(validPath); + }); + }); + + describe('Health Check Configuration', () => { + test('should save scheduled health check settings', async () => { + const response = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${TEST_API_KEY}` + }, + body: JSON.stringify({ + SCHEDULED_HEALTH_CHECK: { + enabled: true, + startupRun: true, + interval: 300000, + providerTypes: ['openai-custom'] + } + }) + }); + + expect(response.status).toBe(200); + + const getResponse = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, { + headers: { + 'Authorization': `Bearer ${TEST_API_KEY}` + } + }); + const config = await getResponse.json(); + expect(config.SCHEDULED_HEALTH_CHECK).toBeDefined(); + expect(config.SCHEDULED_HEALTH_CHECK.enabled).toBe(true); + expect(config.SCHEDULED_HEALTH_CHECK.interval).toBe(300000); + }); + + test('should enforce minimum interval', async () => { + const response = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${TEST_API_KEY}` + }, + body: JSON.stringify({ + SCHEDULED_HEALTH_CHECK: { + enabled: true, + interval: 30000 + } + }) + }); + + expect(response.status).toBe(200); + + const getResponse = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, { + headers: { + 'Authorization': `Bearer ${TEST_API_KEY}` + } + }); + const config = await getResponse.json(); + expect(config.SCHEDULED_HEALTH_CHECK.interval).toBeGreaterThanOrEqual(60000); + }); + }); + + describe('Configuration Validation', () => { + test('should reject invalid port numbers', async () => { + const response = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${TEST_API_KEY}` + }, + body: JSON.stringify({ + SERVER_PORT: 99999 + }) + }); + + expect(response.status).toBe(200); + + const getResponse = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, { + headers: { + 'Authorization': `Bearer ${TEST_API_KEY}` + } + }); + const config = await getResponse.json(); + expect(config.SERVER_PORT).not.toBe(99999); + }); + + test('should reject excessive retry counts', async () => { + const response = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${TEST_API_KEY}` + }, + body: JSON.stringify({ + REQUEST_MAX_RETRIES: 999 + }) + }); + + expect(response.status).toBe(200); + + const getResponse = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, { + headers: { + 'Authorization': `Bearer ${TEST_API_KEY}` + } + }); + const config = await getResponse.json(); + expect(config.REQUEST_MAX_RETRIES).toBeLessThanOrEqual(100); + }); + + test('should mask API key in response', async () => { + const response = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, { + headers: { + 'Authorization': `Bearer ${TEST_API_KEY}` + } + }); + const config = await response.json(); + + if (config.REQUIRED_API_KEY) { + expect(config.REQUIRED_API_KEY).toContain('*'); + } + }); + }); +}); diff --git a/tests/security-fixes.unit.test.js b/tests/security-fixes.unit.test.js new file mode 100644 index 0000000..d648710 --- /dev/null +++ b/tests/security-fixes.unit.test.js @@ -0,0 +1,205 @@ +/** + * Unit Tests for Security Fixes + * + * 这些是不需要运行服务器的纯单元测试 + * 可以直接运行: npm test -- tests/security-fixes.unit.test.js + */ + +import { describe, test, expect } from '@jest/globals'; + +// ========== 模拟 sanitizeProviderData 函数 ========== +function sanitizeProviderData(provider) { + if (!provider || typeof provider !== 'object') return provider; + const sanitized = { ...provider }; + if (typeof sanitized.customName === 'string') { + let name = sanitized.customName; + + // 拒绝包含危险协议 + if (/(?:data|javascript|vbscript)\s*:/i.test(name)) { + sanitized.customName = ''; + return sanitized; + } + + // 移除所有 HTML 标签 + name = name.replace(/<[^>]*>/g, ''); + + // 移除 HTML 事件处理器属性 + name = name.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, ''); + + // 移除潜在的 HTML 实体编码攻击 + name = name.replace(/&[#\w]+;/g, ''); + + sanitized.customName = name.trim(); + } + return sanitized; +} + +// ========== 模拟 withTimeout 函数 ========== +function withTimeout(promise, ms = 30000) { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Operation timeout after ${ms}ms`)), ms) + ) + ]); +} + +// ========== 模拟路径验证逻辑 ========== +import path from 'path'; + +function validatePath(inputPath, cwd) { + const resolved = path.resolve(cwd, inputPath); + const relativePath = path.relative(cwd, resolved); + const isInsideCwd = !path.isAbsolute(relativePath) && !relativePath.startsWith('..') && relativePath !== '..'; + + const isWindows = process.platform === 'win32'; + const normalizedResolved = (isWindows ? resolved.toLowerCase() : resolved).replace(/\\/g, '/'); + const normalizedCwd = (isWindows ? cwd.toLowerCase() : cwd).replace(/\\/g, '/'); + const startsWithCwd = normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd; + + return isInsideCwd && startsWithCwd; +} + +describe('Unit Tests - sanitizeProviderData', () => { + test('should remove script tags', () => { + const input = { customName: 'TestProvider' }; + const result = sanitizeProviderData(input); + expect(result.customName).not.toContain(''); + expect(result.customName).toContain('TestProvider'); + }); + + test('should reject javascript: protocol', () => { + const input = { customName: 'javascript:alert("XSS")' }; + const result = sanitizeProviderData(input); + expect(result.customName).toBe(''); + }); + + test('should reject data: protocol', () => { + const input = { customName: 'data:text/html,' }; + const result = sanitizeProviderData(input); + expect(result.customName).toBe(''); + }); + + test('should reject vbscript: protocol', () => { + const input = { customName: 'vbscript:msgbox("XSS")' }; + const result = sanitizeProviderData(input); + expect(result.customName).toBe(''); + }); + + test('should remove all HTML tags', () => { + const input = { customName: '
Test
' }; + const result = sanitizeProviderData(input); + expect(result.customName).toBe('Test'); + expect(result.customName).not.toContain('<'); + expect(result.customName).not.toContain('>'); + }); + + test('should remove event handlers', () => { + const input = { customName: 'Test onclick="alert(1)" Provider' }; + const result = sanitizeProviderData(input); + expect(result.customName).not.toContain('onclick'); + expect(result.customName).toContain('Test'); + expect(result.customName).toContain('Provider'); + }); + + test('should remove HTML entities', () => { + const input = { customName: 'Test<script>Provider'' }; + const result = sanitizeProviderData(input); + expect(result.customName).not.toContain('<'); + expect(result.customName).not.toContain('>'); + expect(result.customName).not.toContain('''); + }); + + test('should preserve normal text', () => { + const input = { customName: 'My Test Provider 123' }; + const result = sanitizeProviderData(input); + expect(result.customName).toBe('My Test Provider 123'); + }); + + test('should handle empty string', () => { + const input = { customName: '' }; + const result = sanitizeProviderData(input); + expect(result.customName).toBe(''); + }); + + test('should handle null/undefined', () => { + expect(sanitizeProviderData(null)).toBe(null); + expect(sanitizeProviderData(undefined)).toBe(undefined); + }); + + test('should handle object without customName', () => { + const input = { uuid: '123', type: 'test' }; + const result = sanitizeProviderData(input); + expect(result).toEqual(input); + }); + + test('should handle complex XSS vectors', () => { + const vectors = [ + '', + '', + '