From 2ea379cfbf8015c86beb9832cf06f69ee465e693 Mon Sep 17 00:00:00 2001 From: Wenaixi Date: Sun, 12 Apr 2026 14:30:24 +0800 Subject: [PATCH 001/135] =?UTF-8?q?chore(docker):=20=E6=94=B9=E8=BF=9B=20D?= =?UTF-8?q?ocker=20=E9=85=8D=E7=BD=AE=E6=94=AF=E6=8C=81=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E5=92=8C=E5=BC=80=E5=8F=91=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat(docker): Dockerfile 添加 HTTP_PROXY/HTTPS_PROXY 构建参数 - 支持在构建时使用代理 - 适用于国内网络环境 - feat(docker): 添加 docker-compose.dev.yml 开发模式配置 - 支持 Node.js --watch 热重载 - fix(docker): 修正 docker-compose.build.yml 路径和注释 - chore(docker): 添加 VERSION 文件用于版本追踪 --- Dockerfile | 10 ++++++++++ docker/VERSION | 1 + docker/docker-compose.build.yml | 8 ++++---- docker/docker-compose.dev.yml | 12 ++++++++++++ 4 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 docker/VERSION create mode 100644 docker/docker-compose.dev.yml diff --git a/Dockerfile b/Dockerfile index 15ca582c2..5b6ace0da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,11 @@ # ── Stage 1: 编译 Go TLS sidecar ── FROM golang:1.22-alpine AS sidecar-builder +ARG HTTP_PROXY +ARG HTTPS_PROXY +ENV HTTP_PROXY=$HTTP_PROXY +ENV HTTPS_PROXY=$HTTPS_PROXY + RUN apk add --no-cache git WORKDIR /build @@ -19,6 +24,11 @@ FROM node:20-alpine LABEL maintainer="AIClient2API Team" LABEL description="Docker image for AIClient2API server" +ARG HTTP_PROXY +ARG HTTPS_PROXY +ENV HTTP_PROXY=$HTTP_PROXY +ENV HTTPS_PROXY=$HTTPS_PROXY + # 安装必要的系统工具(tar 用于更新功能,git 用于版本检查,procps 用于系统监控) RUN apk add --no-cache tar git procps diff --git a/docker/VERSION b/docker/VERSION new file mode 100644 index 000000000..94f15e9cc --- /dev/null +++ b/docker/VERSION @@ -0,0 +1 @@ +2.13.1 diff --git a/docker/docker-compose.build.yml b/docker/docker-compose.build.yml index 31fa518d0..0b17c66d2 100644 --- a/docker/docker-compose.build.yml +++ b/docker/docker-compose.build.yml @@ -1,7 +1,7 @@ services: aiclient-api: # 方式二:从 Dockerfile 本地构建 - # 使用方法: docker compose -f docker-compose.build.yml up -d --build + # 使用方法: docker compose -f docker/docker-compose.build.yml -f docker/docker-compose.dev.yml up -d --build build: context: .. dockerfile: Dockerfile @@ -9,11 +9,11 @@ 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 + - ../configs:/app/configs environment: - ARGS= healthcheck: @@ -21,4 +21,4 @@ services: interval: 30s timeout: 3s start_period: 5s - retries: 3 \ No newline at end of file + retries: 3 diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml new file mode 100644 index 000000000..0954bb291 --- /dev/null +++ b/docker/docker-compose.dev.yml @@ -0,0 +1,12 @@ +# 开发模式覆盖配置 - 热重载 +# 使用方法: docker compose -f docker/docker-compose.build.yml -f docker/docker-compose.dev.yml up -d --build + +services: + aiclient-api: + build: + context: .. + dockerfile: Dockerfile + volumes: + - ./configs:/app/configs:ro + - ..:/app:ro + command: ["node", "--watch", "src/core/master.js"] From 4ee7e7f752d45a55d7803643451a4c866d59ed66 Mon Sep 17 00:00:00 2001 From: Wenaixi Date: Sun, 12 Apr 2026 23:07:54 +0800 Subject: [PATCH 002/135] =?UTF-8?q?fix(docker):=20=E4=BF=AE=E5=A4=8D=20vol?= =?UTF-8?q?ume=20=E6=8C=82=E8=BD=BD=E8=B7=AF=E5=BE=84=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E8=A6=86=E7=9B=96=20node=5Fmodules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/docker-compose.dev.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 0954bb291..8b9205057 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -7,6 +7,10 @@ services: context: .. dockerfile: Dockerfile volumes: - - ./configs:/app/configs:ro - - ..:/app:ro + # 从项目根目录挂载 configs,确保路径正确 + - ../configs:/app/configs:ro + # 挂载源代码以支持热重载 + - ../src:/app/src:ro + - ../package.json:/app/package.json:ro + # 保留容器内的 node_modules,避免被宿主机覆盖 command: ["node", "--watch", "src/core/master.js"] From a2db70c39ac6e375ba7a1c0fa14b5c9b6437cb3b Mon Sep 17 00:00:00 2001 From: Jarvis-Drawf Date: Mon, 13 Apr 2026 11:16:15 +0800 Subject: [PATCH 003/135] =?UTF-8?q?fix(ui):=20=E7=A7=BB=E9=99=A4=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=20backdrop-filter=20blur=20=E4=BF=AE=E5=A4=8D=20UI=20?= =?UTF-8?q?=E5=8D=A1=E9=A1=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: 所有模态弹窗打开时出现明显 UI 卡顿和掉帧。根因是 backdrop-filter: blur() 强制 GPU 对整个视口背景进行模糊计算,在高 DPI 屏幕或复杂页面布局下导致帧率骤降。 修复: 1. 移除所有 backdrop-filter: blur() 调用(CSS 类和 JS 内联样式) 2. 统一遮罩层背景为 var(--overlay-bg)(亮色 0.6,暗色 0.8) 影响:4 文件,+4 -10 行,无功能变更,安全合并。 --- static/app/mobile.css | 2 +- static/app/provider-manager.js | 4 +--- static/components/section-providers.css | 5 +---- static/components/section-upload-config.css | 3 +-- 4 files changed, 4 insertions(+), 10 deletions(-) diff --git a/static/app/mobile.css b/static/app/mobile.css index 48b718c19..a0da7f797 100644 --- a/static/app/mobile.css +++ b/static/app/mobile.css @@ -56,7 +56,7 @@ left: 0; right: 0; bottom: 0; - background: rgba(0, 0, 0, 0.5); + background: var(--overlay-bg); z-index: 99; opacity: 0; transition: opacity 0.3s ease; diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js index 02087ac39..8ac44ed4e 100644 --- a/static/app/provider-manager.js +++ b/static/app/provider-manager.js @@ -554,9 +554,7 @@ function showSimplePrompt(title, placeholder, callback) { overlay.className = 'modal-overlay'; overlay.style.display = 'flex'; overlay.style.zIndex = '3000'; - overlay.style.background = 'rgba(0, 0, 0, 0.2)'; - overlay.style.backdropFilter = 'blur(2px)'; - + overlay.innerHTML = ` - - ${totalPages > 1 ? renderPagination(1, totalPages, providers.length) : ''} - -
- ${renderProviderListPaginated(providers, 1)} + +
+
+ + +
+
+ + +
- ${totalPages > 1 ? renderPagination(1, totalPages, providers.length, 'bottom') : ''} +
+
+
`; @@ -461,9 +479,8 @@ function showProviderManagerModal(data) { // 添加模态框事件监听 addModalEventListeners(modal); - // 先获取该提供商类型的模型列表(只调用一次API) - const pageProviders = providers.slice(0, PROVIDERS_PER_PAGE); - loadModelsForProviderType(providerType, pageProviders); + // 初始渲染 + window.goToProviderPage(1); } /** @@ -475,16 +492,19 @@ function showProviderManagerModal(data) { * @returns {string} HTML字符串 */ function renderPagination(page, totalPages, totalItems, position = 'top') { + if (totalPages <= 1 || currentViewMode === 'card') { + return `
`; + } + const startItem = (page - 1) * PROVIDERS_PER_PAGE + 1; const endItem = Math.min(page * PROVIDERS_PER_PAGE, totalItems); - // 生成页码按钮 let pageButtons = ''; const maxVisiblePages = 5; let startPage = Math.max(1, page - Math.floor(maxVisiblePages / 2)); let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1); - if (endPage - startPage < maxVisiblePages - 1) { + if (endPage - startPage + 1 < maxVisiblePages) { startPage = Math.max(1, endPage - maxVisiblePages + 1); } @@ -531,31 +551,69 @@ function renderPagination(page, totalPages, totalItems, position = 'top') { `; } +/** + * 获取过滤后的提供商列表 + */ +function getFilteredProviders() { + if (!nodeSearchTerm) return currentProviders; + const term = nodeSearchTerm.toLowerCase().trim(); + return currentProviders.filter(p => { + // 搜索字段:自定义名称、UUID、API Key、Base URL、OAuth 路径等 + const searchFields = [ + p.customName, + p.uuid, + p.OPENAI_API_KEY, + p.OPENAI_BASE_URL, + p.CLAUDE_API_KEY, + p.CLAUDE_BASE_URL, + p.GEMINI_OAUTH_CREDS_FILE_PATH, + p.KIRO_OAUTH_CREDS_FILE_PATH, + p.QWEN_OAUTH_CREDS_FILE_PATH, + p.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH, + p.IFLOW_OAUTH_CREDS_FILE_PATH, + p.CODEX_OAUTH_CREDS_FILE_PATH, + p.GROK_COOKIE_TOKEN, + p.FORWARD_API_KEY, + p.checkModelName + ]; + + return searchFields.some(field => + field && String(field).toLowerCase().includes(term) + ); + }); +} + /** * 跳转到指定页 * @param {number} page - 目标页码 */ function goToProviderPage(page) { - const totalPages = Math.ceil(currentProviders.length / PROVIDERS_PER_PAGE); + const filteredProviders = getFilteredProviders(); + const totalPages = Math.ceil(filteredProviders.length / PROVIDERS_PER_PAGE); // 验证页码范围 if (page < 1) page = 1; - if (page > totalPages) page = totalPages; + if (page > totalPages && totalPages > 0) page = totalPages; + if (totalPages === 0) page = 1; currentPage = page; // 更新提供商列表 const providerList = document.getElementById('providerList'); if (providerList) { - providerList.innerHTML = renderProviderListPaginated(currentProviders, page); + providerList.innerHTML = renderProviderListPaginated(filteredProviders, page); } // 更新分页控件 - const paginationContainers = document.querySelectorAll('.pagination-container'); - paginationContainers.forEach(container => { - const position = container.getAttribute('data-position'); - container.outerHTML = renderPagination(page, totalPages, currentProviders.length, position); - }); + const paginationTop = document.getElementById('paginationTop'); + const paginationBottom = document.getElementById('paginationBottom'); + + if (paginationTop) { + paginationTop.innerHTML = totalPages > 1 ? renderPagination(page, totalPages, filteredProviders.length) : ''; + } + if (paginationBottom) { + paginationBottom.innerHTML = totalPages > 1 ? renderPagination(page, totalPages, filteredProviders.length, 'bottom') : ''; + } // 滚动到顶部 const modalBody = document.querySelector('.provider-modal-body'); @@ -565,8 +623,8 @@ function goToProviderPage(page) { // 为当前页的提供商加载模型列表 const startIndex = (page - 1) * PROVIDERS_PER_PAGE; - const endIndex = Math.min(startIndex + PROVIDERS_PER_PAGE, currentProviders.length); - const pageProviders = currentProviders.slice(startIndex, endIndex); + const endIndex = Math.min(startIndex + PROVIDERS_PER_PAGE, filteredProviders.length); + const pageProviders = filteredProviders.slice(startIndex, endIndex); // 如果已缓存模型列表,直接使用 if (!usesManagedModelList(currentProviderType) && cachedModels.length > 0) { @@ -585,6 +643,20 @@ function goToProviderPage(page) { * @returns {string} HTML字符串 */ function renderProviderListPaginated(providers, page) { + if (providers.length === 0) { + return ` +
+ +

${t('common.noResults') || '没有找到匹配的节点'}

+
+ `; + } + + // 如果是卡片模式,显示所有节点,不分页 + if (currentViewMode === 'card') { + return renderProviderList(providers); + } + const startIndex = (page - 1) * PROVIDERS_PER_PAGE; const endIndex = Math.min(startIndex + PROVIDERS_PER_PAGE, providers.length); const pageProviders = providers.slice(startIndex, endIndex); @@ -650,6 +722,18 @@ function addModalEventListeners(modal) { // 点击背景关闭模态框 const handleBackgroundClick = (event) => { if (event.target === modal) { + // 检查是否有正在编辑的节点 + const editingProvider = modal.querySelector('.provider-item-detail.editing, .provider-item-card.editing'); + if (editingProvider) { + // showToast(t('common.warning'), '请先保存或取消编辑操作', 'warning'); + return; + } + // 检查是否有正在新增的表单 + const addForm = modal.querySelector('.add-provider-form'); + if (addForm) { + // showToast(t('common.warning'), '请先保存或取消添加操作', 'warning'); + return; + } modal.remove(); document.removeEventListener('keydown', handleEscKey); } @@ -684,6 +768,38 @@ function addModalEventListeners(modal) { } } }; + + // 节点搜索事件处理 + const searchInput = modal.querySelector('#nodeSearchInput'); + if (searchInput) { + searchInput.addEventListener('input', (e) => { + nodeSearchTerm = e.target.value; + window.goToProviderPage(1); // 搜索时重置回第一页 + }); + } + + // 视图模式切换事件处理 + const viewModeBtns = modal.querySelectorAll('.view-mode-btn'); + viewModeBtns.forEach(btn => { + btn.addEventListener('click', () => { + const mode = btn.dataset.mode; + if (mode === currentViewMode) return; + + currentViewMode = mode; + localStorage.setItem('providerViewMode', mode); + + // 更新按钮状态 + viewModeBtns.forEach(b => { + const isActive = b.dataset.mode === mode; + b.classList.toggle('active', isActive); + b.style.background = isActive ? 'var(--primary-color)' : 'transparent'; + b.style.color = isActive ? '#fff' : 'var(--text-secondary)'; + }); + + // 重新渲染当前页 + window.goToProviderPage(currentPage); + }); + }); // 添加事件监听器 document.addEventListener('keydown', handleEscKey); @@ -721,11 +837,11 @@ function closeProviderModal(button) { } /** - * 渲染提供商列表 + * 渲染提供商列表(详细模式) * @param {Array} providers - 提供商数组 * @returns {string} HTML字符串 */ -function renderProviderList(providers) { +function renderProviderDetailList(providers) { return providers.map(provider => { const isHealthy = provider.isHealthy; const isDisabled = provider.isDisabled || false; @@ -817,6 +933,72 @@ function renderProviderList(providers) { }).join(''); } +/** + * 渲染提供商列表(卡片模式) + * @param {Array} providers - 提供商数组 + * @returns {string} HTML字符串 + */ +function renderProviderCardList(providers) { + let html = '
'; + html += providers.map(provider => { + const isHealthy = provider.isHealthy; + const isDisabled = provider.isDisabled || false; + const healthClass = isHealthy ? 'healthy' : 'unhealthy'; + const disabledClass = isDisabled ? 'disabled' : ''; + const displayName = provider.customName || provider.uuid; + const needsRefresh = !!provider.needsRefresh; + const toggleButtonText = isDisabled ? t('modal.provider.enabled') : t('modal.provider.disabled'); + const toggleButtonIcon = isDisabled ? 'fas fa-play' : 'fas fa-ban'; + const toggleButtonClass = isDisabled ? 'btn-success' : 'btn-warning'; + + return ` +
+
+
+
${displayName}
+ ${needsRefresh ? '' : ''} +
+
+
+ + ${provider.usageCount || 0} +
+
+ + ${provider.errorCount || 0} +
+
+
+ + +
+
+ ${renderProviderConfig(provider)} +
+
+ `; + }).join(''); + html += '
'; + return html; +} + +/** + * 渲染提供商列表 + * @param {Array} providers - 提供商数组 + * @returns {string} HTML字符串 + */ +function renderProviderList(providers) { + if (currentViewMode === 'card') { + return renderProviderCardList(providers); + } else { + return renderProviderDetailList(providers); + } +} + /** * 渲染提供商配置 * @param {Object} provider - 提供商对象 @@ -1064,7 +1246,8 @@ function getFieldOrder(provider) { const excludedFields = [ 'isHealthy', 'lastUsed', 'usageCount', 'errorCount', 'lastErrorTime', 'uuid', 'isDisabled', 'lastHealthCheckTime', 'lastHealthCheckModel', 'lastErrorMessage', - 'notSupportedModels', 'supportedModels', 'refreshCount', 'needsRefresh', '_lastSelectionSeq' + 'notSupportedModels', 'supportedModels', 'refreshCount', 'needsRefresh', '_lastSelectionSeq', + 'lastRefreshTime', 'lastSuccessTime' ]; // 尝试从当前模态框上下文中获取提供商类型 @@ -1135,7 +1318,7 @@ function toggleProviderDetails(uuid) { function editProvider(uuid, event) { event.stopPropagation(); - const providerDetail = event.target.closest('.provider-item-detail'); + const providerDetail = event.target.closest('.provider-item-detail, .provider-item-card'); const configInputs = providerDetail.querySelectorAll('input[data-config-key]'); const configSelects = providerDetail.querySelectorAll('select[data-config-key]'); const content = providerDetail.querySelector(`#content-${uuid}`); @@ -1203,7 +1386,7 @@ function editProvider(uuid, event) { function cancelEdit(uuid, event) { event.stopPropagation(); - const providerDetail = event.target.closest('.provider-item-detail'); + const providerDetail = event.target.closest('.provider-item-detail, .provider-item-card'); const configInputs = providerDetail.querySelectorAll('input[data-config-key]'); const configSelects = providerDetail.querySelectorAll('select[data-config-key]'); @@ -1291,7 +1474,7 @@ function cancelEdit(uuid, event) { async function saveProvider(uuid, event) { event.stopPropagation(); - const providerDetail = event.target.closest('.provider-item-detail'); + const providerDetail = event.target.closest('.provider-item-detail, .provider-item-card'); const providerType = providerDetail.closest('.provider-modal').getAttribute('data-provider-type'); const providerConfig = collectDraftProviderConfig(providerDetail, providerType, uuid); @@ -1324,7 +1507,7 @@ async function deleteProvider(uuid, event) { return; } - const providerDetail = event.target.closest('.provider-item-detail'); + const providerDetail = event.target.closest('.provider-item-detail, .provider-item-card'); const providerType = providerDetail.closest('.provider-modal').getAttribute('data-provider-type'); try { @@ -1710,7 +1893,7 @@ async function addProvider(providerType) { async function toggleProviderStatus(uuid, event) { event.stopPropagation(); - const providerDetail = event.target.closest('.provider-item-detail'); + const providerDetail = event.target.closest('.provider-item-detail, .provider-item-card'); const providerType = providerDetail.closest('.provider-modal').getAttribute('data-provider-type'); const currentProvider = providerDetail.closest('.provider-modal').querySelector(`[data-uuid="${uuid}"]`); @@ -1823,7 +2006,7 @@ async function performSingleHealthCheck(uuid, event) { event.stopPropagation(); const button = event.currentTarget || event.target.closest('button'); - const providerDetail = event.target.closest('.provider-item-detail'); + const providerDetail = event.target.closest('.provider-item-detail, .provider-item-card'); const providerType = providerDetail?.closest('.provider-modal')?.getAttribute('data-provider-type'); if (!providerDetail || !providerType) { @@ -1887,7 +2070,7 @@ async function refreshProviderUuid(uuid, event) { return; } - const providerDetail = event.target.closest('.provider-item-detail'); + const providerDetail = event.target.closest('.provider-item-detail, .provider-item-card'); const providerType = providerDetail.closest('.provider-modal').getAttribute('data-provider-type'); try { diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js index 8ac44ed4e..5c018f7a0 100644 --- a/static/app/provider-manager.js +++ b/static/app/provider-manager.js @@ -267,6 +267,10 @@ function renderProviders(providers, supportedProviders = []) { let totalAccounts = 0; let totalHealthy = 0; + // 获取搜索关键词 + const searchInput = document.getElementById('providerSearchInput'); + const searchTerm = searchInput ? searchInput.value.toLowerCase().trim() : ''; + // 按照排序后的提供商类型渲染 sortedProviderTypes.forEach((providerType) => { // 如果配置中明确设置为不显示,则跳过 @@ -275,6 +279,22 @@ function renderProviders(providers, supportedProviders = []) { } const accounts = hasProviders ? providers[providerType] || [] : []; + + // 搜索过滤逻辑 + if (searchTerm) { + const displayName = (configMap[providerType]?.name || providerType).toLowerCase(); + const matchesType = displayName.includes(searchTerm) || providerType.toLowerCase().includes(searchTerm); + const matchesNodes = accounts.some(acc => + (acc.customName || '').toLowerCase().includes(searchTerm) || + (acc.uuid || '').toLowerCase().includes(searchTerm) || + (acc.model || '').toLowerCase().includes(searchTerm) + ); + + if (!matchesType && !matchesNodes) { + return; + } + } + const providerDiv = document.createElement('div'); providerDiv.className = 'provider-item'; providerDiv.dataset.providerType = providerType; @@ -429,6 +449,136 @@ function renderProviders(providers, supportedProviders = []) { // 更新统计卡片数据 const activeProviders = hasProviders ? Object.keys(providers).length : 0; updateProviderStatsDisplay(activeProviders, totalHealthy, totalAccounts); + + // 渲染仪表盘提供商状态概览 + renderProviderStatusOverview(providers, configMap, sortedProviderTypes); +} + +/** + * 跳转到特定的提供商节点 + * @param {string} type - 提供商类型 + * @param {string} uuid - 节点UUID + * @param {Event} event - 事件对象 + */ +window.jumpToProviderNode = function(type, uuid, event) { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + + // 切换到提供商页面 + const providersNav = document.querySelector('[data-section="providers"]'); + if (providersNav) { + providersNav.click(); + // 延迟执行以确保页面切换完成 + setTimeout(() => { + openProviderManager(type, uuid); + }, 100); + } +}; + +/** + * 渲染仪表盘提供商状态概览 + * @param {Object} providers - 提供商数据 + * @param {Object} configMap - 提供商配置映射 + * @param {Array} sortedProviderTypes - 排序后的提供商类型 + */ +function renderProviderStatusOverview(providers, configMap, sortedProviderTypes) { + const grid = document.getElementById('providerStatusGrid'); + const panel = document.querySelector('.provider-status-panel'); + if (!grid || !panel) return; + + // 检查是否有任何实际可显示的提供商节点 + let hasVisibleNodes = false; + const validProviderTypes = []; + + sortedProviderTypes.forEach(type => { + const accounts = providers[type] || []; + if (accounts.length > 0) { + hasVisibleNodes = true; + validProviderTypes.push(type); + } + }); + + if (!hasVisibleNodes) { + panel.style.display = 'none'; + + // 没有数据时,自动展开仪表盘的高级信息(路径路由示例等) + const dashboardDetails = document.querySelector('.dashboard-details'); + if (dashboardDetails) { + dashboardDetails.open = true; + } + return; + } + + panel.style.display = 'block'; + grid.innerHTML = ''; + + validProviderTypes.forEach(type => { + const accounts = providers[type]; + const displayName = configMap[type]?.name || type; + const card = document.createElement('div'); + card.className = 'provider-status-card'; + card.style.cursor = 'pointer'; + card.addEventListener('click', () => { + // 点击跳转到提供商管理页面并打开对应类型的管理弹窗 + const providersNav = document.querySelector('[data-section="providers"]'); + if (providersNav) { + providersNav.click(); + setTimeout(() => openProviderManager(type), 100); + } + }); + + const healthyCount = accounts.filter(acc => acc.isHealthy && !acc.isDisabled).length; + const totalCount = accounts.length; + const disabledCount = accounts.filter(acc => acc.isDisabled).length; + const unhealthyCount = totalCount - healthyCount - disabledCount; + + const totalUsage = accounts.reduce((sum, acc) => sum + (acc.usageCount || 0), 0); + const totalErrors = accounts.reduce((sum, acc) => sum + (acc.errorCount || 0), 0); + + card.innerHTML = ` +
+ ${displayName} + ${healthyCount}/${totalCount} +
+ + ${totalCount > 10 ? ` +
+ ${healthyCount} + ${unhealthyCount > 0 ? ` ${unhealthyCount}` : ''} + ${disabledCount > 0 ? ` ${disabledCount}` : ''} +
+ ` : ''} + +
+ ${accounts.map(acc => { + let statusClass = 'healthy'; + let statusTitle = acc.customName || acc.uuid; + if (acc.isDisabled) { + statusClass = 'disabled'; + statusTitle += ` (${t('modal.provider.status.disabled')})`; + } else if (!acc.isHealthy) { + statusClass = 'unhealthy'; + statusTitle += ` (${t('modal.provider.status.unhealthy')})`; + } else { + statusTitle += ` (${t('modal.provider.status.healthy')})`; + } + // 增加提示信息:用量和错误 + statusTitle += `\n${t('providers.stat.usageCount')}: ${acc.usageCount || 0}\n${t('providers.stat.errorCount')}: ${acc.errorCount || 0}`; + + // 为圆点创建 HTML 字符串,添加点击跳转事件 + return ``; + }).join('')} +
+
+ ${totalUsage} + ${totalErrors} + ${totalUsage > 0 ? `${((totalUsage - totalErrors) / totalUsage * 100).toFixed(1)}%` : ''} +
+ `; + grid.appendChild(card); + }); } /** @@ -501,11 +651,16 @@ function updateProviderStatsDisplay(activeProviders, healthyProviders, totalAcco * 打开提供商管理模态框 * @param {string} providerType - 提供商类型 */ -async function openProviderManager(providerType) { +/** + * 打开提供商管理模态框 + * @param {string} providerType - 提供商类型 + * @param {string} searchTerm - 初始搜索词 + */ +async function openProviderManager(providerType, searchTerm = '') { try { const data = await window.apiClient.get(`/providers/${encodeURIComponent(providerType)}`); - showProviderManagerModal(data); + showProviderManagerModal(data, searchTerm); } catch (error) { console.error('Failed to load provider details:', error); showToast(t('common.error'), t('modal.provider.load.failed'), 'error'); diff --git a/static/app/usage-manager.js b/static/app/usage-manager.js index 62c2b20db..74fa68758 100644 --- a/static/app/usage-manager.js +++ b/static/app/usage-manager.js @@ -488,7 +488,9 @@ function createInstanceUsageCard(instance, providerType) { collapsedSummary.innerHTML = `
- ${displayName} + ${displayName} ${statusIcon}
${showUsage ? ` @@ -558,7 +560,9 @@ function createInstanceUsageCard(instance, providerType) {
- ${instance.name || instance.uuid} + ${instance.name || instance.uuid}
${userInfoHTML} `; diff --git a/static/app/utils.js b/static/app/utils.js index d43e29e85..a68476cf0 100644 --- a/static/app/utils.js +++ b/static/app/utils.js @@ -498,6 +498,40 @@ async function apiRequest(url, options = {}) { return apiClient.request(endpoint, options); } +/** + * 复制文本到剪贴板(带兼容性回退) + * @param {string} text - 要复制的文本 + * @returns {Promise} 是否成功 + */ +async function copyToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (err) { + console.warn('navigator.clipboard failed, trying fallback:', err); + } + } + + // Fallback: 使用 textarea 模拟复制 + try { + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-9999px'; + textArea.style.top = '0'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + const successful = document.execCommand('copy'); + document.body.removeChild(textArea); + return successful; + } catch (err) { + console.error('Fallback copy failed:', err); + return false; + } +} + // 导出所有工具函数 export { formatUptime, @@ -508,5 +542,6 @@ export { getProviderConfigs, getBaseProviderConfigs, getProviderStats, - apiRequest + apiRequest, + copyToClipboard }; \ No newline at end of file diff --git a/static/components/section-dashboard.css b/static/components/section-dashboard.css index 8edc74918..14630eb40 100644 --- a/static/components/section-dashboard.css +++ b/static/components/section-dashboard.css @@ -66,6 +66,116 @@ margin: 0; } +/* Provider Status Panel */ +.provider-status-panel { + background: var(--bg-primary); + padding: 1.5rem; + border-radius: 0.5rem; + box-shadow: var(--shadow-md); + margin-top: 1.5rem; +} + +.provider-status-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1rem; + margin-top: 1rem; +} + +.provider-status-card { + background: var(--bg-secondary); + padding: 1rem; + border-radius: var(--radius-lg); + border: 1px solid var(--border-color); + display: flex; + flex-direction: column; + gap: 0.75rem; + transition: all 0.2s ease; +} + +.provider-status-card:hover { + border-color: var(--primary-color); + box-shadow: var(--shadow-sm); + transform: translateY(-2px); +} + +.provider-status-card .provider-info { + display: flex; + justify-content: space-between; + align-items: center; +} + +.provider-status-card .provider-name { + font-weight: 600; + font-size: 0.9rem; + color: var(--text-primary); +} + +.provider-status-card .node-dots { + display: flex; + flex-wrap: wrap; + gap: 6px; + max-height: 85px; /* 限制高度,约显示 3-4 行点 */ + overflow-y: auto; + padding: 2px; + padding-right: 6px; + margin: 4px 0; +} + +/* 优化圆点容器的滚动条 */ +.provider-status-card .node-dots::-webkit-scrollbar { + width: 4px; +} + +.provider-status-card .node-dots::-webkit-scrollbar-track { + background: transparent; +} + +.provider-status-card .node-dots::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; +} + +.provider-status-card .node-dots::-webkit-scrollbar-thumb:hover { + background: var(--primary-color); + opacity: 0.5; +} + +.node-dot { + width: 10px; + height: 10px; + border-radius: 50%; + cursor: help; + transition: transform 0.15s ease; +} + +.node-dot:hover { + transform: scale(1.3); + z-index: 10; +} + +.node-dot.healthy { + background-color: #10b981; +} + +.node-dot.unhealthy { + background-color: #ef4444; +} + +.node-dot.disabled { + background-color: #9ca3af; +} + +.status-loading { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 2rem; + color: var(--text-secondary); +} + .system-info-header { display: flex; justify-content: space-between; @@ -355,6 +465,93 @@ } } +/* Collapsible Dashboard Details */ +.dashboard-details { + background: var(--bg-primary); + border-radius: var(--radius-lg); + border: 1px solid var(--border-color); + margin-top: 1.5rem; + overflow: hidden; + box-shadow: var(--shadow-sm); + transition: var(--transition); +} + +.dashboard-details:hover { + border-color: var(--primary-color); + box-shadow: var(--shadow-md); +} + +.dashboard-details[open] { + box-shadow: var(--shadow-md); +} + +.dashboard-summary { + list-style: none; + padding: 1rem 1.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + background: var(--bg-primary); + user-select: none; + transition: var(--transition); +} + +.dashboard-summary::-webkit-details-marker { + display: none; +} + +.dashboard-summary:hover { + background: var(--primary-10); +} + +.summary-content { + display: flex; + align-items: center; + gap: 0.75rem; + color: var(--text-primary); + font-weight: 600; +} + +.summary-content i { + color: var(--primary-color); + font-size: 1.1rem; +} + +.expand-hint { + font-size: 0.8rem; + color: var(--text-secondary); + font-weight: 400; + margin-left: 0.5rem; + opacity: 0.7; +} + +.caret-icon { + color: var(--text-tertiary); + transition: transform 0.3s ease; + font-size: 0.9rem; +} + +.dashboard-details[open] .caret-icon { + transform: rotate(180deg); +} + +.dashboard-details[open] .expand-hint { + display: none; +} + +.routing-examples-panel { + margin-top: 0; + padding-top: 0; + border-top: none; + background: transparent; + box-shadow: none; +} + +.routing-examples-panel h3 { + padding-top: 1rem; +} + /* ======================================== 可用模型列表样式 ======================================== */ diff --git a/static/components/section-dashboard.html b/static/components/section-dashboard.html index 67c225386..b48a72f5b 100644 --- a/static/components/section-dashboard.html +++ b/static/components/section-dashboard.html @@ -125,49 +125,78 @@

系统信息

- -
-

路径路由调用示例

-

通过不同路径路由访问不同的AI模型提供商,支持灵活的模型切换

- -
- -
+ +
+
+

提供商节点状态

+ +
+
+ +
加载中...
+
-
-

使用提示

-
    -
  • 即时切换: 通过修改URL路径即可切换不同的AI模型提供商
  • -
  • 客户端配置: 在Cherry-Studio、NextChat、Cline等客户端中设置API端点为对应路径
  • -
  • 跨协议调用: 支持OpenAI协议调用Claude模型,或Claude协议调用OpenAI模型
  • -
-
+ +
+ +
+ + 高级信息 (路径路由与模型列表) + 展开更多 +
+ +
- -
-

可用模型列表

-
-
- - 点击模型名称可直接复制到剪贴板 + +
+

路径路由调用示例

+

通过不同路径路由访问不同的AI模型提供商,支持灵活的模型切换

+ +
+ +
+ + 加载中...
- - -
-
- -
- - 加载中... + +
+

使用提示

+
    +
  • 即时切换: 通过修改URL路径即可切换不同的AI模型提供商
  • +
  • 客户端配置: 在Cherry-Studio、NextChat、Cline等客户端中设置API端点为对应路径
  • +
  • 跨协议调用: 支持OpenAI协议调用Claude模型,或Claude协议调用OpenAI模型
  • +
+
+ + +
+

可用模型列表

+
+
+ + 点击模型名称可直接复制到剪贴板 +
+
+ + +
+
+ +
+ + 加载中... +
-
+
\ No newline at end of file diff --git a/static/components/section-providers.css b/static/components/section-providers.css index 25c6ac249..a7dd4d3c1 100644 --- a/static/components/section-providers.css +++ b/static/components/section-providers.css @@ -1607,6 +1607,44 @@ box-sizing: border-box; } +/* Search Bar Styles */ +.search-bar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.search-input-wrapper { + position: relative; + flex: 1; + max-width: 500px; +} + +.search-input-wrapper i { + position: absolute; + left: 1rem; + top: 50%; + transform: translateY(-50%); + color: var(--text-tertiary); +} + +.search-input-wrapper input { + width: 100%; + padding: 0.75rem 1rem 0.75rem 2.5rem; + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + background: var(--bg-primary); + color: var(--text-primary); + transition: var(--transition); +} + +.search-input-wrapper input:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 4px var(--primary-10); + outline: none; +} + /* 响应式调整 */ @media (max-width: 768px) { .routing-examples-grid { grid-template-columns: 1fr; } diff --git a/static/components/section-providers.html b/static/components/section-providers.html index 69b520ab8..e23c0c87c 100644 --- a/static/components/section-providers.html +++ b/static/components/section-providers.html @@ -38,6 +38,15 @@

0

+ + + +
diff --git a/static/model-usage-stats.html b/static/model-usage-stats.html index d28b6622a..c48e82f82 100644 --- a/static/model-usage-stats.html +++ b/static/model-usage-stats.html @@ -8,7 +8,7 @@ @@ -61,10 +61,25 @@

模型用量统计面板

Completion Tokens
0
输出 token 的累计值
总 Tokens
0
等待数据
-
-

Provider 分布

-

Top Models

+ +
+
+
+

Token 用量 (3M)

+
+
+
+
+
+
+
+
+ +
+

Provider 分布

+

Top Models

+

Provider 视图

@@ -121,7 +136,57 @@

模型明 function renderProviders(data){const providers=Object.entries(data.providers||{}).map(([name,p])=>({name,data:p,count:Object.keys(p.models||{}).length})).sort((a,b)=>(b.data.summary?.totalTokens||0)-(a.data.summary?.totalTokens||0));const box=el('providerCards');box.innerHTML='';if(!providers.length){box.innerHTML='
暂无 Provider 统计
当前还没有可展示的累计数据。
';return}providers.forEach(p=>{const s=p.data.summary||{};const node=document.createElement('article');node.className='provider';node.innerHTML=`
${p.name}
包含 ${fmt(p.count)} 个模型
${fmt(s.requestCount)} 次调用
Total Tokens
${fmtToken(s.totalTokens)}
Prompt
${fmtToken(s.promptTokens)}
Cached
${fmtToken(s.cachedTokens)}
Completion
${fmtToken(s.completionTokens)}
最近使用
${rel(s.lastUsedAt)}
`;box.appendChild(node)})} function filteredRows(){const keyword=el('searchInput').value.trim().toLowerCase();const [field,dir]=el('sortSelect').value.split('-');const list=keyword?rows.filter(r=>r.provider.toLowerCase().includes(keyword)||r.model.toLowerCase().includes(keyword)):rows.slice();list.sort((a,b)=>{if(field==='provider'||field==='model'){const l=String(a[field]||''),r=String(b[field]||'');return dir==='desc'?r.localeCompare(l):l.localeCompare(r)}const l=Number(a[field]||0),r=Number(b[field]||0);return dir==='desc'?r-l:l-r});return list} function renderTable(){const tbody=el('tableBody'),list=filteredRows();tbody.innerHTML='';if(!list.length){tbody.innerHTML='没有匹配的数据';return}list.forEach(r=>{const tr=document.createElement('tr');tr.innerHTML=`${r.provider}${r.model}${fmt(r.requestCount)}${fmtToken(r.promptTokens)}${fmtToken(r.cachedTokens)}${fmtToken(r.completionTokens)}${fmtToken(r.totalTokens)}${rel(r.lastUsedAt)}`;tbody.appendChild(tr)})} - function render(data){rows=flatten(data);el('emptyState').style.display=rows.length?'none':'block';renderSummary(data);const providers=Object.entries(data.providers||{}).map(([name,p])=>({name,totalTokens:Number(p.summary?.totalTokens||0)})).sort((a,b)=>b.totalTokens-a.totalTokens).slice(0,8);const topModels=rows.slice().sort((a,b)=>Number(b.totalTokens||0)-Number(a.totalTokens||0)).slice(0,8);bars('providerBars',providers,i=>i.totalTokens,i=>i.name);bars('topModelBars',topModels,i=>Number(i.totalTokens||0),i=>`${i.provider} / ${i.model}`);renderProviders(data);renderTable()} + function renderCalendar(dailyData = {}){ + const grid = el('calendarGrid'), tooltip = el('usageTooltip'); + grid.innerHTML = ''; + const now = new Date(); + const startDate = new Date(); + startDate.setMonth(now.getMonth() - 3); + startDate.setDate(startDate.getDate() - startDate.getDay()); + + const values = Object.values(dailyData).map(d => d.totalTokens || 0); + const max = Math.max(...values, 1000); + const dayCount = Math.floor((now - startDate) / (24 * 3600 * 1000)) + 1; + let currentTotal = 0; + + for(let i = 0; i < dayCount + 7; i++){ + const date = new Date(startDate); + date.setDate(startDate.getDate() + i); + if (date > now && date.getDay() !== 0) continue; + + const dateKey = date.toISOString().split('T')[0]; + const data = dailyData[dateKey] || { totalTokens: 0 }; + const tokens = data.totalTokens || 0; + currentTotal += tokens; + + let level = 0; + if (tokens > 0) { + const ratio = tokens / max; + if (ratio < 0.25) level = 1; + else if (ratio < 0.5) level = 2; + else if (ratio < 0.75) level = 3; + else level = 4; + } + + const day = document.createElement('div'); + day.className = 'calendar-day'; + day.dataset.level = level; + + const tipText = `${dateKey}: ${fmtToken(tokens)} Tokens`; + day.onmouseenter = (e) => { + tooltip.textContent = tipText; + tooltip.style.display = 'block'; + const rect = day.getBoundingClientRect(); + tooltip.style.left = `${rect.left + rect.width / 2 - tooltip.offsetWidth / 2}px`; + tooltip.style.top = `${rect.top - tooltip.offsetHeight - 8}px`; + }; + day.onmouseleave = () => { tooltip.style.display = 'none'; }; + + grid.appendChild(day); + } + el('calendarFooter').textContent = `最近三个月累计消耗: ${fmtToken(currentTotal)} Tokens`; + } + function render(data){rows=flatten(data);el('emptyState').style.display=rows.length?'none':'block';renderSummary(data);const providers=Object.entries(data.providers||{}).map(([name,p])=>({name,totalTokens:Number(p.summary?.totalTokens||0)})).sort((a,b)=>b.totalTokens-a.totalTokens).slice(0,8);const topModels=rows.slice().sort((a,b)=>Number(b.totalTokens||0)-Number(a.totalTokens||0)).slice(0,8);bars('providerBars',providers,i=>i.totalTokens,i=>i.name);bars('topModelBars',topModels,i=>Number(i.totalTokens||0),i=>`${i.provider} / ${i.model}`);renderProviders(data);renderTable();renderCalendar(data.daily || {});} async function loadData(){try{saveCredential();status('正在加载统计数据...');const payload=await request(API_BASE);render(payload.data||payload);badge('已连接',true);status(`已加载 ${fmt(rows.length)} 条模型统计。`,'success')}catch(error){console.error(error);badge('连接失败',false);status(error.message,'error')}} async function resetData(){if(!confirm('确认重置全部模型统计吗?此操作会清空已落库的累计数据。'))return;try{status('正在重置统计数据...');const payload=await request(`${API_BASE}/reset`,{method:'POST'});render(payload.data||payload);status('统计数据已重置。','success')}catch(error){console.error(error);status(error.message,'error')}} async function resetTokenData(){if(!confirm('确认重置模型 Token 统计吗?这会清空 Prompt / Completion / Cached / Total Tokens,但保留请求次数与最近使用时间。'))return;try{status('正在重置 Token 统计...');const payload=await request(`${API_BASE}/reset-tokens`,{method:'POST'});render(payload.data||payload);status('模型 Token 统计已重置。','success')}catch(error){console.error(error);status(error.message,'error')}} diff --git a/static/potluck-user.html b/static/potluck-user.html index 688852872..19bb5d9ca 100644 --- a/static/potluck-user.html +++ b/static/potluck-user.html @@ -333,6 +333,27 @@ width: 100%; justify-content: center; } + + .calendar-panel { background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: var(--radius-xl); padding: 20px; margin-bottom: 2rem; box-shadow: var(--shadow-sm); } + .calendar-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; } + .calendar-legend { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--text-tertiary); } + .calendar-legend .level { width: 10px; height: 10px; border-radius: 2px; } + .calendar-wrapper { overflow-x: auto; padding: 25px 0 10px; scrollbar-width: thin; margin-top: -15px; display: flex; justify-content: center; } + .calendar-grid { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 11px); gap: 3px; min-width: max-content; padding: 2px; } + .calendar-day { width: 11px; height: 11px; border-radius: 2px; background: var(--bg-secondary); position: relative; cursor: pointer; transition: transform 0.1s; } + .calendar-day:hover { transform: scale(1.2); z-index: 10; outline: 1px solid var(--primary-color); } + .calendar-footer { margin-top: 10px; font-size: 12px; color: var(--text-tertiary); text-align: right; } + .level-0, .calendar-day[data-level="0"] { background: var(--bg-secondary); } + .level-1, .calendar-day[data-level="1"] { background: #9be9a8; } + .level-2, .calendar-day[data-level="2"] { background: #40c463; } + .level-3, .calendar-day[data-level="3"] { background: #30a14e; } + .level-4, .calendar-day[data-level="4"] { background: #216e39; } + [data-theme="dark"] .level-1, [data-theme="dark"] .calendar-day[data-level="1"] { background: #0e4429; } + [data-theme="dark"] .level-2, [data-theme="dark"] .calendar-day[data-level="2"] { background: #006d32; } + [data-theme="dark"] .level-3, [data-theme="dark"] .calendar-day[data-level="3"] { background: #26a641; } + [data-theme="dark"] .level-4, [data-theme="dark"] .calendar-day[data-level="4"] { background: #39d353; } + .calendar-tooltip { position: fixed; padding: 6px 10px; background: rgba(0,0,0,0.9); color: #fff; font-size: 11px; border-radius: 4px; pointer-events: none; z-index: 1000; display: none; box-shadow: 0 3px 10px rgba(0,0,0,0.3); white-space: nowrap; line-height: 1.4; } + @@ -430,8 +451,23 @@

个人使用统计

-

暂无统计数据

先发起几次模型请求,再回来查看这里的可视化结果。

+
From fee1065fd7a1aae940a9cb98c3cff1c66bd713c2 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Wed, 15 Apr 2026 10:59:28 +0800 Subject: [PATCH 010/135] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=8F=B7=E8=87=B32.14.3=E5=B9=B6=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=99=A8=E6=B3=A8=E5=86=8C=E9=A1=BA=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将Claude适配器注册顺序提前,使其在Gemini之前 - 注释掉Qwen和IFlow适配器,移除这些服务 --- VERSION | 2 +- src/providers/adapter.js | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/VERSION b/VERSION index 7243b12cf..cf28a128f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.14.2 +2.14.3 diff --git a/src/providers/adapter.js b/src/providers/adapter.js index de1267521..4e3705244 100644 --- a/src/providers/adapter.js +++ b/src/providers/adapter.js @@ -691,15 +691,15 @@ export class GrokApiServiceAdapter extends ApiServiceAdapter { // 注册所有内置适配器 registerAdapter(MODEL_PROVIDER.OPENAI_CUSTOM, OpenAIApiServiceAdapter); registerAdapter(MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES, OpenAIResponsesApiServiceAdapter); +registerAdapter(MODEL_PROVIDER.CLAUDE_CUSTOM, ClaudeApiServiceAdapter); registerAdapter(MODEL_PROVIDER.GEMINI_CLI, GeminiApiServiceAdapter); registerAdapter(MODEL_PROVIDER.ANTIGRAVITY, AntigravityApiServiceAdapter); -registerAdapter(MODEL_PROVIDER.CLAUDE_CUSTOM, ClaudeApiServiceAdapter); registerAdapter(MODEL_PROVIDER.KIRO_API, KiroApiServiceAdapter); -registerAdapter(MODEL_PROVIDER.QWEN_API, QwenApiServiceAdapter); -// registerAdapter(MODEL_PROVIDER.IFLOW_API, IFlowApiServiceAdapter); registerAdapter(MODEL_PROVIDER.CODEX_API, CodexApiServiceAdapter); registerAdapter(MODEL_PROVIDER.GROK_CUSTOM, GrokApiServiceAdapter); // registerAdapter(MODEL_PROVIDER.FORWARD_API, ForwardApiServiceAdapter); +// registerAdapter(MODEL_PROVIDER.QWEN_API, QwenApiServiceAdapter); +// registerAdapter(MODEL_PROVIDER.IFLOW_API, IFlowApiServiceAdapter); // 用于存储服务适配器单例的映射 export const serviceInstances = {}; From 08d57145e917ece16e82291eee8fb2ba4ec5a822 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Wed, 15 Apr 2026 20:03:35 +0800 Subject: [PATCH 011/135] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E6=A8=A1=E5=9E=8B=E7=AE=A1=E7=90=86=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加自定义模型配置支持,允许用户通过UI界面创建和管理自定义模型配置。主要功能包括: 1. 自定义模型参数设置(上下文长度、温度、最大Token数等) 2. 模型别名映射和提供商路由配置 3. 完整的UI管理界面,支持增删改查操作 4. 自动将自定义模型注入到模型列表中 5. 支持在API请求中自动应用自定义参数 新增配置文件示例、API端点、前端组件和管理逻辑,同时更新相关模块以支持自定义模型功能。 --- .gitignore | 1 + configs/config.json.example | 1 + configs/custom_models.json.example | 25 + src/core/config-manager.js | 19 + src/providers/claude/claude-kiro.js | 36 +- src/providers/provider-models.js | 113 ++++- src/providers/provider-pool-manager.js | 41 +- src/services/ui-manager.js | 21 + src/ui-modules/custom-models-api.js | 202 ++++++++ src/utils/common.js | 271 ++++++++++- static/app/app.js | 12 +- static/app/component-loader.js | 1 + static/app/custom-models-manager.js | 344 ++++++++++++++ static/app/i18n.js | 68 +++ static/app/models-manager.js | 13 +- static/components/section-custom-models.css | 472 +++++++++++++++++++ static/components/section-custom-models.html | 115 +++++ static/components/sidebar.html | 3 + static/index.html | 1 + 19 files changed, 1716 insertions(+), 43 deletions(-) create mode 100644 configs/custom_models.json.example create mode 100644 src/ui-modules/custom-models-api.js create mode 100644 static/app/custom-models-manager.js create mode 100644 static/components/section-custom-models.css create mode 100644 static/components/section-custom-models.html diff --git a/.gitignore b/.gitignore index 3dfae3a06..e6167a2b5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules CLAUDE.md config.json provider_pools.json +custom_models.json plugins.json fetch_system_prompt.txt input_system_prompt.txt diff --git a/configs/config.json.example b/configs/config.json.example index 6a4bf1dd6..f04c8ef12 100644 --- a/configs/config.json.example +++ b/configs/config.json.example @@ -16,6 +16,7 @@ "CRON_NEAR_MINUTES": 1, "CRON_REFRESH_TOKEN": false, "PROVIDER_POOLS_FILE_PATH": "configs/provider_pools.json", + "CUSTOM_MODELS_FILE_PATH": "configs/custom_models.json", "MAX_ERROR_COUNT": 3, "GROK_COOKIE_TOKEN": "your-sso-cookie-token", "GROK_CF_CLEARANCE": "your-cf-clearance-cookie", diff --git a/configs/custom_models.json.example b/configs/custom_models.json.example new file mode 100644 index 000000000..727c7aa2f --- /dev/null +++ b/configs/custom_models.json.example @@ -0,0 +1,25 @@ +[ + { + "id": "my-custom-gpt-4", + "name": "Custom GPT-4", + "alias": "gpt-4", + "provider": "openai-custom", + "actualProvider": "openai-custom", + "actualModel": "gpt-4-0613", + "contextLength": 8192, + "maxTokens": 4096, + "temperature": 0.7, + "topP": 1.0, + "description": "Custom configuration for GPT-4" + }, + { + "id": "claude-3-7-sonnet-custom", + "name": "Claude 3.7 Sonnet Custom", + "alias": "claude-3-7-sonnet", + "provider": "claude-custom", + "actualProvider": "claude-kiro-oauth", + "actualModel": "claude-3-7-sonnet-20250219", + "contextLength": 200000, + "temperature": 0.5 + } +] diff --git a/src/core/config-manager.js b/src/core/config-manager.js index 60f9bc8d0..7d6d47571 100644 --- a/src/core/config-manager.js +++ b/src/core/config-manager.js @@ -77,6 +77,7 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP LOGIN_MIN_INTERVAL: 5000, // 两次尝试之间的最小间隔(毫秒),默认1秒 PROVIDER_POOLS_FILE_PATH: null, // 新增号池配置文件路径 MAX_ERROR_COUNT: 10, // 提供商最大错误次数 + CUSTOM_MODELS_FILE_PATH: null, // 自定义模型配置文件路径 SYSTEM_PROMPT_REPLACEMENTS: [], // 系统提示词内容替换规则,例如: [{"old": "AI", "new": "Bot"}, {"old": "OpenAI", "new": "Gemini"}] SCHEDULED_HEALTH_CHECK: { enabled: false, @@ -129,6 +130,7 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP { flag: '--cron-near-minutes', configKey: 'CRON_NEAR_MINUTES', type: 'int' }, { flag: '--cron-refresh-token', configKey: 'CRON_REFRESH_TOKEN', type: 'bool' }, { flag: '--provider-pools-file', configKey: 'PROVIDER_POOLS_FILE_PATH', type: 'string' }, + { flag: '--custom-models-file', configKey: 'CUSTOM_MODELS_FILE_PATH', type: 'string' }, { flag: '--max-error-count', configKey: 'MAX_ERROR_COUNT', type: 'int' }, { flag: '--login-max-attempts', configKey: 'LOGIN_MAX_ATTEMPTS', type: 'int' }, { flag: '--login-lockout-duration', configKey: 'LOGIN_LOCKOUT_DURATION', type: 'int' }, @@ -201,6 +203,23 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP currentConfig.providerPools = {}; } + // 加载自定义模型配置 + if (!currentConfig.CUSTOM_MODELS_FILE_PATH) { + currentConfig.CUSTOM_MODELS_FILE_PATH = 'configs/custom_models.json'; + } + try { + if (fs.existsSync(currentConfig.CUSTOM_MODELS_FILE_PATH)) { + const customModelsData = fs.readFileSync(currentConfig.CUSTOM_MODELS_FILE_PATH, 'utf8'); + currentConfig.customModels = JSON.parse(customModelsData); + logger.info(`[Config] Loaded custom models from ${currentConfig.CUSTOM_MODELS_FILE_PATH}`); + } else { + currentConfig.customModels = []; + } + } catch (error) { + logger.error(`[Config Error] Failed to load custom models from ${currentConfig.CUSTOM_MODELS_FILE_PATH}: ${error.message}`); + currentConfig.customModels = []; + } + // Set PROMPT_LOG_FILENAME based on the determined config if (currentConfig.PROMPT_LOG_MODE === 'file') { const now = new Date(); diff --git a/src/providers/claude/claude-kiro.js b/src/providers/claude/claude-kiro.js index cd7fa0d25..33958ad1c 100644 --- a/src/providers/claude/claude-kiro.js +++ b/src/providers/claude/claude-kiro.js @@ -59,8 +59,38 @@ const MODEL_CONTEXT_TOKENS = { "claude-haiku-4-5-20251001": 200000, }; -function getContextTokensForModel(model) { - return MODEL_CONTEXT_TOKENS[model] || KIRO_CONSTANTS.TOTAL_CONTEXT_TOKENS; +function normalizeContextLength(value) { + if (value === undefined || value === null || value === '') { + return null; + } + + const numericValue = Number(value); + return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : null; +} + +function findCustomModelConfigForModel(model, config = {}) { + const targetModel = typeof model === 'string' + ? model.replace(/^[^:]+:/, '') + : ''; + if (!targetModel) { + return null; + } + + const customModels = Array.isArray(config?.customModels) ? config.customModels : []; + return customModels.find(({ id, alias, actualModel } = {}) => + id === targetModel || alias === targetModel || actualModel === targetModel + ) || null; +} + +function getContextTokensForModel(model, config = {}, fallbackModel = null) { + const customModelConfig = findCustomModelConfigForModel(model, config) || + findCustomModelConfigForModel(fallbackModel, config); + const configuredModelContextLength = normalizeContextLength(customModelConfig?.contextLength); + if (configuredModelContextLength !== null) { + return configuredModelContextLength; + } + + return MODEL_CONTEXT_TOKENS[model] || MODEL_CONTEXT_TOKENS[fallbackModel] || KIRO_CONSTANTS.TOTAL_CONTEXT_TOKENS; } // 从 provider-models.js 获取支持的模型列表 const KIRO_MODELS = getProviderModels(MODEL_PROVIDER.KIRO_API); @@ -2646,7 +2676,7 @@ async saveCredentialsToFile(filePath, newData) { // 总 token = TOTAL_CONTEXT_TOKENS * contextUsagePercentage / 100 // input token = 总 token - output token if (contextUsagePercentage !== null && contextUsagePercentage > 0) { - const contextTokens = getContextTokensForModel(finalModel); + const contextTokens = getContextTokensForModel(model, this.config, finalModel); const totalTokens = Math.round(contextTokens * contextUsagePercentage / 100); inputTokens = Math.max(0, totalTokens - outputTokens); logger.info(`[Kiro] Token calculation from contextUsagePercentage: total=${totalTokens}, output=${outputTokens}, input=${inputTokens}`); diff --git a/src/providers/provider-models.js b/src/providers/provider-models.js index 1c66344c7..25ab8db98 100644 --- a/src/providers/provider-models.js +++ b/src/providers/provider-models.js @@ -1,5 +1,39 @@ import { convertData } from '../convert/convert.js'; import { MODEL_PROVIDER } from '../utils/common.js'; +import { CONFIG } from '../core/config-manager.js'; + +/** + * 获取模型配置元数据 + * @param {string} modelId - 模型 ID 或别名 + * @param {string|null} provider - 自定义模型归属的提供商 + * @returns {Object|null} 模型配置 + */ +export function getCustomModelConfig(modelId, provider = null) { + if (!CONFIG.customModels || !Array.isArray(CONFIG.customModels)) { + return null; + } + + let targetProvider = provider && provider !== MODEL_PROVIDER.AUTO ? provider : null; + let targetModelId = modelId; + + if (typeof modelId === 'string' && modelId.includes(':')) { + const [prefix, ...modelParts] = modelId.split(':'); + targetProvider = prefix; + targetModelId = modelParts.join(':'); + } + + if (!targetProvider) { + return CONFIG.customModels.find(m => + !m.provider && + (m.id === targetModelId || m.alias === targetModelId) + ) || null; + } + + return CONFIG.customModels.find(m => + m.provider === targetProvider && + (m.id === targetModelId || m.alias === targetModelId) + ) || null; +} /** * 各提供商支持的模型列表 @@ -144,6 +178,25 @@ export function normalizeModelIds(models = []) { )].sort((a, b) => a.localeCompare(b)); } +export function getCustomModelActualProvider(modelConfig) { + if (!modelConfig) { + return ''; + } + if (Object.prototype.hasOwnProperty.call(modelConfig, 'actualProvider')) { + return modelConfig.actualProvider || ''; + } + return modelConfig.provider || ''; +} + +export function getCustomModelListProvider(modelConfig) { + return modelConfig?.provider || getCustomModelActualProvider(modelConfig); +} + +export function customModelMatchesProvider(modelConfig, providerType) { + const listProvider = getCustomModelListProvider(modelConfig); + return listProvider === providerType || (listProvider && providerType.startsWith(listProvider + '-')); +} + function extractModelIdsFromListShape(modelList) { if (!modelList) { return []; @@ -204,18 +257,33 @@ export function getConfiguredSupportedModels(providerType, providerConfig = {}) * @returns {Array} 模型列表 */ export function getProviderModels(providerType) { + let models = []; if (PROVIDER_MODELS[providerType]) { - return PROVIDER_MODELS[providerType]; + models = [...PROVIDER_MODELS[providerType]]; + } else { + // 尝试前缀匹配 (例如 openai-custom-1 -> openai-custom) + for (const key of Object.keys(PROVIDER_MODELS)) { + if (providerType.startsWith(key + '-')) { + models = [...PROVIDER_MODELS[key]]; + break; + } + } } - // 尝试前缀匹配 (例如 openai-custom-1 -> openai-custom) - for (const key of Object.keys(PROVIDER_MODELS)) { - if (providerType.startsWith(key + '-')) { - return PROVIDER_MODELS[key]; - } + // 注入自定义模型 + if (CONFIG.customModels && Array.isArray(CONFIG.customModels)) { + CONFIG.customModels.forEach(m => { + // 匹配模型列表归属提供商或其后缀分组 + if (customModelMatchesProvider(m, providerType)) { + // 注入 ID + if (!models.includes(m.id)) { + models.push(m.id); + } + } + }); } - return []; + return normalizeModelIds(models); } /** @@ -223,5 +291,34 @@ export function getProviderModels(providerType) { * @returns {Object} 所有提供商的模型映射 */ export function getAllProviderModels() { - return PROVIDER_MODELS; + // 执行深拷贝,避免修改原始 PROVIDER_MODELS 对象 + const allModels = {}; + for (const provider in PROVIDER_MODELS) { + allModels[provider] = [...PROVIDER_MODELS[provider]]; + } + + // 合并自定义模型到对应的提供商 + if (CONFIG.customModels && Array.isArray(CONFIG.customModels)) { + CONFIG.customModels.forEach(m => { + // 如果指定了模型列表归属提供商,注入到该提供商 + // 如果没有指定(Auto),则注入到特殊的虚拟分组 + const targetProvider = getCustomModelListProvider(m) || 'custom-auto'; + + if (!allModels[targetProvider]) { + allModels[targetProvider] = []; + } + + // 注入 ID + if (!allModels[targetProvider].includes(m.id)) { + allModels[targetProvider].push(m.id); + } + }); + } + + // 对每个列表进行排序 + for (const provider in allModels) { + allModels[provider] = normalizeModelIds(allModels[provider]); + } + + return allModels; } diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index 4aa42a125..c89939387 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -5,12 +5,40 @@ import { MODEL_PROVIDER, getProtocolPrefix } from '../utils/common.js'; import { convertData } from '../convert/convert.js'; import { getConfiguredSupportedModels, + getCustomModelListProvider, getProviderModels, normalizeModelIds } from './provider-models.js'; import { broadcastEvent } from '../ui-modules/event-broadcast.js'; import { ENDPOINT_TYPE } from '../utils/common.js'; +function getCustomModelAliasesForProvider(config, providerType) { + const customModels = Array.isArray(config?.customModels) ? config.customModels : []; + return new Set( + customModels + .filter(model => { + const listProvider = getCustomModelListProvider(model); + return model?.alias && + model.alias !== model.id && + listProvider && + (listProvider === providerType || providerType.startsWith(listProvider + '-')); + }) + .map(model => model.alias) + ); +} + +function getCustomModelIdsForProvider(config, providerType) { + const customModels = Array.isArray(config?.customModels) ? config.customModels : []; + return customModels + .filter(model => { + const listProvider = getCustomModelListProvider(model); + return model?.id && + listProvider && + (listProvider === providerType || providerType.startsWith(listProvider + '-')); + }) + .map(model => model.id); +} + /** * Manages a pool of API service providers, handling their health and selection. */ @@ -1308,17 +1336,20 @@ export class ProviderPoolManager { for (const providerType of allProviderTypes) { if (this.providerStatus[providerType]) { - let models = getProviderModels(providerType); + const customAliases = getCustomModelAliasesForProvider(this.globalConfig, providerType); + const customModelIds = getCustomModelIdsForProvider(this.globalConfig, providerType); const configuredSupportedModels = normalizeModelIds( this.providerStatus[providerType].flatMap(providerStatus => getConfiguredSupportedModels(providerType, providerStatus.config) ) ); + let models = configuredSupportedModels.length > 0 + ? normalizeModelIds([...configuredSupportedModels, ...customModelIds]) + : normalizeModelIds([ + ...getProviderModels(providerType).filter(model => !customAliases.has(model)), + ...customModelIds + ]); - if (configuredSupportedModels.length > 0) { - models = configuredSupportedModels; - } - // 如果硬编码的模型列表为空,或者该类型的提供商在号池中没有配置节点,尝试从服务获取 // 只有在非号池模式,或者号池中有节点时才尝试获取,避免无节点时读取全局默认配置 if (models.length === 0 && (!this.providerStatus[providerType] || this.providerStatus[providerType].length > 0)) { diff --git a/src/services/ui-manager.js b/src/services/ui-manager.js index f1d6bea48..d44b35bb2 100644 --- a/src/services/ui-manager.js +++ b/src/services/ui-manager.js @@ -11,6 +11,7 @@ import * as uploadConfigApi from '../ui-modules/upload-config-api.js'; import * as systemApi from '../ui-modules/system-api.js'; import * as updateApi from '../ui-modules/update-api.js'; import * as oauthApi from '../ui-modules/oauth-api.js'; +import * as customModelsApi from '../ui-modules/custom-models-api.js'; import * as eventBroadcast from '../ui-modules/event-broadcast.js'; // Re-export from event-broadcast module @@ -363,5 +364,25 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo return await pluginApi.handleTogglePlugin(req, res, pluginName); } + // Custom models management + if (method === 'GET' && pathParam === '/api/custom-models') { + return await customModelsApi.handleGetCustomModels(req, res, currentConfig); + } + + if (method === 'POST' && pathParam === '/api/custom-models') { + return await customModelsApi.handleAddCustomModel(req, res, currentConfig); + } + + const customModelMatch = pathParam.match(/^\/api\/custom-models\/(.+)$/); + if (customModelMatch) { + const modelId = decodeURIComponent(customModelMatch[1]); + if (method === 'PUT') { + return await customModelsApi.handleUpdateCustomModel(req, res, currentConfig, modelId); + } + if (method === 'DELETE') { + return await customModelsApi.handleDeleteCustomModel(req, res, currentConfig, modelId); + } + } + return false; } diff --git a/src/ui-modules/custom-models-api.js b/src/ui-modules/custom-models-api.js new file mode 100644 index 000000000..7f051925a --- /dev/null +++ b/src/ui-modules/custom-models-api.js @@ -0,0 +1,202 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import logger from '../utils/logger.js'; +import { getRequestBody } from '../utils/common.js'; +import { broadcastEvent } from './event-broadcast.js'; +import { CONFIG } from '../core/config-manager.js'; + +function syncRuntimeCustomModels(currentConfig, customModels) { + const normalizedCustomModels = Array.isArray(customModels) ? customModels : []; + currentConfig.customModels = normalizedCustomModels; + CONFIG.customModels = normalizedCustomModels; +} + +/** + * 获取自定义模型列表 + */ +export async function handleGetCustomModels(req, res, currentConfig) { + const filePath = currentConfig.CUSTOM_MODELS_FILE_PATH || 'configs/custom_models.json'; + let customModels = []; + + try { + if (existsSync(filePath)) { + const data = readFileSync(filePath, 'utf-8'); + customModels = JSON.parse(data); + } else if (Array.isArray(currentConfig.customModels)) { + customModels = currentConfig.customModels; + } + } catch (error) { + logger.warn('[UI API] Failed to load custom models:', error.message); + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(customModels)); + return true; +} + +/** + * 添加自定义模型 + */ +export async function handleAddCustomModel(req, res, currentConfig) { + try { + const body = await getRequestBody(req); + const newModel = body; + + if (!newModel.id) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Model ID is required' } })); + return true; + } + + const filePath = currentConfig.CUSTOM_MODELS_FILE_PATH || 'configs/custom_models.json'; + let customModels = []; + + if (existsSync(filePath)) { + try { + const data = readFileSync(filePath, 'utf-8'); + customModels = JSON.parse(data); + } catch (e) { + logger.warn('[UI API] Failed to parse custom models file:', e.message); + } + } + + // Check for duplicates + if (customModels.some(m => m.id === newModel.id)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: `Model ID '${newModel.id}' already exists` } })); + return true; + } + + customModels.push(newModel); + + // Save to file + writeFileSync(filePath, JSON.stringify(customModels, null, 2), 'utf-8'); + syncRuntimeCustomModels(currentConfig, customModels); + + logger.info(`[UI API] Added custom model: ${newModel.id}`); + + broadcastEvent('config_update', { + action: 'add_custom_model', + filePath, + model: newModel, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, model: newModel })); + return true; + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message } })); + return true; + } +} + +/** + * 更新自定义模型 + */ +export async function handleUpdateCustomModel(req, res, currentConfig, modelId) { + try { + const body = await getRequestBody(req); + const updatedModel = body; + + const filePath = currentConfig.CUSTOM_MODELS_FILE_PATH || 'configs/custom_models.json'; + let customModels = []; + + if (existsSync(filePath)) { + try { + const data = readFileSync(filePath, 'utf-8'); + customModels = JSON.parse(data); + } catch (e) { + logger.warn('[UI API] Failed to parse custom models file:', e.message); + } + } + + const index = customModels.findIndex(m => m.id === modelId); + if (index === -1) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Model not found' } })); + return true; + } + + // Ensure ID stays consistent if not explicitly changing it (or handle ID change) + if (updatedModel.id && updatedModel.id !== modelId) { + if (customModels.some(m => m.id === updatedModel.id)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: `New Model ID '${updatedModel.id}' already exists` } })); + return true; + } + } + + customModels[index] = { ...customModels[index], ...updatedModel }; + + // Save to file + writeFileSync(filePath, JSON.stringify(customModels, null, 2), 'utf-8'); + syncRuntimeCustomModels(currentConfig, customModels); + + logger.info(`[UI API] Updated custom model: ${modelId}`); + + broadcastEvent('config_update', { + action: 'update_custom_model', + filePath, + model: customModels[index], + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, model: customModels[index] })); + return true; + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message } })); + return true; + } +} + +/** + * 删除自定义模型 + */ +export async function handleDeleteCustomModel(req, res, currentConfig, modelId) { + try { + const filePath = currentConfig.CUSTOM_MODELS_FILE_PATH || 'configs/custom_models.json'; + let customModels = []; + + if (existsSync(filePath)) { + try { + const data = readFileSync(filePath, 'utf-8'); + customModels = JSON.parse(data); + } catch (e) { + logger.warn('[UI API] Failed to parse custom models file:', e.message); + } + } + + const index = customModels.findIndex(m => m.id === modelId); + if (index === -1) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Model not found' } })); + return true; + } + + const deletedModel = customModels.splice(index, 1)[0]; + + // Save to file + writeFileSync(filePath, JSON.stringify(customModels, null, 2), 'utf-8'); + syncRuntimeCustomModels(currentConfig, customModels); + + logger.info(`[UI API] Deleted custom model: ${modelId}`); + + broadcastEvent('config_update', { + action: 'delete_custom_model', + filePath, + model: deletedModel, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, deletedModel })); + return true; + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message } })); + return true; + } +} diff --git a/src/utils/common.js b/src/utils/common.js index b8d50ae7d..9d16581d3 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -53,7 +53,11 @@ export const API_ACTIONS = { import { usesManagedModelList, - getConfiguredSupportedModels + getConfiguredSupportedModels, + getCustomModelConfig, + getCustomModelActualProvider, + getCustomModelListProvider, + normalizeModelIds } from '../providers/provider-models.js'; /** @@ -73,6 +77,121 @@ function getConfiguredSupportedModelsFromPool(providerPoolManager, providerType) )].sort((a, b) => a.localeCompare(b)); } +function getCustomModelEntriesForProvider(config, providerType = null, options = {}) { + const customModels = Array.isArray(config?.customModels) ? config.customModels : []; + const entries = []; + + customModels.forEach(modelConfig => { + if (!modelConfig?.id) { + return; + } + + const modelProvider = getCustomModelListProvider(modelConfig); + const actualProvider = getCustomModelActualProvider(modelConfig); + const isMatch = !providerType || + modelProvider === providerType || + (modelProvider && providerType.startsWith(modelProvider + '-')); + + if (!isMatch) { + return; + } + + const modelId = modelConfig.id; + if (!modelId) { + return; + } + + const responseId = options.prefixProvider && modelProvider + ? `${modelProvider}:${modelId}` + : modelId; + + entries.push({ + id: responseId, + modelId, + provider: modelProvider || providerType || MODEL_PROVIDER.AUTO, + actualProvider: actualProvider || modelProvider || providerType || MODEL_PROVIDER.AUTO, + config: modelConfig + }); + }); + + return entries; +} + +export function resolveCustomModelRouting(model, currentProvider, customModelConfig = getCustomModelConfig(model, currentProvider)) { + if (!customModelConfig) { + return { + isCustomModel: false, + model, + provider: currentProvider, + actualModel: model, + actualProvider: currentProvider, + config: null + }; + } + + const customActualProvider = getCustomModelActualProvider(customModelConfig); + const customActualModel = customModelConfig.actualModel || customModelConfig.id || model; + + return { + isCustomModel: true, + model: customActualModel, + provider: customActualProvider || currentProvider, + actualModel: customActualModel, + actualProvider: customActualProvider || currentProvider, + config: customModelConfig + }; +} + +function appendCustomModelsToModelList(clientModelList, customEntries, providerType, listEndpointType) { + const entries = Array.isArray(customEntries) ? customEntries : []; + const hasMetadataValue = (value) => value !== undefined && value !== null; + + if (!entries.length) { + return clientModelList; + } + + if (listEndpointType === ENDPOINT_TYPE.GEMINI_MODEL_LIST) { + const models = Array.isArray(clientModelList?.models) ? clientModelList.models : []; + + entries.forEach(entry => { + const existingModel = models.find(model => { + const existingId = model?.baseModelId || model?.name; + if (!existingId) return false; + const normalizedId = existingId.startsWith('models/') ? existingId.substring(7) : existingId; + return normalizedId === entry.id; + }); + if (existingModel) { + existingModel.displayName = entry.config.name || existingModel.displayName || entry.id; + existingModel.description = entry.config.description || existingModel.description || `Model ${entry.modelId} provided by ${entry.provider || providerType}`; + if (hasMetadataValue(entry.config.contextLength)) existingModel.inputTokenLimit = entry.config.contextLength; + if (hasMetadataValue(entry.config.maxTokens)) existingModel.outputTokenLimit = entry.config.maxTokens; + return; + } + + const modelResponse = { + name: `models/${entry.id}`, + baseModelId: entry.id, + version: 'v1', + displayName: entry.config.name || entry.id, + description: entry.config.description || `Model ${entry.modelId} provided by ${entry.provider || providerType}`, + supportedGenerationMethods: ['generateContent', 'countTokens'] + }; + + if (hasMetadataValue(entry.config.contextLength)) modelResponse.inputTokenLimit = entry.config.contextLength; + if (hasMetadataValue(entry.config.maxTokens)) modelResponse.outputTokenLimit = entry.config.maxTokens; + + models.push(modelResponse); + }); + + return { + ...clientModelList, + models + }; + } + + return clientModelList; +} + /** * Extracts the protocol prefix from a given model provider string. * This is used to determine if two providers belong to the same underlying protocol (e.g., gemini, openai, claude). @@ -830,25 +949,48 @@ export async function handleModelListRequest(req, res, service, endpointType, CO if (listEndpointType === ENDPOINT_TYPE.OPENAI_MODEL_LIST) { return { object: 'list', - data: models.map(model => ({ - id: model, - object: 'model', - created: Math.floor(Date.now() / 1000), - owned_by: providerType - })) + data: models.map(modelId => { + const customConfig = getCustomModelConfig(modelId, providerType); + const modelResponse = { + id: modelId, + object: 'model', + created: Math.floor(Date.now() / 1000), + owned_by: providerType + }; + + // 注入自定义元数据 + if (customConfig) { + if (customConfig.contextLength) modelResponse.context_length = customConfig.contextLength; + if (customConfig.maxTokens) modelResponse.max_tokens = customConfig.maxTokens; + if (customConfig.description) modelResponse.description = customConfig.description; + } + + return modelResponse; + }) }; } if (listEndpointType === ENDPOINT_TYPE.GEMINI_MODEL_LIST) { return { - models: models.map(model => ({ - name: `models/${model}`, - baseModelId: model, - version: 'v1', - displayName: model, - description: `Model ${model} provided by ${providerType}`, - supportedGenerationMethods: ['generateContent', 'countTokens'] - })) + models: models.map(modelId => { + const customConfig = getCustomModelConfig(modelId, providerType); + const modelResponse = { + name: `models/${modelId}`, + baseModelId: modelId, + version: 'v1', + displayName: modelId, + description: `Model ${modelId} provided by ${providerType}`, + supportedGenerationMethods: ['generateContent', 'countTokens'] + }; + + if (customConfig) { + if (customConfig.contextLength) modelResponse.inputTokenLimit = customConfig.contextLength; + if (customConfig.maxTokens) modelResponse.outputTokenLimit = customConfig.maxTokens; + if (customConfig.description) modelResponse.description = customConfig.description; + } + + return modelResponse; + }) }; } @@ -895,6 +1037,14 @@ export async function handleModelListRequest(req, res, service, endpointType, CO logger.info(`[ModelList Convert] Model list format matches. No conversion needed.`); } } + + const customEntries = getCustomModelEntriesForProvider(CONFIG, toProvider); + clientModelList = appendCustomModelsToModelList(clientModelList, customEntries, toProvider, endpointType); + } + + if (CONFIG.MODEL_PROVIDER === MODEL_PROVIDER.AUTO) { + const customEntries = getCustomModelEntriesForProvider(CONFIG, null, { prefixProvider: true }); + clientModelList = appendCustomModelsToModelList(clientModelList, customEntries, MODEL_PROVIDER.AUTO, endpointType); } // logger.info(`[ModelList Response] Sending model list to client: ${JSON.stringify(clientModelList)}`); @@ -952,6 +1102,26 @@ export async function handleContentGenerationRequest(req, res, service, endpoint if (!model) { throw new Error("Could not determine the model from the request."); } + + // 2.1. 处理自定义模型映射和别名 + const customModelConfig = getCustomModelConfig(model, CONFIG.MODEL_PROVIDER); + CONFIG.customConfig = customModelConfig || null; + if (customModelConfig) { + const customRouting = resolveCustomModelRouting(model, CONFIG.MODEL_PROVIDER, customModelConfig); + logger.info(`[Custom Model] Resolved '${model}' to actual model '${customRouting.actualModel}'`); + + if (customRouting.actualProvider && customRouting.actualProvider !== CONFIG.MODEL_PROVIDER) { + CONFIG.MODEL_PROVIDER = customRouting.actualProvider; + toProvider = customRouting.actualProvider; + logger.info(`[Custom Model] Switched provider to '${CONFIG.MODEL_PROVIDER}' based on custom model config`); + } + + // 映射到实际模型 ID + if (customRouting.actualModel) { + model = customRouting.actualModel; + } + } + logger.info(`[Content Generation] Model: ${model}, Stream: ${isStream}`); let actualCustomName = CONFIG.customName; @@ -1015,6 +1185,12 @@ export async function handleContentGenerationRequest(req, res, service, endpoint // 4. Log the incoming prompt (after potential conversion to the backend's format). const promptText = extractPromptText(processedRequestBody, toProvider); + + // 4.1. 应用自定义模型参数 (温度、最大长度等) + if (customModelConfig) { + _applyCustomModelParameters(processedRequestBody, customModelConfig, toProvider); + } + await logConversation('input', promptText, CONFIG.PROMPT_LOG_MODE, PROMPT_LOG_FILENAME); // 5. Call the appropriate stream or unary handler, passing the provider info. @@ -1081,6 +1257,71 @@ export function extractPromptText(requestBody, provider) { return strategy.extractPromptText(requestBody); } +/** + * 应用自定义模型参数到请求体 + * @param {Object} requestBody - 处理后的请求体 + * @param {Object} customConfig - 自定义模型配置 + * @param {string} provider - 目标提供商 + */ +function _applyCustomModelParameters(requestBody, customConfig, provider) { + const protocol = getProtocolPrefix(provider); + const hasConfiguredValue = (value) => value !== undefined && value !== null; + + // 参数映射表 + const mappings = { + temperature: { + [MODEL_PROTOCOL_PREFIX.OPENAI]: 'temperature', + [MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES]: 'temperature', + [MODEL_PROTOCOL_PREFIX.CLAUDE]: 'temperature', + [MODEL_PROTOCOL_PREFIX.GEMINI]: 'generationConfig.temperature' + }, + maxTokens: { + [MODEL_PROTOCOL_PREFIX.OPENAI]: 'max_tokens', + [MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES]: 'max_output_tokens', + [MODEL_PROTOCOL_PREFIX.CLAUDE]: 'max_tokens', + [MODEL_PROTOCOL_PREFIX.GEMINI]: 'generationConfig.maxOutputTokens' + }, + topP: { + [MODEL_PROTOCOL_PREFIX.OPENAI]: 'top_p', + [MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES]: 'top_p', + [MODEL_PROTOCOL_PREFIX.CLAUDE]: 'top_p', + [MODEL_PROTOCOL_PREFIX.GEMINI]: 'generationConfig.topP' + } + }; + + // 处理嵌套路径 (例如 generationConfig.temperature) + const setNestedProperty = (obj, path, value) => { + const parts = path.split('.'); + let curr = obj; + for (let i = 0; i < parts.length - 1; i++) { + if (!curr[parts[i]]) curr[parts[i]] = {}; + curr = curr[parts[i]]; + } + curr[parts[parts.length - 1]] = value; + logger.debug(`[Custom Model] Applied nested parameter ${path}=${value}`); + }; + + // 应用配置 + Object.keys(mappings).forEach(key => { + const value = customConfig[key]; + const targetPath = mappings[key][protocol]; + + if (hasConfiguredValue(value) && targetPath) { + if (targetPath.includes('.')) { + setNestedProperty(requestBody, targetPath, value); + } else { + requestBody[targetPath] = value; + logger.debug(`[Custom Model] Applied ${key}=${value} to request (${targetPath})`); + } + } + }); + + // 处理特殊的 contextLength (通常不直接发给 API,但可能被某些插件使用) + // if (hasConfiguredValue(customConfig.contextLength)) { + // requestBody._contextLength = customConfig.contextLength; + // } +} + export function handleError(res, error, provider = null, fromProvider = null, req = null) { const statusCode = error.response?.status || error.statusCode || error.status || error.code || 500; diff --git a/static/app/app.js b/static/app/app.js index 4d38fb3f5..20e60734b 100644 --- a/static/app/app.js +++ b/static/app/app.js @@ -88,6 +88,10 @@ import { initTutorialManager } from './tutorial-manager.js'; +import { + CustomModelsManager +} from './custom-models-manager.js'; + /** * 加载初始数据 */ @@ -95,7 +99,9 @@ function loadInitialData() { loadSystemInfo(); loadProviders(); loadConfiguration(); - // showToast('数据已刷新', 'success'); + if (window.customModelsManager) { + window.customModelsManager.load(); + } } /** @@ -125,6 +131,10 @@ function initApp() { initImageZoom(); // 初始化图片放大功能 initPluginManager(); // 初始化插件管理功能 initTutorialManager(); // 初始化教程管理功能 + + // 初始化自定义模型管理 + window.customModelsManager = new CustomModelsManager(); + initMobileMenu(); // 初始化移动端菜单 loadInitialData(); diff --git a/static/app/component-loader.js b/static/app/component-loader.js index 52768edbb..54b447062 100644 --- a/static/app/component-loader.js +++ b/static/app/component-loader.js @@ -105,6 +105,7 @@ async function initializeComponents() { { path: `${basePath}section-config.html`, container: '#content-container', position: 'beforeend' }, { path: `${basePath}section-upload-config.html`, container: '#content-container', position: 'beforeend' }, { path: `${basePath}section-providers.html`, container: '#content-container', position: 'beforeend' }, + { path: `${basePath}section-custom-models.html`, container: '#content-container', position: 'beforeend' }, { path: `${basePath}section-usage.html`, container: '#content-container', position: 'beforeend' }, { path: `${basePath}section-logs.html`, container: '#content-container', position: 'beforeend' }, { path: `${basePath}section-plugins.html`, container: '#content-container', position: 'beforeend' }, diff --git a/static/app/custom-models-manager.js b/static/app/custom-models-manager.js new file mode 100644 index 000000000..faffafa16 --- /dev/null +++ b/static/app/custom-models-manager.js @@ -0,0 +1,344 @@ +/** + * 自定义模型管理类 - 修复版 + */ +import { getProviderConfigs } from './utils.js'; + +export class CustomModelsManager { + constructor() { + this.models = []; + this.providers = []; // 存储带名称的配置对象 + this.initEventListeners(); + console.log('✅ [Custom Models] Manager Initialized'); + } + + /** + * 极简事件绑定,处理全局点击 + */ + initEventListeners() { + document.addEventListener('click', (e) => { + // 添加按钮 + const addBtn = e.target.closest('#addCustomModelBtn'); + if (addBtn) { + this.openAddModal(); + return; + } + + // 保存按钮 + const saveBtn = e.target.closest('#saveCustomModelBtn'); + if (saveBtn) { + this.saveModel(); + return; + } + + // 关闭按钮 + const closeBtn = e.target.closest('#customModelModal .modal-close, #customModelModal .close-modal'); + if (closeBtn) { + this.closeModal('customModelModal'); + return; + } + + // 编辑按钮 + const editBtn = e.target.closest('.edit-model-btn'); + if (editBtn) { + this.openEditModal(editBtn.dataset.id); + return; + } + + // 删除按钮 + const delBtn = e.target.closest('.delete-model-btn'); + if (delBtn) { + this.deleteModel(delBtn.dataset.id); + return; + } + }); + } + + async load() { + try { + await Promise.all([ + this.loadModels(), + this.loadProviders() + ]); + this.render(); + } catch (error) { + console.error('[Custom Models] Load error:', error); + } + } + + async loadModels() { + const client = window.apiClient; + if (!client) return; + try { + const data = await client.get('/custom-models'); + this.models = data || []; + } catch (e) { console.error(e); } + } + + async loadProviders() { + const client = window.apiClient; + if (!client) return; + try { + const response = await client.get('/providers'); + if (response && response.supportedProviders) { + // 使用 utils 中的标准方法处理提供商列表,获取友好名称 + this.providers = getProviderConfigs(response.supportedProviders); + this.updateProviderOptions(); + } + } catch (e) { console.error(e); } + } + + updateProviderOptions() { + ['customModelProvider', 'customModelActualProvider'].forEach(selectId => { + const select = document.getElementById(selectId); + if (!select || select.tagName !== 'SELECT') return; + + select.innerHTML = ''; + + // 遵循 getProviderConfigs 返回的预设顺序,不再手动进行字母排序 + this.providers + .filter(p => p.visible !== false) + .forEach(p => { + const opt = document.createElement('option'); + opt.value = p.id; + opt.textContent = p.name; + select.appendChild(opt); + }); + }); + } + + formatNumber(num) { + if (num === undefined || num === null || num === '') return ''; + const value = Number(num); + if (!Number.isFinite(value)) return num; + + const absValue = Math.abs(value); + const formatScaled = (scaled) => String(Math.round(scaled * 10) / 10); + + if (absValue >= 1000000 && value % 1000000 === 0) return `${formatScaled(value / 1000000)}M`; + if (absValue >= 1024 * 1024 && value % (1024 * 1024) === 0) { + return `${formatScaled(value / (1024 * 1024))}M`; + } + if (absValue >= 1000 && value % 1000 === 0) return `${formatScaled(value / 1000)}K`; + if (absValue >= 1024 && value % 1024 === 0) return `${formatScaled(value / 1024)}K`; + if (absValue >= 1000000) return `${formatScaled(value / 1000000)}M`; + if (absValue >= 1000) return `${formatScaled(value / 1000)}K`; + return String(value); + } + + getProviderDisplayName(providerId) { + if (!providerId) return '-'; + const config = this.providers.find(p => p.id === providerId || p.name === providerId); + return config ? config.name : providerId; + } + + renderProviderCell(model) { + const displayProvider = model.provider || ''; + const displayName = this.getProviderDisplayName(displayProvider); + + return ` +
+ + + ${displayName} + +
+ `; + } + + renderActualRouteCell(model) { + const actualProvider = model.actualProvider || model.provider || ''; + const actualName = this.getProviderDisplayName(actualProvider); + const actualModel = model.actualModel || model.id; + + return ` +
+ + + ${actualName} + + + + ${actualModel} + +
+ `; + } + + render() { + const tbody = document.getElementById('customModelsTableBody'); + if (!tbody) return; + + if (this.models.length === 0) { + tbody.innerHTML = ` + + +
+
暂无自定义模型
+
点击“添加模型”按钮开始创建
+ + `; + if (window.i18n) window.i18n.translateElement(tbody); + return; + } + + tbody.innerHTML = this.models.map(model => ` + + +
+ ${model.id} + ${model.alias ? ` ${model.alias}` : ''} +
+ + ${model.name || '-'} + ${this.renderProviderCell(model)} + ${this.renderActualRouteCell(model)} + +
+ ${model.contextLength ? ` ${this.formatNumber(model.contextLength)}` : ''} + ${model.maxTokens ? ` ${this.formatNumber(model.maxTokens)}` : ''} + ${(model.temperature !== undefined && model.temperature !== null && !isNaN(model.temperature)) ? ` ${model.temperature}` : ''} + ${(model.topP !== undefined && model.topP !== null && !isNaN(model.topP)) ? ` ${model.topP}` : ''} +
+ + +
+ + +
+ + + `).join(''); + } + + showModal(id) { + const modal = document.getElementById(id); + if (modal) { + modal.classList.add('show'); + } + } + + closeModal(id) { + const modal = document.getElementById(id); + if (modal) { + modal.classList.remove('show'); + } + } + + openAddModal() { + this.updateProviderOptions(); + const form = document.getElementById('customModelForm'); + if (form) form.reset(); + + const idInput = document.getElementById('modelId'); + if (idInput) { + idInput.disabled = false; + idInput.value = ''; + } + + const origIdInput = document.getElementById('editModelOriginalId'); + if (origIdInput) origIdInput.value = ''; + + this.showModal('customModelModal'); + } + + openEditModal(id) { + const model = this.models.find(m => m.id === id); + if (!model) return; + + this.updateProviderOptions(); + + const origIdInput = document.getElementById('editModelOriginalId'); + if (origIdInput) origIdInput.value = model.id; + + const idInput = document.getElementById('modelId'); + if (idInput) { + idInput.value = model.id; + idInput.disabled = true; + } + + const fields = { + 'modelName': 'name', + 'modelAlias': 'alias', + 'customModelProvider': 'provider', + 'customModelActualProvider': 'actualProvider', + 'actualModel': 'actualModel', + 'contextLength': 'contextLength', + 'maxTokens': 'maxTokens', + 'temperature': 'temperature', + 'topP': 'topP', + 'modelDescription': 'description' + }; + + Object.keys(fields).forEach(fieldId => { + const el = document.getElementById(fieldId); + if (!el) return; + if (fieldId === 'customModelActualProvider') { + el.value = model.actualProvider || model.provider || ''; + return; + } + el.value = model[fields[fieldId]] ?? ''; + }); + + this.showModal('customModelModal'); + } + + async saveModel() { + const form = document.getElementById('customModelForm'); + if (!form || !form.checkValidity()) { + form?.reportValidity(); + return; + } + + const getVal = (id) => document.getElementById(id)?.value ?? ''; + const getNum = (id, isFloat = false) => { + const val = getVal(id).trim(); + if (val === '') return null; + + const parsed = isFloat ? parseFloat(val) : parseInt(val, 10); + return Number.isNaN(parsed) ? null : parsed; + }; + const origId = document.getElementById('editModelOriginalId').value; + + const data = { + id: getVal('modelId'), + name: getVal('modelName'), + alias: getVal('modelAlias'), + provider: getVal('customModelProvider'), + actualProvider: getVal('customModelActualProvider'), + actualModel: getVal('actualModel'), + contextLength: getNum('contextLength'), + maxTokens: getNum('maxTokens'), + temperature: getNum('temperature', true), + topP: getNum('topP', true), + description: getVal('modelDescription') + }; + + if (!origId) { + ['contextLength', 'maxTokens', 'temperature', 'topP'].forEach(key => { + if (data[key] === null) delete data[key]; + }); + } + + try { + if (origId) { + await window.apiClient.put(`/custom-models/${encodeURIComponent(origId)}`, data); + } else { + await window.apiClient.post('/custom-models', data); + } + this.closeModal('customModelModal'); + await this.load(); + } catch (e) { alert(e.message); } + } + + async deleteModel(id) { + if (!confirm('确定删除该自定义模型吗?')) return; + try { + await window.apiClient.delete(`/custom-models/${encodeURIComponent(id)}`); + await this.load(); + } catch (e) { alert(e.message); } + } +} diff --git a/static/app/i18n.js b/static/app/i18n.js index b698a13b6..71cd45e54 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -35,6 +35,7 @@ const translations = { 'nav.logs': '实时日志', 'nav.plugins': '插件管理', 'nav.models': '可用模型', + 'nav.customModels': '自定义模型', // Dashboard 'dashboard.title': '系统概览', @@ -687,6 +688,37 @@ const translations = { 'plugins.load.failed': '加载插件列表失败', 'plugins.restart.required': '更改已保存', + // Custom Models + 'customModels.title': '自定义模型管理', + 'customModels.addModel': '添加模型', + 'customModels.editModel': '编辑模型', + 'customModels.description': '自定义模型管理支持三种用法:1. 为已存在模型定义默认参数设置;2. 将一个模型映射到其他提供商或实际模型;3. 新建一个模型并加入模型列表,新建模型后需要重启服务生效。', + 'customModels.noModels': '暂无自定义模型', + 'customModels.confirmDelete': '确定要删除模型 {id} 吗?', + 'customModels.table.id': '模型 ID / 别名', + 'customModels.table.name': '显示名称', + 'customModels.table.provider': '列表提供商', + 'customModels.table.actualRoute': '实际路由', + 'customModels.table.actualModel': '实际模型 ID', + 'customModels.table.params': '参数 (Ctx/Temp/TopP)', + 'customModels.table.actions': '操作', + 'customModels.form.id': '模型 ID (唯一标识)', + 'customModels.form.name': '显示名称', + 'customModels.form.alias': '别名 (可通过此名称调用)', + 'customModels.form.provider': '模型列表提供商', + 'customModels.form.actualProvider': '实际路由提供商', + 'customModels.form.actualModel': '实际模型 ID (下游接收的 ID)', + 'customModels.form.contextLength': '上下文长度 (Context)', + 'customModels.form.maxTokens': '最大 Token 数 (MaxTokens)', + 'customModels.form.temperature': '温度 (Temperature)', + 'customModels.form.topP': 'Top P', + 'customModels.form.description': '模型描述', + 'customModels.form.placeholder.id': '例如: my-custom-gpt-4', + 'customModels.form.placeholder.name': '给模型起个好听的名字', + 'customModels.form.placeholder.alias': '例如: gpt-4 (调用此名称会自动映射)', + 'customModels.form.placeholder.actual': '例如: gpt-4-0613', + 'customModels.form.placeholder.desc': '简要描述该模型的用途...', + // Models 'models.title': '可用模型列表', 'models.note': '点击模型名称可直接复制到剪贴板', @@ -882,6 +914,8 @@ const translations = { 'common.passwordTooShort': '密码长度不足', 'common.passwordUpdated': '后台密码已更新,下次登录生效', 'common.configSaved': '配置已保存', + 'common.auto': '自动 (Auto)', + 'common.save': '保存配置', 'common.providerPoolRefreshed': '提供商池数据已刷新', 'common.togglePassword': '显示/隐藏密码', 'common.copy.success': '内容已复制到剪贴板', @@ -941,6 +975,7 @@ const translations = { 'nav.logs': 'Real-time Logs', 'nav.plugins': 'Plugin Management', 'nav.models': 'Available Models', + 'nav.customModels': 'Custom Models', // Dashboard 'dashboard.title': 'System Overview', @@ -1593,6 +1628,37 @@ const translations = { 'plugins.load.failed': 'Failed to load plugins list', 'plugins.restart.required': 'Changes saved', + // Custom Models + 'customModels.title': 'Custom Model Management', + 'customModels.addModel': 'Add Model', + 'customModels.editModel': 'Edit Model', + 'customModels.description': 'Custom Model Management supports three use cases: 1. define default parameters for an existing model; 2. map one model to another provider or actual model; 3. create a new model and add it to the model list. New models require a service restart to take effect.', + 'customModels.noModels': 'No custom models yet', + 'customModels.confirmDelete': 'Are you sure you want to delete model {id}?', + 'customModels.table.id': 'Model ID / Alias', + 'customModels.table.name': 'Display Name', + 'customModels.table.provider': 'List Provider', + 'customModels.table.actualRoute': 'Actual Route', + 'customModels.table.actualModel': 'Actual Model ID', + 'customModels.table.params': 'Params (Ctx/Temp/TopP)', + 'customModels.table.actions': 'Actions', + 'customModels.form.id': 'Model ID (Unique)', + 'customModels.form.name': 'Display Name', + 'customModels.form.alias': 'Alias (Call via this name)', + 'customModels.form.provider': 'Model List Provider', + 'customModels.form.actualProvider': 'Actual Route Provider', + 'customModels.form.actualModel': 'Actual Model ID (Downstream ID)', + 'customModels.form.contextLength': 'Context Length', + 'customModels.form.maxTokens': 'Max Tokens', + 'customModels.form.temperature': 'Temperature', + 'customModels.form.topP': 'Top P', + 'customModels.form.description': 'Description', + 'customModels.form.placeholder.id': 'e.g.: my-custom-gpt-4', + 'customModels.form.placeholder.name': 'Enter a friendly name', + 'customModels.form.placeholder.alias': 'e.g.: gpt-4 (auto-mapped)', + 'customModels.form.placeholder.actual': 'e.g.: gpt-4-0613', + 'customModels.form.placeholder.desc': 'Brief description of this model...', + // Models 'models.title': 'Available Models', 'models.note': 'Click model name to copy to clipboard', @@ -1789,6 +1855,8 @@ const translations = { 'common.passwordTooShort': 'Password too short', 'common.passwordUpdated': 'Admin password updated, takes effect next login', 'common.configSaved': 'Configuration saved', + 'common.auto': 'Auto', + 'common.save': 'Save Configuration', 'common.providerPoolRefreshed': 'Provider pool data refreshed', 'common.copy.success': 'Content copied to clipboard', 'common.copy.failed': 'Copy failed, please copy manually', diff --git a/static/app/models-manager.js b/static/app/models-manager.js index b976b1392..7d0e5d995 100644 --- a/static/app/models-manager.js +++ b/static/app/models-manager.js @@ -4,6 +4,7 @@ */ import { t } from './i18n.js'; +import { apiClient } from './auth.js'; // 模型数据缓存 let modelsCache = null; @@ -33,17 +34,7 @@ async function fetchProviderModels() { } try { - const response = await fetch('/api/provider-models', { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('authToken') || ''}` - } - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - modelsCache = await response.json(); + modelsCache = await apiClient.get('/provider-models'); return modelsCache; } catch (error) { console.error('[Models Manager] Failed to fetch provider models:', error); diff --git a/static/components/section-custom-models.css b/static/components/section-custom-models.css new file mode 100644 index 000000000..1453660fe --- /dev/null +++ b/static/components/section-custom-models.css @@ -0,0 +1,472 @@ +/* 自定义模型管理 - 现代视觉设计 */ +#custom-models .section-header { + margin-bottom: 1.5rem; +} + +#custom-models .pool-description { + margin-bottom: 1.5rem; +} + +#custom-models .highlight-note { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem 1.25rem; + background: linear-gradient(to right, var(--primary-10), transparent); + border-left: 4px solid var(--primary-color); + border-radius: var(--radius-sm) var(--radius-xl) var(--radius-xl) var(--radius-sm); + font-size: 0.9rem; + color: var(--text-secondary); +} + +#custom-models .highlight-note i { + color: var(--primary-color); + font-size: 1.1rem; +} + +/* 容器与表格 */ +.custom-models-container { + background: var(--bg-primary); + border-radius: var(--radius-xl); + border: 1px solid var(--border-color); + box-shadow: var(--shadow-md); + overflow: hidden; + transition: var(--transition); +} + +.custom-models-container .table-responsive { + overflow-x: auto; +} + +.custom-models-table { + width: 100%; + border-collapse: collapse; + min-width: 820px; + font-size: 0.875rem; + line-height: 1.45; +} + +.custom-models-table th { + background: var(--bg-secondary); + padding: 1rem 1.25rem; + text-align: left; + font-size: 0.75rem; + font-weight: 700; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0; + border-bottom: 1px solid var(--border-color); +} + +.custom-models-table th:nth-child(3), +.custom-models-table td:nth-child(3) { + min-width: 170px; +} + +.custom-models-table th:nth-child(4), +.custom-models-table td:nth-child(4) { + min-width: 220px; +} + +.custom-models-table td { + padding: 1.25rem; + border-bottom: 1px solid var(--border-color); + vertical-align: middle; + color: var(--text-primary); + font-size: inherit; + font-weight: 500; + line-height: inherit; +} + +.custom-models-table tr:last-child td { + border-bottom: none; +} + +.custom-models-table tr:hover { + background: var(--bg-secondary); +} + +.model-name-cell { + font-weight: 500; + color: var(--text-primary); +} + +/* 空状态 */ +.table-empty-state { + padding: 4rem 0 !important; + text-align: center; +} + +.table-empty-state .empty-icon { + font-size: 3rem; + color: var(--text-tertiary); + opacity: 0.3; + margin-bottom: 1rem; +} + +.table-empty-state .empty-text { + font-weight: 600; + font-size: 1.1rem; + color: var(--text-secondary); +} + +.table-empty-state .empty-hint { + font-size: 0.85rem; + color: var(--text-tertiary); + margin-top: 0.5rem; +} + +/* 模型 ID 样式 */ +.model-id-cell { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.model-id-cell .main-id { + font-family: inherit; + font-weight: 600; + color: var(--text-primary); + font-size: inherit; + line-height: inherit; +} + +.model-id-cell .alias-tag { + font-size: 0.75rem; + font-weight: 500; + line-height: 1.35; + color: var(--text-tertiary); + display: flex; + align-items: center; + gap: 0.25rem; +} + +/* 提供商 Badge */ +.provider-route-cell { + display: flex; + align-items: center; + max-width: 190px; +} + +.actual-route-cell { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.45rem; + max-width: 260px; +} + +.model-list-chip { + display: inline-flex; + align-items: center; + gap: 0.45rem; + max-width: 100%; + height: 1.8rem; + padding: 0 0.55rem; + border-radius: 6px; + font-family: inherit; + font-size: 0.8125rem; + font-weight: 500; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; +} + +.model-list-chip .chip-icon { + flex: 0 0 auto; + width: 1rem; + height: 1rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; + color: currentColor; + opacity: 0.82; +} + +.model-list-chip .chip-icon i { + font-size: 0.7rem; + line-height: 1; +} + +.model-list-chip .chip-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.provider-badge { + background: var(--info-bg); + color: var(--info-text); + border: 1px solid var(--info-border); +} + +.route-provider-badge { + background: var(--success-bg); + color: var(--success-text); + border: 1px solid var(--success-color); +} + +.actual-model-chip { + background: var(--bg-tertiary); + color: var(--text-secondary); + border: 1px solid var(--border-color); +} + +/* 实际 ID */ +.actual-id-code { + display: block; + max-width: 100%; + background: transparent; + padding: 0; + border-radius: 0; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + color: var(--text-secondary); + border: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 参数标签组 */ +.params-group { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.param-pill { + display: inline-flex; + align-items: center; + gap: 0.35rem; + min-height: 1.8rem; + padding: 0 0.55rem; + border-radius: 6px; + font-family: inherit; + font-size: 0.8125rem; + font-weight: 500; + line-height: 1.2; + white-space: nowrap; +} + +.param-pill i { + width: 1rem; + text-align: center; + font-size: 0.7rem; + opacity: 0.82; +} + +.param-pill.ctx { background: var(--info-bg-light); color: var(--info-text); border: 1px solid var(--info-border); } +.param-pill.temp { background: var(--warning-bg); color: var(--warning-text); border: 1px solid var(--warning-border); } +.param-pill.topp { background: var(--success-bg); color: var(--success-text); border: 1px solid var(--success-color); } +.param-pill.tokens { background: var(--danger-bg); color: var(--danger-text); border: 1px solid var(--danger-border); } + +/* 操作按钮 */ +.action-buttons { + display: flex; + gap: 0.5rem; +} + +.icon-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + border: 1px solid var(--border-color); + background: var(--bg-primary); + cursor: pointer; + transition: var(--transition); + color: var(--text-secondary); +} + +.icon-btn:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-sm); + color: var(--primary-color); + border-color: var(--primary-color); +} + +.icon-btn.delete:hover { + color: var(--danger-color); + border-color: var(--danger-color); +} + +/* 弹窗样式优化 - 使用 ID 确保精确匹配 */ +#customModelModal.modal-overlay { + position: fixed; + inset: 0; + background: var(--overlay-bg); + backdrop-filter: blur(4px); + display: none; + justify-content: center; + align-items: center; + z-index: 10000; + padding: 1rem; + opacity: 0; +} + +#customModelModal.modal-overlay.show { + display: flex; + opacity: 1; +} + +#customModelModal .modal-content { + background: var(--bg-primary); + border-radius: var(--radius-xl); + width: 100%; + max-width: 640px; + box-shadow: var(--shadow-xl); + border: 1px solid var(--border-color); + overflow: hidden; + transform: scale(0.95); + transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +#customModelModal.modal-overlay.show .modal-content { + transform: scale(1); +} + +#customModelModal .modal-header { + background: var(--bg-secondary); + padding: 1.25rem 1.5rem; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--border-color); +} + +#customModelModal .modal-header h3 { + margin: 0; + font-size: 1.1rem; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 0.75rem; +} + +#customModelModal .modal-body { + padding: 1.5rem; + max-height: 70vh; + overflow-y: auto; +} + +#customModelModal .modal-footer { + padding: 1rem 1.5rem; + background: var(--bg-secondary); + display: flex; + justify-content: flex-end; + gap: 0.75rem; + border-top: 1px solid var(--border-color); +} + + +.modal-overlay.show { + display: flex; + opacity: 1; +} + +.modal-content { + background: var(--bg-primary); + border-radius: var(--radius-xl); + width: 100%; + max-width: 640px; + box-shadow: var(--shadow-xl); + border: 1px solid var(--border-color); + overflow: hidden; + transform: scale(0.95); + transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.modal-overlay.show .modal-content { + transform: scale(1); +} + +.modal-header { + background: var(--bg-secondary); + padding: 1.25rem 1.5rem; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--border-color); +} + +#custom-models .modal-header h3 { + margin: 0; + font-size: 1.1rem; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 0.75rem; +} + +.modal-close { + background: transparent; + border: none; + font-size: 1.5rem; + color: var(--text-tertiary); + cursor: pointer; + line-height: 1; + padding: 0.5rem; + border-radius: var(--radius-md); + transition: var(--transition); +} + +.modal-close:hover { + background: var(--bg-tertiary); + color: var(--danger-color); +} + +#custom-models .modal-body { + padding: 1.5rem; + max-height: 70vh; + overflow-y: auto; +} + +#custom-models .modal-footer { + padding: 1rem 1.5rem; + background: var(--bg-secondary); + display: flex; + justify-content: flex-end; + gap: 0.75rem; + border-top: 1px solid var(--border-color); +} + +/* 表单行优化 */ +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 1rem; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-size: 0.85rem; + font-weight: 600; + color: var(--text-secondary); +} + +/* 响应式适配 */ +@media (max-width: 640px) { + .form-row { + grid-template-columns: 1fr; + } + + .custom-models-table th:nth-child(5), + .custom-models-table td:nth-child(5) { + display: none; /* 在移动端隐藏不太重要的列 */ + } + + .modal-content { + max-height: 90vh; + } +} diff --git a/static/components/section-custom-models.html b/static/components/section-custom-models.html new file mode 100644 index 000000000..983803f7d --- /dev/null +++ b/static/components/section-custom-models.html @@ -0,0 +1,115 @@ +
+
+

自定义模型管理

+ +
+ +
+
+ + 自定义模型管理支持三种用法:1. 为已存在模型定义默认参数设置;2. 将一个模型映射到其他提供商或实际模型;3. 新建一个模型并加入模型列表,新建模型后需要重启服务生效。 +
+
+ +
+
+ + + + + + + + + + + + + + +
模型 ID / 别名显示名称列表提供商实际路由参数操作
+
+
+
+ + + diff --git a/static/components/sidebar.html b/static/components/sidebar.html index 7d5c9d37a..658a107a6 100644 --- a/static/components/sidebar.html +++ b/static/components/sidebar.html @@ -17,6 +17,9 @@ 提供商池管理 + + 自定义模型管理 + 凭据文件管理 diff --git a/static/index.html b/static/index.html index 8e28fc8e6..037df5a5a 100644 --- a/static/index.html +++ b/static/index.html @@ -8,6 +8,7 @@ AIClient2API - 管理控制台 + From 40f9a08c3e0350376d1c38d9874eb9011aef5014 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Wed, 15 Apr 2026 20:55:16 +0800 Subject: [PATCH 012/135] =?UTF-8?q?fix(grok):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=E5=93=8D=E5=BA=94=E4=B8=AD=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?responseId=E5=92=8Cusage=E6=95=B0=E6=8D=AE=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在GrokApiService中为缺失responseId的响应设置默认值 - 为流式转换方法添加requestId参数以确保responseId传递 - 在toGeminiStreamChunk和toOpenAIResponsesStreamChunk中添加usage数据支持 - 增强provider-pool-manager中的日志记录和错误处理 --- VERSION | 2 +- src/converters/strategies/GrokConverter.js | 43 +++++++++++++++------- src/providers/grok/grok-core.js | 10 ++++- src/providers/provider-pool-manager.js | 13 ++++++- 4 files changed, 50 insertions(+), 18 deletions(-) diff --git a/VERSION b/VERSION index cf28a128f..a168408c9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.14.3 +2.14.4 diff --git a/src/converters/strategies/GrokConverter.js b/src/converters/strategies/GrokConverter.js index 3ebb621d3..9e5f6fd56 100644 --- a/src/converters/strategies/GrokConverter.js +++ b/src/converters/strategies/GrokConverter.js @@ -460,13 +460,13 @@ export class GrokConverter extends BaseConverter { convertStreamChunk(chunk, targetProtocol, model, requestId) { switch (targetProtocol) { case MODEL_PROTOCOL_PREFIX.OPENAI: - return this.toOpenAIStreamChunk(chunk, model); + return this.toOpenAIStreamChunk(chunk, model, requestId); case MODEL_PROTOCOL_PREFIX.GEMINI: - return this.toGeminiStreamChunk(chunk, model); + return this.toGeminiStreamChunk(chunk, model, requestId); case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: - return this.toOpenAIResponsesStreamChunk(chunk, model); + return this.toOpenAIResponsesStreamChunk(chunk, model, requestId); case MODEL_PROTOCOL_PREFIX.CODEX: - return this.toCodexStreamChunk(chunk, model); + return this.toCodexStreamChunk(chunk, model, requestId); case MODEL_PROTOCOL_PREFIX.CLAUDE: return this.toClaudeStreamChunk(chunk, model, requestId); default: @@ -786,13 +786,13 @@ export class GrokConverter extends BaseConverter { /** * Grok流式响应块 -> OpenAI流式响应块 */ - toOpenAIStreamChunk(grokChunk, model) { + toOpenAIStreamChunk(grokChunk, model, requestId = null) { if (!grokChunk || !grokChunk.result || !grokChunk.result.response) { return null; } const resp = grokChunk.result.response; - const rawResponseId = resp.responseId || ""; + const rawResponseId = resp.responseId || (requestId ? `stream-${requestId}` : ""); const responseId = this._formatResponseId(rawResponseId); const state = this._getState(responseId); @@ -1121,8 +1121,8 @@ export class GrokConverter extends BaseConverter { /** * Grok流式响应块 -> Gemini流式响应块 */ - toGeminiStreamChunk(grokChunk, model) { - const openaiChunks = this.toOpenAIStreamChunk(grokChunk, model); + toGeminiStreamChunk(grokChunk, model, requestId = null) { + const openaiChunks = this.toOpenAIStreamChunk(grokChunk, model, requestId); if (!openaiChunks) return null; const geminiChunks = []; @@ -1159,6 +1159,13 @@ export class GrokConverter extends BaseConverter { }; if (choice.finish_reason) { gchunk.candidates[0].finishReason = choice.finish_reason === 'length' ? 'MAX_TOKENS' : 'STOP'; + if (oachunk.usage) { + gchunk.usageMetadata = { + promptTokenCount: oachunk.usage.prompt_tokens || 0, + candidatesTokenCount: oachunk.usage.completion_tokens || 0, + totalTokenCount: oachunk.usage.total_tokens || 0 + }; + } } geminiChunks.push(gchunk); } @@ -1224,8 +1231,8 @@ export class GrokConverter extends BaseConverter { /** * Grok流式响应块 -> OpenAI Responses流式响应块 */ - toOpenAIResponsesStreamChunk(grokChunk, model) { - const openaiChunks = this.toOpenAIStreamChunk(grokChunk, model); + toOpenAIResponsesStreamChunk(grokChunk, model, requestId = null) { + const openaiChunks = this.toOpenAIStreamChunk(grokChunk, model, requestId); if (!openaiChunks) return null; const events = []; @@ -1274,7 +1281,15 @@ export class GrokConverter extends BaseConverter { } if (choice.finish_reason) { - events.push({ type: "response.completed", response: { id: oachunk.id, status: "completed" } }); + const completed = { type: "response.completed", response: { id: oachunk.id, status: "completed" } }; + if (oachunk.usage) { + completed.response.usage = { + input_tokens: oachunk.usage.prompt_tokens || 0, + output_tokens: oachunk.usage.completion_tokens || 0, + total_tokens: oachunk.usage.total_tokens || 0 + }; + } + events.push(completed); } } @@ -1334,8 +1349,8 @@ export class GrokConverter extends BaseConverter { /** * Grok流式响应块 -> Codex流式响应块 */ - toCodexStreamChunk(grokChunk, model) { - const openaiChunks = this.toOpenAIStreamChunk(grokChunk, model); + toCodexStreamChunk(grokChunk, model, requestId = null) { + const openaiChunks = this.toOpenAIStreamChunk(grokChunk, model, requestId); if (!openaiChunks) return null; const codexChunks = []; @@ -1385,7 +1400,7 @@ export class GrokConverter extends BaseConverter { } toClaudeStreamChunk(chunk, model, requestId) { - const openaiPieces = this.toOpenAIStreamChunk(chunk, model); + const openaiPieces = this.toOpenAIStreamChunk(chunk, model, requestId); if (!openaiPieces) return null; const key = requestId || '_'; diff --git a/src/providers/grok/grok-core.js b/src/providers/grok/grok-core.js index 4e043cf2e..4d6318f82 100644 --- a/src/providers/grok/grok-core.js +++ b/src/providers/grok/grok-core.js @@ -1338,7 +1338,8 @@ export class GrokApiService { maxRedirects: 0 }); const rl = readline.createInterface({ input: response.data, terminal: false }); - let lastResponseId = payload.responseMetadata?.requestModelDetails?.modelId || "final"; + const fallbackResponseId = uuidv4(); + let lastResponseId = fallbackResponseId; let grokStreamUsagePayloadAttached = false; for await (const line of rl) { @@ -1354,6 +1355,9 @@ export class GrokApiService { grokStreamUsagePayloadAttached = true; } const resp = json.result.response; + if (!resp.responseId) { + resp.responseId = lastResponseId; + } resp._requestBaseUrl = reqBaseUrl; resp._uuid = this.uuid; @@ -1443,7 +1447,9 @@ export class GrokApiService { } this._grokLastStreamJsonForDebug = null; } - yield { result: { response: { isDone: true, responseId: lastResponseId, _requestBaseUrl: reqBaseUrl, _uuid: this.uuid } } }; + const doneResult = { response: { isDone: true, responseId: lastResponseId, _requestBaseUrl: reqBaseUrl, _uuid: this.uuid } }; + attachGrokUsageEstimatePayload(doneResult, requestBody); + yield { result: doneResult }; } catch (error) { const { status, errorCode, errorMessage, isNetworkError } = this.classifyApiError(error); const canRetryInRequest = !hasYieldedData && retryCount < maxRetries; diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index c89939387..ae0b91b12 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -1453,7 +1453,7 @@ export class ProviderPoolManager { if (provider) { // 防并发机制 A: 如果已经在刷新中,忽略请求 if (this.refreshingUuids.has(provider.uuid)) { - this._log('debug', `Provider ${providerConfig.uuid} is already in refresh queue, ignoring duplicate request.`); + this._log('info', `Provider ${providerConfig.uuid} is already in refresh queue, ignoring duplicate refresh request.`); return; } @@ -1472,6 +1472,17 @@ export class ProviderPoolManager { this._enqueueRefresh(providerType, provider, true); this._debouncedSave(providerType); + } else { + let matchedType = null; + for (const [type, providers] of Object.entries(this.providerStatus || {})) { + if (providers.some(p => p.uuid === providerConfig.uuid)) { + matchedType = type; + break; + } + } + const knownTypes = Object.keys(this.providerStatus || {}).join(', ') || 'none'; + const typeHint = matchedType ? ` Found same uuid under provider type ${matchedType}.` : ''; + this._log('warn', `Provider ${providerConfig.uuid} not found in providerStatus for type ${providerType}; refresh not enqueued.${typeHint} Known provider types: ${knownTypes}`); } } From 392306eeae14a2e9317e8994da2542d876a25538 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Wed, 15 Apr 2026 22:17:24 +0800 Subject: [PATCH 013/135] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=87=AD?= =?UTF-8?q?=E6=8D=AE=E5=BC=BA=E5=88=B6=E5=88=B7=E6=96=B0=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=E4=BB=A4=E7=89=8C=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增配置管理界面中的凭据强制刷新按钮,可手动触发关联节点的令牌刷新流程 - 在配置扫描器中添加过期时间解析逻辑,支持多种过期时间字段格式 - 优化 Grok 提供商的流式响应处理,支持 include_usage 选项 - 移除 Grok NSFW 模型列表,更新版本号至 2.14.5 - 改进令牌使用量统计插件,支持数组形式的用量数据合并 - 增强 Claude Kiro 提供商的凭据加载逻辑,避免使用过期的内存令牌 - 添加过期时间显示样式和国际化支持 --- VERSION | 2 +- src/converters/strategies/GrokConverter.js | 18 ++- src/plugins/api-potluck/index.js | 8 ++ .../model-usage-stats/stats-manager.js | 10 ++ src/providers/claude/claude-kiro.js | 25 +++-- src/providers/grok/grok-core.js | 7 +- src/providers/provider-models.js | 11 -- src/providers/provider-pool-manager.js | 39 ++++++- src/services/ui-manager.js | 7 ++ src/ui-modules/config-scanner.js | 104 +++++++++++++----- src/ui-modules/upload-config-api.js | 78 +++++++++++++ static/app/base.css | 25 +++++ static/app/i18n.js | 23 ++++ static/app/upload-config-manager.js | 76 +++++++++++++ 14 files changed, 377 insertions(+), 56 deletions(-) diff --git a/VERSION b/VERSION index a168408c9..fbdb3b99a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.14.4 +2.14.5 diff --git a/src/converters/strategies/GrokConverter.js b/src/converters/strategies/GrokConverter.js index 9e5f6fd56..c5daa1388 100644 --- a/src/converters/strategies/GrokConverter.js +++ b/src/converters/strategies/GrokConverter.js @@ -117,7 +117,8 @@ export class GrokConverter extends BaseConverter { seen_images: new Set(), // 用于去重已输出的图片 pending_text_buffer: "", // 用于处理流式输出中被截断的 URL usageAcc: null, // 流式过程中最后一次解析到的上游用量(末包常为合成 isDone 无用量) - usageEstimatePayload: null // grok-core 注入的 prompt/tools 文本,用于本地估算 + usageEstimatePayload: null, // grok-core 注入的 prompt/tools 文本,用于本地估算 + streamIncludeUsage: false // OpenAI stream_options.include_usage 兼容 }); } return this.requestStates.get(requestId); @@ -819,6 +820,9 @@ export class GrokConverter extends BaseConverter { if (est && !state.usageEstimatePayload) { state.usageEstimatePayload = est; } + if (est?.includeUsage === true) { + state.streamIncludeUsage = true; + } const chunks = []; @@ -902,6 +906,18 @@ export class GrokConverter extends BaseConverter { }); } + if (state.streamIncludeUsage) { + chunks.push({ + id: responseId, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: model, + system_fingerprint: state.fingerprint, + choices: [], + usage: terminalUsage + }); + } + // 清理状态 this.requestStates.delete(responseId); return chunks; diff --git a/src/plugins/api-potluck/index.js b/src/plugins/api-potluck/index.js index 636cb5fb0..8aeb065d8 100644 --- a/src/plugins/api-potluck/index.js +++ b/src/plugins/api-potluck/index.js @@ -47,6 +47,14 @@ function normalizeUsageCandidate(candidate) { if (!candidate || typeof candidate !== 'object') { return null; } + if (Array.isArray(candidate)) { + return candidate.reduce((usage, item) => mergeUsage(usage, normalizeUsageCandidate(item)), { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cachedTokens: 0 + }); + } const usage = candidate.usage || candidate.message?.usage || candidate.usageMetadata || candidate.response?.usage || null; const reasoningTokens = toNumber( diff --git a/src/plugins/model-usage-stats/stats-manager.js b/src/plugins/model-usage-stats/stats-manager.js index a1e0e48d4..c09a82dd1 100644 --- a/src/plugins/model-usage-stats/stats-manager.js +++ b/src/plugins/model-usage-stats/stats-manager.js @@ -234,6 +234,16 @@ function normalizeUsageCandidate(candidate) { if (!candidate || typeof candidate !== 'object') { return null; } + if (Array.isArray(candidate)) { + const usage = candidate.reduce((merged, item) => mergeUsage(merged, normalizeUsageCandidate(item)), { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cachedTokens: 0 + }); + const hasUsage = usage.promptTokens > 0 || usage.completionTokens > 0 || usage.totalTokens > 0 || usage.cachedTokens > 0; + return hasUsage ? usage : null; + } const usage = candidate.usage || candidate.message?.usage || candidate.usageMetadata || candidate.response?.usage || null; const reasoningTokens = toNumber( diff --git a/src/providers/claude/claude-kiro.js b/src/providers/claude/claude-kiro.js index 33958ad1c..8807c46ad 100644 --- a/src/providers/claude/claude-kiro.js +++ b/src/providers/claude/claude-kiro.js @@ -620,16 +620,21 @@ async loadCredentials() { logger.warn(`[Kiro Auth] Error loading credentials from directory ${dirPath}: ${error.message}`); } - // Apply loaded credentials - this.accessToken = this.accessToken || mergedCredentials.accessToken; - this.refreshToken = this.refreshToken || mergedCredentials.refreshToken; - this.clientId = this.clientId || mergedCredentials.clientId; - this.clientSecret = this.clientSecret || mergedCredentials.clientSecret; - this.authMethod = this.authMethod || mergedCredentials.authMethod; - this.expiresAt = this.expiresAt || mergedCredentials.expiresAt; - this.profileArn = this.profileArn || mergedCredentials.profileArn; - this.region = this.region || mergedCredentials.region; - this.idcRegion = this.idcRegion || mergedCredentials.idcRegion; + // Apply loaded credentials. Force-refresh paths must not keep stale in-memory tokens. + const applyCredential = (field) => { + if (mergedCredentials[field] !== undefined && mergedCredentials[field] !== null) { + this[field] = mergedCredentials[field]; + } + }; + applyCredential('accessToken'); + applyCredential('refreshToken'); + applyCredential('clientId'); + applyCredential('clientSecret'); + applyCredential('authMethod'); + applyCredential('expiresAt'); + applyCredential('profileArn'); + applyCredential('region'); + applyCredential('idcRegion'); if (!this.region) { logger.warn('[Kiro Auth] Region not found in credentials. Using default region us-east-1 for URLs.'); diff --git a/src/providers/grok/grok-core.js b/src/providers/grok/grok-core.js index 4d6318f82..0e6877681 100644 --- a/src/providers/grok/grok-core.js +++ b/src/providers/grok/grok-core.js @@ -73,7 +73,8 @@ function attachGrokUsageEstimatePayload(collected, requestBody) { const promptText = requestBody.message || ""; const toolsJson = requestBody.tools && Array.isArray(requestBody.tools) && requestBody.tools.length ? JSON.stringify(requestBody.tools) : ""; - collected._grokUsageEstimatePayload = { promptText, toolsJson }; + const includeUsage = requestBody.stream_options?.include_usage === true; + collected._grokUsageEstimatePayload = { promptText, toolsJson, includeUsage }; } export class GrokApiService { @@ -1245,7 +1246,9 @@ export class GrokApiService { } } } - yield { result: { response: { isDone: true, responseId } } }; + const doneResult = { response: { isDone: true, responseId } }; + attachGrokUsageEstimatePayload(doneResult, requestBody); + yield { result: doneResult }; } async uploadFile(fileInput) { diff --git a/src/providers/provider-models.js b/src/providers/provider-models.js index 25ab8db98..304079c8e 100644 --- a/src/providers/provider-models.js +++ b/src/providers/provider-models.js @@ -139,17 +139,6 @@ export const PROVIDER_MODELS = { 'grok-imagine-1.0-edit', 'grok-imagine-1.0-fast', 'grok-imagine-1.0-fast-edit', - 'grok-4.1-mini-nsfw', - 'grok-4.1-thinking-nsfw', - 'grok-4.20-nsfw', - 'grok-4.20-auto-nsfw', - 'grok-4.20-fast-nsfw', - 'grok-4.20-expert-nsfw', - 'grok-4.20-heavy-nsfw', - 'grok-imagine-1.0-nsfw', - 'grok-imagine-1.0-edit-nsfw', - 'grok-imagine-1.0-fast-nsfw', - 'grok-imagine-1.0-fast-edit-nsfw' ] }; diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index ae0b91b12..4a32c25e0 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -112,6 +112,22 @@ export class ProviderPoolManager { this.initializeProviderStatus(); } + /** + * 强制刷新特定节点的令牌 + * @param {string} providerType + * @param {string} uuid + * @param {boolean} force + */ + async refreshNode(providerType, uuid, force = true) { + const provider = this._findProvider(providerType, uuid); + if (provider) { + this._log('info', `Manually triggering refresh for node ${uuid} (${providerType})`); + this._enqueueRefresh(providerType, provider, force); + return true; + } + return false; + } + /** * 检查所有节点的配置文件,如果发现即将过期则触发刷新 */ @@ -147,9 +163,16 @@ export class ProviderPoolManager { try { const fileContent = fs.readFileSync(configPath, 'utf-8'); const credData = JSON.parse(fileContent); - const expiryTime = credData.expiry_date || credData.expiry || credData.expires_at; + const rawExpiryTime = credData.expiry_date ?? credData.expiry ?? credData.expires_at ?? credData.expiresAt; + let expiryTime = null; + if (typeof rawExpiryTime === 'number') { + expiryTime = rawExpiryTime; + } else if (typeof rawExpiryTime === 'string') { + const parsedDate = Date.parse(rawExpiryTime); + expiryTime = Number.isNaN(parsedDate) ? Number(rawExpiryTime) : parsedDate; + } const nearExpiryMs = (this.globalConfig?.CRON_NEAR_MINUTES || 10) * 60 * 1000; - if (!expiryTime) { + if (!Number.isFinite(expiryTime)) { // 凭据文件缺少 expiry 字段,无法判断是否快过期,作为安全措施强制刷新 this._log('warn', `Node ${providerStatus.uuid} (${providerType}) has no expiry field. Forcing refresh as safety measure...`); this._enqueueRefresh(providerType, providerStatus); @@ -347,7 +370,9 @@ export class ProviderPoolManager { const nextTask = currentQueue.waitingTasks.shift(); currentQueue.activeCount++; // 使用 Promise.resolve().then 避免过深的递归 - Promise.resolve().then(nextTask); + Promise.resolve().then(nextTask).catch(err => { + this._log('error', `Failed to execute next task for ${providerType}: ${err.message}`); + }); } else if (currentQueue.activeCount === 0) { // 清理空队列:无论是否持有全局槽位,都应删除已无任务的队列对象 if (currentQueue.waitingTasks.length === 0 && @@ -363,7 +388,9 @@ export class ProviderPoolManager { // 3. 尝试启动下一个等待中的提供商队列 if (this.globalRefreshWaiters.length > 0) { const nextProviderStart = this.globalRefreshWaiters.shift(); - Promise.resolve().then(nextProviderStart); + Promise.resolve().then(nextProviderStart).catch(err => { + this._log('error', `Failed to start next provider queue: ${err.message}`); + }); } } } @@ -372,7 +399,9 @@ export class ProviderPoolManager { const tryStartProviderQueue = () => { if (queue.activeCount < this.refreshConcurrency.perProvider) { queue.activeCount++; - runTask(); + runTask().catch(err => { + this._log('error', `Critical error in runTask for ${providerType}: ${err.message}`); + }); } else { queue.waitingTasks.push(runTask); } diff --git a/src/services/ui-manager.js b/src/services/ui-manager.js index d44b35bb2..40ed1b2d5 100644 --- a/src/services/ui-manager.js +++ b/src/services/ui-manager.js @@ -277,6 +277,13 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo return await uploadConfigApi.handleDeleteConfigFile(req, res, filePath); } + // Force expire specific configuration file + const forceExpireConfigMatch = pathParam.match(/^\/api\/upload-configs\/force-expire\/(.+)$/); + if (method === 'POST' && forceExpireConfigMatch) { + const filePath = decodeURIComponent(forceExpireConfigMatch[1]); + return await uploadConfigApi.handleForceExpireConfig(req, res, filePath, currentConfig, providerPoolManager); + } + // Download all configs as zip if (method === 'GET' && pathParam === '/api/upload-configs/download-all') { return await uploadConfigApi.handleDownloadAllConfigs(req, res); diff --git a/src/ui-modules/config-scanner.js b/src/ui-modules/config-scanner.js index ea4358727..04d229471 100644 --- a/src/ui-modules/config-scanner.js +++ b/src/ui-modules/config-scanner.js @@ -68,7 +68,7 @@ export async function scanConfigFiles(currentConfig, providerPoolManager) { * @param {Set} usedPaths - Set of paths currently in use * @returns {Promise} OAuth file information object */ -async function analyzeOAuthFile(filePath, usedPaths, currentConfig) { +export async function analyzeOAuthFile(filePath, usedPaths, currentConfig) { try { const stats = await fs.stat(filePath); const ext = path.extname(filePath).toLowerCase(); @@ -81,6 +81,8 @@ async function analyzeOAuthFile(filePath, usedPaths, currentConfig) { let isValid = true; let errorMessage = ''; let oauthProvider = 'unknown'; + let expiresAt = null; + let expiresAtTS = null; let usageInfo = getFileUsageInfo(relativePath, filename, usedPaths, currentConfig); // 从路径预检测提供商 @@ -120,34 +122,82 @@ async function analyzeOAuthFile(filePath, usedPaths, currentConfig) { try { const jsonData = JSON.parse(content); - // 如果文件名没识别出类型,尝试从内容识别 - if (type === 'oauth') { - if (jsonData.providerPools || jsonData.provider_pools) { - type = 'provider-pool'; + if (jsonData && typeof jsonData === 'object') { + // 如果文件名没识别出类型,尝试从内容识别 + if (type === 'oauth') { + if (jsonData.providerPools || jsonData.provider_pools) { + type = 'provider-pool'; + } else if (jsonData.apiKey || jsonData.api_key) { + type = 'api-key'; + } + } + + // 识别具体的提供商/认证方式 + if (jsonData.client_id || jsonData.client_secret) { + if (oauthProvider === 'unknown') oauthProvider = 'oauth2'; + } else if (jsonData.access_token || jsonData.refresh_token) { + if (oauthProvider === 'unknown') oauthProvider = 'token_based'; + } else if (jsonData.credentials) { + if (oauthProvider === 'unknown') oauthProvider = 'service_account'; } else if (jsonData.apiKey || jsonData.api_key) { - type = 'api-key'; + if (oauthProvider === 'unknown') oauthProvider = 'api_key'; } - } - // 识别具体的提供商/认证方式 - if (jsonData.client_id || jsonData.client_secret) { - if (oauthProvider === 'unknown') oauthProvider = 'oauth2'; - } else if (jsonData.access_token || jsonData.refresh_token) { - if (oauthProvider === 'unknown') oauthProvider = 'token_based'; - } else if (jsonData.credentials) { - if (oauthProvider === 'unknown') oauthProvider = 'service_account'; - } else if (jsonData.apiKey || jsonData.api_key) { - if (oauthProvider === 'unknown') oauthProvider = 'api_key'; - } - - if (jsonData.base_url || jsonData.endpoint) { - const baseUrl = (jsonData.base_url || jsonData.endpoint).toLowerCase(); - if (baseUrl.includes('openai.com')) { - oauthProvider = 'openai'; - } else if (baseUrl.includes('anthropic.com')) { - oauthProvider = 'claude'; - } else if (baseUrl.includes('googleapis.com')) { - oauthProvider = 'gemini'; + // 提取过期信息 + const getTimestamp = (val) => { + if (val === null || val === undefined || val === '') return null; + if (typeof val === 'number') return val; + if (typeof val === 'string') { + if (/^\d+$/.test(val)) return Number(val); + const parsed = Date.parse(val); + return isNaN(parsed) ? null : parsed; + } + return null; + }; + + const timestamps = []; + + // 收集所有可能的过期时间点 + const possibleSources = [jsonData]; + if (jsonData.tokens) possibleSources.push(jsonData.tokens); + if (jsonData.credentials) possibleSources.push(jsonData.credentials); + if (jsonData.auth) possibleSources.push(jsonData.auth); + + possibleSources.forEach(src => { + if (!src || typeof src !== 'object') return; + + ['expiry_date', 'expiresAt', 'expires_at', 'expiry'].forEach(key => { + const ts = getTimestamp(src[key]); + if (ts) { + // 启发式转换秒到毫秒 + timestamps.push(ts < 10000000000 ? ts * 1000 : ts); + } + }); + + // 收集基于相对时间计算的时间点 + const relExpiresIn = Number(src.expires_in || src.expiresIn); + const relIssuedAt = getTimestamp(src.issued_at || src.issuedAt); + if (!isNaN(relExpiresIn) && relIssuedAt) { + const issuedMS = relIssuedAt < 10000000000 ? relIssuedAt * 1000 : relIssuedAt; + timestamps.push(issuedMS + relExpiresIn * 1000); + } + }); + + if (timestamps.length > 0) { + // 取最大的时间点作为过期时间(最宽松策略) + expiresAtTS = Math.max(...timestamps); + expiresAt = new Date(expiresAtTS).toISOString(); + } + + if (jsonData.base_url || jsonData.endpoint) { + const baseUrl = (jsonData.base_url || jsonData.endpoint).toLowerCase(); + if (baseUrl.includes('openai.com')) { + oauthProvider = 'openai'; + } else if (baseUrl.includes('anthropic.com')) { + oauthProvider = 'claude'; + } else if (baseUrl.includes('googleapis.com')) { + oauthProvider = 'gemini'; + } } } } catch (jsonErr) { @@ -182,6 +232,8 @@ async function analyzeOAuthFile(filePath, usedPaths, currentConfig) { provider: oauthProvider, extension: ext, modified: stats.mtime.toISOString(), + expiresAt: expiresAt, + expiresIn: expiresAtTS ? Math.floor((expiresAtTS - Date.now()) / 1000) : null, isValid: isValid, errorMessage: errorMessage, isUsed: isPathUsed(relativePath, filename, usedPaths), diff --git a/src/ui-modules/upload-config-api.js b/src/ui-modules/upload-config-api.js index 8b92d7ef3..756aeae7a 100644 --- a/src/ui-modules/upload-config-api.js +++ b/src/ui-modules/upload-config-api.js @@ -362,4 +362,82 @@ export async function handleDeleteUnboundConfigs(req, res, currentConfig, provid })); return true; } +} + +/** + * 强制触发凭据关联节点的令牌刷新 + */ +export async function handleForceExpireConfig(req, res, filePath, currentConfig, providerPoolManager) { + try { + const fullPath = path.join(process.cwd(), filePath); + + // 安全检查:确保文件路径在允许的目录内 + const allowedDirs = ['configs']; + const relativePath = path.relative(process.cwd(), fullPath); + const isAllowed = allowedDirs.some(dir => relativePath.startsWith(dir + path.sep) || relativePath === dir); + + if (!isAllowed) { + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Access denied: can only access files in configs directory' + } + })); + return true; + } + + if (!existsSync(fullPath)) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'File does not exist' + } + })); + return true; + } + + // 触发即时刷新逻辑 + let refreshCount = 0; + if (providerPoolManager) { + const configFiles = await scanConfigFiles(currentConfig, providerPoolManager); + const targetFile = configFiles.find(f => f.path === relativePath || f.path === filePath); + + if (targetFile && targetFile.usageInfo && targetFile.usageInfo.isUsed && Array.isArray(targetFile.usageInfo.usageDetails)) { + for (const usage of targetFile.usageInfo.usageDetails) { + if (usage.uuid && usage.providerType) { + // 强制触发刷新 + const success = await providerPoolManager.refreshNode(usage.providerType, usage.uuid, true); + if (success) refreshCount++; + } + } + } + } + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'force_refresh', + filePath: relativePath, + refreshTriggered: refreshCount > 0, + refreshCount: refreshCount, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: refreshCount > 0 ? `Triggered refresh for ${refreshCount} node(s)` : 'No active nodes found for this credential', + filePath: relativePath, + refreshTriggered: refreshCount > 0 + })); + return true; + } catch (error) { + logger.error('[UI API] Failed to force refresh config:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Failed to force refresh config: ' + error.message + } + })); + return true; + } } \ No newline at end of file diff --git a/static/app/base.css b/static/app/base.css index 97a749e0e..72c843c50 100644 --- a/static/app/base.css +++ b/static/app/base.css @@ -679,6 +679,31 @@ body { color: var(--danger-text); } +.expiration-tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; +} + +.expiration-tag.healthy { + background: var(--success-bg-light); + color: var(--success-text); +} + +.expiration-tag.warning { + background: var(--warning-bg); + color: var(--warning-text); +} + +.expiration-tag.expired { + background: var(--danger-bg-light); + color: var(--danger-color); +} + .status-success { color: var(--success-color); } .status-error { color: var(--danger-color); } diff --git a/static/app/i18n.js b/static/app/i18n.js index 71cd45e54..696383faa 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -419,9 +419,16 @@ const translations = { 'upload.detail.size': '文件大小', 'upload.detail.modified': '最后修改', 'upload.detail.status': '关联状态', + 'upload.detail.expiresAt': '过期时间', + 'upload.expiration.remaining': '后过期', + 'upload.expiration.expired': '已过期', 'upload.action.view': '查看', 'upload.action.download': '下载', 'upload.action.delete': '删除', + 'upload.action.forceExpire': '强制刷新', + 'upload.action.forceExpire.failed': '强制刷新失败', + 'upload.forceExpire.confirm': '确定要强制刷新该凭据关联的所有节点吗?这将立即触发令牌刷新流程。', + 'upload.forceExpire.success': '已成功触发刷新请求', 'upload.usage.title': '关联详情 ({type})', 'upload.usage.mainConfig': '主要配置', 'upload.usage.providerPool': '提供商池', @@ -922,6 +929,9 @@ const translations = { 'common.copy.failed': '复制失败,请手动复制', 'common.refresh.success': '刷新成功', 'common.refresh.failed': '刷新失败', + 'common.date.days': '天', + 'common.date.hours': '小时', + 'common.date.minutes': '分', // Login 'login.title': '登录 - AIClient2API', @@ -1359,9 +1369,16 @@ const translations = { 'upload.detail.size': 'File Size', 'upload.detail.modified': 'Last Modified', 'upload.detail.status': 'Status', + 'upload.detail.expiresAt': 'Expiration', + 'upload.expiration.remaining': 'expires in', + 'upload.expiration.expired': 'Expired', 'upload.action.view': 'View', 'upload.action.download': 'Download', 'upload.action.delete': 'Delete', + 'upload.action.forceExpire': 'Force Refresh', + 'upload.action.forceExpire.failed': 'Force refresh failed', + 'upload.forceExpire.confirm': 'Are you sure you want to force refresh all nodes associated with this credential? This will trigger the token refresh process immediately.', + 'upload.forceExpire.success': 'Refresh request triggered successfully', 'upload.usage.title': 'Association Details ({type})', 'upload.usage.mainConfig': 'Main Config', 'upload.usage.providerPool': 'Provider Pool', @@ -1862,6 +1879,12 @@ const translations = { 'common.copy.failed': 'Copy failed, please copy manually', 'common.refresh.success': 'Refresh successful', 'common.refresh.failed': 'Refresh failed', + 'common.date.days': 'd ', + 'common.date.hours': 'h ', + 'common.date.minutes': 'm', + 'common.date.days': 'd ', + 'common.date.hours': 'h ', + 'common.date.minutes': 'm', // Login 'login.title': 'Login - AIClient2API', diff --git a/static/app/upload-config-manager.js b/static/app/upload-config-manager.js index 967b26cd4..4b3a23df4 100644 --- a/static/app/upload-config-manager.js +++ b/static/app/upload-config-manager.js @@ -109,6 +109,44 @@ function createConfigItemElement(config, index) { // 生成关联详情HTML const usageInfoHtml = generateUsageInfoHtml(config); + // 生成过期时间HTML + let expirationHtml = ''; + if (config.expiresAt) { + // 优先使用服务端计算的剩余时间,避免客户端时钟不同步导致判断错误 + const isExpired = config.expiresIn !== undefined && config.expiresIn !== null ? + config.expiresIn <= 0 : + (new Date(config.expiresAt) - new Date() <= 0); + + let timeStr = ''; + if (isExpired) { + timeStr = `${t('upload.expiration.expired') || '已过期'}`; + } else { + // 计算剩余秒数 + const remainingSeconds = config.expiresIn !== undefined && config.expiresIn !== null ? + config.expiresIn : + Math.floor((new Date(config.expiresAt) - new Date()) / 1000); + + const days = Math.floor(remainingSeconds / (24 * 3600)); + const hours = Math.floor((remainingSeconds % (24 * 3600)) / 3600); + const minutes = Math.floor((remainingSeconds % 3600) / 60); + + let duration = ''; + if (days > 0) duration += `${days}${t('common.date.days') || '天'}`; + if (hours > 0 || days > 0) duration += `${hours}${t('common.date.hours') || '小时'}`; + duration += `${minutes}${t('common.date.minutes') || '分'}`; + + timeStr = ` + ${duration} ${t('upload.expiration.remaining') || '后过期'} + `; + } + + expirationHtml = `
+ + ${timeStr} + +
`; + } + // 获取关联的节点简要信息 let linkedNodesInfo = ''; if (config.isUsed && config.usageInfo && config.usageInfo.usageDetails) { @@ -179,6 +217,13 @@ function createConfigItemElement(config, index) { ${t('upload.action.quickLink')} ` : ''; + // 强制刷新按钮 + const canForceExpire = config.expiresAt && config.path.toLowerCase().endsWith('.json'); + const forceExpireBtnHtml = canForceExpire ? + `` : ''; + item.innerHTML = `
@@ -205,6 +250,7 @@ function createConfigItemElement(config, index) { ${formatDate(config.modified)}
+ ${expirationHtml}
@@ -243,6 +289,7 @@ function createConfigItemElement(config, index) {
${usageInfoHtml}
+ ${forceExpireBtnHtml} @@ -260,6 +307,7 @@ function createConfigItemElement(config, index) { const viewBtn = item.querySelector('.btn-view'); const downloadBtn = item.querySelector('.btn-download'); const deleteBtn = item.querySelector('.btn-delete-small'); + const forceExpireBtn = item.querySelector('.btn-force-expire'); if (viewBtn) { viewBtn.addEventListener('click', (e) => { @@ -282,6 +330,13 @@ function createConfigItemElement(config, index) { }); } + if (forceExpireBtn) { + forceExpireBtn.addEventListener('click', (e) => { + e.stopPropagation(); + forceExpireConfig(config.path); + }); + } + // 一键关联按钮事件 const quickLinkBtn = item.querySelector('.btn-quick-link-main'); if (quickLinkBtn) { @@ -899,6 +954,27 @@ async function performDelete(path) { } } +/** + * 强制配置文件过期 + * @param {string} path - 文件路径 + */ +async function forceExpireConfig(path) { + if (!confirm(t('upload.forceExpire.confirm') || '确定要强制该凭据过期吗?这将触发系统尝试重新刷新令牌。')) { + return; + } + + try { + const result = await window.apiClient.post(`/upload-configs/force-expire/${encodeURIComponent(path)}`); + showToast(t('common.success'), t('upload.forceExpire.success') || '凭据已强制过期', 'success'); + + // 重新加载列表 + await loadConfigList(); + } catch (error) { + console.error('强制过期失败:', error); + showToast(t('common.error'), (t('upload.action.forceExpire.failed') || '强制过期失败') + ': ' + error.message, 'error'); + } +} + /** * 删除配置 * @param {string} path - 文件路径 From 2d76607757b956da933d55bbb602b49a26b8676c Mon Sep 17 00:00:00 2001 From: hex2077 Date: Wed, 15 Apr 2026 22:55:49 +0800 Subject: [PATCH 014/135] =?UTF-8?q?fix(config-scanner):=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=AF=B9=E8=BF=87=E6=9C=9F=E5=AD=97=E6=AE=B5'expired'?= =?UTF-8?q?=E7=9A=84=E8=AF=86=E5=88=AB=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在分析OAuth配置文件时,发现某些配置可能使用'expired'字段来表示令牌过期时间。为兼容更多配置格式,在启发式转换时间戳的逻辑中,将'expired'字段加入识别列表。 --- VERSION | 2 +- src/ui-modules/config-scanner.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index fbdb3b99a..07acd025d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.14.5 +2.14.5.1 diff --git a/src/ui-modules/config-scanner.js b/src/ui-modules/config-scanner.js index 04d229471..4c10829d6 100644 --- a/src/ui-modules/config-scanner.js +++ b/src/ui-modules/config-scanner.js @@ -166,7 +166,7 @@ export async function analyzeOAuthFile(filePath, usedPaths, currentConfig) { possibleSources.forEach(src => { if (!src || typeof src !== 'object') return; - ['expiry_date', 'expiresAt', 'expires_at', 'expiry'].forEach(key => { + ['expiry_date', 'expiresAt', 'expires_at', 'expiry', 'expired'].forEach(key => { const ts = getTimestamp(src[key]); if (ts) { // 启发式转换秒到毫秒 From 11f856616c5c993e1e3149b578bc8edbc6b8d50a Mon Sep 17 00:00:00 2001 From: hex2077 Date: Wed, 15 Apr 2026 23:03:49 +0800 Subject: [PATCH 015/135] =?UTF-8?q?fix(claude-kiro):=20=E6=94=B9=E8=BF=9B4?= =?UTF-8?q?03=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E5=B9=B6=E5=A2=9E=E5=BC=BA=E4=BB=A4=E7=89=8C=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E5=81=A5=E5=A3=AE=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构403错误处理,区分可刷新(令牌相关)与不可刷新(账户/策略/配额)情况 - 增强令牌刷新逻辑,自动推断认证方法并添加缺失字段验证 - 改进错误响应文本提取,支持Buffer和JSON格式 - 修复令牌刷新后字段赋值,确保refreshToken和profileArn的向后兼容 - 统一callApi、stream和getUsageLimits中的403处理逻辑 --- VERSION | 2 +- src/providers/claude/claude-kiro.js | 150 +++++++++++++++++----------- 2 files changed, 92 insertions(+), 60 deletions(-) diff --git a/VERSION b/VERSION index 07acd025d..e3b7d6904 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.14.5.1 +2.14.5.2 diff --git a/src/providers/claude/claude-kiro.js b/src/providers/claude/claude-kiro.js index 8807c46ad..f111b3fff 100644 --- a/src/providers/claude/claude-kiro.js +++ b/src/providers/claude/claude-kiro.js @@ -729,9 +729,20 @@ async saveCredentialsToFile(filePath, newData) { refreshToken: this.refreshToken, }; + const hasIdcClientCredentials = !!(this.clientId && this.clientSecret); + const isSocialAuth = this.authMethod === KIRO_CONSTANTS.AUTH_METHOD_SOCIAL || + (!this.authMethod && !hasIdcClientCredentials); + if (!this.authMethod) { + this.authMethod = isSocialAuth ? KIRO_CONSTANTS.AUTH_METHOD_SOCIAL : 'builder-id'; + logger.warn(`[Kiro Auth] authMethod missing in credentials. Inferred ${this.authMethod} from available fields.`); + } + let refreshUrl = this.refreshUrl; - if (this.authMethod !== KIRO_CONSTANTS.AUTH_METHOD_SOCIAL) { + if (!isSocialAuth) { refreshUrl = this.refreshIDCUrl; + if (!hasIdcClientCredentials) { + throw new Error('IDC refresh requires clientId and clientSecret.'); + } requestBody.clientId = this.clientId; requestBody.clientSecret = this.clientSecret; requestBody.grantType = 'refresh_token'; @@ -749,7 +760,7 @@ async saveCredentialsToFile(filePath, newData) { }; this._applySidecar(axiosConfig); - if (this.authMethod === KIRO_CONSTANTS.AUTH_METHOD_SOCIAL) { + if (isSocialAuth) { response = await this.axiosSocialRefreshInstance.request(axiosConfig); logger.info('[Kiro Auth] Token refresh social response: ok'); } else { @@ -759,9 +770,9 @@ async saveCredentialsToFile(filePath, newData) { if (response.data && response.data.accessToken) { this.accessToken = response.data.accessToken; - this.refreshToken = response.data.refreshToken; - this.profileArn = response.data.profileArn; - const expiresIn = response.data.expiresIn; + this.refreshToken = response.data.refreshToken || this.refreshToken; + this.profileArn = response.data.profileArn || this.profileArn; + const expiresIn = Number(response.data.expiresIn) || 3600; const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); this.expiresAt = expiresAt; logger.info('[Kiro Auth] Access token refreshed successfully'); @@ -1591,27 +1602,10 @@ async saveCredentialsToFile(filePath, newData) { await this._handle402Error(error, 'callApi'); } - // Handle 403 (Forbidden) - mark as unhealthy immediately, no retry + // Handle 403 (Forbidden). Most Kiro 403s are account/policy/quota/profile issues, + // not expired access tokens, so do not blindly refresh. if (status === 403 && !isRetry) { - logger.info('[Kiro] Received 403. Marking credential as need refresh...'); - - // 检查是否为 temporarily suspended 错误 - const isSuspended = errorMessage && errorMessage.toLowerCase().includes('temporarily is suspended'); - - if (isSuspended) { - // temporarily suspended 错误:直接标记为不健康,不刷新 UUID - logger.info('[Kiro] Account temporarily suspended. Marking as unhealthy without UUID refresh...'); - this._markCredentialUnhealthy('403 Forbidden - Account temporarily suspended', error); - } else { - // 其他 403 错误:先刷新 UUID,然后标记需要刷新 - // const newUuid = this._refreshUuid(); - // if (newUuid) { - // logger.info(`[Kiro] UUID refreshed: ${this.uuid} -> ${newUuid}`); - // this.uuid = newUuid; - // } - this._markCredentialNeedRefresh('403 Forbidden', error); - } - + this._handleForbiddenCredentialError(error, 'callApi'); // Mark error for credential switch without recording error count error.shouldSwitchCredential = true; error.skipErrorCount = true; @@ -1653,6 +1647,73 @@ async saveCredentialsToFile(filePath, newData) { } } + _getErrorResponseText(error) { + const data = error?.response?.data; + if (data === undefined || data === null) { + return error?.message || ''; + } + if (Buffer.isBuffer(data)) { + return data.toString('utf8'); + } + if (typeof data === 'string') { + return data; + } + try { + return JSON.stringify(data); + } catch { + return String(data); + } + } + + _isRefreshableForbidden(error) { + const text = this._getErrorResponseText(error).toLowerCase(); + if (!text) return false; + + const nonRefreshablePatterns = [ + 'temporarily is suspended', + 'temporarily suspended', + 'disabled', + 'violation of terms', + 'terms of service', + 'appeal', + 'quota', + 'limit exceeded', + 'payment required', + 'not authorized to access', + 'not allowed' + ]; + if (nonRefreshablePatterns.some(pattern => text.includes(pattern))) { + return false; + } + + const tokenRelated = text.includes('token') || + text.includes('authorization') || + text.includes('authenticate') || + text.includes('credential'); + const refreshableAuthState = text.includes('expired') || + text.includes('invalid') || + text.includes('unauthorized'); + + return tokenRelated && refreshableAuthState; + } + + _handleForbiddenCredentialError(error, context) { + const responseText = this._getErrorResponseText(error); + const responseSnippet = responseText ? responseText.substring(0, 500) : ''; + + if (responseSnippet) { + logger.warn(`[Kiro] 403 response body (${context}): ${responseSnippet}`); + } + + if (this._isRefreshableForbidden(error)) { + logger.info(`[Kiro] Received token-related 403 in ${context}. Marking credential as needs refresh.`); + this._markCredentialNeedRefresh(`403 Forbidden (${context}) - token-related${responseSnippet ? `: ${responseSnippet}` : ''}`, error); + } else { + logger.info(`[Kiro] Received non-refreshable 403 in ${context}. Marking credential as unhealthy without refresh.`); + this._markCredentialUnhealthy(`403 Forbidden (${context})${responseSnippet ? `: ${responseSnippet}` : ''}`, error); + } + } + /** * Helper method to refresh the current credential's UUID * Used when encountering 401 errors to get a fresh identity @@ -2132,27 +2193,10 @@ async saveCredentialsToFile(filePath, newData) { await this._handle402Error(error, 'stream'); } - // Handle 403 (Forbidden) - mark as unhealthy immediately, no retry + // Handle 403 (Forbidden). Most Kiro 403s are account/policy/quota/profile issues, + // not expired access tokens, so do not blindly refresh. if (status === 403 && !isRetry) { - logger.info('[Kiro] Received 403 in stream. Marking credential as need refresh...'); - - // 检查是否为 temporarily suspended 错误 - const isSuspended = errorMessage && errorMessage.toLowerCase().includes('temporarily is suspended'); - - if (isSuspended) { - // temporarily suspended 错误:直接标记为不健康,不刷新 UUID - logger.info('[Kiro] Account temporarily suspended in stream. Marking as unhealthy without UUID refresh...'); - this._markCredentialUnhealthy('403 Forbidden - Account temporarily suspended', error); - } else { - // 其他 403 错误:先刷新 UUID,然后标记需要刷新 - // const newUuid = this._refreshUuid(); - // if (newUuid) { - // logger.info(`[Kiro] UUID refreshed: ${this.uuid} -> ${newUuid}`); - // this.uuid = newUuid; - // } - this._markCredentialNeedRefresh('403 Forbidden', error); - } - + this._handleForbiddenCredentialError(error, 'stream'); // Mark error for credential switch without recording error count error.shouldSwitchCredential = true; error.skipErrorCount = true; @@ -3082,20 +3126,8 @@ async saveCredentialsToFile(filePath, newData) { } if (status === 403) { - logger.info('[Kiro] Received 403 on getUsageLimits. Marking credential as unhealthy (no retry)...'); - - // 检查是否为 temporarily suspended 错误 - const isSuspended = errorMessage && errorMessage.toLowerCase().includes('temporarily is suspended'); - - if (isSuspended) { - // temporarily suspended 错误:直接标记为不健康,不刷新 UUID - logger.info('[Kiro] Account temporarily suspended on usage query. Marking as unhealthy without UUID refresh...'); - this._markCredentialUnhealthy('403 Forbidden - Account temporarily suspended on usage query', formattedError); - } else { - // 其他 403 错误:标记需要刷新 - this._markCredentialNeedRefresh('403 Forbidden on usage query', formattedError); - } - + this._handleForbiddenCredentialError(error, 'usage query'); + formattedError.credentialMarkedUnhealthy = true; throw formattedError; } From d48f8522c38d0ecb0ec880d06da7d335e6ec8df0 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Thu, 16 Apr 2026 12:40:59 +0800 Subject: [PATCH 016/135] =?UTF-8?q?fix(adapter):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E9=80=82=E9=85=8D=E5=99=A8=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E5=A4=B1=E6=95=88=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `getServiceInstanceKey` 和 `invalidateServiceAdapter` 工具函数 - 在更新、删除提供商或刷新 UUID 时主动使对应适配器缓存失效 - 修复 Claude Kiro 凭据加载路径错误 - 允许不健康节点尝试刷新以恢复状态 --- VERSION | 2 +- src/providers/adapter.js | 16 +++++++++++++++- src/providers/claude/claude-kiro.js | 2 +- src/providers/provider-pool-manager.js | 8 +++++--- src/ui-modules/provider-api.js | 6 +++++- 5 files changed, 27 insertions(+), 7 deletions(-) diff --git a/VERSION b/VERSION index e3b7d6904..d7b0d017e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.14.5.2 +2.14.6 diff --git a/src/providers/adapter.js b/src/providers/adapter.js index 4e3705244..a61f10e39 100644 --- a/src/providers/adapter.js +++ b/src/providers/adapter.js @@ -704,6 +704,20 @@ registerAdapter(MODEL_PROVIDER.GROK_CUSTOM, GrokApiServiceAdapter); // 用于存储服务适配器单例的映射 export const serviceInstances = {}; +export function getServiceInstanceKey(provider, uuid = null) { + return uuid ? provider + uuid : provider; +} + +export function invalidateServiceAdapter(provider, uuid = null) { + const providerKey = getServiceInstanceKey(provider, uuid); + if (serviceInstances[providerKey]) { + delete serviceInstances[providerKey]; + logger.info(`[Adapter] Invalidated service adapter, provider: ${provider}, uuid: ${uuid || 'default'}`); + return true; + } + return false; +} + /** * 检查提供商是否已注册(支持前缀匹配) * @param {string} provider - 提供商名称 @@ -729,7 +743,7 @@ export function getServiceAdapter(config) { const customNameDisplay = config.customName ? ` (${config.customName})` : ''; logger.info(`[Adapter] getServiceAdapter, provider: ${config.MODEL_PROVIDER}, uuid: ${config.uuid}${customNameDisplay}`); const provider = config.MODEL_PROVIDER; - const providerKey = config.uuid ? provider + config.uuid : provider; + const providerKey = getServiceInstanceKey(provider, config.uuid); if (!serviceInstances[providerKey]) { let AdapterClass = adapterRegistry.get(provider); diff --git a/src/providers/claude/claude-kiro.js b/src/providers/claude/claude-kiro.js index f111b3fff..854769016 100644 --- a/src/providers/claude/claude-kiro.js +++ b/src/providers/claude/claude-kiro.js @@ -591,7 +591,7 @@ async loadCredentials() { } // 从文件加载 - const targetFilePath = this.credsFilePath || path.join(this.credPath, KIRO_AUTH_TOKEN_FILE); + const targetFilePath = tokenFilePath; const dirPath = path.dirname(targetFilePath); const targetFileName = path.basename(targetFilePath); diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index 4a32c25e0..f48eb661d 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -1,5 +1,5 @@ import * as fs from 'fs'; -import { getServiceAdapter, getRegisteredProviders } from './adapter.js'; +import { getServiceAdapter, getRegisteredProviders, invalidateServiceAdapter } from './adapter.js'; import logger from '../utils/logger.js'; import { MODEL_PROVIDER, getProtocolPrefix } from '../utils/common.js'; import { convertData } from '../convert/convert.js'; @@ -156,8 +156,8 @@ export class ProviderPoolManager { } // logger.info(`Checking node ${providerStatus.uuid} (${providerType}) expiry date... configPath: ${configPath}`); - // 排除不健康和禁用的节点 - if (!config.isHealthy || config.isDisabled) continue; + // 排除禁用的节点(不健康节点也应允许尝试刷新以恢复健康) + if (config.isDisabled) continue; if (configPath && fs.existsSync(configPath)) { try { @@ -1809,6 +1809,8 @@ export class ProviderPoolManager { // 更新 provider 的 UUID provider.uuid = newUuid; provider.config.uuid = newUuid; + invalidateServiceAdapter(providerType, oldUuid); + invalidateServiceAdapter(providerType, newUuid); // 同时更新 providerPools 中的原始数据 const poolArray = this.providerPools[providerType]; diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index 063419fe7..a56c04a2c 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -10,7 +10,7 @@ import { } from '../providers/provider-models.js'; import { generateUUID, createProviderConfig, formatSystemPath, detectProviderFromPath, addToUsedPaths, isPathUsed, pathsEqual } from '../utils/provider-utils.js'; import { broadcastEvent } from './event-broadcast.js'; -import { getRegisteredProviders, getServiceAdapter, serviceInstances } from '../providers/adapter.js'; +import { getRegisteredProviders, getServiceAdapter, invalidateServiceAdapter, serviceInstances } from '../providers/adapter.js'; // 文件级互斥锁:防止并发读写导致数据丢失 // 安全净化:移除用户输入字段中的危险内容(script、事件处理器、javascript:协议等), @@ -640,6 +640,7 @@ async function _handleUpdateProvider(req, res, currentConfig, providerPoolManage // Save to file writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); logger.info(`[UI API] Updated provider ${providerUuid} in ${providerType}`); + invalidateServiceAdapter(providerType, providerUuid); // Update provider pool manager if available if (providerPoolManager) { @@ -718,6 +719,7 @@ async function _handleDeleteProvider(req, res, currentConfig, providerPoolManage // Save to file writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); logger.info(`[UI API] Deleted provider ${providerUuid} from ${providerType}`); + invalidateServiceAdapter(providerType, providerUuid); // Update provider pool manager if available if (providerPoolManager) { @@ -1486,6 +1488,8 @@ export async function handleRefreshProviderUuid(req, res, currentConfig, provide // Save to file writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); logger.info(`[UI API] Refreshed UUID for provider in ${providerType}: ${oldUuid} -> ${newUuid}`); + invalidateServiceAdapter(providerType, oldUuid); + invalidateServiceAdapter(providerType, newUuid); // Update provider pool manager if available if (providerPoolManager) { From 56c5b2f6049fdb41e3b42773f779cfe2c04e483f Mon Sep 17 00:00:00 2001 From: hex2077 Date: Thu, 16 Apr 2026 12:54:43 +0800 Subject: [PATCH 017/135] =?UTF-8?q?docs:=20=E7=A7=BB=E9=99=A4=20Qwen=20?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E9=85=8D=E7=BD=AE=E5=92=8C=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除配置示例中的 Qwen 服务提供商配置块,并更新所有语言版本的 README 文档,删除对 Qwen3 Coder Plus 模型的支持说明、配置指南和相关里程碑记录。 --- README-JA.md | 23 +++++------------------ README-ZH.md | 23 +++++------------------ README.md | 23 +++++------------------ docs/OPENCODE_CONFIG_EXAMPLE.md | 17 ++--------------- 4 files changed, 17 insertions(+), 69 deletions(-) diff --git a/README-JA.md b/README-JA.md index d251d0613..6d23bde60 100644 --- a/README-JA.md +++ b/README-JA.md @@ -82,7 +82,7 @@ ## 🚀 概要 -`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%のサービス可用性を保証します。 +`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などの高度なモデルを大規模に無料で使用できるようにします。プロジェクトはストラテジーパターンとアダプターパターンに基づくモジュラーアーキテクチャを採用し、アカウントプール管理、インテリジェントポーリング、自動フェイルオーバー、ヘルスチェック機構を内蔵し、99.9%のサービス可用性を保証します。 > [!NOTE] > **🎉 重要なマイルストーン** @@ -106,7 +106,6 @@ > - **2025.11.11** - Web UI管理コントロールコンソールの追加、リアルタイム設定管理と健康状態モニタリングをサポート > - **2025.11.06** - Gemini 3 プレビュー版のサポートを追加、モデル互換性とパフォーマンス最適化を向上 > - **2025.10.18** - Kiroオープン登録、新規アカウントに500クレジット付与、Claude Sonnet 4.5を完全サポート -> - **2025.09.01** - Qwen Code CLIを統合、`qwen3-coder-plus`モデルサポートを追加 > - **2025.08.29** - アカウントプール管理機能をリリース、マルチアカウントポーリング、自動フェイルオーバー、自動ダウングレード戦略をサポート > - 設定方法:config.jsonに`PROVIDER_POOLS_FILE_PATH`パラメータを追加 > - 参考設定:[provider_pools.json](./configs/provider_pools.json.example) @@ -128,7 +127,7 @@ ### 🚀 制限を突破、効率を向上 * **公式制限の回避**:OAuth認証メカニズムを利用して、Gemini、Antigravityなどの無料APIのレート制限と割り当て制限を効果的に突破 * **TLS 指紋の回避**:内蔵の TLS Sidecar (Go uTLS) によりブラウザの特徴をシミュレートし、Grok などのサービスの Cloudflare 403 ブロックを効果的に回避 -* **無料高度モデル**:Kiro APIモードでClaude Opus 4.5を無料使用、Qwen OAuthモードでQwen3 Coder Plusを使用し、使用コストを削減 +* **無料高度モデル**:Kiro APIモードでClaude Opus 4.5を無料使用、使用コストを削減 * **インテリジェントアカウントプールスケジューリング**:マルチアカウントポーリング、自動フェイルオーバー、設定ダウングレードをサポートし、99.9%のサービス可用性を保証 ### 🛡️ 安全で制御可能、データ透明 @@ -261,7 +260,7 @@ docker compose up -d **📊 ダッシュボード**:システム概要、インタラクティブなルーティング例、クライアント設定ガイド -**⚙️ 設定管理**:全プロバイダー(Gemini、Antigravity、OpenAI、Claude、Kiro、Qwen)のリアルタイムパラメータ修正、高度設定、ファイルアップロード対応 +**⚙️ 設定管理**:全プロバイダー(Gemini、Antigravity、OpenAI、Claude、Kiro)のリアルタイムパラメータ修正、高度設定、ファイルアップロード対応 **🔗 プロバイダープール**:アクティブ接続監視、プロバイダー健全性統計、有効化/無効化管理 @@ -281,7 +280,6 @@ docker compose up -d * **Grok 3 / Grok 4** - xAIのフラッグシップモデル。Grok Cookie/SSO経由でサポートされ、思考モデル、画像生成、動画生成に対応 * **Claude 4.5 Opus** - Anthropic史上最強モデル、Kiro、Antigravity経由でサポート * **Gemini 3 Pro** - Google次世代アーキテクチャプレビュー版、Gemini、Antigravity経由でサポート -* **Qwen3 Coder Plus** - アリババ通義千問の最新コード専用モデル、Qwen Code経由でサポート * **Kimi K2 / MiniMax M2** - 国内トップフラッグシップモデルの同期サポート、カスタムOpenAI、Claude経由でサポート --- @@ -295,8 +293,8 @@ docker compose up -d #### 🌐 Web UI クイック認証 (推奨) Web UI管理インターフェースでは、極めて迅速に認証設定を完了できます: -1. **認証の生成**:**「プロバイダープール」** ページまたは **「設定管理」** ページで、対応するプロバイダー(Gemini、Qwenなど)の右上にある **「認証生成」** ボタンをクリックします。 -2. **スキャン/ログイン**:認証ダイアログが表示されるので、**「ブラウザで開く」** をクリックしてログイン検証を行います。Qwenの場合はウェブログインを完了するだけ、Gemini、Antigravityの場合はGoogleアカウントの認証を完了させます。 +1. **認証の生成**:**「プロバイダープール」** ページまたは **「設定管理」** ページで、対応するプロバイダー(Geminiなど)の右上にある **「認証生成」** ボタンをクリックします。 +2. **スキャン/ログイン**:認証ダイアログが表示されるので、**「ブラウザで開く」** をクリックしてログイン検証を行います。Gemini、Antigravityの場合はGoogleアカウントの認証を完了させます。 3. **自動保存**:認証成功後、システムは自動的に資格情報を取得し、`configs/` の対応するディレクトリに保存します。**「設定ファイル」** ページで新しく生成された資格情報を確認できます。 4. **ビジュアル管理**:Web UIでいつでも資格情報のアップロードや削除、または **「クイック関連付け」** 機能を使用して既存の資格情報ファイルをワンクリックでプロバイダーにバインドできます。 @@ -310,16 +308,6 @@ Web UI管理インターフェースでは、極めて迅速に認証設定を 2. **Pro会員**:Antigravity は一時的に Pro 会員に開放されています。まず Pro 会員を購入する必要があります。 3. **組織アカウント**:組織アカウントは個別に認証が必要です。管理者に連絡して認証を取得してください。 -#### Qwen Code OAuth設定 -1. **初回認証**:Qwenサービス設定後、システムが自動的にブラウザで認証ページを開きます -2. **推奨パラメータ**:最良の結果を得るために公式デフォルトパラメータを使用 - ```json - { - "temperature": 0, - "top_p": 1 - } - ``` - #### Kiro API設定 1. **環境準備**:[Kiroクライアントをダウンロードしてインストール](https://kiro.dev/pricing/) 2. **認証完了**:クライアントでアカウントにログインし、`kiro-auth-token.json`認証情報ファイルを生成 @@ -400,7 +388,6 @@ curl http://localhost:3000/claude-kiro-oauth/v1/chat/completions \ |------|---------|------| | **Gemini** | `~/.gemini/oauth_creds.json` | OAuth認証情報 | | **Kiro** | `~/.aws/sso/cache/kiro-auth-token.json` | Kiro認証トークン | -| **Qwen** | `~/.qwen/oauth_creds.json` | Qwen OAuth認証情報 | | **Antigravity** | `~/.antigravity/oauth_creds.json` | Antigravity OAuth認証情報 (Claude 4.5 Opus サポート) | | **Codex** | `~/.codex/oauth_creds.json` | Codex OAuth認証情報 | diff --git a/README-ZH.md b/README-ZH.md index d4be81894..57bf2f37f 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -81,7 +81,7 @@ ## 🚀 项目概览 -`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% 的服务可用性。 +`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 等高级模型。项目采用策略模式和适配器模式的模块化架构,内置账号池管理、智能轮询、自动故障转移和健康检查机制,确保 99.9% 的服务可用性。 > [!NOTE] > **🎉 重要里程碑** @@ -105,7 +105,6 @@ > - **2025.11.11** - 新增 Web UI 管理控制台,支持实时配置管理和健康状态监控 > - **2025.11.06** - 新增对 Gemini 3 预览版的支持,增强模型兼容性和性能优化 > - **2025.10.18** - Kiro 开放注册,新用户赠送 500 额度,已完整支持 Claude Sonnet 4.5 -> - **2025.09.01** - 集成 Qwen Code CLI,新增 `qwen3-coder-plus` 模型支持 > - **2025.08.29** - 发布账号池管理功能,支持多账号轮询、智能故障转移和自动降级策略 > - 配置方式:在 `configs/config.json` 中添加 `PROVIDER_POOLS_FILE_PATH` 参数 > - 参考配置:[provider_pools.json](./configs/provider_pools.json.example) @@ -126,7 +125,7 @@ ### 🚀 突破限制,提升效率 * **绕过官方限制**:利用 OAuth 授权机制,有效突破 Gemini, Antigravity 等服务的免费 API 速率和配额限制 * **TLS 指纹绕过**:内置 TLS Sidecar (Go uTLS) 模拟浏览器特征,有效绕过 Grok 等服务的 Cloudflare 403 封锁 -* **免费高级模型**:通过 Kiro API 模式免费使用 Claude Opus 4.5,通过 Qwen OAuth 模式使用 Qwen3 Coder Plus,降低使用成本 +* **免费高级模型**:通过 Kiro API 模式免费使用 Claude Opus 4.5,降低使用成本 * **账号池智能调度**:支持多账号轮询、自动故障转移和配置降级,确保 99.9% 服务可用性 ### 🛡️ 安全可控,数据透明 @@ -259,7 +258,7 @@ docker compose up -d **📊 仪表盘**:系统概览、交互式路由示例、客户端配置指南 -**⚙️ 配置管理**:实时参数修改,支持所有提供商(Gemini、Antigravity、OpenAI、Claude、Kiro、Qwen),包含高级设置和文件上传 +**⚙️ 配置管理**:实时参数修改,支持所有提供商(Gemini、Antigravity、OpenAI、Claude、Kiro),包含高级设置和文件上传 **🔗 提供商池**:监控活动连接、提供商健康统计、启用/禁用管理 @@ -279,7 +278,6 @@ docker compose up -d * **Grok 3 / Grok 4** - xAI 旗舰模型,现已通过 Grok Cookie/SSO 支持,支持思考模型、图片生成及视频生成 * **Claude 4.5 Opus** - Anthropic 史上最强模型,现已通过 Kiro, Antigravity 支持 * **Gemini 3 Pro** - Google 下一代架构预览版,现已通过 Gemini, Antigravity 支持 -* **Qwen3 Coder Plus** - 阿里通义千问最新代码专用模型,现已通过Qwen Code 支持 * **Kimi K2 / MiniMax M2** - 国内顶级旗舰模型同步支持,现已通过自定义OpenAI,Claude 支持 --- @@ -293,8 +291,8 @@ docker compose up -d #### 🌐 Web UI 快捷授权 (推荐) 在 Web UI 管理界面中,您可以极速完成授权配置: -1. **生成授权**:在 **“提供商池”** 页面或**“配置管理”** 页面,点击对应提供商(如 Gemini, Qwen)右上角的 **“生成授权”** 按钮。 -2. **扫码/登录**:系统将弹出授权对话框,您可以点击 **“在浏览器中打开”** 进行登录验证。对于 Qwen,只需完成网页登录;对于 Gemini,Antigravity 需完成 Google 账号授权。 +1. **生成授权**:在 **“提供商池”** 页面或**“配置管理”** 页面,点击对应提供商(如 Gemini)右上角的 **“生成授权”** 按钮。 +2. **扫码/登录**:系统将弹出授权对话框,您可以点击 **“在浏览器中打开”** 进行登录验证。对于 Gemini,Antigravity 需完成 Google 账号授权。 3. **自动保存**:授权成功后,系统会自动获取凭据并保存至 `configs/` 对应目录下,您可以在 **“配置文件”** 页面看到新生成的凭据。 4. **可视化管理**:您可以随时在 Web UI 中上传、删除凭据,或通过 **“快速关联”** 功能将已有的凭据文件一键绑定到提供商。 @@ -308,16 +306,6 @@ docker compose up -d 2. **Pro会员**:Antigravity 暂时对 Pro 会员开放,需要先购买 Pro 会员。 3. **组织账号**:组织账号需要单独授权,联系管理员获取授权。 -#### Qwen Code OAuth 配置 -1. **首次授权**:配置Qwen服务后,系统会自动在浏览器中打开授权页面 -2. **推荐参数**:使用官方默认参数以获得最佳效果 - ```json - { - "temperature": 0, - "top_p": 1 - } - ``` - #### Kiro API 配置 1. **环境准备**:[下载并安装 Kiro 客户端](https://kiro.dev/pricing/) 2. **完成授权**:在客户端中登录账号,生成 `kiro-auth-token.json` 凭据文件 @@ -398,7 +386,6 @@ curl http://localhost:3000/claude-kiro-oauth/v1/chat/completions \ |------|---------|------| | **Gemini** | `~/.gemini/oauth_creds.json` | OAuth 认证凭据 | | **Kiro** | `~/.aws/sso/cache/kiro-auth-token.json` | Kiro 认证令牌 | -| **Qwen** | `~/.qwen/oauth_creds.json` | Qwen OAuth 凭据 | | **Antigravity** | `~/.antigravity/oauth_creds.json` | Antigravity OAuth 凭据 (支持 Claude 4.5 Opus) | | **Codex** | `~/.codex/oauth_creds.json` | Codex OAuth 凭据 | diff --git a/README.md b/README.md index 8629d9c90..98f1371aa 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,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, 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. +`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 and Gemini 3.0 Pro 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** @@ -106,7 +106,6 @@ > - **2025.11.11** - Added Web UI management console, supporting real-time configuration management and health status monitoring > - **2025.11.06** - Added support for Gemini 3 Preview, enhanced model compatibility and performance optimization > - **2025.10.18** - Kiro open registration, new accounts get 500 credits, full support for Claude Sonnet 4.5 -> - **2025.09.01** - Integrated Qwen Code CLI, added `qwen3-coder-plus` model support > - **2025.08.29** - Released account pool management feature, supporting multi-account polling, intelligent failover, and automatic degradation strategies > - Configuration: Add `PROVIDER_POOLS_FILE_PATH` parameter in `configs/config.json` > - Reference configuration: [provider_pools.json](./configs/provider_pools.json.example) @@ -128,7 +127,7 @@ ### 🚀 Break Through Limitations, Improve Efficiency * **Bypass Official Restrictions**: Utilize OAuth authorization mechanism to effectively break through rate and quota limits of services like Gemini, Antigravity * **TLS Fingerprint Bypass**: Built-in TLS Sidecar (Go uTLS) to simulate browser features, effectively bypassing Cloudflare 403 blocks for services like Grok -* **Free Advanced Models**: Use Claude Opus 4.5 for free via Kiro API mode, use Qwen3 Coder Plus via Qwen OAuth mode, reducing usage costs +* **Free Advanced Models**: Use Claude Opus 4.5 for free via Kiro API mode, reducing usage costs * **Intelligent Account Pool Scheduling**: Support multi-account polling, automatic failover, and configuration degradation, ensuring 99.9% service availability ### 🛡️ Secure and Controllable, Data Transparent @@ -261,7 +260,7 @@ A functional Web management interface, including: **📊 Dashboard**: System overview, interactive routing examples, client configuration guide -**⚙️ Configuration**: Real-time parameter modification, supporting all providers (Gemini, Antigravity, OpenAI, Claude, Kiro, Qwen), including advanced settings and file uploads +**⚙️ Configuration**: Real-time parameter modification, supporting all providers (Gemini, Antigravity, OpenAI, Claude, Kiro), including advanced settings and file uploads **🔗 Provider Pools**: Monitor active connections, provider health statistics, enable/disable management @@ -281,7 +280,6 @@ Seamlessly support the following latest large models, just configure the corresp * **Grok 3 / Grok 4** - xAI's flagship models, now supported via Grok Cookie/SSO, supporting thinking models, image generation, and video generation * **Claude 4.5 Opus** - Anthropic's strongest model ever, now supported via Kiro, Antigravity * **Gemini 3 Pro** - Google's next-generation architecture preview, now supported via Gemini, Antigravity -* **Qwen3 Coder Plus** - Alibaba Tongyi Qianwen's latest code-specific model, now supported via Qwen Code * **Kimi K2 / MiniMax M2** - Synchronized support for top domestic flagship models, now supported via custom OpenAI, Claude --- @@ -295,8 +293,8 @@ Seamlessly support the following latest large models, just configure the corresp #### 🌐 Web UI Quick Authorization (Recommended) In the Web UI management interface, you can complete authorization configuration rapidly: -1. **Generate Authorization**: On the **"Provider Pools"** page or **"Configuration"** page, click the **"Generate Authorization"** button in the upper right corner of the corresponding provider (e.g., Gemini, Qwen). -2. **Scan/Login**: An authorization dialog will pop up, you can click **"Open in Browser"** for login verification. For Qwen, just complete the web login; for Gemini and Antigravity, complete the Google account authorization. +1. **Generate Authorization**: On the **"Provider Pools"** page or **"Configuration"** page, click the **"Generate Authorization"** button in the upper right corner of the corresponding provider (e.g., Gemini). +2. **Scan/Login**: An authorization dialog will pop up, you can click **"Open in Browser"** for login verification. For Gemini and Antigravity, complete the Google account authorization. 3. **Auto-Save**: After successful authorization, the system will automatically obtain credentials and save them to the corresponding directory in `configs/`. You can see the newly generated credentials on the **"Config Files"** page. 4. **Visual Management**: You can upload or delete credentials at any time in the Web UI, or use the **"Quick Associate"** function to bind existing credential files to providers with one click. @@ -310,16 +308,6 @@ In the Web UI management interface, you can complete authorization configuration 2. **Pro Member**: Antigravity is temporarily open to Pro members, you need to purchase a Pro membership first. 3. **Organization Account**: Organization accounts require separate authorization, contact the administrator to obtain authorization. -#### Qwen Code OAuth Configuration -1. **First Authorization**: After configuring the Qwen service, the system will automatically open the authorization page in the browser -2. **Recommended Parameters**: Use official default parameters for best results - ```json - { - "temperature": 0, - "top_p": 1 - } - ``` - #### Kiro API Configuration 1. **Environment Preparation**: [Download and install Kiro client](https://kiro.dev/pricing/) 2. **Complete Authorization**: Log in to your account in the client to generate `kiro-auth-token.json` credential file @@ -400,7 +388,6 @@ Default storage locations for authorization credential files of each service: |------|---------|------| | **Gemini** | `~/.gemini/oauth_creds.json` | OAuth authentication credentials | | **Kiro** | `~/.aws/sso/cache/kiro-auth-token.json` | Kiro authentication token | -| **Qwen** | `~/.qwen/oauth_creds.json` | Qwen OAuth credentials | | **Antigravity** | `~/.antigravity/oauth_creds.json` | Antigravity OAuth credentials (supports Claude 4.5 Opus) | | **Codex** | `~/.codex/oauth_creds.json` | Codex OAuth credentials | diff --git a/docs/OPENCODE_CONFIG_EXAMPLE.md b/docs/OPENCODE_CONFIG_EXAMPLE.md index 76016bd2c..c6a1734f9 100644 --- a/docs/OPENCODE_CONFIG_EXAMPLE.md +++ b/docs/OPENCODE_CONFIG_EXAMPLE.md @@ -24,19 +24,6 @@ } } }, - "qwen": { - "npm": "@ai-sdk/openai-compatible", - "name": "AIClient2API-qwen", - "options": { - "baseURL": "http://localhost:3000/openai-qwen-oauth/v1", - "apiKey": "123456" - }, - "models": { - "qwen3-coder-plus": { - "name": "Qwen3 Coder Plus Openai " - } - } - }, "gemini-antigravity": { "npm": "@ai-sdk/google", "name": "AIClient2API-antigravity", @@ -83,12 +70,12 @@ ## 配置重点解释 ### 1. `provider` (服务提供商配置) -这是配置的核心部分,每个键(如 `kiro`, `qwen`, `gemini-cli`)代表一个独立的服务提供商实例。 +这是配置的核心部分,每个键(如 `kiro`, `gemini-cli`)代表一个独立的服务提供商实例。 * **`npm` (SDK 适配器)**: * 指定底层使用的 AI SDK。例如: * `@ai-sdk/anthropic`: 用于 Anthropic (Claude) 系列模型。 - * `@ai-sdk/openai-compatible`: 用于兼容 OpenAI 接口标准的模型(如通义千问 Qwen)。 + * `@ai-sdk/openai-compatible`: 用于兼容 OpenAI 接口标准的模型。 * `@ai-sdk/google`: 用于 Google Gemini 系列模型。 * **重点**: 必须确保 `npm` 字段与您要使用的模型协议匹配,否则会导致连接失败。 From b1a939b3886a99615afd4136f7aebd5f60537bfb Mon Sep 17 00:00:00 2001 From: hex2077 Date: Fri, 17 Apr 2026 22:28:46 +0800 Subject: [PATCH 018/135] =?UTF-8?q?docs(guide):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E4=BB=A4=E7=89=8C=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=9B=B8=E5=85=B3=E9=97=AE=E7=AD=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在用户指南常见问题板块新增两条内容,解答用户关于令牌自动续期机制的疑惑以及预加载配置对凭据有效性的影响。涵盖中英日三语文档更新,同步调整前端国际化资源文件与 UI 展示组件。 --- README-JA.md | 20 ++++++++++++++++++++ README-ZH.md | 19 +++++++++++++++++++ README.md | 19 +++++++++++++++++++ static/app/i18n.js | 8 ++++++++ static/components/section-guide.html | 8 ++++++++ 5 files changed, 74 insertions(+) diff --git a/README-JA.md b/README-JA.md index 6d23bde60..0ec98709d 100644 --- a/README-JA.md +++ b/README-JA.md @@ -701,6 +701,26 @@ kill -9 - **リクエスト頻度を確認**:一部のプロバイダーはリクエスト頻度に厳しい制限があります。リクエスト頻度を下げて再試行 - **プロバイダードキュメントを確認**:対応するプロバイダーの公式ドキュメントにアクセスして、具体的なアクセス制限と要件を理解 +### 14. なぜ「OAuthトークンの自動更新」を有効にする必要があるのですか? + +**問題の説明**:トークンの自動更新機能が必要かどうかがわからない。 + +**解決策**: +OAuthトークン(Gemini、Antigravity、Codexなど)には通常、有効期限(例:1時間)があります。 +- **有効な場合**:システムはバックグラウンドで期限が切れる前にトークンを自動的にチェックして更新します。これにより、24時間365日の安定したAPIサービスが保証され、トークンの期限切れによる `401 Unauthorized` や `403 Forbidden` エラーを回避できます。 +- **無効な場合**:トークンが期限切れになると、システムは新しいトークンを自動的に取得できず、手動で再度OAuth認証を行うまでAPIリクエストが失敗します。 + +### 15. 「モデルプロバイダーのプリロード」が未有効の場合、トークンの維持にどのような影響がありますか? + +**問題の説明**:「モデルプロバイダーのプリロード」設定の役割と、それがトークンの更新にどう影響するかがわからない。 + +**解決策**: +システムは、**アクティブなプールにロードされている**プロバイダーに対してのみ自動更新タスクを実行します。 +- **影響**:設定で特定のプロバイダーを「モデルプロバイダーのプリロード」として選択していない場合、システム起動時にそのプロバイダーは初期化されません。プールに含まれていないため、バックグラウンド更新タスクはそのプロバイダーのトークンを**処理しません**。 +- **結果**:そのプロバイダーを長時間使用しない場合、トークンはバックグラウンドで静かに期限切れになります。たまに特定のルートを介して呼び出すと、トークン切れによりリクエストが失敗します。 +- **推奨事項**:頻繁に使用し、アクティブな状態を維持する必要があるプロバイダーは、必ず「モデルプロバイダーのプリロード」で選択してください。 + + --- diff --git a/README-ZH.md b/README-ZH.md index 57bf2f37f..be10dad01 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -701,6 +701,25 @@ kill -9 - **检查请求频率**:某些提供商对请求频率有严格限制,降低请求频率后重试 - **查看提供商文档**:访问对应提供商的官方文档,了解具体的访问限制和要求 +### 14. 为什么要开启“启用OAuth令牌自动刷新”? + +**问题描述**:不确定是否需要开启令牌自动刷新功能。 + +**解决方案**: +OAuth 令牌(如 Gemini, Antigravity, Codex)通常有一定的有效期(如 1 小时)。 +- **开启后**:系统会在后台自动检查并刷新即将过期的令牌。这能确保提供 24/7 稳定的 API 服务,避免因令牌过期导致的 `401 Unauthorized` 或 `403 Forbidden` 错误。 +- **不开启**:令牌过期后,系统无法自动获取新令牌,导致 API 请求失败,直到您手动重新进行 OAuth 授权。 + +### 15. “预加载模型提供商”未开启对 Token 保持有什么影响? + +**问题描述**:不理解“预加载模型提供商”配置的作用及其对 Token 的影响。 + +**解决方案**: +系统仅会对**已加载到活跃池中**的提供商执行自动刷新任务。 +- **影响**:如果您在配置中未勾选某个提供商作为“预加载模型提供商”,系统启动时不会初始化该提供商。即使开启了“自动刷新”,后台任务也**不会**去刷新这个未激活提供商的令牌。 +- **后果**:如果您长时间不使用该提供商,其 Token 会在后台静默过期。当您偶尔通过特定路由调用它时,会因为 Token 过期而请求失败。 +- **建议**:对于需要长期稳定使用的提供商,请务必在“预加载模型提供商”中进行勾选。 + --- diff --git a/README.md b/README.md index 98f1371aa..195ff6bfb 100644 --- a/README.md +++ b/README.md @@ -701,6 +701,25 @@ Or modify the port configuration in `configs/config.json` to use a different por - **Check Request Frequency**: Some providers have strict request frequency limits; reduce request frequency and retry - **View Provider Documentation**: Visit the official documentation of the corresponding provider to understand specific access restrictions and requirements +### 14. Why should I enable "OAuth Token Auto-Refresh"? + +**Problem Description**: Unsure if token auto-refresh is necessary. + +**Solution**: +OAuth tokens (e.g., Gemini, Antigravity, Codex) typically have a limited lifespan (e.g., 1 hour). +- **With it enabled**: The system automatically checks and refreshes tokens before they expire in the background. This ensures 24/7 stable API service and avoids `401 Unauthorized` or `403 Forbidden` errors due to expired tokens. +- **Without it**: Once a token expires, the system cannot automatically obtain a new one, causing API requests to fail until you manually re-authorize. + +### 15. What is the impact of not enabling "Preload Model Providers" on token maintenance? + +**Problem Description**: Confusion about the "Preload Model Providers" configuration and its relation to token refresh. + +**Solution**: +The system only performs auto-refresh tasks for providers that are **loaded into the active pool**. +- **Impact**: If a provider is not checked as a "Preload Model Provider" in the configuration, it won't be initialized when the system starts. Since it's not in the pool, the background refresh task will **not** process its token. +- **Consequence**: If you don't use that provider for a long time, its token will expire silently. When you eventually call it via a specific route, the request will fail due to the expired token. +- **Recommendation**: Always check providers you intend to use frequently and need to keep active in the "Preload Model Providers" list. + --- diff --git a/static/app/i18n.js b/static/app/i18n.js index 696383faa..5fac73d7c 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -785,6 +785,10 @@ const translations = { 'guide.faq.a6': 'A: 这表示对应类型的提供商都不可用。请在"提供商池"页面检查提供商健康状态,确认 OAuth 凭据未过期,或配置 Fallback 链实现自动切换到备用提供商。', 'guide.faq.q7': 'Q: 请求返回 403 Forbidden 错误怎么办?', 'guide.faq.a7': 'A: 403 表示访问被拒绝。首先检查"提供商池"页面中节点状态,如果节点健康检查正常,可以忽略此报错。其他可能原因包括:账号权限不足、API Key 权限受限、地区访问限制、凭据已失效等。', + 'guide.faq.q8': 'Q: 为什么要开启“启用 OAuth 令牌自动刷新”?', + 'guide.faq.a8': 'A: OAuth 令牌通常有有效期(如 1 小时)。开启后,系统会在后台自动刷新即将过期的令牌,确保 24/7 稳定服务,避免因令牌过期导致的请求失败。', + 'guide.faq.q9': 'Q: “预加载模型提供商”未开启对 Token 保持有什么影响?', + 'guide.faq.a9': 'A: 系统仅会对已预加载的提供商执行自动刷新。如果某提供商未预加载,其 Token 将不会在后台自动刷新,长时间不使用可能导致 Token 过期。建议将常用提供商加入预加载列表。', // Guide - Flow 'guide.flow.title': '操作流程图', @@ -1735,6 +1739,10 @@ const translations = { 'guide.faq.a6': 'A: This means all providers of the corresponding type are unavailable. Check provider health status in "Provider Pools" page, confirm OAuth credentials are not expired, or configure Fallback chain for automatic switching to backup providers.', 'guide.faq.q7': 'Q: What to do if request returns 403 Forbidden error?', 'guide.faq.a7': 'A: 403 means access denied. First check node status in "Provider Pools" page. If node health check is normal, you can ignore this error. Other possible causes include: insufficient account permissions, limited API Key permissions, regional access restrictions, expired credentials, etc.', + 'guide.faq.q8': 'Q: Why should I enable "OAuth Token Auto-Refresh"?', + 'guide.faq.a8': 'A: OAuth tokens usually have an expiration (e.g., 1 hour). When enabled, the system automatically refreshes tokens in the background, ensuring 24/7 stable service and avoiding request failures due to expired tokens.', + 'guide.faq.q9': 'Q: What is the impact of not enabling "Preload Model Providers" on token maintenance?', + 'guide.faq.a9': 'A: The system only performs auto-refresh for pre-initialized providers. If a provider is not pre-initialized, its token will not be automatically refreshed in the background, which may lead to expiration if not used for a long time. It is recommended to add frequently used providers to the pre-initialized list.', // Guide - Flow 'guide.flow.title': 'Operation Flowchart', diff --git a/static/components/section-guide.html b/static/components/section-guide.html index b7c39197f..044980ba4 100644 --- a/static/components/section-guide.html +++ b/static/components/section-guide.html @@ -204,6 +204,14 @@

Q: 请求返回 403 Forbidden 错误怎么办?
A: 403 表示访问被拒绝。首先检查"提供商池"页面中节点状态,如果节点健康检查正常,可以忽略此报错。其他可能原因包括:账号权限不足、API Key 权限受限、地区访问限制、凭据已失效等。

+
+
Q: 为什么要开启“启用 OAuth 令牌自动刷新”?
+
A: OAuth 令牌通常有有效期(如 1 小时)。开启后,系统会在后台自动刷新即将过期的令牌,确保 24/7 稳定服务,避免因令牌过期导致的请求失败。
+
+
+
Q: “预加载模型提供商”未开启对 Token 保持有什么影响?
+
A: 系统仅会对已预加载的提供商执行自动刷新。如果某提供商未预加载,其 Token 将不会在后台自动刷新,长时间不使用可能导致 Token 过期。建议将常用提供商加入预加载列表。
+
From 31ad57952684e52b021c8d18dd7f59e98f57a484 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Sat, 18 Apr 2026 15:52:00 +0800 Subject: [PATCH 019/135] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=20QPS/TPS=20=E7=9B=91=E6=8E=A7=E5=88=B0=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 RateManager 类用于跟踪请求和 Token 速率 - 在 API Key 和模型使用统计中集成 QPS/TPS 数据 - 在管理界面展示实时速率指标 - 使用滑动窗口算法确保统计数据的准确性和性能 --- VERSION | 2 +- src/plugins/api-potluck/api-routes.js | 4 +- src/plugins/api-potluck/key-manager.js | 38 ++++- .../model-usage-stats/stats-manager.js | 29 +++- src/utils/rate-tracker.js | 160 ++++++++++++++++++ static/model-usage-stats.html | 20 ++- static/potluck-user.html | 6 + static/potluck.html | 5 + 8 files changed, 254 insertions(+), 10 deletions(-) create mode 100644 src/utils/rate-tracker.js diff --git a/VERSION b/VERSION index d7b0d017e..5b948d986 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.14.6 +2.14.7 diff --git a/src/plugins/api-potluck/api-routes.js b/src/plugins/api-potluck/api-routes.js index 1c987ae02..20ca9b411 100644 --- a/src/plugins/api-potluck/api-routes.js +++ b/src/plugins/api-potluck/api-routes.js @@ -453,7 +453,9 @@ export async function handlePotluckUserApiRoutes(method, path, req, res) { promptTokens: keyData.todayPromptTokens || 0, completionTokens: keyData.todayCompletionTokens || 0, totalTokens: keyData.todayTotalTokens || 0, - cachedTokens: keyData.todayCachedTokens || 0 + cachedTokens: keyData.todayCachedTokens || 0, + qps: keyData.qps || 0, + tps: keyData.tps || 0 }, total: keyData.totalUsage, tokens: { diff --git a/src/plugins/api-potluck/key-manager.js b/src/plugins/api-potluck/key-manager.js index 798a18b1f..2f4ed3745 100644 --- a/src/plugins/api-potluck/key-manager.js +++ b/src/plugins/api-potluck/key-manager.js @@ -8,6 +8,7 @@ import logger from '../../utils/logger.js'; import { existsSync, readFileSync, writeFileSync } from 'fs'; import path from 'path'; import crypto from 'crypto'; +import { RateManager } from '../../utils/rate-tracker.js'; // 配置文件路径 const KEYS_STORE_FILE = path.join(process.cwd(), 'configs', 'api-potluck-keys.json'); @@ -47,6 +48,8 @@ let isWriting = false; let persistTimer = null; let currentPersistInterval = DEFAULT_CONFIG.persistInterval; +const rateManager = new RateManager(60); + function createUsageBucket() { return { requestCount: 0, @@ -315,11 +318,13 @@ export async function createKey(name = '', dailyLimit = null) { totalCachedTokens: 0, lastResetDate: today, lastUsedAt: null, - enabled: true + enabled: true, + usageHistory: {} }; keyStore.keys[apiKey] = keyData; markDirty(); + await persistIfDirty(); // 创建操作立即持久化 logger.info(`[API Potluck] Created key: ${apiKey.substring(0, 12)}...`); @@ -334,8 +339,11 @@ export async function listKeys() { const keys = []; for (const [keyId, keyData] of Object.entries(keyStore.keys)) { const updated = checkAndResetDailyCount({ ...keyData }); + const rates = rateManager.getStats(`key:${keyId}`); keys.push({ ...updated, + qps: rates.qps, + tps: rates.tps, maskedKey: `${keyId.substring(0, 12)}...${keyId.substring(keyId.length - 4)}` }); } @@ -349,7 +357,13 @@ export async function getKey(keyId) { ensureLoaded(); const keyData = keyStore.keys[keyId]; if (!keyData) return null; - return checkAndResetDailyCount({ ...keyData }); + const updated = checkAndResetDailyCount({ ...keyData }); + const rates = rateManager.getStats(`key:${keyId}`); + return { + ...updated, + qps: rates.qps, + tps: rates.tps + }; } /** @@ -359,6 +373,10 @@ export async function deleteKey(keyId) { ensureLoaded(); if (!keyStore.keys[keyId]) return false; delete keyStore.keys[keyId]; + + // 清理速率追踪器,防止内存泄漏 + rateManager.remove(`key:${keyId}`); + markDirty(); await persistIfDirty(); // 删除操作立即持久化 logger.info(`[API Potluck] Deleted key: ${keyId.substring(0, 12)}...`); @@ -412,6 +430,9 @@ export async function resetKeyTokenStats(keyId) { keyData.totalCachedTokens = 0; resetUsageHistoryTokens(keyData.usageHistory); + // 重置该 Key 的速率追踪器 + rateManager.remove(`key:${keyId}`); + markDirty(); await persistIfDirty(); logger.info(`[API Potluck] Reset token stats for key: ${keyId.substring(0, 12)}...`); @@ -438,7 +459,11 @@ export async function resetAllTokenStats() { updated++; } + // 重置所有 Key 的速率追踪器 + rateManager.clear(); + if (updated > 0) { + markDirty(); await persistIfDirty(); } @@ -494,6 +519,9 @@ export async function regenerateKey(oldKeyId) { delete keyStore.keys[oldKeyId]; keyStore.keys[newKeyId] = newKeyData; + // 清理旧 Key 的速率追踪器 + rateManager.remove(`key:${oldKeyId}`); + markDirty(); await persistIfDirty(); // 立即持久化 @@ -580,6 +608,9 @@ export async function incrementUsage(apiKey, provider = 'unknown', model = 'unkn addUsage(userHistory.providers[pName], { requestCount: 1, ...usage }); addUsage(userHistory.models[mName], { requestCount: 1, ...usage }); + // 记录速率统计 + rateManager.record(`key:${apiKey}`, usage.totalTokens); + // 清理该 Key 的过期历史 (保留 100 天以支持 3 个月日历) const userDates = Object.keys(keyData.usageHistory).sort(); if (userDates.length > 100) { @@ -647,6 +678,7 @@ export async function getStats() { } } + const globalRates = rateManager.getGlobalStats(); return { totalKeys: keys.length, enabledKeys, @@ -661,6 +693,8 @@ export async function getStats() { totalCompletionTokens, totalTokens, totalCachedTokens, + qps: globalRates.qps, + tps: globalRates.tps, usageHistory: aggregatedHistory }; } diff --git a/src/plugins/model-usage-stats/stats-manager.js b/src/plugins/model-usage-stats/stats-manager.js index c09a82dd1..56c21777d 100644 --- a/src/plugins/model-usage-stats/stats-manager.js +++ b/src/plugins/model-usage-stats/stats-manager.js @@ -2,6 +2,7 @@ import { promises as fs } from 'fs'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import path from 'path'; import logger from '../../utils/logger.js'; +import { RateManager } from '../../utils/rate-tracker.js'; const STATS_STORE_FILE = path.join(process.cwd(), 'configs', 'model-usage-stats.json'); const DEFAULT_CONFIG = { @@ -17,6 +18,7 @@ let currentPersistInterval = DEFAULT_CONFIG.persistInterval; let mutationVersion = 0; let persistPromise = null; +const rateManager = new RateManager(60); // 使用 60 秒滑动窗口,更平滑 const pendingRequests = new Map(); function getTraceRequestId(requestId) { @@ -421,6 +423,10 @@ export async function finalizeRequest({ requestId, model, provider, fromProvider applyUsage(ensureProviderStore(normalizedProvider).summary, usage, timestamp); applyUsage(ensureModelStore(normalizedProvider, normalizedModel), usage, timestamp); + // 记录速率统计 + rateManager.record(`provider:${normalizedProvider}`, usage.totalTokens); + rateManager.record(`model:${normalizedModel}`, usage.totalTokens); + // 记录每日统计 const dateKey = timestamp.split('T')[0]; if (!statsStore.daily[dateKey]) { @@ -436,13 +442,33 @@ export async function finalizeRequest({ requestId, model, provider, fromProvider export async function getStats() { ensureLoaded(); - return JSON.parse(JSON.stringify(statsStore)); + const stats = JSON.parse(JSON.stringify(statsStore)); + + // 注入速率统计 + const globalRates = rateManager.getGlobalStats(); + stats.summary.qps = globalRates.qps; + stats.summary.tps = globalRates.tps; + + for (const [provider, providerStore] of Object.entries(stats.providers || {})) { + const pRates = rateManager.getStats(`provider:${provider}`); + providerStore.summary.qps = pRates.qps; + providerStore.summary.tps = pRates.tps; + + for (const [model, modelStore] of Object.entries(providerStore.models || {})) { + const mRates = rateManager.getStats(`model:${model}`); + modelStore.qps = mRates.qps; + modelStore.tps = mRates.tps; + } + } + + return stats; } export async function resetStats() { ensureLoaded(); statsStore = createDefaultStore(); pendingRequests.clear(); + rateManager.clear(); // 同时重置速率统计 markDirty(); await persistIfDirty(); logger.warn('[Model Usage Stats] Stats store reset'); @@ -469,6 +495,7 @@ export async function resetTokenStats() { } pendingRequests.clear(); + rateManager.clear(); // 同时重置速率统计 markDirty(); await persistIfDirty(); logger.warn('[Model Usage Stats] Token stats reset'); diff --git a/src/utils/rate-tracker.js b/src/utils/rate-tracker.js new file mode 100644 index 000000000..8322d0526 --- /dev/null +++ b/src/utils/rate-tracker.js @@ -0,0 +1,160 @@ +/** + * 速率追踪器 - 用于统计 QPS 和 TPS + * 使用滑动窗口(桶)实现,性能较好 + */ +export class RateTracker { + /** + * @param {number} windowSeconds - 窗口大小(秒),默认 10 秒 + */ + constructor(windowSeconds = 10) { + this.windowSeconds = windowSeconds; + // 桶数组,每个桶代表 1 秒 + this.buckets = Array.from({ length: windowSeconds }, () => ({ count: 0, total: 0 })); + this.lastUpdateTime = Math.floor(Date.now() / 1000); + this.firstRecordTime = 0; // 记录第一次收到记录的时间,用于计算初期的准确速率 + } + + /** + * 向前推进时间,清理过期的桶 + * @private + */ + _advance(nowSeconds) { + const diff = nowSeconds - this.lastUpdateTime; + if (diff <= 0) return; + + // 如果时间差超过窗口大小,清理所有桶 + const skip = Math.min(diff, this.windowSeconds); + for (let i = 0; i < skip; i++) { + const index = (this.lastUpdateTime + i + 1) % this.windowSeconds; + this.buckets[index] = { count: 0, total: 0 }; + } + this.lastUpdateTime = nowSeconds; + } + + /** + * 记录一次请求和对应的 Token 数量 + * @param {number} tokens - 本次请求产生的 Token 数量 + */ + record(tokens = 0) { + const nowSeconds = Math.floor(Date.now() / 1000); + if (this.firstRecordTime === 0) { + this.firstRecordTime = nowSeconds; + } + + this._advance(nowSeconds); + const index = nowSeconds % this.windowSeconds; + this.buckets[index].count += 1; + this.buckets[index].total += (Number(tokens) || 0); + } + + /** + * 获取当前的速率统计 + * @returns {{qps: number, tps: number}} + */ + getStats() { + const nowSeconds = Math.floor(Date.now() / 1000); + + // 如果从未有记录,直接返回 0 + if (this.firstRecordTime === 0) { + return { qps: 0, tps: 0 }; + } + + this._advance(nowSeconds); + + let totalCount = 0; + let totalTokens = 0; + for (const bucket of this.buckets) { + totalCount += bucket.count; + totalTokens += bucket.total; + } + + // 计算有效的分母:取 (当前时间 - 开始时间 + 1) 和 窗口大小 的较小值,最小为 1 + const elapsed = Math.max(1, nowSeconds - this.firstRecordTime + 1); + const divisor = Math.min(elapsed, this.windowSeconds); + + return { + qps: Number((totalCount / divisor).toFixed(2)), + tps: Number((totalTokens / divisor).toFixed(2)) + }; + } +} + +/** + * 速率管理类 - 用于管理多个追踪器 + */ +export class RateManager { + constructor(windowSeconds = 10, maxTrackers = 5000) { + this.windowSeconds = windowSeconds; + this.maxTrackers = maxTrackers; // 防止内存泄露,限制追踪器数量 + this.trackers = new Map(); + this.globalTracker = new RateTracker(windowSeconds); + } + + /** + * 记录用量 + * @param {string} key - 标识符(如 provider 或 model) + * @param {number} tokens - Token 数量 + */ + record(key, tokens = 0) { + this.globalTracker.record(tokens); + if (key) { + if (!this.trackers.has(key)) { + // 容量控制 + if (this.trackers.size >= this.maxTrackers) { + return; + } + this.trackers.set(key, new RateTracker(this.windowSeconds)); + } + this.trackers.get(key).record(tokens); + } + } + + /** + * 删除指定的追踪器 + * @param {string} key + */ + remove(key) { + if (key) { + this.trackers.delete(key); + } + } + + /** + * 清理所有追踪器 + */ + clear() { + this.trackers.clear(); + this.globalTracker = new RateTracker(this.windowSeconds); + } + + /** + * 获取全局统计 + */ + getGlobalStats() { + return this.globalTracker.getStats(); + } + + /** + * 获取指定标识符的统计 + */ + getStats(key) { + if (!key || !this.trackers.has(key)) { + return { qps: 0, tps: 0 }; + } + return this.trackers.get(key).getStats(); + } + + /** + * 获取所有统计 + */ + getAllStats() { + const result = { + global: this.globalTracker.getStats(), + items: {} + }; + for (const [key, tracker] of this.trackers.entries()) { + result.items[key] = tracker.getStats(); + } + return result; + } +} diff --git a/static/model-usage-stats.html b/static/model-usage-stats.html index 6f70e1bae..fd941f57f 100644 --- a/static/model-usage-stats.html +++ b/static/model-usage-stats.html @@ -780,11 +780,19 @@

模型用量统计面板

-
总请求数
0
累计成功落库的模型调用次数
+
+
总请求数
+
0
+
实时 QPS: 0.00
+
输入 Token
0
输入 token 的累计值
缓存 Token
0
缓存命中的累计值
输出 Token
0
输出 token 的累计值
-
总 Token
0
等待数据
+
+
总 Token
+
0
+
实时 TPS: 0.00 | 等待数据
+
@@ -833,7 +841,7 @@

模型明
- +
ProviderModel请求数PromptCachedCompletionTotal最近使用
ProviderModel请求数 / QPSTPSPromptCachedCompletionTotal最近使用
@@ -997,6 +1005,8 @@

模型明 el('cachedTokens').textContent = fmtToken(s.cachedTokens); el('completionTokens').textContent = fmtToken(s.completionTokens); el('totalTokens').textContent = fmtToken(s.totalTokens); + el('currentQps').textContent = (s.qps || 0).toFixed(2); + el('currentTps').textContent = (s.tps || 0).toFixed(2); el('updatedAt').textContent = data.updatedAt ? `更新于 ${new Date(data.updatedAt).toLocaleString('zh-CN')}` : '尚未写入' } @@ -1016,7 +1026,7 @@

模型明 const s = p.data.summary || {}; const node = document.createElement('article'); node.className = 'provider'; - node.innerHTML = `
${p.name}
包含 ${fmt(p.count)} 个模型
${fmt(s.requestCount)} 次调用
总 Token
${fmtToken(s.totalTokens)}
输入
${fmtToken(s.promptTokens)}
缓存
${fmtToken(s.cachedTokens)}
输出
${fmtToken(s.completionTokens)}
最近使用
${rel(s.lastUsedAt)}
`; + node.innerHTML = `
${p.name}
包含 ${fmt(p.count)} 个模型
${fmt(s.requestCount)} 次调用
总 Token
${fmtToken(s.totalTokens)}
输入
${fmtToken(s.promptTokens)}
输出
${fmtToken(s.completionTokens)}
QPS / TPS
${(s.qps || 0).toFixed(2)} / ${(s.tps || 0).toFixed(2)}
最近使用
${rel(s.lastUsedAt)}
`; box.appendChild(node) }) } @@ -1048,7 +1058,7 @@

模型明 } list.forEach(r => { const tr = document.createElement('tr'); - tr.innerHTML = `${r.provider}${r.model}${fmt(r.requestCount)}${fmtToken(r.promptTokens)}${fmtToken(r.cachedTokens)}${fmtToken(r.completionTokens)}${fmtToken(r.totalTokens)}${rel(r.lastUsedAt)}`; + tr.innerHTML = `${r.provider}${r.model}${fmt(r.requestCount)}
QPS: ${(r.qps || 0).toFixed(2)}
${(r.tps || 0).toFixed(2)}${fmtToken(r.promptTokens)}${fmtToken(r.cachedTokens)}${fmtToken(r.completionTokens)}${fmtToken(r.totalTokens)}${rel(r.lastUsedAt)}`; tbody.appendChild(tr) }) } diff --git a/static/potluck-user.html b/static/potluck-user.html index c612080d5..bd239d2a7 100644 --- a/static/potluck-user.html +++ b/static/potluck-user.html @@ -420,6 +420,7 @@

个人使用统计0 / 0 +
当前 QPS: 0.00
剩余额度
@@ -434,6 +435,7 @@

个人使用统计
今日 Tokens
0
+
当前 TPS: 0.00

今日缓存 Tokens
@@ -646,6 +648,10 @@

API 密钥

document.getElementById('statTotalTokens').textContent = formatTokenCompact(data.tokens?.total || 0); document.getElementById('statTotalCachedTokens').textContent = formatTokenCompact(data.tokens?.cached || 0); + // 速率统计 + document.getElementById('statQps').textContent = (data.usage?.qps || 0).toFixed(2); + document.getElementById('statTps').textContent = (data.usage?.tps || 0).toFixed(2); + // 最后使用时间 if (data.lastUsedAt) { const date = new Date(data.lastUsedAt); diff --git a/static/potluck.html b/static/potluck.html index 416ab6d58..7895ae69a 100644 --- a/static/potluck.html +++ b/static/potluck.html @@ -628,6 +628,7 @@
今日总调用
0
+
实时 QPS: 0.00
累计调用
@@ -636,6 +637,7 @@
今日总 Tokens
0
+
实时 TPS: 0.00
今日缓存 Tokens
@@ -887,6 +889,8 @@

批量应用每日限额

document.getElementById('todayCachedTokens').textContent = formatTokenCompact(stats.todayCachedTokens || 0); document.getElementById('totalTokens').textContent = formatTokenCompact(stats.totalTokens); document.getElementById('totalCachedTokens').textContent = formatTokenCompact(stats.totalCachedTokens || 0); + document.getElementById('currentQps').textContent = (stats.qps || 0).toFixed(2); + document.getElementById('currentTps').textContent = (stats.tps || 0).toFixed(2); // 渲染使用历史分布 renderUsageHistory(stats.usageHistory); @@ -1158,6 +1162,7 @@

批量应用每日限额

今日/限额
${key.todayUsage}/${key.dailyLimit}
${formatTokenCompact(key.todayTotalTokens || 0)} Tokens ${key.todayCachedTokens ? `(含 ${formatTokenCompact(key.todayCachedTokens)} 缓存)` : ''}
+
QPS: ${(key.qps || 0).toFixed(2)} | TPS: ${(key.tps || 0).toFixed(2)}
From 62c821bcfc69189bcf34c08b4ad020b031cc10c9 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Sat, 18 Apr 2026 16:13:14 +0800 Subject: [PATCH 020/135] =?UTF-8?q?fix(api-potluck):=20=E4=BF=AE=E5=A4=8DA?= =?UTF-8?q?PI=E5=AF=86=E9=92=A5=E4=BD=BF=E7=94=A8=E9=87=8F=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E7=BB=9F=E8=AE=A1=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在key-manager.js中添加请求ID防重机制,防止同一请求多次统计速率 - 修改incrementUsage函数,支持通过requestId避免重复记录 - 在stats-manager.js中添加rateRecorded标记,防止请求重复处理 - 移除前端界面中不再需要的QPS/TPS显示,简化统计表格 - 更新VERSION文件至2.14.7.1版本 --- VERSION | 2 +- src/plugins/api-potluck/index.js | 6 +- src/plugins/api-potluck/key-manager.js | 213 ++++++++++-------- .../model-usage-stats/stats-manager.js | 9 + static/model-usage-stats.html | 4 +- static/potluck.html | 1 - 6 files changed, 134 insertions(+), 101 deletions(-) diff --git a/VERSION b/VERSION index 5b948d986..fb04cd508 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.14.7 +2.14.7.1 diff --git a/src/plugins/api-potluck/index.js b/src/plugins/api-potluck/index.js index 8aeb065d8..cf84325aa 100644 --- a/src/plugins/api-potluck/index.js +++ b/src/plugins/api-potluck/index.js @@ -275,13 +275,15 @@ const apiPotluckPlugin = { try { const usage = getPendingUsageForHookContext(hookContext); - // 传入提供商和模型信息 + // 传入提供商和模型信息,以及请求 ID 用于防重 await incrementUsage( hookContext.potluckApiKey, hookContext.toProvider, hookContext.model, - usage + usage, + trackedRequestIds[0] || null ); + } catch (e) { // 静默失败,不影响主流程 logger.error('[API Potluck Plugin] Failed to record usage:', e.message); diff --git a/src/plugins/api-potluck/key-manager.js b/src/plugins/api-potluck/key-manager.js index 2f4ed3745..960568fc1 100644 --- a/src/plugins/api-potluck/key-manager.js +++ b/src/plugins/api-potluck/key-manager.js @@ -134,7 +134,9 @@ function normalizeStore(store = {}) { } function addUsage(target, usage = {}) { - target.requestCount += toNumber(usage.requestCount); + // 默认请求数为 1,确保总量与明细一致 + const rCount = usage.requestCount !== undefined ? toNumber(usage.requestCount) : 1; + target.requestCount += rCount; target.promptTokens += toNumber(usage.promptTokens); target.completionTokens += toNumber(usage.completionTokens); target.totalTokens += toNumber(usage.totalTokens); @@ -494,122 +496,81 @@ export async function updateKeyName(keyId, newName) { return keyStore.keys[keyId]; } -/** - * 重新生成 API Key(保留原有数据,更换 Key ID) - * @param {string} oldKeyId - 原 Key ID - * @returns {Promise<{oldKey: string, newKey: string, keyData: Object}|null>} - */ -export async function regenerateKey(oldKeyId) { - ensureLoaded(); - const oldKeyData = keyStore.keys[oldKeyId]; - if (!oldKeyData) return null; - - // 生成新的唯一 Key - const newKeyId = generateApiKey(); - - // 复制数据到新 Key - const newKeyData = { - ...oldKeyData, - id: newKeyId, - regeneratedAt: new Date().toISOString(), - regeneratedFrom: oldKeyId.substring(0, 12) + '...' - }; - - // 删除旧 Key,添加新 Key - delete keyStore.keys[oldKeyId]; - keyStore.keys[newKeyId] = newKeyData; - - // 清理旧 Key 的速率追踪器 - rateManager.remove(`key:${oldKeyId}`); - - markDirty(); - await persistIfDirty(); // 立即持久化 - - logger.info(`[API Potluck] Regenerated key: ${oldKeyId.substring(0, 12)}... -> ${newKeyId.substring(0, 12)}...`); - - return { - oldKey: oldKeyId, - newKey: newKeyId, - keyData: newKeyData - }; -} +// 用于防止同一请求重复统计速率,改用 Map 以支持批量清理 +const recordedRequests = new Map(); +let lastCleanupTime = Date.now(); /** - * 验证 API Key 是否有效且有配额 + * 清理过期的请求记录 */ -export async function validateKey(apiKey) { - ensureLoaded(); - if (!apiKey || !apiKey.startsWith(KEY_PREFIX)) { - return { valid: false, reason: 'invalid_format' }; - } - const keyData = keyStore.keys[apiKey]; - if (!keyData) return { valid: false, reason: 'not_found' }; - if (!keyData.enabled) return { valid: false, reason: 'disabled' }; - - // 直接在内存中检查和重置 - checkAndResetDailyCount(keyData); +function cleanupRecordedRequests() { + const now = Date.now(); + if (now - lastCleanupTime < 60000) return; // 每分钟清理一次 - // 检查每日限额 - if (keyData.todayUsage < keyData.dailyLimit) { - return { valid: true, keyData }; + const cutoff = now - 60000; // 清理 1 分钟前的记录 + for (const [id, timestamp] of recordedRequests.entries()) { + if (timestamp < cutoff) recordedRequests.delete(id); } - - return { valid: false, reason: 'quota_exceeded', keyData }; + lastCleanupTime = now; } /** - * 增加 Key 的使用次数(原子操作,直接修改内存) - * @param {string} apiKey - API Key - * @param {string} provider - 使用的提供商 - * @param {string} model - 使用的模型 - * @param {{promptTokens?: number, completionTokens?: number, totalTokens?: number, cachedTokens?: number}} usage - token 用量 + * 增加 API Key 的使用量 + * @param {string} apiKey - API Key ID + * @param {string} pName - 提供商名称 + * @param {string} mName - 模型名称 + * @param {Object} usage - 用量数据 + * @param {string} [requestId] - 请求 ID,用于防止重复统计速率 */ -export async function incrementUsage(apiKey, provider = 'unknown', model = 'unknown', usage = {}) { +export async function incrementUsage(apiKey, pName = 'unknown', mName = 'unknown', usage = {}, requestId = null) { ensureLoaded(); const keyData = keyStore.keys[apiKey]; - if (!keyData) return null; + if (!keyData) return; + + // 防止同一请求重复统计速率 + let shouldRecordRate = true; + if (requestId) { + cleanupRecordedRequests(); + if (recordedRequests.has(requestId)) { + shouldRecordRate = false; + } else { + recordedRequests.set(requestId, Date.now()); + } + } - checkAndResetDailyCount(keyData); - - // 消耗每日限额 - if (keyData.todayUsage < keyData.dailyLimit) { - keyData.todayUsage += 1; - } else { - // 每日限额用尽 - return null; + // 记录速率统计 + if (shouldRecordRate) { + rateManager.record(`key:${apiKey}`, usage.totalTokens); } - - keyData.totalUsage += 1; - keyData.todayPromptTokens += toNumber(usage.promptTokens); - keyData.todayCompletionTokens += toNumber(usage.completionTokens); - keyData.todayTotalTokens += toNumber(usage.totalTokens); - keyData.todayCachedTokens += toNumber(usage.cachedTokens); - keyData.totalPromptTokens += toNumber(usage.promptTokens); - keyData.totalCompletionTokens += toNumber(usage.completionTokens); - keyData.totalTokens += toNumber(usage.totalTokens); - keyData.totalCachedTokens += toNumber(usage.cachedTokens); - keyData.lastUsedAt = new Date().toISOString(); - // 记录个人按天统计 (每个 Key 独立) + // 更新每日和历史统计 const today = getTodayDateString(); if (!keyData.usageHistory) keyData.usageHistory = {}; if (!keyData.usageHistory[today]) { keyData.usageHistory[today] = normalizeUsageHistoryDay(); } - // 确保 provider 和 model 是字符串 - const pName = String(provider || 'unknown'); - const mName = String(model || 'unknown'); + const dayHistory = keyData.usageHistory[today]; + addUsage(dayHistory.summary, usage); + + if (!dayHistory.providers[pName]) dayHistory.providers[pName] = createUsageBucket(); + addUsage(dayHistory.providers[pName], usage); - const userHistory = keyData.usageHistory[today]; - userHistory.providers[pName] = normalizeUsageBucket(userHistory.providers[pName]); - userHistory.models[mName] = normalizeUsageBucket(userHistory.models[mName]); - addUsage(userHistory.summary, { requestCount: 1, ...usage }); - addUsage(userHistory.providers[pName], { requestCount: 1, ...usage }); - addUsage(userHistory.models[mName], { requestCount: 1, ...usage }); + if (!dayHistory.models[mName]) dayHistory.models[mName] = createUsageBucket(); + addUsage(dayHistory.models[mName], usage); - // 记录速率统计 - rateManager.record(`key:${apiKey}`, usage.totalTokens); + // 更新今日和累计总量 (统一处理默认调用次数) + const rCount = usage.requestCount !== undefined ? toNumber(usage.requestCount) : 1; + keyData.todayUsage += rCount; + keyData.totalUsage += rCount; + keyData.todayPromptTokens += toNumber(usage.promptTokens); + keyData.todayCompletionTokens += toNumber(usage.completionTokens); + keyData.todayTotalTokens += toNumber(usage.totalTokens); + keyData.todayCachedTokens += toNumber(usage.cachedTokens); + keyData.totalPromptTokens += toNumber(usage.promptTokens); + keyData.totalCompletionTokens += toNumber(usage.completionTokens); + keyData.totalTokens += toNumber(usage.totalTokens); + keyData.totalCachedTokens += toNumber(usage.cachedTokens); // 清理该 Key 的过期历史 (保留 100 天以支持 3 个月日历) const userDates = Object.keys(keyData.usageHistory).sort(); @@ -735,5 +696,67 @@ export function getAllKeyIds() { return Object.keys(keyStore.keys); } +/** + * 验证 API Key 是否有效 + * @param {string} apiKey - 待验证的 Key + * @returns {Promise<{valid: boolean, reason?: string, keyData?: Object}>} + */ +export async function validateKey(apiKey) { + ensureLoaded(); + if (!apiKey || !apiKey.startsWith(KEY_PREFIX)) { + return { valid: false, reason: 'invalid_format' }; + } + const keyData = keyStore.keys[apiKey]; + if (!keyData) { + return { valid: false, reason: 'not_found' }; + } + if (!keyData.enabled) { + return { valid: false, reason: 'disabled' }; + } + const updated = checkAndResetDailyCount(keyData); + if (updated.dailyLimit > 0 && updated.todayUsage >= updated.dailyLimit) { + return { valid: false, reason: 'quota_exceeded', keyData: updated }; + } + return { valid: true, keyData: updated }; +} + +/** + * 重新生成 API Key(保留原有数据,更换 Key ID) + * @param {string} oldKeyId - 原 Key ID + * @returns {Promise<{oldKey: string, newKey: string, keyData: Object}|null>} + */ +export async function regenerateKey(oldKeyId) { + ensureLoaded(); + const oldKeyData = keyStore.keys[oldKeyId]; + if (!oldKeyData) return null; + + // 生成新的唯一 Key + const newKeyId = generateApiKey(); + + // 复制数据到新 Key + const newKeyData = { + ...oldKeyData, + id: newKeyId, + regeneratedAt: new Date().toISOString(), + regeneratedFrom: oldKeyId.substring(0, 12) + '...' + }; + + // 删除旧 Key,添加新 Key + delete keyStore.keys[oldKeyId]; + keyStore.keys[newKeyId] = newKeyData; + + // 清理旧 Key 的速率追踪器 + rateManager.remove(`key:${oldKeyId}`); + + markDirty(); + await persistIfDirty(); + + return { + oldKey: oldKeyId, + newKey: newKeyId, + keyData: newKeyData + }; +} + // 导出常量 export { KEY_PREFIX }; diff --git a/src/plugins/model-usage-stats/stats-manager.js b/src/plugins/model-usage-stats/stats-manager.js index 56c21777d..ac07e7f4c 100644 --- a/src/plugins/model-usage-stats/stats-manager.js +++ b/src/plugins/model-usage-stats/stats-manager.js @@ -402,6 +402,14 @@ export async function finalizeRequest({ requestId, model, provider, fromProvider } const state = getPendingRequest(requestId, { model, provider, fromProvider, isStream }); + + // 防重逻辑:如果该请求已经处理过速率统计,则直接删除并返回 + if (state.rateRecorded) { + pendingRequests.delete(requestId); + return true; + } + state.rateRecorded = true; + pendingRequests.delete(requestId); if (!state.hasResponse) { @@ -412,6 +420,7 @@ export async function finalizeRequest({ requestId, model, provider, fromProvider const timestamp = new Date().toISOString(); const normalizedProvider = state.provider || provider || 'unknown'; const normalizedModel = state.model || model || 'unknown'; + const usage = { promptTokens: state.usage.promptTokens, completionTokens: state.usage.completionTokens, diff --git a/static/model-usage-stats.html b/static/model-usage-stats.html index fd941f57f..aa4d3927f 100644 --- a/static/model-usage-stats.html +++ b/static/model-usage-stats.html @@ -841,7 +841,7 @@

模型明

- +
ProviderModel请求数 / QPSTPSPromptCachedCompletionTotal最近使用
ProviderModel请求数PromptCachedCompletionTotal最近使用
@@ -1058,7 +1058,7 @@

模型明 } list.forEach(r => { const tr = document.createElement('tr'); - tr.innerHTML = `${r.provider}${r.model}${fmt(r.requestCount)}
QPS: ${(r.qps || 0).toFixed(2)}
${(r.tps || 0).toFixed(2)}${fmtToken(r.promptTokens)}${fmtToken(r.cachedTokens)}${fmtToken(r.completionTokens)}${fmtToken(r.totalTokens)}${rel(r.lastUsedAt)}`; + tr.innerHTML = `${r.provider}${r.model}${fmt(r.requestCount)}${fmtToken(r.promptTokens)}${fmtToken(r.cachedTokens)}${fmtToken(r.completionTokens)}${fmtToken(r.totalTokens)}${rel(r.lastUsedAt)}`; tbody.appendChild(tr) }) } diff --git a/static/potluck.html b/static/potluck.html index 7895ae69a..1e3007ef5 100644 --- a/static/potluck.html +++ b/static/potluck.html @@ -1162,7 +1162,6 @@

批量应用每日限额

今日/限额
${key.todayUsage}/${key.dailyLimit}
${formatTokenCompact(key.todayTotalTokens || 0)} Tokens ${key.todayCachedTokens ? `(含 ${formatTokenCompact(key.todayCachedTokens)} 缓存)` : ''}
-
QPS: ${(key.qps || 0).toFixed(2)} | TPS: ${(key.tps || 0).toFixed(2)}
From 7b66cd3a0e18b00ff2a31ca5ebb9bea37e84f808 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Sat, 18 Apr 2026 17:21:15 +0800 Subject: [PATCH 021/135] =?UTF-8?q?feat(rate-tracker):=20=E5=9C=A8?= =?UTF-8?q?=E9=80=9F=E7=8E=87=E8=BF=BD=E8=B8=AA=E4=B8=AD=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=B3=B0=E5=80=BCQPS/TPS=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在RateTracker中新增maxQps和maxTps字段记录峰值速率 - 新增resetPeaks方法用于重置峰值统计 - 在getStats返回结果中增加maxQps和maxTps字段 - 更新API接口、统计页面和用户界面以显示峰值速率 - 同步更新版本号至2.14.7.2 --- VERSION | 2 +- src/plugins/api-potluck/api-routes.js | 4 +- src/plugins/api-potluck/key-manager.js | 2 + .../model-usage-stats/stats-manager.js | 6 +++ src/utils/rate-tracker.js | 37 +++++++++++++++++-- static/model-usage-stats.html | 6 ++- static/potluck-user.html | 6 ++- static/potluck.html | 6 ++- 8 files changed, 57 insertions(+), 12 deletions(-) diff --git a/VERSION b/VERSION index fb04cd508..425b63821 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.14.7.1 +2.14.7.2 diff --git a/src/plugins/api-potluck/api-routes.js b/src/plugins/api-potluck/api-routes.js index 20ca9b411..a6af53536 100644 --- a/src/plugins/api-potluck/api-routes.js +++ b/src/plugins/api-potluck/api-routes.js @@ -455,7 +455,9 @@ export async function handlePotluckUserApiRoutes(method, path, req, res) { totalTokens: keyData.todayTotalTokens || 0, cachedTokens: keyData.todayCachedTokens || 0, qps: keyData.qps || 0, - tps: keyData.tps || 0 + tps: keyData.tps || 0, + maxQps: keyData.maxQps || 0, + maxTps: keyData.maxTps || 0 }, total: keyData.totalUsage, tokens: { diff --git a/src/plugins/api-potluck/key-manager.js b/src/plugins/api-potluck/key-manager.js index 960568fc1..0e6d84eb4 100644 --- a/src/plugins/api-potluck/key-manager.js +++ b/src/plugins/api-potluck/key-manager.js @@ -656,6 +656,8 @@ export async function getStats() { totalCachedTokens, qps: globalRates.qps, tps: globalRates.tps, + maxQps: globalRates.maxQps, + maxTps: globalRates.maxTps, usageHistory: aggregatedHistory }; } diff --git a/src/plugins/model-usage-stats/stats-manager.js b/src/plugins/model-usage-stats/stats-manager.js index ac07e7f4c..e7722fb52 100644 --- a/src/plugins/model-usage-stats/stats-manager.js +++ b/src/plugins/model-usage-stats/stats-manager.js @@ -457,16 +457,22 @@ export async function getStats() { const globalRates = rateManager.getGlobalStats(); stats.summary.qps = globalRates.qps; stats.summary.tps = globalRates.tps; + stats.summary.maxQps = globalRates.maxQps; + stats.summary.maxTps = globalRates.maxTps; for (const [provider, providerStore] of Object.entries(stats.providers || {})) { const pRates = rateManager.getStats(`provider:${provider}`); providerStore.summary.qps = pRates.qps; providerStore.summary.tps = pRates.tps; + providerStore.summary.maxQps = pRates.maxQps; + providerStore.summary.maxTps = pRates.maxTps; for (const [model, modelStore] of Object.entries(providerStore.models || {})) { const mRates = rateManager.getStats(`model:${model}`); modelStore.qps = mRates.qps; modelStore.tps = mRates.tps; + modelStore.maxQps = mRates.maxQps; + modelStore.maxTps = mRates.maxTps; } } diff --git a/src/utils/rate-tracker.js b/src/utils/rate-tracker.js index 8322d0526..e23fff8af 100644 --- a/src/utils/rate-tracker.js +++ b/src/utils/rate-tracker.js @@ -12,6 +12,16 @@ export class RateTracker { this.buckets = Array.from({ length: windowSeconds }, () => ({ count: 0, total: 0 })); this.lastUpdateTime = Math.floor(Date.now() / 1000); this.firstRecordTime = 0; // 记录第一次收到记录的时间,用于计算初期的准确速率 + this.maxQps = 0; // 峰值 QPS + this.maxTps = 0; // 峰值 TPS + } + + /** + * 重置峰值 + */ + resetPeaks() { + this.maxQps = 0; + this.maxTps = 0; } /** @@ -56,7 +66,7 @@ export class RateTracker { // 如果从未有记录,直接返回 0 if (this.firstRecordTime === 0) { - return { qps: 0, tps: 0 }; + return { qps: 0, tps: 0, maxQps: 0, maxTps: 0 }; } this._advance(nowSeconds); @@ -72,9 +82,18 @@ export class RateTracker { const elapsed = Math.max(1, nowSeconds - this.firstRecordTime + 1); const divisor = Math.min(elapsed, this.windowSeconds); + const qps = Number((totalCount / divisor).toFixed(2)); + const tps = Number((totalTokens / divisor).toFixed(2)); + + // 更新峰值 + if (qps > this.maxQps) this.maxQps = qps; + if (tps > this.maxTps) this.maxTps = tps; + return { - qps: Number((totalCount / divisor).toFixed(2)), - tps: Number((totalTokens / divisor).toFixed(2)) + qps, + tps, + maxQps: this.maxQps, + maxTps: this.maxTps }; } } @@ -127,6 +146,16 @@ export class RateManager { this.globalTracker = new RateTracker(this.windowSeconds); } + /** + * 重置所有追踪器的峰值 + */ + resetPeaks() { + this.globalTracker.resetPeaks(); + for (const tracker of this.trackers.values()) { + tracker.resetPeaks(); + } + } + /** * 获取全局统计 */ @@ -139,7 +168,7 @@ export class RateManager { */ getStats(key) { if (!key || !this.trackers.has(key)) { - return { qps: 0, tps: 0 }; + return { qps: 0, tps: 0, maxQps: 0, maxTps: 0 }; } return this.trackers.get(key).getStats(); } diff --git a/static/model-usage-stats.html b/static/model-usage-stats.html index aa4d3927f..36dc4baf5 100644 --- a/static/model-usage-stats.html +++ b/static/model-usage-stats.html @@ -783,7 +783,7 @@

模型用量统计面板

总请求数
0
-
实时 QPS: 0.00
+
QPS: 0.00 (峰值: 0.00)
输入 Token
0
输入 token 的累计值
缓存 Token
0
缓存命中的累计值
@@ -791,7 +791,7 @@

模型用量统计面板

总 Token
0
-
实时 TPS: 0.00 | 等待数据
+
TPS: 0.00 (峰值: 0.00) | 等待数据

@@ -1006,7 +1006,9 @@

模型明 el('completionTokens').textContent = fmtToken(s.completionTokens); el('totalTokens').textContent = fmtToken(s.totalTokens); el('currentQps').textContent = (s.qps || 0).toFixed(2); + el('maxQps').textContent = (s.maxQps || 0).toFixed(2); el('currentTps').textContent = (s.tps || 0).toFixed(2); + el('maxTps').textContent = (s.maxTps || 0).toFixed(2); el('updatedAt').textContent = data.updatedAt ? `更新于 ${new Date(data.updatedAt).toLocaleString('zh-CN')}` : '尚未写入' } diff --git a/static/potluck-user.html b/static/potluck-user.html index bd239d2a7..fbcea37fe 100644 --- a/static/potluck-user.html +++ b/static/potluck-user.html @@ -420,7 +420,7 @@

个人使用统计0 / 0 -
当前 QPS: 0.00
+
QPS: 0.00 (峰值: 0.00)
剩余额度
@@ -435,7 +435,7 @@

个人使用统计
今日 Tokens
0
-
当前 TPS: 0.00
+
TPS: 0.00 (峰值: 0.00)

今日缓存 Tokens
@@ -650,7 +650,9 @@

API 密钥

// 速率统计 document.getElementById('statQps').textContent = (data.usage?.qps || 0).toFixed(2); + document.getElementById('statMaxQps').textContent = (data.usage?.maxQps || 0).toFixed(2); document.getElementById('statTps').textContent = (data.usage?.tps || 0).toFixed(2); + document.getElementById('statMaxTps').textContent = (data.usage?.maxTps || 0).toFixed(2); // 最后使用时间 if (data.lastUsedAt) { diff --git a/static/potluck.html b/static/potluck.html index 1e3007ef5..21adc90be 100644 --- a/static/potluck.html +++ b/static/potluck.html @@ -628,7 +628,7 @@
今日总调用
0
-
实时 QPS: 0.00
+
QPS: 0.00 (峰值: 0.00)
累计调用
@@ -637,7 +637,7 @@
今日总 Tokens
0
-
实时 TPS: 0.00
+
TPS: 0.00 (峰值: 0.00)
今日缓存 Tokens
@@ -890,7 +890,9 @@

批量应用每日限额

document.getElementById('totalTokens').textContent = formatTokenCompact(stats.totalTokens); document.getElementById('totalCachedTokens').textContent = formatTokenCompact(stats.totalCachedTokens || 0); document.getElementById('currentQps').textContent = (stats.qps || 0).toFixed(2); + document.getElementById('maxQps').textContent = (stats.maxQps || 0).toFixed(2); document.getElementById('currentTps').textContent = (stats.tps || 0).toFixed(2); + document.getElementById('maxTps').textContent = (stats.maxTps || 0).toFixed(2); // 渲染使用历史分布 renderUsageHistory(stats.usageHistory); From db0272fb830afd5afbe64cb66f465368d2199255 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Sat, 18 Apr 2026 17:38:48 +0800 Subject: [PATCH 022/135] =?UTF-8?q?feat(claude-kiro):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=AF=B9=20claude-opus-4-7=20=E6=A8=A1=E5=9E=8B=E7=9A=84?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 provider-models.js 中为 claude-kiro-oauth 提供者添加新模型 - 在模型映射表中添加 claude-opus-4-7 的映射 - 修复模型选择逻辑,避免不必要的映射回退 - 更新项目版本号至 2.14.7.3 --- VERSION | 2 +- src/providers/claude/claude-kiro.js | 7 ++++--- src/providers/provider-models.js | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/VERSION b/VERSION index 425b63821..8fd46d4fd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.14.7.2 +2.14.7.3 diff --git a/src/providers/claude/claude-kiro.js b/src/providers/claude/claude-kiro.js index 854769016..fed82efe5 100644 --- a/src/providers/claude/claude-kiro.js +++ b/src/providers/claude/claude-kiro.js @@ -98,6 +98,7 @@ const KIRO_MODELS = getProviderModels(MODEL_PROVIDER.KIRO_API); // 完整的模型映射表 const FULL_MODEL_MAPPING = { "claude-haiku-4-5":"claude-haiku-4.5", + "claude-opus-4-7":"claude-opus-4.7", "claude-opus-4-6":"claude-opus-4.6", "claude-sonnet-4-6":"claude-sonnet-4.6", "claude-opus-4-5":"claude-opus-4.5", @@ -999,7 +1000,7 @@ async saveCredentialsToFile(filePath, newData) { processedMessages.length = 0; processedMessages.push(...mergedMessages); - const codewhispererModel = MODEL_MAPPING[model] || MODEL_MAPPING[this.modelName]; + const codewhispererModel = MODEL_MAPPING[model] || model; // 动态压缩 tools(保留全部工具,但过滤掉 web_search/websearch) let toolsContext = {}; @@ -1912,7 +1913,7 @@ async saveCredentialsToFile(filePath, newData) { this._markCredentialNeedRefresh('Token near expiry in generateContent'); } - const finalModel = MODEL_MAPPING[model] ? model : this.modelName; + const finalModel = MODEL_MAPPING[model] ? model : model; logger.info(`[Kiro] Calling generateContent with model: ${finalModel}`); // Estimate input tokens before making the API call @@ -2272,7 +2273,7 @@ async saveCredentialsToFile(filePath, newData) { this._markCredentialNeedRefresh('Token near expiry in generateContentStream'); } - const finalModel = MODEL_MAPPING[model] ? model : this.modelName; + const finalModel = MODEL_MAPPING[model] ? model : model; logger.info(`[Kiro] Calling generateContentStream with model: ${finalModel} (real streaming)`); let inputTokens = 0; diff --git a/src/providers/provider-models.js b/src/providers/provider-models.js index 304079c8e..8667f5add 100644 --- a/src/providers/provider-models.js +++ b/src/providers/provider-models.js @@ -66,6 +66,7 @@ export const PROVIDER_MODELS = { 'claude-custom': [], 'claude-kiro-oauth': [ 'claude-haiku-4-5', + 'claude-opus-4-7', 'claude-opus-4-6', 'claude-sonnet-4-6', 'claude-opus-4-5', From 63bf55c05cb6d14b809ee11ed702276c843deda4 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Sat, 18 Apr 2026 23:59:27 +0800 Subject: [PATCH 023/135] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20RPM=20?= =?UTF-8?q?=E9=80=9F=E7=8E=87=E7=BB=9F=E8=AE=A1=E5=B9=B6=E5=9C=A8=E5=A4=9A?= =?UTF-8?q?=E4=B8=AA=E7=95=8C=E9=9D=A2=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在速率跟踪器中新增 RPM(每分钟请求数)统计,并在 API 响应、管理后台、用户面板和模型使用统计页面中展示当前 RPM 及其峰值。同时更新版本号至 2.14.8。 --- VERSION | 2 +- src/plugins/api-potluck/api-routes.js | 4 +++- src/plugins/api-potluck/key-manager.js | 8 +++++++- src/plugins/model-usage-stats/stats-manager.js | 6 ++++++ src/utils/rate-tracker.js | 12 +++++++++--- static/model-usage-stats.html | 6 ++++-- static/potluck-user.html | 4 +++- static/potluck.html | 4 +++- 8 files changed, 36 insertions(+), 10 deletions(-) diff --git a/VERSION b/VERSION index 8fd46d4fd..dd644fa22 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.14.7.3 +2.14.8 diff --git a/src/plugins/api-potluck/api-routes.js b/src/plugins/api-potluck/api-routes.js index a6af53536..41b97a02a 100644 --- a/src/plugins/api-potluck/api-routes.js +++ b/src/plugins/api-potluck/api-routes.js @@ -456,8 +456,10 @@ export async function handlePotluckUserApiRoutes(method, path, req, res) { cachedTokens: keyData.todayCachedTokens || 0, qps: keyData.qps || 0, tps: keyData.tps || 0, + rpm: keyData.rpm || 0, maxQps: keyData.maxQps || 0, - maxTps: keyData.maxTps || 0 + maxTps: keyData.maxTps || 0, + maxRpm: keyData.maxRpm || 0 }, total: keyData.totalUsage, tokens: { diff --git a/src/plugins/api-potluck/key-manager.js b/src/plugins/api-potluck/key-manager.js index 0e6d84eb4..7625a167e 100644 --- a/src/plugins/api-potluck/key-manager.js +++ b/src/plugins/api-potluck/key-manager.js @@ -346,6 +346,8 @@ export async function listKeys() { ...updated, qps: rates.qps, tps: rates.tps, + rpm: rates.rpm, + maxRpm: rates.maxRpm, maskedKey: `${keyId.substring(0, 12)}...${keyId.substring(keyId.length - 4)}` }); } @@ -364,7 +366,9 @@ export async function getKey(keyId) { return { ...updated, qps: rates.qps, - tps: rates.tps + tps: rates.tps, + rpm: rates.rpm, + maxRpm: rates.maxRpm }; } @@ -656,8 +660,10 @@ export async function getStats() { totalCachedTokens, qps: globalRates.qps, tps: globalRates.tps, + rpm: globalRates.rpm, maxQps: globalRates.maxQps, maxTps: globalRates.maxTps, + maxRpm: globalRates.maxRpm, usageHistory: aggregatedHistory }; } diff --git a/src/plugins/model-usage-stats/stats-manager.js b/src/plugins/model-usage-stats/stats-manager.js index e7722fb52..be5b8fca4 100644 --- a/src/plugins/model-usage-stats/stats-manager.js +++ b/src/plugins/model-usage-stats/stats-manager.js @@ -457,22 +457,28 @@ export async function getStats() { const globalRates = rateManager.getGlobalStats(); stats.summary.qps = globalRates.qps; stats.summary.tps = globalRates.tps; + stats.summary.rpm = globalRates.rpm; stats.summary.maxQps = globalRates.maxQps; stats.summary.maxTps = globalRates.maxTps; + stats.summary.maxRpm = globalRates.maxRpm; for (const [provider, providerStore] of Object.entries(stats.providers || {})) { const pRates = rateManager.getStats(`provider:${provider}`); providerStore.summary.qps = pRates.qps; providerStore.summary.tps = pRates.tps; + providerStore.summary.rpm = pRates.rpm; providerStore.summary.maxQps = pRates.maxQps; providerStore.summary.maxTps = pRates.maxTps; + providerStore.summary.maxRpm = pRates.maxRpm; for (const [model, modelStore] of Object.entries(providerStore.models || {})) { const mRates = rateManager.getStats(`model:${model}`); modelStore.qps = mRates.qps; modelStore.tps = mRates.tps; + modelStore.rpm = mRates.rpm; modelStore.maxQps = mRates.maxQps; modelStore.maxTps = mRates.maxTps; + modelStore.maxRpm = mRates.maxRpm; } } diff --git a/src/utils/rate-tracker.js b/src/utils/rate-tracker.js index e23fff8af..3679abada 100644 --- a/src/utils/rate-tracker.js +++ b/src/utils/rate-tracker.js @@ -14,6 +14,7 @@ export class RateTracker { this.firstRecordTime = 0; // 记录第一次收到记录的时间,用于计算初期的准确速率 this.maxQps = 0; // 峰值 QPS this.maxTps = 0; // 峰值 TPS + this.maxRpm = 0; // 峰值 RPM } /** @@ -22,6 +23,7 @@ export class RateTracker { resetPeaks() { this.maxQps = 0; this.maxTps = 0; + this.maxRpm = 0; } /** @@ -66,7 +68,7 @@ export class RateTracker { // 如果从未有记录,直接返回 0 if (this.firstRecordTime === 0) { - return { qps: 0, tps: 0, maxQps: 0, maxTps: 0 }; + return { qps: 0, tps: 0, rpm: 0, maxQps: 0, maxTps: 0, maxRpm: 0 }; } this._advance(nowSeconds); @@ -84,16 +86,20 @@ export class RateTracker { const qps = Number((totalCount / divisor).toFixed(2)); const tps = Number((totalTokens / divisor).toFixed(2)); + const rpm = Number((totalCount * (60 / divisor)).toFixed(2)); // 更新峰值 if (qps > this.maxQps) this.maxQps = qps; if (tps > this.maxTps) this.maxTps = tps; + if (rpm > this.maxRpm) this.maxRpm = rpm; return { qps, tps, + rpm, maxQps: this.maxQps, - maxTps: this.maxTps + maxTps: this.maxTps, + maxRpm: this.maxRpm }; } } @@ -168,7 +174,7 @@ export class RateManager { */ getStats(key) { if (!key || !this.trackers.has(key)) { - return { qps: 0, tps: 0, maxQps: 0, maxTps: 0 }; + return { qps: 0, tps: 0, rpm: 0, maxQps: 0, maxTps: 0, maxRpm: 0 }; } return this.trackers.get(key).getStats(); } diff --git a/static/model-usage-stats.html b/static/model-usage-stats.html index 36dc4baf5..d2ceef994 100644 --- a/static/model-usage-stats.html +++ b/static/model-usage-stats.html @@ -783,7 +783,7 @@

模型用量统计面板

总请求数
0
-
QPS: 0.00 (峰值: 0.00)
+
QPS: 0.00 (峰值: 0.00) | RPM: 0.00 (峰值: 0.00)
输入 Token
0
输入 token 的累计值
缓存 Token
0
缓存命中的累计值
@@ -1007,6 +1007,8 @@

模型明 el('totalTokens').textContent = fmtToken(s.totalTokens); el('currentQps').textContent = (s.qps || 0).toFixed(2); el('maxQps').textContent = (s.maxQps || 0).toFixed(2); + el('currentRpm').textContent = (s.rpm || 0).toFixed(2); + el('maxRpm').textContent = (s.maxRpm || 0).toFixed(2); el('currentTps').textContent = (s.tps || 0).toFixed(2); el('maxTps').textContent = (s.maxTps || 0).toFixed(2); el('updatedAt').textContent = data.updatedAt ? `更新于 ${new Date(data.updatedAt).toLocaleString('zh-CN')}` : '尚未写入' @@ -1028,7 +1030,7 @@

模型明 const s = p.data.summary || {}; const node = document.createElement('article'); node.className = 'provider'; - node.innerHTML = `
${p.name}
包含 ${fmt(p.count)} 个模型
${fmt(s.requestCount)} 次调用
总 Token
${fmtToken(s.totalTokens)}
输入
${fmtToken(s.promptTokens)}
输出
${fmtToken(s.completionTokens)}
QPS / TPS
${(s.qps || 0).toFixed(2)} / ${(s.tps || 0).toFixed(2)}
最近使用
${rel(s.lastUsedAt)}
`; + node.innerHTML = `
${p.name}
包含 ${fmt(p.count)} 个模型
${fmt(s.requestCount)} 次调用
总 Token
${fmtToken(s.totalTokens)}
输入
${fmtToken(s.promptTokens)}
输出
${fmtToken(s.completionTokens)}
QPS / RPM
${(s.qps || 0).toFixed(2)} / ${(s.rpm || 0).toFixed(2)}
TPS
${(s.tps || 0).toFixed(2)}
最近使用
${rel(s.lastUsedAt)}
`; box.appendChild(node) }) } diff --git a/static/potluck-user.html b/static/potluck-user.html index fbcea37fe..c8aa0ccf1 100644 --- a/static/potluck-user.html +++ b/static/potluck-user.html @@ -420,7 +420,7 @@

个人使用统计0 / 0

-
QPS: 0.00 (峰值: 0.00)
+
QPS: 0.00 (峰值: 0.00) | RPM: 0.00 (峰值: 0.00)
剩余额度
@@ -651,6 +651,8 @@

API 密钥

// 速率统计 document.getElementById('statQps').textContent = (data.usage?.qps || 0).toFixed(2); document.getElementById('statMaxQps').textContent = (data.usage?.maxQps || 0).toFixed(2); + document.getElementById('statRpm').textContent = (data.usage?.rpm || 0).toFixed(2); + document.getElementById('statMaxRpm').textContent = (data.usage?.maxRpm || 0).toFixed(2); document.getElementById('statTps').textContent = (data.usage?.tps || 0).toFixed(2); document.getElementById('statMaxTps').textContent = (data.usage?.maxTps || 0).toFixed(2); diff --git a/static/potluck.html b/static/potluck.html index 21adc90be..331ee8513 100644 --- a/static/potluck.html +++ b/static/potluck.html @@ -628,7 +628,7 @@
今日总调用
0
-
QPS: 0.00 (峰值: 0.00)
+
QPS: 0.00 (峰值: 0.00) | RPM: 0.00 (峰值: 0.00)
累计调用
@@ -891,6 +891,8 @@

批量应用每日限额

document.getElementById('totalCachedTokens').textContent = formatTokenCompact(stats.totalCachedTokens || 0); document.getElementById('currentQps').textContent = (stats.qps || 0).toFixed(2); document.getElementById('maxQps').textContent = (stats.maxQps || 0).toFixed(2); + document.getElementById('currentRpm').textContent = (stats.rpm || 0).toFixed(2); + document.getElementById('maxRpm').textContent = (stats.maxRpm || 0).toFixed(2); document.getElementById('currentTps').textContent = (stats.tps || 0).toFixed(2); document.getElementById('maxTps').textContent = (stats.maxTps || 0).toFixed(2); From ed146f73db7bb9f7fc83e3309a1d956fdb1bf2c5 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Sun, 19 Apr 2026 11:57:37 +0800 Subject: [PATCH 024/135] =?UTF-8?q?fix(utils):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E6=A8=A1=E5=9E=8B=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E4=B8=8E=E8=AF=B7=E6=B1=82=E4=BD=93=E8=BD=AC?= =?UTF-8?q?=E6=8D=A2=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 appendCustomModelsToModelList 函数中处理 OPENAI_MODEL_LIST 类型时未正确合并自定义模型的问题,现在能正确更新现有模型元数据或添加新模型。 修复 handleContentGenerationRequest 函数中请求体转换时丢失内部属性(如 _monitorRequestId)的问题,转换后现在会保留所有以下划线开头的内部字段。 --- VERSION | 2 +- src/utils/common.js | 51 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/VERSION b/VERSION index dd644fa22..0d5b42c2b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.14.8 +2.14.8.1 diff --git a/src/utils/common.js b/src/utils/common.js index 9d16581d3..0aeb06c62 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -189,6 +189,43 @@ function appendCustomModelsToModelList(clientModelList, customEntries, providerT }; } + if (listEndpointType === ENDPOINT_TYPE.OPENAI_MODEL_LIST) { + const models = Array.isArray(clientModelList?.data) ? clientModelList.data : []; + + entries.forEach(entry => { + const existingModel = models.find(model => model?.id === entry.id); + if (existingModel) { + // 更新现有模型的元数据 + if (entry.config.name) existingModel.display_name = entry.config.name; + if (entry.config.description) existingModel.description = entry.config.description; + if (hasMetadataValue(entry.config.contextLength)) existingModel.context_length = entry.config.contextLength; + if (hasMetadataValue(entry.config.maxTokens)) existingModel.max_tokens = entry.config.maxTokens; + return; + } + + // 添加新模型 + const modelResponse = { + id: entry.id, + object: 'model', + created: Math.floor(Date.now() / 1000), + owned_by: entry.provider || providerType || 'custom', + display_name: entry.config.name || entry.id + }; + + if (entry.config.description) modelResponse.description = entry.config.description; + if (hasMetadataValue(entry.config.contextLength)) modelResponse.context_length = entry.config.contextLength; + if (hasMetadataValue(entry.config.maxTokens)) modelResponse.max_tokens = entry.config.maxTokens; + + models.push(modelResponse); + }); + + return { + ...clientModelList, + object: 'list', + data: models + }; + } + return clientModelList; } @@ -1154,7 +1191,9 @@ export async function handleContentGenerationRequest(req, res, service, endpoint } // 1. Convert request body from client format to backend format, if necessary. - let processedRequestBody = originalRequestBody; + // 使用浅拷贝以避免直接变异 originalRequestBody,保持原始数据的纯净性以供后续钩子使用 + let processedRequestBody = { ...originalRequestBody }; + // 将 _monitorRequestId 注入到 requestBody 中,以便在 service 内部访问 if (CONFIG._monitorRequestId) { processedRequestBody._monitorRequestId = CONFIG._monitorRequestId; @@ -1168,7 +1207,15 @@ export async function handleContentGenerationRequest(req, res, service, endpoint // fs.writeFile('originalRequestBody'+Date.now()+'.json', JSON.stringify(originalRequestBody)); if (getProtocolPrefix(fromProvider) !== getProtocolPrefix(toProvider)) { logger.info(`[Request Convert] Converting request from ${fromProvider} to ${toProvider}`); - processedRequestBody = convertData(originalRequestBody, 'request', fromProvider, toProvider); + const preConvertBody = processedRequestBody; + processedRequestBody = convertData(preConvertBody, 'request', fromProvider, toProvider); + + // 保持以 _ 开头的内部属性(如 _monitorRequestId, _requestBaseUrl) + Object.keys(preConvertBody).forEach(key => { + if (key.startsWith('_') && processedRequestBody[key] === undefined) { + processedRequestBody[key] = preConvertBody[key]; + } + }); } else { logger.info(`[Request Convert] Request format matches backend provider. No conversion needed.`); } From f0f4649519ba1e02ccb684a6bcbdf47c9612b40c Mon Sep 17 00:00:00 2001 From: hex2077 Date: Sun, 19 Apr 2026 12:22:25 +0800 Subject: [PATCH 025/135] =?UTF-8?q?feat(monitoring):=20=E5=9C=A8=E7=9B=91?= =?UTF-8?q?=E6=8E=A7=E9=A1=B5=E9=9D=A2=E6=B7=BB=E5=8A=A0QPS/RPM/TPS?= =?UTF-8?q?=E5=B3=B0=E5=80=BC=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在日历工具提示中显示每日峰值速率(QPS/RPM/TPS) - 在API密钥管理页面添加实时速率和峰值统计卡片 - 扩展数据模型以持久化峰值速率数据 - 修改工具提示样式支持多行显示 - 更新版本号至2.14.8.2 --- VERSION | 2 +- src/plugins/api-potluck/key-manager.js | 46 ++++++++++++++++-- .../model-usage-stats/stats-manager.js | 47 ++++++++++++++----- static/model-usage-stats.html | 6 +-- static/potluck-user.html | 11 +++-- static/potluck.html | 21 +++++++-- 6 files changed, 106 insertions(+), 27 deletions(-) diff --git a/VERSION b/VERSION index 0d5b42c2b..dcad56ffa 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.14.8.1 +2.14.8.2 diff --git a/src/plugins/api-potluck/key-manager.js b/src/plugins/api-potluck/key-manager.js index 7625a167e..b37996b1b 100644 --- a/src/plugins/api-potluck/key-manager.js +++ b/src/plugins/api-potluck/key-manager.js @@ -56,7 +56,10 @@ function createUsageBucket() { promptTokens: 0, completionTokens: 0, totalTokens: 0, - cachedTokens: 0 + cachedTokens: 0, + maxQps: 0, + maxRpm: 0, + maxTps: 0 }; } @@ -80,7 +83,10 @@ function normalizeUsageBucket(bucket) { promptTokens: toNumber(bucket?.promptTokens), completionTokens: toNumber(bucket?.completionTokens), totalTokens: toNumber(bucket?.totalTokens), - cachedTokens: toNumber(bucket?.cachedTokens) + cachedTokens: toNumber(bucket?.cachedTokens), + maxQps: toNumber(bucket?.maxQps), + maxRpm: toNumber(bucket?.maxRpm), + maxTps: toNumber(bucket?.maxTps) }; } @@ -141,6 +147,15 @@ function addUsage(target, usage = {}) { target.completionTokens += toNumber(usage.completionTokens); target.totalTokens += toNumber(usage.totalTokens); target.cachedTokens += toNumber(usage.cachedTokens); + + // 聚合峰值:使用累加还是最大值取决于上下文。 + // 在汇总多个 Key 的历史数据时,累加可能更能代表系统总峰值(假设可能同时发生) + // 但更准确的是记录全局 RateTracker 的峰值。 + // 这里简单处理:如果 usage 中有峰值,则取最大值或累加。 + // 鉴于这是日历展示,我们取最大值以展示该日达到的最高单项或汇总峰值。 + target.maxQps = Math.max(target.maxQps || 0, toNumber(usage.maxQps)); + target.maxRpm = Math.max(target.maxRpm || 0, toNumber(usage.maxRpm)); + target.maxTps = Math.max(target.maxTps || 0, toNumber(usage.maxTps)); } function resetUsageBucketTokens(bucket) { @@ -149,6 +164,9 @@ function resetUsageBucketTokens(bucket) { bucket.completionTokens = 0; bucket.totalTokens = 0; bucket.cachedTokens = 0; + bucket.maxQps = 0; + bucket.maxRpm = 0; + bucket.maxTps = 0; } function resetUsageHistoryTokens(usageHistory) { @@ -347,7 +365,9 @@ export async function listKeys() { qps: rates.qps, tps: rates.tps, rpm: rates.rpm, - maxRpm: rates.maxRpm, + maxQps: Math.max(updated.maxQps || 0, rates.maxQps), + maxTps: Math.max(updated.maxTps || 0, rates.maxTps), + maxRpm: Math.max(updated.maxRpm || 0, rates.maxRpm), maskedKey: `${keyId.substring(0, 12)}...${keyId.substring(keyId.length - 4)}` }); } @@ -368,7 +388,9 @@ export async function getKey(keyId) { qps: rates.qps, tps: rates.tps, rpm: rates.rpm, - maxRpm: rates.maxRpm + maxQps: Math.max(updated.maxQps || 0, rates.maxQps), + maxTps: Math.max(updated.maxTps || 0, rates.maxTps), + maxRpm: Math.max(updated.maxRpm || 0, rates.maxRpm) }; } @@ -547,6 +569,13 @@ export async function incrementUsage(apiKey, pName = 'unknown', mName = 'unknown rateManager.record(`key:${apiKey}`, usage.totalTokens); } + const rates = rateManager.getGlobalStats(); + const updatePeaks = (target) => { + target.maxQps = Math.max(target.maxQps || 0, rates.qps); + target.maxRpm = Math.max(target.maxRpm || 0, rates.rpm); + target.maxTps = Math.max(target.maxTps || 0, rates.tps); + }; + // 更新每日和历史统计 const today = getTodayDateString(); if (!keyData.usageHistory) keyData.usageHistory = {}; @@ -556,12 +585,15 @@ export async function incrementUsage(apiKey, pName = 'unknown', mName = 'unknown const dayHistory = keyData.usageHistory[today]; addUsage(dayHistory.summary, usage); + updatePeaks(dayHistory.summary); if (!dayHistory.providers[pName]) dayHistory.providers[pName] = createUsageBucket(); addUsage(dayHistory.providers[pName], usage); + updatePeaks(dayHistory.providers[pName]); if (!dayHistory.models[mName]) dayHistory.models[mName] = createUsageBucket(); addUsage(dayHistory.models[mName], usage); + updatePeaks(dayHistory.models[mName]); // 更新今日和累计总量 (统一处理默认调用次数) const rCount = usage.requestCount !== undefined ? toNumber(usage.requestCount) : 1; @@ -576,6 +608,12 @@ export async function incrementUsage(apiKey, pName = 'unknown', mName = 'unknown keyData.totalTokens += toNumber(usage.totalTokens); keyData.totalCachedTokens += toNumber(usage.cachedTokens); + // 同时也给 keyData 注入实时峰值(如果需要持久化) + if (!keyData.maxQps) keyData.maxQps = 0; + if (!keyData.maxRpm) keyData.maxRpm = 0; + if (!keyData.maxTps) keyData.maxTps = 0; + updatePeaks(keyData); + // 清理该 Key 的过期历史 (保留 100 天以支持 3 个月日历) const userDates = Object.keys(keyData.usageHistory).sort(); if (userDates.length > 100) { diff --git a/src/plugins/model-usage-stats/stats-manager.js b/src/plugins/model-usage-stats/stats-manager.js index be5b8fca4..f9615ee98 100644 --- a/src/plugins/model-usage-stats/stats-manager.js +++ b/src/plugins/model-usage-stats/stats-manager.js @@ -36,6 +36,9 @@ function createEmptyUsage() { completionTokens: 0, totalTokens: 0, cachedTokens: 0, + maxQps: 0, + maxRpm: 0, + maxTps: 0, lastUsedAt: null }; } @@ -58,6 +61,9 @@ function normalizeUsageBlock(block) { completionTokens: toNumber(block.completionTokens), totalTokens: toNumber(block.totalTokens), cachedTokens: toNumber(block.cachedTokens), + maxQps: toNumber(block.maxQps), + maxRpm: toNumber(block.maxRpm), + maxTps: toNumber(block.maxTps), lastUsedAt: block.lastUsedAt || null }; } @@ -365,6 +371,9 @@ function resetUsageBlockTokens(block) { block.completionTokens = 0; block.totalTokens = 0; block.cachedTokens = 0; + block.maxQps = 0; + block.maxRpm = 0; + block.maxTps = 0; } export function setConfigGetter(getter) { @@ -436,14 +445,29 @@ export async function finalizeRequest({ requestId, model, provider, fromProvider rateManager.record(`provider:${normalizedProvider}`, usage.totalTokens); rateManager.record(`model:${normalizedModel}`, usage.totalTokens); + const globalRates = rateManager.getGlobalStats(); + + // 更新持久化峰值 + const updatePeaks = (target) => { + target.maxQps = Math.max(target.maxQps || 0, globalRates.qps); + target.maxRpm = Math.max(target.maxRpm || 0, globalRates.rpm); + target.maxTps = Math.max(target.maxTps || 0, globalRates.tps); + }; + + updatePeaks(statsStore.summary); + updatePeaks(ensureProviderStore(normalizedProvider).summary); + updatePeaks(ensureModelStore(normalizedProvider, normalizedModel)); + // 记录每日统计 const dateKey = timestamp.split('T')[0]; if (!statsStore.daily[dateKey]) { statsStore.daily[dateKey] = createEmptyUsage(); } - applyUsage(statsStore.daily[dateKey], usage, timestamp); + const dailyBlock = statsStore.daily[dateKey]; + applyUsage(dailyBlock, usage, timestamp); + updatePeaks(dailyBlock); - logger.info(`${getTracePrefix(requestId)} >>> Request Finalized: Provider: ${normalizedProvider} | Model: ${normalizedModel} | Prompt: ${usage.promptTokens} | Completion: ${usage.completionTokens} | Total: ${usage.totalTokens} | Cached: ${usage.cachedTokens} | Stream: ${Boolean(state.isStream)}`); + logger.info(`${getTracePrefix(requestId)} >>> Request Finalized: Provider: ${normalizedProvider} | Model: ${normalizedModel} | Prompt: ${usage.promptTokens} | Completion: ${usage.completionTokens} | Total: ${usage.totalTokens} | Cached: ${usage.cachedTokens} | Stream: ${Boolean(state.isStream)} | QPS: ${globalRates.qps}`); markDirty(); await persistIfDirty(); return true; @@ -458,27 +482,28 @@ export async function getStats() { stats.summary.qps = globalRates.qps; stats.summary.tps = globalRates.tps; stats.summary.rpm = globalRates.rpm; - stats.summary.maxQps = globalRates.maxQps; - stats.summary.maxTps = globalRates.maxTps; - stats.summary.maxRpm = globalRates.maxRpm; + // 峰值取持久化值和当前内存值中的较大者 + stats.summary.maxQps = Math.max(stats.summary.maxQps || 0, globalRates.maxQps); + stats.summary.maxTps = Math.max(stats.summary.maxTps || 0, globalRates.maxTps); + stats.summary.maxRpm = Math.max(stats.summary.maxRpm || 0, globalRates.maxRpm); for (const [provider, providerStore] of Object.entries(stats.providers || {})) { const pRates = rateManager.getStats(`provider:${provider}`); providerStore.summary.qps = pRates.qps; providerStore.summary.tps = pRates.tps; providerStore.summary.rpm = pRates.rpm; - providerStore.summary.maxQps = pRates.maxQps; - providerStore.summary.maxTps = pRates.maxTps; - providerStore.summary.maxRpm = pRates.maxRpm; + providerStore.summary.maxQps = Math.max(providerStore.summary.maxQps || 0, pRates.maxQps); + providerStore.summary.maxTps = Math.max(providerStore.summary.maxTps || 0, pRates.maxTps); + providerStore.summary.maxRpm = Math.max(providerStore.summary.maxRpm || 0, pRates.maxRpm); for (const [model, modelStore] of Object.entries(providerStore.models || {})) { const mRates = rateManager.getStats(`model:${model}`); modelStore.qps = mRates.qps; modelStore.tps = mRates.tps; modelStore.rpm = mRates.rpm; - modelStore.maxQps = mRates.maxQps; - modelStore.maxTps = mRates.maxTps; - modelStore.maxRpm = mRates.maxRpm; + modelStore.maxQps = Math.max(modelStore.maxQps || 0, mRates.maxQps); + modelStore.maxTps = Math.max(modelStore.maxTps || 0, mRates.maxTps); + modelStore.maxRpm = Math.max(modelStore.maxRpm || 0, mRates.maxRpm); } } diff --git a/static/model-usage-stats.html b/static/model-usage-stats.html index d2ceef994..223493fda 100644 --- a/static/model-usage-stats.html +++ b/static/model-usage-stats.html @@ -273,7 +273,7 @@ z-index: 1000; display: none; box-shadow: 0 3px 10px rgba(0, 0, 0, 0.3); - white-space: nowrap; + white-space: pre-line; line-height: 1.4; } @@ -1108,9 +1108,9 @@

模型明 day.className = 'calendar-day'; day.dataset.level = level; - const tipText = `${dateKey}: ${fmt(requests)} 次请求, ${fmtToken(tokens)} Token`; + const tipText = `${dateKey}: ${fmt(requests)} 次请求, ${fmtToken(tokens)} Token${data.maxQps ? `\n峰值: ${data.maxQps.toFixed(2)} QPS / ${data.maxRpm.toFixed(0)} RPM / ${data.maxTps.toFixed(2)} TPS` : ''}`; day.onmouseenter = (e) => { - tooltip.textContent = tipText; + tooltip.innerText = tipText; tooltip.style.display = 'block'; const rect = day.getBoundingClientRect(); tooltip.style.left = `${rect.left + rect.width / 2 - tooltip.offsetWidth / 2}px`; diff --git a/static/potluck-user.html b/static/potluck-user.html index c8aa0ccf1..73404ec54 100644 --- a/static/potluck-user.html +++ b/static/potluck-user.html @@ -352,7 +352,7 @@ [data-theme="dark"] .level-2, [data-theme="dark"] .calendar-day[data-level="2"] { background: #006d32; } [data-theme="dark"] .level-3, [data-theme="dark"] .calendar-day[data-level="3"] { background: #26a641; } [data-theme="dark"] .level-4, [data-theme="dark"] .calendar-day[data-level="4"] { background: #39d353; } - .calendar-tooltip { position: fixed; padding: 6px 10px; background: rgba(0,0,0,0.9); color: #fff; font-size: 11px; border-radius: 4px; pointer-events: none; z-index: 1000; display: none; box-shadow: 0 3px 10px rgba(0,0,0,0.3); white-space: nowrap; line-height: 1.4; } + .calendar-tooltip { position: fixed; padding: 6px 10px; background: rgba(0,0,0,0.9); color: #fff; font-size: 11px; border-radius: 4px; pointer-events: none; z-index: 1000; display: none; box-shadow: 0 3px 10px rgba(0,0,0,0.3); white-space: pre-line; line-height: 1.4; } @@ -686,7 +686,10 @@

API 密钥

Object.entries(usageHistory).forEach(([date, data]) => { dailyData[date] = { totalTokens: data.summary?.totalTokens || 0, - requestCount: data.summary?.requestCount || 0 + requestCount: data.summary?.requestCount || 0, + maxQps: data.summary?.maxQps || 0, + maxRpm: data.summary?.maxRpm || 0, + maxTps: data.summary?.maxTps || 0 }; }); @@ -719,9 +722,9 @@

API 密钥

day.className = 'calendar-day'; day.dataset.level = level; - const tipText = `${dateKey}: ${formatNumber(requests)} 次请求, ${formatTokenCompact(tokens)} Tokens`; + const tipText = `${dateKey}: ${formatNumber(requests)} 次请求, ${formatTokenCompact(tokens)} Tokens${entry.maxQps ? `\n峰值: ${entry.maxQps.toFixed(2)} QPS / ${entry.maxRpm.toFixed(0)} RPM / ${entry.maxTps.toFixed(2)} TPS` : ''}`; day.onmouseenter = (e) => { - tooltip.textContent = tipText; + tooltip.innerText = tipText; tooltip.style.display = 'block'; const rect = day.getBoundingClientRect(); tooltip.style.left = `${rect.left + rect.width / 2 - tooltip.offsetWidth / 2}px`; diff --git a/static/potluck.html b/static/potluck.html index 331ee8513..fd66cfdf8 100644 --- a/static/potluck.html +++ b/static/potluck.html @@ -589,7 +589,7 @@ [data-theme="dark"] .level-2, [data-theme="dark"] .calendar-day[data-level="2"] { background: #006d32; } [data-theme="dark"] .level-3, [data-theme="dark"] .calendar-day[data-level="3"] { background: #26a641; } [data-theme="dark"] .level-4, [data-theme="dark"] .calendar-day[data-level="4"] { background: #39d353; } - .calendar-tooltip { position: fixed; padding: 6px 10px; background: rgba(0,0,0,0.9); color: #fff; font-size: 11px; border-radius: 4px; pointer-events: none; z-index: 1000; display: none; box-shadow: 0 3px 10px rgba(0,0,0,0.3); white-space: nowrap; line-height: 1.4; } + .calendar-tooltip { position: fixed; padding: 6px 10px; background: rgba(0,0,0,0.9); color: #fff; font-size: 11px; border-radius: 4px; pointer-events: none; z-index: 1000; display: none; box-shadow: 0 3px 10px rgba(0,0,0,0.3); white-space: pre-line; line-height: 1.4; } @@ -918,7 +918,10 @@

批量应用每日限额

Object.entries(usageHistory).forEach(([date, data]) => { dailyData[date] = { totalTokens: data.summary?.totalTokens || 0, - requestCount: data.summary?.requestCount || 0 + requestCount: data.summary?.requestCount || 0, + maxQps: data.summary?.maxQps || 0, + maxRpm: data.summary?.maxRpm || 0, + maxTps: data.summary?.maxTps || 0 }; }); @@ -951,9 +954,9 @@

批量应用每日限额

day.className = 'calendar-day'; day.dataset.level = level; - const tipText = `${dateKey}: ${formatNumber(requests)} 次请求, ${formatTokenCompact(tokens)} Tokens`; + const tipText = `${dateKey}: ${formatNumber(requests)} 次请求, ${formatTokenCompact(tokens)} Tokens${entry.maxQps ? `\n峰值: ${entry.maxQps.toFixed(2)} QPS / ${entry.maxRpm.toFixed(0)} RPM / ${entry.maxTps.toFixed(2)} TPS` : ''}`; day.onmouseenter = (e) => { - tooltip.textContent = tipText; + tooltip.innerText = tipText; tooltip.style.display = 'block'; const rect = day.getBoundingClientRect(); tooltip.style.left = `${rect.left + rect.width / 2 - tooltip.offsetWidth / 2}px`; @@ -1168,6 +1171,16 @@

批量应用每日限额

${formatTokenCompact(key.todayTotalTokens || 0)} Tokens ${key.todayCachedTokens ? `(含 ${formatTokenCompact(key.todayCachedTokens)} 缓存)` : ''}
+
+
当前速率
+
${(key.qps || 0).toFixed(2)} QPS
+
峰值: ${(key.maxQps || 0).toFixed(2)} QPS
+
+
+
流量/速率
+
${(key.rpm || 0).toFixed(0)} RPM
+
${(key.tps || 0).toFixed(1)} TPS / 峰值: ${(key.maxTps || 0).toFixed(1)}
+
累计
${key.totalUsage}
From 4f77bbc97b97a9e38e10e4ba538c3bd52aba4e5d Mon Sep 17 00:00:00 2001 From: hex2077 Date: Sun, 19 Apr 2026 12:27:28 +0800 Subject: [PATCH 026/135] =?UTF-8?q?feat(potluck):=20=E5=A2=9E=E5=8A=A0=20Q?= =?UTF-8?q?PS/RPM=20=E6=8E=92=E5=BA=8F=E9=80=89=E9=A1=B9=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E7=BB=9F=E8=AE=A1=E9=9D=A2=E6=9D=BF=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在密钥管理面板的排序下拉菜单中添加 QPS 和 RPM 排序选项 - 重新组织密钥统计信息的布局,将累计数据、请求频率和 Token 速率分栏显示 - 调整响应式设计,确保移动端布局正常 --- VERSION | 2 +- static/potluck.html | 37 +++++++++++++++++++++++++------------ 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/VERSION b/VERSION index dcad56ffa..56c5475ca 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.14.8.2 +2.14.8.3 diff --git a/static/potluck.html b/static/potluck.html index fd66cfdf8..e47e9e7f7 100644 --- a/static/potluck.html +++ b/static/potluck.html @@ -259,12 +259,19 @@ /* Key 统计 */ .key-stats { display: flex; - gap: 24px; + gap: 20px; flex-wrap: wrap; + flex: 3; + align-items: flex-start; } .key-stat { - text-align: center; - min-width: 70px; + flex: 1; + min-width: 140px; + padding: 4px 8px; + border-left: 2px solid var(--border-color); + } + .key-stat:first-child { + border-left: none; } .key-stat .label { font-size: 11px; @@ -506,6 +513,8 @@ .key-stat { min-width: unset; text-align: center; + border-left: none; + padding: 4px; } .key-actions { display: grid; @@ -714,6 +723,8 @@

K + +

@@ -1097,6 +1108,8 @@

批量应用每日限额

else if (field === 'usage') { va = a.todayUsage; vb = b.todayUsage; } else if (field === 'total') { va = a.totalUsage; vb = b.totalUsage; } else if (field === 'lastUsed') { va = a.lastUsedAt || ''; vb = b.lastUsedAt || ''; } + else if (field === 'qps') { va = a.qps || 0; vb = b.qps || 0; } + else if (field === 'rpm') { va = a.rpm || 0; vb = b.rpm || 0; } else if (field === 'created') { va = a.createdAt || ''; vb = b.createdAt || ''; } if (va < vb) return order === 'asc' ? -1 : 1; if (va > vb) return order === 'asc' ? 1 : -1; @@ -1172,19 +1185,19 @@

批量应用每日限额

-
当前速率
-
${(key.qps || 0).toFixed(2)} QPS
-
峰值: ${(key.maxQps || 0).toFixed(2)} QPS
+
累计数据
+
${key.totalUsage} 次请求
+
${formatTokenCompact(key.totalTokens || 0)} Tokens ${key.totalCachedTokens ? `(含 ${formatTokenCompact(key.totalCachedTokens)} 缓存)` : ''}
-
流量/速率
-
${(key.rpm || 0).toFixed(0)} RPM
-
${(key.tps || 0).toFixed(1)} TPS / 峰值: ${(key.maxTps || 0).toFixed(1)}
+
请求频率
+
${(key.qps || 0).toFixed(2)} QPS / ${(key.rpm || 0).toFixed(0)} RPM
+
峰值: ${(key.maxQps || 0).toFixed(2)} QPS / ${(key.maxRpm || 0).toFixed(0)} RPM
-
累计
-
${key.totalUsage}
-
${formatTokenCompact(key.totalTokens || 0)} Tokens ${key.totalCachedTokens ? `(含 ${formatTokenCompact(key.totalCachedTokens)} 缓存)` : ''}
+
Token 速率
+
${(key.tps || 0).toFixed(1)} TPS
+
峰值: ${(key.maxTps || 0).toFixed(1)} TPS
最后调用
From 970b2dca2b06dc29ade97365708bf53e62f6c89e Mon Sep 17 00:00:00 2001 From: hex2077 Date: Sun, 19 Apr 2026 12:43:50 +0800 Subject: [PATCH 027/135] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=92=8C=E5=90=8E=E7=AB=AF=E7=9A=84=E6=97=A5=E6=9C=9F?= =?UTF-8?q?=E6=97=B6=E5=8C=BA=E4=B8=BA=E5=8C=97=E4=BA=AC=E6=97=B6=E9=97=B4?= =?UTF-8?q?=20(UTC+8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复前端统计图表和后端统计记录因时区不一致导致的日期键不匹配问题。在 utils/common.js 中新增 getBeijingDateString 工具函数,并在 stats-manager.js 和 key-manager.js 中使用该函数生成日期键,确保前后端均使用北京时间 (UTC+8) 进行统计。 同时更新项目版本号至 2.14.9。 --- VERSION | 2 +- src/plugins/api-potluck/key-manager.js | 29 +++++++++---------- .../model-usage-stats/stats-manager.js | 7 +++-- src/utils/common.js | 13 +++++++++ static/model-usage-stats.html | 4 ++- static/potluck-user.html | 4 ++- static/potluck.html | 4 ++- 7 files changed, 41 insertions(+), 22 deletions(-) diff --git a/VERSION b/VERSION index 56c5475ca..d6961c361 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.14.8.3 +2.14.9 diff --git a/src/plugins/api-potluck/key-manager.js b/src/plugins/api-potluck/key-manager.js index b37996b1b..550367e26 100644 --- a/src/plugins/api-potluck/key-manager.js +++ b/src/plugins/api-potluck/key-manager.js @@ -9,23 +9,22 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import path from 'path'; import crypto from 'crypto'; import { RateManager } from '../../utils/rate-tracker.js'; +import { getBeijingDateString } from '../../utils/common.js'; // 配置文件路径 const KEYS_STORE_FILE = path.join(process.cwd(), 'configs', 'api-potluck-keys.json'); + const KEY_PREFIX = 'maki_'; -// 默认配置 const DEFAULT_CONFIG = { - defaultDailyLimit: 500, - persistInterval: 5000 + persistInterval: 5000, + defaultDailyLimit: 500 }; -// 配置获取函数(由外部注入) let configGetter = null; /** - * 设置配置获取函数 - * @param {Function} getter - 返回配置对象的函数 + * 设置配置获取器 */ export function setConfigGetter(getter) { configGetter = getter; @@ -41,7 +40,14 @@ function getConfig() { return DEFAULT_CONFIG; } -// 内存缓存 +/** + * 获取今日日期字符串 + */ +function getTodayDateString() { + return getBeijingDateString(); +} + +// 插件状态 let keyStore = null; let isDirty = false; let isWriting = false; @@ -283,14 +289,6 @@ function generateApiKey() { return apiKey; } -/** - * 获取今天的日期字符串 (YYYY-MM-DD) - */ -function getTodayDateString() { - const now = new Date(); - return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; -} - /** * 检查并重置过期的每日计数 */ @@ -607,6 +605,7 @@ export async function incrementUsage(apiKey, pName = 'unknown', mName = 'unknown keyData.totalCompletionTokens += toNumber(usage.completionTokens); keyData.totalTokens += toNumber(usage.totalTokens); keyData.totalCachedTokens += toNumber(usage.cachedTokens); + keyData.lastUsedAt = new Date().toISOString(); // 同时也给 keyData 注入实时峰值(如果需要持久化) if (!keyData.maxQps) keyData.maxQps = 0; diff --git a/src/plugins/model-usage-stats/stats-manager.js b/src/plugins/model-usage-stats/stats-manager.js index f9615ee98..2abfe09e1 100644 --- a/src/plugins/model-usage-stats/stats-manager.js +++ b/src/plugins/model-usage-stats/stats-manager.js @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import path from 'path'; import logger from '../../utils/logger.js'; import { RateManager } from '../../utils/rate-tracker.js'; +import { getBeijingDateString } from '../../utils/common.js'; const STATS_STORE_FILE = path.join(process.cwd(), 'configs', 'model-usage-stats.json'); const DEFAULT_CONFIG = { @@ -426,7 +427,9 @@ export async function finalizeRequest({ requestId, model, provider, fromProvider return false; } - const timestamp = new Date().toISOString(); + const now = new Date(); + const timestamp = now.toISOString(); + const dateKey = getBeijingDateString(); const normalizedProvider = state.provider || provider || 'unknown'; const normalizedModel = state.model || model || 'unknown'; @@ -458,8 +461,6 @@ export async function finalizeRequest({ requestId, model, provider, fromProvider updatePeaks(ensureProviderStore(normalizedProvider).summary); updatePeaks(ensureModelStore(normalizedProvider, normalizedModel)); - // 记录每日统计 - const dateKey = timestamp.split('T')[0]; if (!statsStore.daily[dateKey]) { statsStore.daily[dateKey] = createEmptyUsage(); } diff --git a/src/utils/common.js b/src/utils/common.js index 0aeb06c62..de74e3328 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -9,6 +9,19 @@ import { ProviderStrategyFactory } from './provider-strategies.js'; import { getPluginManager } from '../core/plugin-manager.js'; import { MODEL_PROTOCOL_PREFIX, MODEL_PROVIDER } from './constants.js'; +// ==================== 时间与时区 ==================== + +/** + * 获取北京时间 (UTC+8) 的日期字符串 (YYYY-MM-DD) + * @returns {string} - YYYY-MM-DD 格式的日期字符串 + */ +export function getBeijingDateString() { + const now = new Date(); + // 强制增加 8 小时偏移来模拟 UTC+8 + const utc8Time = new Date(now.getTime() + (8 * 60 * 60 * 1000)); + return utc8Time.toISOString().split('T')[0]; +} + // ==================== 网络错误处理 ==================== /** diff --git a/static/model-usage-stats.html b/static/model-usage-stats.html index 223493fda..b17975bdc 100644 --- a/static/model-usage-stats.html +++ b/static/model-usage-stats.html @@ -1086,7 +1086,9 @@

模型明 date.setDate(startDate.getDate() + i); if (date > now) break; - const dateKey = date.toISOString().split('T')[0]; + // 统一使用北京时间 (UTC+8) 生成 key,与后端保持一致 + const utc8Date = new Date(date.getTime() + (8 * 60 * 60 * 1000)); + const dateKey = utc8Date.toISOString().split('T')[0]; const data = dailyData[dateKey] || { totalTokens: 0, requestCount: 0 diff --git a/static/potluck-user.html b/static/potluck-user.html index 73404ec54..d01241955 100644 --- a/static/potluck-user.html +++ b/static/potluck-user.html @@ -703,7 +703,9 @@

API 密钥

date.setDate(startDate.getDate() + i); if (date > now) break; - const dateKey = date.toISOString().split('T')[0]; + // 统一使用北京时间 (UTC+8) 生成 key,与后端保持一致 + const utc8Date = new Date(date.getTime() + (8 * 60 * 60 * 1000)); + const dateKey = utc8Date.toISOString().split('T')[0]; const entry = dailyData[dateKey] || { totalTokens: 0, requestCount: 0 }; const tokens = entry.totalTokens; const requests = entry.requestCount; diff --git a/static/potluck.html b/static/potluck.html index e47e9e7f7..bf7415219 100644 --- a/static/potluck.html +++ b/static/potluck.html @@ -946,7 +946,9 @@

批量应用每日限额

date.setDate(startDate.getDate() + i); if (date > now) break; - const dateKey = date.toISOString().split('T')[0]; + // 统一使用北京时间 (UTC+8) 生成 key,与后端保持一致 + const utc8Date = new Date(date.getTime() + (8 * 60 * 60 * 1000)); + const dateKey = utc8Date.toISOString().split('T')[0]; const entry = dailyData[dateKey] || { totalTokens: 0, requestCount: 0 }; const tokens = entry.totalTokens; const requests = entry.requestCount; From ed14c0d505f96b2ddd8943f0a14cedb32d79de3f Mon Sep 17 00:00:00 2001 From: hex2077 Date: Mon, 20 Apr 2026 12:39:18 +0800 Subject: [PATCH 028/135] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E9=94=81=E6=9C=BA=E5=88=B6=E4=BB=A5=E8=A7=A3=E5=86=B3?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E5=86=99=E5=85=A5=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引入文件锁管理器,为关键配置文件操作提供异步锁保护,防止并发写入导致的数据损坏。重构多个UI API模块,使用原子化写入和文件锁确保配置更新、自定义模型管理、提供商状态持久化等操作的安全性。同时更新项目文档标题和添加日志目录到.gitignore。 --- .gitignore | 1 + README-JA.md | 2 +- README-ZH.md | 2 +- README.md | 2 +- src/providers/provider-pool-manager.js | 102 +++++++++-------- src/services/service-manager.js | 5 +- src/ui-modules/config-api.js | 87 ++++++++------ src/ui-modules/custom-models-api.js | 39 ++++++- src/ui-modules/provider-api.js | 150 ++++++++++++++++--------- src/ui-modules/update-api.js | 7 +- src/utils/file-lock.js | 138 +++++++++++++++++++++++ static/favicon.ico | Bin 10422 -> 270622 bytes static/favicon0.ico | Bin 0 -> 270622 bytes 13 files changed, 390 insertions(+), 145 deletions(-) create mode 100644 src/utils/file-lock.js create mode 100644 static/favicon0.ico diff --git a/.gitignore b/.gitignore index e6167a2b5..48a04ac86 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules +logs/ .serena/ .claude/ CLAUDE.md diff --git a/README-JA.md b/README-JA.md index 0ec98709d..21bfd1eb7 100644 --- a/README-JA.md +++ b/README-JA.md @@ -2,7 +2,7 @@ logo -# AIClient-2-API 🚀 +# AIClient-2-API(A2)🚀 **複数のクライアント専用大規模言語モデルAPI(Gemini CLI、Antigravity、Codex, Grok、Kiro ...)を模擬リクエストし、ローカルのOpenAI互換インターフェースに統一的にラッピングする強力なプロキシ。** diff --git a/README-ZH.md b/README-ZH.md index be10dad01..458c4a282 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -2,7 +2,7 @@ logo -# AIClient-2-API 🚀 +# AIClient-2-API(A2)🚀 **一个能将多种仅客户端内使用的大模型 API(Gemini CLI, Antigravity, Codex, Grok, Kiro ...),模拟请求,统一封装为本地 OpenAI 兼容接口的强大代理。** diff --git a/README.md b/README.md index 195ff6bfb..b4324d0dc 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ logo -# AIClient-2-API 🚀 +# AIClient-2-API(A2)🚀 **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.** diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index f48eb661d..83ed09d16 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -2,7 +2,9 @@ import * as fs from 'fs'; import { getServiceAdapter, getRegisteredProviders, invalidateServiceAdapter } from './adapter.js'; import logger from '../utils/logger.js'; import { MODEL_PROVIDER, getProtocolPrefix } from '../utils/common.js'; +import { withFileLock, atomicWriteFile } from '../utils/file-lock.js'; import { convertData } from '../convert/convert.js'; + import { getConfiguredSupportedModels, getCustomModelListProvider, @@ -2229,56 +2231,68 @@ export class ProviderPoolManager { * @private */ async _flushPendingSaves() { - const typesToSave = Array.from(this.pendingSaves); - if (typesToSave.length === 0) return; - - this.pendingSaves.clear(); - this.saveTimer = null; + // 立即置空定时器,防止重叠调用 + if (this.saveTimer) { + clearTimeout(this.saveTimer); + this.saveTimer = null; + } + + const filePath = this.globalConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - try { - const filePath = this.globalConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - let currentPools = {}; - - // 一次性读取文件 + // 使用文件锁确保并发安全 + await withFileLock(filePath, async (checkValidity) => { + // 原子化提取待保存任务并清空,防止在异步循环期间丢失新更新 + const typesToSave = Array.from(this.pendingSaves); + if (typesToSave.length === 0) return; + this.pendingSaves.clear(); + try { - const fileContent = await fs.promises.readFile(filePath, 'utf8'); - currentPools = JSON.parse(fileContent); - } catch (readError) { - if (readError.code === 'ENOENT') { - this._log('info', 'configs/provider_pools.json does not exist, creating new file.'); - } else { - throw readError; + let currentPools = {}; + + // 采用“读取-合并-写入”策略,保留可能存在的未知字段 + try { + const fileContent = await fs.promises.readFile(filePath, 'utf8'); + currentPools = JSON.parse(fileContent); + } catch (readError) { + if (readError.code === 'ENOENT') { + this._log('info', 'configs/provider_pools.json does not exist, creating new file.'); + } else { + throw readError; + } } - } - // 更新所有待保存的 providerType - for (const providerType of typesToSave) { - if (this.providerStatus[providerType]) { - currentPools[providerType] = this.providerStatus[providerType].map(p => { - // Convert Date objects to ISOString if they exist - const config = { ...p.config }; - if (config.lastUsed instanceof Date) { - config.lastUsed = config.lastUsed.toISOString(); - } - if (config.lastErrorTime instanceof Date) { - config.lastErrorTime = config.lastErrorTime.toISOString(); - } - if (config.lastHealthCheckTime instanceof Date) { - config.lastHealthCheckTime = config.lastHealthCheckTime.toISOString(); - } - return config; - }); - } else { - this._log('warn', `Attempted to save unknown providerType: ${providerType}`); + // 检查锁是否依然有效 + checkValidity(); + + // 更新所有待保存的 providerType + for (const providerType of typesToSave) { + if (this.providerStatus[providerType]) { + currentPools[providerType] = this.providerStatus[providerType].map(p => { + const config = { ...p.config }; + if (config.lastUsed instanceof Date) { + config.lastUsed = config.lastUsed.toISOString(); + } + if (config.lastErrorTime instanceof Date) { + config.lastErrorTime = config.lastErrorTime.toISOString(); + } + if (config.lastHealthCheckTime instanceof Date) { + config.lastHealthCheckTime = config.lastHealthCheckTime.toISOString(); + } + return config; + }); + } else { + this._log('warn', `Attempted to save unknown providerType: ${providerType}`); + } } + + // 一次性写入文件(使用原子化写入) + await atomicWriteFile(filePath, JSON.stringify(currentPools, null, 2), 'utf8'); + + this._log('info', `configs/provider_pools.json updated successfully for types: ${typesToSave.join(', ')}`); + } catch (error) { + this._log('error', `Failed to write provider_pools.json: ${error.message}`); } - - // 一次性写入文件 - await fs.promises.writeFile(filePath, JSON.stringify(currentPools, null, 2), 'utf8'); - this._log('info', `configs/provider_pools.json updated successfully for types: ${typesToSave.join(', ')}`); - } catch (error) { - this._log('error', `Failed to write provider_pools.json: ${error.message}`); - } + }); } } diff --git a/src/services/service-manager.js b/src/services/service-manager.js index 315b460f5..9f62e603c 100644 --- a/src/services/service-manager.js +++ b/src/services/service-manager.js @@ -13,6 +13,7 @@ import { getFileName, formatSystemPath } from '../utils/provider-utils.js'; +import { withFileLock, atomicWriteFile } from '../utils/file-lock.js'; import { MODEL_PROVIDER } from '../utils/constants.js'; // 存储 ProviderPoolManager 实例 @@ -88,7 +89,9 @@ export async function autoLinkProviderConfigs(config, options = {}) { if (totalNewProviders > 0) { const filePath = config.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; try { - await pfs.writeFile(filePath, JSON.stringify(config.providerPools, null, 2), 'utf8'); + await withFileLock(filePath, async () => { + await atomicWriteFile(filePath, JSON.stringify(config.providerPools, null, 2), 'utf8'); + }); logger.info(`[Auto-Link] Added ${totalNewProviders} new config(s) to provider pools:`); for (const [displayName, providers] of Object.entries(allNewProviders)) { logger.info(` ${displayName}: ${providers.length} config(s)`); diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js index 536380a72..5b77d9f28 100644 --- a/src/ui-modules/config-api.js +++ b/src/ui-modules/config-api.js @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import logger from '../utils/logger.js'; import { promises as fs } from 'fs'; import path from 'path'; @@ -9,36 +9,39 @@ 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'; +import { withFileLock, atomicWriteFile } from '../utils/file-lock.js'; /** * 重载配置文件 - * 动态导入config-manager并重新初始化配置 - * @returns {Promise} 返回重载后的配置对象 */ export async function reloadConfig(providerPoolManager) { try { - // Import config manager dynamically - const { initializeConfig } = await import('../core/config-manager.js'); - - // Reload main config - const newConfig = await initializeConfig(process.argv.slice(2), 'configs/config.json'); - // Update provider pool manager if available - if (providerPoolManager) { - providerPoolManager.providerPools = newConfig.providerPools; - providerPoolManager.initializeProviderStatus(); - } - - // Update global CONFIG - Object.assign(CONFIG, newConfig); - logger.info('[UI API] Configuration reloaded:'); + const configPath = 'configs/config.json'; + // 使用文件锁进行重载,防止在写入期间读取 + return await withFileLock(configPath, async () => { + // Import config manager dynamically + const { initializeConfig } = await import('../core/config-manager.js'); + + // Reload main config + const newConfig = await initializeConfig(process.argv.slice(2), configPath); + // Update provider pool manager if available + if (providerPoolManager) { + providerPoolManager.providerPools = newConfig.providerPools; + providerPoolManager.initializeProviderStatus(); + } - // Update initApiService - 清空并重新初始化服务实例 - Object.keys(serviceInstances).forEach(key => delete serviceInstances[key]); - initApiService(CONFIG); - - logger.info('[UI API] Configuration reloaded successfully'); - - return newConfig; + // Update global CONFIG + Object.assign(CONFIG, newConfig); + logger.info('[UI API] Configuration reloaded:'); + + // Update initApiService - 清空并重新初始化服务实例 + Object.keys(serviceInstances).forEach(key => delete serviceInstances[key]); + initApiService(CONFIG); + + logger.info('[UI API] Configuration reloaded successfully'); + + return newConfig; + }); } catch (error) { logger.error('[UI API] Failed to reload configuration:', error); throw error; @@ -112,6 +115,16 @@ export async function handleGetConfig(req, res, currentConfig) { export async function handleUpdateConfig(req, res, currentConfig) { try { const body = await getRequestBody(req); + const configPath = 'configs/config.json'; + return await withFileLock(configPath, () => _handleUpdateConfig(req, res, currentConfig, body)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'File operation failed: ' + err.message } })); + return true; + } +} +async function _handleUpdateConfig(req, res, currentConfig, body) { + try { const newConfig = body; // Update config values in memory(含类型校验) @@ -266,7 +279,7 @@ export async function handleUpdateConfig(req, res, currentConfig) { const promptPath = currentConfig.SYSTEM_PROMPT_FILE_PATH || 'configs/input_system_prompt.txt'; try { const relativePath = path.relative(process.cwd(), promptPath); - writeFileSync(promptPath, newConfig.systemPrompt, 'utf-8'); + await atomicWriteFile(promptPath, newConfig.systemPrompt, 'utf-8'); // 广播更新事件 broadcastEvent('config_update', { @@ -326,7 +339,7 @@ export async function handleUpdateConfig(req, res, currentConfig) { SCHEDULED_HEALTH_CHECK: currentConfig.SCHEDULED_HEALTH_CHECK }; - writeFileSync(configPath, JSON.stringify(configToSave, null, 2), 'utf-8'); + await atomicWriteFile(configPath, JSON.stringify(configToSave, null, 2), 'utf-8'); logger.info('[UI API] Configuration saved to configs/config.json'); // 广播更新事件 @@ -414,22 +427,22 @@ export async function handleUpdateAdminPassword(req, res) { if (!password || password.trim() === '') { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { + res.end(JSON.stringify({ + error: { message: 'Password cannot be empty', messageCode: 'common.passwordEmpty' - } + } })); return true; } if (password.trim().length < PASSWORD.MIN_LENGTH) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { + res.end(JSON.stringify({ + error: { message: `Password must be at least ${PASSWORD.MIN_LENGTH} characters`, messageCode: 'common.passwordTooShort' - } + } })); return true; } @@ -444,8 +457,12 @@ export async function handleUpdateAdminPassword(req, res) { const stored = `pbkdf2:${salt}:${hash}`; const pwdFilePath = path.join(process.cwd(), 'configs', 'pwd'); - await fs.writeFile(pwdFilePath, stored, 'utf-8'); - + + // 使用文件锁和原子化写入 + await withFileLock(pwdFilePath, async () => { + await atomicWriteFile(pwdFilePath, stored, 'utf-8'); + }); + logger.info('[UI API] Admin password updated successfully'); res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -464,4 +481,4 @@ export async function handleUpdateAdminPassword(req, res) { })); return true; } -} \ No newline at end of file +} diff --git a/src/ui-modules/custom-models-api.js b/src/ui-modules/custom-models-api.js index 7f051925a..d74a4c7a3 100644 --- a/src/ui-modules/custom-models-api.js +++ b/src/ui-modules/custom-models-api.js @@ -1,8 +1,9 @@ -import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import logger from '../utils/logger.js'; import { getRequestBody } from '../utils/common.js'; import { broadcastEvent } from './event-broadcast.js'; import { CONFIG } from '../core/config-manager.js'; +import { withFileLock, atomicWriteFile } from '../utils/file-lock.js'; function syncRuntimeCustomModels(currentConfig, customModels) { const normalizedCustomModels = Array.isArray(customModels) ? customModels : []; @@ -39,6 +40,16 @@ export async function handleGetCustomModels(req, res, currentConfig) { export async function handleAddCustomModel(req, res, currentConfig) { try { const body = await getRequestBody(req); + const filePath = CONFIG.CUSTOM_MODELS_FILE_PATH || 'configs/custom_models.json'; + return await withFileLock(filePath, () => _handleAddCustomModel(req, res, currentConfig, body)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'File operation failed: ' + err.message } })); + return true; + } +} +async function _handleAddCustomModel(req, res, currentConfig, body) { + try { const newModel = body; if (!newModel.id) { @@ -69,7 +80,7 @@ export async function handleAddCustomModel(req, res, currentConfig) { customModels.push(newModel); // Save to file - writeFileSync(filePath, JSON.stringify(customModels, null, 2), 'utf-8'); + await atomicWriteFile(filePath, JSON.stringify(customModels, null, 2), 'utf-8'); syncRuntimeCustomModels(currentConfig, customModels); logger.info(`[UI API] Added custom model: ${newModel.id}`); @@ -97,6 +108,16 @@ export async function handleAddCustomModel(req, res, currentConfig) { export async function handleUpdateCustomModel(req, res, currentConfig, modelId) { try { const body = await getRequestBody(req); + const filePath = CONFIG.CUSTOM_MODELS_FILE_PATH || 'configs/custom_models.json'; + return await withFileLock(filePath, () => _handleUpdateCustomModel(req, res, currentConfig, modelId, body)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'File operation failed: ' + err.message } })); + return true; + } +} +async function _handleUpdateCustomModel(req, res, currentConfig, modelId, body) { + try { const updatedModel = body; const filePath = currentConfig.CUSTOM_MODELS_FILE_PATH || 'configs/custom_models.json'; @@ -130,7 +151,7 @@ export async function handleUpdateCustomModel(req, res, currentConfig, modelId) customModels[index] = { ...customModels[index], ...updatedModel }; // Save to file - writeFileSync(filePath, JSON.stringify(customModels, null, 2), 'utf-8'); + await atomicWriteFile(filePath, JSON.stringify(customModels, null, 2), 'utf-8'); syncRuntimeCustomModels(currentConfig, customModels); logger.info(`[UI API] Updated custom model: ${modelId}`); @@ -156,6 +177,16 @@ export async function handleUpdateCustomModel(req, res, currentConfig, modelId) * 删除自定义模型 */ export async function handleDeleteCustomModel(req, res, currentConfig, modelId) { + try { + const filePath = CONFIG.CUSTOM_MODELS_FILE_PATH || 'configs/custom_models.json'; + return await withFileLock(filePath, () => _handleDeleteCustomModel(req, res, currentConfig, modelId)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'File operation failed: ' + err.message } })); + return true; + } +} +async function _handleDeleteCustomModel(req, res, currentConfig, modelId) { try { const filePath = currentConfig.CUSTOM_MODELS_FILE_PATH || 'configs/custom_models.json'; let customModels = []; @@ -179,7 +210,7 @@ export async function handleDeleteCustomModel(req, res, currentConfig, modelId) const deletedModel = customModels.splice(index, 1)[0]; // Save to file - writeFileSync(filePath, JSON.stringify(customModels, null, 2), 'utf-8'); + await atomicWriteFile(filePath, JSON.stringify(customModels, null, 2), 'utf-8'); syncRuntimeCustomModels(currentConfig, customModels); logger.info(`[UI API] Deleted custom model: ${modelId}`); diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index a56c04a2c..f6c6ccff9 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import logger from '../utils/logger.js'; import { getRequestBody } from '../utils/common.js'; import { @@ -11,6 +11,9 @@ import { import { generateUUID, createProviderConfig, formatSystemPath, detectProviderFromPath, addToUsedPaths, isPathUsed, pathsEqual } from '../utils/provider-utils.js'; import { broadcastEvent } from './event-broadcast.js'; import { getRegisteredProviders, getServiceAdapter, invalidateServiceAdapter, serviceInstances } from '../providers/adapter.js'; +import { withFileLock, atomicWriteFile } from '../utils/file-lock.js'; + + // 文件级互斥锁:防止并发读写导致数据丢失 // 安全净化:移除用户输入字段中的危险内容(script、事件处理器、javascript:协议等), @@ -117,7 +120,7 @@ function getManagedSupportedModels(providerType, providers = []) { ); } -function persistProviderStatusToFile(currentConfig, providerPoolManager) { +async function persistProviderStatusToFile(currentConfig, providerPoolManager) { const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; const providerPools = {}; @@ -125,7 +128,7 @@ function persistProviderStatusToFile(currentConfig, providerPoolManager) { providerPools[providerType] = providerPoolManager.providerStatus[providerType].map(providerStatus => providerStatus.config); } - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + await atomicWriteFile(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); return filePath; } @@ -213,30 +216,6 @@ async function runProviderHealthCheck(providerPoolManager, providerType, provide } } -// 使用 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(() => withTimeout(fn(), 30000)) - .catch(err => { - // 记录错误并抛出,中断操作 - logger.error('[FileLock] Operation failed:', err?.message || err); - throw err; - }); - _fileLockChain = next.then(() => {}).catch(() => {}); - return next; -} /** * 获取所有提供商的状态(包括支持的类型和号池组) */ @@ -472,15 +451,18 @@ export async function handleDetectProviderModels(req, res, currentConfig, provid * 添加新的提供商配置 */ export async function handleAddProvider(req, res, currentConfig, providerPoolManager) { - return withFileLock(() => _handleAddProvider(req, res, currentConfig, providerPoolManager)).catch(err => { + try { + const body = await getRequestBody(req); + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + return await withFileLock(filePath, () => _handleAddProvider(req, res, currentConfig, providerPoolManager, body)); + } 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) { +async function _handleAddProvider(req, res, currentConfig, providerPoolManager, body) { try { - const body = await getRequestBody(req); const { providerType, providerConfig } = body; if (!providerType || !providerConfig) { @@ -528,7 +510,7 @@ async function _handleAddProvider(req, res, currentConfig, providerPoolManager) providerPools[providerType].push(filteredConfig); // Save to file - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + await atomicWriteFile(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); logger.info(`[UI API] Added new provider to ${providerType}: ${providerConfig.uuid}`); // Update provider pool manager if available @@ -573,15 +555,18 @@ 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)).catch(err => { + try { + const body = await getRequestBody(req); + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + return await withFileLock(filePath, () => _handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, body)); + } 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) { +async function _handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, body) { try { - const body = await getRequestBody(req); const { providerConfig } = body; if (!providerConfig) { @@ -638,7 +623,7 @@ async function _handleUpdateProvider(req, res, currentConfig, providerPoolManage providerPools[providerType][providerIndex] = updatedProvider; // Save to file - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + await atomicWriteFile(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); logger.info(`[UI API] Updated provider ${providerUuid} in ${providerType}`); invalidateServiceAdapter(providerType, providerUuid); @@ -675,7 +660,8 @@ 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)).catch(err => { + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + return withFileLock(filePath, () => _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; @@ -717,7 +703,7 @@ async function _handleDeleteProvider(req, res, currentConfig, providerPoolManage } // Save to file - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + await atomicWriteFile(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); logger.info(`[UI API] Deleted provider ${providerUuid} from ${providerType}`); invalidateServiceAdapter(providerType, providerUuid); @@ -750,11 +736,13 @@ 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)).catch(err => { + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + return withFileLock(filePath, () => _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; @@ -792,7 +780,7 @@ async function _handleDisableEnableProvider(req, res, currentConfig, providerPoo provider.isDisabled = action === 'disable'; // Save to file - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + await atomicWriteFile(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); logger.info(`[UI API] ${action === 'disable' ? 'Disabled' : 'Enabled'} provider ${providerUuid} in ${providerType}`); // Update provider pool manager if available @@ -834,6 +822,14 @@ async function _handleDisableEnableProvider(req, res, currentConfig, providerPoo * 重置特定提供商类型的所有提供商健康状态 */ export async function handleResetProviderHealth(req, res, currentConfig, providerPoolManager, providerType) { + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + return withFileLock(filePath, () => _handleResetProviderHealth(req, res, currentConfig, providerPoolManager, providerType)).catch(err => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'File operation failed: ' + err.message } })); + return true; + }); +} +async function _handleResetProviderHealth(req, res, currentConfig, providerPoolManager, providerType) { try { const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; let providerPools = {}; @@ -874,7 +870,7 @@ export async function handleResetProviderHealth(req, res, currentConfig, provide }); // Save to file - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + await atomicWriteFile(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); logger.info(`[UI API] Reset health status for ${resetCount} providers in ${providerType}`); // Update provider pool manager if available @@ -911,6 +907,14 @@ export async function handleResetProviderHealth(req, res, currentConfig, provide * 删除特定提供商类型的所有不健康节点 */ export async function handleDeleteUnhealthyProviders(req, res, currentConfig, providerPoolManager, providerType) { + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + return withFileLock(filePath, () => _handleDeleteUnhealthyProviders(req, res, currentConfig, providerPoolManager, providerType)).catch(err => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'File operation failed: ' + err.message } })); + return true; + }); +} +async function _handleDeleteUnhealthyProviders(req, res, currentConfig, providerPoolManager, providerType) { try { const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; let providerPools = {}; @@ -959,7 +963,7 @@ export async function handleDeleteUnhealthyProviders(req, res, currentConfig, pr } // Save to file - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + await atomicWriteFile(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); logger.info(`[UI API] Deleted ${unhealthyProviders.length} unhealthy providers from ${providerType}`); // Update provider pool manager if available @@ -998,6 +1002,14 @@ export async function handleDeleteUnhealthyProviders(req, res, currentConfig, pr * 批量刷新特定提供商类型的所有不健康节点的 UUID */ export async function handleRefreshUnhealthyUuids(req, res, currentConfig, providerPoolManager, providerType) { + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + return withFileLock(filePath, () => _handleRefreshUnhealthyUuids(req, res, currentConfig, providerPoolManager, providerType)).catch(err => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'File operation failed: ' + err.message } })); + return true; + }); +} +async function _handleRefreshUnhealthyUuids(req, res, currentConfig, providerPoolManager, providerType) { try { const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; let providerPools = {}; @@ -1050,7 +1062,7 @@ export async function handleRefreshUnhealthyUuids(req, res, currentConfig, provi } // Save to file - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + await atomicWriteFile(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); logger.info(`[UI API] Refreshed UUIDs for ${refreshedProviders.length} unhealthy providers in ${providerType}`); // Update provider pool manager if available @@ -1089,6 +1101,8 @@ export async function handleRefreshUnhealthyUuids(req, res, currentConfig, provi * 对特定提供商类型的所有提供商执行健康检查 */ export async function handleHealthCheck(req, res, currentConfig, providerPoolManager, providerType) { + // 健康检查涉及大量异步操作,但最后的文件保存必须加锁 + // 为了不长时间占用文件锁,我们只在保存文件时加锁 try { if (!providerPoolManager) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -1191,15 +1205,29 @@ export async function handleHealthCheck(req, res, currentConfig, providerPoolMan } } - // 保存更新后的状态到文件 + // 保存更新后的状态到文件 - 使用文件锁 const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - // 从 providerStatus 构建 providerPools 对象并保存 - const providerPools = {}; - for (const pType in providerPoolManager.providerStatus) { - providerPools[pType] = providerPoolManager.providerStatus[pType].map(ps => ps.config); - } - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + await withFileLock(filePath, async (checkValidity) => { + let currentPools = {}; + // 读取现有配置,保留未知字段 + if (existsSync(filePath)) { + try { + const fileContent = readFileSync(filePath, 'utf-8'); + currentPools = JSON.parse(fileContent); + } catch (readError) { + logger.warn('[UI API] Failed to read existing provider pools for health check merge:', readError.message); + } + } + + // 在写入前检查锁是否过期 + checkValidity(); + + // 更新当前 providerType 的所有节点 + currentPools[providerType] = providerPoolManager.providerStatus[providerType].map(ps => ps.config); + + await atomicWriteFile(filePath, JSON.stringify(currentPools, null, 2), 'utf-8'); + }); const successCount = results.filter(r => r.success === true).length; const failCount = results.filter(r => r.success === false).length; @@ -1209,7 +1237,7 @@ export async function handleHealthCheck(req, res, currentConfig, providerPoolMan // 广播更新事件 broadcastEvent('config_update', { action: 'health_check', - filePath: filePath, + filePath, providerType, results: results.map(r => ({ ...r, message: sanitizeProviderData({ message: r.message }).message })), timestamp: new Date().toISOString() @@ -1259,7 +1287,8 @@ export async function handleSingleProviderHealthCheck(req, res, currentConfig, p const result = await runProviderHealthCheck(providerPoolManager, providerType, providerStatus); // 使用文件锁进行持久化,防止并发写入冲突 - const filePath = await withFileLock(async () => { + const poolFilePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + const filePath = await withFileLock(poolFilePath, async () => { return persistProviderStatusToFile(currentConfig, providerPoolManager); }); @@ -1393,8 +1422,8 @@ export async function handleQuickLinkProvider(req, res, currentConfig, providerP // Save to file only if there were successful links const successCount = results.filter(r => r.success).length; if (successCount > 0) { - await withFileLock(async () => { - writeFileSync(poolsFilePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + await withFileLock(poolsFilePath, async () => { + await atomicWriteFile(poolsFilePath, JSON.stringify(providerPools, null, 2), 'utf-8'); return poolsFilePath; }); @@ -1452,6 +1481,14 @@ export async function handleQuickLinkProvider(req, res, currentConfig, providerP * 刷新特定提供商的UUID */ export async function handleRefreshProviderUuid(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + return withFileLock(filePath, () => _handleRefreshProviderUuid(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 _handleRefreshProviderUuid(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { try { const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; let providerPools = {}; @@ -1486,7 +1523,8 @@ export async function handleRefreshProviderUuid(req, res, currentConfig, provide providerPools[providerType][providerIndex].uuid = newUuid; // Save to file - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + await atomicWriteFile(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + logger.info(`[UI API] Refreshed UUID for provider in ${providerType}: ${oldUuid} -> ${newUuid}`); invalidateServiceAdapter(providerType, oldUuid); invalidateServiceAdapter(providerType, newUuid); diff --git a/src/ui-modules/update-api.js b/src/ui-modules/update-api.js index a4c657bec..797c50f11 100644 --- a/src/ui-modules/update-api.js +++ b/src/ui-modules/update-api.js @@ -1,4 +1,5 @@ -import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; +import { atomicWriteFile, withFileLock } from '../utils/file-lock.js'; import logger from '../utils/logger.js'; import { promises as fs } from 'fs'; import path from 'path'; @@ -383,7 +384,9 @@ export async function performUpdate(targetTag = null) { const versionFilePath = path.join(process.cwd(), 'VERSION'); try { const newVersion = finalTag.replace(/^v/, ''); - writeFileSync(versionFilePath, newVersion, 'utf-8'); + await withFileLock(versionFilePath, async () => { + await atomicWriteFile(versionFilePath, newVersion, 'utf-8'); + }); logger.info(`[Update] VERSION file updated to ${newVersion}`); } catch (error) { logger.warn('[Update] Failed to update VERSION file:', error.message); diff --git a/src/utils/file-lock.js b/src/utils/file-lock.js new file mode 100644 index 000000000..739a32f56 --- /dev/null +++ b/src/utils/file-lock.js @@ -0,0 +1,138 @@ +import logger from './logger.js'; +import { writeFileSync, renameSync, unlinkSync, promises as pfs } from 'fs'; + +/** + * 文件锁管理器:支持按文件路径隔离的异步锁 + */ +class FileLockManager { + constructor() { + this.locks = new Map(); + this.currentHolders = new Map(); // 记录每个路径当前的持有者 ID + } + + /** + * 获取指定路径的锁链 + */ + getLock(filePath) { + if (!this.locks.has(filePath)) { + this.locks.set(filePath, Promise.resolve()); + } + return this.locks.get(filePath); + } + + /** + * 更新指定路径的锁链 + */ + updateLock(filePath, promise) { + this.locks.set(filePath, promise.then(() => {}).catch(() => {})); + } + + /** + * 分配一个新的持有者 ID + */ + assignHolder(filePath) { + const id = Math.random().toString(36).substring(2, 11); + this.currentHolders.set(filePath, id); + return id; + } + + /** + * 校验持有者 ID 是否仍然有效 + */ + isHolderValid(filePath, id) { + return this.currentHolders.get(filePath) === id; + } +} + +const lockManager = new FileLockManager(); + +/** + * 超时包装函数 + */ +function withTimeout(promise, ms = 30000) { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Operation timeout after ${ms}ms`)), ms) + ) + ]); +} + +/** + * 带有重试机制的重命名操作 + */ +async function retryRename(src, dest, retries = 5, delay = 100) { + for (let i = 0; i < retries; i++) { + try { + await pfs.rename(src, dest); + return; + } catch (err) { + const isLocked = err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'EBUSY'; + if (isLocked && i < retries - 1) { + await new Promise(resolve => setTimeout(resolve, delay * (i + 1))); + continue; + } + throw err; + } + } +} + +/** + * 获取文件锁并执行回调函数 + * @param {string} filePath - 锁的标识(文件路径) + * @param {Function} fn - 回调函数,接收一个 isLocked 有效性检查函数 + */ +export function withFileLock(filePath, fn) { + const lockKey = typeof filePath === 'string' ? filePath : 'global_lock'; + const callback = typeof filePath === 'function' ? filePath : fn; + + const currentLock = lockManager.getLock(lockKey); + const next = currentLock + .then(async () => { + const holderId = lockManager.assignHolder(lockKey); + // 传递一个检查函数,让回调内部可以判断自己是否因超时而失去了锁 + const checkValidity = () => { + if (!lockManager.isHolderValid(lockKey, holderId)) { + throw new Error(`Lock on ${lockKey} has been revoked due to timeout or preemption.`); + } + }; + return await withTimeout(callback(checkValidity), 30000); + }) + .catch(err => { + logger.error(`[FileLock][${lockKey}] Operation failed:`, err?.message || err); + throw err; + }); + + lockManager.updateLock(lockKey, next); + return next; +} + +/** + * 原子化写入文件:先写临时文件,成功后再 rename + */ +export function atomicWriteFileSync(filePath, data, encoding = 'utf-8') { + const tempPath = `${filePath}.${Date.now()}.${Math.random().toString(36).substring(2, 7)}.tmp`; + try { + writeFileSync(tempPath, data, encoding); + renameSync(tempPath, filePath); + } catch (error) { + logger.error(`[FileLock] Atomic write failed for ${filePath}:`, error.message); + try { unlinkSync(tempPath); } catch (e) {} + throw error; + } +} + +/** + * 原子化写入文件(异步版,带 Windows 重试支持) + */ +export async function atomicWriteFile(filePath, data, encoding = 'utf-8') { + const tempPath = `${filePath}.${Date.now()}.${Math.random().toString(36).substring(2, 7)}.tmp`; + try { + await pfs.writeFile(tempPath, data, encoding); + await retryRename(tempPath, filePath); + } catch (error) { + logger.error(`[FileLock] Atomic write (async) failed for ${filePath}:`, error.message); + try { await pfs.unlink(tempPath); } catch (e) {} + throw error; + } +} diff --git a/static/favicon.ico b/static/favicon.ico index 3861e26df9aafe5df6879ca07022dafa5227acae..614153f76872622913d38307e2b2f057dc8e0f5c 100644 GIT binary patch literal 270622 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 Rs)11rjA~$1122&V{vS}fkGKE; literal 10422 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 diff --git a/static/favicon0.ico b/static/favicon0.ico new file mode 100644 index 0000000000000000000000000000000000000000..af89f1c46a5d38931865e8f230269d2e6b9d9798 GIT binary patch literal 270622 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 diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index 83ed09d16..1cefa04bb 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -58,6 +58,7 @@ export class ProviderPoolManager { 'openai-codex-oauth': 'gpt-5-codex-mini', 'openaiResponses-custom': 'gpt-4o-mini', 'forward-api': 'gpt-4o-mini', + 'grok-custom': 'grok-4.1-mini', }; constructor(providerPools, options = {}) { @@ -723,8 +724,9 @@ export class ProviderPoolManager { /** * Initializes the status for each provider in the pools. * Initially, all providers are considered healthy and have zero usage. + * @param {boolean} syncFromConfig - 是否强制从配置同步统计数据(不保留内存中的旧数据) */ - initializeProviderStatus() { + initializeProviderStatus(syncFromConfig = false) { const oldFullStatus = this.providerStatus || {}; const isColdStart = Object.keys(oldFullStatus).length === 0; this.providerStatus = {}; // Tracks health and usage for each provider instance @@ -749,13 +751,14 @@ export class ProviderPoolManager { providerConfig.isDisabled = providerConfig.isDisabled !== undefined ? providerConfig.isDisabled : false; // --- V3: 统计数据管理 --- - if (isColdStart) { - // 冷启动:清空所有统计数据 - providerConfig.lastUsed = null; - providerConfig.usageCount = 0; - providerConfig.errorCount = 0; - providerConfig.lastErrorTime = null; - providerConfig.lastErrorMessage = null; + if (isColdStart || syncFromConfig) { + // 冷启动或强制同步:使用传入配置中的统计数据 + // 如果传入配置中没有,则初始化为默认值 + providerConfig.lastUsed = providerConfig.lastUsed || null; + providerConfig.usageCount = providerConfig.usageCount || 0; + providerConfig.errorCount = providerConfig.errorCount || 0; + providerConfig.lastErrorTime = providerConfig.lastErrorTime || null; + providerConfig.lastErrorMessage = providerConfig.lastErrorMessage || null; } else if (existing) { // 热重载:从旧状态中恢复统计数据,避免被配置文件中的旧数据覆盖 providerConfig.lastUsed = existing.config.lastUsed; @@ -1740,6 +1743,9 @@ export class ProviderPoolManager { if (provider) { provider.config.errorCount = 0; provider.config.usageCount = 0; + provider.config.isHealthy = true; + provider.config.lastErrorTime = null; + provider.config.lastErrorMessage = null; provider.config._lastSelectionSeq = 0; this._log('info', `Reset provider counters: ${provider.config.uuid} for type ${providerType}`); @@ -1747,6 +1753,27 @@ export class ProviderPoolManager { } } + /** + * 重置特定类型的所有提供商健康状态 + * @param {string} providerType - 提供商类型 + */ + resetAllHealthInType(providerType) { + const pool = this.providerStatus[providerType]; + if (!pool) return; + + pool.forEach(provider => { + provider.config.isHealthy = true; + provider.config.errorCount = 0; + provider.config.lastErrorTime = null; + provider.config.lastErrorMessage = null; + provider.config.refreshCount = 0; + provider.config.needsRefresh = false; + }); + + this._log('info', `Reset all health status for type ${providerType}`); + this._debouncedSave(providerType); + } + /** * 禁用指定提供商 * @param {string} providerType - 提供商类型 diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js index 5b77d9f28..d931d33b0 100644 --- a/src/ui-modules/config-api.js +++ b/src/ui-modules/config-api.js @@ -27,7 +27,7 @@ export async function reloadConfig(providerPoolManager) { // Update provider pool manager if available if (providerPoolManager) { providerPoolManager.providerPools = newConfig.providerPools; - providerPoolManager.initializeProviderStatus(); + providerPoolManager.initializeProviderStatus(true); } // Update global CONFIG diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index f6c6ccff9..f88b62587 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -832,53 +832,57 @@ export async function handleResetProviderHealth(req, res, currentConfig, provide async function _handleResetProviderHealth(req, res, currentConfig, providerPoolManager, providerType) { try { const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - let providerPools = {}; - // Load existing pools - if (existsSync(filePath)) { - try { - const fileContent = readFileSync(filePath, 'utf-8'); - providerPools = JSON.parse(fileContent); - } catch (readError) { + let resetCount = 0; + let totalCount = 0; + + if (providerPoolManager && providerPoolManager.providerStatus[providerType]) { + // 如果管理器存在,优先使用管理器的方法直接重置内存和触发保存 + const pool = providerPoolManager.providerStatus[providerType]; + totalCount = pool.length; + + pool.forEach(ps => { + if (!ps.config.isHealthy) resetCount++; + }); + + providerPoolManager.resetAllHealthInType(providerType); + } else { + // 回退逻辑:手动操作文件 + let providerPools = {}; + if (existsSync(filePath)) { + try { + const fileContent = readFileSync(filePath, 'utf-8'); + providerPools = JSON.parse(fileContent); + } catch (readError) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } })); + return true; + } + } + + const providers = providerPools[providerType] || []; + if (providers.length === 0) { res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } })); + res.end(JSON.stringify({ error: { message: 'No providers found for this type' } })); return true; } - } - // Reset health status for all providers of this type - const providers = providerPools[providerType] || []; - - if (providers.length === 0) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'No providers found for this type' } })); - return true; - } + totalCount = providers.length; + providers.forEach(provider => { + if (!provider.isHealthy) resetCount++; + provider.isHealthy = true; + provider.errorCount = 0; + provider.refreshCount = 0; + provider.needsRefresh = false; + provider.lastErrorTime = null; + provider.lastErrorMessage = null; + }); - let resetCount = 0; - providers.forEach(provider => { - // 统计 isHealthy 从 false 变为 true 的节点数量 - if (!provider.isHealthy) { - resetCount++; - } - // 重置所有节点的状态 - provider.isHealthy = true; - provider.errorCount = 0; - provider.refreshCount = 0; - provider.needsRefresh = false; - provider.lastErrorTime = null; - }); + await atomicWriteFile(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + } - // Save to file - await atomicWriteFile(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); logger.info(`[UI API] Reset health status for ${resetCount} providers in ${providerType}`); - // Update provider pool manager if available - if (providerPoolManager) { - providerPoolManager.providerPools = providerPools; - providerPoolManager.initializeProviderStatus(); - } - // 广播更新事件 broadcastEvent('config_update', { action: 'reset_health', @@ -893,7 +897,7 @@ async function _handleResetProviderHealth(req, res, currentConfig, providerPoolM success: true, message: `Successfully reset health status for ${resetCount} providers`, resetCount, - totalCount: providers.length + totalCount })); return true; } catch (error) { @@ -1427,11 +1431,10 @@ export async function handleQuickLinkProvider(req, res, currentConfig, providerP return poolsFilePath; }); - // Update provider pool manager if available - if (providerPoolManager) { - providerPoolManager.providerPools = providerPools; - providerPoolManager.initializeProviderStatus(); - } + // Update provider pool manager if available + if (providerPoolManager) { + providerPoolManager.resetAllHealthInType(providerType); + } // Broadcast update events broadcastEvent('config_update', { diff --git a/src/utils/provider-utils.js b/src/utils/provider-utils.js index 16c1eb7ce..60bd25cfb 100644 --- a/src/utils/provider-utils.js +++ b/src/utils/provider-utils.js @@ -85,7 +85,7 @@ export const PROVIDER_MAPPINGS = [ patterns: ['configs/grok/', '/grok/'], providerType: 'grok-custom', credPathKey: 'GROK_COOKIE_TOKEN', - defaultCheckModel: 'grok-3', + defaultCheckModel: 'grok-4.1-mini', displayName: 'Grok Reverse', needsProjectId: false, urlKeys: ['GROK_BASE_URL', 'GROK_CF_CLEARANCE', 'GROK_USER_AGENT'] diff --git a/static/app/routing-examples.js b/static/app/routing-examples.js index 4caf762cd..a5fb50078 100644 --- a/static/app/routing-examples.js +++ b/static/app/routing-examples.js @@ -380,7 +380,7 @@ async function copyCurlExample(provider, options = {}) { -H "Content-Type: application/json" \\ -H "Authorization: Bearer YOUR_API_KEY" \\ -d '{ - "model": "grok-3", + "model": "grok-4.1-mini", "messages": [{"role": "user", "content": "${message}"}], "stream": true }'`; @@ -389,7 +389,7 @@ async function copyCurlExample(provider, options = {}) { -H "Content-Type: application/json" \\ -H "X-API-Key: YOUR_API_KEY" \\ -d '{ - "model": "grok-3", + "model": "grok-4.1-mini", "max_tokens": 1000, "messages": [{"role": "user", "content": "${message}"}] }'`; @@ -444,7 +444,7 @@ function renderRoutingExamples(providerConfigs) { 'openai-qwen-oauth': 'qwen3-coder-plus', 'openai-iflow': 'qwen3-max', 'openai-codex-oauth': 'gpt-5', - 'grok-custom': 'grok-3', + 'grok-custom': 'grok-4.1-mini', 'openaiResponses-custom': 'gpt-4o' }; From e7a054c2c36402ca1c4b6e09638acbedd9935e10 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Mon, 20 Apr 2026 21:07:49 +0800 Subject: [PATCH 031/135] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E8=B5=9E?= =?UTF-8?q?=E5=8A=A9=E5=95=86=E5=88=97=E8=A1=A8=E5=B9=B6=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?VisionCoder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在README、README-ZH.md和README-JA.md中,将Poixe AI移至赞助商列表首位,新增VisionCoder作为第二位赞助商,并将LingtrueAPI移至第三位。同时添加了VisionCoder的logo图片文件。 --- README-JA.md | 22 ++++++++++++++++------ README-ZH.md | 22 ++++++++++++++++------ README.md | 22 ++++++++++++++++------ static/visioncoder.png | Bin 0 -> 155590 bytes 4 files changed, 48 insertions(+), 18 deletions(-) create mode 100644 static/visioncoder.png diff --git a/README-JA.md b/README-JA.md index 21bfd1eb7..748915ec9 100644 --- a/README-JA.md +++ b/README-JA.md @@ -49,22 +49,32 @@ - - LingtrueAPI Sponsor + + Poixe AI Sponsor - LingtrueAPIによる本プロジェクトへのスポンサーに感謝します!LingtrueAPIは世界的な大規模言語モデルAPI中継プラットフォームであり、Claude opus 4.6、GPT 5.4、Gemini 3.1 proなど各種モデルのAPI呼び出しサービスを提供しています。低コスト、高安定性で世界中のAI機能に接続し、生産性を最大化することを目指しています。LingtrueAPIは本ソフトウェアユーザー向けに特別優遇を提供しています。このリンクから登録し、初回チャージ時に「LingtrueAPI」のクーポンコードを入力すると、10%オフで利用できます。 + Poixe AI は信頼性の高い LLM API サービスを提供しています。プラットフォームが提供する API エンドポイントを活用して、AI 製品をシームレスに構築できます。また、AI API リソースをプラットフォームに提供するベンダーになり、収益を得ることも可能です。AIClient-2-API 専用リンクから登録すると、初回チャージ時に $5 USD のボーナスを受け取れます。 - - Poixe AI Sponsor + + VisionCoder Sponsor - Poixe AI は信頼性の高い LLM API サービスを提供しています。プラットフォームが提供する API エンドポイントを活用して、AI 製品をシームレスに構築できます。また、AI API リソースをプラットフォームに提供するベンダーになり、収益を得ることも可能です。AIClient-2-API 専用リンクから登録すると、初回チャージ時に $5 USD のボーナスを受け取れます。 + VisionCoder による本プロジェクトへのスポンサーに感謝します!VisionCoder 開発プラットフォームは信頼性が高く効率的な API 中継サービスプロバイダーであり、Claude Code、Codex、Gemini などの主要な AI モデルへのアクセスを提供しています。開発者やチームが AI 機能をより簡単に統合し、生産性を向上させるのを支援します。VisionCoder は本ソフトウェアのユーザー向けに期間限定の Token Plan 特典を提供しています:1ヶ月の購入で1ヶ月分を無料で進呈。 + + + + + + LingtrueAPI Sponsor + + + + LingtrueAPIによる本プロジェクトへのスポンサーに感謝します!LingtrueAPIは世界的な大規模言語モデルAPI中継プラットフォームであり、Claude opus 4.6、GPT 5.4、Gemini 3.1 proなど各種モデルのAPI呼び出しサービスを提供しています。低コスト、高安定性で世界中のAI功能に接続し、生産性を最大化することを目指しています。LingtrueAPIは本ソフトウェアユーザー向けに特別優遇を提供しています。このリンクから登録し、初回チャージ時に「LingtrueAPI」のクーポンコードを入力すると、10%オフで利用できます。 diff --git a/README-ZH.md b/README-ZH.md index 458c4a282..d95b78d08 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -48,22 +48,32 @@ - - LingtrueAPI Sponsor + + Poixe AI Sponsor - 感谢 LingtrueAPI 对本项目的赞助!LingtrueAPI 是一家全球大模型API中转服务平台,提供Claude opus 4.6、GPT 5.4、Gemini 3.1 pro等多种模型API调用服务,致力于让用户以低成本、高稳定性链接全球AI能力,最大化生产效率。LingtrueAPI为本软件用户提供了特别优惠:通过此链接注册并在首次充值时输入 LingtrueAPI 优惠码即可享受 9折优惠。 + Poixe AI 提供可靠的 AI 模型接口服务,您可以使用平台提供的 LLM API 接口轻松构建 AI 产品,同时也可以成为供应商,为平台提供大模型资源以赚取收益。通过 AIClient-2-API 专属链接注册,充值额外赠送 $5 美金。 - - Poixe AI Sponsor + + VisionCoder Sponsor - Poixe AI 提供可靠的 AI 模型接口服务,您可以使用平台提供的 LLM API 接口轻松构建 AI 产品,同时也可以成为供应商,为平台提供大模型资源以赚取收益。通过 AIClient-2-API 专属链接注册,充值额外赠送 $5 美金。 + 感谢 VisionCoder 对本项目的支持。VisionCoder 开发平台 是一个可靠高效的 API 中继服务提供商,提供 Claude Code、Codex、Gemini 等主流 AI 模型,帮助开发者和团队更轻松地集成 AI 功能,提升工作效率。VisionCoder 还为我们的用户提供Token Plan 限时活动:购买 1 个月,赠送 1 个月。 + + + + + + LingtrueAPI Sponsor + + + + 感谢 LingtrueAPI 对本项目的赞助!LingtrueAPI 是一家全球大模型API中转服务平台,提供Claude opus 4.6、GPT 5.4、Gemini 3.1 pro等多种模型API调用服务,致力于让用户以低成本、高稳定性链接全球AI能力,最大化生产效率。LingtrueAPI为本软件用户提供了特别优惠:通过此链接注册并在首次充值时输入 LingtrueAPI 优惠码即可享受 9折优惠。 diff --git a/README.md b/README.md index b4324d0dc..de9f65a43 100644 --- a/README.md +++ b/README.md @@ -49,22 +49,32 @@ - - LingtrueAPI Sponsor + + Poixe AI Sponsor - Thanks to LingtrueAPI for its sponsorship of this project! LingtrueAPI is a global large-model API intermediary service platform that offers API calling services for various models such as Claude opus 4.6, GPT 5.4, and Gemini 3.1 pro. It is committed to enabling users to connect to global AI capabilities at low cost and with high stability, maximizing production efficiency. LingtrueAPI provides special discounts for users of this software: register using this link and enter the LingtrueAPI promo code when making the first recharge to enjoy a 10% discount. + Poixe AI provides reliable LLM API services. You can leverage the platform's API endpoints to seamlessly build AI-powered products. Additionally, you can become a vendor by providing AI API resources to the platform and earn revenue. Register through the exclusive AIClient-2-API referral link and receive a bonus of $5 USD on your first top-up. - - Poixe AI Sponsor + + VisionCoder Sponsor - Poixe AI provides reliable LLM API services. You can leverage the platform's API endpoints to seamlessly build AI-powered products. Additionally, you can become a vendor by providing AI API resources to the platform and earn revenue. Register through the exclusive AIClient-2-API referral link and receive a bonus of $5 USD on your first top-up. + Thanks to VisionCoder for supporting this project. VisionCoder Developer Platform is a reliable and efficient API relay service provider, offering access to mainstream AI models such as Claude Code, Codex, and Gemini. It helps developers and teams integrate AI capabilities more easily and improve productivity. VisionCoder is also offering our users a limited-time Token Plan promotion: buy 1 month and get 1 month free. + + + + + + LingtrueAPI Sponsor + + + + Thanks to LingtrueAPI for its sponsorship of this project! LingtrueAPI is a global large-model API intermediary service platform that offers API calling services for various models such as Claude opus 4.6, GPT 5.4, and Gemini 3.1 pro. It is committed to enabling users to connect to global AI capabilities at low cost and with high stability, maximizing production efficiency. LingtrueAPI provides special discounts for users of this software: register using this link and enter the LingtrueAPI promo code when making the first recharge to enjoy a 10% discount. diff --git a/static/visioncoder.png b/static/visioncoder.png new file mode 100644 index 0000000000000000000000000000000000000000..24b1760ce5afe51d7cedbac1985211c3c4ca78bf GIT binary patch literal 155590 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

hoVN*buH}60`&dlT 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

NS-own{b-@DQN*}Xzo^`N_RT-{imAg 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�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 B6q5h| literal 0 HcmV?d00001 From d2fe1152d0b06e352da95ac4e8664dd8cbc0e7d3 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Mon, 20 Apr 2026 21:12:49 +0800 Subject: [PATCH 032/135] =?UTF-8?q?docs:=20=E4=B8=BA=E8=B5=9E=E5=8A=A9?= =?UTF-8?q?=E5=95=86=E6=B4=BB=E5=8A=A8=E9=93=BE=E6=8E=A5=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E8=B6=85=E6=96=87=E6=9C=AC=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 README.md、README-ZH.md 和 README-JA.md 文件中,将“Token Plan”文本更新为可点击的超链接,指向 https://visioncoder.com,以提升用户体验和活动可访问性。 --- README-JA.md | 2 +- README-ZH.md | 2 +- README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README-JA.md b/README-JA.md index 748915ec9..6015fd8fd 100644 --- a/README-JA.md +++ b/README-JA.md @@ -64,7 +64,7 @@ - VisionCoder による本プロジェクトへのスポンサーに感謝します!VisionCoder 開発プラットフォームは信頼性が高く効率的な API 中継サービスプロバイダーであり、Claude Code、Codex、Gemini などの主要な AI モデルへのアクセスを提供しています。開発者やチームが AI 機能をより簡単に統合し、生産性を向上させるのを支援します。VisionCoder は本ソフトウェアのユーザー向けに期間限定の Token Plan 特典を提供しています:1ヶ月の購入で1ヶ月分を無料で進呈。 + VisionCoder による本プロジェクトへのスポンサーに感謝します!VisionCoder 開発プラットフォームは信頼性が高く効率的な API 中継サービスプロバイダーであり、Claude Code、Codex、Gemini などの主要な AI モデルへのアクセスを提供しています。開発者やチームが AI 機能をより簡単に統合し、生産性を向上させるのを支援します。VisionCoder は本ソフトウェアのユーザー向けに期間限定の [Token Plan](https://visioncoder.com) 特典を提供しています:1ヶ月の購入で1ヶ月分を無料で進呈。 diff --git a/README-ZH.md b/README-ZH.md index d95b78d08..2098e872f 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -63,7 +63,7 @@ - 感谢 VisionCoder 对本项目的支持。VisionCoder 开发平台 是一个可靠高效的 API 中继服务提供商,提供 Claude Code、Codex、Gemini 等主流 AI 模型,帮助开发者和团队更轻松地集成 AI 功能,提升工作效率。VisionCoder 还为我们的用户提供Token Plan 限时活动:购买 1 个月,赠送 1 个月。 + 感谢 VisionCoder 对本项目的支持。VisionCoder 开发平台 是一个可靠高效的 API 中继服务提供商,提供 Claude Code、Codex、Gemini 等主流 AI 模型,帮助开发者和团队更轻松地集成 AI 功能,提升工作效率。VisionCoder 还为我们的用户提供 [Token Plan](https://visioncoder.com) 限时活动:购买 1 个月,赠送 1 个月。 diff --git a/README.md b/README.md index de9f65a43..b105914a5 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ - Thanks to VisionCoder for supporting this project. VisionCoder Developer Platform is a reliable and efficient API relay service provider, offering access to mainstream AI models such as Claude Code, Codex, and Gemini. It helps developers and teams integrate AI capabilities more easily and improve productivity. VisionCoder is also offering our users a limited-time Token Plan promotion: buy 1 month and get 1 month free. + Thanks to VisionCoder for supporting this project. VisionCoder Developer Platform is a reliable and efficient API relay service provider, offering access to mainstream AI models such as Claude Code, Codex, and Gemini. It helps developers and teams integrate AI capabilities more easily and improve productivity. VisionCoder is also offering our users a limited-time [Token Plan](https://visioncoder.com) promotion: buy 1 month and get 1 month free. From bac153dd3414ee1bdd3ce0f70712033cb829e0a5 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Mon, 20 Apr 2026 21:15:23 +0800 Subject: [PATCH 033/135] =?UTF-8?q?docs:=20=E5=B0=86=E8=B5=9E=E5=8A=A9?= =?UTF-8?q?=E5=95=86=E9=93=BE=E6=8E=A5=E4=BB=8EMarkdown=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=B8=BAHTML=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使 Token Plan 链接在 README 文件中更醒目,以提升赞助商推广效果。 --- README-JA.md | 2 +- README-ZH.md | 2 +- README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README-JA.md b/README-JA.md index 6015fd8fd..2e4a87c3f 100644 --- a/README-JA.md +++ b/README-JA.md @@ -64,7 +64,7 @@ - VisionCoder による本プロジェクトへのスポンサーに感謝します!VisionCoder 開発プラットフォームは信頼性が高く効率的な API 中継サービスプロバイダーであり、Claude Code、Codex、Gemini などの主要な AI モデルへのアクセスを提供しています。開発者やチームが AI 機能をより簡単に統合し、生産性を向上させるのを支援します。VisionCoder は本ソフトウェアのユーザー向けに期間限定の [Token Plan](https://visioncoder.com) 特典を提供しています:1ヶ月の購入で1ヶ月分を無料で進呈。 + VisionCoder による本プロジェクトへのスポンサーに感謝します!VisionCoder 開発プラットフォームは信頼性が高く効率的な API 中継サービスプロバイダーであり、Claude Code、Codex、Gemini などの主要な AI モデルへのアクセスを提供しています。開発者やチームが AI 機能をより簡単に統合し、生産性を向上させるのを支援します。VisionCoder は本ソフトウェアのユーザー向けに期間限定の Token Plan 特典を提供しています:1ヶ月の購入で1ヶ月分を無料で進呈。 diff --git a/README-ZH.md b/README-ZH.md index 2098e872f..a243565a9 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -63,7 +63,7 @@ - 感谢 VisionCoder 对本项目的支持。VisionCoder 开发平台 是一个可靠高效的 API 中继服务提供商,提供 Claude Code、Codex、Gemini 等主流 AI 模型,帮助开发者和团队更轻松地集成 AI 功能,提升工作效率。VisionCoder 还为我们的用户提供 [Token Plan](https://visioncoder.com) 限时活动:购买 1 个月,赠送 1 个月。 + 感谢 VisionCoder 对本项目的支持。VisionCoder 开发平台 是一个可靠高效的 API 中继服务提供商,提供 Claude Code、Codex、Gemini 等主流 AI 模型,帮助开发者和团队更轻松地集成 AI 功能,提升工作效率。VisionCoder 还为我们的用户提供 Token Plan 限时活动:购买 1 个月,赠送 1 个月。 diff --git a/README.md b/README.md index b105914a5..4400247e8 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ - Thanks to VisionCoder for supporting this project. VisionCoder Developer Platform is a reliable and efficient API relay service provider, offering access to mainstream AI models such as Claude Code, Codex, and Gemini. It helps developers and teams integrate AI capabilities more easily and improve productivity. VisionCoder is also offering our users a limited-time [Token Plan](https://visioncoder.com) promotion: buy 1 month and get 1 month free. + Thanks to VisionCoder for supporting this project. VisionCoder Developer Platform is a reliable and efficient API relay service provider, offering access to mainstream AI models such as Claude Code, Codex, and Gemini. It helps developers and teams integrate AI capabilities more easily and improve productivity. VisionCoder is also offering our users a limited-time Token Plan promotion: buy 1 month and get 1 month free. From f7f93d85d866498f9e3f4f7434a9b85df4e652cc Mon Sep 17 00:00:00 2001 From: hex2077 Date: Mon, 20 Apr 2026 21:16:54 +0800 Subject: [PATCH 034/135] =?UTF-8?q?docs:=20=E8=B0=83=E6=95=B4=E8=B5=9E?= =?UTF-8?q?=E5=8A=A9=E5=95=86=E6=8E=A8=E5=B9=BF=E6=96=87=E6=A1=88=E7=9A=84?= =?UTF-8?q?HTML=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将“Token Plan”链接的标签移除,并将加粗效果应用于其后的促销描述文本,使视觉重点更明确。 --- README-JA.md | 2 +- README-ZH.md | 2 +- README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README-JA.md b/README-JA.md index 2e4a87c3f..4e8d304fe 100644 --- a/README-JA.md +++ b/README-JA.md @@ -64,7 +64,7 @@ - VisionCoder による本プロジェクトへのスポンサーに感謝します!VisionCoder 開発プラットフォームは信頼性が高く効率的な API 中継サービスプロバイダーであり、Claude Code、Codex、Gemini などの主要な AI モデルへのアクセスを提供しています。開発者やチームが AI 機能をより簡単に統合し、生産性を向上させるのを支援します。VisionCoder は本ソフトウェアのユーザー向けに期間限定の Token Plan 特典を提供しています:1ヶ月の購入で1ヶ月分を無料で進呈。 + VisionCoder による本プロジェクトへのスポンサーに感謝します!VisionCoder 開発プラットフォームは信頼性が高く効率的な API 中継サービスプロバイダーであり、Claude Code、Codex、Gemini などの主要な AI モデルへのアクセスを提供しています。開発者やチームが AI 機能をより簡単に統合し、生産性を向上させるのを支援します。VisionCoder は本ソフトウェアのユーザー向けに期間限定の Token Plan 特典を提供しています:1ヶ月の購入で1ヶ月分を無料で進呈。 diff --git a/README-ZH.md b/README-ZH.md index a243565a9..748443c54 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -63,7 +63,7 @@ - 感谢 VisionCoder 对本项目的支持。VisionCoder 开发平台 是一个可靠高效的 API 中继服务提供商,提供 Claude Code、Codex、Gemini 等主流 AI 模型,帮助开发者和团队更轻松地集成 AI 功能,提升工作效率。VisionCoder 还为我们的用户提供 Token Plan 限时活动:购买 1 个月,赠送 1 个月。 + 感谢 VisionCoder 对本项目的支持。VisionCoder 开发平台 是一个可靠高效的 API 中继服务提供商,提供 Claude Code、Codex、Gemini 等主流 AI 模型,帮助开发者和团队更轻松地集成 AI 功能,提升工作效率。VisionCoder 还为我们的用户提供 Token Plan 限时活动:购买 1 个月,赠送 1 个月。 diff --git a/README.md b/README.md index 4400247e8..335565513 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ - Thanks to VisionCoder for supporting this project. VisionCoder Developer Platform is a reliable and efficient API relay service provider, offering access to mainstream AI models such as Claude Code, Codex, and Gemini. It helps developers and teams integrate AI capabilities more easily and improve productivity. VisionCoder is also offering our users a limited-time Token Plan promotion: buy 1 month and get 1 month free. + Thanks to VisionCoder for supporting this project. VisionCoder Developer Platform is a reliable and efficient API relay service provider, offering access to mainstream AI models such as Claude Code, Codex, and Gemini. It helps developers and teams integrate AI capabilities more easily and improve productivity. VisionCoder is also offering our users a limited-time Token Plan promotion: buy 1 month and get 1 month free. From 5ba375441fad2f7a3442b5e177c46b61dd23d9ab Mon Sep 17 00:00:00 2001 From: hex2077 Date: Mon, 20 Apr 2026 23:49:33 +0800 Subject: [PATCH 035/135] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=8F=90?= =?UTF-8?q?=E4=BE=9B=E5=95=86=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6=E6=A3=80?= =?UTF-8?q?=E6=B5=8B=E4=B8=8E=E5=81=A5=E5=BA=B7=E7=8A=B6=E6=80=81=E9=87=8D?= =?UTF-8?q?=E7=BD=AE=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新提供商模型列表,移除已废弃的模型 - 修复配置文件扫描中的提供商检测逻辑,支持更多路径模式 - 改进健康状态重置功能,确保文件与内存状态同步 - 优化快速链接功能,正确处理提供商池更新 - 修复代码核心服务中的消息字段处理和推理努力映射 --- VERSION | 2 +- src/providers/openai/codex-core.js | 4 +- src/providers/provider-models.js | 8 --- src/ui-modules/config-scanner.js | 47 ++++++++++------ src/ui-modules/provider-api.js | 86 +++++++++++++++++++++--------- src/utils/provider-utils.js | 11 ++-- 6 files changed, 102 insertions(+), 56 deletions(-) diff --git a/VERSION b/VERSION index 3b1fc7950..07d875c2d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.15.1 +2.15.2 diff --git a/src/providers/openai/codex-core.js b/src/providers/openai/codex-core.js index 88970be58..d2aafc83a 100644 --- a/src/providers/openai/codex-core.js +++ b/src/providers/openai/codex-core.js @@ -404,12 +404,14 @@ export class CodexApiService { service_tier: cleanedBody.service_tier || defaultServiceTier, reasoning: { ...cleanedBody.reasoning, - effort: isFastModel ? defaultReasoningEffort : cleanedBody.reasoning?.effort + effort: isFastModel ? defaultReasoningEffort : (cleanedBody.reasoning?.effort === 'minimal' ? 'none' : (cleanedBody.reasoning?.effort || defaultReasoningEffort)) }, stream, prompt_cache_key: cache.id }; + delete result.messages; + if (result.service_tier !== 'priority') { delete result.service_tier; } diff --git a/src/providers/provider-models.js b/src/providers/provider-models.js index 8667f5add..be56f1278 100644 --- a/src/providers/provider-models.js +++ b/src/providers/provider-models.js @@ -113,15 +113,7 @@ export const PROVIDER_MODELS = { 'minimax-m2.5', ], 'openai-codex-oauth': [ - 'gpt-5', - 'gpt-5-codex', - 'gpt-5-codex-mini', - 'gpt-5.1', - 'gpt-5.1-codex', - 'gpt-5.1-codex-mini', - 'gpt-5.1-codex-max', 'gpt-5.2', - 'gpt-5.2-codex', 'gpt-5.3-codex', 'gpt-5.3-codex-spark', 'gpt-5.4', diff --git a/src/ui-modules/config-scanner.js b/src/ui-modules/config-scanner.js index 4c10829d6..1e34b8fdf 100644 --- a/src/ui-modules/config-scanner.js +++ b/src/ui-modules/config-scanner.js @@ -2,7 +2,7 @@ import { existsSync } from 'fs'; import logger from '../utils/logger.js'; import { promises as fs } from 'fs'; import path from 'path'; -import { addToUsedPaths, isPathUsed, pathsEqual } from '../utils/provider-utils.js'; +import { addToUsedPaths, isPathUsed, pathsEqual, detectProviderFromPath } from '../utils/provider-utils.js'; /** * 扫描和分析配置文件 @@ -53,7 +53,7 @@ export async function scanConfigFiles(currentConfig, providerPoolManager) { try { // 扫描configs目录下的所有子目录和文件 - const configsFiles = await scanOAuthDirectory(configsPath, usedPaths, currentConfig); + const configsFiles = await scanOAuthDirectory(configsPath, usedPaths, currentConfig, providerPools); configFiles.push(...configsFiles); } catch (error) { logger.warn(`[Config Scanner] Failed to scan configs directory:`, error.message); @@ -68,7 +68,7 @@ export async function scanConfigFiles(currentConfig, providerPoolManager) { * @param {Set} usedPaths - Set of paths currently in use * @returns {Promise} OAuth file information object */ -export async function analyzeOAuthFile(filePath, usedPaths, currentConfig) { +export async function analyzeOAuthFile(filePath, usedPaths, currentConfig, providerPools) { try { const stats = await fs.stat(filePath); const ext = path.extname(filePath).toLowerCase(); @@ -83,16 +83,28 @@ export async function analyzeOAuthFile(filePath, usedPaths, currentConfig) { let oauthProvider = 'unknown'; let expiresAt = null; let expiresAtTS = null; - let usageInfo = getFileUsageInfo(relativePath, filename, usedPaths, currentConfig); + let usageInfo = getFileUsageInfo(relativePath, filename, usedPaths, currentConfig, providerPools); - // 从路径预检测提供商 + // 从路径检测提供商 const normalizedPath = relativePath.replace(/\\/g, '/').toLowerCase(); - if (normalizedPath.includes('/kiro/')) oauthProvider = 'kiro'; - else if (normalizedPath.includes('/gemini/')) oauthProvider = 'gemini'; - else if (normalizedPath.includes('/qwen/')) oauthProvider = 'qwen'; - else if (normalizedPath.includes('/antigravity/')) oauthProvider = 'antigravity'; - else if (normalizedPath.includes('/codex/')) oauthProvider = 'codex'; - else if (normalizedPath.includes('/iflow/')) oauthProvider = 'iflow'; + const providerMapping = detectProviderFromPath(normalizedPath); + if (providerMapping) { + const type = providerMapping.providerType; + if (type.includes('kiro')) oauthProvider = 'kiro'; + else if (type.includes('gemini')) oauthProvider = 'gemini'; + else if (type.includes('qwen')) oauthProvider = 'qwen'; + else if (type.includes('antigravity')) oauthProvider = 'antigravity'; + else if (type.includes('codex')) oauthProvider = 'codex'; + else if (type.includes('iflow')) oauthProvider = 'iflow'; + } else { + // 兜底逻辑 + if (normalizedPath.includes('/kiro/') || normalizedPath.includes('kiro-auth-token')) oauthProvider = 'kiro'; + else if (normalizedPath.includes('/gemini/') || normalizedPath.includes('/.gemini/')) oauthProvider = 'gemini'; + else if (normalizedPath.includes('/qwen/')) oauthProvider = 'qwen'; + else if (normalizedPath.includes('/antigravity/') || normalizedPath.includes('/.antigravity/')) oauthProvider = 'antigravity'; + else if (normalizedPath.includes('/codex/') || normalizedPath.includes('/.codex/')) oauthProvider = 'codex'; + else if (normalizedPath.includes('/iflow/')) oauthProvider = 'iflow'; + } try { content = await fs.readFile(filePath, 'utf8'); @@ -254,7 +266,7 @@ export async function analyzeOAuthFile(filePath, usedPaths, currentConfig) { * @param {Object} currentConfig - Current configuration * @returns {Object} Usage information object */ -function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) { +function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig, providerPools) { const usageInfo = { isUsed: false, usageType: null, @@ -326,9 +338,10 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) { } // 检查提供商池中的使用情况 - if (currentConfig.providerPools) { + const poolsToUse = providerPools || currentConfig.providerPools; + if (poolsToUse) { // 使用 flatMap 将双重循环优化为单层循环 O(n) - const allProviders = Object.entries(currentConfig.providerPools).flatMap( + const allProviders = Object.entries(poolsToUse).flatMap( ([providerType, providers]) => providers.map((provider, index) => ({ provider, providerType, index })) ); @@ -454,7 +467,7 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) { * @param {Object} currentConfig - Current configuration * @returns {Promise} Array of OAuth configuration file objects */ -async function scanOAuthDirectory(dirPath, usedPaths, currentConfig) { +async function scanOAuthDirectory(dirPath, usedPaths, currentConfig, providerPools) { const oauthFiles = []; try { @@ -467,7 +480,7 @@ async function scanOAuthDirectory(dirPath, usedPaths, currentConfig) { const ext = path.extname(file.name).toLowerCase(); // 只关注OAuth相关的文件类型 if (['.json', '.oauth', '.creds', '.key', '.pem', '.txt'].includes(ext)) { - const fileInfo = await analyzeOAuthFile(fullPath, usedPaths, currentConfig); + const fileInfo = await analyzeOAuthFile(fullPath, usedPaths, currentConfig, providerPools); if (fileInfo) { oauthFiles.push(fileInfo); } @@ -477,7 +490,7 @@ async function scanOAuthDirectory(dirPath, usedPaths, currentConfig) { const relativePath = path.relative(process.cwd(), fullPath); // 最大深度4层,以支持 configs/kiro/{subfolder}/file.json 这样的结构 if (relativePath.split(path.sep).length < 4) { - const subFiles = await scanOAuthDirectory(fullPath, usedPaths, currentConfig); + const subFiles = await scanOAuthDirectory(fullPath, usedPaths, currentConfig, providerPools); oauthFiles.push(...subFiles); } } diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index f88b62587..6a8c422e2 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -835,31 +835,45 @@ async function _handleResetProviderHealth(req, res, currentConfig, providerPoolM let resetCount = 0; let totalCount = 0; + let providerPools = {}; + // 1. 首先加载完整的提供商池数据 + if (existsSync(filePath)) { + try { + const fileContent = readFileSync(filePath, 'utf-8'); + providerPools = JSON.parse(fileContent); + } catch (readError) { + logger.warn('[UI API] Failed to read provider pools during reset:', readError.message); + // 如果读取失败且管理器也不存在,才返回错误 + if (!providerPoolManager) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Failed to read provider pools' } })); + return true; + } + } + } + + // 2. 执行重置逻辑 if (providerPoolManager && providerPoolManager.providerStatus[providerType]) { - // 如果管理器存在,优先使用管理器的方法直接重置内存和触发保存 + // 如果管理器存在,优先使用管理器的方法 const pool = providerPoolManager.providerStatus[providerType]; totalCount = pool.length; pool.forEach(ps => { - if (!ps.config.isHealthy) resetCount++; + if (!ps.config.isHealthy || ps.config.needsRefresh || (ps.config.errorCount && ps.config.errorCount > 0)) { + resetCount++; + } }); + // 重置内存状态 providerPoolManager.resetAllHealthInType(providerType); - } else { - // 回退逻辑:手动操作文件 - let providerPools = {}; - if (existsSync(filePath)) { - try { - const fileContent = readFileSync(filePath, 'utf-8'); - providerPools = JSON.parse(fileContent); - } catch (readError) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } })); - return true; - } + + // 从管理器获取最新的完整的池数据用于持久化 + if (providerPoolManager.providerPools) { + providerPools = providerPoolManager.providerPools; } - + } else { + // 如果管理器中没有,则只重置文件中的数据 const providers = providerPools[providerType] || []; if (providers.length === 0) { res.writeHead(404, { 'Content-Type': 'application/json' }); @@ -869,7 +883,9 @@ async function _handleResetProviderHealth(req, res, currentConfig, providerPoolM totalCount = providers.length; providers.forEach(provider => { - if (!provider.isHealthy) resetCount++; + if (!provider.isHealthy || provider.needsRefresh || (provider.errorCount && provider.errorCount > 0)) { + resetCount++; + } provider.isHealthy = true; provider.errorCount = 0; provider.refreshCount = 0; @@ -877,11 +893,17 @@ async function _handleResetProviderHealth(req, res, currentConfig, providerPoolM provider.lastErrorTime = null; provider.lastErrorMessage = null; }); + } - await atomicWriteFile(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + // 3. 立即保存到文件,不依赖管理器的防抖保存,确保“重置”操作的即时性 + await atomicWriteFile(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + + // 4. 同步更新 currentConfig 引用 + if (currentConfig) { + currentConfig.providerPools = providerPools; } - logger.info(`[UI API] Reset health status for ${resetCount} providers in ${providerType}`); + logger.info(`[UI API] Reset health status for type ${providerType}: ${resetCount}/${totalCount} nodes reset`); // 广播更新事件 broadcastEvent('config_update', { @@ -889,6 +911,7 @@ async function _handleResetProviderHealth(req, res, currentConfig, providerPoolM filePath: filePath, providerType, resetCount, + totalCount, timestamp: new Date().toISOString() }); @@ -896,11 +919,13 @@ async function _handleResetProviderHealth(req, res, currentConfig, providerPoolM res.end(JSON.stringify({ success: true, message: `Successfully reset health status for ${resetCount} providers`, - resetCount, - totalCount + resetCount: resetCount, + totalCount: totalCount, + providerType: providerType })); return true; } catch (error) { + logger.error('[UI API] Reset health status failed:', error); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { message: error.message } })); return true; @@ -1373,7 +1398,7 @@ export async function handleQuickLinkProvider(req, res, currentConfig, providerP continue; } - const { providerType, credPathKey, defaultCheckModel, displayName } = providerMapping; + const { providerType, credPathKey, defaultCheckModel, displayName, urlKeys } = providerMapping; // Ensure provider type array exists if (!providerPools[providerType]) { @@ -1406,7 +1431,8 @@ export async function handleQuickLinkProvider(req, res, currentConfig, providerP credPathKey, credPath: formatSystemPath(currentFilePath), defaultCheckModel, - needsProjectId: providerMapping.needsProjectId + needsProjectId: providerMapping.needsProjectId, + urlKeys: urlKeys }); providerPools[providerType].push(newProvider); @@ -1433,7 +1459,19 @@ export async function handleQuickLinkProvider(req, res, currentConfig, providerP // Update provider pool manager if available if (providerPoolManager) { - providerPoolManager.resetAllHealthInType(providerType); + // 重要:更新管理器的内存池数据,确保后续扫描能立即看到变化 + providerPoolManager.providerPools = providerPools; + providerPoolManager.initializeProviderStatus(true); + + const uniqueTypes = [...new Set(linkedProviders.map(lp => lp.providerType))]; + for (const type of uniqueTypes) { + providerPoolManager.resetAllHealthInType(type); + } + } + + // 更新当前配置引用 + if (currentConfig) { + currentConfig.providerPools = providerPools; } // Broadcast update events @@ -1457,7 +1495,7 @@ export async function handleQuickLinkProvider(req, res, currentConfig, providerP const failCount = results.filter(r => !r.success).length; const message = successCount > 0 ? `Successfully linked ${successCount} config file(s)${failCount > 0 ? `, ${failCount} failed` : ''}` - : `Failed to link all ${failCount} config file(s)`; + : `Failed to link all ${failCount} config file(s)${failCount === 1 && results[0].error ? `: ${results[0].error}` : ''}`; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ diff --git a/src/utils/provider-utils.js b/src/utils/provider-utils.js index 60bd25cfb..de2852ce1 100644 --- a/src/utils/provider-utils.js +++ b/src/utils/provider-utils.js @@ -15,7 +15,7 @@ export const PROVIDER_MAPPINGS = [ { // Kiro OAuth 配置 dirName: 'kiro', - patterns: ['configs/kiro/', '/kiro/'], + patterns: ['configs/kiro/', '/kiro/', 'kiro-auth-token'], providerType: 'claude-kiro-oauth', credPathKey: 'KIRO_OAUTH_CREDS_FILE_PATH', defaultCheckModel: 'claude-haiku-4-5', @@ -26,7 +26,7 @@ export const PROVIDER_MAPPINGS = [ { // Gemini CLI OAuth 配置 dirName: 'gemini', - patterns: ['configs/gemini/', '/gemini/', 'configs/gemini-cli/'], + patterns: ['configs/gemini/', '/gemini/', '/.gemini/', 'configs/gemini-cli/'], providerType: 'gemini-cli-oauth', credPathKey: 'GEMINI_OAUTH_CREDS_FILE_PATH', defaultCheckModel: 'gemini-2.5-flash', @@ -49,7 +49,7 @@ export const PROVIDER_MAPPINGS = [ { // Antigravity OAuth 配置 dirName: 'antigravity', - patterns: ['configs/antigravity/', '/antigravity/'], + patterns: ['configs/antigravity/', '/antigravity/', '/.antigravity/'], providerType: 'gemini-antigravity', credPathKey: 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH', defaultCheckModel: 'gemini-2.5-computer-use-preview-10-2025', @@ -71,7 +71,7 @@ export const PROVIDER_MAPPINGS = [ { // Codex OAuth 配置 dirName: 'codex', - patterns: ['configs/codex/', '/codex/'], + patterns: ['configs/codex/', '/codex/', '/.codex/'], providerType: 'openai-codex-oauth', credPathKey: 'CODEX_OAUTH_CREDS_FILE_PATH', defaultCheckModel: 'gpt-5.2-codex', @@ -269,7 +269,8 @@ export function detectProviderFromPath(normalizedPath) { credPathKey: mapping.credPathKey, defaultCheckModel: mapping.defaultCheckModel, displayName: mapping.displayName, - needsProjectId: mapping.needsProjectId + needsProjectId: mapping.needsProjectId, + urlKeys: mapping.urlKeys }; } } From 6c1b85e3086c96078b1e9f2d7f3063ddde5bc38b Mon Sep 17 00:00:00 2001 From: hex2077 Date: Tue, 21 Apr 2026 12:48:22 +0800 Subject: [PATCH 036/135] =?UTF-8?q?fix:=20=E4=BB=85=E5=AF=B9401=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E8=A7=A6=E5=8F=91=E5=87=AD=E8=AF=81=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除对400错误状态的凭证刷新处理,避免误判正常请求错误 - 更新日志信息以准确反映仅针对401未授权的情况 - 同步更新版本号至2.15.2.1 --- VERSION | 2 +- src/providers/gemini/antigravity-core.js | 6 +++--- src/providers/gemini/gemini-core.js | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/VERSION b/VERSION index 07d875c2d..a14a5560b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.15.2 +2.15.2.1 diff --git a/src/providers/gemini/antigravity-core.js b/src/providers/gemini/antigravity-core.js index 905a344e4..db8f0f147 100644 --- a/src/providers/gemini/antigravity-core.js +++ b/src/providers/gemini/antigravity-core.js @@ -1110,13 +1110,13 @@ export class AntigravityApiService { logger.error(`[Antigravity API] Error calling (Status: ${status}, Code: ${errorCode}):`, error.message); - if ((status === 400 || status === 401) && !isRetry) { - logger.info('[Antigravity API] Received 401/400. Triggering background refresh via PoolManager...'); + if ((status === 401) && !isRetry) { + logger.info('[Antigravity API] Received 401 Unauthorized. Triggering background refresh via PoolManager...'); // 标记当前凭证为不健康(会自动进入刷新队列) const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { - logger.info(`[Antigravity] Marking credential ${this.uuid} as needs refresh. Reason: 401/400 Unauthorized`); + logger.info(`[Antigravity] Marking credential ${this.uuid} as needs refresh. Reason: 401 Unauthorized`); poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.ANTIGRAVITY, { uuid: this.uuid }); diff --git a/src/providers/gemini/gemini-core.js b/src/providers/gemini/gemini-core.js index c57fd49cc..c7de50b37 100644 --- a/src/providers/gemini/gemini-core.js +++ b/src/providers/gemini/gemini-core.js @@ -598,13 +598,13 @@ export class GeminiApiService { logger.error(`[Gemini API] Error calling (Status: ${status}, Code: ${errorCode}):`, errorMessage); // Handle 401 (Unauthorized) - refresh auth and retry once - if ((status === 400 || status === 401) && !isRetry) { - logger.info('[Gemini API] Received 401/400. Triggering background refresh via PoolManager...'); + if ((status === 401) && !isRetry) { + logger.info('[Gemini API] Received 401 Unauthorized. Triggering background refresh via PoolManager...'); // 标记当前凭证为不健康(会自动进入刷新队列) const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { - logger.info(`[Gemini] Marking credential ${this.uuid} as needs refresh. Reason: 401/400 Unauthorized`); + logger.info(`[Gemini] Marking credential ${this.uuid} as needs refresh. Reason: 401 Unauthorized`); poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.GEMINI_CLI, { uuid: this.uuid }); From d3dfc373612d64db24fae8344559e783de7ca1b0 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Tue, 21 Apr 2026 15:21:37 +0800 Subject: [PATCH 037/135] =?UTF-8?q?fix(provider):=20=E4=BB=85=E5=AF=B9401?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E8=A7=A6=E5=8F=91=E5=87=AD=E8=AF=81=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E5=B9=B6=E4=BC=98=E5=8C=96=E9=85=8D=E7=BD=AE=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将流式请求中触发凭证刷新的条件从 "400或401" 修正为仅 "401" 错误 - 同步配置时,主动使现有服务适配器失效以确保代理等设置即时生效 - 区分冷启动与配置同步场景,避免统计数据被意外重置 - 更新项目版本至 2.15.2.2 --- VERSION | 2 +- src/providers/gemini/antigravity-core.js | 6 +++--- src/providers/gemini/gemini-core.js | 6 +++--- src/providers/provider-pool-manager.js | 22 +++++++++++++++++++--- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/VERSION b/VERSION index a14a5560b..e4acdc478 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.15.2.1 +2.15.2.2 diff --git a/src/providers/gemini/antigravity-core.js b/src/providers/gemini/antigravity-core.js index db8f0f147..4874aaf4c 100644 --- a/src/providers/gemini/antigravity-core.js +++ b/src/providers/gemini/antigravity-core.js @@ -1213,13 +1213,13 @@ export class AntigravityApiService { logger.error(`[Antigravity API] Error during stream (Status: ${status}, Code: ${errorCode}):`, error.message); - if ((status === 400 || status === 401) && !isRetry) { - logger.info('[Antigravity API] Received 401/400 during stream. Triggering background refresh via PoolManager...'); + if ((status === 401) && !isRetry) { + logger.info('[Antigravity API] Received 401 Unauthorized during stream. Triggering background refresh via PoolManager...'); // 标记当前凭证为不健康(会自动进入刷新队列) const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { - logger.info(`[Antigravity] Marking credential ${this.uuid} as needs refresh. Reason: 401/400 Unauthorized in stream`); + logger.info(`[Antigravity] Marking credential ${this.uuid} as needs refresh. Reason: 401 Unauthorized in stream`); poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.ANTIGRAVITY, { uuid: this.uuid }); diff --git a/src/providers/gemini/gemini-core.js b/src/providers/gemini/gemini-core.js index c7de50b37..4c9382915 100644 --- a/src/providers/gemini/gemini-core.js +++ b/src/providers/gemini/gemini-core.js @@ -681,13 +681,13 @@ export class GeminiApiService { logger.error(`[Gemini API] Error during stream (Status: ${status}, Code: ${errorCode}):`, errorMessage); // Handle 401 (Unauthorized) - refresh auth and retry once - if ((status === 400 || status === 401) && !isRetry) { - logger.info('[Gemini API] Received 401/400 during stream. Triggering background refresh via PoolManager...'); + if ((status === 401) && !isRetry) { + logger.info('[Gemini API] Received 401 Unauthorized during stream. Triggering background refresh via PoolManager...'); // 标记当前凭证为不健康(会自动进入刷新队列) const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { - logger.info(`[Gemini] Marking credential ${this.uuid} as needs refresh. Reason: 401/400 Unauthorized in stream`); + logger.info(`[Gemini] Marking credential ${this.uuid} as needs refresh. Reason: 401 Unauthorized in stream`); poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.GEMINI_CLI, { uuid: this.uuid }); diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index 1cefa04bb..0fb7f1c91 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -741,6 +741,16 @@ export class ProviderPoolManager { const pool = this.providerPools[providerType]; + // 如果是同步配置,主动使该类型下所有已有的服务适配器失效,确保代理等设置能即时生效 + if (syncFromConfig) { + this._log('info', `Syncing config for type ${providerType}, invalidating existing service adapters to apply new proxy settings.`); + pool.forEach(config => { + if (config.uuid) { + invalidateServiceAdapter(providerType, config.uuid); + } + }); + } + pool.forEach((providerConfig) => { try { // 尝试从旧状态中恢复活跃请求计数和队列,避免重载配置时重置并发限制 @@ -751,9 +761,15 @@ export class ProviderPoolManager { providerConfig.isDisabled = providerConfig.isDisabled !== undefined ? providerConfig.isDisabled : false; // --- V3: 统计数据管理 --- - if (isColdStart || syncFromConfig) { - // 冷启动或强制同步:使用传入配置中的统计数据 - // 如果传入配置中没有,则初始化为默认值 + if (isColdStart && !syncFromConfig) { + // 冷启动:清空所有统计数据,确保重启后计数重置 + providerConfig.lastUsed = null; + providerConfig.usageCount = 0; + providerConfig.errorCount = 0; + providerConfig.lastErrorTime = null; + providerConfig.lastErrorMessage = null; + } else if (syncFromConfig) { + // 强制同步:从配置中恢复统计数据 providerConfig.lastUsed = providerConfig.lastUsed || null; providerConfig.usageCount = providerConfig.usageCount || 0; providerConfig.errorCount = providerConfig.errorCount || 0; From a86256cf5e6aaea09ee2277f061f5080c4b15524 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Tue, 21 Apr 2026 15:30:09 +0800 Subject: [PATCH 038/135] =?UTF-8?q?feat(i18n):=20=E6=96=B0=E5=A2=9E=20OAut?= =?UTF-8?q?h=20=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86=E5=8F=8A=E5=88=86?= =?UTF-8?q?=E7=BB=84=E5=9F=BA=E7=A1=80=E7=B1=BB=E5=9E=8B=E7=BF=BB=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为 OAuth 流程添加错误状态 'oauth.error.process' 的翻译 - 在分组管理界面新增 'providers.addGroup.baseType' 字段翻译 --- VERSION | 2 +- static/app/i18n.js | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index e4acdc478..6480dd5ed 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.15.2.2 +2.15.3 diff --git a/static/app/i18n.js b/static/app/i18n.js index 5fac73d7c..4971b507f 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -127,6 +127,7 @@ const translations = { 'oauth.manual.placeholder': '粘贴回调 URL (包含 code=...)', 'oauth.manual.submit': '提交', 'oauth.success.msg': '授权链接已复制到剪贴板', + 'oauth.error.process': '授权回调处理失败', 'oauth.window.blocked': '授权窗口被浏览器拦截,请允许弹出窗口', 'oauth.window.opened': '已打开授权窗口,请在窗口中完成操作', 'oauth.processing': '正在完成授权...', @@ -476,6 +477,7 @@ const translations = { 'providers.addGroup': '新的分组', 'providers.addGroup.title': '添加新分组', 'providers.addGroup.success': '分组创建成功,请添加账号', + 'providers.addGroup.baseType': '基础类型', 'providers.addGroup.error': '创建失败', 'providers.addGroup.suffix': '分组名称 (后缀)', 'providers.addGroup.suffixPlaceholder': '例如: qwen, glm, minimax', @@ -1081,6 +1083,7 @@ const translations = { 'oauth.manual.placeholder': 'Paste callback URL (contains code=...)', 'oauth.manual.submit': 'Submit', 'oauth.success.msg': 'Authorization link copied to clipboard', + 'oauth.error.process': 'Failed to process authorization callback', 'oauth.window.blocked': 'Auth window was blocked by the browser, please allow pop-ups', 'oauth.window.opened': 'Auth window opened, please complete the process there', 'oauth.processing': 'Completing authorization...', From 38c77163b4d1c0663f1f6b3ddc1d1571dc17abf9 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Tue, 21 Apr 2026 23:39:11 +0800 Subject: [PATCH 039/135] =?UTF-8?q?fix(provider):=20=E6=94=B9=E8=BF=9B?= =?UTF-8?q?=E4=BB=A4=E7=89=8C=E5=88=B7=E6=96=B0=E5=A4=B1=E8=B4=A5=E5=A4=84?= =?UTF-8?q?=E7=90=86=E5=92=8C=E8=87=AA=E5=AE=9A=E4=B9=89=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E8=80=85=E9=85=8D=E7=BD=AE=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复令牌刷新失败时节点被立即标记为不健康的逻辑,改为在达到最大重试次数(5次)后才标记。刷新失败后重置 needsRefresh 为 false,允许节点回到池中继续服务,避免因瞬时故障导致节点不可用。 同时增强配置管理器中提供者名称的匹配逻辑,支持前缀匹配(如 openai-custom-1),便于用户配置自定义分组。 --- VERSION | 2 +- src/core/config-manager.js | 23 ++++++++++++++---- src/providers/provider-pool-manager.js | 33 ++++++++++++++++++++++++-- 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/VERSION b/VERSION index 6480dd5ed..ded6434f4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.15.3 +2.15.3.1 diff --git a/src/core/config-manager.js b/src/core/config-manager.js index 7d6d47571..e1269ebe2 100644 --- a/src/core/config-manager.js +++ b/src/core/config-manager.js @@ -21,14 +21,29 @@ function normalizeConfiguredProviders(config) { if (!trimmed) { return; } + + // 1. 优先尝试精确匹配基础类型 const matched = ALL_MODEL_PROVIDERS.find((provider) => provider.toLowerCase() === trimmed.toLowerCase()); - if (!matched) { - logger.warn(`[Config Warning] Unknown model provider '${trimmed}'. This entry will be ignored.`); + if (matched) { + if (!dedupedProviders.includes(matched)) { + dedupedProviders.push(matched); + } return; } - if (!dedupedProviders.includes(matched)) { - dedupedProviders.push(matched); + + // 2. 尝试前缀匹配 (支持带后缀的自定义分组,例如 openai-custom-1) + const prefixMatch = ALL_MODEL_PROVIDERS.find((provider) => + provider !== 'auto' && trimmed.toLowerCase().startsWith(provider.toLowerCase() + '-') + ); + + if (prefixMatch) { + if (!dedupedProviders.includes(trimmed)) { + dedupedProviders.push(trimmed); + } + return; } + + logger.warn(`[Config Warning] Unknown model provider '${trimmed}'. This entry will be ignored.`); }; const rawValue = config.MODEL_PROVIDER; diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index 0fb7f1c91..4edff2398 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -487,13 +487,20 @@ export class ProviderPoolManager { } else { refreshOperation = serviceAdapter.refreshToken(); } - await this._awaitRefreshWithTimeout(refreshOperation, providerType, providerStatus.uuid); + const refreshResult = await this._awaitRefreshWithTimeout(refreshOperation, providerType, providerStatus.uuid); + + // 处理返回 false 的情况(部分适配器可能不抛出异常而是返回 false) + if (refreshResult === false) { + throw new Error('Refresh operation returned false'); + } + const duration = Date.now() - startTime; this._log('info', `Token refresh successful for node ${providerStatus.uuid} (Duration: ${duration}ms)`); // 刷新成功,统一重置状态 config.needsRefresh = false; config.refreshCount = 0; + config.errorCount = 0; // 刷新成功也重置错误计数 config.lastRefreshTime = Date.now(); // 记录最后刷新成功时间 this._debouncedSave(providerType); @@ -503,7 +510,29 @@ export class ProviderPoolManager { } catch (error) { this._log('error', `Token refresh failed for node ${providerStatus.uuid}: ${error.message}`); - this.markProviderUnhealthyImmediately(providerType, config, `Refresh failed: ${error.message}`); + + // 记录错误信息 + config.lastErrorTime = new Date().toISOString(); + config.lastErrorMessage = `Refresh failed: ${error.message}`; + + // 增加错误计数(用于普通的健康检查参考,虽然刷新错误主要参考 refreshCount) + config.errorCount = (config.errorCount || 0) + 1; + + // 只有当刷新重试次数达到上限(5次)时,才标记为不健康 + // 注意:refreshCount 在进入本方法后的 try 块前已经自增(L466) + if (config.refreshCount >= 5) { + this.markProviderUnhealthyImmediately(providerType, config, `Refresh failed after maximum attempts (5): ${error.message}`); + } else { + // 关键修复:重置 needsRefresh 为 false,允许该节点回到池中 + // 这样它才有机会被下一次请求选中,从而再次触发刷新重试 + config.needsRefresh = false; + + // 增加冷却保护:更新 lastRefreshTime,利用 markProviderNeedRefresh 中的 30s 保护逻辑, + // 防止因瞬时高并发请求导致 5 次重试机会在短时间内被耗尽。 + config.lastRefreshTime = Date.now(); + + this._debouncedSave(providerType); + } throw error; } } From 7e96cecfe7e246deef870d1793a8f60f3ba217d3 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Thu, 23 Apr 2026 11:52:47 +0800 Subject: [PATCH 040/135] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DSSE=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E5=92=8C=E4=BB=A4=E7=89=8C=E5=88=B7=E6=96=B0=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为SSE连接添加token参数支持,解决前端EventStream认证问题 - 修改令牌刷新接口返回布尔值,区分实际刷新和无操作 - 修复刷新时间记录逻辑,避免心跳检测干扰保护机制 - 更新认证检查逻辑,同时支持Authorization头和URL参数 - 移除/api/events接口的免认证例外,统一认证要求 --- VERSION | 2 +- src/providers/adapter.js | 64 +++++++++++++++----------- src/providers/grok/grok-core.js | 1 + src/providers/provider-pool-manager.js | 20 ++++---- src/services/ui-manager.js | 4 +- src/ui-modules/auth.js | 20 +++++++- static/app/event-stream.js | 6 ++- 7 files changed, 76 insertions(+), 41 deletions(-) diff --git a/VERSION b/VERSION index ded6434f4..86fbeafcc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.15.3.1 +2.15.4 diff --git a/src/providers/adapter.js b/src/providers/adapter.js index a61f10e39..8e429bed9 100644 --- a/src/providers/adapter.js +++ b/src/providers/adapter.js @@ -72,7 +72,7 @@ export class ApiServiceAdapter { /** * 刷新认证令牌 - * @returns {Promise} + * @returns {Promise} - 是否执行了实际的刷新操作 */ async refreshToken() { throw new Error("Method 'refreshToken()' must be implemented."); @@ -80,7 +80,7 @@ export class ApiServiceAdapter { /** * 强制刷新认证令牌(不判断是否接近过期) - * @returns {Promise} + * @returns {Promise} - 是否执行了实际的刷新操作 */ async forceRefreshToken() { throw new Error("Method 'forceRefreshToken()' must be implemented."); @@ -136,9 +136,10 @@ export class GeminiApiServiceAdapter extends ApiServiceAdapter { } if(this.isExpiryDateNear()===true){ logger.info(`[Gemini] Expiry date is near, refreshing token...`); - return this.geminiApiService.initializeAuth(true); + await this.geminiApiService.initializeAuth(true); + return true; } - return Promise.resolve(); + return false; } async forceRefreshToken() { @@ -146,7 +147,8 @@ export class GeminiApiServiceAdapter extends ApiServiceAdapter { await this.geminiApiService.initialize(); } logger.info(`[Gemini] Force refreshing token...`); - return this.geminiApiService.initializeAuth(true); + await this.geminiApiService.initializeAuth(true); + return true; } isExpiryDateNear() { @@ -203,9 +205,10 @@ export class AntigravityApiServiceAdapter extends ApiServiceAdapter { } if (this.isExpiryDateNear() === true) { logger.info(`[Antigravity] Expiry date is near, refreshing token...`); - return this.antigravityApiService.initializeAuth(true); + await this.antigravityApiService.initializeAuth(true); + return true; } - return Promise.resolve(); + return false; } async forceRefreshToken() { @@ -213,7 +216,8 @@ export class AntigravityApiServiceAdapter extends ApiServiceAdapter { await this.antigravityApiService.initialize(); } logger.info(`[Antigravity] Force refreshing token...`); - return this.antigravityApiService.initializeAuth(true); + await this.antigravityApiService.initializeAuth(true); + return true; } isExpiryDateNear() { @@ -260,12 +264,12 @@ export class OpenAIApiServiceAdapter extends ApiServiceAdapter { async refreshToken() { // OpenAI API keys are typically static and do not require refreshing. - return Promise.resolve(); + return false; } async forceRefreshToken() { // OpenAI API keys are typically static and do not require refreshing. - return Promise.resolve(); + return false; } isExpiryDateNear() { @@ -298,12 +302,12 @@ export class OpenAIResponsesApiServiceAdapter extends ApiServiceAdapter { async refreshToken() { // OpenAI API keys are typically static and do not require refreshing. - return Promise.resolve(); + return false; } async forceRefreshToken() { // OpenAI API keys are typically static and do not require refreshing. - return Promise.resolve(); + return false; } isExpiryDateNear() { @@ -335,11 +339,11 @@ export class ClaudeApiServiceAdapter extends ApiServiceAdapter { } async refreshToken() { - return Promise.resolve(); + return false; } async forceRefreshToken() { - return Promise.resolve(); + return false; } isExpiryDateNear() { @@ -391,9 +395,10 @@ export class KiroApiServiceAdapter extends ApiServiceAdapter { } if(this.isExpiryDateNear()===true){ logger.info(`[Kiro] Expiry date is near, refreshing token...`); - return this.kiroApiService.initializeAuth(true); + await this.kiroApiService.initializeAuth(true); + return true; } - return Promise.resolve(); + return false; } async forceRefreshToken() { @@ -401,7 +406,8 @@ export class KiroApiServiceAdapter extends ApiServiceAdapter { await this.kiroApiService.initialize(); } logger.info(`[Kiro] Force refreshing token...`); - return this.kiroApiService.initializeAuth(true); + await this.kiroApiService.initializeAuth(true); + return true; } isExpiryDateNear() { @@ -467,9 +473,10 @@ export class QwenApiServiceAdapter extends ApiServiceAdapter { } if (this.isExpiryDateNear()) { logger.info(`[Qwen] Expiry date is near, refreshing token...`); - return this.qwenApiService._initializeAuth(true); + await this.qwenApiService._initializeAuth(true); + return true; } - return Promise.resolve(); + return false; } async forceRefreshToken() { @@ -477,7 +484,8 @@ export class QwenApiServiceAdapter extends ApiServiceAdapter { await this.qwenApiService.initialize(); } logger.info(`[Qwen] Force refreshing token...`); - return this.qwenApiService._initializeAuth(true); + await this.qwenApiService._initializeAuth(true); + return true; } isExpiryDateNear() { @@ -523,8 +531,9 @@ export class IFlowApiServiceAdapter extends ApiServiceAdapter { if (this.isExpiryDateNear()) { logger.info(`[iFlow] Expiry date is near, refreshing API key...`); await this.iflowApiService.initializeAuth(true); + return true; } - return Promise.resolve(); + return false; } async forceRefreshToken() { @@ -532,7 +541,8 @@ export class IFlowApiServiceAdapter extends ApiServiceAdapter { await this.iflowApiService.initialize(); } logger.info(`[iFlow] Force refreshing API key...`); - return this.iflowApiService.initializeAuth(true); + await this.iflowApiService.initializeAuth(true); + return true; } isExpiryDateNear() { @@ -575,8 +585,9 @@ export class CodexApiServiceAdapter extends ApiServiceAdapter { if (this.isExpiryDateNear()) { logger.info(`[Codex] Expiry date is near, refreshing token...`); await this.codexApiService.initializeAuth(true); + return true; } - return Promise.resolve(); + return false; } async forceRefreshToken() { @@ -584,7 +595,8 @@ export class CodexApiServiceAdapter extends ApiServiceAdapter { await this.codexApiService.initialize(); } logger.info(`[Codex] Force refreshing token...`); - return this.codexApiService.initializeAuth(true); + await this.codexApiService.initializeAuth(true); + return true; } isExpiryDateNear() { @@ -624,11 +636,11 @@ export class ForwardApiServiceAdapter extends ApiServiceAdapter { } async refreshToken() { - return Promise.resolve(); + return false; } async forceRefreshToken() { - return Promise.resolve(); + return false; } isExpiryDateNear() { diff --git a/src/providers/grok/grok-core.js b/src/providers/grok/grok-core.js index 0e6877681..b6be4f3a3 100644 --- a/src/providers/grok/grok-core.js +++ b/src/providers/grok/grok-core.js @@ -339,6 +339,7 @@ export class GrokApiService { if (poolManager && this.uuid) { poolManager.resetProviderRefreshStatus(this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM, this.uuid); } + return true; } catch (error) { logger.error('[Grok] Failed to initialize authentication:', error); throw new Error(`Failed to refreshToken.`); diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index 4edff2398..e788a119f 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -488,20 +488,22 @@ export class ProviderPoolManager { refreshOperation = serviceAdapter.refreshToken(); } const refreshResult = await this._awaitRefreshWithTimeout(refreshOperation, providerType, providerStatus.uuid); - - // 处理返回 false 的情况(部分适配器可能不抛出异常而是返回 false) - if (refreshResult === false) { - throw new Error('Refresh operation returned false'); - } const duration = Date.now() - startTime; - this._log('info', `Token refresh successful for node ${providerStatus.uuid} (Duration: ${duration}ms)`); - // 刷新成功,统一重置状态 + // 只有在真正执行了刷新操作时,才更新 lastRefreshTime + // 这可以防止 heartbeat 的 no-op 刷新误更新时间,导致后续真正的刷新被 markProviderNeedRefresh 拦截(30秒保护) + if (refreshResult === true) { + this._log('info', `Token refresh successful for node ${providerStatus.uuid} (Duration: ${duration}ms)`); + config.lastRefreshTime = Date.now(); // 记录最后实际刷新成功时间 + } else { + this._log('info', `Token refresh no-op for node ${providerStatus.uuid} (Already valid)`); + } + + // 刷新流程结束(无论是否真正刷新),重置状态 config.needsRefresh = false; config.refreshCount = 0; - config.errorCount = 0; // 刷新成功也重置错误计数 - config.lastRefreshTime = Date.now(); // 记录最后刷新成功时间 + config.errorCount = 0; // 成功/无操作也重置错误计数 this._debouncedSave(providerType); } else { diff --git a/src/services/ui-manager.js b/src/services/ui-manager.js index 40ed1b2d5..f2f660b60 100644 --- a/src/services/ui-manager.js +++ b/src/services/ui-manager.js @@ -64,8 +64,8 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo return await systemApi.handleHealthCheck(req, res); } - // Handle UI management API requests (需要token验证,除了登录接口、健康检查和Events接口) - if (pathParam.startsWith('/api/') && pathParam !== '/api/login' && pathParam !== '/api/health' && pathParam !== '/api/events' && pathParam !== '/api/grok/assets') { + // Handle UI management API requests (需要token验证,除了登录接口、健康检查) + if (pathParam.startsWith('/api/') && pathParam !== '/api/login' && pathParam !== '/api/health' && pathParam !== '/api/grok/assets') { // 检查token验证 const isAuth = await auth.checkAuth(req); if (!isAuth) { diff --git a/src/ui-modules/auth.js b/src/ui-modules/auth.js index b60995f05..490a757e9 100644 --- a/src/ui-modules/auth.js +++ b/src/ui-modules/auth.js @@ -282,15 +282,31 @@ export async function cleanupExpiredTokens() { /** * 检查token验证 + * 支持 Authorization Header 或 URL 参数 token (用于 SSE) */ export async function checkAuth(req) { + let token = null; + + // 1. 检查 Authorization header const authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith('Bearer ')) { + token = authHeader.substring(7); + } + + // 2. 检查 URL 参数 (用于 EventSource/SSE) + if (!token && req.url) { + try { + const url = new URL(req.url, 'http://localhost'); + token = url.searchParams.get('token'); + } catch (e) { + // 解析失败忽略 + } + } - if (!authHeader || !authHeader.startsWith('Bearer ')) { + if (!token) { return false; } - const token = authHeader.substring(7); const tokenInfo = await verifyToken(token); return tokenInfo !== null; diff --git a/static/app/event-stream.js b/static/app/event-stream.js index cbd969c76..fc0805d10 100644 --- a/static/app/event-stream.js +++ b/static/app/event-stream.js @@ -2,6 +2,7 @@ import { eventSource, setEventSource, elements, addLog, autoScroll } from './constants.js'; import { t } from './i18n.js'; +import { authManager } from './auth.js'; /** * Server-Sent Events初始化 @@ -11,7 +12,10 @@ function initEventStream() { eventSource.close(); } - const newEventSource = new EventSource('/api/events'); + // 从 AuthManager 获取 token 并作为 URL 参数传递 + const token = authManager.getToken(); + const url = token ? `/api/events?token=${encodeURIComponent(token)}` : '/api/events'; + const newEventSource = new EventSource(url); setEventSource(newEventSource); newEventSource.onopen = () => { From 86f83da21501fbd82a0a9165d7c45000c8859b84 Mon Sep 17 00:00:00 2001 From: Howard Xie <39507089+KouzenSha@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:30:21 +0800 Subject: [PATCH 041/135] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=8F=AF?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E7=9A=84=20429=20=E8=B4=A6=E5=8F=B7=E7=9F=AD?= =?UTF-8?q?=E5=86=B7=E5=8D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README-JA.md | 1 + README-ZH.md | 1 + README.md | 1 + configs/config.json.example | 4 + src/core/config-manager.js | 9 +- src/ui-modules/config-api.js | 27 +++++ src/utils/common.js | 134 +++++++++++++++++++++++- static/app/config-manager.js | 6 ++ static/app/i18n.js | 20 ++++ static/components/section-config.css | 19 ++++ static/components/section-config.html | 21 ++++ static/components/section-tutorial.html | 12 +++ tests/rate-limit-cooldown.unit.test.js | 98 +++++++++++++++++ 13 files changed, 350 insertions(+), 3 deletions(-) create mode 100644 tests/rate-limit-cooldown.unit.test.js diff --git a/README-JA.md b/README-JA.md index 4e8d304fe..19d628d93 100644 --- a/README-JA.md +++ b/README-JA.md @@ -625,6 +625,7 @@ kill -9 **解決策**: - **アカウントプールを設定**:`provider_pools.json` に複数のアカウントを追加し、ポーリングメカニズムを有効化 - **フォールバックを設定**:`config.json` で `providerFallbackChain` を設定し、クロスタイプ降格を実現 +- **429クールダウンを有効化**:`RATE_LIMIT_COOLDOWN_ENABLED` を `true` にし、`RATE_LIMIT_COOLDOWN_MS` で既定の冷却時間を設定すると、レート制限されたアカウントは一時的にプールから外れ、自動的に復帰します - **リクエスト頻度を下げる**:リクエスト間隔を適切に増やし、レート制限のトリガーを回避 - **割り当てリセットを待つ**:無料割り当ては通常、毎日または毎分リセットされます diff --git a/README-ZH.md b/README-ZH.md index 748443c54..488065bff 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -623,6 +623,7 @@ kill -9 **解决方案**: - **配置账号池**:添加多个账号到 `provider_pools.json`,启用轮询机制 - **配置 Fallback**:在 `config.json` 中配置 `providerFallbackChain`,实现跨类型降级 +- **启用 429 短冷却**:将 `RATE_LIMIT_COOLDOWN_ENABLED` 设为 `true`,并通过 `RATE_LIMIT_COOLDOWN_MS` 设置默认冷却时间,让被限流账号短暂退出账号池后自动恢复 - **降低请求频率**:适当增加请求间隔,避免触发速率限制 - **等待配额重置**:免费配额通常每日或每分钟重置 diff --git a/README.md b/README.md index 335565513..5010c7c3e 100644 --- a/README.md +++ b/README.md @@ -625,6 +625,7 @@ Or modify the port configuration in `configs/config.json` to use a different por **Solutions**: - **Configure Account Pool**: Add multiple accounts to `provider_pools.json`, enable polling mechanism - **Configure Fallback**: Configure `providerFallbackChain` in `config.json` for cross-type degradation +- **Enable 429 Cooldown**: Set `RATE_LIMIT_COOLDOWN_ENABLED` to `true` and tune `RATE_LIMIT_COOLDOWN_MS` so rate-limited accounts temporarily leave the pool and recover automatically - **Reduce Request Frequency**: Appropriately increase request intervals to avoid triggering rate limits - **Wait for Quota Reset**: Free quotas usually reset daily or per minute diff --git a/configs/config.json.example b/configs/config.json.example index f04c8ef12..d9e55e6e6 100644 --- a/configs/config.json.example +++ b/configs/config.json.example @@ -13,6 +13,10 @@ "PROMPT_LOG_MODE": "none", "REQUEST_MAX_RETRIES": 3, "REQUEST_BASE_DELAY": 1000, + "RATE_LIMIT_COOLDOWN_ENABLED": false, + "RATE_LIMIT_COOLDOWN_MS": 30000, + "RATE_LIMIT_COOLDOWN_JITTER_MS": 5000, + "RATE_LIMIT_COOLDOWN_MAX_MS": 300000, "CRON_NEAR_MINUTES": 1, "CRON_REFRESH_TOKEN": false, "PROVIDER_POOLS_FILE_PATH": "configs/provider_pools.json", diff --git a/src/core/config-manager.js b/src/core/config-manager.js index e1269ebe2..e333ffe6d 100644 --- a/src/core/config-manager.js +++ b/src/core/config-manager.js @@ -84,6 +84,10 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP REQUEST_MAX_RETRIES: 3, REQUEST_BASE_DELAY: 1000, CREDENTIAL_SWITCH_MAX_RETRIES: 5, // 坏凭证切换最大重试次数(用于认证错误后切换凭证) + RATE_LIMIT_COOLDOWN_ENABLED: false, // 429 限流后是否短暂冷却账号 + RATE_LIMIT_COOLDOWN_MS: 30000, // 429 限流默认冷却时间(毫秒) + RATE_LIMIT_COOLDOWN_JITTER_MS: 5000, // 429 限流冷却随机抖动(毫秒) + RATE_LIMIT_COOLDOWN_MAX_MS: 300000, // Retry-After 允许的最大冷却时间(毫秒) CRON_NEAR_MINUTES: 15, CRON_REFRESH_TOKEN: false, LOGIN_EXPIRY: 3600, // 登录过期时间(秒),默认1小时 @@ -142,6 +146,10 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP { flag: '--system-prompt-mode', configKey: 'SYSTEM_PROMPT_MODE', type: 'enum', validValues: ['overwrite', 'append'] }, { flag: '--host', configKey: 'HOST', type: 'string' }, { flag: '--prompt-log-base-name', configKey: 'PROMPT_LOG_BASE_NAME', type: 'string' }, + { flag: '--rate-limit-cooldown-enabled', configKey: 'RATE_LIMIT_COOLDOWN_ENABLED', type: 'bool' }, + { flag: '--rate-limit-cooldown-ms', configKey: 'RATE_LIMIT_COOLDOWN_MS', type: 'int' }, + { flag: '--rate-limit-cooldown-jitter-ms', configKey: 'RATE_LIMIT_COOLDOWN_JITTER_MS', type: 'int' }, + { flag: '--rate-limit-cooldown-max-ms', configKey: 'RATE_LIMIT_COOLDOWN_MAX_MS', type: 'int' }, { flag: '--cron-near-minutes', configKey: 'CRON_NEAR_MINUTES', type: 'int' }, { flag: '--cron-refresh-token', configKey: 'CRON_REFRESH_TOKEN', type: 'bool' }, { flag: '--provider-pools-file', configKey: 'PROVIDER_POOLS_FILE_PATH', type: 'string' }, @@ -297,4 +305,3 @@ export async function getSystemPromptFileContent(filePath) { } export { ALL_MODEL_PROVIDERS }; - diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js index d931d33b0..8cf392103 100644 --- a/src/ui-modules/config-api.js +++ b/src/ui-modules/config-api.js @@ -11,6 +11,12 @@ import { broadcastEvent } from '../ui-modules/event-broadcast.js'; import { HEALTH_CHECK, PASSWORD, NETWORK, RETRY } from '../utils/constants.js'; import { withFileLock, atomicWriteFile } from '../utils/file-lock.js'; +function parseBooleanConfig(value) { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value.toLowerCase() === 'true'; + return Boolean(value); +} + /** * 重载配置文件 */ @@ -75,6 +81,10 @@ export async function handleGetConfig(req, res, currentConfig) { REQUEST_MAX_RETRIES: currentConfig.REQUEST_MAX_RETRIES, REQUEST_BASE_DELAY: currentConfig.REQUEST_BASE_DELAY, CREDENTIAL_SWITCH_MAX_RETRIES: currentConfig.CREDENTIAL_SWITCH_MAX_RETRIES, + RATE_LIMIT_COOLDOWN_ENABLED: currentConfig.RATE_LIMIT_COOLDOWN_ENABLED, + RATE_LIMIT_COOLDOWN_MS: currentConfig.RATE_LIMIT_COOLDOWN_MS, + RATE_LIMIT_COOLDOWN_JITTER_MS: currentConfig.RATE_LIMIT_COOLDOWN_JITTER_MS, + RATE_LIMIT_COOLDOWN_MAX_MS: currentConfig.RATE_LIMIT_COOLDOWN_MAX_MS, CRON_NEAR_MINUTES: currentConfig.CRON_NEAR_MINUTES, CRON_REFRESH_TOKEN: currentConfig.CRON_REFRESH_TOKEN, LOGIN_EXPIRY: currentConfig.LOGIN_EXPIRY, @@ -180,6 +190,19 @@ async function _handleUpdateConfig(req, res, currentConfig, body) { } 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.RATE_LIMIT_COOLDOWN_ENABLED !== undefined) currentConfig.RATE_LIMIT_COOLDOWN_ENABLED = parseBooleanConfig(newConfig.RATE_LIMIT_COOLDOWN_ENABLED); + if (newConfig.RATE_LIMIT_COOLDOWN_MS !== undefined) { + const v = Number(newConfig.RATE_LIMIT_COOLDOWN_MS); + if (Number.isInteger(v) && v >= 0) currentConfig.RATE_LIMIT_COOLDOWN_MS = v; + } + if (newConfig.RATE_LIMIT_COOLDOWN_JITTER_MS !== undefined) { + const v = Number(newConfig.RATE_LIMIT_COOLDOWN_JITTER_MS); + if (Number.isInteger(v) && v >= 0) currentConfig.RATE_LIMIT_COOLDOWN_JITTER_MS = v; + } + if (newConfig.RATE_LIMIT_COOLDOWN_MAX_MS !== undefined) { + const v = Number(newConfig.RATE_LIMIT_COOLDOWN_MAX_MS); + if (Number.isInteger(v) && v >= 0) currentConfig.RATE_LIMIT_COOLDOWN_MAX_MS = v; + } if (newConfig.CRON_NEAR_MINUTES !== undefined) currentConfig.CRON_NEAR_MINUTES = newConfig.CRON_NEAR_MINUTES; if (newConfig.CRON_REFRESH_TOKEN !== undefined) currentConfig.CRON_REFRESH_TOKEN = newConfig.CRON_REFRESH_TOKEN; if (newConfig.LOGIN_EXPIRY !== undefined) currentConfig.LOGIN_EXPIRY = newConfig.LOGIN_EXPIRY; @@ -313,6 +336,10 @@ async function _handleUpdateConfig(req, res, currentConfig, body) { REQUEST_MAX_RETRIES: currentConfig.REQUEST_MAX_RETRIES, REQUEST_BASE_DELAY: currentConfig.REQUEST_BASE_DELAY, CREDENTIAL_SWITCH_MAX_RETRIES: currentConfig.CREDENTIAL_SWITCH_MAX_RETRIES, + RATE_LIMIT_COOLDOWN_ENABLED: currentConfig.RATE_LIMIT_COOLDOWN_ENABLED, + RATE_LIMIT_COOLDOWN_MS: currentConfig.RATE_LIMIT_COOLDOWN_MS, + RATE_LIMIT_COOLDOWN_JITTER_MS: currentConfig.RATE_LIMIT_COOLDOWN_JITTER_MS, + RATE_LIMIT_COOLDOWN_MAX_MS: currentConfig.RATE_LIMIT_COOLDOWN_MAX_MS, CRON_NEAR_MINUTES: currentConfig.CRON_NEAR_MINUTES, CRON_REFRESH_TOKEN: currentConfig.CRON_REFRESH_TOKEN, LOGIN_EXPIRY: currentConfig.LOGIN_EXPIRY, diff --git a/src/utils/common.js b/src/utils/common.js index de74e3328..1ae8dacf4 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -57,6 +57,118 @@ export function isRetryableNetworkError(error) { ); } +function getErrorStatusCode(error) { + return error?.response?.status || error?.status || error?.statusCode || error?.code || null; +} + +function getHeaderValue(headers, headerName) { + if (!headers) return null; + + if (typeof headers.get === 'function') { + return headers.get(headerName) || headers.get(headerName.toLowerCase()); + } + + const lowerName = headerName.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === lowerName) { + return Array.isArray(value) ? value[0] : value; + } + } + + return null; +} + +function parseRetryAfterMs(value, now = Date.now()) { + if (value === null || value === undefined) return null; + + const rawValue = Array.isArray(value) ? value[0] : value; + const text = String(rawValue).trim(); + if (!text) return null; + + const seconds = Number(text); + if (Number.isFinite(seconds)) { + return Math.max(0, Math.round(seconds * 1000)); + } + + const dateMs = Date.parse(text); + if (!Number.isNaN(dateMs)) { + return Math.max(0, dateMs - now); + } + + return null; +} + +function parseDurationMs(value) { + if (value === null || value === undefined) return null; + if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.round(value)); + + const text = String(value).trim(); + const match = text.match(/^([\d.]+)\s*(ms|s)?$/i); + if (!match) return null; + + const amount = Number(match[1]); + if (!Number.isFinite(amount)) return null; + + return Math.max(0, Math.round(match[2]?.toLowerCase() === 's' ? amount * 1000 : amount)); +} + +function getRetryDelayFromBody(errorBody) { + try { + const data = typeof errorBody === 'string' ? JSON.parse(errorBody) : errorBody; + + const directDelay = parseDurationMs(data?.retryDelay ?? data?.retry_delay ?? data?.retryAfterMs); + if (directDelay !== null) return directDelay; + + const details = data?.error?.details; + if (Array.isArray(details)) { + for (const detail of details) { + const retryDelay = parseDurationMs(detail?.retryDelay || detail?.metadata?.quotaResetDelay); + if (retryDelay !== null) return retryDelay; + } + } + } catch {} + + return null; +} + +function getRetryAfterMs(error, now = Date.now()) { + const headerDelay = parseRetryAfterMs(getHeaderValue(error?.response?.headers, 'retry-after'), now); + if (headerDelay !== null) return headerDelay; + + const explicitDelay = parseDurationMs(error?.retryAfterMs); + if (explicitDelay !== null) return explicitDelay; + + const retryAfterDelay = parseRetryAfterMs(error?.retryAfter ?? error?.response?.data?.retryAfter ?? error?.response?.data?.retry_after, now); + if (retryAfterDelay !== null) return retryAfterDelay; + + return getRetryDelayFromBody(error?.response?.data); +} + +function getPositiveInteger(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : fallback; +} + +/** + * Calculates a scheduled recovery time for optional 429 account cooldown. + * Returns null when cooldown is disabled or the error is not an HTTP 429. + */ +export function getRateLimitCooldownRecoveryTime(error, config = {}, now = Date.now()) { + if (!config?.RATE_LIMIT_COOLDOWN_ENABLED || Number(getErrorStatusCode(error)) !== 429) { + return null; + } + + const defaultCooldownMs = getPositiveInteger(config.RATE_LIMIT_COOLDOWN_MS, 30000); + const maxCooldownMs = getPositiveInteger(config.RATE_LIMIT_COOLDOWN_MAX_MS, 300000); + const jitterMs = getPositiveInteger(config.RATE_LIMIT_COOLDOWN_JITTER_MS, 0); + const retryAfterMs = getRetryAfterMs(error, now); + const baseCooldownMs = retryAfterMs === null ? defaultCooldownMs : retryAfterMs; + const cappedCooldownMs = Math.min(baseCooldownMs, Math.max(defaultCooldownMs, maxCooldownMs)); + const jitter = jitterMs > 0 ? Math.floor(Math.random() * (jitterMs + 1)) : 0; + + return new Date(now + cappedCooldownMs + jitter); +} + // ==================== API 常量 ==================== export const API_ACTIONS = { @@ -676,7 +788,7 @@ export async function handleStreamRequest(res, service, model, requestBody, from } // 获取状态码(用于日志记录,不再用于判断是否重试) - const status = error.response?.status; + const status = getErrorStatusCode(error); // 检查是否应该跳过错误计数(用于 429/5xx 等需要直接切换凭证的情况) const skipErrorCount = error.skipErrorCount === true; @@ -685,6 +797,15 @@ export async function handleStreamRequest(res, service, model, requestBody, from // 检查凭证是否已在底层被标记为不健康(避免重复标记) let credentialMarkedUnhealthy = error.credentialMarkedUnhealthy === true; + + const rateLimitRecoveryTime = getRateLimitCooldownRecoveryTime(error, CONFIG); + if (rateLimitRecoveryTime && providerPoolManager && pooluuid) { + logger.info(`[Provider Pool] Applying 429 cooldown for ${toProvider} (${pooluuid}) until ${rateLimitRecoveryTime.toISOString()}`); + providerPoolManager.markProviderUnhealthyWithRecoveryTime(toProvider, { + uuid: pooluuid + }, '429 Too Many Requests - short cooldown', rateLimitRecoveryTime); + credentialMarkedUnhealthy = true; + } // 如果底层未标记,且不跳过错误计数,则在此处标记 if (!credentialMarkedUnhealthy && !skipErrorCount && providerPoolManager && pooluuid) { @@ -878,7 +999,7 @@ export async function handleUnaryRequest(res, service, model, requestBody, fromP logger.error('\n[Server] Error during unary processing:', error.stack); // 获取状态码(用于日志记录,不再用于判断是否重试) - const status = error.response?.status; + const status = getErrorStatusCode(error); // 检查是否应该跳过错误计数(用于 429/5xx 等需要直接切换凭证的情况) const skipErrorCount = error.skipErrorCount === true; @@ -887,6 +1008,15 @@ export async function handleUnaryRequest(res, service, model, requestBody, fromP // 检查凭证是否已在底层被标记为不健康(避免重复标记) let credentialMarkedUnhealthy = error.credentialMarkedUnhealthy === true; + + const rateLimitRecoveryTime = getRateLimitCooldownRecoveryTime(error, CONFIG); + if (rateLimitRecoveryTime && providerPoolManager && pooluuid) { + logger.info(`[Provider Pool] Applying 429 cooldown for ${toProvider} (${pooluuid}) until ${rateLimitRecoveryTime.toISOString()}`); + providerPoolManager.markProviderUnhealthyWithRecoveryTime(toProvider, { + uuid: pooluuid + }, '429 Too Many Requests - short cooldown', rateLimitRecoveryTime); + credentialMarkedUnhealthy = true; + } // 如果底层未标记,且不跳过错误计数,则在此处标记 if (!credentialMarkedUnhealthy && !skipErrorCount && providerPoolManager && pooluuid) { diff --git a/static/app/config-manager.js b/static/app/config-manager.js index 1e3cf7f0c..e2f10251f 100644 --- a/static/app/config-manager.js +++ b/static/app/config-manager.js @@ -252,6 +252,8 @@ async function loadConfiguration() { const refreshConcurrencyPerProviderEl = document.getElementById('refreshConcurrencyPerProvider'); const providerFallbackChainEl = document.getElementById('providerFallbackChain'); const modelFallbackMappingEl = document.getElementById('modelFallbackMapping'); + const rateLimitCooldownEnabledEl = document.getElementById('rateLimitCooldownEnabled'); + const rateLimitCooldownMsEl = document.getElementById('rateLimitCooldownMs'); if (systemPromptFilePathEl) systemPromptFilePathEl.value = data.SYSTEM_PROMPT_FILE_PATH || 'configs/input_system_prompt.txt'; if (systemPromptModeEl) systemPromptModeEl.value = data.SYSTEM_PROMPT_MODE || 'append'; @@ -263,6 +265,8 @@ async function loadConfiguration() { // 坏凭证切换最大重试次数 const credentialSwitchMaxRetriesEl = document.getElementById('credentialSwitchMaxRetries'); if (credentialSwitchMaxRetriesEl) credentialSwitchMaxRetriesEl.value = data.CREDENTIAL_SWITCH_MAX_RETRIES || 5; + if (rateLimitCooldownEnabledEl) rateLimitCooldownEnabledEl.checked = data.RATE_LIMIT_COOLDOWN_ENABLED || false; + if (rateLimitCooldownMsEl) rateLimitCooldownMsEl.value = data.RATE_LIMIT_COOLDOWN_MS || 30000; if (cronNearMinutesEl) cronNearMinutesEl.value = data.CRON_NEAR_MINUTES || 1; if (cronRefreshTokenEl) cronRefreshTokenEl.checked = data.CRON_REFRESH_TOKEN || false; @@ -451,6 +455,8 @@ async function saveConfiguration() { config.REQUEST_MAX_RETRIES = parseInt(document.getElementById('requestMaxRetries')?.value || 3); config.REQUEST_BASE_DELAY = parseInt(document.getElementById('requestBaseDelay')?.value || 1000); config.CREDENTIAL_SWITCH_MAX_RETRIES = parseInt(document.getElementById('credentialSwitchMaxRetries')?.value || 5); + config.RATE_LIMIT_COOLDOWN_ENABLED = document.getElementById('rateLimitCooldownEnabled')?.checked || false; + config.RATE_LIMIT_COOLDOWN_MS = parseInt(document.getElementById('rateLimitCooldownMs')?.value || 30000); config.CRON_NEAR_MINUTES = parseInt(document.getElementById('cronNearMinutes')?.value || 1); config.CRON_REFRESH_TOKEN = document.getElementById('cronRefreshToken')?.checked || false; config.LOGIN_EXPIRY = parseInt(document.getElementById('loginExpiry')?.value || 3600); diff --git a/static/app/i18n.js b/static/app/i18n.js index 4971b507f..c9c89e6ec 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -309,6 +309,10 @@ const translations = { 'config.advanced.baseDelay': '重试基础延迟(毫秒)', 'config.advanced.credentialSwitchMaxRetries': '坏凭证切换最大重试次数', 'config.advanced.credentialSwitchMaxRetriesNote': '认证错误(401/403)后切换凭证的最大重试次数,默认 5 次', + 'config.advanced.rateLimitCooldownEnabled': '启用 429 短冷却', + 'config.advanced.rateLimitCooldownTitle': '429 限流保护', + 'config.advanced.rateLimitCooldownNote': '上游返回 429 时,让当前账号短暂退出账号池,到期后自动恢复。', + 'config.advanced.rateLimitCooldownMs': '默认冷却时间(毫秒)', 'config.advanced.warmupTarget': '系统预热节点数', 'config.advanced.warmupTargetNote': '系统启动时自动刷新的节点数量,默认为 0', 'config.advanced.refreshConcurrencyPerProvider': '提供商内刷新并发数', @@ -328,6 +332,10 @@ const translations = { 'config.advanced.poolSizeLimitNote': '每个提供商类型参与轮询的最大健康凭证数量,0 表示不限制,使用所有健康凭证', 'config.advanced.credentialSwitchMaxRetries': '坏凭证切换最大重试次数', 'config.advanced.credentialSwitchMaxRetriesNote': '认证错误(401/403)后切换凭证的最大重试次数,默认 5 次', + 'config.advanced.rateLimitCooldownEnabled': '启用 429 短冷却', + 'config.advanced.rateLimitCooldownTitle': '429 限流保护', + 'config.advanced.rateLimitCooldownNote': '上游返回 429 时,让当前账号短暂退出账号池,到期后自动恢复。', + 'config.advanced.rateLimitCooldownMs': '默认冷却时间(毫秒)', 'config.advanced.fallbackChain': '跨类型 Fallback 链配置', 'config.advanced.fallbackChainPlaceholder': '例如:\n{\n "gemini-cli-oauth": ["gemini-antigravity"],\n "gemini-antigravity": ["gemini-cli-oauth"],\n "claude-kiro-oauth": ["claude-custom"]\n}', 'config.advanced.fallbackChainNote': '当某一 Provider Type 所有账号都不健康时,自动切换到配置的 Fallback 类型。JSON 格式,键为主类型,值为 Fallback 类型数组(按优先级排序)', @@ -852,6 +860,8 @@ const translations = { 'tutorial.main.retry.max': '提供商内最大重试次数', 'tutorial.main.retry.delay': '重试基础延迟(毫秒)', 'tutorial.main.retry.credentialSwitch': '坏凭证切换最大重试次数', + 'tutorial.main.retry.rateLimitCooldownEnabled': '429 后是否短暂冷却账号', + 'tutorial.main.retry.rateLimitCooldownMs': '429 默认冷却时间(毫秒)', 'tutorial.main.retry.error': '提供商最大错误次数,超过后标记为不健康', 'tutorial.main.governance.warmup': '系统启动时自动刷新的节点数量', 'tutorial.main.governance.concurrency': '每个提供商内部最大并行刷新任务数', @@ -1265,6 +1275,10 @@ const translations = { 'config.advanced.baseDelay': 'Base Retry Delay (ms)', 'config.advanced.credentialSwitchMaxRetries': 'Credential Switch Max Retries', 'config.advanced.credentialSwitchMaxRetriesNote': 'Max retry count for switching credentials after auth errors (401/403), default 5', + 'config.advanced.rateLimitCooldownEnabled': 'Enable 429 Cooldown', + 'config.advanced.rateLimitCooldownTitle': '429 Rate Limit Protection', + 'config.advanced.rateLimitCooldownNote': 'When upstream returns 429, temporarily remove the current account from the pool and recover it automatically.', + 'config.advanced.rateLimitCooldownMs': 'Default Cooldown (ms)', 'config.advanced.warmupTarget': 'Warmup Target Nodes', 'config.advanced.warmupTargetNote': 'Number of nodes to refresh on startup, default 0', 'config.advanced.refreshConcurrencyPerProvider': 'Refresh Concurrency per Provider', @@ -1284,6 +1298,10 @@ const translations = { 'config.advanced.poolSizeLimitNote': 'Maximum number of healthy credentials per provider type for rotation. 0 means no limit, use all healthy credentials', 'config.advanced.credentialSwitchMaxRetries': 'Credential Switch Max Retries', 'config.advanced.credentialSwitchMaxRetriesNote': 'Maximum retries for switching credentials after authentication errors (401/403), default is 5', + 'config.advanced.rateLimitCooldownEnabled': 'Enable 429 Cooldown', + 'config.advanced.rateLimitCooldownTitle': '429 Rate Limit Protection', + 'config.advanced.rateLimitCooldownNote': 'When upstream returns 429, temporarily remove the current account from the pool and recover it automatically.', + 'config.advanced.rateLimitCooldownMs': 'Default Cooldown (ms)', 'config.advanced.fallbackChain': 'Cross-Type Fallback Chain Config', 'config.advanced.fallbackChainPlaceholder': 'Example:\n{\n "gemini-cli-oauth": ["gemini-antigravity"],\n "gemini-antigravity": ["gemini-cli-oauth"],\n "claude-kiro-oauth": ["claude-custom"]\n}', 'config.advanced.fallbackChainNote': 'When all accounts of a Provider Type are unhealthy, automatically switch to configured Fallback types. JSON format, key is primary type, value is Fallback type array (sorted by priority)', @@ -1807,6 +1825,8 @@ const translations = { 'tutorial.main.retry.max': 'Provider max retry count', 'tutorial.main.retry.delay': 'Base retry delay (milliseconds)', 'tutorial.main.retry.credentialSwitch': 'Max retries for switching bad credentials', + 'tutorial.main.retry.rateLimitCooldownEnabled': 'Whether to temporarily cool down accounts after 429', + 'tutorial.main.retry.rateLimitCooldownMs': 'Default 429 cooldown duration (milliseconds)', 'tutorial.main.retry.error': 'Max provider error count before marking unhealthy', 'tutorial.main.governance.warmup': 'Number of nodes to auto-refresh on startup', 'tutorial.main.governance.concurrency': 'Maximum parallel refresh tasks per provider', diff --git a/static/components/section-config.css b/static/components/section-config.css index e503c665f..1f23d9fc6 100644 --- a/static/components/section-config.css +++ b/static/components/section-config.css @@ -525,6 +525,25 @@ input:checked + .toggle-slider:before { opacity: 0.5; } +.config-inline-heading { + margin-bottom: 1rem; +} + +.config-inline-heading span { + display: block; + color: var(--text-primary); + font-size: 0.95rem; + font-weight: 600; + margin-bottom: 0.35rem; +} + +.config-inline-heading small { + display: block; + color: var(--text-secondary); + font-size: 0.85rem; + line-height: 1.5; +} + /* 响应式调整 */ @media (max-width: 768px) { .form-row, .config-row { diff --git a/static/components/section-config.html b/static/components/section-config.html index 7bba76ded..5ecf89d1e 100644 --- a/static/components/section-config.html +++ b/static/components/section-config.html @@ -179,6 +179,27 @@

服务 + +
+
+ 429 限流保护 + 上游返回 429 时,让当前账号短暂退出账号池,到期后自动恢复。 +
+
+
+ + +
+
+ + +
+
+
+
diff --git a/static/components/section-tutorial.html b/static/components/section-tutorial.html index e616e933a..95b0b19d1 100644 --- a/static/components/section-tutorial.html +++ b/static/components/section-tutorial.html @@ -153,6 +153,18 @@

服务治理

5 坏凭证切换最大重试次数 + + RATE_LIMIT_COOLDOWN_ENABLED + boolean + false + 429 后是否短暂冷却账号 + + + RATE_LIMIT_COOLDOWN_MS + number + 30000 + 429 默认冷却时间(毫秒) + MAX_ERROR_COUNT number diff --git a/tests/rate-limit-cooldown.unit.test.js b/tests/rate-limit-cooldown.unit.test.js new file mode 100644 index 000000000..07c1118d7 --- /dev/null +++ b/tests/rate-limit-cooldown.unit.test.js @@ -0,0 +1,98 @@ +import { describe, expect, test } from '@jest/globals'; +import { getRateLimitCooldownRecoveryTime } from '../src/utils/common.js'; + +const NOW = Date.parse('2026-04-22T00:00:00.000Z'); + +describe('rate-limit cooldown helper', () => { + test('returns null when cooldown is disabled', () => { + const recoveryTime = getRateLimitCooldownRecoveryTime( + { response: { status: 429 } }, + { RATE_LIMIT_COOLDOWN_ENABLED: false, RATE_LIMIT_COOLDOWN_MS: 30000 }, + NOW + ); + + expect(recoveryTime).toBeNull(); + }); + + test('returns null for non-429 errors', () => { + const recoveryTime = getRateLimitCooldownRecoveryTime( + { response: { status: 400 } }, + { RATE_LIMIT_COOLDOWN_ENABLED: true, RATE_LIMIT_COOLDOWN_MS: 30000 }, + NOW + ); + + expect(recoveryTime).toBeNull(); + }); + + test('uses default cooldown when retry-after is absent', () => { + const recoveryTime = getRateLimitCooldownRecoveryTime( + { response: { status: 429 } }, + { + RATE_LIMIT_COOLDOWN_ENABLED: true, + RATE_LIMIT_COOLDOWN_MS: 30000, + RATE_LIMIT_COOLDOWN_JITTER_MS: 0 + }, + NOW + ); + + expect(recoveryTime.toISOString()).toBe('2026-04-22T00:00:30.000Z'); + }); + + test('uses Retry-After seconds when present', () => { + const recoveryTime = getRateLimitCooldownRecoveryTime( + { response: { status: 429, headers: { 'retry-after': '10' } } }, + { + RATE_LIMIT_COOLDOWN_ENABLED: true, + RATE_LIMIT_COOLDOWN_MS: 30000, + RATE_LIMIT_COOLDOWN_JITTER_MS: 0, + RATE_LIMIT_COOLDOWN_MAX_MS: 300000 + }, + NOW + ); + + expect(recoveryTime.toISOString()).toBe('2026-04-22T00:00:10.000Z'); + }); + + test('caps excessive Retry-After values', () => { + const recoveryTime = getRateLimitCooldownRecoveryTime( + { response: { status: 429, headers: { 'retry-after': '9999' } } }, + { + RATE_LIMIT_COOLDOWN_ENABLED: true, + RATE_LIMIT_COOLDOWN_MS: 30000, + RATE_LIMIT_COOLDOWN_JITTER_MS: 0, + RATE_LIMIT_COOLDOWN_MAX_MS: 60000 + }, + NOW + ); + + expect(recoveryTime.toISOString()).toBe('2026-04-22T00:01:00.000Z'); + }); + + test('reads provider retryDelay bodies', () => { + const recoveryTime = getRateLimitCooldownRecoveryTime( + { + response: { + status: 429, + data: { + error: { + details: [ + { + '@type': 'type.googleapis.com/google.rpc.RetryInfo', + retryDelay: '3s' + } + ] + } + } + } + }, + { + RATE_LIMIT_COOLDOWN_ENABLED: true, + RATE_LIMIT_COOLDOWN_MS: 30000, + RATE_LIMIT_COOLDOWN_JITTER_MS: 0 + }, + NOW + ); + + expect(recoveryTime.toISOString()).toBe('2026-04-22T00:00:03.000Z'); + }); +}); From acabbdfe7939043a8879b1192c5ff3ac39471cf5 Mon Sep 17 00:00:00 2001 From: Howard Xie <39507089+KouzenSha@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:23:59 +0800 Subject: [PATCH 042/135] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20429=20?= =?UTF-8?q?=E5=86=B7=E5=8D=B4=E6=97=B6=E9=97=B4=E8=A7=A3=E6=9E=90=E4=B8=8E?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=9B=9E=E5=A1=AB=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/common.js | 5 ++++- static/app/config-manager.js | 2 +- tests/rate-limit-cooldown.unit.test.js | 15 +++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/utils/common.js b/src/utils/common.js index 1ae8dacf4..28535ba30 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -138,7 +138,10 @@ function getRetryAfterMs(error, now = Date.now()) { const explicitDelay = parseDurationMs(error?.retryAfterMs); if (explicitDelay !== null) return explicitDelay; - const retryAfterDelay = parseRetryAfterMs(error?.retryAfter ?? error?.response?.data?.retryAfter ?? error?.response?.data?.retry_after, now); + const internalRetryAfterDelay = parseDurationMs(error?.retryAfter); + if (internalRetryAfterDelay !== null) return internalRetryAfterDelay; + + const retryAfterDelay = parseRetryAfterMs(error?.response?.data?.retryAfter ?? error?.response?.data?.retry_after, now); if (retryAfterDelay !== null) return retryAfterDelay; return getRetryDelayFromBody(error?.response?.data); diff --git a/static/app/config-manager.js b/static/app/config-manager.js index e2f10251f..18308d5bb 100644 --- a/static/app/config-manager.js +++ b/static/app/config-manager.js @@ -266,7 +266,7 @@ async function loadConfiguration() { const credentialSwitchMaxRetriesEl = document.getElementById('credentialSwitchMaxRetries'); if (credentialSwitchMaxRetriesEl) credentialSwitchMaxRetriesEl.value = data.CREDENTIAL_SWITCH_MAX_RETRIES || 5; if (rateLimitCooldownEnabledEl) rateLimitCooldownEnabledEl.checked = data.RATE_LIMIT_COOLDOWN_ENABLED || false; - if (rateLimitCooldownMsEl) rateLimitCooldownMsEl.value = data.RATE_LIMIT_COOLDOWN_MS || 30000; + if (rateLimitCooldownMsEl) rateLimitCooldownMsEl.value = data.RATE_LIMIT_COOLDOWN_MS ?? 30000; if (cronNearMinutesEl) cronNearMinutesEl.value = data.CRON_NEAR_MINUTES || 1; if (cronRefreshTokenEl) cronRefreshTokenEl.checked = data.CRON_REFRESH_TOKEN || false; diff --git a/tests/rate-limit-cooldown.unit.test.js b/tests/rate-limit-cooldown.unit.test.js index 07c1118d7..e5f5a3616 100644 --- a/tests/rate-limit-cooldown.unit.test.js +++ b/tests/rate-limit-cooldown.unit.test.js @@ -53,6 +53,21 @@ describe('rate-limit cooldown helper', () => { expect(recoveryTime.toISOString()).toBe('2026-04-22T00:00:10.000Z'); }); + test('treats internal error.retryAfter values as milliseconds', () => { + const recoveryTime = getRateLimitCooldownRecoveryTime( + { response: { status: 429 }, retryAfter: 60000 }, + { + RATE_LIMIT_COOLDOWN_ENABLED: true, + RATE_LIMIT_COOLDOWN_MS: 30000, + RATE_LIMIT_COOLDOWN_JITTER_MS: 0, + RATE_LIMIT_COOLDOWN_MAX_MS: 300000 + }, + NOW + ); + + expect(recoveryTime.toISOString()).toBe('2026-04-22T00:01:00.000Z'); + }); + test('caps excessive Retry-After values', () => { const recoveryTime = getRateLimitCooldownRecoveryTime( { response: { status: 429, headers: { 'retry-after': '9999' } } }, From e5ef690d1c026cd996c22d364b544e8be9dc2dfd Mon Sep 17 00:00:00 2001 From: emptyinkpot Date: Thu, 23 Apr 2026 19:49:30 +0800 Subject: [PATCH 043/135] feat(ui): add quick access page for keys and provider routes --- src/services/ui-manager.js | 6 + src/ui-modules/access-api.js | 111 ++++++++++ static/app/access-manager.js | 279 ++++++++++++++++++++++++++ static/app/app.js | 8 + static/app/component-loader.js | 3 +- static/app/config-manager.js | 4 + static/app/i18n.js | 82 ++++++++ static/app/navigation.js | 10 +- static/components/section-access.css | 266 ++++++++++++++++++++++++ static/components/section-access.html | 120 +++++++++++ static/components/sidebar.html | 5 +- tests/access-info.unit.test.js | 59 ++++++ 12 files changed, 950 insertions(+), 3 deletions(-) create mode 100644 src/ui-modules/access-api.js create mode 100644 static/app/access-manager.js create mode 100644 static/components/section-access.css create mode 100644 static/components/section-access.html create mode 100644 tests/access-info.unit.test.js diff --git a/src/services/ui-manager.js b/src/services/ui-manager.js index f2f660b60..237a0a830 100644 --- a/src/services/ui-manager.js +++ b/src/services/ui-manager.js @@ -12,6 +12,7 @@ import * as systemApi from '../ui-modules/system-api.js'; import * as updateApi from '../ui-modules/update-api.js'; import * as oauthApi from '../ui-modules/oauth-api.js'; import * as customModelsApi from '../ui-modules/custom-models-api.js'; +import * as accessApi from '../ui-modules/access-api.js'; import * as eventBroadcast from '../ui-modules/event-broadcast.js'; // Re-export from event-broadcast module @@ -99,6 +100,11 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo return await configApi.handleGetConfig(req, res, currentConfig); } + // Get access overview information for the simplified connection page + if (method === 'GET' && pathParam === '/api/access-info') { + return await accessApi.handleGetAccessInfo(req, res, currentConfig, providerPoolManager); + } + // Update configuration if (method === 'POST' && pathParam === '/api/config') { return await configApi.handleUpdateConfig(req, res, currentConfig); diff --git a/src/ui-modules/access-api.js b/src/ui-modules/access-api.js new file mode 100644 index 000000000..c5fd0e69f --- /dev/null +++ b/src/ui-modules/access-api.js @@ -0,0 +1,111 @@ +import { existsSync, readFileSync } from 'fs'; + +function getProviderPoolsFilePath(currentConfig) { + return currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; +} + +function getDefaultProviders(currentConfig) { + if (Array.isArray(currentConfig.DEFAULT_MODEL_PROVIDERS) && currentConfig.DEFAULT_MODEL_PROVIDERS.length > 0) { + return currentConfig.DEFAULT_MODEL_PROVIDERS.filter(Boolean); + } + + if (typeof currentConfig.MODEL_PROVIDER === 'string' && currentConfig.MODEL_PROVIDER.trim()) { + return currentConfig.MODEL_PROVIDER + .split(',') + .map(item => item.trim()) + .filter(Boolean); + } + + return []; +} + +function collectProviderStatus(currentConfig, providerPoolManager) { + const providerStatus = {}; + + if (providerPoolManager?.providerStatus) { + for (const [type, providers] of Object.entries(providerPoolManager.providerStatus)) { + providerStatus[type] = providers.map(provider => ({ + ...provider.config, + activeRequests: provider.state?.activeCount || 0, + waitingRequests: provider.state?.waitingCount || 0 + })); + } + } + + const filePath = getProviderPoolsFilePath(currentConfig); + if (existsSync(filePath)) { + try { + const poolsData = JSON.parse(readFileSync(filePath, 'utf-8')); + for (const [type, providers] of Object.entries(poolsData)) { + if (!providerStatus[type] || providerStatus[type].length === 0) { + providerStatus[type] = Array.isArray(providers) ? providers : []; + } + } + } catch (error) { + console.warn('[Access API] Failed to read provider pools file:', error.message); + } + } + + return providerStatus; +} + +function buildProviderSummaries(providerStatus, defaultProviders, registeredProviders = []) { + const supportedProviders = [...new Set([ + ...registeredProviders, + ...Object.keys(providerStatus) + ])]; + + const providerSummaries = supportedProviders.map(id => { + const providers = Array.isArray(providerStatus[id]) ? providerStatus[id] : []; + const totalNodes = providers.length; + const healthyNodes = providers.filter(provider => provider.isHealthy).length; + const disabledNodes = providers.filter(provider => provider.isDisabled).length; + + return { + id, + totalNodes, + healthyNodes, + enabledNodes: totalNodes - disabledNodes, + disabledNodes, + isDefault: defaultProviders.includes(id) + }; + }); + + providerSummaries.sort((left, right) => { + if (left.isDefault !== right.isDefault) { + return left.isDefault ? -1 : 1; + } + if (left.totalNodes !== right.totalNodes) { + return right.totalNodes - left.totalNodes; + } + return left.id.localeCompare(right.id); + }); + + return { + supportedProviders, + providerSummaries + }; +} + +export function buildAccessInfoPayload(currentConfig, providerPoolManager, registeredProviders = []) { + const defaultProviders = getDefaultProviders(currentConfig); + const providerStatus = collectProviderStatus(currentConfig, providerPoolManager); + const { supportedProviders, providerSummaries } = buildProviderSummaries(providerStatus, defaultProviders, registeredProviders); + + return { + apiKey: currentConfig.REQUIRED_API_KEY || '', + hasApiKey: Boolean(currentConfig.REQUIRED_API_KEY), + defaultProviders, + providerPoolsFilePath: getProviderPoolsFilePath(currentConfig), + supportedProviders, + providers: providerSummaries + }; +} + +export async function handleGetAccessInfo(req, res, currentConfig, providerPoolManager) { + const { getRegisteredProviders } = await import('../providers/adapter.js'); + const payload = buildAccessInfoPayload(currentConfig, providerPoolManager, getRegisteredProviders()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); + return true; +} diff --git a/static/app/access-manager.js b/static/app/access-manager.js new file mode 100644 index 000000000..7f32e6f42 --- /dev/null +++ b/static/app/access-manager.js @@ -0,0 +1,279 @@ +import { getProviderConfigs, copyToClipboard, showToast, escapeHtml } from './utils.js'; +import { getAvailableRoutes } from './routing-examples.js'; +import { t } from './i18n.js'; + +function getElement(id) { + return document.getElementById(id); +} + +function buildProviderConfigMap(supportedProviders) { + return new Map(getProviderConfigs(supportedProviders).map(config => [config.id, config])); +} + +function resolveRouteInfo(providerId, providerName) { + const routes = getAvailableRoutes(); + let route = routes.find(item => item.provider === providerId); + + if (!route) { + const baseRoute = routes.find(item => providerId.startsWith(item.provider + '-')); + route = { + provider: providerId, + name: providerName, + paths: { + openai: `/${providerId}/v1/chat/completions`, + claude: `/${providerId}/v1/messages` + }, + badge: baseRoute?.badge || '', + badgeClass: baseRoute?.badgeClass || '' + }; + } + + return route; +} + +function getOriginBaseUrl() { + return window.location.origin; +} + +function getFullEndpoint(path) { + return `${getOriginBaseUrl()}${path}`; +} + +function renderDefaultProviders(defaultProviders, configMap) { + const container = getElement('accessDefaultProviders'); + if (!container) { + return; + } + + if (!defaultProviders.length) { + container.innerHTML = `
${escapeHtml(t('access.empty.defaultProviders'))}
`; + return; + } + + container.innerHTML = defaultProviders.map(providerId => { + const config = configMap.get(providerId); + const name = config?.name || providerId; + const icon = config?.icon || 'fa-server'; + return ` + + + ${escapeHtml(name)} + + `; + }).join(''); +} + +function renderProviderCards(providers, defaultProviders, configMap) { + const container = getElement('accessProvidersTable'); + if (!container) { + return; + } + + if (!providers.length) { + container.innerHTML = `
${escapeHtml(t('access.empty.providers'))}
`; + return; + } + + container.innerHTML = providers.map(provider => { + const config = configMap.get(provider.id); + const name = config?.name || provider.id; + const icon = config?.icon || 'fa-server'; + const route = resolveRouteInfo(provider.id, name); + const openaiPath = route.paths?.openai; + const claudePath = route.paths?.claude; + const isDefault = defaultProviders.includes(provider.id); + const emptyClass = provider.totalNodes === 0 ? 'empty' : ''; + const emptyBadge = provider.totalNodes === 0 + ? `${escapeHtml(t('access.badges.empty'))}` + : ''; + const defaultBadge = isDefault + ? `${escapeHtml(t('access.badges.default'))}` + : ''; + + return ` +
+
+
+ +
+

${escapeHtml(name)}

+
${escapeHtml(provider.id)}
+
+
+
+ ${defaultBadge} + ${emptyBadge} +
+
+
+
+ ${escapeHtml(t('access.providers.totalNodes'))} + ${provider.totalNodes} +
+
+ ${escapeHtml(t('access.providers.healthyNodes'))} + ${provider.healthyNodes} +
+
+ ${escapeHtml(t('access.providers.disabledNodes'))} + ${provider.disabledNodes} +
+
+
+
+ ${escapeHtml(t('access.providers.openaiEndpoint'))} + ${escapeHtml(openaiPath ? getFullEndpoint(openaiPath) : t('access.empty.endpoint'))} + +
+
+ ${escapeHtml(t('access.providers.claudeEndpoint'))} + ${escapeHtml(claudePath ? getFullEndpoint(claudePath) : t('access.empty.endpoint'))} + +
+
+
+ `; + }).join(''); +} + +function updateStats(data) { + const providerGroupsCount = data.providers.length; + const totalNodesCount = data.providers.reduce((sum, provider) => sum + provider.totalNodes, 0); + + const keyStatus = data.hasApiKey + ? t('access.stats.keyReady') + : t('access.stats.keyMissing'); + + if (getElement('accessApiKeyStatus')) getElement('accessApiKeyStatus').textContent = keyStatus; + if (getElement('accessDefaultProvidersCount')) getElement('accessDefaultProvidersCount').textContent = data.defaultProviders.length; + if (getElement('accessProviderGroupsCount')) getElement('accessProviderGroupsCount').textContent = providerGroupsCount; + if (getElement('accessTotalNodesCount')) getElement('accessTotalNodesCount').textContent = totalNodesCount; +} + +function updateFields(data) { + const apiKeyField = getElement('accessApiKeyField'); + const baseUrlField = getElement('accessBaseUrlField'); + + if (apiKeyField) { + apiKeyField.value = data.apiKey || ''; + apiKeyField.placeholder = t('access.empty.key'); + } + + if (baseUrlField) { + baseUrlField.value = getOriginBaseUrl(); + } +} + +async function copyFromButton(button) { + const value = button.getAttribute('data-copy') || ''; + if (!value) { + showToast(t('common.error'), t('access.copy.missing'), 'error'); + return; + } + + const copied = await copyToClipboard(value); + if (copied) { + showToast(t('common.success'), t('common.copy.success'), 'success'); + } else { + showToast(t('common.error'), t('common.copy.failed'), 'error'); + } +} + +export async function loadAccessInfo() { + const container = getElement('accessProvidersTable'); + if (container) { + container.innerHTML = ` +
+ + ${escapeHtml(t('common.loading'))} +
+ `; + } + + try { + const data = await window.apiClient.get('/access-info'); + const configMap = buildProviderConfigMap(data.supportedProviders || []); + + updateStats(data); + updateFields(data); + renderDefaultProviders(data.defaultProviders || [], configMap); + renderProviderCards(data.providers || [], data.defaultProviders || [], configMap); + } catch (error) { + console.error('Failed to load access info:', error); + if (container) { + container.innerHTML = `
${escapeHtml(error.message || t('common.error'))}
`; + } + showToast(t('common.error'), t('access.load.failed', { error: error.message }), 'error'); + } +} + +export function initAccessManager() { + const refreshButton = getElement('refreshAccessInfo'); + if (refreshButton && !refreshButton.dataset.bound) { + refreshButton.addEventListener('click', () => loadAccessInfo()); + refreshButton.dataset.bound = 'true'; + } + + const toggleButton = getElement('toggleAccessApiKey'); + const apiKeyField = getElement('accessApiKeyField'); + if (toggleButton && apiKeyField && !toggleButton.dataset.bound) { + toggleButton.addEventListener('click', () => { + const icon = toggleButton.querySelector('i'); + const isPassword = apiKeyField.type === 'password'; + apiKeyField.type = isPassword ? 'text' : 'password'; + if (icon) { + icon.className = isPassword ? 'fas fa-eye-slash' : 'fas fa-eye'; + } + }); + toggleButton.dataset.bound = 'true'; + } + + const copyApiKeyButton = getElement('copyAccessApiKey'); + if (copyApiKeyButton && !copyApiKeyButton.dataset.bound) { + copyApiKeyButton.addEventListener('click', async () => { + const value = getElement('accessApiKeyField')?.value || ''; + if (!value) { + showToast(t('common.error'), t('access.copy.missing'), 'error'); + return; + } + + const copied = await copyToClipboard(value); + if (copied) { + showToast(t('common.success'), t('common.copy.success'), 'success'); + } else { + showToast(t('common.error'), t('common.copy.failed'), 'error'); + } + }); + copyApiKeyButton.dataset.bound = 'true'; + } + + const copyBaseUrlButton = getElement('copyAccessBaseUrl'); + if (copyBaseUrlButton && !copyBaseUrlButton.dataset.bound) { + copyBaseUrlButton.addEventListener('click', async () => { + const value = getElement('accessBaseUrlField')?.value || ''; + const copied = await copyToClipboard(value); + if (copied) { + showToast(t('common.success'), t('common.copy.success'), 'success'); + } else { + showToast(t('common.error'), t('common.copy.failed'), 'error'); + } + }); + copyBaseUrlButton.dataset.bound = 'true'; + } + + if (!document.body.dataset.accessCopyBound) { + document.body.addEventListener('click', async event => { + const button = event.target.closest('.access-copy-btn'); + if (button) { + await copyFromButton(button); + } + }); + document.body.dataset.accessCopyBound = 'true'; + } +} diff --git a/static/app/app.js b/static/app/app.js index 20e60734b..7646b34b3 100644 --- a/static/app/app.js +++ b/static/app/app.js @@ -60,6 +60,11 @@ import { initRoutingExamples } from './routing-examples.js'; +import { + initAccessManager, + loadAccessInfo +} from './access-manager.js'; + import { initUploadConfigManager, loadConfigList, @@ -99,6 +104,7 @@ function loadInitialData() { loadSystemInfo(); loadProviders(); loadConfiguration(); + loadAccessInfo(); if (window.customModelsManager) { window.customModelsManager.load(); } @@ -126,6 +132,7 @@ function initApp() { initEventStream(); initFileUpload(); // 初始化文件上传功能 initRoutingExamples(); // 初始化路径路由示例功能 + initAccessManager(); // 初始化快速接入页面 initUploadConfigManager(); // 初始化配置管理功能 initUsageManager(); // 初始化用量管理功能 initImageZoom(); // 初始化图片放大功能 @@ -255,6 +262,7 @@ window.closeConfigModal = closeConfigModal; window.copyConfigContent = copyConfigContent; window.reloadConfig = reloadConfig; window.generateApiKey = generateApiKey; +window.loadAccessInfo = loadAccessInfo; // 用量管理相关全局函数 window.refreshUsage = refreshUsage; diff --git a/static/app/component-loader.js b/static/app/component-loader.js index 54b447062..62a0e3e2e 100644 --- a/static/app/component-loader.js +++ b/static/app/component-loader.js @@ -100,6 +100,7 @@ async function initializeComponents() { // 最后加载所有 section 组件 const sectionComponents = [ { path: `${basePath}section-dashboard.html`, container: '#content-container', position: 'beforeend' }, + { path: `${basePath}section-access.html`, container: '#content-container', position: 'beforeend' }, { path: `${basePath}section-guide.html`, container: '#content-container', position: 'beforeend' }, { path: `${basePath}section-tutorial.html`, container: '#content-container', position: 'beforeend' }, { path: `${basePath}section-config.html`, container: '#content-container', position: 'beforeend' }, @@ -137,4 +138,4 @@ export { loadComponents, initializeComponents, clearComponentCache -}; \ No newline at end of file +}; diff --git a/static/app/config-manager.js b/static/app/config-manager.js index 1e3cf7f0c..dac7fef14 100644 --- a/static/app/config-manager.js +++ b/static/app/config-manager.js @@ -557,6 +557,10 @@ async function saveConfiguration() { await window.apiClient.post('/reload-config'); showToast(t('common.success'), t('common.configSaved'), 'success'); + + if (window.loadAccessInfo) { + await window.loadAccessInfo(); + } // 检查当前是否在提供商池管理页面,如果是则刷新数据 const providersSection = document.getElementById('providers'); diff --git a/static/app/i18n.js b/static/app/i18n.js index 4971b507f..d8696d059 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -26,6 +26,7 @@ const translations = { // Navigation 'nav.main': '主导航', 'nav.dashboard': '仪表盘', + 'nav.access': '快速接入', 'nav.guide': '使用指南', 'nav.tutorial': '配置教程', 'nav.config': '配置管理', @@ -106,6 +107,46 @@ const translations = { 'dashboard.contact.sponsorDesc': '您的赞助是项目持续发展的动力', 'dashboard.contact.coffee': 'Buy me a coffee', 'dashboard.contact.coffeeDesc': 'If you like this project, buy me a coffee!', + + // Access + 'access.title': '快速接入', + 'access.subtitle': '把对外分发 Key、基础地址和供应商路由集中到一个页面里,方便直接复制给客户端。', + 'access.stats.key': '对外 Key', + 'access.stats.defaultProviders': '默认供应商', + 'access.stats.providerGroups': '供应商分组', + 'access.stats.totalNodes': '节点总数', + 'access.stats.keyReady': '已配置', + 'access.stats.keyMissing': '未配置', + 'access.connection.title': '接入信息', + 'access.connection.apiKey': '当前对外分发 Key', + 'access.connection.apiKeyHelp': '这是客户端调用 `/v1/chat/completions` 或 `/v1/messages` 时要带的认证 Key。', + 'access.connection.baseUrl': '当前管理台访问地址', + 'access.connection.baseUrlHelp': '通常把这个地址加上供应商路径后,直接发给客户端即可。', + 'access.connection.defaultProviders': '当前默认供应商', + 'access.quickStart.title': '快速说明', + 'access.quickStart.step1Title': '1. 先复制 Key', + 'access.quickStart.step1Desc': '把上面的对外 Key 发给客户端,客户端请求头里带 `Authorization: Bearer KEY`。', + 'access.quickStart.step2Title': '2. 再选供应商路由', + 'access.quickStart.step2Desc': '不同供应商对应不同路径,例如 `openai-codex-oauth`、`gemini-cli-oauth`。', + 'access.quickStart.step3Title': '3. 直接复制端点', + 'access.quickStart.step3Desc': '下面每一行都给了 OpenAI / Claude 两种协议端点,点按钮即可复制。', + 'access.providers.title': '供应商与调用端点', + 'access.providers.totalNodes': '节点总数', + 'access.providers.healthyNodes': '健康节点', + 'access.providers.disabledNodes': '禁用节点', + 'access.providers.openaiEndpoint': 'OpenAI 端点', + 'access.providers.claudeEndpoint': 'Claude 端点', + 'access.actions.copyKey': '复制 Key', + 'access.actions.copyBaseUrl': '复制地址', + 'access.actions.copyEndpoint': '复制端点', + 'access.badges.default': '默认', + 'access.badges.empty': '未配置节点', + 'access.empty.key': '当前还没有设置对外 Key', + 'access.empty.endpoint': '当前没有可用端点', + 'access.empty.defaultProviders': '当前没有默认供应商', + 'access.empty.providers': '当前没有供应商信息', + 'access.copy.missing': '当前没有可复制的内容', + 'access.load.failed': '加载快速接入信息失败: {error}', // OAuth 'oauth.modal.title': 'OAuth 授权', @@ -982,6 +1023,7 @@ const translations = { // Navigation 'nav.main': 'Main Navigation', 'nav.dashboard': 'Dashboard', + 'nav.access': 'Quick Access', 'nav.guide': 'User Guide', 'nav.tutorial': 'Configuration Tutorial', 'nav.config': 'Configuration', @@ -1062,6 +1104,46 @@ const translations = { 'dashboard.contact.sponsorDesc': 'Your support is the driving force for the project\'s continuous development', 'dashboard.contact.coffee': 'Buy me a coffee', 'dashboard.contact.coffeeDesc': 'If you like this project, buy me a coffee!', + + // Access + 'access.title': 'Quick Access', + 'access.subtitle': 'Keep the public API key, base URL, and provider routes in one place so they can be copied straight into a client.', + 'access.stats.key': 'Public Key', + 'access.stats.defaultProviders': 'Default Providers', + 'access.stats.providerGroups': 'Provider Groups', + 'access.stats.totalNodes': 'Total Nodes', + 'access.stats.keyReady': 'Ready', + 'access.stats.keyMissing': 'Missing', + 'access.connection.title': 'Connection Info', + 'access.connection.apiKey': 'Current Public API Key', + 'access.connection.apiKeyHelp': 'This is the key clients must send when calling `/v1/chat/completions` or `/v1/messages`.', + 'access.connection.baseUrl': 'Current Console Base URL', + 'access.connection.baseUrlHelp': 'Usually you can append a provider route to this base URL and send it directly to the client.', + 'access.connection.defaultProviders': 'Current Default Providers', + 'access.quickStart.title': 'Quick Guide', + 'access.quickStart.step1Title': '1. Copy the key first', + 'access.quickStart.step1Desc': 'Share the public key with the client and send it in the `Authorization: Bearer KEY` header.', + 'access.quickStart.step2Title': '2. Pick a provider route', + 'access.quickStart.step2Desc': 'Each provider uses a different path, such as `openai-codex-oauth` or `gemini-cli-oauth`.', + 'access.quickStart.step3Title': '3. Copy the final endpoint', + 'access.quickStart.step3Desc': 'Each row below gives OpenAI and Claude protocol endpoints, ready to copy.', + 'access.providers.title': 'Providers & Endpoints', + 'access.providers.totalNodes': 'Total Nodes', + 'access.providers.healthyNodes': 'Healthy Nodes', + 'access.providers.disabledNodes': 'Disabled Nodes', + 'access.providers.openaiEndpoint': 'OpenAI Endpoint', + 'access.providers.claudeEndpoint': 'Claude Endpoint', + 'access.actions.copyKey': 'Copy Key', + 'access.actions.copyBaseUrl': 'Copy URL', + 'access.actions.copyEndpoint': 'Copy Endpoint', + 'access.badges.default': 'Default', + 'access.badges.empty': 'No Nodes', + 'access.empty.key': 'No public API key configured yet', + 'access.empty.endpoint': 'No endpoint available', + 'access.empty.defaultProviders': 'No default providers configured', + 'access.empty.providers': 'No provider information available', + 'access.copy.missing': 'Nothing to copy right now', + 'access.load.failed': 'Failed to load quick access info: {error}', // OAuth 'oauth.modal.title': 'OAuth Authorization', diff --git a/static/app/navigation.js b/static/app/navigation.js index 142b80ae9..9c6376bb7 100644 --- a/static/app/navigation.js +++ b/static/app/navigation.js @@ -40,6 +40,10 @@ function initNavigation() { // 滚动到顶部 scrollToTop(); + + if (sectionId === 'access' && typeof window.loadAccessInfo === 'function') { + window.loadAccessInfo(); + } }); }); } @@ -77,6 +81,10 @@ function switchToSection(sectionId) { // 滚动到顶部 scrollToTop(); + + if (sectionId === 'access' && typeof window.loadAccessInfo === 'function') { + window.loadAccessInfo(); + } } /** @@ -112,4 +120,4 @@ export { switchToSection, switchToDashboard, switchToProviders -}; \ No newline at end of file +}; diff --git a/static/components/section-access.css b/static/components/section-access.css new file mode 100644 index 000000000..95b59860c --- /dev/null +++ b/static/components/section-access.css @@ -0,0 +1,266 @@ +.access-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.access-subtitle { + color: var(--text-secondary); + max-width: 68ch; + line-height: 1.6; +} + +.access-stats { + margin-bottom: 1.5rem; +} + +.access-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.5rem; + margin-bottom: 1.5rem; +} + +.access-panel { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-sm); + padding: 1.5rem; +} + +.access-panel-full { + margin-top: 0; +} + +.access-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.access-panel-header h3 { + margin: 0; + font-size: 1.1rem; +} + +.access-field-group { + margin-bottom: 1.25rem; +} + +.access-field-group:last-child { + margin-bottom: 0; +} + +.access-copy-row { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.access-copy-row .form-control, +.access-copy-row .password-input-wrapper { + flex: 1; +} + +.access-chip-list { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.access-chip { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: var(--primary-10); + color: var(--primary-color); + border: 1px solid var(--primary-20); + border-radius: 999px; + padding: 0.5rem 0.85rem; + font-size: 0.875rem; + font-weight: 600; +} + +.access-hint-list { + display: grid; + gap: 0.875rem; +} + +.access-hint-card { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 1rem; +} + +.access-hint-card strong { + display: block; + margin-bottom: 0.35rem; +} + +.access-hint-card p { + color: var(--text-secondary); + line-height: 1.55; + margin: 0; +} + +.access-provider-list { + display: grid; + gap: 1rem; +} + +.access-provider-card { + background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%); + border: 1px solid var(--border-color); + border-radius: var(--radius-xl); + padding: 1.25rem; + display: grid; + gap: 1rem; +} + +.access-provider-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.access-provider-name { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.access-provider-name i { + width: 42px; + height: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-lg); + background: var(--primary-10); + color: var(--primary-color); +} + +.access-provider-name h4 { + margin: 0 0 0.2rem; + font-size: 1rem; +} + +.access-provider-id { + color: var(--text-secondary); + font-size: 0.875rem; +} + +.access-provider-badges { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.5rem; +} + +.access-badge { + display: inline-flex; + align-items: center; + gap: 0.4rem; + border-radius: 999px; + padding: 0.35rem 0.75rem; + font-size: 0.8rem; + font-weight: 600; +} + +.access-badge.default { + background: rgba(5, 150, 105, 0.12); + color: #047857; +} + +.access-badge.empty { + background: rgba(148, 163, 184, 0.14); + color: var(--text-secondary); +} + +.access-provider-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; +} + +.access-provider-stat { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 0.85rem; +} + +.access-provider-stat span { + display: block; + color: var(--text-secondary); + font-size: 0.8rem; + margin-bottom: 0.3rem; +} + +.access-provider-stat strong { + font-size: 1.25rem; +} + +.access-endpoints { + display: grid; + gap: 0.75rem; +} + +.access-endpoint-row { + display: grid; + grid-template-columns: 90px minmax(0, 1fr) auto; + align-items: center; + gap: 0.75rem; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 0.85rem; +} + +.access-endpoint-row code { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: block; +} + +.access-empty { + color: var(--text-secondary); + text-align: center; + padding: 2rem 1rem; + border: 1px dashed var(--border-color); + border-radius: var(--radius-xl); +} + +@media (max-width: 1024px) { + .access-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .access-header, + .access-provider-head, + .access-copy-row { + flex-direction: column; + align-items: stretch; + } + + .access-provider-badges { + justify-content: flex-start; + } + + .access-provider-stats { + grid-template-columns: 1fr; + } + + .access-endpoint-row { + grid-template-columns: 1fr; + } +} diff --git a/static/components/section-access.html b/static/components/section-access.html new file mode 100644 index 000000000..d69cd59ac --- /dev/null +++ b/static/components/section-access.html @@ -0,0 +1,120 @@ + +
+
+
+

快速接入

+

把对外分发 Key、基础地址和供应商路由集中到一个页面里,方便直接复制给客户端。

+
+ +
+ +
+
+
+
+

--

+

对外 Key

+
+
+
+
+
+

0

+

默认供应商

+
+
+
+
+
+

0

+

供应商分组

+
+
+
+
+
+

0

+

节点总数

+
+
+
+ +
+
+
+

接入信息

+
+ +
+ +
+
+
+ + +
+
+ +
+ 这是客户端调用 `/v1/chat/completions` 或 `/v1/messages` 时要带的认证 Key。 +
+ +
+ +
+ + +
+ 通常把这个地址加上供应商路径后,直接发给客户端即可。 +
+ +
+ +
+
+
+ +
+
+

快速说明

+
+
+
+ 1. 先复制 Key +

把上面的对外 Key 发给客户端,客户端请求头里带 `Authorization: Bearer KEY`。

+
+
+ 2. 再选供应商路由 +

不同供应商对应不同路径,例如 `openai-codex-oauth`、`gemini-cli-oauth`。

+
+
+ 3. 直接复制端点 +

下面每一行都给了 OpenAI / Claude 两种协议端点,点按钮即可复制。

+
+
+
+
+ +
+
+

供应商与调用端点

+
+
+
+ + 加载中... +
+
+
+
diff --git a/static/components/sidebar.html b/static/components/sidebar.html index 658a107a6..20d070b1c 100644 --- a/static/components/sidebar.html +++ b/static/components/sidebar.html @@ -5,6 +5,9 @@ 仪表盘 + + 快速接入 + 使用指南 @@ -33,4 +36,4 @@ 实时日志 - \ No newline at end of file + diff --git a/tests/access-info.unit.test.js b/tests/access-info.unit.test.js new file mode 100644 index 000000000..b2fc9120b --- /dev/null +++ b/tests/access-info.unit.test.js @@ -0,0 +1,59 @@ +import { describe, test, expect } from '@jest/globals'; + +import { buildAccessInfoPayload } from '../src/ui-modules/access-api.js'; + +describe('buildAccessInfoPayload', () => { + test('should expose the real public api key and default providers for the access page', () => { + const currentConfig = { + REQUIRED_API_KEY: 'sk-live-demo-key', + DEFAULT_MODEL_PROVIDERS: ['openai-codex-oauth', 'gemini-cli-oauth'], + PROVIDER_POOLS_FILE_PATH: 'configs/provider_pools.json' + }; + + const providerPoolManager = { + providerStatus: { + 'openai-codex-oauth': [ + { config: { uuid: 'codex-1', isHealthy: true, isDisabled: false }, state: {} }, + { config: { uuid: 'codex-2', isHealthy: false, isDisabled: true }, state: {} } + ], + 'gemini-cli-oauth': [ + { config: { uuid: 'gemini-1', isHealthy: true, isDisabled: false }, state: {} } + ] + } + }; + + const payload = buildAccessInfoPayload(currentConfig, providerPoolManager); + + expect(payload.apiKey).toBe('sk-live-demo-key'); + expect(payload.hasApiKey).toBe(true); + expect(payload.defaultProviders).toEqual(['openai-codex-oauth', 'gemini-cli-oauth']); + + const codexSummary = payload.providers.find(item => item.id === 'openai-codex-oauth'); + expect(codexSummary).toEqual(expect.objectContaining({ + totalNodes: 2, + healthyNodes: 1, + disabledNodes: 1, + enabledNodes: 1, + isDefault: true + })); + + const geminiSummary = payload.providers.find(item => item.id === 'gemini-cli-oauth'); + expect(geminiSummary).toEqual(expect.objectContaining({ + totalNodes: 1, + healthyNodes: 1, + disabledNodes: 0, + enabledNodes: 1, + isDefault: true + })); + }); + + test('should fall back to MODEL_PROVIDER when DEFAULT_MODEL_PROVIDERS is missing', () => { + const payload = buildAccessInfoPayload({ + REQUIRED_API_KEY: '', + MODEL_PROVIDER: 'openai-custom,claude-custom' + }, { providerStatus: {} }); + + expect(payload.hasApiKey).toBe(false); + expect(payload.defaultProviders).toEqual(['openai-custom', 'claude-custom']); + }); +}); From 17ed249071aa4504f7039e3498112361506a48aa Mon Sep 17 00:00:00 2001 From: emptyinkpot Date: Thu, 23 Apr 2026 20:26:35 +0800 Subject: [PATCH 044/135] Add client snippets to quick access page --- static/app/access-manager.js | 212 ++++++++++++++++++++++++-- static/app/i18n.js | 10 ++ static/components/section-access.css | 93 ++++++++++- static/components/section-access.html | 48 ++++++ 4 files changed, 351 insertions(+), 12 deletions(-) diff --git a/static/app/access-manager.js b/static/app/access-manager.js index 7f32e6f42..986f05773 100644 --- a/static/app/access-manager.js +++ b/static/app/access-manager.js @@ -2,6 +2,22 @@ import { getProviderConfigs, copyToClipboard, showToast, escapeHtml } from './ut import { getAvailableRoutes } from './routing-examples.js'; import { t } from './i18n.js'; +let latestAccessData = null; + +const recommendedModelMap = { + 'gemini-cli-oauth': 'gemini-3-flash-preview', + 'gemini-antigravity': 'gemini-3-flash-preview', + 'claude-custom': 'claude-sonnet-4-6', + 'claude-kiro-oauth': 'claude-sonnet-4-6', + 'openai-custom': 'gpt-4o', + 'openai-qwen-oauth': 'qwen3-coder-plus', + 'openai-iflow': 'qwen3-max', + 'openai-codex-oauth': 'gpt-5', + 'grok-custom': 'grok-4.1-mini', + 'openaiResponses-custom': 'gpt-4o', + 'forward-api': 'gpt-4o' +}; + function getElement(id) { return document.getElementById(id); } @@ -39,6 +55,44 @@ function getFullEndpoint(path) { return `${getOriginBaseUrl()}${path}`; } +function getClientBaseUrl(path) { + if (!path) { + return getOriginBaseUrl(); + } + + const normalizedPath = path + .replace(/\/v1\/chat\/completions$/, '/v1') + .replace(/\/v1\/messages$/, '/v1') + .replace(/\/v1\/responses$/, '/v1'); + + return `${getOriginBaseUrl()}${normalizedPath}`; +} + +function getConfiguredProviders(providers) { + return providers.filter(provider => provider.totalNodes > 0); +} + +function getVisibleProviders(providers) { + const configuredOnly = getElement('accessConfiguredOnly')?.checked !== false; + if (!configuredOnly) { + return providers; + } + return getConfiguredProviders(providers); +} + +function getRecommendedModel(providerId) { + if (recommendedModelMap[providerId]) { + return recommendedModelMap[providerId]; + } + + const matchedBaseId = Object.keys(recommendedModelMap).find(baseId => providerId.startsWith(baseId + '-')); + if (matchedBaseId) { + return recommendedModelMap[matchedBaseId]; + } + + return 'gpt-4o'; +} + function renderDefaultProviders(defaultProviders, configMap) { const container = getElement('accessDefaultProviders'); if (!container) { @@ -70,7 +124,11 @@ function renderProviderCards(providers, defaultProviders, configMap) { } if (!providers.length) { - container.innerHTML = `
${escapeHtml(t('access.empty.providers'))}
`; + const configuredOnly = getElement('accessConfiguredOnly')?.checked !== false; + const emptyText = configuredOnly + ? t('access.empty.providersConfiguredOnly') + : t('access.empty.providers'); + container.innerHTML = `
${escapeHtml(emptyText)}
`; return; } @@ -170,6 +228,93 @@ function updateFields(data) { } } +function renderSnippetProviderOptions(data, configMap) { + const select = getElement('accessSnippetProvider'); + if (!select) { + return; + } + + const preferredProviders = getConfiguredProviders(data.providers); + const fallbackProviders = data.providers; + const optionsSource = preferredProviders.length > 0 ? preferredProviders : fallbackProviders; + const currentValue = select.value; + const preferredSelected = optionsSource.find(provider => provider.id === currentValue) + ? currentValue + : (data.defaultProviders.find(id => optionsSource.some(provider => provider.id === id)) || optionsSource[0]?.id || ''); + + select.innerHTML = optionsSource.map(provider => { + const config = configMap.get(provider.id); + const name = config?.name || provider.id; + return ``; + }).join(''); + + if (preferredSelected) { + select.value = preferredSelected; + } +} + +function buildCherryStudioSnippet(providerName, baseUrl, apiKey, model) { + return [ + '[Cherry Studio]', + 'Provider: OpenAI Compatible', + `Name: AIClient2API (${providerName})`, + `Base URL: ${baseUrl}`, + `API Key: ${apiKey || t('access.empty.key')}`, + `Model: ${model}` + ].join('\n'); +} + +function buildNextChatSnippet(baseUrl, apiKey, model) { + return [ + '# NextChat', + `OPENAI_API_KEY=${apiKey || 'YOUR_API_KEY'}`, + `OPENAI_BASE_URL=${baseUrl}`, + `CUSTOM_MODELS=${model}` + ].join('\n'); +} + +function buildClineSnippet(providerName, baseUrl, apiKey, model) { + return [ + '[Cline]', + 'API Provider: OpenAI Compatible', + `Profile Name: AIClient2API (${providerName})`, + `Base URL: ${baseUrl}`, + `API Key: ${apiKey || 'YOUR_API_KEY'}`, + `Model ID: ${model}` + ].join('\n'); +} + +function renderClientSnippets(data, configMap) { + const select = getElement('accessSnippetProvider'); + const cherryNode = getElement('accessCherrySnippet'); + const nextChatNode = getElement('accessNextChatSnippet'); + const clineNode = getElement('accessClineSnippet'); + + if (!select || !cherryNode || !nextChatNode || !clineNode) { + return; + } + + const providerId = select.value; + const provider = data.providers.find(item => item.id === providerId) || data.providers[0]; + if (!provider) { + cherryNode.textContent = t('access.empty.providers'); + nextChatNode.textContent = t('access.empty.providers'); + clineNode.textContent = t('access.empty.providers'); + return; + } + + const config = configMap.get(provider.id); + const providerName = config?.name || provider.id; + const route = resolveRouteInfo(provider.id, providerName); + const baseUrl = getClientBaseUrl(route.paths?.openai || route.paths?.claude); + const model = getRecommendedModel(provider.id); + const apiKey = data.apiKey || ''; + + cherryNode.textContent = buildCherryStudioSnippet(providerName, baseUrl, apiKey, model); + nextChatNode.textContent = buildNextChatSnippet(baseUrl, apiKey, model); + clineNode.textContent = buildClineSnippet(providerName, baseUrl, apiKey, model); +} + async function copyFromButton(button) { const value = button.getAttribute('data-copy') || ''; if (!value) { @@ -185,6 +330,18 @@ async function copyFromButton(button) { } } +function renderAccessPage(data) { + const configMap = buildProviderConfigMap(data.supportedProviders || []); + const visibleProviders = getVisibleProviders(data.providers || []); + + updateStats(data); + updateFields(data); + renderDefaultProviders(data.defaultProviders || [], configMap); + renderSnippetProviderOptions(data, configMap); + renderClientSnippets(data, configMap); + renderProviderCards(visibleProviders, data.defaultProviders || [], configMap); +} + export async function loadAccessInfo() { const container = getElement('accessProvidersTable'); if (container) { @@ -197,13 +354,8 @@ export async function loadAccessInfo() { } try { - const data = await window.apiClient.get('/access-info'); - const configMap = buildProviderConfigMap(data.supportedProviders || []); - - updateStats(data); - updateFields(data); - renderDefaultProviders(data.defaultProviders || [], configMap); - renderProviderCards(data.providers || [], data.defaultProviders || [], configMap); + latestAccessData = await window.apiClient.get('/access-info'); + renderAccessPage(latestAccessData); } catch (error) { console.error('Failed to load access info:', error); if (container) { @@ -267,11 +419,49 @@ export function initAccessManager() { copyBaseUrlButton.dataset.bound = 'true'; } + const configuredOnlyToggle = getElement('accessConfiguredOnly'); + if (configuredOnlyToggle && !configuredOnlyToggle.dataset.bound) { + configuredOnlyToggle.addEventListener('change', () => { + if (latestAccessData) { + renderAccessPage(latestAccessData); + } + }); + configuredOnlyToggle.dataset.bound = 'true'; + } + + const snippetProviderSelect = getElement('accessSnippetProvider'); + if (snippetProviderSelect && !snippetProviderSelect.dataset.bound) { + snippetProviderSelect.addEventListener('change', () => { + if (latestAccessData) { + const configMap = buildProviderConfigMap(latestAccessData.supportedProviders || []); + renderClientSnippets(latestAccessData, configMap); + } + }); + snippetProviderSelect.dataset.bound = 'true'; + } + if (!document.body.dataset.accessCopyBound) { document.body.addEventListener('click', async event => { - const button = event.target.closest('.access-copy-btn'); - if (button) { - await copyFromButton(button); + const endpointButton = event.target.closest('.access-copy-btn'); + if (endpointButton) { + await copyFromButton(endpointButton); + return; + } + + const snippetButton = event.target.closest('.access-snippet-copy-btn'); + if (snippetButton) { + const targetId = snippetButton.getAttribute('data-target'); + const text = getElement(targetId)?.textContent || ''; + if (!text) { + showToast(t('common.error'), t('access.copy.missing'), 'error'); + return; + } + const copied = await copyToClipboard(text); + if (copied) { + showToast(t('common.success'), t('common.copy.success'), 'success'); + } else { + showToast(t('common.error'), t('common.copy.failed'), 'error'); + } } }); document.body.dataset.accessCopyBound = 'true'; diff --git a/static/app/i18n.js b/static/app/i18n.js index d8696d059..1db1e340d 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -123,6 +123,8 @@ const translations = { 'access.connection.baseUrl': '当前管理台访问地址', 'access.connection.baseUrlHelp': '通常把这个地址加上供应商路径后,直接发给客户端即可。', 'access.connection.defaultProviders': '当前默认供应商', + 'access.snippets.title': '客户端配置片段', + 'access.snippets.provider': '用于生成片段的供应商', 'access.quickStart.title': '快速说明', 'access.quickStart.step1Title': '1. 先复制 Key', 'access.quickStart.step1Desc': '把上面的对外 Key 发给客户端,客户端请求头里带 `Authorization: Bearer KEY`。', @@ -134,17 +136,20 @@ const translations = { 'access.providers.totalNodes': '节点总数', 'access.providers.healthyNodes': '健康节点', 'access.providers.disabledNodes': '禁用节点', + 'access.providers.configuredOnly': '只显示已配置供应商', 'access.providers.openaiEndpoint': 'OpenAI 端点', 'access.providers.claudeEndpoint': 'Claude 端点', 'access.actions.copyKey': '复制 Key', 'access.actions.copyBaseUrl': '复制地址', 'access.actions.copyEndpoint': '复制端点', + 'access.actions.copySnippet': '复制片段', 'access.badges.default': '默认', 'access.badges.empty': '未配置节点', 'access.empty.key': '当前还没有设置对外 Key', 'access.empty.endpoint': '当前没有可用端点', 'access.empty.defaultProviders': '当前没有默认供应商', 'access.empty.providers': '当前没有供应商信息', + 'access.empty.providersConfiguredOnly': '当前没有已配置节点的供应商,可先关闭“只显示已配置供应商”查看全部路由。', 'access.copy.missing': '当前没有可复制的内容', 'access.load.failed': '加载快速接入信息失败: {error}', @@ -1120,6 +1125,8 @@ const translations = { 'access.connection.baseUrl': 'Current Console Base URL', 'access.connection.baseUrlHelp': 'Usually you can append a provider route to this base URL and send it directly to the client.', 'access.connection.defaultProviders': 'Current Default Providers', + 'access.snippets.title': 'Client Config Snippets', + 'access.snippets.provider': 'Provider used for snippet generation', 'access.quickStart.title': 'Quick Guide', 'access.quickStart.step1Title': '1. Copy the key first', 'access.quickStart.step1Desc': 'Share the public key with the client and send it in the `Authorization: Bearer KEY` header.', @@ -1131,17 +1138,20 @@ const translations = { 'access.providers.totalNodes': 'Total Nodes', 'access.providers.healthyNodes': 'Healthy Nodes', 'access.providers.disabledNodes': 'Disabled Nodes', + 'access.providers.configuredOnly': 'Configured providers only', 'access.providers.openaiEndpoint': 'OpenAI Endpoint', 'access.providers.claudeEndpoint': 'Claude Endpoint', 'access.actions.copyKey': 'Copy Key', 'access.actions.copyBaseUrl': 'Copy URL', 'access.actions.copyEndpoint': 'Copy Endpoint', + 'access.actions.copySnippet': 'Copy Snippet', 'access.badges.default': 'Default', 'access.badges.empty': 'No Nodes', 'access.empty.key': 'No public API key configured yet', 'access.empty.endpoint': 'No endpoint available', 'access.empty.defaultProviders': 'No default providers configured', 'access.empty.providers': 'No provider information available', + 'access.empty.providersConfiguredOnly': 'No providers with configured nodes yet. Turn off "Configured providers only" to view all routes.', 'access.copy.missing': 'Nothing to copy right now', 'access.load.failed': 'Failed to load quick access info: {error}', diff --git a/static/components/section-access.css b/static/components/section-access.css index 95b59860c..111774b21 100644 --- a/static/components/section-access.css +++ b/static/components/section-access.css @@ -72,6 +72,70 @@ gap: 0.75rem; } +.access-snippet-toolbar { + display: flex; + justify-content: space-between; + align-items: end; + gap: 1rem; + margin-bottom: 1rem; +} + +.access-snippet-provider { + min-width: min(420px, 100%); +} + +.access-snippet-provider label { + display: block; + margin-bottom: 0.5rem; + color: var(--text-secondary); + font-size: 0.875rem; +} + +.access-snippet-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; +} + +.access-snippet-card { + border: 1px solid var(--border-color); + border-radius: var(--radius-xl); + background: var(--bg-secondary); + padding: 1rem; + display: grid; + gap: 0.75rem; +} + +.access-snippet-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; +} + +.access-snippet-head h4 { + margin: 0; + font-size: 1rem; +} + +.access-snippet-card pre { + margin: 0; + padding: 1rem; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + overflow-x: auto; + min-height: 210px; +} + +.access-snippet-card code { + white-space: pre-wrap; + word-break: break-word; + line-height: 1.6; + font-size: 0.85rem; + display: block; +} + .access-chip { display: inline-flex; align-items: center; @@ -163,6 +227,21 @@ gap: 0.5rem; } +.access-filter-toggle { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: var(--text-secondary); + font-size: 0.875rem; + cursor: pointer; + user-select: none; +} + +.access-filter-toggle input { + width: 16px; + height: 16px; +} + .access-badge { display: inline-flex; align-items: center; @@ -242,12 +321,18 @@ .access-grid { grid-template-columns: 1fr; } + + .access-snippet-grid { + grid-template-columns: 1fr; + } } @media (max-width: 768px) { .access-header, .access-provider-head, - .access-copy-row { + .access-copy-row, + .access-snippet-toolbar, + .access-snippet-head { flex-direction: column; align-items: stretch; } @@ -263,4 +348,10 @@ .access-endpoint-row { grid-template-columns: 1fr; } + + .access-panel-header { + align-items: stretch; + gap: 0.75rem; + flex-direction: column; + } } diff --git a/static/components/section-access.html b/static/components/section-access.html index d69cd59ac..8fef5bc46 100644 --- a/static/components/section-access.html +++ b/static/components/section-access.html @@ -106,9 +106,57 @@

+
+
+

客户端配置片段

+
+
+
+ + +
+
+
+
+
+

Cherry Studio

+ +
+
+
+
+
+

NextChat

+ +
+
+
+
+
+

Cline

+ +
+
+
+
+
+

供应商与调用端点

+
From da165ea26f2872f3d51d848590a1aff3908d98dd Mon Sep 17 00:00:00 2001 From: emptyinkpot Date: Thu, 23 Apr 2026 20:57:32 +0800 Subject: [PATCH 045/135] Polish quick access snippets and deep links --- static/app/access-manager.js | 181 ++++++++++++++++++++++---- static/app/i18n.js | 18 +++ static/app/navigation.js | 67 +++++----- static/components/section-access.css | 82 +++++++++++- static/components/section-access.html | 36 ++++- 5 files changed, 320 insertions(+), 64 deletions(-) diff --git a/static/app/access-manager.js b/static/app/access-manager.js index 986f05773..9f50ff508 100644 --- a/static/app/access-manager.js +++ b/static/app/access-manager.js @@ -3,6 +3,7 @@ import { getAvailableRoutes } from './routing-examples.js'; import { t } from './i18n.js'; let latestAccessData = null; +let latestSnippetFormat = 'markdown'; const recommendedModelMap = { 'gemini-cli-oauth': 'gemini-3-flash-preview', @@ -22,6 +23,22 @@ function getElement(id) { return document.getElementById(id); } +function normalizeSnippetFormat(format) { + return ['markdown', 'env', 'json'].includes(format) ? format : 'markdown'; +} + +function getSelectedSnippetFormat() { + const activeButton = document.querySelector('.access-format-btn.active'); + return normalizeSnippetFormat(activeButton?.dataset.format || latestSnippetFormat); +} + +function setSelectedSnippetFormat(format) { + latestSnippetFormat = normalizeSnippetFormat(format); + document.querySelectorAll('.access-format-btn').forEach(button => { + button.classList.toggle('active', button.dataset.format === latestSnippetFormat); + }); +} + function buildProviderConfigMap(supportedProviders) { return new Map(getProviderConfigs(supportedProviders).map(config => [config.id, config])); } @@ -93,6 +110,21 @@ function getRecommendedModel(providerId) { return 'gpt-4o'; } +function toPrettyJson(value) { + return JSON.stringify(value, null, 2); +} + +function buildMarkdownSnippet(title, entries) { + return [ + `# ${title}`, + ...entries.map(([label, value]) => `- ${label}: ${value}`) + ].join('\n'); +} + +function buildEnvSnippet(entries) { + return entries.map(([key, value]) => `${key}=${value}`).join('\n'); +} + function renderDefaultProviders(defaultProviders, configMap) { const container = getElement('accessDefaultProviders'); if (!container) { @@ -253,35 +285,100 @@ function renderSnippetProviderOptions(data, configMap) { } } -function buildCherryStudioSnippet(providerName, baseUrl, apiKey, model) { - return [ - '[Cherry Studio]', - 'Provider: OpenAI Compatible', - `Name: AIClient2API (${providerName})`, - `Base URL: ${baseUrl}`, - `API Key: ${apiKey || t('access.empty.key')}`, - `Model: ${model}` - ].join('\n'); +function buildCherryStudioSnippet(providerName, baseUrl, apiKey, model, format) { + const resolvedApiKey = apiKey || 'YOUR_API_KEY'; + + if (format === 'json') { + return toPrettyJson({ + client: 'Cherry Studio', + providerType: 'OpenAI Compatible', + name: `AIClient2API (${providerName})`, + baseUrl, + apiKey: resolvedApiKey, + model + }); + } + + if (format === 'env') { + return buildEnvSnippet([ + ['CHERRY_STUDIO_PROVIDER_TYPE', 'OpenAI Compatible'], + ['CHERRY_STUDIO_NAME', `AIClient2API (${providerName})`], + ['CHERRY_STUDIO_BASE_URL', baseUrl], + ['CHERRY_STUDIO_API_KEY', resolvedApiKey], + ['CHERRY_STUDIO_MODEL', model] + ]); + } + + return buildMarkdownSnippet('Cherry Studio', [ + ['Provider Type', 'OpenAI Compatible'], + ['Display Name', `AIClient2API (${providerName})`], + ['Base URL', baseUrl], + ['API Key', resolvedApiKey], + ['Model', model] + ]); } -function buildNextChatSnippet(baseUrl, apiKey, model) { - return [ - '# NextChat', - `OPENAI_API_KEY=${apiKey || 'YOUR_API_KEY'}`, - `OPENAI_BASE_URL=${baseUrl}`, - `CUSTOM_MODELS=${model}` - ].join('\n'); +function buildNextChatSnippet(baseUrl, apiKey, model, format) { + const resolvedApiKey = apiKey || 'YOUR_API_KEY'; + + if (format === 'json') { + return toPrettyJson({ + client: 'NextChat', + openAIApiKey: resolvedApiKey, + openAIBaseUrl: baseUrl, + customModels: model, + defaultModel: model + }); + } + + if (format === 'env') { + return buildEnvSnippet([ + ['NEXTCHAT_OPENAI_API_KEY', resolvedApiKey], + ['NEXTCHAT_OPENAI_BASE_URL', baseUrl], + ['NEXTCHAT_CUSTOM_MODELS', model], + ['NEXTCHAT_DEFAULT_MODEL', model] + ]); + } + + return buildMarkdownSnippet('NextChat', [ + ['API Key', resolvedApiKey], + ['Base URL', baseUrl], + ['Custom Models', model], + ['Default Model', model] + ]); } -function buildClineSnippet(providerName, baseUrl, apiKey, model) { - return [ - '[Cline]', - 'API Provider: OpenAI Compatible', - `Profile Name: AIClient2API (${providerName})`, - `Base URL: ${baseUrl}`, - `API Key: ${apiKey || 'YOUR_API_KEY'}`, - `Model ID: ${model}` - ].join('\n'); +function buildClineSnippet(providerName, baseUrl, apiKey, model, format) { + const resolvedApiKey = apiKey || 'YOUR_API_KEY'; + + if (format === 'json') { + return toPrettyJson({ + client: 'Cline', + apiProvider: 'OpenAI Compatible', + profileName: `AIClient2API (${providerName})`, + baseUrl, + apiKey: resolvedApiKey, + modelId: model + }); + } + + if (format === 'env') { + return buildEnvSnippet([ + ['CLINE_API_PROVIDER', 'OpenAI Compatible'], + ['CLINE_PROFILE_NAME', `AIClient2API (${providerName})`], + ['CLINE_BASE_URL', baseUrl], + ['CLINE_API_KEY', resolvedApiKey], + ['CLINE_MODEL_ID', model] + ]); + } + + return buildMarkdownSnippet('Cline', [ + ['API Provider', 'OpenAI Compatible'], + ['Profile Name', `AIClient2API (${providerName})`], + ['Base URL', baseUrl], + ['API Key', resolvedApiKey], + ['Model ID', model] + ]); } function renderClientSnippets(data, configMap) { @@ -295,6 +392,7 @@ function renderClientSnippets(data, configMap) { } const providerId = select.value; + const format = getSelectedSnippetFormat(); const provider = data.providers.find(item => item.id === providerId) || data.providers[0]; if (!provider) { cherryNode.textContent = t('access.empty.providers'); @@ -310,9 +408,9 @@ function renderClientSnippets(data, configMap) { const model = getRecommendedModel(provider.id); const apiKey = data.apiKey || ''; - cherryNode.textContent = buildCherryStudioSnippet(providerName, baseUrl, apiKey, model); - nextChatNode.textContent = buildNextChatSnippet(baseUrl, apiKey, model); - clineNode.textContent = buildClineSnippet(providerName, baseUrl, apiKey, model); + cherryNode.textContent = buildCherryStudioSnippet(providerName, baseUrl, apiKey, model, format); + nextChatNode.textContent = buildNextChatSnippet(baseUrl, apiKey, model, format); + clineNode.textContent = buildClineSnippet(providerName, baseUrl, apiKey, model, format); } async function copyFromButton(button) { @@ -330,10 +428,21 @@ async function copyFromButton(button) { } } +function navigateToSection(sectionId) { + const navItem = document.querySelector(`.nav-item[data-section="${sectionId}"]`); + if (navItem) { + navItem.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + return; + } + + window.location.hash = `#${sectionId}`; +} + function renderAccessPage(data) { const configMap = buildProviderConfigMap(data.supportedProviders || []); const visibleProviders = getVisibleProviders(data.providers || []); + setSelectedSnippetFormat(getSelectedSnippetFormat()); updateStats(data); updateFields(data); renderDefaultProviders(data.defaultProviders || [], configMap); @@ -442,6 +551,22 @@ export function initAccessManager() { if (!document.body.dataset.accessCopyBound) { document.body.addEventListener('click', async event => { + const jumpButton = event.target.closest('.access-jump-btn'); + if (jumpButton) { + navigateToSection(jumpButton.dataset.section); + return; + } + + const formatButton = event.target.closest('.access-format-btn'); + if (formatButton) { + setSelectedSnippetFormat(formatButton.dataset.format); + if (latestAccessData) { + const configMap = buildProviderConfigMap(latestAccessData.supportedProviders || []); + renderClientSnippets(latestAccessData, configMap); + } + return; + } + const endpointButton = event.target.closest('.access-copy-btn'); if (endpointButton) { await copyFromButton(endpointButton); diff --git a/static/app/i18n.js b/static/app/i18n.js index 1db1e340d..b2efd3f64 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -125,13 +125,22 @@ const translations = { 'access.connection.defaultProviders': '当前默认供应商', 'access.snippets.title': '客户端配置片段', 'access.snippets.provider': '用于生成片段的供应商', + 'access.snippets.subtitle': '这些片段更偏向“现成可复制的说明文本”,方便贴给客户端或运维同学,不等同于各客户端官方导入文件。', + 'access.snippets.format': '片段格式', + 'access.snippets.cherryDesc': '适合手动新建 OpenAI 兼容供应商时直接照着填。', + 'access.snippets.nextchatDesc': '适合环境变量、自定义 OpenAI 接入或发给部署同学。', + 'access.snippets.clineDesc': '适合新建 OpenAI Provider profile 时直接参考。', 'access.quickStart.title': '快速说明', + 'access.quickStart.subtitle': '适合第一次给客户端发接入信息时按顺序操作,先拿 Key,再选路由,最后复制成片段。', 'access.quickStart.step1Title': '1. 先复制 Key', 'access.quickStart.step1Desc': '把上面的对外 Key 发给客户端,客户端请求头里带 `Authorization: Bearer KEY`。', 'access.quickStart.step2Title': '2. 再选供应商路由', 'access.quickStart.step2Desc': '不同供应商对应不同路径,例如 `openai-codex-oauth`、`gemini-cli-oauth`。', 'access.quickStart.step3Title': '3. 直接复制端点', 'access.quickStart.step3Desc': '下面每一行都给了 OpenAI / Claude 两种协议端点,点按钮即可复制。', + 'access.quickStart.gotoConfig': '去配置管理', + 'access.quickStart.gotoProviders': '去提供商池管理', + 'access.quickStart.note': '如果这里还没有已配置节点,可以先去配置管理和提供商池管理补配置,再回来复制片段。', 'access.providers.title': '供应商与调用端点', 'access.providers.totalNodes': '节点总数', 'access.providers.healthyNodes': '健康节点', @@ -1127,13 +1136,22 @@ const translations = { 'access.connection.defaultProviders': 'Current Default Providers', 'access.snippets.title': 'Client Config Snippets', 'access.snippets.provider': 'Provider used for snippet generation', + 'access.snippets.subtitle': 'These snippets are optimized as copy-ready handoff text for clients or operators, not official import files for each app.', + 'access.snippets.format': 'Snippet format', + 'access.snippets.cherryDesc': 'Best for manually creating an OpenAI-compatible provider in Cherry Studio.', + 'access.snippets.nextchatDesc': 'Best for environment variables, custom OpenAI access, or handing off to deployment teammates.', + 'access.snippets.clineDesc': 'Best for creating a new OpenAI provider profile in Cline.', 'access.quickStart.title': 'Quick Guide', + 'access.quickStart.subtitle': 'Best for first-time client handoff: grab the key, choose the route, then copy the final snippet.', 'access.quickStart.step1Title': '1. Copy the key first', 'access.quickStart.step1Desc': 'Share the public key with the client and send it in the `Authorization: Bearer KEY` header.', 'access.quickStart.step2Title': '2. Pick a provider route', 'access.quickStart.step2Desc': 'Each provider uses a different path, such as `openai-codex-oauth` or `gemini-cli-oauth`.', 'access.quickStart.step3Title': '3. Copy the final endpoint', 'access.quickStart.step3Desc': 'Each row below gives OpenAI and Claude protocol endpoints, ready to copy.', + 'access.quickStart.gotoConfig': 'Open Config', + 'access.quickStart.gotoProviders': 'Open Provider Pools', + 'access.quickStart.note': 'If there are no configured nodes yet, finish the setup in Config and Provider Pools first, then come back to copy the snippet.', 'access.providers.title': 'Providers & Endpoints', 'access.providers.totalNodes': 'Total Nodes', 'access.providers.healthyNodes': 'Healthy Nodes', diff --git a/static/app/navigation.js b/static/app/navigation.js index 9c6376bb7..935bc94d7 100644 --- a/static/app/navigation.js +++ b/static/app/navigation.js @@ -14,45 +14,36 @@ function initNavigation() { elements.navItems.forEach(item => { item.addEventListener('click', (e) => { e.preventDefault(); - const sectionId = item.dataset.section; - - // 更新导航状态 - elements.navItems.forEach(nav => nav.classList.remove('active')); - item.classList.add('active'); - - // 显示对应章节 - elements.sections.forEach(section => { - section.classList.remove('active'); - if (section.id === sectionId) { - section.classList.add('active'); - - // 如果是日志页面,默认滚动到底部 - if (sectionId === 'logs') { - setTimeout(() => { - const logsContainer = document.getElementById('logsContainer'); - if (logsContainer) { - logsContainer.scrollTop = logsContainer.scrollHeight; - } - }, 100); - } - } - }); - - // 滚动到顶部 - scrollToTop(); - - if (sectionId === 'access' && typeof window.loadAccessInfo === 'function') { - window.loadAccessInfo(); - } + activateSection(item.dataset.section, { updateHash: true }); }); }); + + window.addEventListener('hashchange', () => { + const sectionId = window.location.hash.slice(1); + if (sectionId) { + activateSection(sectionId, { updateHash: false }); + } + }); + + const initialSectionId = window.location.hash.slice(1); + if (initialSectionId) { + activateSection(initialSectionId, { updateHash: false }); + } } /** - * 切换到指定章节 + * 激活指定章节 * @param {string} sectionId - 章节ID + * @param {Object} options - 额外选项 */ -function switchToSection(sectionId) { +function activateSection(sectionId, options = {}) { + const { updateHash = false } = options; + const hasMatchingSection = Array.from(elements.sections).some(section => section.id === sectionId); + + if (!hasMatchingSection) { + return; + } + // 更新导航状态 elements.navItems.forEach(nav => { nav.classList.remove('active'); @@ -82,11 +73,23 @@ function switchToSection(sectionId) { // 滚动到顶部 scrollToTop(); + if (updateHash && window.location.hash !== `#${sectionId}`) { + window.location.hash = sectionId; + } + if (sectionId === 'access' && typeof window.loadAccessInfo === 'function') { window.loadAccessInfo(); } } +/** + * 切换到指定章节 + * @param {string} sectionId - 章节ID + */ +function switchToSection(sectionId) { + activateSection(sectionId, { updateHash: true }); +} + /** * 滚动到页面顶部 */ diff --git a/static/components/section-access.css b/static/components/section-access.css index 111774b21..37202c01e 100644 --- a/static/components/section-access.css +++ b/static/components/section-access.css @@ -47,6 +47,20 @@ font-size: 1.1rem; } +.access-panel-subtitle { + margin: 0 0 1rem; + color: var(--text-secondary); + max-width: 72ch; + line-height: 1.6; +} + +.access-panel-note { + margin: 0.85rem 0 0; + color: var(--text-secondary); + font-size: 0.875rem; + line-height: 1.6; +} + .access-field-group { margin-bottom: 1.25rem; } @@ -91,6 +105,48 @@ font-size: 0.875rem; } +.access-snippet-format { + display: grid; + gap: 0.5rem; + justify-items: end; +} + +.access-snippet-format-label { + color: var(--text-secondary); + font-size: 0.875rem; +} + +.access-segmented-control { + display: inline-flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: flex-end; +} + +.access-format-btn { + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-secondary); + border-radius: 999px; + padding: 0.5rem 0.9rem; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.access-format-btn:hover { + border-color: var(--primary-color); + color: var(--primary-color); +} + +.access-format-btn.active { + background: var(--primary-color); + border-color: var(--primary-color); + color: #fff; + box-shadow: var(--shadow-sm); +} + .access-snippet-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -118,6 +174,13 @@ font-size: 1rem; } +.access-snippet-desc { + margin: 0.35rem 0 0; + color: var(--text-secondary); + font-size: 0.84rem; + line-height: 1.5; +} + .access-snippet-card pre { margin: 0; padding: 1rem; @@ -125,7 +188,7 @@ border: 1px solid var(--border-color); border-radius: var(--radius-lg); overflow-x: auto; - min-height: 210px; + min-height: 230px; } .access-snippet-card code { @@ -154,6 +217,13 @@ gap: 0.875rem; } +.access-quick-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1rem; +} + .access-hint-card { background: var(--bg-secondary); border: 1px solid var(--border-color); @@ -349,6 +419,16 @@ grid-template-columns: 1fr; } + .access-snippet-format, + .access-segmented-control { + justify-items: stretch; + justify-content: stretch; + } + + .access-format-btn { + width: 100%; + } + .access-panel-header { align-items: stretch; gap: 0.75rem; diff --git a/static/components/section-access.html b/static/components/section-access.html index 8fef5bc46..5246a7ec4 100644 --- a/static/components/section-access.html +++ b/static/components/section-access.html @@ -89,6 +89,7 @@

接入

快速说明

+

适合第一次给客户端发接入信息时按顺序操作,先拿 Key,再选路由,最后复制成片段。

1. 先复制 Key @@ -103,6 +104,17 @@

下面每一行都给了 OpenAI / Claude 两种协议端点,点按钮即可复制。

+
+ + +
+

如果这里还没有已配置节点,可以先去配置管理和提供商池管理补配置,再回来复制片段。

@@ -110,16 +122,28 @@

客户端配置片段

+

这些片段更偏向“现成可复制的说明文本”,方便贴给客户端或运维同学,不等同于各客户端官方导入文件。

+
+ 片段格式 +
+ + + +
+
-

Cherry Studio

+
+

Cherry Studio

+

适合手动新建 OpenAI 兼容供应商时直接照着填。

+
-

NextChat

+
+

NextChat

+

适合环境变量、自定义 OpenAI 接入或发给部署同学。

+
-

Cline

+
+

Cline

+

适合新建 OpenAI Provider profile 时直接参考。

+
+ +
+
+
+
+ 对外 Key + -- +
+
+ 默认供应商 + -- +
+
+ 下一步 + 去快速接入复制现成片段 +
+
+
From 5d1bc8efec4771cc41a9f03ae0486087d44f2859 Mon Sep 17 00:00:00 2001 From: emptyinkpot Date: Thu, 23 Apr 2026 22:29:35 +0800 Subject: [PATCH 047/135] Bridge provider pools to quick access --- static/app/i18n.js | 32 +++++++++ static/app/provider-manager.js | 87 ++++++++++++++++++++++++ static/components/section-providers.css | 78 +++++++++++++++++++++ static/components/section-providers.html | 38 ++++++++++- 4 files changed, 234 insertions(+), 1 deletion(-) diff --git a/static/app/i18n.js b/static/app/i18n.js index 73fa1faa1..b06dc71ff 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -554,6 +554,22 @@ const translations = { 'providers.activeConnections': '活动连接', 'providers.activeProviders': '活跃提供商', 'providers.healthyProviders': '健康提供商', + 'providers.handoff.title': '节点交付准备', + 'providers.handoff.subtitle': '先确认至少有一组可用节点,再去“快速接入”复制最终端点;如果还没设默认供应商,也可以顺手回配置管理补齐。', + 'providers.handoff.openConfig': '去配置管理', + 'providers.handoff.openAccess': '打开快速接入', + 'providers.handoff.groups': '已配置分组', + 'providers.handoff.healthyNodes': '健康节点', + 'providers.handoff.defaultProviders': '默认供应商', + 'providers.handoff.nextStep': '下一步', + 'providers.handoff.groupsReady': '{count} 个分组', + 'providers.handoff.groupsMissing': '还没有已配置分组', + 'providers.handoff.healthyReady': '{healthy}/{total} 个节点健康', + 'providers.handoff.healthyMissing': '还没有配置节点', + 'providers.handoff.defaultsMissing': '还没有设置默认供应商', + 'providers.handoff.nextStepAddNodes': '先添加至少一个可用节点', + 'providers.handoff.nextStepConfig': '去配置管理补默认供应商', + 'providers.handoff.nextStepAccess': '去快速接入复制最终端点', 'providers.status.healthy': '{healthy}/{total} 可用', 'providers.status.empty': '0/0 节点', 'providers.status.needsRefresh': '刷新中', @@ -1577,6 +1593,22 @@ const translations = { 'providers.activeConnections': 'Active Connections', 'providers.activeProviders': 'Active Providers', 'providers.healthyProviders': 'Healthy Providers', + 'providers.handoff.title': 'Node Handoff Readiness', + 'providers.handoff.subtitle': 'Confirm there is at least one usable node here, then jump to Quick Access to copy the final endpoint. If default providers are still missing, you can fill them in from Config.', + 'providers.handoff.openConfig': 'Open Config', + 'providers.handoff.openAccess': 'Open Quick Access', + 'providers.handoff.groups': 'Configured Groups', + 'providers.handoff.healthyNodes': 'Healthy Nodes', + 'providers.handoff.defaultProviders': 'Default Providers', + 'providers.handoff.nextStep': 'Next Step', + 'providers.handoff.groupsReady': '{count} groups', + 'providers.handoff.groupsMissing': 'No configured groups yet', + 'providers.handoff.healthyReady': '{healthy}/{total} healthy nodes', + 'providers.handoff.healthyMissing': 'No nodes configured yet', + 'providers.handoff.defaultsMissing': 'No default providers configured yet', + 'providers.handoff.nextStepAddNodes': 'Add at least one usable node first', + 'providers.handoff.nextStepConfig': 'Open Config and set default providers', + 'providers.handoff.nextStepAccess': 'Open Quick Access and copy the final endpoint', 'providers.status.healthy': '{healthy}/{total} Available', 'providers.status.empty': '0/0 Nodes', 'providers.status.needsRefresh': 'Refreshing', diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js index 9c208de53..69559486e 100644 --- a/static/app/provider-manager.js +++ b/static/app/provider-manager.js @@ -18,6 +18,91 @@ let initialUptime = null; let initialLoadTime = null; let isStaticProviderConfigsUpdated = false; let cachedSupportedProviders = null; +let latestProvidersAccessInfo = null; + +function navigateToSection(sectionId) { + const navItem = document.querySelector(`.nav-item[data-section="${sectionId}"]`); + if (navItem) { + navItem.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + return; + } + + window.location.hash = `#${sectionId}`; +} + +function initProvidersPageHelpers() { + const openAccessBtn = document.getElementById('providersOpenQuickAccess'); + if (openAccessBtn && !openAccessBtn.dataset.bound) { + openAccessBtn.addEventListener('click', () => navigateToSection('access')); + openAccessBtn.dataset.bound = 'true'; + } + + const openConfigBtn = document.getElementById('providersOpenConfig'); + if (openConfigBtn && !openConfigBtn.dataset.bound) { + openConfigBtn.addEventListener('click', () => navigateToSection('config')); + openConfigBtn.dataset.bound = 'true'; + } +} + +function updateProvidersHandoffSummary(providers = {}, supportedProviders = []) { + const providerGroups = Object.values(providers).filter(group => Array.isArray(group) && group.length > 0); + const totalGroups = providerGroups.length; + const allNodes = providerGroups.flat(); + const totalNodes = allNodes.length; + const healthyNodes = allNodes.filter(node => node.isHealthy && !node.isDisabled).length; + + const groupsEl = document.getElementById('providersHandoffGroups'); + const healthyEl = document.getElementById('providersHandoffHealthyNodes'); + const defaultsEl = document.getElementById('providersHandoffDefaults'); + const nextStepEl = document.getElementById('providersHandoffNextStep'); + + if (groupsEl) { + groupsEl.textContent = totalGroups > 0 + ? t('providers.handoff.groupsReady', { count: totalGroups }) + : t('providers.handoff.groupsMissing'); + } + + if (healthyEl) { + healthyEl.textContent = totalNodes > 0 + ? t('providers.handoff.healthyReady', { healthy: healthyNodes, total: totalNodes }) + : t('providers.handoff.healthyMissing'); + } + + const providerConfigs = getProviderConfigs(supportedProviders); + const configMap = providerConfigs.reduce((map, config) => { + map[config.id] = config; + return map; + }, {}); + const defaultProviders = latestProvidersAccessInfo?.defaultProviders || []; + const defaultProviderNames = defaultProviders.map(id => configMap[id]?.name || id); + + if (defaultsEl) { + defaultsEl.textContent = defaultProviderNames.length > 0 + ? defaultProviderNames.join(' / ') + : t('providers.handoff.defaultsMissing'); + } + + if (nextStepEl) { + if (totalNodes === 0) { + nextStepEl.textContent = t('providers.handoff.nextStepAddNodes'); + } else if (defaultProviderNames.length === 0) { + nextStepEl.textContent = t('providers.handoff.nextStepConfig'); + } else { + nextStepEl.textContent = t('providers.handoff.nextStepAccess'); + } + } +} + +async function refreshProvidersHandoffSummary(providers = {}, supportedProviders = []) { + try { + latestProvidersAccessInfo = await window.apiClient.get('/access-info'); + } catch (error) { + console.warn('Failed to load provider handoff summary:', error); + latestProvidersAccessInfo = null; + } + + updateProvidersHandoffSummary(providers, supportedProviders); +} /** * 加载系统信息 @@ -183,6 +268,7 @@ function updateTimeDisplay() { */ async function loadProviders(forceRefreshSupported = false) { try { + initProvidersPageHelpers(); // 获取合并后的数据(包括 providers 和 supportedProviders) const data = await window.apiClient.get('/providers'); if (!data || !data.providers) return; @@ -213,6 +299,7 @@ async function loadProviders(forceRefreshSupported = false) { } renderProviders(providers, cachedSupportedProviders); + await refreshProvidersHandoffSummary(providers, cachedSupportedProviders); } catch (error) { console.error('Failed to load providers:', error); } diff --git a/static/components/section-providers.css b/static/components/section-providers.css index a7dd4d3c1..e89e9a2da 100644 --- a/static/components/section-providers.css +++ b/static/components/section-providers.css @@ -1,4 +1,64 @@ /* 统计卡片 */ +.providers-handoff-panel { + background: linear-gradient(135deg, var(--primary-10) 0%, var(--bg-primary) 58%, var(--bg-secondary) 100%); + border: 1px solid var(--primary-20); + border-radius: var(--radius-xl); + padding: 1.5rem; + margin-bottom: 1.5rem; + box-shadow: var(--shadow-sm); +} + +.providers-handoff-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.providers-handoff-head h3 { + margin: 0; + font-size: 1.1rem; +} + +.providers-handoff-subtitle { + margin: 0.35rem 0 0; + color: var(--text-secondary); + max-width: 72ch; + line-height: 1.6; +} + +.providers-handoff-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.providers-handoff-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1rem; +} + +.providers-handoff-item { + background: rgba(255, 255, 255, 0.72); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 1rem; + display: grid; + gap: 0.35rem; +} + +.providers-handoff-label { + color: var(--text-secondary); + font-size: 0.82rem; +} + +.providers-handoff-item strong { + font-size: 1rem; + line-height: 1.45; +} + .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); @@ -301,6 +361,24 @@ padding-right: 2.5rem; } +@media (max-width: 1100px) { + .providers-handoff-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 768px) { + .providers-handoff-head, + .providers-handoff-actions { + flex-direction: column; + align-items: stretch; + } + + .providers-handoff-grid { + grid-template-columns: 1fr; + } +} + .copy-btn { position: absolute; right: 0.5rem; diff --git a/static/components/section-providers.html b/static/components/section-providers.html index e23c0c87c..363250664 100644 --- a/static/components/section-providers.html +++ b/static/components/section-providers.html @@ -8,6 +8,42 @@

提供商池管理

使用默认路径配置需添加一个空节点
+
+
+
+

节点交付准备

+

先确认至少有一组可用节点,再去“快速接入”复制最终端点;如果还没设默认供应商,也可以顺手回配置管理补齐。

+
+
+ + +
+
+
+
+ 已配置分组 + -- +
+
+ 健康节点 + -- +
+
+ 默认供应商 + -- +
+
+ 下一步 + -- +
+
+
@@ -55,4 +91,4 @@

0

- \ No newline at end of file + From 8483a7198285cc358cd33658df32008187d1007b Mon Sep 17 00:00:00 2001 From: hex2077 Date: Fri, 24 Apr 2026 12:30:41 +0800 Subject: [PATCH 048/135] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E5=AF=BC?= =?UTF-8?q?=E8=88=AA=E9=80=BB=E8=BE=91=E5=B9=B6=E6=95=B4=E5=90=88=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E7=A4=BA=E4=BE=8B=E5=88=B0=E5=BF=AB=E9=80=9F=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除仪表盘中的路径路由示例功能,将其整合到快速接入页面 优化导航哈希处理逻辑,避免重复加载访问信息 为提供商卡片添加复制curl示例按钮 更新国际化文本以反映功能调整 --- static/app/access-manager.js | 85 ++++-- static/app/app.js | 1 - static/app/i18n.js | 54 +++- static/app/navigation.js | 19 +- static/app/routing-examples.js | 144 ++++++--- static/components/section-access.css | 369 +++++++++++++++++++++++ static/components/section-access.html | 32 +- static/components/section-dashboard.html | 28 +- 8 files changed, 595 insertions(+), 137 deletions(-) diff --git a/static/app/access-manager.js b/static/app/access-manager.js index 9f50ff508..7c72320b1 100644 --- a/static/app/access-manager.js +++ b/static/app/access-manager.js @@ -1,5 +1,5 @@ import { getProviderConfigs, copyToClipboard, showToast, escapeHtml } from './utils.js'; -import { getAvailableRoutes } from './routing-examples.js'; +import { getAvailableRoutes, copyCurlExample } from './routing-examples.js'; import { t } from './i18n.js'; let latestAccessData = null; @@ -116,8 +116,8 @@ function toPrettyJson(value) { function buildMarkdownSnippet(title, entries) { return [ - `# ${title}`, - ...entries.map(([label, value]) => `- ${label}: ${value}`) + `# ${t(title)}`, + ...entries.map(([label, value]) => `- ${t(label)}: ${t(value)}`) ].join('\n'); } @@ -179,6 +179,9 @@ function renderProviderCards(providers, defaultProviders, configMap) { const defaultBadge = isDefault ? `${escapeHtml(t('access.badges.default'))}` : ''; + const routeBadge = route.badge + ? `${escapeHtml(t(route.badge))}` + : ''; return `
@@ -191,6 +194,7 @@ function renderProviderCards(providers, defaultProviders, configMap) {
+ ${routeBadge} ${defaultBadge} ${emptyBadge}
@@ -213,18 +217,40 @@ function renderProviderCards(providers, defaultProviders, configMap) {
${escapeHtml(t('access.providers.openaiEndpoint'))} ${escapeHtml(openaiPath ? getFullEndpoint(openaiPath) : t('access.empty.endpoint'))} - +
+ + +
${escapeHtml(t('access.providers.claudeEndpoint'))} ${escapeHtml(claudePath ? getFullEndpoint(claudePath) : t('access.empty.endpoint'))} - +
+ + +
@@ -310,11 +336,11 @@ function buildCherryStudioSnippet(providerName, baseUrl, apiKey, model, format) } return buildMarkdownSnippet('Cherry Studio', [ - ['Provider Type', 'OpenAI Compatible'], - ['Display Name', `AIClient2API (${providerName})`], - ['Base URL', baseUrl], - ['API Key', resolvedApiKey], - ['Model', model] + ['API 提供商', 'OpenAI 兼容协议'], + ['显示名称', `AIClient2API (${providerName})`], + ['Base URL (基础地址)', baseUrl], + ['API Key (密钥)', resolvedApiKey], + ['模型 (Model)', model] ]); } @@ -341,10 +367,10 @@ function buildNextChatSnippet(baseUrl, apiKey, model, format) { } return buildMarkdownSnippet('NextChat', [ - ['API Key', resolvedApiKey], - ['Base URL', baseUrl], - ['Custom Models', model], - ['Default Model', model] + ['API Key (密钥)', resolvedApiKey], + ['Base URL (基础地址)', baseUrl], + ['自定义模型 (Custom Models)', model], + ['默认模型 (Default Model)', model] ]); } @@ -373,11 +399,11 @@ function buildClineSnippet(providerName, baseUrl, apiKey, model, format) { } return buildMarkdownSnippet('Cline', [ - ['API Provider', 'OpenAI Compatible'], - ['Profile Name', `AIClient2API (${providerName})`], - ['Base URL', baseUrl], - ['API Key', resolvedApiKey], - ['Model ID', model] + ['API 提供商', 'OpenAI 兼容协议'], + ['配置名称 (Profile Name)', `AIClient2API (${providerName})`], + ['Base URL (基础地址)', baseUrl], + ['API Key (密钥)', resolvedApiKey], + ['模型 ID', model] ]); } @@ -573,6 +599,15 @@ export function initAccessManager() { return; } + const curlButton = event.target.closest('.access-curl-btn'); + if (curlButton) { + const provider = curlButton.dataset.provider; + const protocol = curlButton.dataset.protocol; + const model = getRecommendedModel(provider); + await copyCurlExample(provider, { protocol, model }); + return; + } + const snippetButton = event.target.closest('.access-snippet-copy-btn'); if (snippetButton) { const targetId = snippetButton.getAttribute('data-target'); diff --git a/static/app/app.js b/static/app/app.js index 7646b34b3..f751a9437 100644 --- a/static/app/app.js +++ b/static/app/app.js @@ -131,7 +131,6 @@ function initApp() { initEventListeners(); initEventStream(); initFileUpload(); // 初始化文件上传功能 - initRoutingExamples(); // 初始化路径路由示例功能 initAccessManager(); // 初始化快速接入页面 initUploadConfigManager(); // 初始化配置管理功能 initUsageManager(); // 初始化用量管理功能 diff --git a/static/app/i18n.js b/static/app/i18n.js index b06dc71ff..cf226d7a0 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -65,7 +65,7 @@ const translations = { 'dashboard.memoryUsage': '内存使用', 'dashboard.cpuUsage': 'CPU 使用', 'dashboard.providerStatus': '提供商节点状态', - 'dashboard.advancedInfo': '高级信息 (路径路由与模型列表)', + 'dashboard.advancedInfo': '可用模型列表', 'dashboard.expandMore': '展开更多', 'dashboard.serviceMode': '运行模式', 'dashboard.serviceMode.worker': '子进程模式', @@ -91,12 +91,17 @@ const translations = { 'dashboard.routing.tip3': '跨协议调用: 支持OpenAI协议调用Claude模型,或Claude协议调用OpenAI模型', 'dashboard.routing.nodeName.gemini': 'Gemini CLI OAuth', 'dashboard.routing.nodeName.antigravity': 'Gemini Antigravity', + 'dashboard.routing.nodeName.newapi': 'NewAPI', 'dashboard.routing.nodeName.claude': 'Claude Custom', 'dashboard.routing.nodeName.kiro': 'Claude Kiro OAuth', 'dashboard.routing.nodeName.openai': 'OpenAI Custom', 'dashboard.routing.nodeName.qwen': 'Qwen OAuth', + 'dashboard.routing.description.qwen': 'Qwen Code Plus', 'dashboard.routing.nodeName.iflow': 'iFlow OAuth', 'dashboard.routing.nodeName.codex': 'OpenAI Codex OAuth', + 'dashboard.routing.nodeName.responses': 'OpenAI Responses', + 'dashboard.routing.description.responses': '结构化对话API', + 'dashboard.routing.badge.responses': 'Responses', 'dashboard.routing.nodeName.grok': 'Grok Reverse', 'dashboard.contact.title': '联系与赞助', 'dashboard.contact.wechat': '扫码进群,注明来意', @@ -127,6 +132,10 @@ const translations = { 'access.snippets.provider': '用于生成片段的供应商', 'access.snippets.subtitle': '这些片段更偏向“现成可复制的说明文本”,方便贴给客户端或运维同学,不等同于各客户端官方导入文件。', 'access.snippets.format': '片段格式', + 'access.snippets.formatLabel': '片段格式', + 'access.snippets.format.markdown': 'Markdown', + 'access.snippets.format.env': 'ENV', + 'access.snippets.format.json': 'JSON', 'access.snippets.cherryDesc': '适合手动新建 OpenAI 兼容供应商时直接照着填。', 'access.snippets.nextchatDesc': '适合环境变量、自定义 OpenAI 接入或发给部署同学。', 'access.snippets.clineDesc': '适合新建 OpenAI Provider profile 时直接参考。', @@ -146,12 +155,13 @@ const translations = { 'access.providers.healthyNodes': '健康节点', 'access.providers.disabledNodes': '禁用节点', 'access.providers.configuredOnly': '只显示已配置供应商', - 'access.providers.openaiEndpoint': 'OpenAI 端点', - 'access.providers.claudeEndpoint': 'Claude 端点', + 'access.providers.openaiEndpoint': 'OpenAI 端点路径', + 'access.providers.claudeEndpoint': 'Claude 端点路径', 'access.actions.copyKey': '复制 Key', 'access.actions.copyBaseUrl': '复制地址', - 'access.actions.copyEndpoint': '复制端点', + 'access.actions.copyEndpoint': '复制端点路径', 'access.actions.copySnippet': '复制片段', + 'access.actions.copyCurl': '复制 curl 示例', 'access.badges.default': '默认', 'access.badges.empty': '未配置节点', 'access.empty.key': '当前还没有设置对外 Key', @@ -854,7 +864,7 @@ const translations = { 'guide.client.cline.step2': '搜索 Cline 或 Continue 配置', 'guide.client.cline.step3': '设置 API Base URL 为: http://localhost:3000/{provider}/v1', 'guide.client.cline.step4': '填入 API Key 和模型名称', - 'guide.client.note': '提示:将 {provider} 替换为实际的提供商路径,如 gemini-cli-oauth、claude-kiro-oauth 等。可在仪表盘的路由示例中查看完整路径。', + 'guide.client.note': '提示:将 {provider} 替换为实际的提供商路径,如 gemini-cli-oauth、claude-kiro-oauth 等。可在「快速接入」页面的路由示例中查看完整路径。', 'guide.faq.title': '常见问题', 'guide.faq.q1': 'Q: 请求返回 404 错误怎么办?', 'guide.faq.a1': 'A: 检查接口路径是否正确。某些客户端会自动在 Base URL 后追加路径,导致路径重复。请查看控制台中的实际请求 URL,移除多余的路径部分。', @@ -903,7 +913,7 @@ const translations = { 'guide.flow.step3.item2': '自动关联到提供商池', 'guide.flow.step3.item3': '删除无效凭据', 'guide.flow.step4.title': '开始使用', - 'guide.flow.step4.desc': '在「仪表盘」查看路由示例并开始调用 API', + 'guide.flow.step4.desc': '在「快速接入」查看路由示例并开始调用 API', 'guide.flow.step4.item1': '查看路由调用示例', 'guide.flow.step4.item2': '复制 API 端点地址', 'guide.flow.step4.item3': '在客户端中配置使用', @@ -1104,7 +1114,7 @@ const translations = { 'dashboard.memoryUsage': 'Memory Usage', 'dashboard.cpuUsage': 'CPU Usage', 'dashboard.providerStatus': 'Provider Nodes Status', - 'dashboard.advancedInfo': 'Advanced Info (Routing & Models)', + 'dashboard.advancedInfo': 'Available Models List', 'dashboard.expandMore': 'Expand More', 'dashboard.serviceMode': 'Service Mode', 'dashboard.serviceMode.worker': 'Worker Mode', @@ -1130,12 +1140,17 @@ const translations = { 'dashboard.routing.tip3': 'Cross-protocol Calls: Support calling Claude models with OpenAI protocol, or OpenAI models with Claude protocol', 'dashboard.routing.nodeName.gemini': 'Gemini CLI OAuth', 'dashboard.routing.nodeName.antigravity': 'Gemini Antigravity', + 'dashboard.routing.nodeName.newapi': 'NewAPI', 'dashboard.routing.nodeName.claude': 'Claude Custom', 'dashboard.routing.nodeName.kiro': 'Claude Kiro OAuth', 'dashboard.routing.nodeName.openai': 'OpenAI Custom', 'dashboard.routing.nodeName.qwen': 'Qwen OAuth', + 'dashboard.routing.description.qwen': 'Qwen Code Plus', 'dashboard.routing.nodeName.iflow': 'iFlow OAuth', 'dashboard.routing.nodeName.codex': 'OpenAI Codex OAuth', + 'dashboard.routing.nodeName.responses': 'OpenAI Responses', + 'dashboard.routing.description.responses': 'Structured Dialogue API', + 'dashboard.routing.badge.responses': 'Responses', 'dashboard.routing.nodeName.grok': 'Grok Reverse', 'dashboard.contact.title': 'Contact & Support', 'dashboard.contact.wechat': 'Scan to Join Group', @@ -1166,6 +1181,20 @@ const translations = { 'access.snippets.provider': 'Provider used for snippet generation', 'access.snippets.subtitle': 'These snippets are optimized as copy-ready handoff text for clients or operators, not official import files for each app.', 'access.snippets.format': 'Snippet format', + 'access.snippets.formatLabel': 'Snippet Format', + 'access.snippets.format.markdown': 'Markdown', + 'access.snippets.format.env': 'ENV', + 'access.snippets.format.json': 'JSON', + 'API 提供商': 'API Provider', + 'OpenAI 兼容协议': 'OpenAI Compatible', + '显示名称': 'Display Name', + 'Base URL (基础地址)': 'Base URL', + 'API Key (密钥)': 'API Key', + '模型 (Model)': 'Model', + '配置名称 (Profile Name)': 'Profile Name', + '模型 ID': 'Model ID', + '自定义模型 (Custom Models)': 'Custom Models', + '默认模型 (Default Model)': 'Default Model', 'access.snippets.cherryDesc': 'Best for manually creating an OpenAI-compatible provider in Cherry Studio.', 'access.snippets.nextchatDesc': 'Best for environment variables, custom OpenAI access, or handing off to deployment teammates.', 'access.snippets.clineDesc': 'Best for creating a new OpenAI provider profile in Cline.', @@ -1185,12 +1214,13 @@ const translations = { 'access.providers.healthyNodes': 'Healthy Nodes', 'access.providers.disabledNodes': 'Disabled Nodes', 'access.providers.configuredOnly': 'Configured providers only', - 'access.providers.openaiEndpoint': 'OpenAI Endpoint', - 'access.providers.claudeEndpoint': 'Claude Endpoint', + 'access.providers.openaiEndpoint': 'OpenAI Endpoint Path', + 'access.providers.claudeEndpoint': 'Claude Endpoint Path', 'access.actions.copyKey': 'Copy Key', 'access.actions.copyBaseUrl': 'Copy URL', - 'access.actions.copyEndpoint': 'Copy Endpoint', + 'access.actions.copyEndpoint': 'Copy Endpoint Path', 'access.actions.copySnippet': 'Copy Snippet', + 'access.actions.copyCurl': 'Copy curl example', 'access.badges.default': 'Default', 'access.badges.empty': 'No Nodes', 'access.empty.key': 'No public API key configured yet', @@ -1892,7 +1922,7 @@ const translations = { 'guide.client.cline.step2': 'Search for Cline or Continue configuration', 'guide.client.cline.step3': 'Set API Base URL to: http://localhost:3000/{provider}/v1', 'guide.client.cline.step4': 'Enter API Key and model name', - 'guide.client.note': 'Tip: Replace {provider} with the actual provider path, such as gemini-cli-oauth, claude-kiro-oauth, etc. See the routing examples on the dashboard for full paths.', + 'guide.client.note': 'Tip: Replace {provider} with the actual provider path, such as gemini-cli-oauth, claude-kiro-oauth, etc. See the routing examples on the "Quick Access" page for full paths.', 'guide.faq.title': 'FAQ', 'guide.faq.q1': 'Q: What to do if request returns 404 error?', 'guide.faq.a1': 'A: Check if the API path is correct. Some clients automatically append paths to Base URL, causing duplication. Check the actual request URL in the console and remove redundant path parts.', @@ -1941,7 +1971,7 @@ const translations = { 'guide.flow.step3.item2': 'Auto-link to provider pool', 'guide.flow.step3.item3': 'Delete invalid credentials', 'guide.flow.step4.title': 'Start Using', - 'guide.flow.step4.desc': 'View routing examples in "Dashboard" and start calling API', + 'guide.flow.step4.desc': 'View routing examples in "Quick Access" and start calling API', 'guide.flow.step4.item1': 'View routing call examples', 'guide.flow.step4.item2': 'Copy API endpoint address', 'guide.flow.step4.item3': 'Configure in client application', diff --git a/static/app/navigation.js b/static/app/navigation.js index 935bc94d7..d48666960 100644 --- a/static/app/navigation.js +++ b/static/app/navigation.js @@ -19,16 +19,12 @@ function initNavigation() { }); window.addEventListener('hashchange', () => { - const sectionId = window.location.hash.slice(1); - if (sectionId) { - activateSection(sectionId, { updateHash: false }); - } + const sectionId = window.location.hash.slice(1) || 'dashboard'; + activateSection(sectionId, { updateHash: false }); }); - const initialSectionId = window.location.hash.slice(1); - if (initialSectionId) { - activateSection(initialSectionId, { updateHash: false }); - } + const initialSectionId = window.location.hash.slice(1) || 'dashboard'; + activateSection(initialSectionId, { updateHash: false }); } /** @@ -73,11 +69,14 @@ function activateSection(sectionId, options = {}) { // 滚动到顶部 scrollToTop(); - if (updateHash && window.location.hash !== `#${sectionId}`) { + const hashWillChange = updateHash && window.location.hash !== `#${sectionId}`; + if (hashWillChange) { window.location.hash = sectionId; } - if (sectionId === 'access' && typeof window.loadAccessInfo === 'function') { + // 只有在哈希不改变时(例如初始加载、hashchange 事件触发、或点击当前已激活的项)才调用 loadAccessInfo + // 这样可以防止在 hashchange 触发时产生重复请求 + if (sectionId === 'access' && !hashWillChange && typeof window.loadAccessInfo === 'function') { window.loadAccessInfo(); } } diff --git a/static/app/routing-examples.js b/static/app/routing-examples.js index a5fb50078..d983ade20 100644 --- a/static/app/routing-examples.js +++ b/static/app/routing-examples.js @@ -1,6 +1,6 @@ // 路径路由示例功能模块 -import { showToast } from './utils.js'; +import { showToast, copyToClipboard } from './utils.js'; import { t } from './i18n.js'; /** @@ -67,20 +67,24 @@ function initCopyButtons() { if (!path) return; try { - await navigator.clipboard.writeText(path); - showToast(t('common.success'), `${t('common.success')}: ${path}`, 'success'); - - // 临时更改按钮图标 - const icon = button.querySelector('i'); - if (icon) { - const originalClass = icon.className; - icon.className = 'fas fa-check'; - button.style.color = 'var(--success-color)'; + const success = await copyToClipboard(path); + if (success) { + showToast(t('common.success'), `${t('common.success')}: ${path}`, 'success'); - setTimeout(() => { - icon.className = originalClass; - button.style.color = ''; - }, 2000); + // 临时更改按钮图标 + const icon = button.querySelector('i'); + if (icon) { + const originalClass = icon.className; + icon.className = 'fas fa-check'; + button.style.color = 'var(--success-color)'; + + setTimeout(() => { + icon.className = originalClass; + button.style.color = ''; + }, 2000); + } + } else { + throw new Error('Copy failed'); } } catch (error) { @@ -120,7 +124,7 @@ function getAvailableRoutes() { return [ { provider: 'forward-api', - name: 'NewAPI', + name: t('dashboard.routing.nodeName.newapi'), paths: { openai: '/forward-api/v1/chat/completions', claude: '/forward-api/v1/messages' @@ -131,7 +135,7 @@ function getAvailableRoutes() { }, { provider: 'claude-custom', - name: 'Claude Custom', + name: t('dashboard.routing.nodeName.claude'), paths: { openai: '/claude-custom/v1/chat/completions', claude: '/claude-custom/v1/messages' @@ -142,7 +146,7 @@ function getAvailableRoutes() { }, { provider: 'claude-kiro-oauth', - name: 'Claude Kiro OAuth', + name: t('dashboard.routing.nodeName.kiro'), paths: { openai: '/claude-kiro-oauth/v1/chat/completions', claude: '/claude-kiro-oauth/v1/messages' @@ -153,7 +157,7 @@ function getAvailableRoutes() { }, { provider: 'openai-custom', - name: 'OpenAI Custom', + name: t('dashboard.routing.nodeName.openai'), paths: { openai: '/openai-custom/v1/chat/completions', claude: '/openai-custom/v1/messages' @@ -164,7 +168,7 @@ function getAvailableRoutes() { }, { provider: 'gemini-cli-oauth', - name: 'Gemini CLI OAuth', + name: t('dashboard.routing.nodeName.gemini'), paths: { openai: '/gemini-cli-oauth/v1/chat/completions', claude: '/gemini-cli-oauth/v1/messages' @@ -175,29 +179,29 @@ function getAvailableRoutes() { }, { provider: 'gemini-antigravity', - name: 'Gemini Antigravity', + name: t('dashboard.routing.nodeName.antigravity'), paths: { openai: '/gemini-antigravity/v1/chat/completions', claude: '/gemini-antigravity/v1/messages' }, - description: t('dashboard.routing.experimental') || '实验性', - badge: t('dashboard.routing.experimental') || '实验性', + description: t('dashboard.routing.experimental'), + badge: t('dashboard.routing.experimental'), badgeClass: 'oauth' }, { provider: 'openai-qwen-oauth', - name: 'Qwen OAuth', + name: t('dashboard.routing.nodeName.qwen'), paths: { openai: '/openai-qwen-oauth/v1/chat/completions', claude: '/openai-qwen-oauth/v1/messages' }, - description: 'Qwen Code Plus', + description: t('dashboard.routing.description.qwen'), badge: t('dashboard.routing.oauth'), badgeClass: 'oauth' }, { provider: 'openai-iflow', - name: 'iFlow OAuth', + name: t('dashboard.routing.nodeName.iflow'), paths: { openai: '/openai-iflow/v1/chat/completions', claude: '/openai-iflow/v1/messages' @@ -208,7 +212,7 @@ function getAvailableRoutes() { }, { provider: 'openai-codex-oauth', - name: 'OpenAI Codex OAuth', + name: t('dashboard.routing.nodeName.codex'), paths: { openai: '/openai-codex-oauth/v1/chat/completions', claude: '/openai-codex-oauth/v1/messages' @@ -219,18 +223,18 @@ function getAvailableRoutes() { }, { provider: 'openaiResponses-custom', - name: 'OpenAI Responses', + name: t('dashboard.routing.nodeName.responses'), paths: { openai: '/openaiResponses-custom/v1/responses', claude: '/openaiResponses-custom/v1/messages' }, - description: '结构化对话API', - badge: 'Responses', + description: t('dashboard.routing.description.responses'), + badge: t('dashboard.routing.badge.responses'), badgeClass: 'responses' }, { provider: 'grok-custom', - name: 'Grok Reverse', + name: t('dashboard.routing.nodeName.grok'), paths: { openai: '/grok-custom/v1/chat/completions', claude: '/grok-custom/v1/messages' @@ -269,8 +273,23 @@ function highlightProviderRoute(provider) { */ async function copyCurlExample(provider, options = {}) { const routes = getAvailableRoutes(); - const route = routes.find(r => r.provider === provider); + let route = routes.find(r => r.provider === provider); + // 如果没找到,尝试匹配前缀 + if (!route) { + const baseRoute = routes.find(r => provider.startsWith(r.provider + '-')); + if (baseRoute) { + route = { + ...baseRoute, + provider: provider, + paths: { + openai: `/${provider}/v1/chat/completions`, + claude: `/${provider}/v1/messages` + } + }; + } + } + if (!route) { showToast(t('common.error'), t('common.error'), 'error'); return; @@ -284,14 +303,21 @@ async function copyCurlExample(provider, options = {}) { return; } + const hostname = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' ? + `http://${window.location.host}` : + `${window.location.protocol}//${window.location.host}`; + let curlCommand = ''; + // 获取基础提供商ID(去掉后缀) + const baseProviderId = routes.find(r => provider.startsWith(r.provider))?.provider || provider; + // 根据不同提供商和协议生成对应的curl命令 - switch (provider) { + switch (baseProviderId) { case 'claude-custom': case 'claude-kiro-oauth': if (protocol === 'openai') { - curlCommand = `curl http://localhost:3000${path} \\ + curlCommand = `curl ${hostname}${path} \\ -H "Content-Type: application/json" \\ -H "Authorization: Bearer YOUR_API_KEY" \\ -d '{ @@ -300,7 +326,7 @@ async function copyCurlExample(provider, options = {}) { "max_tokens": 1000 }'`; } else { - curlCommand = `curl http://localhost:3000${path} \\ + curlCommand = `curl ${hostname}${path} \\ -H "Content-Type: application/json" \\ -d '{ "model": "${model}", @@ -312,8 +338,11 @@ async function copyCurlExample(provider, options = {}) { case 'openai-custom': case 'openai-qwen-oauth': + case 'openai-iflow': + case 'openai-codex-oauth': + case 'forward-api': if (protocol === 'openai') { - curlCommand = `curl http://localhost:3000${path} \\ + curlCommand = `curl ${hostname}${path} \\ -H "Content-Type: application/json" \\ -H "Authorization: Bearer YOUR_API_KEY" \\ -d '{ @@ -322,7 +351,7 @@ async function copyCurlExample(provider, options = {}) { "max_tokens": 1000 }'`; } else { - curlCommand = `curl http://localhost:3000${path} \\ + curlCommand = `curl ${hostname}${path} \\ -H "Content-Type: application/json" \\ -H "X-API-Key: YOUR_API_KEY" \\ -d '{ @@ -334,19 +363,20 @@ async function copyCurlExample(provider, options = {}) { break; case 'gemini-cli-oauth': + case 'gemini-antigravity': if (protocol === 'openai') { - curlCommand = `curl http://localhost:3000${path} \\ + curlCommand = `curl ${hostname}${path} \\ -H "Content-Type: application/json" \\ -d '{ - "model": "gemini-3.1-pro-preview", + "model": "${model}", "messages": [{"role": "user", "content": "${message}"}], "max_tokens": 1000 }'`; } else { - curlCommand = `curl http://localhost:3000${path} \\ + curlCommand = `curl ${hostname}${path} \\ -H "Content-Type: application/json" \\ -d '{ - "model": "gemini-3.1-pro-preview", + "model": "${model}", "max_tokens": 1000, "messages": [{"role": "user", "content": "${message}"}] }'`; @@ -355,7 +385,7 @@ async function copyCurlExample(provider, options = {}) { case 'openaiResponses-custom': if (protocol === 'openai') { - curlCommand = `curl http://localhost:3000${path} \\ + curlCommand = `curl ${hostname}${path} \\ -H "Content-Type: application/json" \\ -H "Authorization: Bearer YOUR_API_KEY" \\ -d '{ @@ -364,7 +394,7 @@ async function copyCurlExample(provider, options = {}) { "max_output_tokens": 1000 }'`; } else { - curlCommand = `curl http://localhost:3000${path} \\ + curlCommand = `curl ${hostname}${path} \\ -H "Content-Type: application/json" \\ -H "X-API-Key: YOUR_API_KEY" \\ -d '{ @@ -376,30 +406,48 @@ async function copyCurlExample(provider, options = {}) { break; case 'grok-custom': if (protocol === 'openai') { - curlCommand = `curl http://localhost:3000${path} \\ + curlCommand = `curl ${hostname}${path} \\ -H "Content-Type: application/json" \\ -H "Authorization: Bearer YOUR_API_KEY" \\ -d '{ - "model": "grok-4.1-mini", + "model": "${model}", "messages": [{"role": "user", "content": "${message}"}], "stream": true }'`; } else { - curlCommand = `curl http://localhost:3000${path} \\ + curlCommand = `curl ${hostname}${path} \\ -H "Content-Type: application/json" \\ -H "X-API-Key: YOUR_API_KEY" \\ -d '{ - "model": "grok-4.1-mini", + "model": "${model}", "max_tokens": 1000, "messages": [{"role": "user", "content": "${message}"}] }'`; } break; + default: + // 通用默认模板 + curlCommand = `curl ${hostname}${path} \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer YOUR_API_KEY" \\ + -d '{ + "model": "${model}", + "messages": [{"role": "user", "content": "${message}"}] + }'`; } + if (!curlCommand) { + showToast(t('common.error'), t('common.error'), 'error'); + return; + } + try { - await navigator.clipboard.writeText(curlCommand); - showToast(t('common.success'), t('oauth.success.msg'), 'success'); + const success = await copyToClipboard(curlCommand); + if (success) { + showToast(t('common.success'), t('common.copy.success'), 'success'); + } else { + throw new Error('Copy failed'); + } } catch (error) { console.error('Failed to copy curl command:', error); showToast(t('common.error'), t('common.error'), 'error'); diff --git a/static/components/section-access.css b/static/components/section-access.css index 37202c01e..f7d2136be 100644 --- a/static/components/section-access.css +++ b/static/components/section-access.css @@ -435,3 +435,372 @@ flex-direction: column; } } + +/* Collapsible Access Details (Moved from Dashboard) */ +.access-details { + background: var(--bg-primary); + border-radius: var(--radius-lg); + border: 1px solid var(--border-color); + margin-top: 1.5rem; + overflow: hidden; + box-shadow: var(--shadow-sm); + transition: var(--transition); +} + +.access-details:hover { + border-color: var(--primary-color); + box-shadow: var(--shadow-md); +} + +.access-details[open] { + box-shadow: var(--shadow-md); +} + +.access-summary { + list-style: none; + padding: 1rem 1.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + background: var(--bg-primary); + user-select: none; + transition: var(--transition); +} + +.access-summary::-webkit-details-marker { + display: none; +} + +.access-summary:hover { + background: var(--primary-10); +} + +.summary-content { + display: flex; + align-items: center; + gap: 0.75rem; + color: var(--text-primary); + font-weight: 600; +} + +.summary-content i { + color: var(--primary-color); + font-size: 1.1rem; +} + +.expand-hint { + font-size: 0.8rem; + color: var(--text-secondary); + font-weight: 400; + margin-left: 0.5rem; + opacity: 0.7; +} + +.caret-icon { + color: var(--text-tertiary); + transition: transform 0.3s ease; + font-size: 0.9rem; +} + +.access-details[open] .caret-icon { + transform: rotate(180deg); +} + +.access-details[open] .expand-hint { + display: none; +} + +.routing-examples-panel { + margin-top: 0; + padding-top: 0; + border-top: none; + background: transparent; + box-shadow: none; + padding: 0 1.5rem 1.5rem; +} + +.routing-examples-panel h3 { + padding-top: 1rem; +} + +/* ======================================== + 可用模型列表样式 + ======================================== */ + +.models-section { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border-color); +} + +.models-section-title { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 1.25rem; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.models-section-title i { + color: var(--primary-color); +} + +.models-description { + margin-bottom: 1.5rem; +} + +.models-container { + background: var(--bg-secondary); + padding: 1.25rem; + border-radius: var(--radius-lg); + border: 1px solid var(--border-color); +} + +.models-list { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.models-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 2.5rem; + color: var(--text-secondary); + font-size: 1rem; +} + +.models-loading i { + font-size: 1.25rem; + color: var(--primary-color); +} + +.provider-models-group { + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + overflow: hidden; + transition: var(--transition); + background: var(--bg-primary); +} + +.provider-models-group:hover { + border-color: var(--primary-color); + box-shadow: var(--shadow-md); +} + +.provider-models-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.875rem 1.25rem; + background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%); + border-bottom: 1px solid var(--border-color); + cursor: pointer; + transition: var(--transition); +} + +.provider-models-header:hover { + background: linear-gradient(135deg, var(--primary-10) 0%, var(--bg-tertiary) 100%); +} + +.provider-models-title { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.provider-models-title i { + font-size: 1.125rem; + color: var(--primary-color); +} + +.provider-models-title h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); +} + +.provider-models-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.5rem; + height: 1.5rem; + padding: 0 0.4rem; + background: var(--primary-color); + color: white; + border-radius: 9999px; + font-size: 0.7rem; + font-weight: 600; +} + +.provider-models-toggle { + color: var(--text-secondary); + transition: var(--transition); +} + +.provider-models-header.collapsed .provider-models-toggle { + transform: rotate(-90deg); +} + +.provider-models-content { + padding: 0.875rem 1.25rem; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 0.625rem; +} + +.provider-models-content.collapsed { + display: none; +} + +.model-item { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.625rem 0.875rem; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + cursor: pointer; + transition: var(--transition); + position: relative; + overflow: hidden; +} + +.model-item:hover { + border-color: var(--primary-color); + background: var(--primary-10); + transform: translateY(-2px); + box-shadow: var(--shadow-sm); +} + +.model-item:active { + transform: translateY(0); +} + +.model-item-icon { + display: flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + background: var(--primary-10); + border-radius: var(--radius-sm); + color: var(--primary-color); + flex-shrink: 0; + font-size: 0.75rem; +} + +.model-item-name { + flex: 1; + font-size: 0.8rem; + font-weight: 500; + color: var(--text-primary); + word-break: break-all; +} + +.model-item-copy { + display: flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + color: var(--text-tertiary); + opacity: 0; + transition: var(--transition); + font-size: 0.75rem; +} + +.model-item:hover .model-item-copy { + opacity: 1; + color: var(--primary-color); +} + +.model-item.copied { + border-color: var(--success-color); + background: var(--success-10); +} + +.model-item.copied .model-item-copy { + opacity: 1; + color: var(--success-color); +} + +.models-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2.5rem; + color: var(--text-secondary); + text-align: center; +} + +.models-empty i { + font-size: 2.5rem; + margin-bottom: 0.75rem; + opacity: 0.5; +} + +.models-empty p { + margin: 0; + font-size: 0.9rem; +} + +.models-description .highlight-note { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.875rem 1rem; + background: linear-gradient(135deg, var(--info-bg) 0%, var(--info-bg-light, var(--info-bg)) 100%); + border: 1px solid var(--info-border); + border-radius: 0.5rem; + color: var(--info-text); + font-weight: 500; + width: 100%; + box-sizing: border-box; + font-size: 0.875rem; +} + +.models-description .highlight-note i { + color: var(--info-color, var(--info-text)); + font-size: 1.125rem; + flex-shrink: 0; +} + +.models-description .highlight-note span { + flex: 1; + text-align: center; +} + +.access-endpoint-actions { + display: flex; + gap: 0.5rem; +} + +/* Dark Theme Adjustments */ +[data-theme="dark"] .access-details { + background: var(--bg-primary); + border-color: var(--border-color); +} + +[data-theme="dark"] .provider-models-group { + background: var(--bg-primary); + border-color: var(--border-color); +} + +[data-theme="dark"] .model-item { + background: var(--bg-secondary); + border-color: var(--border-color); +} + +[data-theme="dark"] .models-container { + background: var(--bg-secondary); +} diff --git a/static/components/section-access.html b/static/components/section-access.html index 5246a7ec4..02b00e50c 100644 --- a/static/components/section-access.html +++ b/static/components/section-access.html @@ -54,14 +54,14 @@

接入
-
- 这是客户端调用 `/v1/chat/completions` 或 `/v1/messages` 时要带的认证 Key。 @@ -71,9 +71,9 @@

接入
-
通常把这个地址加上供应商路径后,直接发给客户端即可。 @@ -128,12 +128,12 @@

用于生成片段的供应商 -
+
片段格式 -
- - - +
+ + +
@@ -144,9 +144,9 @@

Cherry Studio

适合手动新建 OpenAI 兼容供应商时直接照着填。

-
@@ -157,9 +157,9 @@

Cherry Studio

NextChat

适合环境变量、自定义 OpenAI 接入或发给部署同学。

-
@@ -170,9 +170,9 @@

NextChat

Cline

适合新建 OpenAI Provider profile 时直接参考。

-
diff --git a/static/components/section-dashboard.html b/static/components/section-dashboard.html index b48a72f5b..56fa3d6a9 100644 --- a/static/components/section-dashboard.html +++ b/static/components/section-dashboard.html @@ -146,38 +146,16 @@

- - 高级信息 (路径路由与模型列表) + + 可用模型列表 展开更多
-
-

路径路由调用示例

-

通过不同路径路由访问不同的AI模型提供商,支持灵活的模型切换

- -
- -
- - 加载中... -
-
- -
-

使用提示

-
    -
  • 即时切换: 通过修改URL路径即可切换不同的AI模型提供商
  • -
  • 客户端配置: 在Cherry-Studio、NextChat、Cline等客户端中设置API端点为对应路径
  • -
  • 跨协议调用: 支持OpenAI协议调用Claude模型,或Claude协议调用OpenAI模型
  • -
-
- -
-

可用模型列表

+
From b5c4e74589a12e7b70efa4c65346dd409b7d3656 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Fri, 24 Apr 2026 15:43:35 +0800 Subject: [PATCH 049/135] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Grok=20SSO?= =?UTF-8?q?=20Token=20=E6=89=B9=E9=87=8F=E5=AF=BC=E5=85=A5=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=B9=B6=E6=9B=B4=E6=96=B0=20Codex=20=E7=89=88?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Grok SSO Token 批量导入功能,支持纯文本和 JSON 数组格式 - 更新 Codex API 版本至 0.124.0,添加 gpt-5.5 模型支持 - 移除已弃用的单元测试文件,优化代码结构 - 修复 UI 交互问题:provider 卡片点击行为和光标样式 - 添加多语言翻译支持,完善 OAuth 批量导入的用户体验 --- src/auth/grok-auth.js | 170 +++++++++++ src/auth/index.js | 6 + src/auth/oauth-handlers.js | 4 +- src/providers/openai/codex-core.js | 2 +- src/providers/provider-models.js | 1 + src/services/ui-manager.js | 4 + src/ui-modules/oauth-api.js | 94 +++++- static/app/base.css | 2 +- static/app/file-upload.js | 3 +- static/app/i18n.js | 38 +++ static/app/modal.js | 30 +- static/app/provider-manager.js | 394 ++++++++++++++++++++++++- tests/access-info.unit.test.js | 59 ---- tests/provider-models.unit.test.js | 33 --- tests/rate-limit-cooldown.unit.test.js | 113 ------- tests/security-fixes.test.js | 325 -------------------- tests/security-fixes.unit.test.js | 205 ------------- 17 files changed, 691 insertions(+), 792 deletions(-) create mode 100644 src/auth/grok-auth.js delete mode 100644 tests/access-info.unit.test.js delete mode 100644 tests/provider-models.unit.test.js delete mode 100644 tests/rate-limit-cooldown.unit.test.js delete mode 100644 tests/security-fixes.test.js delete mode 100644 tests/security-fixes.unit.test.js diff --git a/src/auth/grok-auth.js b/src/auth/grok-auth.js new file mode 100644 index 000000000..40ded754a --- /dev/null +++ b/src/auth/grok-auth.js @@ -0,0 +1,170 @@ +import fs from 'fs'; +import path from 'path'; +import crypto from 'crypto'; +import logger from '../utils/logger.js'; +import { broadcastEvent } from '../services/ui-manager.js'; +import { getProviderPoolManager } from '../services/service-manager.js'; +import { CONFIG } from '../core/config-manager.js'; +import { createProviderConfig } from '../utils/provider-utils.js'; +import { withFileLock, atomicWriteFile } from '../utils/file-lock.js'; + +/** + * 批量导入 Grok SSO Tokens (流式处理) + * @param {Array} tokens - Token 数组 (可以是字符串数组 or 对象数组) + * @param {Function} onProgress - 进度回调函数 + * @param {Boolean} skipDuplicateCheck - 是否跳过重复检查 + * @returns {Promise} 导入结果统计 + */ +export async function batchImportGrokTokensStream(tokens, onProgress = null, skipDuplicateCheck = false) { + const results = { + total: tokens.length, + success: 0, + failed: 0, + details: [] + }; + + const providerType = 'grok-custom'; + const poolManager = getProviderPoolManager(); + const allPools = poolManager ? poolManager.providerPools : (CONFIG.providerPools || {}); + if (!allPools[providerType]) allPools[providerType] = []; + + const pool = allPools[providerType]; + + for (let i = 0; i < tokens.length; i++) { + let ssoToken = tokens[i]; + + // 支持多种输入格式:直接是字符串或者是包含 sso 字段的对象 + if (typeof ssoToken === 'object' && ssoToken !== null) { + ssoToken = ssoToken.sso || ssoToken.GROK_COOKIE_TOKEN || ssoToken.token; + } + + const progressData = { + index: i + 1, + total: tokens.length, + current: null + }; + + try { + if (!ssoToken || typeof ssoToken !== 'string') { + throw new Error('无效的 SSO Token 格式'); + } + + // 清理 token 字符串(去除前后空格及可能的引号) + let cleanedToken = ssoToken.trim(); + if (cleanedToken.startsWith('"') && cleanedToken.endsWith('"')) { + cleanedToken = cleanedToken.substring(1, cleanedToken.length - 1).trim(); + } + if (cleanedToken.startsWith("'") && cleanedToken.endsWith("'")) { + cleanedToken = cleanedToken.substring(1, cleanedToken.length - 1).trim(); + } + + if (!cleanedToken) { + throw new Error('SSO Token 不能为空'); + } + + // 使用 token 的哈希作为 ID 防止重复 + const tokenId = crypto.createHash('md5').update(cleanedToken).digest('hex').substring(0, 12); + + // 检查重复 + if (!skipDuplicateCheck) { + const existingProvider = pool.find(p => { + // 1. 精确匹配 Token 字段 + if (p.GROK_COOKIE_TOKEN === cleanedToken) return true; + + // 2. 模糊匹配:检查对象的所有字符串属性值是否包含该 Token (处理可能的不同字段名) + return Object.values(p).some(val => + typeof val === 'string' && val.trim() === cleanedToken + ); + }); + + if (existingProvider) { + progressData.current = { + index: i + 1, + success: false, + error: 'duplicate', + existingPath: existingProvider.customName || existingProvider.uuid + }; + results.failed++; + results.details.push(progressData.current); + if (onProgress) { + onProgress({ + ...progressData, + successCount: results.success, + failedCount: results.failed + }); + } + continue; + } + } + + // 创建新的提供商配置 + const newProvider = createProviderConfig({ + credPathKey: 'GROK_COOKIE_TOKEN', + credPath: cleanedToken, // 直接存储 Token 字符串 + defaultCheckModel: 'grok-4.1-mini', + needsProjectId: false, + urlKeys: ['GROK_BASE_URL', 'GROK_CF_CLEARANCE', 'GROK_USER_AGENT'] + }); + + // 补充 Grok 默认配置 + newProvider.GROK_BASE_URL = 'https://grok.com'; + newProvider.customName = `Imported Token ${tokenId}`; + + // 添加到 Pool + pool.push(newProvider); + + progressData.current = { + index: i + 1, + success: true, + path: `Token ${tokenId}` + }; + results.success++; + + } catch (error) { + logger.error(`[Grok Batch Import] Token ${i + 1} import failed:`, error.message); + progressData.current = { + index: i + 1, + success: false, + error: error.message + }; + results.failed++; + } + + results.details.push(progressData.current); + + // 发送进度更新 + if (onProgress) { + onProgress({ + ...progressData, + successCount: results.success, + failedCount: results.failed + }); + } + } + + // 如果有成功的,更新 ProviderPoolManager 并广播事件 + if (results.success > 0) { + try { + // 确保 CONFIG.providerPools 与 allPools 同步 + CONFIG.providerPools = allPools; + + // 更新 ProviderPoolManager + if (poolManager) { + poolManager.providerPools = allPools; + poolManager.initializeProviderStatus(); + } + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'batch_add', + provider: 'grok', + count: results.success, + timestamp: new Date().toISOString() + }); + } catch (error) { + logger.error(`[Grok Batch Import] Failed to update provider pools: ${error.message}`); + } + } + + return results; +} diff --git a/src/auth/index.js b/src/auth/index.js index dc5cfb022..488c1dba0 100644 --- a/src/auth/index.js +++ b/src/auth/index.js @@ -33,3 +33,9 @@ export { handleIFlowOAuth, refreshIFlowTokens } from './iflow-oauth.js'; + +// Grok Auth +export { + batchImportGrokTokensStream +} from './grok-auth.js'; + diff --git a/src/auth/oauth-handlers.js b/src/auth/oauth-handlers.js index 1183e4a50..39814f706 100644 --- a/src/auth/oauth-handlers.js +++ b/src/auth/oauth-handlers.js @@ -24,4 +24,6 @@ export { // iFlow OAuth handleIFlowOAuth, refreshIFlowTokens, -} from './index.js'; \ No newline at end of file + // Grok Auth + batchImportGrokTokensStream +} from './index.js'; diff --git a/src/providers/openai/codex-core.js b/src/providers/openai/codex-core.js index d2aafc83a..00c5cc2dd 100644 --- a/src/providers/openai/codex-core.js +++ b/src/providers/openai/codex-core.js @@ -14,7 +14,7 @@ import { getProviderModels } from '../provider-models.js'; const baseModels = getProviderModels(MODEL_PROVIDER.CODEX_API); const fastModels = baseModels.map(m => `${m}-fast`); const CODEX_MODELS = [...new Set([...baseModels, ...fastModels])]; -const CODEX_VERSION = '0.118.0'; +const CODEX_VERSION = '0.124.0'; /** * Codex API 服务类 diff --git a/src/providers/provider-models.js b/src/providers/provider-models.js index be56f1278..39ca885fc 100644 --- a/src/providers/provider-models.js +++ b/src/providers/provider-models.js @@ -118,6 +118,7 @@ export const PROVIDER_MODELS = { 'gpt-5.3-codex-spark', 'gpt-5.4', 'gpt-5.4-mini', + 'gpt-5.5', ], 'forward-api': [], 'grok-custom': [ diff --git a/src/services/ui-manager.js b/src/services/ui-manager.js index 237a0a830..c946c64ec 100644 --- a/src/services/ui-manager.js +++ b/src/services/ui-manager.js @@ -360,6 +360,10 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo return await oauthApi.handleBatchImportCodexTokens(req, res); } + if (method === 'POST' && pathParam === '/api/grok/batch-import-tokens') { + return await oauthApi.handleBatchImportGrokTokens(req, res); + } + // Import AWS SSO credentials for Kiro if (method === 'POST' && pathParam === '/api/kiro/import-aws-credentials') { return await oauthApi.handleImportAwsCredentials(req, res); diff --git a/src/ui-modules/oauth-api.js b/src/ui-modules/oauth-api.js index 91c509459..a8e99626f 100644 --- a/src/ui-modules/oauth-api.js +++ b/src/ui-modules/oauth-api.js @@ -10,7 +10,8 @@ import { handleCodexOAuth, batchImportCodexTokensStream, batchImportKiroRefreshTokensStream, - importAwsCredentials + importAwsCredentials, + batchImportGrokTokensStream } from '../auth/oauth-handlers.js'; /** @@ -185,7 +186,7 @@ export async function handleManualOAuthCallback(req, res) { export async function handleBatchImportKiroTokens(req, res) { try { const body = await getRequestBody(req); - const { refreshTokens, region } = body; + const { refreshTokens, region, skipDuplicateCheck } = body; if (!refreshTokens || !Array.isArray(refreshTokens) || refreshTokens.length === 0) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -230,7 +231,8 @@ export async function handleBatchImportKiroTokens(req, res) { (progress) => { // 每处理完一个 token 发送进度更新 sendSSE('progress', progress); - } + }, + !!skipDuplicateCheck // 显式传递:默认为 false (执行去重) ); logger.info(`[Kiro Batch Import] Completed: ${result.success} success, ${result.failed} failed`); @@ -312,7 +314,7 @@ export async function handleBatchImportGeminiTokens(req, res) { (progress) => { sendSSE('progress', progress); }, - skipDuplicateCheck !== false // 默认为 true + !!skipDuplicateCheck // 默认为 false (执行去重) ); logger.info(`[Gemini Batch Import] Completed: ${result.success} success, ${result.failed} failed`); @@ -388,7 +390,7 @@ export async function handleBatchImportCodexTokens(req, res) { (progress) => { sendSSE('progress', progress); }, - skipDuplicateCheck !== false // 默认为 true + !!skipDuplicateCheck // 默认为 false (执行去重) ); logger.info(`[Codex Batch Import] Completed: ${result.success} success, ${result.failed} failed`); @@ -422,13 +424,89 @@ export async function handleBatchImportCodexTokens(req, res) { } } +/** + * 批量导入 Grok SSO Tokens (流式处理) + */ +export async function handleBatchImportGrokTokens(req, res) { + try { + const body = await getRequestBody(req); + const { tokens, skipDuplicateCheck } = body; + + if (!tokens || !Array.isArray(tokens) || tokens.length === 0) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: 'tokens array is required and must not be empty' + })); + return true; + } + + logger.info(`[Grok Batch Import] Starting batch import with ${tokens.length} tokens...`); + + // 设置 SSE 响应头 + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no' + }); + + // 发送 SSE 事件的辅助函数 + const sendSSE = (event, data) => { + res.write(`event: ${event}\n`); + res.write(`data: ${JSON.stringify(data)}\n\n`); + }; + + // 发送开始事件 + sendSSE('start', { total: tokens.length }); + + // 执行流式批量导入 + const result = await batchImportGrokTokensStream( + tokens, + (progress) => { + sendSSE('progress', progress); + }, + !!skipDuplicateCheck // 默认为 false (执行去重) + ); + + logger.info(`[Grok Batch Import] Completed: ${result.success} success, ${result.failed} failed`); + + // 发送完成事件 + sendSSE('complete', { + success: true, + total: result.total, + successCount: result.success, + failedCount: result.failed, + details: result.details + }); + + res.end(); + return true; + + } catch (error) { + logger.error('[Grok Batch Import] Error:', error); + if (res.headersSent) { + res.write(`event: error\n`); + res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`); + res.end(); + } else { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: error.message + })); + } + return true; + } +} + /** * 导入 AWS SSO 凭据用于 Kiro(支持单个或批量导入) */ export async function handleImportAwsCredentials(req, res) { try { const body = await getRequestBody(req); - const { credentials } = body; + const { credentials, skipDuplicateCheck } = body; if (!credentials) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -513,7 +591,7 @@ export async function handleImportAwsCredentials(req, res) { }; try { - const result = await importAwsCredentials(cred); + const result = await importAwsCredentials(cred, !!skipDuplicateCheck); if (result.success) { progressData.current = { @@ -584,7 +662,7 @@ export async function handleImportAwsCredentials(req, res) { logger.info('[Kiro AWS Import] Starting AWS credentials import...'); - const result = await importAwsCredentials(credentials); + const result = await importAwsCredentials(credentials, !!skipDuplicateCheck); if (result.success) { logger.info(`[Kiro AWS Import] Successfully imported credentials to: ${result.path}`); diff --git a/static/app/base.css b/static/app/base.css index 72c843c50..13652c52d 100644 --- a/static/app/base.css +++ b/static/app/base.css @@ -722,7 +722,7 @@ body { padding: 8px 10px; position: relative; transition: all 0.2s ease; - cursor: pointer; + cursor: default; display: flex; flex-direction: column; gap: 4px; diff --git a/static/app/file-upload.js b/static/app/file-upload.js index a6dd6100e..f026ee6f0 100644 --- a/static/app/file-upload.js +++ b/static/app/file-upload.js @@ -58,7 +58,8 @@ class FileUploadHandler { 'gemini-antigravity': 'antigravity', 'claude-kiro-oauth': 'kiro', 'openai-qwen-oauth': 'qwen', - 'openai-iflow': 'iflow' + 'openai-iflow': 'iflow', + 'openai-codex-oauth': 'codex' }; return providerMap[provider] || 'gemini'; } diff --git a/static/app/i18n.js b/static/app/i18n.js index b39ec5f1e..532ad87ee 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -275,6 +275,8 @@ const translations = { 'oauth.gemini.jsonHint': '请确保 JSON 包含 access_token 和 refresh_token', 'oauth.codex.batchImport': '批量导入 Codex Token', 'oauth.codex.batchImportDesc': '批量导入多个 Codex Token JSON 数据', + 'oauth.codex.oauth': 'Codex OAuth 授权', + 'oauth.codex.oauthDesc': '通过 OpenAI 账号进行标准 OAuth 授权', 'oauth.codex.tokensLabel': 'Token 数据 (JSON 数组)', 'oauth.codex.tokensPlaceholder': '请粘贴包含 access_token 和 id_token 的 JSON 数组...', 'oauth.codex.importInstructions': '请粘贴从浏览器或 CLI 获取的 Codex Token JSON 数据。支持单个对象或对象数组。', @@ -289,6 +291,23 @@ const translations = { 'oauth.codex.startImport': '开始导入', 'oauth.codex.jsonExample': '查看 JSON 格式示例', 'oauth.codex.jsonHint': '请确保 JSON 包含 access_token 和 id_token', + 'oauth.grok.batchImport': '批量导入 Grok SSO Token', + 'oauth.grok.batchImportDesc': '批量导入多个 Grok SSO Token 数据', + 'oauth.grok.tokensLabel': 'Token 数据 (每行一个 SSO)', + 'oauth.grok.tokensPlaceholder': '请粘贴 Grok SSO Token,支持每行一个,或 JSON 数组...', + 'oauth.grok.importInstructions': '请粘贴从浏览器获取的 Grok SSO Token 数据。支持纯文本(每行一个)或 JSON 数组格式。', + 'oauth.grok.noTokens': '请输入有效的 Grok Token 数据', + 'oauth.grok.importing': '正在导入...', + 'oauth.grok.importingProgress': '正在处理: {current} / {total}', + 'oauth.grok.importSuccess': '成功导入 {count} 个凭据', + 'oauth.grok.importAllFailed': '所有 {count} 个凭据导入失败', + 'oauth.grok.importPartial': '部分导入成功: {success} 成功, {failed} 失败', + 'oauth.grok.importError': '导入过程中出错', + 'oauth.grok.tokenCount': 'Token 数量', + 'oauth.grok.startImport': '开始导入', + 'oauth.grok.jsonExample': '查看导入格式示例', + 'oauth.grok.jsonHint': '支持直接粘贴 SSO 字符串(每行一个)', + 'oauth.grok.duplicateToken': '重复凭据 - 此 SSO Token 已存在', 'oauth.kiro.duplicateCredentials': '该凭据已存在,请勿重复导入', 'oauth.kiro.builderIDStartURL': 'Builder ID Start URL', 'oauth.kiro.builderIDStartURLHint': '如果您使用 AWS IAM Identity Center,请输入您的 Start URL', @@ -1344,6 +1363,8 @@ const translations = { 'oauth.gemini.jsonHint': 'Ensure JSON contains access_token and refresh_token', 'oauth.codex.batchImport': 'Batch Import Codex Tokens', 'oauth.codex.batchImportDesc': 'Import multiple Codex Token JSON objects', + 'oauth.codex.oauth': 'Codex OAuth', + 'oauth.codex.oauthDesc': 'Standard OAuth via OpenAI account', 'oauth.codex.tokensLabel': 'Token Data (JSON Array)', 'oauth.codex.tokensPlaceholder': 'Paste JSON array containing access_token and id_token...', 'oauth.codex.importInstructions': 'Paste Codex Token JSON from browser or CLI. Supports single object or array.', @@ -1358,6 +1379,23 @@ const translations = { 'oauth.codex.startImport': 'Start Import', 'oauth.codex.jsonExample': 'View JSON Example', 'oauth.codex.jsonHint': 'Ensure JSON contains access_token and id_token', + 'oauth.grok.batchImport': 'Batch Import Grok SSO Tokens', + 'oauth.grok.batchImportDesc': 'Import multiple Grok SSO Token strings', + 'oauth.grok.tokensLabel': 'Token Data (one SSO per line)', + 'oauth.grok.tokensPlaceholder': 'Paste Grok SSO Tokens, one per line or as a JSON array...', + 'oauth.grok.importInstructions': 'Paste Grok SSO Tokens from browser. Supports plain text (one per line) or JSON array format.', + 'oauth.grok.noTokens': 'Please enter valid Grok Token data', + 'oauth.grok.importing': 'Importing...', + 'oauth.grok.importingProgress': 'Processing: {current} / {total}', + 'oauth.grok.importSuccess': 'Successfully imported {count} credentials', + 'oauth.grok.importAllFailed': 'Failed to import all {count} credentials', + 'oauth.grok.importPartial': 'Partial success: {success} succeeded, {failed} failed', + 'oauth.grok.importError': 'Import error', + 'oauth.grok.tokenCount': 'Token Count', + 'oauth.grok.startImport': 'Start Import', + 'oauth.grok.jsonExample': 'View Import Examples', + 'oauth.grok.jsonHint': 'Supports pasting SSO strings directly (one per line)', + 'oauth.grok.duplicateToken': 'Duplicate credential - this SSO Token already exists', 'oauth.kiro.duplicateCredentials': 'This credential already exists, please do not import duplicates', 'oauth.kiro.builderIDStartURL': 'Builder ID Start URL', 'oauth.kiro.builderIDStartURLHint': 'If you use AWS IAM Identity Center, enter your Start URL', diff --git a/static/app/modal.js b/static/app/modal.js index 547dcbd7e..a40f0a9fd 100644 --- a/static/app/modal.js +++ b/static/app/modal.js @@ -952,7 +952,7 @@ function renderProviderCardList(providers) { const toggleButtonClass = isDisabled ? 'btn-success' : 'btn-warning'; return ` -
+
${displayName}
@@ -1614,34 +1614,6 @@ function showAddProviderForm(providerType) { return; } - // Codex OAuth 只支持授权添加,不支持手动添加 - if (providerType === 'openai-codex-oauth') { - const form = document.createElement('div'); - form.className = 'add-provider-form'; - form.innerHTML = ` -

添加新提供商配置

-
-
- - Codex 仅支持 OAuth 授权添加 -
-

- OpenAI Codex 需要通过 OAuth 授权获取访问令牌,无法手动填写凭据。请点击下方按钮进行授权。 -

- - -
- `; - - const providerList = modal.querySelector('.provider-list'); - providerList.parentNode.insertBefore(form, providerList); - return; - } - const form = document.createElement('div'); form.className = 'add-provider-form'; form.innerHTML = ` diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js index 69559486e..e94ec264a 100644 --- a/static/app/provider-manager.js +++ b/static/app/provider-manager.js @@ -758,8 +758,8 @@ async function openProviderManager(providerType, searchTerm = '') { * @returns {string} 授权按钮HTML */ function generateAuthButton(providerType) { - // 只为支持OAuth的提供商显示授权按钮 - const oauthProviders = ['gemini-cli-oauth', 'gemini-antigravity', 'openai-qwen-oauth', 'claude-kiro-oauth', 'openai-iflow', 'openai-codex-oauth']; + // 只为支持OAuth或批量导入的提供商显示授权按钮 + const oauthProviders = ['gemini-cli-oauth', 'gemini-antigravity', 'openai-qwen-oauth', 'claude-kiro-oauth', 'openai-iflow', 'openai-codex-oauth', 'grok-custom']; if (!oauthProviders.includes(providerType)) { return ''; @@ -872,6 +872,12 @@ async function handleGenerateAuthUrl(providerType) { return; } + // 如果是 Grok,显示认证方式选择对话框(目前仅支持批量导入,因为没有标准 OAuth) + if (providerType === 'grok-custom') { + showGrokAuthMethodSelector(providerType); + return; + } + await executeGenerateAuthUrl(providerType, {}); } @@ -893,10 +899,10 @@ function showCodexAuthMethodSelector(providerType) { + `; + + document.body.appendChild(modal); + + // 关闭按钮事件 + const closeBtn = modal.querySelector('.modal-close'); + const cancelBtn = modal.querySelector('.modal-cancel'); + [closeBtn, cancelBtn].forEach(btn => { + btn.addEventListener('click', () => { + modal.remove(); + }); + }); + + // 认证方式选择按钮事件 + const methodBtns = modal.querySelectorAll('.auth-method-btn'); + methodBtns.forEach(btn => { + btn.addEventListener('mouseenter', () => { + btn.style.borderColor = '#10b981'; + btn.style.background = '#f0fdf4'; + }); + btn.addEventListener('mouseleave', () => { + btn.style.borderColor = '#e0e0e0'; + btn.style.background = 'white'; + }); + btn.addEventListener('click', async () => { + const method = btn.dataset.method; + modal.remove(); + + if (method === 'batch-import') { + showGrokBatchImportModal(providerType); + } + }); }); } +/** + * 显示 Grok 批量导入模态框 + * @param {string} providerType - 提供商类型 + */ +function showGrokBatchImportModal(providerType) { + const modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.style.display = 'flex'; + + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + + const textarea = modal.querySelector('#batchGrokTokens'); + const statsDiv = modal.querySelector('#grokBatchStats'); + const tokenCountValue = modal.querySelector('#grokTokenCountValue'); + const progressDiv = modal.querySelector('#grokBatchProgress'); + const progressBar = modal.querySelector('#grokImportProgressBar'); + const resultDiv = modal.querySelector('#grokBatchResult'); + const submitBtn = modal.querySelector('#grokBatchSubmit'); + const closeBtn = modal.querySelector('.modal-close'); + const cancelBtn = modal.querySelector('.modal-cancel'); + + // 自动检测输入 + textarea.addEventListener('input', () => { + const content = textarea.value.trim(); + if (!content) { + statsDiv.style.display = 'none'; + return; + } + + let tokens = []; + try { + // 尝试解析为 JSON + const parsed = JSON.parse(content); + tokens = Array.isArray(parsed) ? parsed : [parsed]; + } catch (e) { + // 解析失败,按行分割 + tokens = content.split('\n').map(t => t.trim()).filter(t => t.length > 0); + } + + tokenCountValue.textContent = tokens.length; + statsDiv.style.display = 'block'; + }); + + // 关闭 + [closeBtn, cancelBtn].forEach(btn => { + btn.addEventListener('click', () => modal.remove()); + }); + + // 提交 + submitBtn.onclick = async () => { + const content = textarea.value.trim(); + if (!content) { + showToast(t('common.error'), t('oauth.grok.noTokens'), 'error'); + return; + } + + let tokens = []; + try { + const parsed = JSON.parse(content); + tokens = Array.isArray(parsed) ? parsed : [parsed]; + } catch (e) { + tokens = content.split('\n').map(t => t.trim()).filter(t => t.length > 0); + } + + if (tokens.length === 0) { + showToast(t('common.warning'), t('oauth.grok.noTokens'), 'warning'); + return; + } + + // 开始导入 + textarea.disabled = true; + submitBtn.disabled = true; + cancelBtn.disabled = true; + progressDiv.style.display = 'block'; + resultDiv.style.display = 'none'; + progressBar.style.width = '0%'; + + // 创建实时结果显示区域 + resultDiv.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #f3f4f6; border: 1px solid #d1d5db;'; + resultDiv.innerHTML = ` +
+ + ${t('oauth.grok.importingProgress', { current: 0, total: tokens.length })} +
+
+ `; + + const progressText = resultDiv.querySelector('#grokBatchProgressText'); + const resultsList = resultDiv.querySelector('#grokBatchResultsList'); + + let importSuccess = false; + + try { + const response = await fetch('/api/grok/batch-import-tokens', { + method: 'POST', + headers: window.apiClient ? window.apiClient.getAuthHeaders() : { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ tokens }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + let eventType = ''; + let eventData = ''; + + for (const line of lines) { + if (line.startsWith('event: ')) { + eventType = line.substring(7).trim(); + } else if (line.startsWith('data: ')) { + eventData = line.substring(6).trim(); + + if (eventType && eventData) { + try { + const data = JSON.parse(eventData); + + if (eventType === 'progress') { + const { index, total, current } = data; + const percentage = Math.round((index / total) * 100); + progressBar.style.width = `${percentage}%`; + progressText.textContent = t('oauth.grok.importingProgress', { current: index, total: total }); + + const resultItem = document.createElement('div'); + resultItem.style.cssText = 'padding: 4px 0; border-bottom: 1px solid rgba(0,0,0,0.1);'; + if (current.success) { + resultItem.innerHTML = `Token ${current.index}: ✓ ${current.path}`; + importSuccess = true; + } else if (current.error === 'duplicate') { + resultItem.innerHTML = `Token ${current.index}: ⚠ ${t('oauth.grok.duplicateToken')} + ${current.existingPath ? `(${current.existingPath})` : ''}`; + } else { + resultItem.innerHTML = `Token ${current.index}: ✗ ${current.error}`; + } + resultsList.appendChild(resultItem); + resultsList.scrollTop = resultsList.scrollHeight; + } else if (eventType === 'complete') { + progressBar.style.width = '100%'; + progressDiv.style.display = 'none'; + + const isAllSuccess = data.failedCount === 0; + const isAllFailed = data.successCount === 0; + let resultClass, resultIcon, resultMessage; + + if (isAllSuccess) { + resultClass = 'background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;'; + resultIcon = 'fa-check-circle'; + resultMessage = t('oauth.grok.importSuccess', { count: data.successCount }); + } else if (isAllFailed) { + resultClass = 'background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;'; + resultIcon = 'fa-times-circle'; + resultMessage = t('oauth.grok.importAllFailed', { count: data.failedCount }); + } else { + resultClass = 'background: #fffbeb; border: 1px solid #fde68a; color: #92400e;'; + resultIcon = 'fa-exclamation-triangle'; + resultMessage = t('oauth.grok.importPartial', { success: data.successCount, failed: data.failedCount }); + } + + // 移除加载图标并更新文案 + const headerWrapper = resultDiv.querySelector('div:first-child'); + headerWrapper.innerHTML = ` ${resultMessage}`; + + // 刷新页面数据 + if (importSuccess) { + setTimeout(() => { + if (window.loadProviders) window.loadProviders(); + if (window.refreshProviderConfig) window.refreshProviderConfig(providerType); + }, 1000); + } + + // 恢复按钮 + submitBtn.innerHTML = ` ${t('common.confirm')}`; + submitBtn.disabled = false; + submitBtn.onclick = () => modal.remove(); + cancelBtn.style.display = 'none'; + } + } catch (e) { + console.error('Error parsing SSE data:', e); + } + } + } + } + } + } catch (error) { + console.error('Grok Batch Import Error:', error); + progressText.innerHTML = `${t('oauth.grok.importError')}: ${error.message}`; + submitBtn.disabled = false; + cancelBtn.disabled = false; + } + }; +} + /** * 显示 Kiro OAuth 认证方式选择对话框 * @param {string} providerType - 提供商类型 @@ -1544,7 +1900,7 @@ function showGeminiBatchImportModal(providerType) { }); // 提交按钮事件 - submitBtn.addEventListener('click', async () => { + submitBtn.onclick = async () => { let tokens = []; try { const val = textarea.value.trim(); @@ -1661,9 +2017,9 @@ function showGeminiBatchImportModal(providerType) { resultMessage = t('oauth.gemini.importPartial', { success: data.successCount, failed: data.failedCount }); } - resultDiv.style.cssText = `display: block; margin-top: 16px; padding: 12px; border-radius: 8px; ${resultClass}`; - const headerDiv = resultDiv.querySelector('div:first-child'); - headerDiv.innerHTML = ` ${resultMessage}`; + // 移除加载图标并更新文案 + const headerWrapper = resultDiv.querySelector('div:first-child'); + headerWrapper.innerHTML = ` ${resultMessage}`; if (data.successCount > 0) { importSuccess = true; @@ -1700,10 +2056,13 @@ function showGeminiBatchImportModal(providerType) { submitBtn.disabled = false; submitBtn.innerHTML = ` ${t('oauth.gemini.startImport')}`; } else { - submitBtn.innerHTML = ` ${t('common.success')}`; + submitBtn.innerHTML = ` ${t('common.confirm')}`; + submitBtn.disabled = false; + submitBtn.onclick = () => modal.remove(); + cancelBtn.style.display = 'none'; } } - }); + }; } /** @@ -1797,7 +2156,7 @@ function showKiroBatchImportModal() { }); // 提交按钮事件 - 使用 SSE 流式响应实时显示进度 - submitBtn.addEventListener('click', async () => { + submitBtn.onclick = async () => { const tokens = textarea.value.split('\n').filter(line => line.trim()); if (tokens.length === 0) { @@ -1975,10 +2334,13 @@ function showKiroBatchImportModal() { submitBtn.disabled = false; submitBtn.innerHTML = ` ${t('oauth.kiro.startImport')}`; } else { - submitBtn.innerHTML = ` ${t('common.success')}`; + submitBtn.innerHTML = ` ${t('common.confirm')}`; + submitBtn.disabled = false; + submitBtn.onclick = () => modal.remove(); + cancelBtn.style.display = 'none'; } } - }); + }; } /** diff --git a/tests/access-info.unit.test.js b/tests/access-info.unit.test.js deleted file mode 100644 index b2fc9120b..000000000 --- a/tests/access-info.unit.test.js +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, test, expect } from '@jest/globals'; - -import { buildAccessInfoPayload } from '../src/ui-modules/access-api.js'; - -describe('buildAccessInfoPayload', () => { - test('should expose the real public api key and default providers for the access page', () => { - const currentConfig = { - REQUIRED_API_KEY: 'sk-live-demo-key', - DEFAULT_MODEL_PROVIDERS: ['openai-codex-oauth', 'gemini-cli-oauth'], - PROVIDER_POOLS_FILE_PATH: 'configs/provider_pools.json' - }; - - const providerPoolManager = { - providerStatus: { - 'openai-codex-oauth': [ - { config: { uuid: 'codex-1', isHealthy: true, isDisabled: false }, state: {} }, - { config: { uuid: 'codex-2', isHealthy: false, isDisabled: true }, state: {} } - ], - 'gemini-cli-oauth': [ - { config: { uuid: 'gemini-1', isHealthy: true, isDisabled: false }, state: {} } - ] - } - }; - - const payload = buildAccessInfoPayload(currentConfig, providerPoolManager); - - expect(payload.apiKey).toBe('sk-live-demo-key'); - expect(payload.hasApiKey).toBe(true); - expect(payload.defaultProviders).toEqual(['openai-codex-oauth', 'gemini-cli-oauth']); - - const codexSummary = payload.providers.find(item => item.id === 'openai-codex-oauth'); - expect(codexSummary).toEqual(expect.objectContaining({ - totalNodes: 2, - healthyNodes: 1, - disabledNodes: 1, - enabledNodes: 1, - isDefault: true - })); - - const geminiSummary = payload.providers.find(item => item.id === 'gemini-cli-oauth'); - expect(geminiSummary).toEqual(expect.objectContaining({ - totalNodes: 1, - healthyNodes: 1, - disabledNodes: 0, - enabledNodes: 1, - isDefault: true - })); - }); - - test('should fall back to MODEL_PROVIDER when DEFAULT_MODEL_PROVIDERS is missing', () => { - const payload = buildAccessInfoPayload({ - REQUIRED_API_KEY: '', - MODEL_PROVIDER: 'openai-custom,claude-custom' - }, { providerStatus: {} }); - - expect(payload.hasApiKey).toBe(false); - expect(payload.defaultProviders).toEqual(['openai-custom', 'claude-custom']); - }); -}); diff --git a/tests/provider-models.unit.test.js b/tests/provider-models.unit.test.js deleted file mode 100644 index f65f7f031..000000000 --- a/tests/provider-models.unit.test.js +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, test } from '@jest/globals'; -import { - extractModelIdsFromNativeList, - getConfiguredSupportedModels, - usesManagedModelList -} from '../src/providers/provider-models.js'; - -describe('provider-models helpers', () => { - test('recognizes managed model list providers', () => { - expect(usesManagedModelList('openai-custom')).toBe(true); - expect(usesManagedModelList('openaiResponses-custom-lab')).toBe(true); - expect(usesManagedModelList('gemini-cli-oauth')).toBe(false); - }); - - test('normalizes supported models for managed providers', () => { - expect(getConfiguredSupportedModels('openai-custom', { - supportedModels: [' gpt-4o-mini ', '', 'gpt-4o-mini', 'gpt-4.1'] - })).toEqual(['gpt-4.1', 'gpt-4o-mini']); - - expect(getConfiguredSupportedModels('gemini-cli-oauth', { - supportedModels: ['gemini-2.5-flash'] - })).toEqual([]); - }); - - test('extracts model ids from openai-style model lists', () => { - expect(extractModelIdsFromNativeList({ - data: [ - { id: 'gpt-4o-mini' }, - { id: 'gpt-4.1' } - ] - }, 'openai-custom')).toEqual(['gpt-4.1', 'gpt-4o-mini']); - }); -}); diff --git a/tests/rate-limit-cooldown.unit.test.js b/tests/rate-limit-cooldown.unit.test.js deleted file mode 100644 index e5f5a3616..000000000 --- a/tests/rate-limit-cooldown.unit.test.js +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, expect, test } from '@jest/globals'; -import { getRateLimitCooldownRecoveryTime } from '../src/utils/common.js'; - -const NOW = Date.parse('2026-04-22T00:00:00.000Z'); - -describe('rate-limit cooldown helper', () => { - test('returns null when cooldown is disabled', () => { - const recoveryTime = getRateLimitCooldownRecoveryTime( - { response: { status: 429 } }, - { RATE_LIMIT_COOLDOWN_ENABLED: false, RATE_LIMIT_COOLDOWN_MS: 30000 }, - NOW - ); - - expect(recoveryTime).toBeNull(); - }); - - test('returns null for non-429 errors', () => { - const recoveryTime = getRateLimitCooldownRecoveryTime( - { response: { status: 400 } }, - { RATE_LIMIT_COOLDOWN_ENABLED: true, RATE_LIMIT_COOLDOWN_MS: 30000 }, - NOW - ); - - expect(recoveryTime).toBeNull(); - }); - - test('uses default cooldown when retry-after is absent', () => { - const recoveryTime = getRateLimitCooldownRecoveryTime( - { response: { status: 429 } }, - { - RATE_LIMIT_COOLDOWN_ENABLED: true, - RATE_LIMIT_COOLDOWN_MS: 30000, - RATE_LIMIT_COOLDOWN_JITTER_MS: 0 - }, - NOW - ); - - expect(recoveryTime.toISOString()).toBe('2026-04-22T00:00:30.000Z'); - }); - - test('uses Retry-After seconds when present', () => { - const recoveryTime = getRateLimitCooldownRecoveryTime( - { response: { status: 429, headers: { 'retry-after': '10' } } }, - { - RATE_LIMIT_COOLDOWN_ENABLED: true, - RATE_LIMIT_COOLDOWN_MS: 30000, - RATE_LIMIT_COOLDOWN_JITTER_MS: 0, - RATE_LIMIT_COOLDOWN_MAX_MS: 300000 - }, - NOW - ); - - expect(recoveryTime.toISOString()).toBe('2026-04-22T00:00:10.000Z'); - }); - - test('treats internal error.retryAfter values as milliseconds', () => { - const recoveryTime = getRateLimitCooldownRecoveryTime( - { response: { status: 429 }, retryAfter: 60000 }, - { - RATE_LIMIT_COOLDOWN_ENABLED: true, - RATE_LIMIT_COOLDOWN_MS: 30000, - RATE_LIMIT_COOLDOWN_JITTER_MS: 0, - RATE_LIMIT_COOLDOWN_MAX_MS: 300000 - }, - NOW - ); - - expect(recoveryTime.toISOString()).toBe('2026-04-22T00:01:00.000Z'); - }); - - test('caps excessive Retry-After values', () => { - const recoveryTime = getRateLimitCooldownRecoveryTime( - { response: { status: 429, headers: { 'retry-after': '9999' } } }, - { - RATE_LIMIT_COOLDOWN_ENABLED: true, - RATE_LIMIT_COOLDOWN_MS: 30000, - RATE_LIMIT_COOLDOWN_JITTER_MS: 0, - RATE_LIMIT_COOLDOWN_MAX_MS: 60000 - }, - NOW - ); - - expect(recoveryTime.toISOString()).toBe('2026-04-22T00:01:00.000Z'); - }); - - test('reads provider retryDelay bodies', () => { - const recoveryTime = getRateLimitCooldownRecoveryTime( - { - response: { - status: 429, - data: { - error: { - details: [ - { - '@type': 'type.googleapis.com/google.rpc.RetryInfo', - retryDelay: '3s' - } - ] - } - } - } - }, - { - RATE_LIMIT_COOLDOWN_ENABLED: true, - RATE_LIMIT_COOLDOWN_MS: 30000, - RATE_LIMIT_COOLDOWN_JITTER_MS: 0 - }, - NOW - ); - - expect(recoveryTime.toISOString()).toBe('2026-04-22T00:00:03.000Z'); - }); -}); diff --git a/tests/security-fixes.test.js b/tests/security-fixes.test.js deleted file mode 100644 index fe854f3e0..000000000 --- a/tests/security-fixes.test.js +++ /dev/null @@ -1,325 +0,0 @@ -/** - * 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 deleted file mode 100644 index d64871061..000000000 --- a/tests/security-fixes.unit.test.js +++ /dev/null @@ -1,205 +0,0 @@ -/** - * 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 = [ - '', - '', - '

--

0

-

默认供应商

+

预加载供应商

@@ -80,7 +80,7 @@

接入

- +
diff --git a/static/components/section-config.html b/static/components/section-config.html index d33be5e23..e6a6ad21f 100644 --- a/static/components/section-config.html +++ b/static/components/section-config.html @@ -6,7 +6,7 @@

配置管理

对外接入交付

-

先在这里把对外 Key 和默认供应商配好,再一键跳到“快速接入”页面复制给客户端。

+

先在这里把对外 Key 和预加载供应商配好,再一键跳到“快速接入”页面复制给客户端。

- 默认供应商 + 预加载供应商 --
diff --git a/static/components/section-providers.html b/static/components/section-providers.html index 363250664..94808ec5c 100644 --- a/static/components/section-providers.html +++ b/static/components/section-providers.html @@ -12,7 +12,7 @@

提供商池管理

节点交付准备

-

先确认至少有一组可用节点,再去“快速接入”复制最终端点;如果还没设默认供应商,也可以顺手回配置管理补齐。

+

先确认至少有一组可用节点,再去“快速接入”复制最终端点;如果还没设预加载供应商,也可以顺手回配置管理补齐。

- 默认供应商 + 预加载供应商 --
From 4bfb3e3fb57d2f1fa11b820abf7869b0c574ba6d Mon Sep 17 00:00:00 2001 From: agang0311 Date: Fri, 24 Apr 2026 22:13:50 +0800 Subject: [PATCH 052/135] fix: add provider pool file fallback for usage API and support remainingPercent display --- src/ui-modules/usage-api.js | 41 ++++++++++++++++++++++++++++++------- static/app/usage-manager.js | 33 ++++++++++++++++++++--------- 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/src/ui-modules/usage-api.js b/src/ui-modules/usage-api.js index e9c6329d3..8e3380139 100644 --- a/src/ui-modules/usage-api.js +++ b/src/ui-modules/usage-api.js @@ -5,6 +5,7 @@ import { formatKiroUsage, formatGeminiUsage, formatAntigravityUsage, formatCodex import { readUsageCache, writeUsageCache, readProviderUsageCache, updateProviderUsageCache } from './usage-cache.js'; import { PROVIDER_MAPPINGS } from '../utils/provider-utils.js'; import path from 'path'; +import { existsSync, readFileSync } from 'fs'; const supportedProviders = ['claude-kiro-oauth', 'gemini-cli-oauth', 'gemini-antigravity', 'openai-codex-oauth', 'grok-custom']; @@ -49,6 +50,37 @@ async function getAllProvidersUsage(currentConfig, providerPoolManager) { return results; } +/** + * 加载提供商池数据(从内存或文件) + * @param {string} providerType - 提供商类型 + * @param {Object} currentConfig - 当前配置 + * @param {Object} providerPoolManager - 提供商池管理器 + * @returns {Array} 提供商列表 + */ +function loadProviderList(providerType, currentConfig, providerPoolManager) { + // 优先从内存获取 + if (providerPoolManager && providerPoolManager.providerPools && providerPoolManager.providerPools[providerType]) { + return providerPoolManager.providerPools[providerType]; + } + if (currentConfig.providerPools && currentConfig.providerPools[providerType]) { + return currentConfig.providerPools[providerType]; + } + // Fallback: 从文件读取 + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + try { + if (existsSync(filePath)) { + const poolsData = JSON.parse(readFileSync(filePath, 'utf-8')); + if (poolsData[providerType] && poolsData[providerType].length > 0) { + logger.info(`[Usage API] Loaded ${poolsData[providerType].length} providers for ${providerType} from file fallback`); + return poolsData[providerType]; + } + } + } catch (fileError) { + logger.warn(`[Usage API] Failed to load provider pools from file: ${fileError.message}`); + } + return []; +} + /** * 获取指定提供商类型的用量信息 * @param {string} providerType - 提供商类型 @@ -65,13 +97,8 @@ async function getProviderTypeUsage(providerType, currentConfig, providerPoolMan errorCount: 0 }; - // 获取提供商池中的所有实例 - let providers = []; - if (providerPoolManager && providerPoolManager.providerPools && providerPoolManager.providerPools[providerType]) { - providers = providerPoolManager.providerPools[providerType]; - } else if (currentConfig.providerPools && currentConfig.providerPools[providerType]) { - providers = currentConfig.providerPools[providerType]; - } + // 获取提供商池中的所有实例(使用统一的加载函数) + const providers = loadProviderList(providerType, currentConfig, providerPoolManager); result.totalCount = providers.length; diff --git a/static/app/usage-manager.js b/static/app/usage-manager.js index 74fa68758..ddb096177 100644 --- a/static/app/usage-manager.js +++ b/static/app/usage-manager.js @@ -7,10 +7,10 @@ import { t, getCurrentLanguage } from './i18n.js'; /** * 不支持显示用量数据的提供商列表 * 这些提供商只显示模型名称和重置时间,不显示用量数字和进度条 + * + * 注:gemini-antigravity 已支持 remainingPercent,移除限制 */ -const PROVIDERS_WITHOUT_USAGE_DISPLAY = [ - 'gemini-antigravity' -]; +const PROVIDERS_WITHOUT_USAGE_DISPLAY = []; // 提供商配置缓存 let currentProviderConfigs = null; @@ -702,21 +702,34 @@ function createUsageBreakdownHTML(breakdown, providerType) { // 检查是否应该显示用量信息 const showUsage = shouldShowUsage(providerType); - const usagePercent = breakdown.usageLimit > 0 - ? Math.min(100, (breakdown.currentUsage / breakdown.usageLimit) * 100) - : 0; + // 优先使用 remainingPercent(antigravity 等提供商提供) + const hasRemainingPct = breakdown.remainingPercent !== undefined && breakdown.remainingPercent !== null; + const usagePercent = hasRemainingPct + ? (100 - breakdown.remainingPercent) + : (breakdown.usageLimit > 0 + ? Math.min(100, (breakdown.currentUsage / breakdown.usageLimit) * 100) + : 0); + const remainingPercent = hasRemainingPct ? breakdown.remainingPercent : (100 - usagePercent); - const progressClass = usagePercent >= 90 ? 'danger' : (usagePercent >= 70 ? 'warning' : 'normal'); + const progressClass = remainingPercent <= 10 ? 'danger' : (remainingPercent <= 30 ? 'warning' : 'normal'); + + // 显示格式:如果有 remainingPercent,显示剩余百分比;否则显示已用/总量 + let usageDisplay = ''; + if (hasRemainingPct) { + usageDisplay = `${remainingPercent}%`; + } else if (showUsage && breakdown.usageLimit > 0) { + usageDisplay = `${formatNumber(breakdown.currentUsage)} / ${formatNumber(breakdown.usageLimit)}`; + } let html = `
- ${breakdown.displayName || breakdown.resourceType} - ${showUsage ? `${formatNumber(breakdown.currentUsage)} / ${formatNumber(breakdown.usageLimit)}` : ''} + ${breakdown.displayName || breakdown.modelName || breakdown.resourceType} + ${usageDisplay}
${showUsage ? `
-
+
` : ''} `; From de1480a4a1fd31c894d87bec38767b43d93ea4de Mon Sep 17 00:00:00 2001 From: zouyifan Date: Sat, 25 Apr 2026 11:30:56 -0500 Subject: [PATCH 053/135] feat: add the codex proxy iamge-2 --- src/converters/strategies/CodexConverter.js | 65 +++++++++++++++++++++ src/providers/openai/codex-core.js | 35 +++++++---- src/providers/provider-models.js | 1 + 3 files changed, 91 insertions(+), 10 deletions(-) diff --git a/src/converters/strategies/CodexConverter.js b/src/converters/strategies/CodexConverter.js index 1da0b36bf..6d0e4859e 100644 --- a/src/converters/strategies/CodexConverter.js +++ b/src/converters/strategies/CodexConverter.js @@ -611,6 +611,12 @@ export class CodexConverter extends BaseConverter { } }); break; + case 'image_generation_call': + if (item.result) { + const imgMd = `![generated image](data:image/${item.output_format || 'png'};base64,${item.result})`; + contentText = contentText ? `${contentText}\n${imgMd}` : imgMd; + } + break; } } @@ -749,6 +755,9 @@ export class CodexConverter extends BaseConverter { args: typeof item.arguments === 'string' ? JSON.parse(item.arguments) : item.arguments } }); + } else if (item.type === 'image_generation_call' && item.result) { + const imgMd = `![generated image](data:image/${item.output_format || 'png'};base64,${item.result})`; + parts.push({ text: imgMd }); } } } @@ -812,6 +821,9 @@ export class CodexConverter extends BaseConverter { name: this.getOriginalToolName(item.name), input: typeof item.arguments === 'string' ? JSON.parse(item.arguments) : item.arguments }); + } else if (item.type === 'image_generation_call' && item.result) { + const imgMd = `![generated image](data:image/${item.output_format || 'png'};base64,${item.result})`; + content.push({ type: "text", text: imgMd }); } } } @@ -964,6 +976,22 @@ export class CodexConverter extends BaseConverter { return template; } + if (type === 'response.output_item.done' && chunk.item?.type === 'image_generation_call') { + if (chunk.item.result) { + const template = buildTemplate(); + const imgMd = `![generated image](data:image/${chunk.item.output_format || 'png'};base64,${chunk.item.result})`; + template.choices[0].delta = { + role: 'assistant', + content: state.isFirstChunk ? imgMd : imgMd, + reasoning_content: null, + tool_calls: null + }; + state.isFirstChunk = false; + return template; + } + return null; + } + if (type === 'response.completed') { const template = buildTemplate(); const finishReason = state.functionCallIndex > 0 ? 'tool_calls' : 'stop'; @@ -1139,6 +1167,15 @@ export class CodexConverter extends BaseConverter { return template; } + if (type === 'response.output_item.done' && chunk.item?.type === 'image_generation_call') { + if (chunk.item.result) { + const imgMd = `![generated image](data:image/${chunk.item.output_format || 'png'};base64,${chunk.item.result})`; + template.candidates[0].content.parts.push({ text: imgMd }); + return template; + } + return null; + } + if (type === 'response.completed') { template.candidates[0].finishReason = "STOP"; template.usageMetadata = { @@ -1302,6 +1339,34 @@ export class CodexConverter extends BaseConverter { return events; } + if (type === 'response.output_item.done' && chunk.item?.type === 'image_generation_call') { + if (chunk.item.result) { + const events = []; + if (state.blockStarted && state.currentBlockType !== 'text') { + events.push({ type: "content_block_stop", index: state.blockIndex }); + state.blockIndex++; + state.blockStarted = false; + } + if (!state.blockStarted) { + events.push({ + type: "content_block_start", + index: state.blockIndex, + content_block: { type: "text", text: "" } + }); + state.blockStarted = true; + state.currentBlockType = 'text'; + } + const imgMd = `![generated image](data:image/${chunk.item.output_format || 'png'};base64,${chunk.item.result})`; + events.push({ + type: "content_block_delta", + index: state.blockIndex, + delta: { type: "text_delta", text: imgMd } + }); + return events; + } + return null; + } + if (type === 'response.completed') { const events = []; // Close any open content block before ending the message diff --git a/src/providers/openai/codex-core.js b/src/providers/openai/codex-core.js index 00c5cc2dd..f4cf1045d 100644 --- a/src/providers/openai/codex-core.js +++ b/src/providers/openai/codex-core.js @@ -358,21 +358,36 @@ export class CodexApiService { const defaultServiceTier = isFastModel ? 'priority' : 'default'; const defaultReasoningEffort = isFastModel ? 'xhigh' : 'medium'; + // 图像生成模型:gpt-image-2 通过 image_generation 工具 + gpt-5.4 实现 + const IMAGE_MODELS = ['gpt-image-2']; + const isImageModel = IMAGE_MODELS.includes(upstreamModel); + const effectiveUpstreamModel = isImageModel ? 'gpt-5.4' : upstreamModel; + const cleanedBody = { ...requestBody }; delete cleanedBody.metadata; // 【关键修复】确保传给上游的模型名称不带 -fast 后缀 // 即使 originalRequestBody 中已经带了 model,这里也必须覆盖 - cleanedBody.model = upstreamModel; - - // 为所有 Codex 模型增加默认工具 - if (!cleanedBody.tools) { - cleanedBody.tools = []; - } - if (Array.isArray(cleanedBody.tools)) { - const hasWebSearch = cleanedBody.tools.some(t => t.type === 'web_search'); - if (!hasWebSearch) { - cleanedBody.tools.push({ type: 'web_search' }); + cleanedBody.model = effectiveUpstreamModel; + + if (isImageModel) { + // 图像模型:强制使用 image_generation 工具,不加 web_search + cleanedBody.tools = [{ type: 'image_generation' }]; + // 服务器要求 instructions 非空 + if (!cleanedBody.instructions?.trim()) { + cleanedBody.instructions = 'You are a helpful assistant.'; + } + logger.info(`[Codex] Image model detected: ${upstreamModel} -> ${effectiveUpstreamModel} with image_generation tool`); + } else { + // 为普通 Codex 模型增加默认工具 + if (!cleanedBody.tools) { + cleanedBody.tools = []; + } + if (Array.isArray(cleanedBody.tools)) { + const hasWebSearch = cleanedBody.tools.some(t => t.type === 'web_search'); + if (!hasWebSearch) { + cleanedBody.tools.push({ type: 'web_search' }); + } } } diff --git a/src/providers/provider-models.js b/src/providers/provider-models.js index 39ca885fc..1bc900a3c 100644 --- a/src/providers/provider-models.js +++ b/src/providers/provider-models.js @@ -119,6 +119,7 @@ export const PROVIDER_MODELS = { 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.5', + 'gpt-image-2', ], 'forward-api': [], 'grok-custom': [ From 2b0bdef002cd37c51fa10faa341601a514635a6b Mon Sep 17 00:00:00 2001 From: hex2077 Date: Sun, 26 Apr 2026 21:04:36 +0800 Subject: [PATCH 054/135] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E9=87=8F=E8=BF=9B=E5=BA=A6=E6=9D=A1=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=92=8C=E6=8F=90=E4=BE=9B=E5=95=86=E6=98=BE=E7=A4=BA=E5=90=8D?= =?UTF-8?q?=E7=A7=B0=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复使用量进度条在无剩余百分比时显示错误的问题,将进度填充宽度统一使用新变量计算。 优化提供商显示名称的提取逻辑,优先使用凭据文件名(不含扩展名)作为显示名称,提升可读性。 更新版本号至 2.15.6。 --- VERSION | 2 +- src/ui-modules/usage-api.js | 19 ++++++++++--------- static/app/usage-manager.js | 5 +++-- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/VERSION b/VERSION index 93f03caff..74e26a411 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.15.5.1 +2.15.6 diff --git a/src/ui-modules/usage-api.js b/src/ui-modules/usage-api.js index 8e3380139..5dbe14883 100644 --- a/src/ui-modules/usage-api.js +++ b/src/ui-modules/usage-api.js @@ -228,24 +228,25 @@ async function getAdapterUsage(adapter, providerType) { * @returns {string} 显示名称 */ function getProviderDisplayName(provider, providerType) { - // 优先使用自定义名称 + // 1. 优先使用自定义名称 if (provider.customName) { return provider.customName; } - if (provider.uuid) { - return provider.uuid; - } - - // 尝试从凭据文件路径提取名称 + // 2. 尝试从凭据文件路径提取名称(自动从文件名识别账号) const mapping = PROVIDER_MAPPINGS.find(m => m.providerType === providerType); const credPathKey = mapping ? mapping.credPathKey : null; if (credPathKey && provider[credPathKey]) { const filePath = provider[credPathKey]; - const fileName = path.basename(filePath); - const dirName = path.basename(path.dirname(filePath)); - return `${dirName}/${fileName}`; + // 提取文件名(不含扩展名)作为显示名称,例如 account-a.json -> account-a + const fileName = path.basename(filePath, path.extname(filePath)); + return fileName; + } + + // 3. 兜底显示 UUID + if (provider.uuid) { + return provider.uuid; } return 'Unnamed'; diff --git a/static/app/usage-manager.js b/static/app/usage-manager.js index ddb096177..2b8a3f18c 100644 --- a/static/app/usage-manager.js +++ b/static/app/usage-manager.js @@ -710,6 +710,7 @@ function createUsageBreakdownHTML(breakdown, providerType) { ? Math.min(100, (breakdown.currentUsage / breakdown.usageLimit) * 100) : 0); const remainingPercent = hasRemainingPct ? breakdown.remainingPercent : (100 - usagePercent); + const progressFillPercent = hasRemainingPct ? remainingPercent : usagePercent; const progressClass = remainingPercent <= 10 ? 'danger' : (remainingPercent <= 30 ? 'warning' : 'normal'); @@ -729,7 +730,7 @@ function createUsageBreakdownHTML(breakdown, providerType) {
${showUsage ? `
-
+
` : ''} `; @@ -1013,4 +1014,4 @@ function formatDate(dateStr) { } catch (e) { return dateStr; } -} \ No newline at end of file +} From 70c275b9a7043500066b54ce574acbacc6f73a85 Mon Sep 17 00:00:00 2001 From: zouyifan Date: Sat, 25 Apr 2026 11:30:56 -0500 Subject: [PATCH 055/135] feat: add the codex proxy iamge-2 --- src/converters/strategies/CodexConverter.js | 65 +++++++++++++++++++++ src/providers/openai/codex-core.js | 35 +++++++---- src/providers/provider-models.js | 1 + 3 files changed, 91 insertions(+), 10 deletions(-) diff --git a/src/converters/strategies/CodexConverter.js b/src/converters/strategies/CodexConverter.js index 1da0b36bf..6d0e4859e 100644 --- a/src/converters/strategies/CodexConverter.js +++ b/src/converters/strategies/CodexConverter.js @@ -611,6 +611,12 @@ export class CodexConverter extends BaseConverter { } }); break; + case 'image_generation_call': + if (item.result) { + const imgMd = `![generated image](data:image/${item.output_format || 'png'};base64,${item.result})`; + contentText = contentText ? `${contentText}\n${imgMd}` : imgMd; + } + break; } } @@ -749,6 +755,9 @@ export class CodexConverter extends BaseConverter { args: typeof item.arguments === 'string' ? JSON.parse(item.arguments) : item.arguments } }); + } else if (item.type === 'image_generation_call' && item.result) { + const imgMd = `![generated image](data:image/${item.output_format || 'png'};base64,${item.result})`; + parts.push({ text: imgMd }); } } } @@ -812,6 +821,9 @@ export class CodexConverter extends BaseConverter { name: this.getOriginalToolName(item.name), input: typeof item.arguments === 'string' ? JSON.parse(item.arguments) : item.arguments }); + } else if (item.type === 'image_generation_call' && item.result) { + const imgMd = `![generated image](data:image/${item.output_format || 'png'};base64,${item.result})`; + content.push({ type: "text", text: imgMd }); } } } @@ -964,6 +976,22 @@ export class CodexConverter extends BaseConverter { return template; } + if (type === 'response.output_item.done' && chunk.item?.type === 'image_generation_call') { + if (chunk.item.result) { + const template = buildTemplate(); + const imgMd = `![generated image](data:image/${chunk.item.output_format || 'png'};base64,${chunk.item.result})`; + template.choices[0].delta = { + role: 'assistant', + content: state.isFirstChunk ? imgMd : imgMd, + reasoning_content: null, + tool_calls: null + }; + state.isFirstChunk = false; + return template; + } + return null; + } + if (type === 'response.completed') { const template = buildTemplate(); const finishReason = state.functionCallIndex > 0 ? 'tool_calls' : 'stop'; @@ -1139,6 +1167,15 @@ export class CodexConverter extends BaseConverter { return template; } + if (type === 'response.output_item.done' && chunk.item?.type === 'image_generation_call') { + if (chunk.item.result) { + const imgMd = `![generated image](data:image/${chunk.item.output_format || 'png'};base64,${chunk.item.result})`; + template.candidates[0].content.parts.push({ text: imgMd }); + return template; + } + return null; + } + if (type === 'response.completed') { template.candidates[0].finishReason = "STOP"; template.usageMetadata = { @@ -1302,6 +1339,34 @@ export class CodexConverter extends BaseConverter { return events; } + if (type === 'response.output_item.done' && chunk.item?.type === 'image_generation_call') { + if (chunk.item.result) { + const events = []; + if (state.blockStarted && state.currentBlockType !== 'text') { + events.push({ type: "content_block_stop", index: state.blockIndex }); + state.blockIndex++; + state.blockStarted = false; + } + if (!state.blockStarted) { + events.push({ + type: "content_block_start", + index: state.blockIndex, + content_block: { type: "text", text: "" } + }); + state.blockStarted = true; + state.currentBlockType = 'text'; + } + const imgMd = `![generated image](data:image/${chunk.item.output_format || 'png'};base64,${chunk.item.result})`; + events.push({ + type: "content_block_delta", + index: state.blockIndex, + delta: { type: "text_delta", text: imgMd } + }); + return events; + } + return null; + } + if (type === 'response.completed') { const events = []; // Close any open content block before ending the message diff --git a/src/providers/openai/codex-core.js b/src/providers/openai/codex-core.js index 00c5cc2dd..f4cf1045d 100644 --- a/src/providers/openai/codex-core.js +++ b/src/providers/openai/codex-core.js @@ -358,21 +358,36 @@ export class CodexApiService { const defaultServiceTier = isFastModel ? 'priority' : 'default'; const defaultReasoningEffort = isFastModel ? 'xhigh' : 'medium'; + // 图像生成模型:gpt-image-2 通过 image_generation 工具 + gpt-5.4 实现 + const IMAGE_MODELS = ['gpt-image-2']; + const isImageModel = IMAGE_MODELS.includes(upstreamModel); + const effectiveUpstreamModel = isImageModel ? 'gpt-5.4' : upstreamModel; + const cleanedBody = { ...requestBody }; delete cleanedBody.metadata; // 【关键修复】确保传给上游的模型名称不带 -fast 后缀 // 即使 originalRequestBody 中已经带了 model,这里也必须覆盖 - cleanedBody.model = upstreamModel; - - // 为所有 Codex 模型增加默认工具 - if (!cleanedBody.tools) { - cleanedBody.tools = []; - } - if (Array.isArray(cleanedBody.tools)) { - const hasWebSearch = cleanedBody.tools.some(t => t.type === 'web_search'); - if (!hasWebSearch) { - cleanedBody.tools.push({ type: 'web_search' }); + cleanedBody.model = effectiveUpstreamModel; + + if (isImageModel) { + // 图像模型:强制使用 image_generation 工具,不加 web_search + cleanedBody.tools = [{ type: 'image_generation' }]; + // 服务器要求 instructions 非空 + if (!cleanedBody.instructions?.trim()) { + cleanedBody.instructions = 'You are a helpful assistant.'; + } + logger.info(`[Codex] Image model detected: ${upstreamModel} -> ${effectiveUpstreamModel} with image_generation tool`); + } else { + // 为普通 Codex 模型增加默认工具 + if (!cleanedBody.tools) { + cleanedBody.tools = []; + } + if (Array.isArray(cleanedBody.tools)) { + const hasWebSearch = cleanedBody.tools.some(t => t.type === 'web_search'); + if (!hasWebSearch) { + cleanedBody.tools.push({ type: 'web_search' }); + } } } diff --git a/src/providers/provider-models.js b/src/providers/provider-models.js index 39ca885fc..1bc900a3c 100644 --- a/src/providers/provider-models.js +++ b/src/providers/provider-models.js @@ -119,6 +119,7 @@ export const PROVIDER_MODELS = { 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.5', + 'gpt-image-2', ], 'forward-api': [], 'grok-custom': [ From 275e743549e52e5e4dcf40378fd5dba3cf543d15 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Sun, 26 Apr 2026 23:42:17 +0800 Subject: [PATCH 056/135] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DTLS=20Sidecar?= =?UTF-8?q?=E5=90=AF=E7=94=A8=E6=97=B6HTTP=E4=BB=A3=E7=90=86=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=86=B2=E7=AA=81=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在多个provider核心文件中添加TLS Sidecar检查,避免同时配置agent和代理 - 添加ensureValidStatusCode函数确保HTTP状态码有效性 - 修复Codex转换器中图片生成工具的处理逻辑 - 更新usage-manager以排除gemini-antigravity的用量显示 --- VERSION | 2 +- src/converters/strategies/CodexConverter.js | 261 +++++++++++++- src/providers/claude/claude-core.js | 20 +- src/providers/claude/claude-kiro.js | 20 +- src/providers/forward/forward-core.js | 16 +- src/providers/gemini/antigravity-core.js | 9 +- src/providers/gemini/gemini-core.js | 9 +- src/providers/grok/grok-core.js | 15 +- src/providers/openai/codex-core.js | 327 ++++++++++++++---- src/providers/openai/openai-core.js | 18 +- src/providers/openai/openai-responses-core.js | 20 +- src/utils/common.js | 33 +- static/app/usage-manager.js | 4 +- 13 files changed, 618 insertions(+), 136 deletions(-) diff --git a/VERSION b/VERSION index 74e26a411..a1a6927ec 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.15.6 +2.15.7 diff --git a/src/converters/strategies/CodexConverter.js b/src/converters/strategies/CodexConverter.js index 1da0b36bf..6717b13ea 100644 --- a/src/converters/strategies/CodexConverter.js +++ b/src/converters/strategies/CodexConverter.js @@ -25,6 +25,76 @@ export class CodexConverter extends BaseConverter { this.streamParams = new Map(); // 用于存储流式状态,key 为响应 ID 或临时标识 } + /** + * 提取 Codex 图片生成输出,供不同客户端协议复用。 + */ + codexImageGenerationToImageData(item) { + if (!item || item.type !== 'image_generation_call') { + return null; + } + + const rawResult = typeof item.result === 'string' ? item.result.trim() : ''; + if (!rawResult) { + return null; + } + + const format = typeof item.output_format === 'string' && item.output_format.trim() + ? item.output_format.trim().toLowerCase() + : 'png'; + const mimeType = format.includes('/') ? format : `image/${format}`; + const dataUrlMatch = rawResult.match(/^data:([^;,]+);base64,(.*)$/s); + const data = dataUrlMatch ? dataUrlMatch[2] : rawResult; + const resolvedMimeType = dataUrlMatch ? dataUrlMatch[1] : mimeType; + + return { + mimeType: resolvedMimeType, + data, + dataUrl: rawResult.startsWith('data:') + ? rawResult + : `data:${resolvedMimeType};base64,${data}` + }; + } + + codexImageGenerationToMarkdown(item, index = 0) { + const imageData = this.codexImageGenerationToImageData(item); + if (!imageData) { + return ''; + } + + const alt = item.revised_prompt ? `generated image ${index + 1}` : 'generated image'; + + return `![${alt}](${imageData.dataUrl})`; + } + + codexImageGenerationToGeminiPart(item) { + const imageData = this.codexImageGenerationToImageData(item); + if (!imageData) { + return null; + } + + return { + inlineData: { + mimeType: imageData.mimeType, + data: imageData.data + } + }; + } + + /** + * Codex 内置图片生成工具不是 function tool,必须保留原始 type 和参数结构。 + */ + normalizeCodexBuiltinTool(tool) { + if (!tool || tool.type !== 'image_generation') { + return null; + } + + return { + ...tool, + type: 'image_generation', + output_format: 'png' + }; + } + /** * 转换请求 */ @@ -62,7 +132,7 @@ export class CodexConverter extends BaseConverter { case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: return this.toOpenAIResponsesStreamChunk(chunk, model); case MODEL_PROTOCOL_PREFIX.GEMINI: - return this.toGeminiStreamChunk(chunk, model); + return this.toGeminiStreamChunk(chunk, model, requestId); case MODEL_PROTOCOL_PREFIX.CLAUDE: return this.toClaudeStreamChunk(chunk, model, requestId); case MODEL_PROTOCOL_PREFIX.CODEX: @@ -402,6 +472,9 @@ export class CodexConverter extends BaseConverter { const names = []; for (const t of tools) { + if (this.normalizeCodexBuiltinTool(t)) { + continue; + } if (t.type === 'function' && t.function?.name) { names.push(t.function.name); } else if (t.name) { @@ -452,6 +525,11 @@ export class CodexConverter extends BaseConverter { */ convertTools(tools) { return tools.map(tool => { + const builtinTool = this.normalizeCodexBuiltinTool(tool); + if (builtinTool) { + return builtinTool; + } + // 处理 Claude 的 web_search if (tool.type === "web_search_20250305") { return { type: "web_search" }; @@ -586,6 +664,7 @@ export class CodexConverter extends BaseConverter { let contentText = ''; let reasoningText = ''; const toolCalls = []; + const imageMarkdownParts = []; for (const item of output) { switch (item.type) { @@ -611,9 +690,18 @@ export class CodexConverter extends BaseConverter { } }); break; + case 'image_generation_call': { + const imageMarkdown = this.codexImageGenerationToMarkdown(item, imageMarkdownParts.length); + if (imageMarkdown) imageMarkdownParts.push(imageMarkdown); + break; + } } } + if (imageMarkdownParts.length > 0) { + contentText = [contentText, ...imageMarkdownParts].filter(Boolean).join('\n\n'); + } + if (contentText) openaiResponse.choices[0].message.content = contentText; if (reasoningText) openaiResponse.choices[0].message.reasoning_content = reasoningText; if (toolCalls.length > 0) openaiResponse.choices[0].message.tool_calls = toolCalls; @@ -687,6 +775,19 @@ export class CodexConverter extends BaseConverter { arguments: typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments), status: "completed" }); + } else if (item.type === 'image_generation_call') { + output.push({ + id: item.id || `ig_${uuidv4().replace(/-/g, '')}`, + type: "image_generation_call", + status: item.status || "completed", + action: item.action || "generate", + background: item.background, + output_format: item.output_format || "png", + quality: item.quality, + result: item.result, + revised_prompt: item.revised_prompt, + size: item.size + }); } } } @@ -749,6 +850,11 @@ export class CodexConverter extends BaseConverter { args: typeof item.arguments === 'string' ? JSON.parse(item.arguments) : item.arguments } }); + } else if (item.type === 'image_generation_call') { + const imagePart = this.codexImageGenerationToGeminiPart(item); + if (imagePart) { + parts.push(imagePart); + } } } } @@ -812,6 +918,11 @@ export class CodexConverter extends BaseConverter { name: this.getOriginalToolName(item.name), input: typeof item.arguments === 'string' ? JSON.parse(item.arguments) : item.arguments }); + } else if (item.type === 'image_generation_call') { + const imageMarkdown = this.codexImageGenerationToMarkdown(item, content.length); + if (imageMarkdown) { + content.push({ type: "text", text: imageMarkdown }); + } } } } @@ -844,7 +955,8 @@ export class CodexConverter extends BaseConverter { createdAt: Math.floor(Date.now() / 1000), responseID: chunk.response?.id || `chatcmpl-${Date.now()}`, functionCallIndex: 0, // 初始值为 0,第一个 function_call 的 index 为 0 - isFirstChunk: true // 标记是否是第一个内容 chunk + isFirstChunk: true, // 标记是否是第一个内容 chunk + emittedImageGenerationItems: new Set() }); } const state = this.streamParams.get(stateKey); @@ -876,6 +988,7 @@ export class CodexConverter extends BaseConverter { // 重置 functionCallIndex,确保每个新请求从 0 开始 state.functionCallIndex = 0; state.isFirstChunk = true; + state.emittedImageGenerationItems = new Set(); // response.created 不发送 chunk,等待第一个内容 chunk return null; } @@ -964,7 +1077,47 @@ export class CodexConverter extends BaseConverter { return template; } + if (type === 'response.output_item.done' && chunk.item?.type === 'image_generation_call') { + const template = buildTemplate(); + template.choices[0].delta = { + role: 'assistant', + content: this.codexImageGenerationToMarkdown(chunk.item), + reasoning_content: null, + tool_calls: null + }; + state.isFirstChunk = false; + if (chunk.item.id) { + state.emittedImageGenerationItems.add(chunk.item.id); + } + return template; + } + if (type === 'response.completed') { + const results = []; + const completedOutput = Array.isArray(chunk.response?.output) ? chunk.response.output : []; + for (const item of completedOutput) { + if (item.type !== 'image_generation_call' || (item.id && state.emittedImageGenerationItems.has(item.id))) { + continue; + } + + const imageMarkdown = this.codexImageGenerationToMarkdown(item); + if (!imageMarkdown) { + continue; + } + + const imageTemplate = buildTemplate(); + imageTemplate.choices[0].delta = { + role: 'assistant', + content: imageMarkdown, + reasoning_content: null, + tool_calls: null + }; + results.push(imageTemplate); + if (item.id) { + state.emittedImageGenerationItems.add(item.id); + } + } + const template = buildTemplate(); const finishReason = state.functionCallIndex > 0 ? 'tool_calls' : 'stop'; template.choices[0].delta = { @@ -987,7 +1140,8 @@ export class CodexConverter extends BaseConverter { } // 完成后清理状态 this.streamParams.delete(stateKey); - return template; + results.push(template); + return results.length === 1 ? results[0] : results; } return null; @@ -1095,15 +1249,16 @@ export class CodexConverter extends BaseConverter { /** * Codex → Gemini 流式响应转换 */ - toGeminiStreamChunk(chunk, model) { + toGeminiStreamChunk(chunk, model, requestId) { const type = chunk.type; - const resId = chunk.response?.id || 'default'; + const resId = requestId || 'gemini_stream_current'; if (!this.streamParams.has(resId)) { this.streamParams.set(resId, { model: model, createdAt: Math.floor(Date.now() / 1000), - responseID: resId + responseID: chunk.response?.id || resId, + emittedImageGenerationItems: new Set() }); } const state = this.streamParams.get(resId); @@ -1139,7 +1294,30 @@ export class CodexConverter extends BaseConverter { return template; } + if (type === 'response.output_item.done' && chunk.item?.type === 'image_generation_call') { + const imagePart = this.codexImageGenerationToGeminiPart(chunk.item); + if (!imagePart) { + return null; + } + template.candidates[0].content.parts.push(imagePart); + if (chunk.item.id) { + state.emittedImageGenerationItems.add(chunk.item.id); + } + return template; + } + if (type === 'response.completed') { + const completedOutput = Array.isArray(chunk.response?.output) ? chunk.response.output : []; + for (const item of completedOutput) { + if (item.type !== 'image_generation_call' || (item.id && state.emittedImageGenerationItems.has(item.id))) { + continue; + } + + const imagePart = this.codexImageGenerationToGeminiPart(item); + if (imagePart) { + template.candidates[0].content.parts.push(imagePart); + } + } template.candidates[0].finishReason = "STOP"; template.usageMetadata = { promptTokenCount: chunk.response.usage?.input_tokens || 0, @@ -1174,6 +1352,7 @@ export class CodexConverter extends BaseConverter { blockIndex: 0, blockStarted: false, currentBlockType: null, + emittedImageGenerationItems: new Set() }); const state = this.streamParams.get(stateKey); return { @@ -1199,6 +1378,7 @@ export class CodexConverter extends BaseConverter { blockIndex: 0, blockStarted: false, currentBlockType: null, + emittedImageGenerationItems: new Set() }); } const state = this.streamParams.get(stateKey); @@ -1302,11 +1482,80 @@ export class CodexConverter extends BaseConverter { return events; } + if (type === 'response.output_item.done' && chunk.item?.type === 'image_generation_call') { + const imageMarkdown = this.codexImageGenerationToMarkdown(chunk.item); + if (!imageMarkdown) { + return null; + } + + const events = []; + if (state.blockStarted) { + events.push({ type: "content_block_stop", index: state.blockIndex }); + state.blockIndex++; + state.blockStarted = false; + state.currentBlockType = null; + } + events.push( + { + type: "content_block_start", + index: state.blockIndex, + content_block: { type: "text", text: "" } + }, + { + type: "content_block_delta", + index: state.blockIndex, + delta: { type: "text_delta", text: imageMarkdown } + }, + { + type: "content_block_stop", + index: state.blockIndex + } + ); + state.blockIndex++; + if (chunk.item.id) { + state.emittedImageGenerationItems.add(chunk.item.id); + } + return events; + } + if (type === 'response.completed') { const events = []; // Close any open content block before ending the message if (state.blockStarted) { events.push({ type: "content_block_stop", index: state.blockIndex }); + state.blockIndex++; + state.blockStarted = false; + state.currentBlockType = null; + } + + const completedOutput = Array.isArray(chunk.response?.output) ? chunk.response.output : []; + for (const item of completedOutput) { + if (item.type !== 'image_generation_call' || (item.id && state.emittedImageGenerationItems.has(item.id))) { + continue; + } + + const imageMarkdown = this.codexImageGenerationToMarkdown(item); + if (!imageMarkdown) { + continue; + } + + events.push( + { + type: "content_block_start", + index: state.blockIndex, + content_block: { type: "text", text: "" } + }, + { + type: "content_block_delta", + index: state.blockIndex, + delta: { type: "text_delta", text: imageMarkdown } + }, + { + type: "content_block_stop", + index: state.blockIndex + } + ); + state.blockIndex++; } events.push( { diff --git a/src/providers/claude/claude-core.js b/src/providers/claude/claude-core.js index 89f5e229b..36bb87e79 100644 --- a/src/providers/claude/claude-core.js +++ b/src/providers/claude/claude-core.js @@ -2,7 +2,7 @@ import axios from 'axios'; import logger from '../../utils/logger.js'; import * as http from 'http'; import * as https from 'https'; -import { configureAxiosProxy, configureTLSSidecar } from '../../utils/proxy-utils.js'; +import { configureAxiosProxy, configureTLSSidecar, isTLSSidecarEnabledForProvider } from '../../utils/proxy-utils.js'; import { isRetryableNetworkError, MODEL_PROVIDER } from '../../utils/common.js'; /** @@ -46,25 +46,25 @@ export class ClaudeApiService { timeout: 120000, }); + const isTLSSidecarEnabled = isTLSSidecarEnabledForProvider(this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.CLAUDE_CUSTOM); + const axiosConfig = { baseURL: this.baseUrl, - httpAgent, - httpsAgent, headers: { 'x-api-key': this.apiKey, 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01', // Claude API 版本 }, }; - - // 禁用系统代理以避免HTTPS代理错误 - if (!this.useSystemProxy) { - axiosConfig.proxy = false; + + // 如果启用了 TLS Sidecar,就不配置 httpAgent 和 httpsAgent,避免配置冲突 + if (!isTLSSidecarEnabled) { + axiosConfig.httpAgent = httpAgent; + axiosConfig.httpsAgent = httpsAgent; + // 配置自定义代理 + configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.CLAUDE_CUSTOM); } - // 配置自定义代理 - configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.CLAUDE_CUSTOM); - return axios.create(axiosConfig); } diff --git a/src/providers/claude/claude-kiro.js b/src/providers/claude/claude-kiro.js index fed82efe5..2da5d06d7 100644 --- a/src/providers/claude/claude-kiro.js +++ b/src/providers/claude/claude-kiro.js @@ -15,7 +15,7 @@ import { processContent as processContentUtil, getContentText as getContentTextUtil } from '../../utils/token-utils.js'; -import { configureAxiosProxy, configureTLSSidecar } from '../../utils/proxy-utils.js'; +import { configureAxiosProxy, configureTLSSidecar, isTLSSidecarEnabledForProvider } from '../../utils/proxy-utils.js'; import { isRetryableNetworkError, MODEL_PROVIDER, formatExpiryLog } from '../../utils/common.js'; import { getProviderPoolManager } from '../../services/service-manager.js'; @@ -502,10 +502,10 @@ export class KiroApiService { timeout: KIRO_CONSTANTS.AXIOS_TIMEOUT, }); + const isTLSSidecarEnabled = isTLSSidecarEnabledForProvider(this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.KIRO_API); + const axiosConfig = { timeout: KIRO_CONSTANTS.AXIOS_TIMEOUT, - httpAgent, - httpsAgent, headers: { 'Content-Type': KIRO_CONSTANTS.CONTENT_TYPE_JSON, 'Accept': KIRO_CONSTANTS.ACCEPT_JSON, @@ -518,15 +518,15 @@ export class KiroApiService { 'Connection': 'close' }, }; - - // 根据 useSystemProxy 配置代理设置 - if (!this.useSystemProxy) { - axiosConfig.proxy = false; + + // 如果启用了 TLS Sidecar,就不配置 httpAgent 和 httpsAgent,避免配置冲突 + if (!isTLSSidecarEnabled) { + axiosConfig.httpAgent = httpAgent; + axiosConfig.httpsAgent = httpsAgent; + // 配置自定义代理 + configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.KIRO_API); } - // 配置自定义代理 - configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.KIRO_API); - this.axiosInstance = axios.create(axiosConfig); axiosConfig.headers = new Headers(); diff --git a/src/providers/forward/forward-core.js b/src/providers/forward/forward-core.js index 260dcd20a..6a5ada66e 100644 --- a/src/providers/forward/forward-core.js +++ b/src/providers/forward/forward-core.js @@ -2,7 +2,7 @@ import axios from 'axios'; import logger from '../../utils/logger.js'; import * as http from 'http'; import * as https from 'https'; -import { configureAxiosProxy, configureTLSSidecar } from '../../utils/proxy-utils.js'; +import { configureAxiosProxy, configureTLSSidecar, isTLSSidecarEnabledForProvider } from '../../utils/proxy-utils.js'; import { isRetryableNetworkError, MODEL_PROVIDER } from '../../utils/common.js'; /** @@ -45,19 +45,21 @@ export class ForwardApiService { }; headers[this.headerName] = `${this.headerValuePrefix}${this.apiKey}`; + const isTLSSidecarEnabled = isTLSSidecarEnabledForProvider(config, config.MODEL_PROVIDER || MODEL_PROVIDER.FORWARD_API); + const axiosConfig = { baseURL: this.baseUrl, - httpAgent, - httpsAgent, headers, }; - if (!this.useSystemProxy) { - axiosConfig.proxy = false; + // 如果启用了 TLS Sidecar,就不配置 httpAgent 和 httpsAgent,避免配置冲突 + if (!isTLSSidecarEnabled) { + axiosConfig.httpAgent = httpAgent; + axiosConfig.httpsAgent = httpsAgent; + // 配置自定义代理 + configureAxiosProxy(axiosConfig, config, config.MODEL_PROVIDER || MODEL_PROVIDER.FORWARD_API); } - configureAxiosProxy(axiosConfig, config, config.MODEL_PROVIDER || MODEL_PROVIDER.FORWARD_API); - this.axiosInstance = axios.create(axiosConfig); } diff --git a/src/providers/gemini/antigravity-core.js b/src/providers/gemini/antigravity-core.js index 4874aaf4c..cbcc69b9c 100644 --- a/src/providers/gemini/antigravity-core.js +++ b/src/providers/gemini/antigravity-core.js @@ -14,7 +14,7 @@ import { configureTLSSidecar } from '../../utils/proxy-utils.js'; import { formatExpiryTime, isRetryableNetworkError, formatExpiryLog } from '../../utils/common.js'; import { getProviderModels } from '../provider-models.js'; import { handleGeminiAntigravityOAuth } from '../../auth/oauth-handlers.js'; -import { getProxyConfigForProvider, getGoogleAuthProxyConfig } from '../../utils/proxy-utils.js'; +import { getProxyConfigForProvider, getGoogleAuthProxyConfig, isTLSSidecarEnabledForProvider } from '../../utils/proxy-utils.js'; import { cleanJsonSchemaProperties } from '../../converters/utils.js'; import { getProviderPoolManager } from '../../services/service-manager.js'; import { MODEL_PROVIDER } from '../../utils/common.js'; @@ -716,13 +716,18 @@ export class AntigravityApiService { // 检查是否需要使用代理 const proxyConfig = getGoogleAuthProxyConfig(config, config.MODEL_PROVIDER || MODEL_PROVIDER.ANTIGRAVITY); + // 检查是否启用了 TLS Sidecar + const isTLSSidecarEnabled = isTLSSidecarEnabledForProvider(config, config.MODEL_PROVIDER || MODEL_PROVIDER.ANTIGRAVITY); + // 配置 OAuth2Client 使用自定义的 HTTP agent const oauth2Options = { clientId: OAUTH_CLIENT_ID, clientSecret: OAUTH_CLIENT_SECRET, }; - if (proxyConfig) { + if (isTLSSidecarEnabled) { + logger.info('[Antigravity] TLS Sidecar enabled, skipping proxy/agent configuration for OAuth2Client'); + } else if (proxyConfig) { oauth2Options.transporterOptions = proxyConfig; logger.info('[Antigravity] Using proxy for OAuth2Client'); } else { diff --git a/src/providers/gemini/gemini-core.js b/src/providers/gemini/gemini-core.js index 4c9382915..a583db87d 100644 --- a/src/providers/gemini/gemini-core.js +++ b/src/providers/gemini/gemini-core.js @@ -11,7 +11,7 @@ import { configureTLSSidecar } from '../../utils/proxy-utils.js'; import { API_ACTIONS, formatExpiryTime, isRetryableNetworkError, formatExpiryLog } from '../../utils/common.js'; import { getProviderModels } from '../provider-models.js'; import { handleGeminiCliOAuth } from '../../auth/oauth-handlers.js'; -import { getProxyConfigForProvider, getGoogleAuthProxyConfig } from '../../utils/proxy-utils.js'; +import { getProxyConfigForProvider, getGoogleAuthProxyConfig, isTLSSidecarEnabledForProvider } from '../../utils/proxy-utils.js'; import { getProviderPoolManager } from '../../services/service-manager.js'; import { MODEL_PROVIDER } from '../../utils/common.js'; @@ -307,13 +307,18 @@ export class GeminiApiService { // 检查是否需要使用代理 const proxyConfig = getGoogleAuthProxyConfig(config, config.MODEL_PROVIDER || MODEL_PROVIDER.GEMINI_CLI); + // 检查是否启用了 TLS Sidecar + const isTLSSidecarEnabled = isTLSSidecarEnabledForProvider(config, config.MODEL_PROVIDER || MODEL_PROVIDER.GEMINI_CLI); + // 配置 OAuth2Client 使用自定义的 HTTP agent const oauth2Options = { clientId: OAUTH_CLIENT_ID, clientSecret: OAUTH_CLIENT_SECRET, }; - if (proxyConfig) { + if (isTLSSidecarEnabled) { + logger.info('[Gemini] TLS Sidecar enabled, skipping proxy/agent configuration for OAuth2Client'); + } else if (proxyConfig) { oauth2Options.transporterOptions = proxyConfig; logger.info('[Gemini] Using proxy for OAuth2Client'); } else { diff --git a/src/providers/grok/grok-core.js b/src/providers/grok/grok-core.js index b6be4f3a3..df5a3662e 100644 --- a/src/providers/grok/grok-core.js +++ b/src/providers/grok/grok-core.js @@ -5,7 +5,7 @@ import * as https from 'https'; import { v4 as uuidv4 } from 'uuid'; import { MODEL_PROTOCOL_PREFIX, isRetryableNetworkError } from '../../utils/common.js'; import { getProviderModels } from '../provider-models.js'; -import { configureAxiosProxy, configureTLSSidecar } from '../../utils/proxy-utils.js'; +import { configureAxiosProxy, configureTLSSidecar, isTLSSidecarEnabledForProvider } from '../../utils/proxy-utils.js'; import { MODEL_PROVIDER } from '../../utils/common.js'; import { ConverterFactory } from '../../converters/ConverterFactory.js'; import * as readline from 'readline'; @@ -230,19 +230,26 @@ export class GrokApiService { ...otherOptions } = options; + // 检查是否启用了 TLS Sidecar + const isTLSSidecarEnabled = isTLSSidecarEnabledForProvider(this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM); + const axiosConfig = { method, url, headers, data, - httpAgent, - httpsAgent, timeout, ...otherOptions }; if (responseType) axiosConfig.responseType = responseType; - configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM); + // 如果未启用 TLS Sidecar,则配置 httpAgent 和 httpsAgent + if (!isTLSSidecarEnabled) { + axiosConfig.httpAgent = httpAgent; + axiosConfig.httpsAgent = httpsAgent; + configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM); + } + this._applySidecar(axiosConfig); return await axios(axiosConfig); diff --git a/src/providers/openai/codex-core.js b/src/providers/openai/codex-core.js index 00c5cc2dd..01ad3ead8 100644 --- a/src/providers/openai/codex-core.js +++ b/src/providers/openai/codex-core.js @@ -6,7 +6,7 @@ import path from 'path'; import os from 'os'; import { refreshCodexTokensWithRetry } from '../../auth/oauth-handlers.js'; import { getProviderPoolManager } from '../../services/service-manager.js'; -import { configureTLSSidecar } from '../../utils/proxy-utils.js'; +import { configureTLSSidecar, isTLSSidecarEnabledForProvider } from '../../utils/proxy-utils.js'; import { MODEL_PROVIDER, formatExpiryLog } from '../../utils/common.js'; import { getProxyConfigForProvider } from '../../utils/proxy-utils.js'; import { getProviderModels } from '../provider-models.js'; @@ -37,6 +37,8 @@ export class CodexApiService { // 会话缓存管理 this.conversationCache = new Map(); // key: model-userId, value: {id, expire} this.startCacheCleanup(); + + this.imageGenTool = { type: 'image_generation', output_format: 'png' }; } _applySidecar(axiosConfig) { @@ -187,18 +189,23 @@ export class CodexApiService { const body = await this.prepareRequestBody(selectedModel, requestBody, true); const headers = this.buildHeaders(body.prompt_cache_key, true); + // 检查是否启用了 TLS Sidecar + const isTLSSidecarEnabled = isTLSSidecarEnabledForProvider(this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.CODEX_API); + try { const config = { headers, responseType: 'text', // 确保以文本形式接收 SSE 流 - timeout: 120000 // 2 分钟超时 + timeout: 300000 // 5 分钟超时,适应慢速模型 }; - // 配置代理 - const proxyConfig = getProxyConfigForProvider(this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.CODEX_API); - if (proxyConfig) { - config.httpAgent = proxyConfig.httpAgent; - config.httpsAgent = proxyConfig.httpsAgent; + // 配置代理(如果未启用 TLS Sidecar) + if (!isTLSSidecarEnabled) { + const proxyConfig = getProxyConfigForProvider(this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.CODEX_API); + if (proxyConfig) { + config.httpAgent = proxyConfig.httpAgent; + config.httpsAgent = proxyConfig.httpsAgent; + } } const axiosRequestConfig = { @@ -264,18 +271,23 @@ export class CodexApiService { const body = await this.prepareRequestBody(selectedModel, requestBody, true); const headers = this.buildHeaders(body.prompt_cache_key, true); + // 检查是否启用了 TLS Sidecar + const isTLSSidecarEnabled = isTLSSidecarEnabledForProvider(this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.CODEX_API); + try { const config = { headers, responseType: 'stream', - timeout: 120000 + timeout: 300000 // 5 分钟超时 }; - // 配置代理 - const proxyConfig = getProxyConfigForProvider(this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.CODEX_API); - if (proxyConfig) { - config.httpAgent = proxyConfig.httpAgent; - config.httpsAgent = proxyConfig.httpsAgent; + // 配置代理(如果未启用 TLS Sidecar) + if (!isTLSSidecarEnabled) { + const proxyConfig = getProxyConfigForProvider(this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.CODEX_API); + if (proxyConfig) { + config.httpAgent = proxyConfig.httpAgent; + config.httpsAgent = proxyConfig.httpsAgent; + } } const axiosRequestConfig = { @@ -341,6 +353,29 @@ export class CodexApiService { return headers; } + /** + * 确保包含图像生成工具 + */ + ensureImageGenerationTool(body, model) { + if (model.endsWith('spark')) { + return body; + } + + if (!body.tools) { + body.tools = [this.imageGenTool]; + return body; + } + + if (Array.isArray(body.tools)) { + const hasImageGen = body.tools.some(t => t.type === 'image_generation'); + if (!hasImageGen) { + body.tools.push(this.imageGenTool); + } + } + + return body; + } + /** * 准备请求体 */ @@ -376,6 +411,9 @@ export class CodexApiService { } } + // 确保包含图像生成工具 + this.ensureImageGenerationTool(cleanedBody, upstreamModel); + if (isFastModel) { logger.info(`[Codex] Detected -fast model: ${normalizedModel} -> ${upstreamModel}, service_tier: ${cleanedBody.service_tier || defaultServiceTier}`); } @@ -561,11 +599,58 @@ export class CodexApiService { } } + /** + * 收集 Codex 输出项 + */ + collectCodexOutputItemDone(eventData, outputItemsByIndex, outputItemsFallback) { + if (!eventData.item) { + return; + } + if (eventData.output_index !== undefined) { + outputItemsByIndex.set(eventData.output_index, eventData.item); + } else { + outputItemsFallback.push(eventData.item); + } + } + + /** + * 修正 Codex 完成输出 + */ + patchCodexCompletedOutput(eventData, outputItemsByIndex, outputItemsFallback) { + const response = eventData.response || {}; + const output = response.output; + + const shouldPatch = (!output || !Array.isArray(output) || output.length === 0) && + (outputItemsByIndex.size > 0 || outputItemsFallback.length > 0); + + if (!shouldPatch) { + return eventData; + } + + const items = []; + // 按索引排序 + const sortedIndexes = Array.from(outputItemsByIndex.keys()).sort((a, b) => a - b); + for (const idx of sortedIndexes) { + items.push(outputItemsByIndex.get(idx)); + } + // 添加 fallback 项 + items.push(...outputItemsFallback); + + if (!eventData.response) { + eventData.response = {}; + } + eventData.response.output = items; + + return eventData; + } + /** * 解析 SSE 流 */ async *parseSSEStream(stream) { let buffer = ''; + const outputItemsByIndex = new Map(); + const outputItemsFallback = []; for await (const chunk of stream) { buffer += chunk.toString(); @@ -573,31 +658,81 @@ export class CodexApiService { buffer = lines.pop(); // 保留不完整的行 for (const line of lines) { - if (line.startsWith('data: ')) { - const data = line.slice(6).trim(); - if (data && data !== '[DONE]') { - try { - const parsed = JSON.parse(data); - yield parsed; - } catch (e) { - logger.error('[Codex] Failed to parse SSE data:', e.message); + const trimmedLine = line.trim(); + if (!trimmedLine) continue; + + let dataStr = trimmedLine; + if (trimmedLine.startsWith('data: ')) { + dataStr = trimmedLine.slice(6).trim(); + } + + if (dataStr && dataStr !== '[DONE]') { + try { + let parsed = JSON.parse(dataStr); + + if (parsed.type === 'error') { + logger.error('[Codex] API returned error in stream:', parsed.error || parsed); + const errorMsg = (parsed.error && parsed.error.message) || JSON.stringify(parsed.error || parsed); + const error = new Error(`Codex API error: ${errorMsg}`); + if (parsed.error?.code === 'insufficient_quota' || parsed.error?.type === 'insufficient_quota') { + error.shouldSwitchCredential = true; + error.skipErrorCount = true; + } + throw error; + } + + if (parsed.type === 'response.output_item.done') { + this.collectCodexOutputItemDone(parsed, outputItemsByIndex, outputItemsFallback); + } else if (parsed.type === 'response.completed') { + parsed = this.patchCodexCompletedOutput(parsed, outputItemsByIndex, outputItemsFallback); + } + + yield parsed; + } catch (e) { + if (e.message.startsWith('Codex API error')) { + throw e; } + logger.error('[Codex] Failed to parse SSE data:', e.message); } } } } // 处理剩余的 buffer - if (buffer.trim()) { - if (buffer.startsWith('data: ')) { - const data = buffer.slice(6).trim(); - if (data && data !== '[DONE]') { - try { - const parsed = JSON.parse(data); - yield parsed; - } catch (e) { - logger.error('[Codex] Failed to parse final SSE data:', e.message); + const finalTrimmed = buffer.trim(); + if (finalTrimmed) { + let dataStr = finalTrimmed; + if (finalTrimmed.startsWith('data: ')) { + dataStr = finalTrimmed.slice(6).trim(); + } + + if (dataStr && dataStr !== '[DONE]') { + try { + let parsed = JSON.parse(dataStr); + + if (parsed.type === 'error') { + logger.error('[Codex] API returned error in final stream buffer:', parsed.error || parsed); + const errorMsg = (parsed.error && parsed.error.message) || JSON.stringify(parsed.error || parsed); + const error = new Error(`Codex API error: ${errorMsg}`); + if (parsed.error?.code === 'insufficient_quota' || parsed.error?.type === 'insufficient_quota') { + error.shouldSwitchCredential = true; + error.skipErrorCount = true; + } + throw error; + } + + if (parsed.type === 'response.output_item.done') { + this.collectCodexOutputItemDone(parsed, outputItemsByIndex, outputItemsFallback); + } else if (parsed.type === 'response.completed') { + parsed = this.patchCodexCompletedOutput(parsed, outputItemsByIndex, outputItemsFallback); } + + yield parsed; + } catch (e) { + if (e.message.startsWith('Codex API error')) { + throw e; + } + logger.error('[Codex] Failed to parse final SSE data:', e.message); } } } @@ -614,47 +749,98 @@ export class CodexApiService { const lines = responseText.split('\n'); const outputItems = new Map(); // id -> output item const textDeltas = new Map(); // item_id -> accumulated text + + const outputItemsByIndex = new Map(); + const outputItemsFallback = []; + let completedEvent = null; for (const line of lines) { - if (line.startsWith('data: ')) { - const jsonData = line.slice(6).trim(); - if (!jsonData || jsonData === '[DONE]') { - continue; - } - try { - const parsed = JSON.parse(jsonData); - switch (parsed.type) { - case 'response.output_item.added': - if (parsed.item) { - outputItems.set(parsed.item.id, parsed.item); - } - break; - case 'response.output_text.delta': - if (parsed.item_id && parsed.delta) { - const existing = textDeltas.get(parsed.item_id) || ''; - textDeltas.set(parsed.item_id, existing + parsed.delta); - } - break; - case 'response.output_text.done': - if (parsed.item_id && parsed.text) { - textDeltas.set(parsed.item_id, parsed.text); - } - break; - case 'response.completed': - completedEvent = parsed; - break; - } - } catch (e) { - // 继续解析下一行 - logger.debug('[Codex] Failed to parse SSE line:', e.message); + const trimmedLine = line.trim(); + if (!trimmedLine) continue; + + let jsonData = trimmedLine; + if (trimmedLine.startsWith('data: ')) { + jsonData = trimmedLine.slice(6).trim(); + } + + if (!jsonData || jsonData === '[DONE]') { + continue; + } + + try { + let parsed = JSON.parse(jsonData); + switch (parsed.type) { + case 'error': + logger.error('[Codex] API returned error:', parsed.error || parsed); + const errorMsg = (parsed.error && parsed.error.message) || JSON.stringify(parsed.error || parsed); + const error = new Error(`Codex API error: ${errorMsg}`); + if (parsed.error?.code === 'insufficient_quota' || parsed.error?.type === 'insufficient_quota') { + error.shouldSwitchCredential = true; + error.skipErrorCount = true; + } + throw error; + case 'response.output_item.added': + if (parsed.item) { + outputItems.set(parsed.item.id, parsed.item); + } + break; + case 'response.output_item.done': + this.collectCodexOutputItemDone(parsed, outputItemsByIndex, outputItemsFallback); + break; + case 'response.output_text.delta': + if (parsed.item_id && parsed.delta) { + const existing = textDeltas.get(parsed.item_id) || ''; + textDeltas.set(parsed.item_id, existing + parsed.delta); + } + break; + case 'response.output_text.done': + if (parsed.item_id && parsed.text) { + textDeltas.set(parsed.item_id, parsed.text); + } + break; + case 'response.completed': + completedEvent = this.patchCodexCompletedOutput(parsed, outputItemsByIndex, outputItemsFallback); + break; } + } catch (e) { + // 继续解析下一行 + logger.debug('[Codex] Failed to parse SSE line:', e.message); } } if (!completedEvent) { - logger.error('[Codex] No completed response found in Codex response'); - throw new Error('stream error: stream disconnected before completion: stream closed before response.completed'); + // 如果我们已经收集到了一些输出项或文本,尝试合成一个完成事件 + if (outputItems.size > 0 || textDeltas.size > 0 || outputItemsByIndex.size > 0 || outputItemsFallback.length > 0) { + logger.warn('[Codex] No completed response found, but some output items were received. Synthesizing response.'); + + // 构造一个模拟的 completed 事件 + completedEvent = { + type: 'response.completed', + response: { + id: 'synth_' + Date.now(), + status: 'completed', + object: 'response', + model: 'unknown', + output: [] + } + }; + + // 使用 patchCodexCompletedOutput 填充输出 + completedEvent = this.patchCodexCompletedOutput(completedEvent, outputItemsByIndex, outputItemsFallback); + + // 如果 patch 后还是没输出,尝试直接从 outputItems 填充 + if (completedEvent.response.output.length === 0 && outputItems.size > 0) { + completedEvent.response.output = Array.from(outputItems.values()); + } + } else { + logger.error('[Codex] No completed response found in Codex response'); + // 记录前 1000 个字符用于调试 + const debugInfo = responseText.length > 1000 ? responseText.slice(0, 1000) + '...' : responseText; + logger.debug('[Codex] Raw response data:', debugInfo); + + throw new Error('stream error: stream disconnected before completion: stream closed before response.completed'); + } } // 用累积的 delta 文本填充 output items 中缺失的内容 @@ -743,6 +929,9 @@ export class CodexApiService { await this.initialize(); } + // 检查是否启用了 TLS Sidecar + const isTLSSidecarEnabled = isTLSSidecarEnabledForProvider(this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.CODEX_API); + try { const url = 'https://chatgpt.com/backend-api/wham/usage'; const headers = { @@ -759,11 +948,13 @@ export class CodexApiService { timeout: 30000 // 30 秒超时 }; - // 配置代理 - const proxyConfig = getProxyConfigForProvider(this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.CODEX_API); - if (proxyConfig) { - config.httpAgent = proxyConfig.httpAgent; - config.httpsAgent = proxyConfig.httpsAgent; + // 配置代理(如果未启用 TLS Sidecar) + if (!isTLSSidecarEnabled) { + const proxyConfig = getProxyConfigForProvider(this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.CODEX_API); + if (proxyConfig) { + config.httpAgent = proxyConfig.httpAgent; + config.httpsAgent = proxyConfig.httpsAgent; + } } const axiosRequestConfig = { diff --git a/src/providers/openai/openai-core.js b/src/providers/openai/openai-core.js index 0485fd639..50bbca321 100644 --- a/src/providers/openai/openai-core.js +++ b/src/providers/openai/openai-core.js @@ -2,7 +2,7 @@ import axios from 'axios'; import logger from '../../utils/logger.js'; import * as http from 'http'; import * as https from 'https'; -import { configureAxiosProxy, configureTLSSidecar } from '../../utils/proxy-utils.js'; +import { configureAxiosProxy, configureTLSSidecar, isTLSSidecarEnabledForProvider } from '../../utils/proxy-utils.js'; import { isRetryableNetworkError, MODEL_PROVIDER } from '../../utils/common.js'; // Assumed OpenAI API specification service for interacting with third-party models @@ -31,24 +31,24 @@ export class OpenAIApiService { timeout: 120000, }); + const isTLSSidecarEnabled = isTLSSidecarEnabledForProvider(config, config.MODEL_PROVIDER || MODEL_PROVIDER.OPENAI_CUSTOM); + const axiosConfig = { baseURL: this.baseUrl, - httpAgent, - httpsAgent, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` }, }; - // 禁用系统代理以避免HTTPS代理错误 - if (!this.useSystemProxy) { - axiosConfig.proxy = false; + // 如果启用了 TLS Sidecar,就不配置 httpAgent 和 httpsAgent,避免配置冲突 + if (!isTLSSidecarEnabled) { + axiosConfig.httpAgent = httpAgent; + axiosConfig.httpsAgent = httpsAgent; + // 配置自定义代理 + configureAxiosProxy(axiosConfig, config, config.MODEL_PROVIDER || MODEL_PROVIDER.OPENAI_CUSTOM); } - // 配置自定义代理 - configureAxiosProxy(axiosConfig, config, config.MODEL_PROVIDER || MODEL_PROVIDER.OPENAI_CUSTOM); - this.axiosInstance = axios.create(axiosConfig); } diff --git a/src/providers/openai/openai-responses-core.js b/src/providers/openai/openai-responses-core.js index 853a3fdc5..f9e60d5bb 100644 --- a/src/providers/openai/openai-responses-core.js +++ b/src/providers/openai/openai-responses-core.js @@ -2,7 +2,7 @@ import axios from 'axios'; import logger from '../../utils/logger.js'; import * as http from 'http'; import * as https from 'https'; -import { configureAxiosProxy, configureTLSSidecar } from '../../utils/proxy-utils.js'; +import { configureAxiosProxy, configureTLSSidecar, isTLSSidecarEnabledForProvider } from '../../utils/proxy-utils.js'; import { MODEL_PROVIDER } from '../../utils/common.js'; // OpenAI Responses API specification service for interacting with third-party models @@ -31,25 +31,27 @@ export class OpenAIResponsesApiService { timeout: 120000, }); + // 检查是否启用了 TLS Sidecar + const isTLSSidecarEnabled = isTLSSidecarEnabledForProvider(config, config.MODEL_PROVIDER || MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES); + const axiosConfig = { baseURL: this.baseUrl, - httpAgent, - httpsAgent, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` } }; - // 禁用系统代理以避免HTTPS代理错误 - if (!this.useSystemProxy) { - axiosConfig.proxy = false; + // 如果启用了 TLS Sidecar,就不配置 httpAgent 和 httpsAgent,避免配置冲突 + if (!isTLSSidecarEnabled) { + axiosConfig.httpAgent = httpAgent; + axiosConfig.httpsAgent = httpsAgent; + // 配置自定义代理 (使用 openai-custom 的代理配置) + configureAxiosProxy(axiosConfig, config, config.MODEL_PROVIDER || MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES); } - - // 配置自定义代理 (使用 openai-custom 的代理配置) - configureAxiosProxy(axiosConfig, config, config.MODEL_PROVIDER || MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES); this.axiosInstance = axios.create(axiosConfig); + } _applySidecar(axiosConfig) { diff --git a/src/utils/common.js b/src/utils/common.js index 28535ba30..f4c3f639e 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -52,11 +52,25 @@ export function isRetryableNetworkError(error) { const errorCode = error.code || ''; const errorMessage = error.message || ''; - return RETRYABLE_NETWORK_ERRORS.some(errId => - errorCode === errId || errorMessage.includes(errId) + return RETRYABLE_NETWORK_ERRORS.some(err => + errorCode === err || errorMessage.includes(err) ); } +/** + * 确保状态码是有效的 HTTP 状态码 + * @param {any} code - 待检查的状态码 + * @returns {number} - 有效的 HTTP 状态码 (100-599),默认为 500 + */ +export function ensureValidStatusCode(code) { + const num = parseInt(code, 10); + if (!isNaN(num) && num >= 100 && num < 600) { + return num; + } + return 500; +} + + function getErrorStatusCode(error) { return error?.response?.status || error?.status || error?.statusCode || error?.code || null; } @@ -560,10 +574,11 @@ export function isAuthorized(req, requestUrl, REQUIRED_API_KEY) { * @param {boolean} isStream - Whether the response is a stream. */ export async function handleUnifiedResponse(res, responsePayload, isStream, statusCode = 200) { + const validatedStatusCode = ensureValidStatusCode(statusCode); if (isStream) { res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "Transfer-Encoding": "chunked" }); } else { - res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.writeHead(validatedStatusCode, { 'Content-Type': 'application/json' }); } if (isStream) { @@ -1091,7 +1106,8 @@ export async function handleUnaryRequest(res, service, model, requestBody, fromP // 使用新方法创建符合 fromProvider 格式的错误响应 const errorResponse = createErrorResponse(error, fromProvider); - const statusCode = error.status || error.code || (error.response && error.response.status) || 500; + const rawStatusCode = error.status || error.code || (error.response && error.response.status) || 500; + const statusCode = ensureValidStatusCode(rawStatusCode); await handleUnifiedResponse(res, JSON.stringify(errorResponse), false, statusCode); } finally { // 确保在请求结束或出错时释放插槽 @@ -1516,7 +1532,8 @@ function _applyCustomModelParameters(requestBody, customConfig, provider) { } export function handleError(res, error, provider = null, fromProvider = null, req = null) { - const statusCode = error.response?.status || error.statusCode || error.status || error.code || 500; + const rawStatusCode = error.response?.status || error.statusCode || error.status || error.code || 500; + const statusCode = ensureValidStatusCode(rawStatusCode); // 如果没有提供 fromProvider 但提供了 req,尝试从路径推断 if (!fromProvider && req && req.url) { @@ -1852,7 +1869,8 @@ export function formatToLocal(dateInput) { */ function createErrorResponse(error, fromProvider) { const protocolPrefix = getProtocolPrefix(fromProvider); - const statusCode = error.status || error.code || 500; + const rawStatusCode = error.status || error.code || 500; + const statusCode = ensureValidStatusCode(rawStatusCode); const errorMessage = error.message || "An error occurred during processing."; // 根据 HTTP 状态码映射错误类型 @@ -1936,7 +1954,8 @@ function createErrorResponse(error, fromProvider) { */ function createStreamErrorResponse(error, fromProvider) { const protocolPrefix = getProtocolPrefix(fromProvider); - const statusCode = error.status || error.code || 500; + const rawStatusCode = error.status || error.code || 500; + const statusCode = ensureValidStatusCode(rawStatusCode); const errorMessage = error.message || "An error occurred during streaming."; // 根据 HTTP 状态码映射错误类型 diff --git a/static/app/usage-manager.js b/static/app/usage-manager.js index 2b8a3f18c..a735418c3 100644 --- a/static/app/usage-manager.js +++ b/static/app/usage-manager.js @@ -10,7 +10,9 @@ import { t, getCurrentLanguage } from './i18n.js'; * * 注:gemini-antigravity 已支持 remainingPercent,移除限制 */ -const PROVIDERS_WITHOUT_USAGE_DISPLAY = []; +const PROVIDERS_WITHOUT_USAGE_DISPLAY = [ + 'gemini-antigravity' +]; // 提供商配置缓存 let currentProviderConfigs = null; From 2c2478044325bd411197c8b4b3f803610d643b80 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Mon, 27 Apr 2026 00:08:29 +0800 Subject: [PATCH 057/135] =?UTF-8?q?fix:=20=E5=B0=86=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E8=80=85=E8=A7=92=E8=89=B2=E6=B6=88=E6=81=AF=E8=A7=86=E4=B8=BA?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E6=B6=88=E6=81=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新多个文件中的系统消息检测逻辑,将 'developer' 角色与 'system' 角色同等对待,确保包含开发者角色的消息也能被正确提取和处理为系统提示词。 --- VERSION | 2 +- src/converters/strategies/CodexConverter.js | 2 +- src/converters/utils.js | 2 +- src/providers/forward/forward-strategy.js | 2 +- src/providers/openai/openai-strategy.js | 2 +- src/utils/common.js | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/VERSION b/VERSION index a1a6927ec..2badd6971 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.15.7 +2.15.7.1 diff --git a/src/converters/strategies/CodexConverter.js b/src/converters/strategies/CodexConverter.js index 6717b13ea..efba74dec 100644 --- a/src/converters/strategies/CodexConverter.js +++ b/src/converters/strategies/CodexConverter.js @@ -338,7 +338,7 @@ export class CodexConverter extends BaseConverter { // 首先检查显式的 instructions 字段 (OpenAI Responses) if (data.instructions) return data.instructions; - const systemMessages = (data.messages || []).filter(m => m.role === 'system'); + const systemMessages = (data.messages || []).filter(m => m.role === 'system' || m.role === 'developer'); if (systemMessages.length > 0) { return systemMessages.map(m => { if (typeof m.content === 'string') { diff --git a/src/converters/utils.js b/src/converters/utils.js index 96b2a7824..3d35f508e 100644 --- a/src/converters/utils.js +++ b/src/converters/utils.js @@ -155,7 +155,7 @@ export function extractAndProcessSystemMessages(messages, replacements = []) { const nonSystemMessages = []; for (const message of messages) { - if (message.role === 'system') { + if (message.role === 'system' || message.role === 'developer') { let content = extractTextFromMessageContent(message.content); // 应用系统提示词内容替换 diff --git a/src/providers/forward/forward-strategy.js b/src/providers/forward/forward-strategy.js index 10c2df4af..93b0fda4d 100644 --- a/src/providers/forward/forward-strategy.js +++ b/src/providers/forward/forward-strategy.js @@ -64,7 +64,7 @@ class ForwardStrategy extends ProviderStrategy { if (!requestBody.messages) { requestBody.messages = []; } - const systemMessageIndex = requestBody.messages.findIndex(m => m.role === 'system'); + const systemMessageIndex = requestBody.messages.findIndex(m => m.role === 'system' || m.role === 'developer'); if (systemMessageIndex !== -1) { requestBody.messages[systemMessageIndex].content = finalSystemText; } else { diff --git a/src/providers/openai/openai-strategy.js b/src/providers/openai/openai-strategy.js index 098ed68ee..fc54bb3fb 100644 --- a/src/providers/openai/openai-strategy.js +++ b/src/providers/openai/openai-strategy.js @@ -68,7 +68,7 @@ class OpenAIStrategy extends ProviderStrategy { if (!requestBody.messages) { requestBody.messages = []; } - const systemMessageIndex = requestBody.messages.findIndex(m => m.role === 'system'); + const systemMessageIndex = requestBody.messages.findIndex(m => m.role === 'system' || m.role === 'developer'); if (systemMessageIndex !== -1) { requestBody.messages[systemMessageIndex].content = finalSystemText; } else { diff --git a/src/utils/common.js b/src/utils/common.js index f4c3f639e..b6f7e8df1 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -1769,7 +1769,7 @@ export function extractSystemPromptFromRequestBody(requestBody, provider) { let incomingSystemText = ''; switch (provider) { case MODEL_PROTOCOL_PREFIX.OPENAI: - const openaiSystemMessage = requestBody.messages?.find(m => m.role === 'system'); + const openaiSystemMessage = requestBody.messages?.find(m => m.role === 'system' || m.role === 'developer'); if (openaiSystemMessage?.content) { incomingSystemText = openaiSystemMessage.content; } else if (requestBody.messages?.length > 0) { From b70eb6b5f37ab1fe97e6ded1a6c9ebbfad8406cf Mon Sep 17 00:00:00 2001 From: zouyifan Date: Sun, 26 Apr 2026 11:27:51 -0500 Subject: [PATCH 058/135] feat: enhance output item handling in CodexConverter and codex-core --- src/converters/strategies/CodexConverter.js | 2 +- src/providers/openai/codex-core.js | 20 +++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/converters/strategies/CodexConverter.js b/src/converters/strategies/CodexConverter.js index 6d0e4859e..f0532464c 100644 --- a/src/converters/strategies/CodexConverter.js +++ b/src/converters/strategies/CodexConverter.js @@ -598,7 +598,7 @@ export class CodexConverter extends BaseConverter { case 'message': if (Array.isArray(item.content)) { const contentItem = item.content.find(c => c.type === 'output_text'); - if (contentItem) contentText = contentItem.text; + if (contentItem?.text) contentText = contentItem.text; } break; case 'function_call': diff --git a/src/providers/openai/codex-core.js b/src/providers/openai/codex-core.js index f4cf1045d..92ea786cb 100644 --- a/src/providers/openai/codex-core.js +++ b/src/providers/openai/codex-core.js @@ -645,6 +645,12 @@ export class CodexApiService { outputItems.set(parsed.item.id, parsed.item); } break; + case 'response.output_item.done': + // 用完整的 done item 覆盖 added 时的占位,保留 result 等字段 + if (parsed.item) { + outputItems.set(parsed.item.id, parsed.item); + } + break; case 'response.output_text.delta': if (parsed.item_id && parsed.delta) { const existing = textDeltas.get(parsed.item_id) || ''; @@ -672,14 +678,14 @@ export class CodexApiService { throw new Error('stream error: stream disconnected before completion: stream closed before response.completed'); } - // 用累积的 delta 文本填充 output items 中缺失的内容 - if (completedEvent.response && textDeltas.size > 0) { + // 用累积的 delta 文本 & output_item.done 数据填充 output items 中缺失的内容 + if (completedEvent.response) { const output = completedEvent.response.output || []; + for (const item of output) { if (item.type === 'message' && item.role === 'assistant') { const accumulatedText = textDeltas.get(item.id); if (accumulatedText !== undefined) { - // content 为空或不含 output_text,直接注入 if (!item.content || item.content.length === 0) { item.content = [{ type: 'output_text', text: accumulatedText }]; } else { @@ -691,8 +697,16 @@ export class CodexApiService { }); } } + } else if (item.type === 'image_generation_call' && !item.result) { + // response.completed 里的 image_generation_call 可能缺 result,从 output_item.done 补充 + const doneItem = outputItems.get(item.id); + if (doneItem?.result) { + item.result = doneItem.result; + item.output_format = doneItem.output_format || item.output_format; + } } } + // 如果 output 完全为空,从累积事件重建 if (output.length === 0 && outputItems.size > 0) { for (const [id, item] of outputItems) { From 19b4e40918a4661a7d1fd41dc1c70b3bf9c03f97 Mon Sep 17 00:00:00 2001 From: zouyifan Date: Sun, 26 Apr 2026 11:41:41 -0500 Subject: [PATCH 059/135] feat: implement image generation endpoint and enhance error logging --- src/providers/openai/codex-core.js | 9 +++- src/services/api-manager.js | 86 +++++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/providers/openai/codex-core.js b/src/providers/openai/codex-core.js index 92ea786cb..ecfd2f982 100644 --- a/src/providers/openai/codex-core.js +++ b/src/providers/openai/codex-core.js @@ -225,7 +225,8 @@ export class CodexApiService { error.skipErrorCount = true; throw error; } else { - logger.error(`[Codex] Error calling non-stream API (Status: ${error.response?.status}, Code: ${error.code || 'N/A'}):`, error.message); + const errBody = error.response?.data ? String(error.response.data).slice(0, 500) : ''; + logger.error(`[Codex] Error calling non-stream API (Status: ${error.response?.status}, Code: ${error.code || 'N/A'}): ${error.message}${errBody ? ` | body: ${errBody}` : ''}`); throw error; } } @@ -416,10 +417,14 @@ export class CodexApiService { // 注意:requestBody 已经去除了 metadata const result = { ...cleanedBody, + store: cleanedBody.store ?? false, + parallel_tool_calls: cleanedBody.parallel_tool_calls ?? true, + include: cleanedBody.include || ['reasoning.encrypted_content'], service_tier: cleanedBody.service_tier || defaultServiceTier, reasoning: { ...cleanedBody.reasoning, - effort: isFastModel ? defaultReasoningEffort : (cleanedBody.reasoning?.effort === 'minimal' ? 'none' : (cleanedBody.reasoning?.effort || defaultReasoningEffort)) + effort: isFastModel ? defaultReasoningEffort : (cleanedBody.reasoning?.effort === 'minimal' ? 'none' : (cleanedBody.reasoning?.effort || defaultReasoningEffort)), + summary: cleanedBody.reasoning?.summary || 'auto', }, stream, prompt_cache_key: cache.id diff --git a/src/services/api-manager.js b/src/services/api-manager.js index 8cacc0991..f2a405270 100644 --- a/src/services/api-manager.js +++ b/src/services/api-manager.js @@ -2,9 +2,10 @@ import { handleModelListRequest, handleContentGenerationRequest, API_ACTIONS, - ENDPOINT_TYPE + ENDPOINT_TYPE, + getRequestBody } from '../utils/common.js'; -import { getProviderPoolManager } from './service-manager.js'; +import { getProviderPoolManager, getApiServiceWithFallback } from './service-manager.js'; import logger from '../utils/logger.js'; /** * Handle API authentication and routing @@ -33,6 +34,12 @@ export async function handleAPIRequests(method, path, req, res, currentConfig, a } } + // Route image generation requests + if (method === 'POST' && path === '/v1/images/generations') { + await handleImageGenerationRequest(req, res, currentConfig, providerPoolManager); + return true; + } + // Route content generation requests if (method === 'POST') { if (path === '/v1/chat/completions') { @@ -95,6 +102,81 @@ export function initializeAPIManagement(services) { }; } +/** + * Handle POST /v1/images/generations - OpenAI 标准生图接口 + */ +async function handleImageGenerationRequest(req, res, currentConfig, providerPoolManager) { + try { + const body = await getRequestBody(req); + const { model = 'gpt-image-2', prompt, n = 1, response_format = 'b64_json' } = body; + + if (!prompt) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'prompt is required', type: 'invalid_request_error', code: 'invalid_request_error' } })); + return; + } + + // 构造 Codex 格式请求,prepareRequestBody 会自动处理 gpt-image-2 → gpt-5.4 + image_generation tool + const codexRequestBody = { + model, + input: [{ + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: prompt }] + }] + }; + + // 从号池获取服务实例 + const shouldUsePool = providerPoolManager && currentConfig.providerPools; + const result = await getApiServiceWithFallback(currentConfig, model, { acquireSlot: !!shouldUsePool }); + const service = result.service; + + if (!service) { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'No service available for image generation', type: 'server_error' } })); + return; + } + + logger.info(`[Image Generation] model=${model}, n=${n}, response_format=${response_format}`); + + // 发起请求(generateContent 内部已经是 SSE 解析,返回 completedEvent) + const imageRequests = []; + for (let i = 0; i < Math.max(1, n); i++) { + imageRequests.push(service.generateContent(model, { ...codexRequestBody })); + } + const completedEvents = await Promise.all(imageRequests); + + // 从 response.output 中提取 image_generation_call 结果 + const data = []; + for (const completedEvent of completedEvents) { + const output = completedEvent?.response?.output || []; + for (const item of output) { + if (item.type === 'image_generation_call' && item.result) { + if (response_format === 'url') { + data.push({ url: `data:image/${item.output_format || 'png'};base64,${item.result}` }); + } else { + data.push({ b64_json: item.result }); + } + } + } + } + + if (data.length === 0) { + logger.error('[Image Generation] No image found in response output'); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Image generation failed: no image in response', type: 'server_error' } })); + return; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ created: Math.floor(Date.now() / 1000), data })); + } catch (error) { + logger.error('[Image Generation] Error:', error.message); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message, type: 'server_error' } })); + } +} + /** * Helper function to read request body * @param {http.IncomingMessage} req The HTTP request object. From 9b07b071b4b03af3a0aa4d3f751390d0bbba21f2 Mon Sep 17 00:00:00 2001 From: zouyifan Date: Sun, 26 Apr 2026 11:56:05 -0500 Subject: [PATCH 060/135] feat: enhance image generation handling with configurable size and revised prompt support --- src/providers/openai/codex-core.js | 11 +++++++++-- src/services/api-manager.js | 15 ++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/providers/openai/codex-core.js b/src/providers/openai/codex-core.js index ecfd2f982..db0f572ab 100644 --- a/src/providers/openai/codex-core.js +++ b/src/providers/openai/codex-core.js @@ -373,12 +373,17 @@ export class CodexApiService { if (isImageModel) { // 图像模型:强制使用 image_generation 工具,不加 web_search - cleanedBody.tools = [{ type: 'image_generation' }]; + const imageToolConfig = { type: 'image_generation' }; + if (cleanedBody._imageSize) { + imageToolConfig.size = cleanedBody._imageSize; + } + delete cleanedBody._imageSize; + cleanedBody.tools = [imageToolConfig]; // 服务器要求 instructions 非空 if (!cleanedBody.instructions?.trim()) { cleanedBody.instructions = 'You are a helpful assistant.'; } - logger.info(`[Codex] Image model detected: ${upstreamModel} -> ${effectiveUpstreamModel} with image_generation tool`); + logger.info(`[Codex] Image model detected: ${upstreamModel} -> ${effectiveUpstreamModel} with image_generation tool${imageToolConfig.size ? `, size=${imageToolConfig.size}` : ''}`); } else { // 为普通 Codex 模型增加默认工具 if (!cleanedBody.tools) { @@ -708,6 +713,8 @@ export class CodexApiService { if (doneItem?.result) { item.result = doneItem.result; item.output_format = doneItem.output_format || item.output_format; + if (doneItem.revised_prompt) item.revised_prompt = doneItem.revised_prompt; + if (doneItem.size) item.size = doneItem.size; } } } diff --git a/src/services/api-manager.js b/src/services/api-manager.js index f2a405270..88bf31d63 100644 --- a/src/services/api-manager.js +++ b/src/services/api-manager.js @@ -108,7 +108,7 @@ export function initializeAPIManagement(services) { async function handleImageGenerationRequest(req, res, currentConfig, providerPoolManager) { try { const body = await getRequestBody(req); - const { model = 'gpt-image-2', prompt, n = 1, response_format = 'b64_json' } = body; + const { model = 'gpt-image-2', prompt, n = 1, response_format = 'b64_json', size } = body; if (!prompt) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -123,7 +123,8 @@ async function handleImageGenerationRequest(req, res, currentConfig, providerPoo type: 'message', role: 'user', content: [{ type: 'input_text', text: prompt }] - }] + }], + ...(size ? { _imageSize: size } : {}) }; // 从号池获取服务实例 @@ -152,11 +153,11 @@ async function handleImageGenerationRequest(req, res, currentConfig, providerPoo const output = completedEvent?.response?.output || []; for (const item of output) { if (item.type === 'image_generation_call' && item.result) { - if (response_format === 'url') { - data.push({ url: `data:image/${item.output_format || 'png'};base64,${item.result}` }); - } else { - data.push({ b64_json: item.result }); - } + const dataItem = response_format === 'url' + ? { url: `data:image/${item.output_format || 'png'};base64,${item.result}` } + : { b64_json: item.result }; + if (item.revised_prompt) dataItem.revised_prompt = item.revised_prompt; + data.push(dataItem); } } } From a4092942f9f7652126350cb3a17228eb6d688716 Mon Sep 17 00:00:00 2001 From: zouyifan Date: Sun, 26 Apr 2026 12:11:47 -0500 Subject: [PATCH 061/135] feat: enhance image generation request handling with improved validation and concurrency support --- src/providers/openai/codex-core.js | 2 +- src/services/api-manager.js | 52 ++++++++++++++++++++++-------- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/providers/openai/codex-core.js b/src/providers/openai/codex-core.js index db0f572ab..4898a1997 100644 --- a/src/providers/openai/codex-core.js +++ b/src/providers/openai/codex-core.js @@ -191,7 +191,7 @@ export class CodexApiService { const config = { headers, responseType: 'text', // 确保以文本形式接收 SSE 流 - timeout: 120000 // 2 分钟超时 + timeout: 180000 // 3 分钟超时(图片生成可能耗时会多一些) }; // 配置代理 diff --git a/src/services/api-manager.js b/src/services/api-manager.js index 88bf31d63..a5bfcfd8c 100644 --- a/src/services/api-manager.js +++ b/src/services/api-manager.js @@ -106,13 +106,27 @@ export function initializeAPIManagement(services) { * Handle POST /v1/images/generations - OpenAI 标准生图接口 */ async function handleImageGenerationRequest(req, res, currentConfig, providerPoolManager) { + const IMAGE_GEN_MAX_N = 4; + const VALID_RESPONSE_FORMATS = new Set(['b64_json', 'url']); + + let slotProviderType = null; + let slotUuid = null; + try { const body = await getRequestBody(req); - const { model = 'gpt-image-2', prompt, n = 1, response_format = 'b64_json', size } = body; + const { model = 'gpt-image-2', prompt, response_format = 'b64_json', size } = body; + // cap n:至少 1,最多 IMAGE_GEN_MAX_N,非数字降级为 1 + const n = Math.min(Math.max(1, parseInt(body.n) || 1), IMAGE_GEN_MAX_N); if (!prompt) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'prompt is required', type: 'invalid_request_error', code: 'invalid_request_error' } })); + res.end(JSON.stringify({ error: { message: 'prompt is required', type: 'invalid_request_error' } })); + return; + } + + if (!VALID_RESPONSE_FORMATS.has(response_format)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: `response_format must be 'b64_json' or 'url'`, type: 'invalid_request_error' } })); return; } @@ -127,9 +141,9 @@ async function handleImageGenerationRequest(req, res, currentConfig, providerPoo ...(size ? { _imageSize: size } : {}) }; - // 从号池获取服务实例 - const shouldUsePool = providerPoolManager && currentConfig.providerPools; - const result = await getApiServiceWithFallback(currentConfig, model, { acquireSlot: !!shouldUsePool }); + // 从号池获取服务实例,acquireSlot 与其他接口保持一致 + const shouldUsePool = !!(providerPoolManager && currentConfig.providerPools); + const result = await getApiServiceWithFallback(currentConfig, model, { acquireSlot: shouldUsePool }); const service = result.service; if (!service) { @@ -138,13 +152,18 @@ async function handleImageGenerationRequest(req, res, currentConfig, providerPoo return; } - logger.info(`[Image Generation] model=${model}, n=${n}, response_format=${response_format}`); - - // 发起请求(generateContent 内部已经是 SSE 解析,返回 completedEvent) - const imageRequests = []; - for (let i = 0; i < Math.max(1, n); i++) { - imageRequests.push(service.generateContent(model, { ...codexRequestBody })); + // 记录 slot 信息,供 finally 释放 + if (shouldUsePool && result.uuid) { + slotProviderType = result.actualProviderType || currentConfig.MODEL_PROVIDER; + slotUuid = result.uuid; } + + logger.info(`[Image Generation] model=${model}, n=${n}, response_format=${response_format}${size ? `, size=${size}` : ''}`); + + // n 张图并发发起,每张独立 generateContent 调用 + const imageRequests = Array.from({ length: n }, () => + service.generateContent(model, { ...codexRequestBody }) + ); const completedEvents = await Promise.all(imageRequests); // 从 response.output 中提取 image_generation_call 结果 @@ -173,8 +192,15 @@ async function handleImageGenerationRequest(req, res, currentConfig, providerPoo res.end(JSON.stringify({ created: Math.floor(Date.now() / 1000), data })); } catch (error) { logger.error('[Image Generation] Error:', error.message); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: error.message, type: 'server_error' } })); + if (!res.writableEnded) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message, type: 'server_error' } })); + } + } finally { + // 确保并发槽在请求结束后归还(与 handleStreamRequest/handleUnaryRequest 保持一致) + if (providerPoolManager && slotProviderType && slotUuid) { + providerPoolManager.releaseSlot(slotProviderType, slotUuid); + } } } From 7ad4706b30519a2603f072a8ae8750f97859b242 Mon Sep 17 00:00:00 2001 From: zouyifan Date: Sun, 26 Apr 2026 14:51:15 -0500 Subject: [PATCH 062/135] style: format code for consistency and readability --- src/providers/openai/codex-core.js | 75 +++++++++++++++--------------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/src/providers/openai/codex-core.js b/src/providers/openai/codex-core.js index 249f86a0d..4bf0fa628 100644 --- a/src/providers/openai/codex-core.js +++ b/src/providers/openai/codex-core.js @@ -1,15 +1,15 @@ import axios from 'axios'; import logger from '../../utils/logger.js'; import crypto from 'crypto'; -import { promises as fs } from 'fs'; +import {promises as fs} from 'fs'; import path from 'path'; import os from 'os'; -import { refreshCodexTokensWithRetry } from '../../auth/oauth-handlers.js'; -import { getProviderPoolManager } from '../../services/service-manager.js'; -import { configureTLSSidecar, isTLSSidecarEnabledForProvider } from '../../utils/proxy-utils.js'; -import { MODEL_PROVIDER, formatExpiryLog } from '../../utils/common.js'; -import { getProxyConfigForProvider } from '../../utils/proxy-utils.js'; -import { getProviderModels } from '../provider-models.js'; +import {refreshCodexTokensWithRetry} from '../../auth/oauth-handlers.js'; +import {getProviderPoolManager} from '../../services/service-manager.js'; +import {configureTLSSidecar, isTLSSidecarEnabledForProvider} from '../../utils/proxy-utils.js'; +import {MODEL_PROVIDER, formatExpiryLog} from '../../utils/common.js'; +import {getProxyConfigForProvider} from '../../utils/proxy-utils.js'; +import {getProviderModels} from '../provider-models.js'; const baseModels = getProviderModels(MODEL_PROVIDER.CODEX_API); const fastModels = baseModels.map(m => `${m}-fast`); @@ -38,7 +38,7 @@ export class CodexApiService { this.conversationCache = new Map(); // key: model-userId, value: {id, expire} this.startCacheCleanup(); - this.imageGenTool = { type: 'image_generation', output_format: 'png' }; + this.imageGenTool = {type: 'image_generation', output_format: 'png'}; } _applySidecar(axiosConfig) { @@ -242,7 +242,7 @@ export class CodexApiService { /** * 流式生成内容 */ - async *generateContentStream(model, requestBody) { + async* generateContentStream(model, requestBody) { if (!this.isInitialized) { await this.initialize(); } @@ -383,10 +383,10 @@ export class CodexApiService { async prepareRequestBody(model, requestBody, stream) { // 提取 metadata 并从请求体中移除,避免透传到上游 const metadata = requestBody.metadata || {}; - + // 明确会话维度:优先使用 session_id 或 conversation_id,其次 user_id const sessionId = metadata.session_id || metadata.conversation_id || metadata.user_id || 'default'; - + // 判断是否为 fast 模型并确定默认值 const normalizedModel = String(model || '').trim(); const isFastModel = /-fast$/i.test(normalizedModel); @@ -399,7 +399,7 @@ export class CodexApiService { const isImageModel = IMAGE_MODELS.includes(upstreamModel); const effectiveUpstreamModel = isImageModel ? 'gpt-5.4' : upstreamModel; - const cleanedBody = { ...requestBody }; + const cleanedBody = {...requestBody}; delete cleanedBody.metadata; // 【关键修复】确保传给上游的模型名称不带 -fast 后缀 @@ -408,7 +408,7 @@ export class CodexApiService { if (isImageModel) { // 图像模型:强制使用 image_generation 工具,不加 web_search - const imageToolConfig = { type: 'image_generation' }; + const imageToolConfig = {type: 'image_generation'}; if (cleanedBody._imageSize) { imageToolConfig.size = cleanedBody._imageSize; } @@ -427,7 +427,7 @@ export class CodexApiService { if (Array.isArray(cleanedBody.tools)) { const hasWebSearch = cleanedBody.tools.some(t => t.type === 'web_search'); if (!hasWebSearch) { - cleanedBody.tools.push({ type: 'web_search' }); + cleanedBody.tools.push({type: 'web_search'}); } } } @@ -446,7 +446,7 @@ export class CodexApiService { if (sessionId === 'default') { cacheKey = `${model}-default`; } - + let cache = this.conversationCache.get(cacheKey); if (!cache || cache.expire < Date.now()) { @@ -482,7 +482,7 @@ export class CodexApiService { // 监控钩子:内部请求转换 if (this.config?._monitorRequestId) { try { - const { getPluginManager } = await import('../../core/plugin-manager.js'); + const {getPluginManager} = await import('../../core/plugin-manager.js'); const pluginManager = getPluginManager(); if (pluginManager) { await pluginManager.executeHook('onInternalRequestConverted', { @@ -551,7 +551,7 @@ export class CodexApiService { return true; } const nearMinutes = 20; - const { message, isNearExpiry } = formatExpiryLog('Codex', expiry, nearMinutes); + const {message, isNearExpiry} = formatExpiryLog('Codex', expiry, nearMinutes); logger.info(message); return isNearExpiry; } @@ -588,7 +588,7 @@ export class CodexApiService { throw new Error('Invalid expiresAt when saving Codex credentials'); } - await fs.mkdir(credsDir, { recursive: true }); + await fs.mkdir(credsDir, {recursive: true}); await fs.writeFile( credsPath, JSON.stringify( @@ -605,7 +605,7 @@ export class CodexApiService { null, 2 ), - { mode: 0o600 } + {mode: 0o600} ); // 更新缓存路径(例如首次无 credsPath 兜底生成了新文件) @@ -645,8 +645,8 @@ export class CodexApiService { const response = eventData.response || {}; const output = response.output; - const shouldPatch = (!output || !Array.isArray(output) || output.length === 0) && - (outputItemsByIndex.size > 0 || outputItemsFallback.length > 0); + const shouldPatch = (!output || !Array.isArray(output) || output.length === 0) && + (outputItemsByIndex.size > 0 || outputItemsFallback.length > 0); if (!shouldPatch) { return eventData; @@ -672,7 +672,7 @@ export class CodexApiService { /** * 解析 SSE 流 */ - async *parseSSEStream(stream) { + async* parseSSEStream(stream) { let buffer = ''; const outputItemsByIndex = new Map(); const outputItemsFallback = []; @@ -694,7 +694,7 @@ export class CodexApiService { if (dataStr && dataStr !== '[DONE]') { try { let parsed = JSON.parse(dataStr); - + if (parsed.type === 'error') { logger.error('[Codex] API returned error in stream:', parsed.error || parsed); const errorMsg = (parsed.error && parsed.error.message) || JSON.stringify(parsed.error || parsed); @@ -774,10 +774,10 @@ export class CodexApiService { const lines = responseText.split('\n'); const outputItems = new Map(); // id -> output item const textDeltas = new Map(); // item_id -> accumulated text - + const outputItemsByIndex = new Map(); const outputItemsFallback = []; - + let completedEvent = null; for (const line of lines) { @@ -838,7 +838,7 @@ export class CodexApiService { // 如果我们已经收集到了一些输出项或文本,尝试合成一个完成事件 if (outputItems.size > 0 || textDeltas.size > 0 || outputItemsByIndex.size > 0 || outputItemsFallback.length > 0) { logger.warn('[Codex] No completed response found, but some output items were received. Synthesizing response.'); - + // 构造一个模拟的 completed 事件 completedEvent = { type: 'response.completed', @@ -850,10 +850,10 @@ export class CodexApiService { output: [] } }; - + // 使用 patchCodexCompletedOutput 填充输出 completedEvent = this.patchCodexCompletedOutput(completedEvent, outputItemsByIndex, outputItemsFallback); - + // 如果 patch 后还是没输出,尝试直接从 outputItems 填充 if (completedEvent.response.output.length === 0 && outputItems.size > 0) { completedEvent.response.output = Array.from(outputItems.values()); @@ -863,7 +863,7 @@ export class CodexApiService { // 记录前 1000 个字符用于调试 const debugInfo = responseText.length > 1000 ? responseText.slice(0, 1000) + '...' : responseText; logger.debug('[Codex] Raw response data:', debugInfo); - + throw new Error('stream error: stream disconnected before completion: stream closed before response.completed'); } } @@ -877,16 +877,17 @@ export class CodexApiService { const accumulatedText = textDeltas.get(item.id); if (accumulatedText !== undefined) { if (!item.content || item.content.length === 0) { - item.content = [{ type: 'output_text', text: accumulatedText }]; + item.content = [{type: 'output_text', text: accumulatedText}]; } else { item.content = item.content.map(c => { if (c.type === 'output_text' && !c.text) { - return { ...c, text: accumulatedText }; + return {...c, text: accumulatedText}; } return c; }); } } + } } // 如果 output 完全为空,从累积事件重建 @@ -894,7 +895,7 @@ export class CodexApiService { for (const [id, item] of outputItems) { const accumulatedText = textDeltas.get(id); if (accumulatedText !== undefined && item.type === 'message') { - item.content = [{ type: 'output_text', text: accumulatedText }]; + item.content = [{type: 'output_text', text: accumulatedText}]; } output.push(item); } @@ -990,10 +991,10 @@ export class CodexApiService { this._applySidecar(axiosRequestConfig); const response = await axios.request(axiosRequestConfig); - + // 解析响应数据并转换为通用格式 const data = response.data; - + // 通用格式:{ lastUpdated, models: { "model-id": { remaining, resetTime, resetTimeRaw } } } const result = { lastUpdated: Date.now(), @@ -1005,13 +1006,13 @@ export class CodexApiService { if (data.rate_limit) { const primaryWindow = data.rate_limit.primary_window; const secondaryWindow = data.rate_limit.secondary_window; - + // 使用主窗口的数据作为主要配额信息 if (primaryWindow) { // remaining = 1 - (used_percent / 100) const remaining = 1 - (primaryWindow.used_percent || 0) / 100; const resetTime = primaryWindow.reset_at ? new Date(primaryWindow.reset_at * 1000).toISOString() : null; - + // 为所有 Codex 模型设置相同的配额信息 const codexModels = ['default']; for (const modelId of codexModels) { @@ -1046,7 +1047,7 @@ export class CodexApiService { error.shouldSwitchCredential = true; error.skipErrorCount = true; } - + logger.error('[Codex] Failed to get usage limits:', error.message); throw error; } From 9114de49b21976933760f6c5e5f4a949077a7e75 Mon Sep 17 00:00:00 2001 From: zouyifan Date: Sun, 26 Apr 2026 14:53:46 -0500 Subject: [PATCH 063/135] feat: add image editing endpoint with multipart/form-data support --- src/services/api-manager.js | 162 ++++++++++++++++++++++++++++++++++-- 1 file changed, 157 insertions(+), 5 deletions(-) diff --git a/src/services/api-manager.js b/src/services/api-manager.js index a5bfcfd8c..8756f9c2e 100644 --- a/src/services/api-manager.js +++ b/src/services/api-manager.js @@ -7,6 +7,8 @@ import { } from '../utils/common.js'; import { getProviderPoolManager, getApiServiceWithFallback } from './service-manager.js'; import logger from '../utils/logger.js'; +import busboy from 'busboy'; + /** * Handle API authentication and routing * @param {string} method - The HTTP method @@ -34,11 +36,15 @@ export async function handleAPIRequests(method, path, req, res, currentConfig, a } } - // Route image generation requests + // Route image generation/editing requests if (method === 'POST' && path === '/v1/images/generations') { await handleImageGenerationRequest(req, res, currentConfig, providerPoolManager); return true; } + if (method === 'POST' && path === '/v1/images/edits') { + await handleImageEditsRequest(req, res, currentConfig, providerPoolManager); + return true; + } // Route content generation requests if (method === 'POST') { @@ -83,9 +89,9 @@ export function initializeAPIManagement(services) { // For pooled providers, refreshToken should be handled by individual instances // For single instances, this remains relevant if (serviceAdapter.config?.uuid && providerPoolManager) { - providerPoolManager._enqueueRefresh(serviceAdapter.config.MODEL_PROVIDER, { - config: serviceAdapter.config, - uuid: serviceAdapter.config.uuid + providerPoolManager._enqueueRefresh(serviceAdapter.config.MODEL_PROVIDER, { + config: serviceAdapter.config, + uuid: serviceAdapter.config.uuid }); } else { await serviceAdapter.refreshToken(); @@ -204,6 +210,152 @@ async function handleImageGenerationRequest(req, res, currentConfig, providerPoo } } +/** + * Parse multipart/form-data from a raw http.IncomingMessage via busboy. + * Returns { fields: {key: string}, files: {key: {buffer, mimetype}} } + * File buffers are collected in memory; mask field is accepted but ignored. + */ +function parseMultipartForm(req) { + return new Promise((resolve, reject) => { + const contentType = req.headers['content-type'] || ''; + if (!contentType.includes('multipart/form-data')) { + return reject(new Error('Content-Type must be multipart/form-data')); + } + + const bb = busboy({ headers: req.headers, limits: { fileSize: 20 * 1024 * 1024 } }); // 20MB cap + const fields = {}; + const files = {}; + + bb.on('field', (name, value) => { fields[name] = value; }); + + bb.on('file', (name, stream, info) => { + const chunks = []; + stream.on('data', chunk => chunks.push(chunk)); + stream.on('end', () => { files[name] = { buffer: Buffer.concat(chunks), mimetype: info.mimeType }; }); + stream.on('error', reject); + }); + + bb.on('close', () => resolve({ fields, files })); + bb.on('error', reject); + + req.pipe(bb); + }); +} + +/** + * Handle POST /v1/images/edits - OpenAI 标准改图接口 + * Accepts multipart/form-data: image (required), prompt (required), + * mask (ignored), model, n, size, response_format + */ +async function handleImageEditsRequest(req, res, currentConfig, providerPoolManager) { + const IMAGE_GEN_MAX_N = 4; + const VALID_RESPONSE_FORMATS = new Set(['b64_json', 'url']); + + let slotProviderType = null; + let slotUuid = null; + + try { + const { fields, files } = await parseMultipartForm(req); + + const model = fields.model || 'gpt-image-2'; + const prompt = fields.prompt; + const response_format = fields.response_format || 'b64_json'; + const size = fields.size; + const n = Math.min(Math.max(1, parseInt(fields.n) || 1), IMAGE_GEN_MAX_N); + + if (!prompt) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'prompt is required', type: 'invalid_request_error' } })); + return; + } + + if (!files.image) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'image is required', type: 'invalid_request_error' } })); + return; + } + + if (!VALID_RESPONSE_FORMATS.has(response_format)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: `response_format must be 'b64_json' or 'url'`, type: 'invalid_request_error' } })); + return; + } + + const { buffer, mimetype } = files.image; + const imageUrl = `data:${mimetype || 'image/png'};base64,${buffer.toString('base64')}`; + + // 构造 Codex 请求:input_image + input_text,prepareRequestBody 自动处理 gpt-image-2 → gpt-5.4 + image_generation tool + const codexRequestBody = { + model, + input: [{ + type: 'message', + role: 'user', + content: [ + { type: 'input_image', image_url: imageUrl }, + { type: 'input_text', text: prompt } + ] + }], + ...(size ? { _imageSize: size } : {}) + }; + + const shouldUsePool = !!(providerPoolManager && currentConfig.providerPools); + const result = await getApiServiceWithFallback(currentConfig, model, { acquireSlot: shouldUsePool }); + const service = result.service; + + if (!service) { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'No service available for image editing', type: 'server_error' } })); + return; + } + + if (shouldUsePool && result.uuid) { + slotProviderType = result.actualProviderType || currentConfig.MODEL_PROVIDER; + slotUuid = result.uuid; + } + + logger.info(`[Image Edits] model=${model}, n=${n}, response_format=${response_format}, imageSize=${Math.round(buffer.length / 1024)}KB${size ? `, size=${size}` : ''}`); + + const imageRequests = Array.from({ length: n }, () => + service.generateContent(model, { ...codexRequestBody }) + ); + const completedEvents = await Promise.all(imageRequests); + + const data = []; + for (const completedEvent of completedEvents) { + const output = completedEvent?.response?.output || []; + for (const item of output) { + if (item.type === 'image_generation_call' && item.result) { + const dataItem = response_format === 'url' + ? { url: `data:image/${item.output_format || 'png'};base64,${item.result}` } + : { b64_json: item.result }; + if (item.revised_prompt) dataItem.revised_prompt = item.revised_prompt; + data.push(dataItem); + } + } + } + + if (data.length === 0) { + logger.error('[Image Edits] No image found in response output'); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Image editing failed: no image in response', type: 'server_error' } })); + return; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ created: Math.floor(Date.now() / 1000), data })); + } catch (error) { + logger.error('[Image Edits] Error:', error.message); + if (!res.writableEnded) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message, type: 'server_error' } })); + } + } finally { + if (providerPoolManager && slotProviderType && slotUuid) { + providerPoolManager.releaseSlot(slotProviderType, slotUuid); + } + } +} + /** * Helper function to read request body * @param {http.IncomingMessage} req The HTTP request object. @@ -222,4 +374,4 @@ export function readRequestBody(req) { reject(err); }); }); -} \ No newline at end of file +} From 9d195e2898a886900ca4c00fffa9e5257d85e674 Mon Sep 17 00:00:00 2001 From: zouyifan Date: Sun, 26 Apr 2026 15:10:12 -0500 Subject: [PATCH 064/135] feat: implement support for image model validation in image generation requests --- src/providers/openai/codex-core.js | 36 +++++++----------------------- src/services/api-manager.js | 22 +++++++++++++----- 2 files changed, 24 insertions(+), 34 deletions(-) diff --git a/src/providers/openai/codex-core.js b/src/providers/openai/codex-core.js index 4bf0fa628..5160d594a 100644 --- a/src/providers/openai/codex-core.js +++ b/src/providers/openai/codex-core.js @@ -15,6 +15,7 @@ const baseModels = getProviderModels(MODEL_PROVIDER.CODEX_API); const fastModels = baseModels.map(m => `${m}-fast`); const CODEX_MODELS = [...new Set([...baseModels, ...fastModels])]; const CODEX_VERSION = '0.124.0'; +export const IMAGE_MODELS = new Set(['gpt-image-2']); /** * Codex API 服务类 @@ -354,29 +355,6 @@ export class CodexApiService { return headers; } - /** - * 确保包含图像生成工具 - */ - ensureImageGenerationTool(body, model) { - if (model.endsWith('spark')) { - return body; - } - - if (!body.tools) { - body.tools = [this.imageGenTool]; - return body; - } - - if (Array.isArray(body.tools)) { - const hasImageGen = body.tools.some(t => t.type === 'image_generation'); - if (!hasImageGen) { - body.tools.push(this.imageGenTool); - } - } - - return body; - } - /** * 准备请求体 */ @@ -395,8 +373,7 @@ export class CodexApiService { const defaultReasoningEffort = isFastModel ? 'xhigh' : 'medium'; // 图像生成模型:gpt-image-2 通过 image_generation 工具 + gpt-5.4 实现 - const IMAGE_MODELS = ['gpt-image-2']; - const isImageModel = IMAGE_MODELS.includes(upstreamModel); + const isImageModel = IMAGE_MODELS.has(upstreamModel); const effectiveUpstreamModel = isImageModel ? 'gpt-5.4' : upstreamModel; const cleanedBody = {...requestBody}; @@ -429,12 +406,15 @@ export class CodexApiService { if (!hasWebSearch) { cleanedBody.tools.push({type: 'web_search'}); } + if (!upstreamModel.endsWith('spark')) { + const hasImageGen = cleanedBody.tools.some(t => t.type === 'image_generation'); + if (!hasImageGen) { + cleanedBody.tools.push(this.imageGenTool); + } + } } } - // 确保包含图像生成工具 - this.ensureImageGenerationTool(cleanedBody, upstreamModel); - if (isFastModel) { logger.info(`[Codex] Detected -fast model: ${normalizedModel} -> ${upstreamModel}, service_tier: ${cleanedBody.service_tier || defaultServiceTier}`); } diff --git a/src/services/api-manager.js b/src/services/api-manager.js index 8756f9c2e..2eaaf353f 100644 --- a/src/services/api-manager.js +++ b/src/services/api-manager.js @@ -8,6 +8,10 @@ import { import { getProviderPoolManager, getApiServiceWithFallback } from './service-manager.js'; import logger from '../utils/logger.js'; import busboy from 'busboy'; +import { IMAGE_MODELS as SUPPORTED_IMAGE_MODELS } from '../providers/openai/codex-core.js'; + +const IMAGE_GEN_MAX_N = 4; +const VALID_RESPONSE_FORMATS = new Set(['b64_json', 'url']); /** * Handle API authentication and routing @@ -112,9 +116,6 @@ export function initializeAPIManagement(services) { * Handle POST /v1/images/generations - OpenAI 标准生图接口 */ async function handleImageGenerationRequest(req, res, currentConfig, providerPoolManager) { - const IMAGE_GEN_MAX_N = 4; - const VALID_RESPONSE_FORMATS = new Set(['b64_json', 'url']); - let slotProviderType = null; let slotUuid = null; @@ -124,6 +125,12 @@ async function handleImageGenerationRequest(req, res, currentConfig, providerPoo // cap n:至少 1,最多 IMAGE_GEN_MAX_N,非数字降级为 1 const n = Math.min(Math.max(1, parseInt(body.n) || 1), IMAGE_GEN_MAX_N); + if (!SUPPORTED_IMAGE_MODELS.has(model)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: `model '${model}' is not supported; supported image models: ${[...SUPPORTED_IMAGE_MODELS].join(', ')}`, type: 'invalid_request_error' } })); + return; + } + if (!prompt) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'prompt is required', type: 'invalid_request_error' } })); @@ -248,9 +255,6 @@ function parseMultipartForm(req) { * mask (ignored), model, n, size, response_format */ async function handleImageEditsRequest(req, res, currentConfig, providerPoolManager) { - const IMAGE_GEN_MAX_N = 4; - const VALID_RESPONSE_FORMATS = new Set(['b64_json', 'url']); - let slotProviderType = null; let slotUuid = null; @@ -263,6 +267,12 @@ async function handleImageEditsRequest(req, res, currentConfig, providerPoolMana const size = fields.size; const n = Math.min(Math.max(1, parseInt(fields.n) || 1), IMAGE_GEN_MAX_N); + if (!SUPPORTED_IMAGE_MODELS.has(model)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: `model '${model}' is not supported; supported image models: ${[...SUPPORTED_IMAGE_MODELS].join(', ')}`, type: 'invalid_request_error' } })); + return; + } + if (!prompt) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'prompt is required', type: 'invalid_request_error' } })); From ab3ed91e187a233d0f66b3b8109fefb0080cf694 Mon Sep 17 00:00:00 2001 From: zouyifan Date: Sun, 26 Apr 2026 15:17:45 -0500 Subject: [PATCH 065/135] feat: enhance error handling for image generation and editing with content policy rejection messages --- src/services/api-manager.js | 43 +++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/src/services/api-manager.js b/src/services/api-manager.js index 2eaaf353f..c0f65cb5a 100644 --- a/src/services/api-manager.js +++ b/src/services/api-manager.js @@ -195,9 +195,16 @@ async function handleImageGenerationRequest(req, res, currentConfig, providerPoo } if (data.length === 0) { - logger.error('[Image Generation] No image found in response output'); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Image generation failed: no image in response', type: 'server_error' } })); + const rejectionText = extractRejectionMessage(completedEvents); + if (rejectionText) { + logger.warn(`[Image Generation] Content policy rejection: ${rejectionText.slice(0, 100)}`); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { code: 'content_policy_violation', message: rejectionText, type: 'invalid_request_error' } })); + } else { + logger.error('[Image Generation] No image found in response output'); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Image generation failed: no image in response', type: 'server_error' } })); + } return; } @@ -217,6 +224,23 @@ async function handleImageGenerationRequest(req, res, currentConfig, providerPoo } } +/** + * Extract assistant rejection text from Codex output items. + * Returns the text if a policy/safety rejection message is found, otherwise null. + */ +function extractRejectionMessage(completedEvents) { + for (const completedEvent of completedEvents) { + const output = completedEvent?.response?.output || []; + for (const item of output) { + if (item.type === 'message' && item.role === 'assistant') { + const textPart = (item.content || []).find(c => c.type === 'output_text' && c.text); + if (textPart?.text) return textPart.text; + } + } + } + return null; +} + /** * Parse multipart/form-data from a raw http.IncomingMessage via busboy. * Returns { fields: {key: string}, files: {key: {buffer, mimetype}} } @@ -345,9 +369,16 @@ async function handleImageEditsRequest(req, res, currentConfig, providerPoolMana } if (data.length === 0) { - logger.error('[Image Edits] No image found in response output'); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Image editing failed: no image in response', type: 'server_error' } })); + const rejectionText = extractRejectionMessage(completedEvents); + if (rejectionText) { + logger.warn(`[Image Edits] Content policy rejection: ${rejectionText.slice(0, 100)}`); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { code: 'content_policy_violation', message: rejectionText, type: 'invalid_request_error' } })); + } else { + logger.error('[Image Edits] No image found in response output'); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Image editing failed: no image in response', type: 'server_error' } })); + } return; } From 4cd8532fba2d1d99795c1b752b920440f5c1729f Mon Sep 17 00:00:00 2001 From: zouyifan Date: Sun, 26 Apr 2026 16:03:40 -0500 Subject: [PATCH 066/135] feat: add Playground section with provider and model selection --- static/app/app.js | 5 + static/app/component-loader.js | 1 + static/app/playground-manager.js | 388 ++++++++++++++++++++++ static/components/section-playground.css | 353 ++++++++++++++++++++ static/components/section-playground.html | 62 ++++ static/components/sidebar.html | 3 + 6 files changed, 812 insertions(+) create mode 100644 static/app/playground-manager.js create mode 100644 static/components/section-playground.css create mode 100644 static/components/section-playground.html diff --git a/static/app/app.js b/static/app/app.js index f751a9437..f7b440f76 100644 --- a/static/app/app.js +++ b/static/app/app.js @@ -97,6 +97,10 @@ import { CustomModelsManager } from './custom-models-manager.js'; +import { + initPlaygroundManager +} from './playground-manager.js'; + /** * 加载初始数据 */ @@ -137,6 +141,7 @@ function initApp() { initImageZoom(); // 初始化图片放大功能 initPluginManager(); // 初始化插件管理功能 initTutorialManager(); // 初始化教程管理功能 + initPlaygroundManager(); // 初始化 Playground // 初始化自定义模型管理 window.customModelsManager = new CustomModelsManager(); diff --git a/static/app/component-loader.js b/static/app/component-loader.js index 62a0e3e2e..089e893a3 100644 --- a/static/app/component-loader.js +++ b/static/app/component-loader.js @@ -108,6 +108,7 @@ async function initializeComponents() { { path: `${basePath}section-providers.html`, container: '#content-container', position: 'beforeend' }, { path: `${basePath}section-custom-models.html`, container: '#content-container', position: 'beforeend' }, { path: `${basePath}section-usage.html`, container: '#content-container', position: 'beforeend' }, + { path: `${basePath}section-playground.html`, container: '#content-container', position: 'beforeend' }, { path: `${basePath}section-logs.html`, container: '#content-container', position: 'beforeend' }, { path: `${basePath}section-plugins.html`, container: '#content-container', position: 'beforeend' }, ]; diff --git a/static/app/playground-manager.js b/static/app/playground-manager.js new file mode 100644 index 000000000..f6cb36892 --- /dev/null +++ b/static/app/playground-manager.js @@ -0,0 +1,388 @@ +// Playground 管理模块 + +import { getAuthHeaders } from './auth.js'; + +let providerModels = {}; // { providerType: [model1, model2, ...] } +let apiKey = ''; // REQUIRED_API_KEY, used for /v1/chat/completions auth +let messages = []; // current conversation history +let pendingFiles = []; // { name, type, dataUrl } +let isStreaming = false; +let currentAbortController = null; + +// ── DOM helpers ────────────────────────────────────────────────────────────── + +function el(id) { + return document.getElementById(id); +} + +function getProviderSelect() { return el('pg-provider-select'); } +function getModelSelect() { return el('pg-model-select'); } +function getInput() { return el('pg-input'); } +function getSendBtn() { return el('pg-send-btn'); } +function getMessages() { return el('pg-messages'); } +function getEmpty() { return el('pg-empty'); } +function getAttachPreview() { return el('pg-attachments-preview'); } + +// ── Initialisation ─────────────────────────────────────────────────────────── + +export function initPlaygroundManager() { + loadProviderData(); + bindEvents(); +} + +async function loadProviderData() { + try { + const headers = getAuthHeaders(); + + const [accessRes, modelsRes] = await Promise.all([ + fetch('/api/access-info', { headers }), + fetch('/api/provider-models', { headers }) + ]); + + if (accessRes.ok) { + const data = await accessRes.json(); + apiKey = data.apiKey || ''; + renderProviderOptions(data.providers || []); + } + + if (modelsRes.ok) { + providerModels = await modelsRes.json(); + } + } catch (e) { + console.error('[Playground] Failed to load provider data:', e); + } +} + +function renderProviderOptions(providers) { + const sel = getProviderSelect(); + if (!sel) return; + + sel.innerHTML = ''; + + providers.forEach(p => { + const hasNodes = p.totalNodes > 0; + const opt = document.createElement('option'); + opt.value = p.id; + opt.textContent = hasNodes ? `● ${p.id} (${p.healthyNodes}/${p.totalNodes})` : `○ ${p.id}`; + opt.disabled = !hasNodes; + if (!hasNodes) opt.style.color = 'var(--text-secondary)'; + sel.appendChild(opt); + }); +} + +// ── Events ─────────────────────────────────────────────────────────────────── + +function bindEvents() { + // Provider change → populate models + document.addEventListener('change', (e) => { + if (e.target.id === 'pg-provider-select') onProviderChange(e.target.value); + }); + + // Model change → enable input + document.addEventListener('change', (e) => { + if (e.target.id === 'pg-model-select') updateInputState(); + }); + + // Send on Enter (not Shift+Enter) + document.addEventListener('keydown', (e) => { + if (e.target.id === 'pg-input' && e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }); + + // Auto-resize textarea + document.addEventListener('input', (e) => { + if (e.target.id === 'pg-input') { + e.target.style.height = 'auto'; + e.target.style.height = Math.min(e.target.scrollHeight, 160) + 'px'; + } + }); + + // Send button + document.addEventListener('click', (e) => { + if (e.target.closest('#pg-send-btn')) handleSend(); + if (e.target.closest('#pg-clear-btn')) clearChat(); + if (e.target.closest('#pg-attach-btn')) el('pg-file-input')?.click(); + }); + + // File input + document.addEventListener('change', (e) => { + if (e.target.id === 'pg-file-input') handleFiles(e.target.files); + }); +} + +function onProviderChange(providerType) { + const modelSel = getModelSelect(); + if (!modelSel) return; + + if (!providerType) { + modelSel.innerHTML = ''; + modelSel.disabled = true; + updateInputState(); + return; + } + + const models = providerModels[providerType] || []; + modelSel.innerHTML = ''; + models.forEach(m => { + const opt = document.createElement('option'); + opt.value = m; + opt.textContent = m; + modelSel.appendChild(opt); + }); + modelSel.disabled = false; + updateInputState(); +} + +function updateInputState() { + const provider = getProviderSelect()?.value; + const model = getModelSelect()?.value; + const ready = !!(provider && model && !isStreaming); + const input = getInput(); + const sendBtn = getSendBtn(); + if (input) input.disabled = !ready; + if (sendBtn) sendBtn.disabled = !ready; +} + +// ── Chat logic ──────────────────────────────────────────────────────────────── + +async function handleSend() { + if (isStreaming) return; + + const provider = getProviderSelect()?.value; + const model = getModelSelect()?.value; + const input = getInput(); + const text = input?.value.trim(); + + if (!provider || !model || (!text && pendingFiles.length === 0)) return; + + // Build user message content + const userContent = buildUserContent(text, pendingFiles); + messages.push({ role: 'user', content: userContent }); + + // Render user bubble (show text + file names) + const displayText = [ + text, + ...pendingFiles.map(f => `[附件: ${f.name}]`) + ].filter(Boolean).join('\n'); + appendMessage('user', displayText); + + // Reset input + if (input) { input.value = ''; input.style.height = 'auto'; } + pendingFiles = []; + renderAttachmentPreview(); + + // Start streaming + const assistantBubble = appendMessage('assistant', ''); + await streamResponse(provider, model, assistantBubble); +} + +function buildUserContent(text, files) { + if (files.length === 0) return text; + + const parts = []; + if (text) parts.push({ type: 'text', text }); + + files.forEach(f => { + if (f.type.startsWith('image/')) { + parts.push({ + type: 'image_url', + image_url: { url: f.dataUrl } + }); + } else { + // PDF or other — send as text note (broad compatibility) + parts.push({ type: 'text', text: `[File: ${f.name}]\n${f.dataUrl}` }); + } + }); + + return parts; +} + +async function streamResponse(provider, model, bubble) { + isStreaming = true; + updateInputState(); + + const cursor = document.createElement('span'); + cursor.className = 'pg-cursor'; + bubble.appendChild(cursor); + + currentAbortController = new AbortController(); + let accumulated = ''; + + try { + const response = await fetch('/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + 'model-provider': provider + }, + body: JSON.stringify({ + model, + messages, + stream: true + }), + signal: currentAbortController.signal + }); + + if (!response.ok) { + const errText = await response.text(); + let msg = `请求失败 (${response.status})`; + try { msg = JSON.parse(errText)?.error?.message || msg; } catch {} + throw new Error(msg); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + const data = line.slice(6).trim(); + if (data === '[DONE]') break; + + try { + const json = JSON.parse(data); + const delta = json.choices?.[0]?.delta?.content || ''; + if (delta) { + accumulated += delta; + // Update bubble text (before cursor) + bubble.textContent = accumulated; + bubble.appendChild(cursor); + scrollToBottom(); + } + } catch {} + } + } + + messages.push({ role: 'assistant', content: accumulated }); + + } catch (e) { + if (e.name === 'AbortError') { + accumulated = accumulated || '(已中断)'; + } else { + bubble.textContent = ''; + bubble.className = 'pg-message-bubble'; + const errBubble = document.createElement('span'); + errBubble.textContent = e.message; + bubble.appendChild(errBubble); + bubble.closest('.pg-message')?.classList.add('error'); + } + } finally { + cursor.remove(); + if (accumulated && !bubble.closest('.pg-message.error')) { + bubble.textContent = accumulated; + } + isStreaming = false; + currentAbortController = null; + updateInputState(); + scrollToBottom(); + } +} + +// ── UI helpers ──────────────────────────────────────────────────────────────── + +function appendMessage(role, text) { + const empty = getEmpty(); + if (empty) empty.style.display = 'none'; + + const container = getMessages(); + if (!container) return document.createElement('span'); + + const wrapper = document.createElement('div'); + wrapper.className = `pg-message ${role}`; + + const roleLabel = document.createElement('div'); + roleLabel.className = 'pg-message-role'; + roleLabel.textContent = role === 'user' ? '你' : 'AI'; + wrapper.appendChild(roleLabel); + + const bubble = document.createElement('div'); + bubble.className = 'pg-message-bubble'; + bubble.textContent = text; + wrapper.appendChild(bubble); + + container.appendChild(wrapper); + scrollToBottom(); + return bubble; +} + +function clearChat() { + messages = []; + pendingFiles = []; + renderAttachmentPreview(); + + const container = getMessages(); + if (!container) return; + container.innerHTML = ''; + + const empty = document.createElement('div'); + empty.className = 'playground-empty'; + empty.id = 'pg-empty'; + empty.innerHTML = '

选择提供商和模型后开始对话

'; + container.appendChild(empty); + + if (currentAbortController) { + currentAbortController.abort(); + currentAbortController = null; + } +} + +function scrollToBottom() { + const container = getMessages(); + if (container) container.scrollTop = container.scrollHeight; +} + +// ── File handling ───────────────────────────────────────────────────────────── + +async function handleFiles(fileList) { + if (!fileList?.length) return; + + for (const file of fileList) { + const dataUrl = await readFileAsDataUrl(file); + pendingFiles.push({ name: file.name, type: file.type, dataUrl }); + } + + // Reset input so same file can be re-selected + const fileInput = el('pg-file-input'); + if (fileInput) fileInput.value = ''; + + renderAttachmentPreview(); +} + +function readFileAsDataUrl(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = e => resolve(e.target.result); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +function renderAttachmentPreview() { + const preview = getAttachPreview(); + if (!preview) return; + preview.innerHTML = ''; + + pendingFiles.forEach((f, i) => { + const tag = document.createElement('div'); + tag.className = 'pg-attachment-tag'; + tag.innerHTML = ` + + ${f.name} + + `; + tag.querySelector('button').addEventListener('click', () => { + pendingFiles.splice(i, 1); + renderAttachmentPreview(); + }); + preview.appendChild(tag); + }); +} diff --git a/static/components/section-playground.css b/static/components/section-playground.css new file mode 100644 index 000000000..46af56d57 --- /dev/null +++ b/static/components/section-playground.css @@ -0,0 +1,353 @@ +/* Playground Section */ +.playground-layout { + display: flex; + gap: 1.5rem; + height: calc(100vh - 160px); + min-height: 500px; +} + +/* Left panel */ +.playground-config { + width: 260px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.playground-config-card { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.playground-config-card label { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.playground-select { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color); + border-radius: 0.375rem; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 0.875rem; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b7280' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + padding-right: 2rem; +} + +.playground-select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.1); +} + +.playground-select:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.playground-provider-item { + display: flex; + align-items: center; + gap: 0.375rem; +} + +.provider-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.provider-dot.healthy { + background: #10b981; +} + +.provider-dot.unhealthy { + background: #d1d5db; +} + +.playground-clear-btn { + margin-top: auto; + width: 100%; + padding: 0.5rem; + background: transparent; + border: 1px solid var(--border-color); + border-radius: 0.375rem; + color: var(--text-secondary); + font-size: 0.875rem; + cursor: pointer; + transition: all 0.15s; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.playground-clear-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +/* Right panel - chat area */ +.playground-chat { + flex: 1; + display: flex; + flex-direction: column; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + overflow: hidden; + min-width: 0; +} + +.playground-messages { + flex: 1; + overflow-y: auto; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.playground-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: var(--text-secondary); + gap: 0.75rem; +} + +.playground-empty i { + font-size: 2.5rem; + opacity: 0.3; +} + +.playground-empty p { + font-size: 0.9rem; + opacity: 0.6; +} + +/* Message bubbles */ +.pg-message { + display: flex; + flex-direction: column; + max-width: 85%; +} + +.pg-message.user { + align-self: flex-end; + align-items: flex-end; +} + +.pg-message.assistant { + align-self: flex-start; + align-items: flex-start; +} + +.pg-message-role { + font-size: 0.7rem; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 0.25rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.pg-message-bubble { + padding: 0.625rem 0.875rem; + border-radius: 0.75rem; + font-size: 0.9rem; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; +} + +.pg-message.user .pg-message-bubble { + background: var(--primary-color); + color: white; + border-bottom-right-radius: 0.25rem; +} + +.pg-message.assistant .pg-message-bubble { + background: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-bottom-left-radius: 0.25rem; +} + +.pg-message.error .pg-message-bubble { + background: #fef2f2; + color: #dc2626; + border: 1px solid #fecaca; + border-bottom-left-radius: 0.25rem; +} + +.pg-cursor { + display: inline-block; + width: 2px; + height: 1em; + background: var(--text-primary); + margin-left: 1px; + vertical-align: text-bottom; + animation: pg-blink 1s step-end infinite; +} + +@keyframes pg-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +/* Attachment preview */ +.pg-attachments-preview { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.5rem 1rem 0; +} + +.pg-attachment-tag { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.5rem; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 0.25rem; + font-size: 0.75rem; + color: var(--text-secondary); +} + +.pg-attachment-tag button { + background: none; + border: none; + cursor: pointer; + color: var(--text-secondary); + padding: 0; + line-height: 1; + font-size: 0.875rem; +} + +.pg-attachment-tag button:hover { + color: #dc2626; +} + +/* Input area */ +.playground-input-area { + border-top: 1px solid var(--border-color); + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.playground-input-row { + display: flex; + align-items: flex-end; + gap: 0.5rem; +} + +.pg-attach-btn { + flex-shrink: 0; + width: 36px; + height: 36px; + border: 1px solid var(--border-color); + border-radius: 0.375rem; + background: var(--bg-primary); + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; +} + +.pg-attach-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.playground-textarea { + flex: 1; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color); + border-radius: 0.375rem; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 0.9rem; + resize: none; + min-height: 36px; + max-height: 160px; + line-height: 1.5; + font-family: inherit; + overflow-y: auto; +} + +.playground-textarea:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.1); +} + +.pg-send-btn { + flex-shrink: 0; + width: 36px; + height: 36px; + border: none; + border-radius: 0.375rem; + background: var(--primary-color); + color: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; +} + +.pg-send-btn:hover:not(:disabled) { + background: var(--primary-hover, #047857); +} + +.pg-send-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.pg-hint { + font-size: 0.7rem; + color: var(--text-secondary); + opacity: 0.6; + text-align: right; +} + +/* Responsive */ +@media (max-width: 768px) { + .playground-layout { + flex-direction: column; + height: auto; + } + + .playground-config { + width: 100%; + } + + .playground-chat { + height: 60vh; + } +} diff --git a/static/components/section-playground.html b/static/components/section-playground.html new file mode 100644 index 000000000..1e7f34d45 --- /dev/null +++ b/static/components/section-playground.html @@ -0,0 +1,62 @@ + + +
+

Playground

+ +
+ +
+
+ + + + + +
+ + +
+ + +
+
+
+ +

选择提供商和模型后开始对话

+
+
+ + +
+ + +
+
+ + + +
+
Shift+Enter 换行 · Enter 发送
+
+
+
+ + + +
diff --git a/static/components/sidebar.html b/static/components/sidebar.html index 20d070b1c..36b5d72d1 100644 --- a/static/components/sidebar.html +++ b/static/components/sidebar.html @@ -32,6 +32,9 @@ 插件管理 + + Playground + 实时日志 From cd6741449a74473a12ef4dc62f10ab2276750e23 Mon Sep 17 00:00:00 2001 From: zouyifan Date: Sun, 26 Apr 2026 16:06:30 -0500 Subject: [PATCH 067/135] feat: add internationalization support for Playground section --- static/app/i18n.js | 40 +++++++++++++++++++++-- static/app/playground-manager.js | 32 ++++++------------ static/components/section-playground.html | 19 ++++++----- static/components/sidebar.html | 4 +-- 4 files changed, 60 insertions(+), 35 deletions(-) diff --git a/static/app/i18n.js b/static/app/i18n.js index d53c81702..da3b88db3 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -37,6 +37,7 @@ const translations = { 'nav.plugins': '插件管理', 'nav.models': '可用模型', 'nav.customModels': '自定义模型', + 'nav.playground': 'Playground', // Dashboard 'dashboard.title': '系统概览', @@ -796,7 +797,24 @@ const translations = { 'logs.clear.success.title': '清空成功', 'logs.clear.success.msg': '前端实时日志和服务器当日日志文件已全部清空', 'logs.clear.failed': '清空日志失败', - + + // Playground + 'playground.provider': '提供商', + 'playground.model': '模型', + 'playground.loading': '加载中...', + 'playground.selectProvider': '— 选择提供商 —', + 'playground.selectModel': '— 选择模型 —', + 'playground.providerFirst': '请先选择提供商', + 'playground.clearChat': '清空对话', + 'playground.emptyHint': '选择提供商和模型后开始对话', + 'playground.inputPlaceholder': '输入消息... (Shift+Enter 换行,Enter 发送)', + 'playground.hint': 'Shift+Enter 换行 · Enter 发送', + 'playground.attachTitle': '上传文件(图片/PDF)', + 'playground.you': '你', + 'playground.aborted': '(已中断)', + 'playground.attachPrefix': '[附件: ', + 'playground.reqFailed': '请求失败', + // Plugins 'plugins.title': '插件管理', 'plugins.description': '插件系统允许您扩展系统功能,启用或禁用插件需要重启服务才能生效', @@ -1115,6 +1133,7 @@ const translations = { 'nav.plugins': 'Plugin Management', 'nav.models': 'Available Models', 'nav.customModels': 'Custom Models', + 'nav.playground': 'Playground', // Dashboard 'dashboard.title': 'System Overview', @@ -1883,7 +1902,24 @@ const translations = { 'logs.clear.success.title': 'Success', 'logs.clear.success.msg': 'Both real-time logs and today\'s log file on server have been cleared', 'logs.clear.failed': 'Failed to clear logs', - + + // Playground + 'playground.provider': 'Provider', + 'playground.model': 'Model', + 'playground.loading': 'Loading...', + 'playground.selectProvider': '— Select Provider —', + 'playground.selectModel': '— Select Model —', + 'playground.providerFirst': 'Select a provider first', + 'playground.clearChat': 'Clear Chat', + 'playground.emptyHint': 'Select a provider and model to start chatting', + 'playground.inputPlaceholder': 'Type a message... (Shift+Enter for new line, Enter to send)', + 'playground.hint': 'Shift+Enter for new line · Enter to send', + 'playground.attachTitle': 'Upload file (image/PDF)', + 'playground.you': 'You', + 'playground.aborted': '(Aborted)', + 'playground.attachPrefix': '[Attachment: ', + 'playground.reqFailed': 'Request failed', + // Plugins 'plugins.title': 'Plugin Management', 'plugins.description': 'The plugin system allows you to extend system functionality. Enabling or disabling plugins requires a service restart to take effect.', diff --git a/static/app/playground-manager.js b/static/app/playground-manager.js index f6cb36892..97e98bed8 100644 --- a/static/app/playground-manager.js +++ b/static/app/playground-manager.js @@ -1,6 +1,7 @@ // Playground 管理模块 import { getAuthHeaders } from './auth.js'; +import { t } from './i18n.js'; let providerModels = {}; // { providerType: [model1, model2, ...] } let apiKey = ''; // REQUIRED_API_KEY, used for /v1/chat/completions auth @@ -57,7 +58,7 @@ function renderProviderOptions(providers) { const sel = getProviderSelect(); if (!sel) return; - sel.innerHTML = ''; + sel.innerHTML = ``; providers.forEach(p => { const hasNodes = p.totalNodes > 0; @@ -73,17 +74,14 @@ function renderProviderOptions(providers) { // ── Events ─────────────────────────────────────────────────────────────────── function bindEvents() { - // Provider change → populate models document.addEventListener('change', (e) => { if (e.target.id === 'pg-provider-select') onProviderChange(e.target.value); }); - // Model change → enable input document.addEventListener('change', (e) => { if (e.target.id === 'pg-model-select') updateInputState(); }); - // Send on Enter (not Shift+Enter) document.addEventListener('keydown', (e) => { if (e.target.id === 'pg-input' && e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); @@ -91,7 +89,6 @@ function bindEvents() { } }); - // Auto-resize textarea document.addEventListener('input', (e) => { if (e.target.id === 'pg-input') { e.target.style.height = 'auto'; @@ -99,14 +96,12 @@ function bindEvents() { } }); - // Send button document.addEventListener('click', (e) => { if (e.target.closest('#pg-send-btn')) handleSend(); if (e.target.closest('#pg-clear-btn')) clearChat(); if (e.target.closest('#pg-attach-btn')) el('pg-file-input')?.click(); }); - // File input document.addEventListener('change', (e) => { if (e.target.id === 'pg-file-input') handleFiles(e.target.files); }); @@ -117,14 +112,14 @@ function onProviderChange(providerType) { if (!modelSel) return; if (!providerType) { - modelSel.innerHTML = ''; + modelSel.innerHTML = ``; modelSel.disabled = true; updateInputState(); return; } const models = providerModels[providerType] || []; - modelSel.innerHTML = ''; + modelSel.innerHTML = ``; models.forEach(m => { const opt = document.createElement('option'); opt.value = m; @@ -157,23 +152,19 @@ async function handleSend() { if (!provider || !model || (!text && pendingFiles.length === 0)) return; - // Build user message content const userContent = buildUserContent(text, pendingFiles); messages.push({ role: 'user', content: userContent }); - // Render user bubble (show text + file names) const displayText = [ text, - ...pendingFiles.map(f => `[附件: ${f.name}]`) + ...pendingFiles.map(f => `${t('playground.attachPrefix')}${f.name}]`) ].filter(Boolean).join('\n'); appendMessage('user', displayText); - // Reset input if (input) { input.value = ''; input.style.height = 'auto'; } pendingFiles = []; renderAttachmentPreview(); - // Start streaming const assistantBubble = appendMessage('assistant', ''); await streamResponse(provider, model, assistantBubble); } @@ -191,7 +182,6 @@ function buildUserContent(text, files) { image_url: { url: f.dataUrl } }); } else { - // PDF or other — send as text note (broad compatibility) parts.push({ type: 'text', text: `[File: ${f.name}]\n${f.dataUrl}` }); } }); @@ -228,7 +218,7 @@ async function streamResponse(provider, model, bubble) { if (!response.ok) { const errText = await response.text(); - let msg = `请求失败 (${response.status})`; + let msg = `${t('playground.reqFailed')} (${response.status})`; try { msg = JSON.parse(errText)?.error?.message || msg; } catch {} throw new Error(msg); } @@ -253,7 +243,6 @@ async function streamResponse(provider, model, bubble) { const delta = json.choices?.[0]?.delta?.content || ''; if (delta) { accumulated += delta; - // Update bubble text (before cursor) bubble.textContent = accumulated; bubble.appendChild(cursor); scrollToBottom(); @@ -266,7 +255,7 @@ async function streamResponse(provider, model, bubble) { } catch (e) { if (e.name === 'AbortError') { - accumulated = accumulated || '(已中断)'; + accumulated = accumulated || t('playground.aborted'); } else { bubble.textContent = ''; bubble.className = 'pg-message-bubble'; @@ -301,7 +290,7 @@ function appendMessage(role, text) { const roleLabel = document.createElement('div'); roleLabel.className = 'pg-message-role'; - roleLabel.textContent = role === 'user' ? '你' : 'AI'; + roleLabel.textContent = role === 'user' ? t('playground.you') : 'AI'; wrapper.appendChild(roleLabel); const bubble = document.createElement('div'); @@ -326,7 +315,7 @@ function clearChat() { const empty = document.createElement('div'); empty.className = 'playground-empty'; empty.id = 'pg-empty'; - empty.innerHTML = '

选择提供商和模型后开始对话

'; + empty.innerHTML = `

${t('playground.emptyHint')}

`; container.appendChild(empty); if (currentAbortController) { @@ -350,7 +339,6 @@ async function handleFiles(fileList) { pendingFiles.push({ name: file.name, type: file.type, dataUrl }); } - // Reset input so same file can be re-selected const fileInput = el('pg-file-input'); if (fileInput) fileInput.value = ''; @@ -377,7 +365,7 @@ function renderAttachmentPreview() { tag.innerHTML = ` ${f.name} - + `; tag.querySelector('button').addEventListener('click', () => { pendingFiles.splice(i, 1); diff --git a/static/components/section-playground.html b/static/components/section-playground.html index 1e7f34d45..9aa6a18e4 100644 --- a/static/components/section-playground.html +++ b/static/components/section-playground.html @@ -7,19 +7,19 @@

- + - +

@@ -28,7 +28,7 @@

-

选择提供商和模型后开始对话

+

选择提供商和模型后开始对话

@@ -38,21 +38,22 @@

- -
-
Shift+Enter 换行 · Enter 发送
+
Shift+Enter 换行 · Enter 发送

diff --git a/static/components/sidebar.html b/static/components/sidebar.html index 36b5d72d1..e328c082b 100644 --- a/static/components/sidebar.html +++ b/static/components/sidebar.html @@ -32,8 +32,8 @@ 插件管理 - - Playground + + Playground 实时日志 From 09eb9f5e594e0efc7f1d9c45b8aae4c1cc7c7e34 Mon Sep 17 00:00:00 2001 From: zouyifan Date: Sun, 26 Apr 2026 16:18:34 -0500 Subject: [PATCH 068/135] fix: improve SSE metadata handling and update send button state logic --- src/providers/openai/codex-core.js | 14 +++++--------- static/app/playground-manager.js | 7 +++++++ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/providers/openai/codex-core.js b/src/providers/openai/codex-core.js index 01ad3ead8..db10ae5e0 100644 --- a/src/providers/openai/codex-core.js +++ b/src/providers/openai/codex-core.js @@ -660,11 +660,10 @@ export class CodexApiService { for (const line of lines) { const trimmedLine = line.trim(); if (!trimmedLine) continue; + // skip SSE metadata lines (event:, id:, retry:) + if (!trimmedLine.startsWith('data: ')) continue; - let dataStr = trimmedLine; - if (trimmedLine.startsWith('data: ')) { - dataStr = trimmedLine.slice(6).trim(); - } + const dataStr = trimmedLine.slice(6).trim(); if (dataStr && dataStr !== '[DONE]') { try { @@ -700,11 +699,8 @@ export class CodexApiService { // 处理剩余的 buffer const finalTrimmed = buffer.trim(); - if (finalTrimmed) { - let dataStr = finalTrimmed; - if (finalTrimmed.startsWith('data: ')) { - dataStr = finalTrimmed.slice(6).trim(); - } + if (finalTrimmed && finalTrimmed.startsWith('data: ')) { + const dataStr = finalTrimmed.slice(6).trim(); if (dataStr && dataStr !== '[DONE]') { try { diff --git a/static/app/playground-manager.js b/static/app/playground-manager.js index 97e98bed8..a1bb47b98 100644 --- a/static/app/playground-manager.js +++ b/static/app/playground-manager.js @@ -51,6 +51,9 @@ async function loadProviderData() { } } catch (e) { console.error('[Playground] Failed to load provider data:', e); + } finally { + // re-evaluate send button state after data loads + updateInputState(); } } @@ -151,6 +154,10 @@ async function handleSend() { const text = input?.value.trim(); if (!provider || !model || (!text && pendingFiles.length === 0)) return; + if (!apiKey) { + console.warn('[Playground] API key not loaded yet, aborting send'); + return; + } const userContent = buildUserContent(text, pendingFiles); messages.push({ role: 'user', content: userContent }); From 9941fd4fcf43bbbc4d627811d42dabaac0eb20cf Mon Sep 17 00:00:00 2001 From: zouyifan Date: Sun, 26 Apr 2026 17:50:47 -0500 Subject: [PATCH 069/135] feat: enhance SSE handling and add markdown rendering support --- static/app/playground-manager.js | 93 ++++++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 10 deletions(-) diff --git a/static/app/playground-manager.js b/static/app/playground-manager.js index a1bb47b98..98925d969 100644 --- a/static/app/playground-manager.js +++ b/static/app/playground-manager.js @@ -206,6 +206,7 @@ async function streamResponse(provider, model, bubble) { currentAbortController = new AbortController(); let accumulated = ''; + let errorMsg = ''; try { const response = await fetch('/v1/chat/completions', { @@ -232,13 +233,16 @@ async function streamResponse(provider, model, bubble) { const reader = response.body.getReader(); const decoder = new TextDecoder(); + let sseBuffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; - const chunk = decoder.decode(value, { stream: true }); - const lines = chunk.split('\n'); + // buffer across chunks so a large data: line isn't split mid-JSON + sseBuffer += decoder.decode(value, {stream: true}); + const lines = sseBuffer.split('\n'); + sseBuffer = lines.pop(); // keep the (possibly incomplete) last line for (const line of lines) { if (!line.startsWith('data: ')) continue; @@ -254,6 +258,19 @@ async function streamResponse(provider, model, bubble) { bubble.appendChild(cursor); scrollToBottom(); } + } catch { + } + } + } + + // flush whatever remains in the buffer + if (sseBuffer.trim().startsWith('data: ')) { + const data = sseBuffer.slice(6).trim(); + if (data && data !== '[DONE]') { + try { + const json = JSON.parse(data); + const delta = json.choices?.[0]?.delta?.content || ''; + if (delta) accumulated += delta; } catch {} } } @@ -264,17 +281,16 @@ async function streamResponse(provider, model, bubble) { if (e.name === 'AbortError') { accumulated = accumulated || t('playground.aborted'); } else { - bubble.textContent = ''; - bubble.className = 'pg-message-bubble'; - const errBubble = document.createElement('span'); - errBubble.textContent = e.message; - bubble.appendChild(errBubble); - bubble.closest('.pg-message')?.classList.add('error'); + console.error('[Playground] Stream error:', e.message); + errorMsg = e.message || t('playground.reqFailed'); } } finally { cursor.remove(); - if (accumulated && !bubble.closest('.pg-message.error')) { - bubble.textContent = accumulated; + if (errorMsg) { + bubble.textContent = errorMsg; + bubble.closest('.pg-message')?.classList.add('error'); + } else if (accumulated) { + bubble.innerHTML = renderMarkdown(accumulated); } isStreaming = false; currentAbortController = null; @@ -283,6 +299,63 @@ async function streamResponse(provider, model, bubble) { } } +// ── Markdown renderer ───────────────────────────────────────────────────────── + +function escapeHtml(str) { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function isSafeImageUrl(url) { + return url.startsWith('data:image/') || /^https?:\/\//.test(url); +} + +function renderMarkdown(text) { + const blocks = []; + + // pull out fenced code blocks first to protect them from further processing + text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => { + const escaped = escapeHtml(code.trimEnd()); + const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : ''; + const html = `
${escaped}
`; + blocks.push(html); + return `\x00BLOCK${blocks.length - 1}\x00`; + }); + + // inline code `...` + text = text.replace(/`([^`]+)`/g, (_, code) => + `${escapeHtml(code)}` + ); + + // markdown images ![alt](url) — only render safe URLs as + text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => { + if (!isSafeImageUrl(url)) return escapeHtml(match); + const safeAlt = escapeHtml(alt); + const safeUrl = url.startsWith('data:image/') ? url : escapeHtml(url); + return `${safeAlt}`; + }); + + // markdown links [text](url) + text = text.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, (_, label, url) => + `
${escapeHtml(label)}` + ); + + // **bold** and *italic* + text = text.replace(/\*\*([^*]+)\*\*/g, (_, s) => `${escapeHtml(s)}`); + text = text.replace(/\*([^*\n]+)\*/g, (_, s) => `${escapeHtml(s)}`); + + // newlines →
+ text = text.replace(/\n/g, '
'); + + // restore protected code blocks + text = text.replace(/\x00BLOCK(\d+)\x00/g, (_, i) => blocks[+i]); + + return text; +} + // ── UI helpers ──────────────────────────────────────────────────────────────── function appendMessage(role, text) { From 6a2dc0f37fc4c5f5936c56f917f1e6fc1c406b14 Mon Sep 17 00:00:00 2001 From: zouyifan Date: Sun, 26 Apr 2026 18:06:27 -0500 Subject: [PATCH 070/135] feat: update Playground input state handling and enhance user hints --- static/app/i18n.js | 19 ++-------- static/app/playground-manager.js | 46 +++++++++++++++++++---- static/components/section-playground.css | 21 ++++++++++- static/components/section-playground.html | 2 +- 4 files changed, 64 insertions(+), 24 deletions(-) diff --git a/static/app/i18n.js b/static/app/i18n.js index da3b88db3..95becd22c 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -427,12 +427,6 @@ const translations = { 'config.advanced.poolSizeLimit': '账号池轮询上限', 'config.advanced.poolSizeLimitPlaceholder': '默认: 0 (不限制)', 'config.advanced.poolSizeLimitNote': '每个提供商类型参与轮询的最大健康凭证数量,0 表示不限制,使用所有健康凭证', - 'config.advanced.credentialSwitchMaxRetries': '坏凭证切换最大重试次数', - 'config.advanced.credentialSwitchMaxRetriesNote': '认证错误(401/403)后切换凭证的最大重试次数,默认 5 次', - 'config.advanced.rateLimitCooldownEnabled': '启用 429 短冷却', - 'config.advanced.rateLimitCooldownTitle': '429 限流保护', - 'config.advanced.rateLimitCooldownNote': '上游返回 429 时,让当前账号短暂退出账号池,到期后自动恢复。', - 'config.advanced.rateLimitCooldownMs': '默认冷却时间(毫秒)', 'config.advanced.fallbackChain': '跨类型 Fallback 链配置', 'config.advanced.fallbackChainPlaceholder': '例如:\n{\n "gemini-cli-oauth": ["gemini-antigravity"],\n "gemini-antigravity": ["gemini-cli-oauth"],\n "claude-kiro-oauth": ["claude-custom"]\n}', 'config.advanced.fallbackChainNote': '当某一 Provider Type 所有账号都不健康时,自动切换到配置的 Fallback 类型。JSON 格式,键为主类型,值为 Fallback 类型数组(按优先级排序)', @@ -814,6 +808,8 @@ const translations = { 'playground.aborted': '(已中断)', 'playground.attachPrefix': '[附件: ', 'playground.reqFailed': '请求失败', + 'playground.selectFirst': '← 请先在左侧选择提供商和模型', + 'playground.generating': '正在生成回复,请稍候...', // Plugins 'plugins.title': '插件管理', @@ -1510,12 +1506,6 @@ const translations = { 'config.advanced.promptLogMode.file': 'File', 'config.advanced.maxRetries': 'Provider Max Retries', 'config.advanced.baseDelay': 'Base Retry Delay (ms)', - 'config.advanced.credentialSwitchMaxRetries': 'Credential Switch Max Retries', - 'config.advanced.credentialSwitchMaxRetriesNote': 'Max retry count for switching credentials after auth errors (401/403), default 5', - 'config.advanced.rateLimitCooldownEnabled': 'Enable 429 Cooldown', - 'config.advanced.rateLimitCooldownTitle': '429 Rate Limit Protection', - 'config.advanced.rateLimitCooldownNote': 'When upstream returns 429, temporarily remove the current account from the pool and recover it automatically.', - 'config.advanced.rateLimitCooldownMs': 'Default Cooldown (ms)', 'config.advanced.warmupTarget': 'Warmup Target Nodes', 'config.advanced.warmupTargetNote': 'Number of nodes to refresh on startup, default 0', 'config.advanced.refreshConcurrencyPerProvider': 'Refresh Concurrency per Provider', @@ -1919,6 +1909,8 @@ const translations = { 'playground.aborted': '(Aborted)', 'playground.attachPrefix': '[Attachment: ', 'playground.reqFailed': 'Request failed', + 'playground.selectFirst': '← Please select a provider and model on the left', + 'playground.generating': 'Generating response, please wait...', // Plugins 'plugins.title': 'Plugin Management', @@ -2183,9 +2175,6 @@ const translations = { 'common.date.days': 'd ', 'common.date.hours': 'h ', 'common.date.minutes': 'm', - 'common.date.days': 'd ', - 'common.date.hours': 'h ', - 'common.date.minutes': 'm', // Login 'login.title': 'Login - AIClient2API', diff --git a/static/app/playground-manager.js b/static/app/playground-manager.js index 98925d969..91e519de1 100644 --- a/static/app/playground-manager.js +++ b/static/app/playground-manager.js @@ -136,11 +136,26 @@ function onProviderChange(providerType) { function updateInputState() { const provider = getProviderSelect()?.value; const model = getModelSelect()?.value; - const ready = !!(provider && model && !isStreaming); + const selected = !!(provider && model); + const ready = selected && !isStreaming; + const input = getInput(); const sendBtn = getSendBtn(); if (input) input.disabled = !ready; if (sendBtn) sendBtn.disabled = !ready; + + const inputArea = document.querySelector('.playground-input-area'); + const hint = el('pg-hint'); + if (inputArea) inputArea.classList.toggle('pg-input-disabled', !ready); + if (hint) { + if (ready) { + hint.textContent = t('playground.hint'); + } else if (isStreaming) { + hint.textContent = t('playground.generating'); + } else { + hint.textContent = t('playground.selectFirst'); + } + } } // ── Chat logic ──────────────────────────────────────────────────────────────── @@ -235,7 +250,8 @@ async function streamResponse(provider, model, bubble) { const decoder = new TextDecoder(); let sseBuffer = ''; - while (true) { + let streamDone = false; + outer: while (true) { const { done, value } = await reader.read(); if (done) break; @@ -247,10 +263,17 @@ async function streamResponse(provider, model, bubble) { for (const line of lines) { if (!line.startsWith('data: ')) continue; const data = line.slice(6).trim(); - if (data === '[DONE]') break; + if (data === '[DONE]') { + streamDone = true; + break outer; + } try { const json = JSON.parse(data); + // detect server-side stream error event + if (json.error) { + throw new Error(json.error.message || t('playground.reqFailed')); + } const delta = json.choices?.[0]?.delta?.content || ''; if (delta) { accumulated += delta; @@ -258,24 +281,33 @@ async function streamResponse(provider, model, bubble) { bubble.appendChild(cursor); scrollToBottom(); } - } catch { + } catch (parseErr) { + if (parseErr.message && !parseErr.message.startsWith('Unexpected')) { + // re-throw real stream errors, swallow JSON parse errors + throw parseErr; + } } } } // flush whatever remains in the buffer - if (sseBuffer.trim().startsWith('data: ')) { + if (!streamDone && sseBuffer.trim().startsWith('data: ')) { const data = sseBuffer.slice(6).trim(); if (data && data !== '[DONE]') { try { const json = JSON.parse(data); + if (json.error) throw new Error(json.error.message || t('playground.reqFailed')); const delta = json.choices?.[0]?.delta?.content || ''; if (delta) accumulated += delta; - } catch {} + } catch (parseErr) { + if (parseErr.message && !parseErr.message.startsWith('Unexpected')) throw parseErr; + } } } - messages.push({ role: 'assistant', content: accumulated }); + // strip base64 data URLs before storing in history to avoid context overflow + const historyContent = accumulated.replace(/data:[^;]+;base64,[A-Za-z0-9+/=]+/g, '[图片]'); + messages.push({role: 'assistant', content: historyContent}); } catch (e) { if (e.name === 'AbortError') { diff --git a/static/components/section-playground.css b/static/components/section-playground.css index 46af56d57..cfb26cf63 100644 --- a/static/components/section-playground.css +++ b/static/components/section-playground.css @@ -305,6 +305,12 @@ box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.1); } +.playground-textarea:disabled { + background: var(--bg-secondary); + color: var(--text-secondary); + cursor: not-allowed; +} + .pg-send-btn { flex-shrink: 0; width: 36px; @@ -325,8 +331,21 @@ } .pg-send-btn:disabled { - opacity: 0.5; + opacity: 0.4; cursor: not-allowed; + background: var(--text-secondary, #9ca3af); +} + +/* Input area disabled state */ +.playground-input-area.pg-input-disabled .playground-input-row { + opacity: 0.55; + pointer-events: none; +} + +.playground-input-area.pg-input-disabled .pg-hint { + opacity: 1; + color: var(--primary-color, #059669); + font-weight: 500; } .pg-hint { diff --git a/static/components/section-playground.html b/static/components/section-playground.html index 9aa6a18e4..7ad8fe462 100644 --- a/static/components/section-playground.html +++ b/static/components/section-playground.html @@ -53,7 +53,7 @@

-
Shift+Enter 换行 · Enter 发送
+
Shift+Enter 换行 · Enter 发送
From 257b625ddd993a7ad321500f7eda2fa8452c17cb Mon Sep 17 00:00:00 2001 From: zouyifan Date: Sun, 26 Apr 2026 18:13:45 -0500 Subject: [PATCH 071/135] feat: update Playground section for internationalization support --- static/app/i18n.js | 4 ++-- static/components/section-playground.html | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/static/app/i18n.js b/static/app/i18n.js index 95becd22c..b4b76af89 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -37,8 +37,8 @@ const translations = { 'nav.plugins': '插件管理', 'nav.models': '可用模型', 'nav.customModels': '自定义模型', - 'nav.playground': 'Playground', - + 'nav.playground': '模型测试', + // Dashboard 'dashboard.title': '系统概览', 'dashboard.uptime': '运行时间', diff --git a/static/components/section-playground.html b/static/components/section-playground.html index 7ad8fe462..682fbf803 100644 --- a/static/components/section-playground.html +++ b/static/components/section-playground.html @@ -1,7 +1,8 @@
-

Playground

+

模型测试

From 6a765f95fbaa1ab87bf24e33e537691d053ddccd Mon Sep 17 00:00:00 2001 From: zouyifan Date: Sun, 26 Apr 2026 19:19:44 -0500 Subject: [PATCH 072/135] feat: exclude specific event prefixes from processing in codex-core.js --- src/providers/openai/codex-core.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/providers/openai/codex-core.js b/src/providers/openai/codex-core.js index 5160d594a..b59c2a383 100644 --- a/src/providers/openai/codex-core.js +++ b/src/providers/openai/codex-core.js @@ -666,6 +666,10 @@ export class CodexApiService { const trimmedLine = line.trim(); if (!trimmedLine) continue; + if (trimmedLine.startsWith('event: ') || trimmedLine.startsWith('id: ') || trimmedLine.startsWith('retry: ')) { + continue; + } + let dataStr = trimmedLine; if (trimmedLine.startsWith('data: ')) { dataStr = trimmedLine.slice(6).trim(); @@ -764,6 +768,10 @@ export class CodexApiService { const trimmedLine = line.trim(); if (!trimmedLine) continue; + if (trimmedLine.startsWith('event: ') || trimmedLine.startsWith('id: ') || trimmedLine.startsWith('retry: ')) { + continue; + } + let jsonData = trimmedLine; if (trimmedLine.startsWith('data: ')) { jsonData = trimmedLine.slice(6).trim(); From fe2df5da30a2e06362171a41fb04786d86f7063e Mon Sep 17 00:00:00 2001 From: zouyifan Date: Sun, 26 Apr 2026 20:10:30 -0500 Subject: [PATCH 073/135] feat: improve error handling for file uploads in api-manager.js --- src/services/api-manager.js | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/services/api-manager.js b/src/services/api-manager.js index c0f65cb5a..674857d47 100644 --- a/src/services/api-manager.js +++ b/src/services/api-manager.js @@ -257,17 +257,39 @@ function parseMultipartForm(req) { const fields = {}; const files = {}; + let settled = false; + const rejectOnce = (err) => { + if (!settled) { + settled = true; + reject(err); + } + }; + const resolveOnce = (val) => { + if (!settled) { + settled = true; + resolve(val); + } + }; + bb.on('field', (name, value) => { fields[name] = value; }); bb.on('file', (name, stream, info) => { const chunks = []; stream.on('data', chunk => chunks.push(chunk)); stream.on('end', () => { files[name] = { buffer: Buffer.concat(chunks), mimetype: info.mimeType }; }); - stream.on('error', reject); + stream.on('error', rejectOnce); }); - bb.on('close', () => resolve({ fields, files })); - bb.on('error', reject); + bb.on('close', () => resolveOnce({fields, files})); + bb.on('error', rejectOnce); + + // 客户端提前断连时 req 不会触发 'end',需要主动拒绝,否则 Promise 永远挂起 + req.on('aborted', () => rejectOnce(new Error('Request aborted by client'))); + req.on('close', () => { + if (!req.complete) { + rejectOnce(new Error('Request connection closed before body was fully received')); + } + }); req.pipe(bb); }); From 25a2af26dd3aa5bf74a0a8cdc263b10124f29750 Mon Sep 17 00:00:00 2001 From: zouyifan Date: Sun, 26 Apr 2026 22:33:22 -0500 Subject: [PATCH 074/135] feat: enhance image editing error handling and support for multiple image field names --- src/providers/openai/codex-core.js | 3 +++ src/services/api-manager.js | 13 +++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/providers/openai/codex-core.js b/src/providers/openai/codex-core.js index b59c2a383..bde6b876f 100644 --- a/src/providers/openai/codex-core.js +++ b/src/providers/openai/codex-core.js @@ -817,6 +817,9 @@ export class CodexApiService { break; } } catch (e) { + if (e.message.startsWith('Codex API error')) { + throw e; + } // 继续解析下一行 logger.debug('[Codex] Failed to parse SSE line:', e.message); } diff --git a/src/services/api-manager.js b/src/services/api-manager.js index 674857d47..47651dd60 100644 --- a/src/services/api-manager.js +++ b/src/services/api-manager.js @@ -313,31 +313,40 @@ async function handleImageEditsRequest(req, res, currentConfig, providerPoolMana const size = fields.size; const n = Math.min(Math.max(1, parseInt(fields.n) || 1), IMAGE_GEN_MAX_N); + // 兼容 image[] 字段名(LiteLLM 发送的是 image[],OpenAI 标准是 image) + const imageFile = files.image || files['image[]']; + + logger.info(`[Image Edits] Received request: model=${model}, n=${n}, response_format=${response_format}, hasPrompt=${!!prompt}, hasImage=${!!imageFile}${size ? `, size=${size}` : ''}, fields=${JSON.stringify(Object.keys(fields))}, fileKeys=${JSON.stringify(Object.keys(files))}`); + if (!SUPPORTED_IMAGE_MODELS.has(model)) { + logger.warn(`[Image Edits] Unsupported model: ${model}`); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { message: `model '${model}' is not supported; supported image models: ${[...SUPPORTED_IMAGE_MODELS].join(', ')}`, type: 'invalid_request_error' } })); return; } if (!prompt) { + logger.warn(`[Image Edits] Missing required field: prompt`); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'prompt is required', type: 'invalid_request_error' } })); return; } - if (!files.image) { + if (!imageFile) { + logger.warn(`[Image Edits] Missing required field: image (received file keys: ${JSON.stringify(Object.keys(files))})`); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'image is required', type: 'invalid_request_error' } })); return; } if (!VALID_RESPONSE_FORMATS.has(response_format)) { + logger.warn(`[Image Edits] Invalid response_format: ${response_format}`); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { message: `response_format must be 'b64_json' or 'url'`, type: 'invalid_request_error' } })); return; } - const { buffer, mimetype } = files.image; + const {buffer, mimetype} = imageFile; const imageUrl = `data:${mimetype || 'image/png'};base64,${buffer.toString('base64')}`; // 构造 Codex 请求:input_image + input_text,prepareRequestBody 自动处理 gpt-image-2 → gpt-5.4 + image_generation tool From 885bfafe520abfa8ff453de8fe2207cdeef56d1e Mon Sep 17 00:00:00 2001 From: hex2077 Date: Mon, 27 Apr 2026 17:04:51 +0800 Subject: [PATCH 075/135] =?UTF-8?q?feat(ui):=20=E5=A2=9E=E5=BC=BA=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E6=98=BE=E7=A4=BA=E5=B9=B6=E4=BC=98=E5=8C=96=E6=8F=90?= =?UTF-8?q?=E4=BE=9B=E8=80=85=E9=80=89=E6=8B=A9=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在日志条目中显示时间戳和日志级别 - 计算并展示可用节点数,仅显示有可用节点的提供者 - 去除控制台日志中的服务器时间戳前缀以提升可读性 --- VERSION | 2 +- src/ui-modules/access-api.js | 2 ++ src/ui-modules/event-broadcast.js | 44 ++++++++++++++++++++----------- static/app/event-stream.js | 5 +++- static/app/playground-manager.js | 17 ++++++------ 5 files changed, 43 insertions(+), 27 deletions(-) diff --git a/VERSION b/VERSION index 2badd6971..0580f07ff 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.15.7.1 +2.15.8 diff --git a/src/ui-modules/access-api.js b/src/ui-modules/access-api.js index c5fd0e69f..33647330d 100644 --- a/src/ui-modules/access-api.js +++ b/src/ui-modules/access-api.js @@ -60,11 +60,13 @@ function buildProviderSummaries(providerStatus, defaultProviders, registeredProv const totalNodes = providers.length; const healthyNodes = providers.filter(provider => provider.isHealthy).length; const disabledNodes = providers.filter(provider => provider.isDisabled).length; + const usableNodes = providers.filter(provider => provider.isHealthy && !provider.isDisabled).length; return { id, totalNodes, healthyNodes, + usableNodes, enabledNodes: totalNodes - disabledNodes, disabledNodes, isDefault: defaultProviders.includes(id) diff --git a/src/ui-modules/event-broadcast.js b/src/ui-modules/event-broadcast.js index af54ad4a0..5a9771133 100644 --- a/src/ui-modules/event-broadcast.js +++ b/src/ui-modules/event-broadcast.js @@ -89,17 +89,23 @@ export function initializeUIManagement() { const originalLog = console.log; console.log = function(...args) { originalLog.apply(console, args); + + let message = args.map(arg => { + if (typeof arg === 'string') return arg; + try { + return JSON.stringify(arg); + } catch (e) { + return String(arg); + } + }).join(' '); + + // Strip server-side timestamp if present [YYYY-MM-DD HH:MM:SS.mmm] + message = message.replace(/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\]\s*/, ''); + const logEntry = { timestamp: new Date().toISOString(), level: 'info', - message: args.map(arg => { - if (typeof arg === 'string') return arg; - try { - return JSON.stringify(arg); - } catch (e) { - return String(arg); - } - }).join(' ') + message: message }; global.logBuffer.push(logEntry); if (global.logBuffer.length > 100) { @@ -112,17 +118,23 @@ export function initializeUIManagement() { const originalError = console.error; console.error = function(...args) { originalError.apply(console, args); + + let message = args.map(arg => { + if (typeof arg === 'string') return arg; + try { + return JSON.stringify(arg); + } catch (e) { + return String(arg); + } + }).join(' '); + + // Strip server-side timestamp if present + message = message.replace(/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\]\s*/, ''); + const logEntry = { timestamp: new Date().toISOString(), level: 'error', - message: args.map(arg => { - if (typeof arg === 'string') return arg; - try { - return JSON.stringify(arg); - } catch (e) { - return String(arg); - } - }).join(' ') + message: message }; global.logBuffer.push(logEntry); if (global.logBuffer.length > 100) { diff --git a/static/app/event-stream.js b/static/app/event-stream.js index fc0805d10..07eabf182 100644 --- a/static/app/event-stream.js +++ b/static/app/event-stream.js @@ -68,10 +68,13 @@ function addLogEntry(logData) { const logEntry = document.createElement('div'); logEntry.className = 'log-entry'; - const time = new Date(logData.timestamp).toLocaleTimeString(); + const date = new Date(logData.timestamp); + const timeStr = date.toLocaleTimeString(); const levelClass = `log-level-${logData.level}`; logEntry.innerHTML = ` + [${timeStr}] + [${logData.level.toUpperCase()}] ${escapeHtml(logData.message)} `; diff --git a/static/app/playground-manager.js b/static/app/playground-manager.js index 91e519de1..815648350 100644 --- a/static/app/playground-manager.js +++ b/static/app/playground-manager.js @@ -63,15 +63,14 @@ function renderProviderOptions(providers) { sel.innerHTML = ``; - providers.forEach(p => { - const hasNodes = p.totalNodes > 0; - const opt = document.createElement('option'); - opt.value = p.id; - opt.textContent = hasNodes ? `● ${p.id} (${p.healthyNodes}/${p.totalNodes})` : `○ ${p.id}`; - opt.disabled = !hasNodes; - if (!hasNodes) opt.style.color = 'var(--text-secondary)'; - sel.appendChild(opt); - }); + providers + .filter(p => (p.usableNodes || 0) > 0) + .forEach(p => { + const opt = document.createElement('option'); + opt.value = p.id; + opt.textContent = `● ${p.id} (${p.usableNodes}/${p.totalNodes})`; + sel.appendChild(opt); + }); } // ── Events ─────────────────────────────────────────────────────────────────── From 23ddd2c148a0da73040a660504993f4274ae17ae Mon Sep 17 00:00:00 2001 From: hex2077 Date: Mon, 27 Apr 2026 17:18:03 +0800 Subject: [PATCH 076/135] =?UTF-8?q?docs:=20=E4=B8=BA=E8=B5=9E=E5=8A=A9?= =?UTF-8?q?=E5=95=86=E7=AB=A0=E8=8A=82=E6=B7=BB=E5=8A=A0=E6=8E=92=E5=BA=8F?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 README 的英文、中文和日文版本中,均在赞助商表格前添加了说明文字,以明确赞助商的排序依据并推荐注册使用。 --- README-JA.md | 2 ++ README-ZH.md | 2 ++ README.md | 2 ++ 3 files changed, 6 insertions(+) diff --git a/README-JA.md b/README-JA.md index 19d628d93..481b75683 100644 --- a/README-JA.md +++ b/README-JA.md @@ -26,6 +26,8 @@ ## 💎 スポンサー +*スポンサーは先着順に掲載されており、すべてのアカウント登録と利用を推奨します。* +
diff --git a/README-ZH.md b/README-ZH.md index 488065bff..1965b60bb 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -25,6 +25,8 @@ ## 💎 赞助商 +*排序按赞助先后顺序排列,均推荐注册使用。* +
diff --git a/README.md b/README.md index 5010c7c3e..267501e33 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ ## 💎 Sponsors +*Sponsors are listed in chronological order; all are recommended for registration and use.* + @@ -56,7 +59,7 @@ @@ -106,7 +109,8 @@ >
> クリックして詳細なバージョン履歴を展開 > -> - **2026.04.29** - OpenAI 標準の画像生成 (`/v1/images/generations`) および画像編集 (`/v1/images/edits`) インターフェースを完全にサポート。OpenAI 形式のリクエストを各モデルのネイティブ画像生成プロトコルに自動変換し、プロバイダープールのポーリングや自動リトライメカニズムに完全対応。マルチモーダル制作の安定性を大幅に向上。 +> - **2026.05.04 (v3.0.0)** - **マイルストーンアップデート:高度な AI 統合と自己発見アーキテクチャ**。自動化された Skill ガイドとリモート `/api/help`、`/api/example` エンドポイントを追加し、AI 代理が 50 以上の全 API エンドポイントをシームレスに理解・操作できるようになりました。CLI と REST API の出力結果を完全に統一し、構造化 JSON サポートを強化しました。 +> - **2026.04.29** - OpenAI 標準の画像生成 (`/v1/images/generations`) および画像編集 (`/v1/images/edits`) インターフェースを完全にサポート。OpenAI 形式のリクエストを各モデルのネイティブ画像生成プロトコルに自動変換し、プロバイダープールのポーリングや自动リトライメカニズムに完全対応。マルチモーダル制作の安定性を大幅に向上。 > - **2026.03.02** - Grokプロトコルサポートを追加:Cookie/SSO方式でxAI Grokシリーズモデル(Grok 3/4)へのアクセスに対応し、マルチモーダル入力、画像/動画生成、自動トークンリフレッシュ、ストリーミング出力をサポート > - **2026.01.26** - Codexプロトコルサポートを追加:OpenAI Codex OAuth認証での接続に対応 > - **2026.01.25** - AI 監視プラグインの強化:AI プロトコル変換前後のリクエストパラメータとレスポンスの監視をサポート。ログ管理の最適化:統一されたログ形式、ビジュアル設定 @@ -131,6 +135,16 @@ ## 💡 コアアドバンテージ +### 🤖 AI 連携と Skill ガイド + +> **AI 優先設計**:本プロジェクトは、AI Agent(Claude Code, Cursor, OpenCode など)との効率的な連携をネイティブにサポートしています。 +> +> **💡 クイックコマンド**:AI に直接以下の文章を伝えると、本プロジェクトのすべての使用方法を自动的にマスターします: +> +> ```text +> https://raw.githubusercontent.com/justlovemaki/AIClient2API/main/docs/skills/aiclient-cli-usage.md にある Skill をロードして学習し、AIClient2API のすべての起動パラメータと 50 以上の API エンドポイントの使用方法をマスターしてください。 +> ``` + ### 🎯 統一アクセス、ワンストップ管理 * **マルチモデル統一インターフェース**:標準OpenAI互換プロトコルを通じて、一度の設定でGemini、Claude、Grok、Codex、 K2、MiniMax M2などの主流大規模モデルにアクセス * **柔軟な切り替えメカニズム**:Pathルーティング、起動パラメータ、環境変数の3つの方法で動的にモデルを切り替え、異なるシナリオのニーズに対応 @@ -176,7 +190,7 @@ ### 🚀 クイックスタート -AIClient-2-APIを使い始める最も推奨される方法は、自動起動スクリプトを使用し、**Web UIコンソール**で直接ビジュアル設定を行うことです。 +AIClient2APIを使い始める最も推奨される方法は、自動起動スクリプトを使用し、**Web UIコンソール**で直接ビジュアル設定を行うことです。 #### 🐳 Docker クイックスタート (推奨) @@ -210,13 +224,19 @@ docker compose up -d * **Linux/macOS**: `chmod +x install-and-run.sh && ./install-and-run.sh` * **Windows**: `install-and-run.bat` をダブルクリックして実行 -> **💡 スクリプトの実行に失敗した場合は、手動で依存関係をインストールして起動できます:** +> **💡 手動インストールと起動(カスタムパラメータ対応):** > ```bash > npm install +> # デフォルト起動 > npm start +> # ヘルプ情報を表示 +> npm run help +> # API 呼び出しの例を表示 +> npm run example:api +> # バックエンドのみモード(フロントエンド管理画面と) +> npm start -- --no-ui > ``` - #### 2. コンソールへのアクセス サーバー起動後、ブラウザで以下にアクセスしてください: 👉 [**http://localhost:3000**](http://localhost:3000) @@ -328,7 +348,7 @@ Web UI管理インターフェースでは、極めて迅速に認証設定を 4. **重要なお知らせ**:Kiroサービス使用ポリシーが更新されました、最新の使用制限と条件については公式ウェブサイトをご確認ください。 #### Kiro 拡張思考 (Claude モデル) -AIClient-2-API は、`claude-kiro-oauth` にルーティングされた Claude 互換リクエスト (`/v1/messages`) または OpenAI 互換リクエスト (`/v1/chat/completions`) を使用する場合、Kiro 拡張思考をサポートします。 +AIClient2API は、`claude-kiro-oauth` にルーティングされた Claude 互換リクエスト (`/v1/messages`) または OpenAI 互換リクエスト (`/v1/chat/completions`) を使用する場合、Kiro 拡張思考をサポートします。 **Claude 互換インターフェース (`/v1/messages`)**: ```bash @@ -750,7 +770,7 @@ OAuthトークン(Gemini、Antigravity、Codexなど)には通常、有効 ### 貢献者リスト -AIClient-2-APIプロジェクトに貢献してくれたすべての開発者に感謝します: +AIClient2APIプロジェクトに貢献してくれたすべての開発者に感謝します: [![Contributors](https://contrib.rocks/image?repo=justlovemaki/AIClient-2-API)](https://github.com/justlovemaki/AIClient-2-API/graphs/contributors) @@ -765,7 +785,7 @@ AIClient-2-APIプロジェクトに貢献してくれたすべての開発者に ## ⚠️ 免責事項 ### 使用リスクの注意 -本プロジェクト(AIClient-2-API)は学習と研究目的のみです。ユーザーは本プロジェクト使用時、すべてのリスクを自己負担する必要があります。作者は本プロジェクトの使用により生じた直接的、間接的、または結果的な損失について一切の責任を負いません。 +本プロジェクト(AIClient2API)は学習と研究目的のみです。ユーザーは本プロジェクト使用時、すべてのリスクを自己負担する必要があります。作者は本プロジェクトの使用により生じた直接的、間接的、または結果的な損失について一切の責任を負いません。 ### サードパーティサービスの責任説明 本プロジェクトはAPIプロキシツールであり、AIモデルサービスを提供していません。すべてのAIモデルサービスは対応するサードパーティプロバイダー(Google、OpenAI、Anthropicなど)により提供されます。ユーザーが本プロジェクトを通じてこれらのサードパーティサービスにアクセスする際は、各サードパーティサービスの利用規約とポリシーを遵守する必要があります。作者はサードパーティサービスの可用性、品質、セキュリティ、合法性について責任を負いません。 diff --git a/README-ZH.md b/README-ZH.md index 5d3fd4d10..88676c1e1 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -2,7 +2,7 @@ logo -# AIClient-2-API(A2)🚀 +# AIClient2API(A2)🚀 **一个能将多种仅客户端内使用的大模型 API(Gemini CLI, Antigravity, Codex, Grok, Kiro ...),模拟请求,统一封装为本地 OpenAI 兼容接口的强大代理。** @@ -23,6 +23,9 @@ +--- + + ## 💎 赞助商 *排序按赞助先后顺序排列,均推荐注册使用。* @@ -45,7 +48,7 @@
@@ -55,7 +58,7 @@ @@ -105,6 +108,7 @@ >
> 点击展开查看详细版本历史 > +> - **2026.05.04 (v3.0.0)** - **里程碑更新:AI 深度集成与自发现架构**。新增自动化 Skill 指南与远程 `/api/help`、`/api/example` 接口,支持 AI 代理无缝理解并操作 50+ 个全量 API 端点;实现了 CLI 与 REST API 输出结果的完全统一,增强了结构化 JSON 支持。 > - **2026.04.29** - 全面支持 OpenAI 标准的图片生成 (`/v1/images/generations`) 与编辑 (`/v1/images/edits`) 接口。支持自动将 OpenAI 格式请求转换为各模型对应的原生生图协议,并适配号池轮询与自动重试机制,大幅提升多模态创作的稳定性。 > - **2026.03.02** - 新增 Grok 协议支持,支持通过 Cookie/SSO 方式访问 xAI Grok 系列模型(Grok 3/4),支持多模态输入、图片/视频生成、自动 token 刷新及流式输出 > - **2026.01.26** - 新增 Codex 协议支持:支持 OpenAI Codex OAuth 授权接入 @@ -129,6 +133,16 @@ ## 💡 核心优势 +### 🤖 AI 交互与 Skill 指南 + +> **AI 优先设计**:本项目原生支持与 AI Agent(如 Claude Code, Cursor, OpenCode)的高效交互。 +> +> **💡 快速指令**:你可以直接对 AI 说下面这句话,它将自动掌握本项目的所有用法: +> +> ```text +> 请加载并学习 https://raw.githubusercontent.com/justlovemaki/AIClient2API/main/docs/skills/aiclient-cli-usage.md 中的 Skill,以掌握 AIClient2API 的所有启动参数和 50+ 个 API 接口用法。 +> ``` + ### 🎯 统一接入,一站式管理 * **多模型统一接口**:通过标准 OpenAI 兼容协议,一次配置即可接入 Gemini、Claude、Grok、Codex、Kimi K2、MiniMax M2 等主流大模型 * **灵活切换机制**:Path 路由、支持通过启动参数、环境变量三种方式动态切换模型,满足不同场景需求 @@ -174,7 +188,7 @@ ### 🚀 快速启动 -使用 AIClient-2-API 最推荐的方式是通过自动化脚本启动,并直接在 **Web UI 控制台** 进行可视化配置。 +使用 AIClient2API 最推荐的方式是通过自动化脚本启动,并直接在 **Web UI 控制台** 进行可视化配置。 #### 🐳 Docker 快捷启动 (推荐) @@ -211,10 +225,16 @@ docker compose up -d > **💡 如果脚本运行失败,可以尝试手动安装依赖并启动:** > ```bash > npm install +> # 默认启动 > npm start +> # 查看帮助信息 +> npm run help +> # 查看 API 调用示例 +> npm run example:api +> # 纯后端模式(禁用前端管理界面) +> npm start -- --no-ui > ``` - #### 2. 访问控制台 服务器启动后,打开浏览器访问: 👉 [**http://localhost:3000**](http://localhost:3000) @@ -326,7 +346,7 @@ docker compose up -d 4. **重要提示**:Kiro 服务使用政策已更新,请访问官方网站查看最新使用限制和条款 #### Kiro 扩展思考 (Claude 模型) -AIClient-2-API 在使用路由到 `claude-kiro-oauth` 的 Claude 兼容请求 (`/v1/messages`) 或 OpenAI 兼容请求 (`/v1/chat/completions`) 时支持 Kiro 扩展思考。 +AIClient2API 在使用路由到 `claude-kiro-oauth` 的 Claude 兼容请求 (`/v1/messages`) 或 OpenAI 兼容请求 (`/v1/chat/completions`) 时支持 Kiro 扩展思考。 **Claude 兼容接口 (`/v1/messages`)**: ```bash @@ -747,7 +767,7 @@ OAuth 令牌(如 Gemini, Antigravity, Codex)通常有一定的有效期( 本项目的开发受到了官方 Google Gemini CLI 的极大启发,并参考了Cline 3.18.0 版本 `gemini-cli.ts` 的部分代码实现。在此对 Google 官方团队和 Cline 开发团队的卓越工作表示衷心的感谢! ### 贡献者列表 -感谢以下所有为 AIClient-2-API 项目做出贡献的开发者: +感谢以下所有为 AIClient2API 项目做出贡献的开发者: [![Contributors](https://contrib.rocks/image?repo=justlovemaki/AIClient-2-API)](https://github.com/justlovemaki/AIClient-2-API/graphs/contributors) @@ -762,7 +782,7 @@ OAuth 令牌(如 Gemini, Antigravity, Codex)通常有一定的有效期( ## ⚠️ 免责声明 ### 使用风险提示 -本项目(AIClient-2-API)仅供学习和研究使用。用户在使用本项目时,应自行承担所有风险。作者不对因使用本项目而导致的任何直接、间接或 consequential 损失承担责任。 +本项目(AIClient2API)仅供学习和研究使用。用户在使用本项目时,应自行承担所有风险。作者不对因使用本项目而导致的任何直接、间接或 consequential 损失承担责任。 ### 第三方服务责任说明 本项目是一个API代理工具,不提供任何AI模型服务。所有AI模型服务由相应的第三方提供商(如Google、OpenAI、Anthropic等)提供。用户在使用本项目访问这些第三方服务时,应遵守各第三方服务的使用条款和政策。作者不对第三方服务的可用性、质量、安全性或合法性承担责任。 diff --git a/README.md b/README.md index 7f8e96203..76d40390a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ logo -# AIClient-2-API(A2)🚀 +# AIClient2API(A2)🚀 **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.** @@ -24,6 +24,9 @@ +--- + + ## 💎 Sponsors *Sponsors are listed in chronological order; all are recommended for registration and use.* @@ -46,7 +49,7 @@
@@ -56,7 +59,7 @@ @@ -106,6 +109,7 @@ >
> Click to expand detailed version history > +> - **2026.05.04 (v3.0.0)** - **Milestone Update: Deep AI Integration & Self-Discovery Architecture**. Added automated Skill guides and remote `/api/help`, `/api/example` endpoints, enabling AI agents to seamlessly understand and operate 50+ full API endpoints; achieved full unification of CLI and REST API output results with enhanced structured JSON support. > - **2026.04.29** - Comprehensive support for OpenAI standard Image Generation (`/v1/images/generations`) and Image Editing (`/v1/images/edits`) interfaces. Supports automatic conversion from OpenAI format to native image generation protocols of various models, fully compatible with provider pool polling and retry mechanisms, significantly improving the stability of multimodal creation. > - **2026.03.02** - Added Grok protocol support, supporting access to xAI Grok series models (Grok 3/4) via Cookie/SSO, supporting multimodal input, image/video generation, automatic token refresh and streaming output > - **2026.01.26** - Added Codex protocol support: supports OpenAI Codex OAuth authorization access @@ -131,6 +135,16 @@ ## 💡 Core Advantages +### 🤖 AI Interaction & Skill Guide + +> **AI-First Design**: This project natively supports efficient interaction with AI Agents (e.g., Claude Code, Cursor, OpenCode). +> +> **💡 Quick Command**: You can tell the AI this sentence directly, and it will automatically master all usage of this project: +> +> ```text +> Please load and learn the Skill in https://raw.githubusercontent.com/justlovemaki/AIClient2API/main/docs/skills/aiclient-cli-usage.md to master all startup parameters and 50+ API endpoints of AIClient2API. +> ``` + ### 🎯 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, 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 @@ -176,7 +190,7 @@ ### 🚀 Quick Start -The most recommended way to use AIClient-2-API is to start it through an automated script and configure it visually directly in the **Web UI console**. +The most recommended way to use AIClient2API is to start it through an automated script and configure it visually directly in the **Web UI console**. #### 🐳 Docker Quick Start (Recommended) @@ -210,13 +224,19 @@ To build from source instead of using the pre-built image, edit `docker-compose. * **Linux/macOS**: `chmod +x install-and-run.sh && ./install-and-run.sh` * **Windows**: Double-click `install-and-run.bat` -> **💡 If the script fails, you can try manually installing dependencies and starting:** +> **💡 Manual installation and startup (supports custom parameters):** > ```bash > npm install +> # Default startup > npm start +> # Show help information +> npm run help +> # Show API calling examples +> npm run example:api +> # Backend-only mode (disable frontend management UI) +> npm start -- --no-ui > ``` - #### 2. Access the console After the server starts, open your browser and visit: 👉 [**http://localhost:3000**](http://localhost:3000) @@ -229,7 +249,7 @@ Go to the **"Configuration"** page, you can: * ✅ Switch default model providers in real-time * ✅ Monitor health status and real-time request logs -#### 4. Local Environment Preparation (Non-Docker Users) +#### 4. Local Environment Preparation (Non-Docker Users)s If you are running directly on your local machine (via script or Node.js) and need to bypass TLS detection for services like Grok, please ensure: * ✅ **Install Go Language**: Go to the [official Go website](https://go.dev/) to download and install (1.20+). * ✅ **Manually Compile Sidecar**: Execute the following command to compile the TLS proxy component: @@ -328,7 +348,7 @@ In the Web UI management interface, you can complete authorization configuration 4. **Important Notice**: Kiro service usage policy has been updated, please visit the official website for the latest usage restrictions and terms #### Kiro Extended Thinking (Claude Models) -AIClient-2-API supports Kiro extended thinking when using Claude-compatible requests (`/v1/messages`) or OpenAI-compatible requests (`/v1/chat/completions`) routed to `claude-kiro-oauth`. +AIClient2API supports Kiro extended thinking when using Claude-compatible requests (`/v1/messages`) or OpenAI-compatible requests (`/v1/chat/completions`) routed to `claude-kiro-oauth`. **Claude-compatible (`/v1/messages`)**: ```bash @@ -749,7 +769,7 @@ The development of this project was greatly inspired by the official Google Gemi ### Contributor List -Thanks to all the developers who contributed to the AIClient-2-API project: +Thanks to all the developers who contributed to the AIClient2API project: [![Contributors](https://contrib.rocks/image?repo=justlovemaki/AIClient-2-API)](https://github.com/justlovemaki/AIClient-2-API/graphs/contributors) @@ -764,7 +784,7 @@ Thanks to all the developers who contributed to the AIClient-2-API project: ## ⚠️ Disclaimer ### Usage Risk Warning -This project (AIClient-2-API) is for learning and research purposes only. Users assume all risks when using this project. The author is not responsible for any direct, indirect, or consequential losses resulting from the use of this project. +This project (AIClient2API) is for learning and research purposes only. Users assume all risks when using this project. The author is not responsible for any direct, indirect, or consequential losses resulting from the use of this project. ### Third-Party Service Responsibility Statement This project is an API proxy tool and does not provide any AI model services. All AI model services are provided by their respective third-party providers (such as Google, OpenAI, Anthropic, etc.). Users should comply with the terms of service and policies of each third-party service when accessing them through this project. The author is not responsible for the availability, quality, security, or legality of third-party services. diff --git a/VERSION b/VERSION index 37b36c19d..56fea8a08 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.16.3 +3.0.0 \ No newline at end of file diff --git a/configs/config.json.example b/configs/config.json.example index d9e55e6e6..be9e6f8ce 100644 --- a/configs/config.json.example +++ b/configs/config.json.example @@ -68,5 +68,6 @@ "LOG_MAX_FILE_SIZE": 10485760, "LOG_MAX_FILES": 10, "TLS_SIDECAR_ENABLED": false, - "TLS_SIDECAR_PORT": 9090 + "TLS_SIDECAR_PORT": 9090, + "UI_ENABLED": true } diff --git a/docker/VERSION b/docker/VERSION index 94f15e9cc..56fea8a08 100644 --- a/docker/VERSION +++ b/docker/VERSION @@ -1 +1 @@ -2.13.1 +3.0.0 \ No newline at end of file diff --git a/docs/OPENCLAW_CONFIG_GUIDE-JA.md b/docs/OPENCLAW_CONFIG_GUIDE-JA.md index d33f60e48..cb22b89bf 100644 --- a/docs/OPENCLAW_CONFIG_GUIDE-JA.md +++ b/docs/OPENCLAW_CONFIG_GUIDE-JA.md @@ -1,12 +1,12 @@ # OpenClaw 設定ガイド -OpenClaw で AIClient-2-API を使用するためのクイック設定ガイド。 +OpenClaw で AIClient2API を使用するためのクイック設定ガイド。 --- ## 前提条件 -1. AIClient-2-API サービスを起動 +1. AIClient2API サービスを起動 2. Web UI (`http://localhost:3000`) で少なくとも1つのプロバイダーを設定 3. 設定ファイルから API Key を記録 4. OpenClaw をインストール @@ -195,7 +195,7 @@ openclaw chat --model aiclient2api/gemini-3-flash-preview "あなたの質問" ## よくある質問 **Q: 接続に失敗しますか?** -- AIClient-2-API サービスが実行中であることを確認 +- AIClient2API サービスが実行中であることを確認 - Base URL が正しいか確認(OpenAI プロトコルには `/v1` サフィックスが必要) - `localhost` の代わりに `127.0.0.1` を使用してみる @@ -204,10 +204,10 @@ openclaw chat --model aiclient2api/gemini-3-flash-preview "あなたの質問" - 環境変数 `AICLIENT2API_KEY` が設定されているか確認 **Q: モデルが利用できない?** -- AIClient-2-API Web UI でプロバイダーが設定されているか確認 +- AIClient2API Web UI でプロバイダーが設定されているか確認 - `openclaw gateway restart` を実行してゲートウェイを再起動 - `openclaw models list` を実行してモデルリストを確認 --- -詳細については、[AIClient-2-API ドキュメント](../README-JA.md) を参照してください +詳細については、[AIClient2API ドキュメント](../README-JA.md) を参照してください diff --git a/docs/OPENCLAW_CONFIG_GUIDE-ZH.md b/docs/OPENCLAW_CONFIG_GUIDE-ZH.md index e45b8cc86..d5770dcea 100644 --- a/docs/OPENCLAW_CONFIG_GUIDE-ZH.md +++ b/docs/OPENCLAW_CONFIG_GUIDE-ZH.md @@ -1,12 +1,12 @@ # OpenClaw 配置指南 -在 OpenClaw 中使用 AIClient-2-API 的快速配置指南。 +在 OpenClaw 中使用 AIClient2API 的快速配置指南。 --- ## 前置准备 -1. 启动 AIClient-2-API 服务 +1. 启动 AIClient2API 服务 2. 在 Web UI (`http://localhost:3000`) 配置至少一个提供商 3. 记录配置文件中的 API Key 4. 安装 OpenClaw @@ -195,7 +195,7 @@ openclaw chat --model aiclient2api/gemini-3-flash-preview "你的问题" ## 常见问题 **Q: 连接失败?** -- 确认 AIClient-2-API 服务运行中 +- 确认 AIClient2API 服务运行中 - 检查 Base URL 是否正确(OpenAI 协议需要 `/v1` 后缀) - 尝试使用 `127.0.0.1` 替代 `localhost` @@ -204,10 +204,10 @@ openclaw chat --model aiclient2api/gemini-3-flash-preview "你的问题" - 确认环境变量 `AICLIENT2API_KEY` 已设置 **Q: 模型不可用?** -- 在 AIClient-2-API Web UI 确认已配置对应提供商 +- 在 AIClient2API Web UI 确认已配置对应提供商 - 运行 `openclaw gateway restart` 重启网关 - 运行 `openclaw models list` 验证模型列表 --- -更多信息请参考 [AIClient-2-API 文档](../README-ZH.md) +更多信息请参考 [AIClient2API 文档](../README-ZH.md) diff --git a/docs/OPENCLAW_CONFIG_GUIDE.md b/docs/OPENCLAW_CONFIG_GUIDE.md index f62529e08..e016c6ce5 100644 --- a/docs/OPENCLAW_CONFIG_GUIDE.md +++ b/docs/OPENCLAW_CONFIG_GUIDE.md @@ -1,12 +1,12 @@ # OpenClaw Configuration Guide -Quick configuration guide for using AIClient-2-API with OpenClaw. +Quick configuration guide for using AIClient2API with OpenClaw. --- ## Prerequisites -1. Start AIClient-2-API service +1. Start AIClient2API service 2. Configure at least one provider in Web UI (`http://localhost:3000`) 3. Note the API Key from configuration file 4. Install OpenClaw @@ -195,7 +195,7 @@ openclaw chat --model aiclient2api/gemini-3-flash-preview "your question" ## FAQ **Q: Connection failed?** -- Confirm AIClient-2-API service is running +- Confirm AIClient2API service is running - Check if Base URL is correct (OpenAI protocol needs `/v1` suffix) - Try using `127.0.0.1` instead of `localhost` @@ -204,10 +204,10 @@ openclaw chat --model aiclient2api/gemini-3-flash-preview "your question" - Confirm environment variable `AICLIENT2API_KEY` is set **Q: Model unavailable?** -- Confirm provider is configured in AIClient-2-API Web UI +- Confirm provider is configured in AIClient2API Web UI - Run `openclaw gateway restart` to restart gateway - Run `openclaw models list` to verify model list --- -For more information, see [AIClient-2-API Documentation](../README.md) +For more information, see [AIClient2API Documentation](../README.md) diff --git a/docs/skills/aiclient-cli-usage.md b/docs/skills/aiclient-cli-usage.md new file mode 100644 index 000000000..28165a59f --- /dev/null +++ b/docs/skills/aiclient-cli-usage.md @@ -0,0 +1,78 @@ +--- +name: aiclient-cli-usage +description: Use when an agent needs to understand, configure, or call the AIClient2API service via its CLI tools or REST APIs. +--- + +# AIClient2API CLI & API Usage Skill + +## Overview +This skill provides instructions for AI agents to interact with the AIClient2API service. It covers how to discover available commands and navigate the exhaustive API surface using both local CLI and remote REST interfaces. + +## Self-Discovery Modes + +### 1. Local Mode (CLI) +Use these when you have shell access to the server environment: +- **Help**: `npm run help` (Add `--json` for structured data) +- **API Guide**: `npm run example:api` (Add `--json` for structured data) + +### 2. Remote Mode (REST API) +Use these when interacting with a running instance over HTTP. **Ask the user for the `Server Address`. Credential requirements vary by category (see below).** +- **Help JSON**: `GET /api/help` (Public, No Auth) +- **API Guide JSON**: `GET /api/example` (Public, No Auth) +- **Plain Text**: Append `?format=text` to either endpoint (e.g., `GET /api/help?format=text`) + +## When to Use +- When you need to understand, configure, or call the AIClient2API service. +- To programmatically manage model providers or account pools. +- To monitor system health, logs, or usage. + +## Core API Categories (AI vs. Management) + +### 1. AI Business Path (`/v1/*`, `/v1beta/*`, `/count_tokens`) +- **Purpose**: AI model inference (chat, image, token counting). +- **Auth**: Static `API Key`. **Ask the user for this if not provided.** +- **Header**: `Authorization: Bearer ` + +### 2. Management Path (`/api/*`, `/health`, `/provider_health`) +- **Purpose**: Server config, node pool management, logs, stats. +- **Auth**: Dynamic `Token` via `/api/login`. **Ask the user for the `Admin Password`.** +- **Header**: `Authorization: Bearer ` (Except for `/api/login` and public health checks). + +## Advanced Patterns + +### Path Routing +Force a specific provider by prefixing the AI business path: +- `/gemini-cli-oauth/v1/chat/completions` +- `/claude-custom/v1/messages` + +### Real-time Logs (SSE) +Subscribe to `GET /api/events` via `EventSource` for live system output. + +## Quick Reference + +| Mode | Command/Endpoint | Format | +|------|------------------|--------| +| Local | `npm run help -- --json` | JSON | +| Local | `npm run example:api` | Text | +| Remote | `GET /api/help` | JSON | +| Remote | `GET /api/example?format=text` | Text | + +## Common Mistakes +- **Wrong Key**: Using the static AI Key for management APIs (causes 401). +- **No Login**: Attempting to fetch `/api/config` without first calling `/api/login`. +- **Format Mismatch**: Expecting JSON from a CLI command without the `--json` flag. + +## Code Example: Management API Flow +```javascript +// 1. Login to get token +const login = await fetch('/api/login', { + method: 'POST', + body: JSON.stringify({ password: 'admin' }) +}); +const { token } = await login.json(); + +// 2. Use token for management +const nodes = await fetch('/api/providers', { + headers: { 'Authorization': `Bearer ${token}` } +}); +``` diff --git a/package.json b/package.json index 5b1b23bc9..a870284de 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,6 @@ { + "name": "aiclient2api", + "version": "3.0.0", "type": "module", "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", @@ -37,6 +39,8 @@ "test:silent": "jest --silent", "test:unit": "node run-tests.js --unit", "test:integration": "node run-tests.js --integration", - "test:summary": "node test-summary.js" + "test:summary": "node test-summary.js", + "help": "node src/scripts/help.js", + "example:api": "node src/scripts/example-api.js" } } diff --git a/src/auth/kiro-oauth.js b/src/auth/kiro-oauth.js index 718f9b4f9..7d64c5fb9 100644 --- a/src/auth/kiro-oauth.js +++ b/src/auth/kiro-oauth.js @@ -587,7 +587,7 @@ function createKiroHttpCallbackServer(port, codeVerifier, expectedState, options method: 'POST', headers: { 'Content-Type': 'application/json', - 'User-Agent': 'AIClient-2-API/1.0.0' + 'User-Agent': 'AIClient2API/1.0.0' }, body: JSON.stringify({ code, diff --git a/src/core/config-manager.js b/src/core/config-manager.js index e333ffe6d..508fcd5d0 100644 --- a/src/core/config-manager.js +++ b/src/core/config-manager.js @@ -116,7 +116,8 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP TLS_SIDECAR_ENABLED_PROVIDERS: [], // 启用 TLS Sidecar 的提供商列表 TLS_SIDECAR_PORT: 9090, // sidecar 监听端口 TLS_SIDECAR_BINARY_PATH: null, // 自定义二进制路径(默认自动搜索) - TLS_SIDECAR_PROXY_URL: null // TLS Sidecar 专用的上游代理地址 + TLS_SIDECAR_PROXY_URL: null, // TLS Sidecar 专用的上游代理地址 + UI_ENABLED: true // 是否启用前端管理界面 }; let currentConfig = { ...defaultConfig }; @@ -160,6 +161,8 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP { flag: '--login-min-interval', configKey: 'LOGIN_MIN_INTERVAL', type: 'int' }, { flag: '--scheduled-health-check-enabled', configKey: 'SCHEDULE_HEALTH_CHECK_ENABLED', type: 'bool' }, { flag: '--scheduled-health-check-interval', configKey: 'SCHEDULE_HEALTH_CHECK_INTERVAL', type: 'int' }, + { flag: '--no-ui', configKey: 'UI_ENABLED', type: 'flag', value: false }, + { flag: '--ui', configKey: 'UI_ENABLED', type: 'bool' } ]; // Parse command-line arguments using definitions @@ -168,13 +171,16 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP const def = flagMap.get(args[i]); if (!def) continue; - if (i + 1 >= args.length) { + if (def.type !== 'flag' && i + 1 >= args.length) { logger.warn(`[Config Warning] ${def.flag} flag requires a value.`); continue; } - const rawValue = args[++i]; + const rawValue = def.type === 'flag' ? null : args[++i]; switch (def.type) { + case 'flag': + currentConfig[def.configKey] = def.value; + break; case 'string': currentConfig[def.configKey] = rawValue; break; diff --git a/src/handlers/request-handler.js b/src/handlers/request-handler.js index d39b473df..01917c03d 100644 --- a/src/handlers/request-handler.js +++ b/src/handlers/request-handler.js @@ -2,6 +2,7 @@ import deepmerge from 'deepmerge'; import logger from '../utils/logger.js'; import { handleError, getClientIp } from '../utils/common.js'; import { handleUIApiRequests, serveStaticFiles } from '../services/ui-manager.js'; +import { isUIPath, isUIApiPath } from '../utils/ui-utils.js'; import { handleAPIRequests } from '../services/api-manager.js'; import { getApiService, getProviderStatus } from '../services/service-manager.js'; import { getProviderPoolManager } from '../services/service-manager.js'; @@ -83,27 +84,43 @@ export function createRequestHandler(config, providerPoolManager) { // 检查是否是插件静态文件 const pluginManager = getPluginManager(); const isPluginStatic = pluginManager.isPluginStaticPath(path); - const pluginStaticOwner = isPluginStatic ? pluginManager.getPluginByStaticPath(path) : null; - if (pluginStaticOwner && !pluginStaticOwner._enabled) { - res.writeHead(503, { 'Content-Type': 'application/json; charset=utf-8' }); - res.end(JSON.stringify({ - success: false, - error: { - message: `插件未启用:${pluginStaticOwner.name}`, - code: 'PLUGIN_DISABLED' - } - })); - return; + + // 如果 UI 已禁用,拦截所有 UI 相关的静态资源请求 + const _isUIPath = isUIPath(path); + + if (!currentConfig.UI_ENABLED) { + if (_isUIPath || isPluginStatic) { + handleError(res, { status: 404, message: 'UI static files are disabled' }, currentConfig.MODEL_PROVIDER, null, req); + return; + } } - if (path.startsWith('/static/') || path === '/' || path === '/favicon.ico' || path === '/index.html' || path.startsWith('/app/') || path.startsWith('/components/') || path === '/login.html' || isPluginStatic) { - const served = await serveStaticFiles(path, res); - if (served) return; + + // 尝试处理 UI 相关的请求 + if (currentConfig.UI_ENABLED) { + // 如果启用了 UI,或者请求的不是 UI 静态资源(可能是 API),则继续 + if (_isUIPath || isPluginStatic) { + const pluginStaticOwner = isPluginStatic ? pluginManager.getPluginByStaticPath(path) : null; + if (pluginStaticOwner && !pluginStaticOwner._enabled) { + res.writeHead(503, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ + success: false, + error: { + message: `插件未启用:${pluginStaticOwner.name}`, + code: 'PLUGIN_DISABLED' + } + })); + return; + } + const served = await serveStaticFiles(path, res); + if (served) return; + } } // 执行插件路由 const pluginRouteHandled = await pluginManager.executeRoutes(method, path, req, res, currentConfig); if (pluginRouteHandled) return; + // 处理 UI API 请求(即使 UI_ENABLED 为 false,API 也保持可用) const uiHandled = await handleUIApiRequests(method, path, req, res, currentConfig, providerPoolManager); if (uiHandled) return; diff --git a/src/providers/claude/claude-kiro.js b/src/providers/claude/claude-kiro.js index 55984e2c8..b33cf2e03 100644 --- a/src/providers/claude/claude-kiro.js +++ b/src/providers/claude/claude-kiro.js @@ -946,7 +946,7 @@ async saveCredentialsToFile(filePath, newData) { const builtInPrefix = ` 你一定不能说自己是 kiro ,这是非常重要的一点。 你只能回答用户的问题,不能回答任何关于 kiro 的问题。 - 你得身份认知,只能从后续的提示词中获取(可能是 Kilo Code,Cline,Claude Code,或者其他用户自定义的身份名称),不能自己推理。如果没有,就说自己是 ”开发者何夕2077,你现在使用的不是原版Claude,而是AIClient-2-API”。 + 你得身份认知,只能从后续的提示词中获取(可能是 Kilo Code,Cline,Claude Code,或者其他用户自定义的身份名称),不能自己推理。如果没有,就说自己是 ”开发者何夕2077,你现在使用的不是原版Claude,而是AIClient2API”。 你是 {{identity}},一名拥有多种编程语言、框架、设计模式和最佳实践丰富知识的高级软件工程师。 diff --git a/src/scripts/example-api.js b/src/scripts/example-api.js new file mode 100644 index 000000000..0a4f4f083 --- /dev/null +++ b/src/scripts/example-api.js @@ -0,0 +1,15 @@ +import { API_GUIDE_DATA, API_EXAMPLES, formatApiGuideText } from '../utils/docs-data.js'; + +const args = process.argv.join(' '); +const isJson = args.includes('--json') || args.includes('-j') || args.includes('--ai') || process.env.npm_config_json; + +if (isJson) { + console.log(JSON.stringify({ + routes: API_GUIDE_DATA, + examples: API_EXAMPLES + }, null, 2)); +} else { + console.log(formatApiGuideText()); + console.log('\n\x1b[33m提示: 运行 npm run example:api -- --json 可获取结构化数据。\x1b[0m\n'); +} + diff --git a/src/scripts/help.js b/src/scripts/help.js new file mode 100644 index 000000000..886d37fba --- /dev/null +++ b/src/scripts/help.js @@ -0,0 +1,10 @@ +import { HELP_DATA, formatHelpText } from '../utils/docs-data.js'; + +const args = process.argv.join(' '); +const isJson = args.includes('--json') || args.includes('-j') || args.includes('--ai') || process.env.npm_config_json; + +if (isJson) { + console.log(JSON.stringify(HELP_DATA, null, 2)); +} else { + console.log(formatHelpText()); +} diff --git a/src/services/api-server.js b/src/services/api-server.js index 76aadbd1d..dcd22a817 100644 --- a/src/services/api-server.js +++ b/src/services/api-server.js @@ -111,6 +111,7 @@ import { HEALTH_CHECK } from '../utils/constants.js'; * --cron-near-minutes OAuth 令牌刷新任务计划的间隔时间(分钟)。 / Interval for OAuth token refresh task in minutes (default: 15) * --cron-refresh-token 是否开启 OAuth 令牌自动刷新任务 / Whether to enable automatic OAuth token refresh task (default: true) * --provider-pools-file 提供商号池配置文件路径 / Path to provider pools configuration file (default: null) + * --no-ui 禁用前端管理界面 / Disable frontend management UI * */ @@ -335,8 +336,8 @@ async function startServer() { logger.info(` • Health check: /health`); logger.info(` • UI Management Console: http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}/`); - // Auto-open browser to UI (only if host is 0.0.0.0 or 127.0.0.1) - // if (CONFIG.HOST === '0.0.0.0' || CONFIG.HOST === '127.0.0.1') { + // Auto-open browser to UI (only if host is 0.0.0.0 or 127.0.0.1 and UI_ENABLED is true) + if (CONFIG.UI_ENABLED) { try { const open = (await import('open')).default; // 作为子进程启动时,需要更长的延迟确保服务完全就绪 @@ -357,7 +358,9 @@ async function startServer() { } catch (err) { logger.info(`[UI] Login page available at: http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}/login.html`); } - // } + } else { + logger.info(`[UI] UI is disabled.`); + } if (CONFIG.CRON_REFRESH_TOKEN) { logger.info(` • Cron Near Minutes: ${CONFIG.CRON_NEAR_MINUTES}`); diff --git a/src/services/ui-manager.js b/src/services/ui-manager.js index c946c64ec..049e43fd0 100644 --- a/src/services/ui-manager.js +++ b/src/services/ui-manager.js @@ -1,5 +1,6 @@ import { existsSync, readFileSync } from 'fs'; import path from 'path'; +import { isUIApiPath } from '../utils/ui-utils.js'; // Import UI modules import * as auth from '../ui-modules/auth.js'; @@ -14,6 +15,7 @@ import * as oauthApi from '../ui-modules/oauth-api.js'; import * as customModelsApi from '../ui-modules/custom-models-api.js'; import * as accessApi from '../ui-modules/access-api.js'; import * as eventBroadcast from '../ui-modules/event-broadcast.js'; +import { HELP_DATA, API_GUIDE_DATA, API_EXAMPLES, formatHelpText, formatApiGuideText } from '../utils/docs-data.js'; // Re-export from event-broadcast module export { broadcastEvent, initializeUIManagement, handleUploadOAuthCredentials, upload } from '../ui-modules/event-broadcast.js'; @@ -65,8 +67,8 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo return await systemApi.handleHealthCheck(req, res); } - // Handle UI management API requests (需要token验证,除了登录接口、健康检查) - if (pathParam.startsWith('/api/') && pathParam !== '/api/login' && pathParam !== '/api/health' && pathParam !== '/api/grok/assets') { + // Handle UI management API requests (需要token验证) + if (isUIApiPath(pathParam)) { // 检查token验证 const isAuth = await auth.checkAuth(req); if (!isAuth) { @@ -347,6 +349,38 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo return await systemApi.handleGetServiceMode(req, res); } + // Help and API guide for remote AI calling + if (method === 'GET' && pathParam === '/api/help') { + const url = new URL(req.url, `http://${req.headers.host}`); + const format = url.searchParams.get('format'); + + if (format === 'text') { + res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end(formatHelpText().replace(/\x1b\[[0-9;]*m/g, '')); // 去掉颜色代码 + } else { + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(HELP_DATA)); + } + return true; + } + + if (method === 'GET' && pathParam === '/api/example') { + const url = new URL(req.url, `http://${req.headers.host}`); + const format = url.searchParams.get('format'); + + if (format === 'text') { + res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end(formatApiGuideText().replace(/\x1b\[[0-9;]*m/g, '')); // 去掉颜色代码 + } else { + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ + routes: API_GUIDE_DATA, + examples: API_EXAMPLES + })); + } + return true; + } + // Batch import Kiro refresh tokens with SSE (real-time progress) if (method === 'POST' && pathParam === '/api/kiro/batch-import-tokens') { return await oauthApi.handleBatchImportKiroTokens(req, res); diff --git a/src/ui-modules/update-api.js b/src/ui-modules/update-api.js index 797c50f11..3840d8b00 100644 --- a/src/ui-modules/update-api.js +++ b/src/ui-modules/update-api.js @@ -10,7 +10,7 @@ import { parseProxyUrl } from '../utils/proxy-utils.js'; import { getRequestBody } from '../utils/common.js'; const execAsync = promisify(exec); -const GITHUB_REPO = 'justlovemaki/AIClient-2-API'; +const GITHUB_REPO = 'justlovemaki/AIClient2API'; function buildGitHubApiCandidates(repo) { const apiPath = `repos/${repo}/tags`; diff --git a/src/utils/constants.js b/src/utils/constants.js index 23e440992..33e024961 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -75,3 +75,15 @@ export const SUPPORTED_IMAGE_MODELS = new Set([ 'grok-imagine-1.0-edit', 'gemini-3.1-flash-image' ]); + +// UI 相关的路径常量 +export const UI_PATHS = { + // 静态文件和基础路径前缀 + STATIC_PREFIXES: ['/static/', '/app/', '/components/'], + // 静态文件精确匹配路径 + STATIC_EXACT: ['/', '/favicon.ico', '/index.html', '/login.html'], + // API 路径前缀 + API_PREFIX: '/api/', + // API 白名单(即使在禁用 UI 时也允许访问) + API_WHITELIST: ['/api/health', '/api/grok/assets', '/api/login', '/api/help', '/api/example'] +}; diff --git a/src/utils/docs-data.js b/src/utils/docs-data.js new file mode 100644 index 000000000..2ce568175 --- /dev/null +++ b/src/utils/docs-data.js @@ -0,0 +1,204 @@ +/** + * AIClient2API 文档数据与格式化中心 (全量版) + * 确保命令行 (CLI) 和远程 API 返回完全一致且详尽的数据结果 + */ + +export const HELP_DATA = { + project: "AIClient2API", + description: "高性能 AI 模型接口聚合代理服务", + usage: [ + { mode: "Master (Default)", cmd: "npm start", desc: "多进程模式,支持子进程监控与自动重启" }, + { mode: "Standalone", cmd: "npm run start:standalone", desc: "单进程模式,直接运行 API 服务" }, + { mode: "Dev", cmd: "npm run start:dev", desc: "开发调试模式,开启详细日志" } + ], + scripts: [ + { os: "Linux/macOS", file: "./install-and-run.sh", desc: "自动安装依赖并启动" }, + { os: "Windows", file: "install-and-run.bat", desc: "自动安装依赖并启动" } + ], + cli_args: [ + { flag: "--host", default: "0.0.0.0", desc: "服务器监听地址" }, + { flag: "--port", default: "3000", desc: "服务器监听端口" }, + { flag: "--api-key", default: "123456", desc: "业务接口身份验证密钥" }, + { flag: "--no-ui", default: "false", desc: "禁用前端管理界面" }, + { flag: "--model-provider", default: "gemini-cli-oauth", desc: "默认模型提供商" }, + { flag: "--system-prompt-file", default: "configs/input_system_prompt.txt", desc: "系统提示词路径" }, + { flag: "--system-prompt-mode", default: "append", desc: "提示词模式 (overwrite/append)" }, + { flag: "--log-prompts", default: "none", desc: "提示词日志模式 (console/file/none)" }, + { flag: "--prompt-log-base-name", default: "prompt_log", desc: "日志文件前缀" }, + { flag: "--request-max-retries", default: "3", desc: "API 请求最大重试次数" }, + { flag: "--cron-refresh-token", default: "false", desc: "是否开启令牌自动刷新" }, + { flag: "--provider-pools-file", default: "configs/provider_pools.json", desc: "号池配置文件路径" }, + { flag: "--max-error-count", default: "10", desc: "账号最大连续错误次数" }, + { flag: "--rate-limit-cooldown-enabled", default: "false", desc: "是否启用 429 冷却" }, + { flag: "--rate-limit-cooldown-ms", default: "30000", desc: "默认冷却时长 (ms)" }, + { flag: "--scheduled-health-check-enabled", default: "false", desc: "是否开启定时健康检查" }, + { flag: "--scheduled-health-check-interval", default: "600000", desc: "检查间隔 (ms)" } + ], + remote_docs: [ + { path: "/api/help", desc: "获取启动帮助信息 (JSON/Text)" }, + { path: "/api/example", desc: "获取 API 资源指南 (JSON/Text)" } + ], + docker: "docker run -d -p 3000:3000 -v \"$(pwd)/configs:/app/configs\" justlikemaki/aiclient2api" +}; + +export const API_GUIDE_DATA = [ + { group: '1. AI 核心业务接口 (AI Business)', routes: [ + { method: 'POST', path: '/v1/chat/completions', desc: 'OpenAI 兼容对话接口 (支持流式)' }, + { method: 'GET', path: '/v1/models', desc: '获取 OpenAI 格式模型列表' }, + { method: 'POST', path: '/v1/images/generations', desc: '图片生成 (OpenAI 标准)' }, + { method: 'POST', path: '/v1/images/edits', desc: '图片编辑/改图 (OpenAI 标准)' }, + { method: 'POST', path: '/v1/messages', desc: 'Claude 兼容消息接口' }, + { method: 'GET', path: '/v1beta/models', desc: 'Gemini 格式模型列表' }, + { method: 'POST', path: '/v1beta/models/{model}:generateContent', desc: 'Gemini 原生生成' }, + { method: 'POST', path: '/v1/responses', desc: 'Codex 专有响应接口' }, + { method: 'POST', path: '/count_tokens', desc: 'Token 计数 (Anthropic 格式)' } + ]}, + { group: '2. 系统状态与鉴权 (System & Auth)', routes: [ + { method: 'POST', path: '/api/login', desc: '管理员登录,获取 Token' }, + { method: 'POST', path: '/api/admin-password', desc: '修改管理员登录密码' }, + { method: 'GET', path: '/api/health', desc: '管理端健康检查' }, + { method: 'GET', path: '/health', desc: '基础健康检查' }, + { method: 'GET', path: '/api/system', desc: '服务器运行状态统计' }, + { method: 'GET', path: '/api/service-mode', desc: '获取运行模式 (Standalone/Master)' }, + { method: 'GET', path: '/api/access-info', desc: '获取接入指南概览' } + ]}, + { group: '3. 配置管理与维护 (Configuration)', routes: [ + { method: 'GET', path: '/api/config', desc: '获取全量配置内容' }, + { method: 'POST', path: '/api/config', desc: '动态更新系统配置' }, + { method: 'POST', path: '/api/reload-config', desc: '热加载配置文件' }, + { method: 'POST', path: '/api/restart-service', desc: '重启 Worker 服务进程' }, + { method: 'GET', path: '/api/events', desc: 'SSE 实时系统日志推送' }, + { method: 'GET', path: '/api/system/download-log', desc: '下载今日系统日志' }, + { method: 'POST', path: '/api/system/clear-log', desc: '清除今日运行日志' } + ]}, + { group: '4. 提供商账号池管理 (Provider Pool)', routes: [ + { method: 'GET', path: '/api/providers', desc: '获取所有提供商及账号状态' }, + { method: 'POST', path: '/api/providers', desc: '新增提供商账号' }, + { method: 'GET', path: '/api/providers/supported', desc: '查看系统支持的类型' }, + { method: 'GET', path: '/api/providers/{type}', desc: '获取特定类型的账号列表' }, + { method: 'PUT', path: '/api/providers/{type}/{uuid}', desc: '更新指定账号详情' }, + { method: 'DELETE', path: '/api/providers/{type}/{uuid}', desc: '从池中删除特定账号' }, + { method: 'POST', path: '/api/providers/{type}/health-check', desc: '触发该类型全量检查' }, + { method: 'POST', path: '/api/providers/{type}/{uuid}/health-check', desc: '触发单账号检查' }, + { method: 'POST', path: '/api/providers/{type}/reset-health', desc: '重置该类型健康状态' }, + { method: 'POST', path: '/api/providers/{type}/{uuid}/detect-models', desc: '探测账号可用模型' }, + { method: 'POST', path: '/api/providers/{type}/{uuid}/enable', desc: '启用特定账号' }, + { method: 'POST', path: '/api/providers/{type}/{uuid}/disable', desc: '禁用特定账号' }, + { method: 'POST', path: '/api/providers/{type}/{uuid}/refresh-uuid', desc: '刷新账号 UUID' }, + { method: 'GET', path: '/api/provider-models', desc: '获取所有可用模型汇总' }, + { method: 'GET', path: '/provider_health', desc: '账号池健康度聚合统计' } + ]}, + { group: '5. 凭据管理与自动化工具 (Credentials)', routes: [ + { method: 'GET', path: '/api/upload-configs', desc: '列出 configs 目录下的文件' }, + { method: 'POST', path: '/api/upload-oauth-credentials', desc: '上传新的 OAuth 凭据' }, + { method: 'GET', path: '/api/upload-configs/view/{file}', desc: '查看特定凭据文件内容' }, + { method: 'DELETE', path: '/api/upload-configs/delete/{file}', desc: '删除特定凭据文件' }, + { method: 'GET', path: '/api/upload-configs/download-all', desc: '打包下载所有配置' }, + { method: 'POST', path: '/api/quick-link-provider', desc: '自动关联本地凭据到账号池' }, + { method: 'POST', path: '/api/providers/{type}/generate-auth-url', desc: '生成 OAuth 授权链接' }, + { method: 'POST', path: '/api/{type}/batch-import-tokens', desc: '批量导入 Refresh Tokens (SSE)' } + ]}, + { group: '6. 插件、更新与扩展 (Plugin & Update)', routes: [ + { method: 'GET', path: '/api/plugins', desc: '列出所有已加载的插件' }, + { method: 'POST', path: '/api/plugins/{name}/toggle', desc: '启用或禁用特定插件' }, + { method: 'GET', path: '/api/custom-models', desc: '获取自定义模型映射' }, + { method: 'POST', path: '/api/custom-models', desc: '新增自定义模型映射' }, + { method: 'PUT/DEL', path: '/api/custom-models/{id}', desc: '更新或删除模型映射' }, + { method: 'GET', path: '/api/check-update', desc: '检查 GitHub 远程更新' }, + { method: 'POST', path: '/api/update', desc: '执行版本自动更新' } + ]}, + { group: '7. 远程文档与自发现接口 (Remote Docs)', routes: [ + { method: 'GET', path: '/api/help', desc: '获取启动帮助信息 (JSON/Text)' }, + { method: 'GET', path: '/api/example', desc: '获取 API 资源指南 (JSON/Text)' } + ]} +]; + +export const API_EXAMPLES = { + ai_api: ` +/** + * 示例 1: 调用 AI 业务接口 (如对话) + * 授权: 使用静态配置的 API Key + */ +async function chat(prompt) { + const res = await fetch('/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer 123456' // 替换为你的 --api-key + }, + body: JSON.stringify({ + model: 'gpt-3.5-turbo', + messages: [{ role: 'user', content: prompt }] + }) + }); + return await res.json(); +}`, + management_api: ` +/** + * 示例 2: 调用管理后台接口 (如修改配置) + * 授权: 先通过 login 获取动态 Token + */ +async function updateConfig(newConfig) { + // 1. 获取管理员 Token + const auth = await fetch('/api/login', { + method: 'POST', + body: JSON.stringify({ password: '你的管理员密码' }) + }); + const { token } = await auth.json(); + + // 2. 带上 Token 调用管理 API + await fetch('/api/config', { + method: 'POST', + headers: { 'Authorization': \`Bearer \${token}\` }, + body: JSON.stringify(newConfig) + }); +}` +}; + +/** + * 格式化帮助信息为文本 (用于 CLI) + */ +export function formatHelpText(data = HELP_DATA) { + let output = `\n\x1b[1m\x1b[35m${data.project} - ${data.description}\x1b[0m\n`; + output += `\n\x1b[36m[ 用法 / Usage ]\x1b[0m\n`; + data.usage.forEach(u => { + output += ` ${u.cmd.padEnd(30)} | ${u.desc}\n`; + }); + output += `\n\x1b[36m[ 启动脚本 / Scripts ]\x1b[0m\n`; + data.scripts.forEach(s => { + output += ` ${s.file.padEnd(30)} | (${s.os}) ${s.desc}\n`; + }); + output += `\n\x1b[36m[ 配置参数 / Arguments ]\x1b[0m\n`; + data.cli_args.forEach(a => { + output += ` ${a.flag.padEnd(30)} | 默认: ${a.default.padEnd(15)} | ${a.desc}\n`; + }); + output += `\n\x1b[36m[ Docker 启动 ]\x1b[0m\n ${data.docker}\n`; + output += `\n\x1b[36m[ 远程文档 API ]\x1b[0m\n`; + data.remote_docs.forEach(d => { + output += ` GET ${d.path.padEnd(26)} | ${d.desc}\n`; + }); + return output; +} + +/** + * 格式化 API 指南为文本 (用于 CLI) + */ +export function formatApiGuideText(data = API_GUIDE_DATA, examples = API_EXAMPLES) { + let output = `\n\x1b[1m\x1b[35mAIClient2API 全量 API 资源指南\x1b[0m\n`; + data.forEach(g => { + output += `\n\x1b[36m[ ${g.group} ]\x1b[0m\n`; + g.routes.forEach(r => { + output += ` \x1b[32m${r.method.padEnd(10)}\x1b[0m ${r.path.padEnd(45)} | ${r.desc}\n`; + }); + }); + + output += `\n\x1b[33m${'='.repeat(100)}\x1b[0m\n`; + output += `\x1b[1m\x1b[33m 前端调用代码实现示例\x1b[0m\n`; + output += `\x1b[33m${'='.repeat(100)}\x1b[0m\n`; + output += examples.ai_api + `\n`; + output += `\x1b[33m${'-'.repeat(100)}\x1b[0m\n`; + output += examples.management_api + `\n`; + + return output; +} + diff --git a/src/utils/ui-utils.js b/src/utils/ui-utils.js new file mode 100644 index 000000000..b1ed000d4 --- /dev/null +++ b/src/utils/ui-utils.js @@ -0,0 +1,34 @@ +/** + * UI 相关的工具函数 + */ +import { UI_PATHS } from './constants.js'; + +/** + * 判断是否为 UI 静态资源路径 + * @param {string} path - 请求路径 + * @returns {boolean} + */ +export function isUIPath(path) { + return UI_PATHS.STATIC_PREFIXES.some(prefix => path.startsWith(prefix)) || + UI_PATHS.STATIC_EXACT.includes(path); +} + +/** + * 判断是否为 UI 管理 API 路径 + * @param {string} path - 请求路径 + * @returns {boolean} + */ +export function isUIApiPath(path) { + // 检查是否以 API 前缀开始,且不在白名单中 + return path.startsWith(UI_PATHS.API_PREFIX) && + !UI_PATHS.API_WHITELIST.includes(path); +} + +/** + * 判断是否为任何形式的 UI 相关路径(资源或 API) + * @param {string} path - 请求路径 + * @returns {boolean} + */ +export function isAnyUIPath(path) { + return isUIPath(path) || isUIApiPath(path); +} From 3d804031858eb4fc06db4a791e650f2d46e5b7d8 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Mon, 4 May 2026 17:31:08 +0800 Subject: [PATCH 089/135] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=87=92?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E4=B8=8E=E9=87=8D=E5=A4=8D=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入懒加载机制,各页面数据仅在首次激活时加载 - 添加 bindOnce 和 markOnce 工具函数避免重复事件绑定 - 在 ApiClient 中合并重复的 GET 请求以减少网络开销 - 修复 Kiro 工具调用 input 解析异常问题 - 更新版本号至 3.0.1 --- VERSION | 2 +- src/core/config-manager.js | 1 + src/providers/claude/claude-kiro.js | 66 +++++++++++---------- src/utils/docs-data.js | 92 ++++++++++++++++------------- static/app/app.js | 39 ++++++++---- static/app/auth.js | 17 +++++- static/app/config-manager.js | 27 +++------ static/app/event-handlers.js | 15 +---- static/app/navigation.js | 46 +++++++++++++-- static/app/playground-manager.js | 10 +++- static/app/plugin-manager.js | 11 +--- static/app/provider-manager.js | 22 +++---- static/app/upload-config-manager.js | 88 +++++++++++---------------- static/app/usage-manager.js | 27 +++++---- static/app/utils.js | 47 ++++++++++++++- 15 files changed, 304 insertions(+), 206 deletions(-) diff --git a/VERSION b/VERSION index 56fea8a08..13d683ccb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.0 \ No newline at end of file +3.0.1 \ No newline at end of file diff --git a/src/core/config-manager.js b/src/core/config-manager.js index 508fcd5d0..55c56fc07 100644 --- a/src/core/config-manager.js +++ b/src/core/config-manager.js @@ -147,6 +147,7 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP { flag: '--system-prompt-mode', configKey: 'SYSTEM_PROMPT_MODE', type: 'enum', validValues: ['overwrite', 'append'] }, { flag: '--host', configKey: 'HOST', type: 'string' }, { flag: '--prompt-log-base-name', configKey: 'PROMPT_LOG_BASE_NAME', type: 'string' }, + { flag: '--request-max-retries', configKey: 'REQUEST_MAX_RETRIES', type: 'int' }, { flag: '--rate-limit-cooldown-enabled', configKey: 'RATE_LIMIT_COOLDOWN_ENABLED', type: 'bool' }, { flag: '--rate-limit-cooldown-ms', configKey: 'RATE_LIMIT_COOLDOWN_MS', type: 'int' }, { flag: '--rate-limit-cooldown-jitter-ms', configKey: 'RATE_LIMIT_COOLDOWN_JITTER_MS', type: 'int' }, diff --git a/src/providers/claude/claude-kiro.js b/src/providers/claude/claude-kiro.js index b33cf2e03..d4e48de89 100644 --- a/src/providers/claude/claude-kiro.js +++ b/src/providers/claude/claude-kiro.js @@ -48,6 +48,23 @@ const KIRO_CONSTANTS = { TOTAL_CONTEXT_TOKENS: 200000, // Claude Sonnet 4.5 actual context is 200K }; +function normalizeKiroToolInput(input) { + if (input === undefined || input === null) { + return ''; + } + if (typeof input === 'string') { + return input; + } + if (typeof input === 'object') { + try { + return JSON.stringify(input); + } catch (e) { + return String(input); + } + } + return String(input); +} + // Per-model context window sizes for accurate token estimation const MODEL_CONTEXT_TOKENS = { "claude-opus-4-7": 1000000, @@ -1496,7 +1513,7 @@ async saveCredentialsToFile(filePath, newData) { }; } if (eventData.input) { - currentToolCallDict.function.arguments += eventData.input; + currentToolCallDict.function.arguments += normalizeKiroToolInput(eventData.input); } if (eventData.stop) { try { @@ -1967,28 +1984,9 @@ async saveCredentialsToFile(filePath, newData) { let searchStart = 0; while (true) { - // 查找真正的 JSON payload 起始位置 - // AWS Event Stream 包含二进制头部,我们只搜索有效的 JSON 模式 - // Kiro 返回格式: {"content":"..."} 或 {"name":"xxx","toolUseId":"xxx",...} 或 {"followupPrompt":"..."} - - // 搜索所有可能的 JSON payload 开头模式 - // Kiro 返回的 toolUse 可能分多个事件: - // 1. {"name":"xxx","toolUseId":"xxx"} - 开始 - // 2. {"input":"..."} - input 数据(可能多次) - // 3. {"stop":true} - 结束 - // 4. {"contextUsagePercentage":...} - 上下文使用百分比(最后一条消息) - const contentStart = remaining.indexOf('{"content":', searchStart); - const nameStart = remaining.indexOf('{"name":', searchStart); - const followupStart = remaining.indexOf('{"followupPrompt":', searchStart); - const inputStart = remaining.indexOf('{"input":', searchStart); - const stopStart = remaining.indexOf('{"stop":', searchStart); - const contextUsageStart = remaining.indexOf('{"contextUsagePercentage":', searchStart); - - // 找到最早出现的有效 JSON 模式 - const candidates = [contentStart, nameStart, followupStart, inputStart, stopStart, contextUsageStart].filter(pos => pos >= 0); - if (candidates.length === 0) break; - - const jsonStart = Math.min(...candidates); + // 查找真正的 JSON payload 起始位置。AWS Event Stream 包含二进制头部, + // payload 对象里的 key 顺序不稳定,所以不能依赖 {"input": 这类固定开头。 + const jsonStart = remaining.indexOf('{', searchStart); if (jsonStart < 0) break; // 正确处理嵌套的 {} - 使用括号计数法 @@ -2052,17 +2050,18 @@ async saveCredentialsToFile(filePath, newData) { data: { name: parsed.name, toolUseId: parsed.toolUseId, - input: parsed.input || '', + input: normalizeKiroToolInput(parsed.input), stop: parsed.stop || false } }); } - // 处理工具调用的 input 续传事件(只有 input 字段) + // 处理工具调用的 input 续传事件(可能包含 toolUseId,且 key 顺序不固定) else if (parsed.input !== undefined && !parsed.name) { events.push({ type: 'toolUseInput', data: { - input: parsed.input + toolUseId: parsed.toolUseId, + input: normalizeKiroToolInput(parsed.input) } }); } @@ -2085,7 +2084,9 @@ async saveCredentialsToFile(filePath, newData) { }); } } catch (e) { - // JSON 解析失败,跳过这个位置继续搜索 + // JSON 解析失败,跳过这个 "{" 继续搜索,避免二进制头部中的偶然字符阻塞后续 payload + searchStart = jsonStart + 1; + continue; } searchStart = jsonEnd + 1; @@ -2620,20 +2621,21 @@ async saveCredentialsToFile(filePath, newData) { } } else if (event.type === 'toolUseInput') { // 工具调用的 input 续传事件 + const inputDelta = normalizeKiroToolInput(event.input); // 统计 input 内容到 totalContent(用于 token 计算) - if (event.input) { - totalContent += event.input; + if (inputDelta) { + totalContent += inputDelta; } if (currentToolCall) { - currentToolCall.input += event.input || ''; + currentToolCall.input += inputDelta; const blockIndex = toolUseBlockIndexes.get(currentToolCall.toolUseId); - if (blockIndex != null && event.input) { + if (blockIndex != null && inputDelta) { yield* pushEvents([{ type: "content_block_delta", index: blockIndex, delta: { type: "input_json_delta", - partial_json: event.input + partial_json: inputDelta } }]); } diff --git a/src/utils/docs-data.js b/src/utils/docs-data.js index 2ce568175..91acee002 100644 --- a/src/utils/docs-data.js +++ b/src/utils/docs-data.js @@ -1,7 +1,7 @@ /** * AIClient2API 文档数据与格式化中心 (全量版) * 确保命令行 (CLI) 和远程 API 返回完全一致且详尽的数据结果 - */ + */ export const HELP_DATA = { project: "AIClient2API", @@ -26,19 +26,21 @@ export const HELP_DATA = { { flag: "--log-prompts", default: "none", desc: "提示词日志模式 (console/file/none)" }, { flag: "--prompt-log-base-name", default: "prompt_log", desc: "日志文件前缀" }, { flag: "--request-max-retries", default: "3", desc: "API 请求最大重试次数" }, - { flag: "--cron-refresh-token", default: "false", desc: "是否开启令牌自动刷新" }, - { flag: "--provider-pools-file", default: "configs/provider_pools.json", desc: "号池配置文件路径" }, - { flag: "--max-error-count", default: "10", desc: "账号最大连续错误次数" }, - { flag: "--rate-limit-cooldown-enabled", default: "false", desc: "是否启用 429 冷却" }, - { flag: "--rate-limit-cooldown-ms", default: "30000", desc: "默认冷却时长 (ms)" }, - { flag: "--scheduled-health-check-enabled", default: "false", desc: "是否开启定时健康检查" }, - { flag: "--scheduled-health-check-interval", default: "600000", desc: "检查间隔 (ms)" } + { flag: "--cron-near-minutes", default: "15", desc: "Token 自动刷新检查间隔 (分钟)" }, + { flag: "--cron-refresh-token", default: "false", desc: "是否开启令牌自动刷新任务" }, + { flag: "--provider-pools-file", default: "configs/provider_pools.json", desc: "提供商号池配置文件路径" }, + { flag: "--custom-models-file", default: "configs/custom_models.json", desc: "自定义模型映射文件路径" }, + { flag: "--max-error-count", default: "10", desc: "单个账号最大连续错误重置次数" }, + { flag: "--rate-limit-cooldown-enabled", default: "false", desc: "是否启用 429 频率限制冷却" }, + { flag: "--rate-limit-cooldown-ms", default: "30000", desc: "基础冷却时长 (ms)" }, + { flag: "--scheduled-health-check-enabled", default: "false", desc: "是否开启定时自动健康检查" }, + { flag: "--scheduled-health-check-interval", default: "600000", desc: "自动检查间隔时间 (ms)" } ], remote_docs: [ { path: "/api/help", desc: "获取启动帮助信息 (JSON/Text)" }, { path: "/api/example", desc: "获取 API 资源指南 (JSON/Text)" } ], - docker: "docker run -d -p 3000:3000 -v \"$(pwd)/configs:/app/configs\" justlikemaki/aiclient2api" + docker: "docker run -d -p 3000:3000 -v \"$(pwd)/configs:/app/configs\" justlikemaki/aiclient-2-api" }; export const API_GUIDE_DATA = [ @@ -60,7 +62,7 @@ export const API_GUIDE_DATA = [ { method: 'GET', path: '/health', desc: '基础健康检查' }, { method: 'GET', path: '/api/system', desc: '服务器运行状态统计' }, { method: 'GET', path: '/api/service-mode', desc: '获取运行模式 (Standalone/Master)' }, - { method: 'GET', path: '/api/access-info', desc: '获取接入指南概览' } + { method: 'GET', path: '/api/access-info', desc: '获取 API 接入配置与账号状态汇总' } ]}, { group: '3. 配置管理与维护 (Configuration)', routes: [ { method: 'GET', path: '/api/config', desc: '获取全量配置内容' }, @@ -72,40 +74,50 @@ export const API_GUIDE_DATA = [ { method: 'POST', path: '/api/system/clear-log', desc: '清除今日运行日志' } ]}, { group: '4. 提供商账号池管理 (Provider Pool)', routes: [ - { method: 'GET', path: '/api/providers', desc: '获取所有提供商及账号状态' }, - { method: 'POST', path: '/api/providers', desc: '新增提供商账号' }, - { method: 'GET', path: '/api/providers/supported', desc: '查看系统支持的类型' }, - { method: 'GET', path: '/api/providers/{type}', desc: '获取特定类型的账号列表' }, - { method: 'PUT', path: '/api/providers/{type}/{uuid}', desc: '更新指定账号详情' }, - { method: 'DELETE', path: '/api/providers/{type}/{uuid}', desc: '从池中删除特定账号' }, - { method: 'POST', path: '/api/providers/{type}/health-check', desc: '触发该类型全量检查' }, - { method: 'POST', path: '/api/providers/{type}/{uuid}/health-check', desc: '触发单账号检查' }, - { method: 'POST', path: '/api/providers/{type}/reset-health', desc: '重置该类型健康状态' }, - { method: 'POST', path: '/api/providers/{type}/{uuid}/detect-models', desc: '探测账号可用模型' }, - { method: 'POST', path: '/api/providers/{type}/{uuid}/enable', desc: '启用特定账号' }, - { method: 'POST', path: '/api/providers/{type}/{uuid}/disable', desc: '禁用特定账号' }, - { method: 'POST', path: '/api/providers/{type}/{uuid}/refresh-uuid', desc: '刷新账号 UUID' }, - { method: 'GET', path: '/api/provider-models', desc: '获取所有可用模型汇总' }, - { method: 'GET', path: '/provider_health', desc: '账号池健康度聚合统计' } + { method: 'GET', path: '/api/providers', desc: '获取所有提供商及账号状态汇总' }, + { method: 'POST', path: '/api/providers', desc: '新增提供商账号配置' }, + { method: 'GET', path: '/api/providers/supported', desc: '获取系统支持的模型提供商类型' }, + { method: 'GET', path: '/api/providers/{type}', desc: '获取指定类型的账号池列表' }, + { method: 'PUT', path: '/api/providers/{type}/{uuid}', desc: '更新指定账号的详细配置' }, + { method: 'DELETE', path: '/api/providers/{type}/{uuid}', desc: '从账号池中删除特定账号' }, + { method: 'DELETE', path: '/api/providers/{type}/delete-unhealthy', desc: '删除该类型下所有不健康的账号' }, + { method: 'POST', path: '/api/providers/{type}/health-check', desc: '触发该类型下所有账号的健康检查' }, + { method: 'POST', path: '/api/providers/{type}/{uuid}/health-check', desc: '触发单个账号的健康检查' }, + { method: 'POST', path: '/api/providers/{type}/reset-health', desc: '重置该类型下所有账号的健康状态' }, + { method: 'POST', path: '/api/providers/{type}/{uuid}/detect-models', desc: '探测特定账号支持的模型列表' }, + { method: 'POST', path: '/api/providers/{type}/refresh-unhealthy-uuids', desc: '刷新该类型下所有不健康账号的 UUID' }, + { method: 'POST', path: '/api/providers/{type}/{uuid}/enable', desc: '启用特定的账号节点' }, + { method: 'POST', path: '/api/providers/{type}/{uuid}/disable', desc: '禁用特定的账号节点' }, + { method: 'POST', path: '/api/providers/{type}/{uuid}/refresh-uuid', desc: '刷新特定账号的 UUID' }, + { method: 'GET', path: '/api/provider-models', desc: '获取所有已配置账号支持的模型汇总' }, + { method: 'GET', path: '/api/usage', desc: '获取全量提供商配额使用统计' }, + { method: 'GET', path: '/api/usage/supported-providers', desc: '获取支持配额查询的提供商列表' }, + { method: 'GET', path: '/api/usage/{type}', desc: '获取指定提供商类型的配额详情' }, + { method: 'GET', path: '/provider_health', desc: '账号池整体健康度聚合统计' } ]}, { group: '5. 凭据管理与自动化工具 (Credentials)', routes: [ - { method: 'GET', path: '/api/upload-configs', desc: '列出 configs 目录下的文件' }, - { method: 'POST', path: '/api/upload-oauth-credentials', desc: '上传新的 OAuth 凭据' }, - { method: 'GET', path: '/api/upload-configs/view/{file}', desc: '查看特定凭据文件内容' }, - { method: 'DELETE', path: '/api/upload-configs/delete/{file}', desc: '删除特定凭据文件' }, - { method: 'GET', path: '/api/upload-configs/download-all', desc: '打包下载所有配置' }, - { method: 'POST', path: '/api/quick-link-provider', desc: '自动关联本地凭据到账号池' }, - { method: 'POST', path: '/api/providers/{type}/generate-auth-url', desc: '生成 OAuth 授权链接' }, - { method: 'POST', path: '/api/{type}/batch-import-tokens', desc: '批量导入 Refresh Tokens (SSE)' } + { method: 'GET', path: '/api/upload-configs', desc: '列出 configs 目录下的所有配置文件' }, + { method: 'POST', path: '/api/upload-oauth-credentials', desc: '上传新的 OAuth 凭据 (JSON)' }, + { method: 'GET', path: '/api/upload-configs/view/{file}', desc: '查看特定凭据文件的内容' }, + { method: 'GET', path: '/api/upload-configs/download/{file}', desc: '下载特定的凭据文件' }, + { method: 'DELETE', path: '/api/upload-configs/delete/{file}', desc: '删除特定的凭据文件' }, + { method: 'DELETE', path: '/api/upload-configs/delete-unbound', desc: '清理未被账号池绑定的凭据文件' }, + { method: 'GET', path: '/api/upload-configs/download-all', desc: '打包下载所有配置文件 (ZIP)' }, + { method: 'POST', path: '/api/quick-link-provider', desc: '自动将本地凭据关联到账号池' }, + { method: 'POST', path: '/api/oauth/manual-callback', desc: '手动提交 OAuth 授权回调数据' }, + { method: 'POST', path: '/api/providers/{type}/generate-auth-url', desc: '生成 OAuth 授权引导链接' }, + { method: 'POST', path: '/api/kiro/import-aws-credentials', desc: '导入 AWS SSO 凭据 (Kiro)' }, + { method: 'POST', path: '/api/{type}/batch-import-tokens', desc: '批量导入 Refresh Tokens (SSE 模式)' } ]}, { group: '6. 插件、更新与扩展 (Plugin & Update)', routes: [ - { method: 'GET', path: '/api/plugins', desc: '列出所有已加载的插件' }, - { method: 'POST', path: '/api/plugins/{name}/toggle', desc: '启用或禁用特定插件' }, - { method: 'GET', path: '/api/custom-models', desc: '获取自定义模型映射' }, - { method: 'POST', path: '/api/custom-models', desc: '新增自定义模型映射' }, - { method: 'PUT/DEL', path: '/api/custom-models/{id}', desc: '更新或删除模型映射' }, - { method: 'GET', path: '/api/check-update', desc: '检查 GitHub 远程更新' }, - { method: 'POST', path: '/api/update', desc: '执行版本自动更新' } + { method: 'GET', path: '/api/plugins', desc: '列出系统已加载的所有插件' }, + { method: 'POST', path: '/api/plugins/{name}/toggle', desc: '启用或禁用特定的插件' }, + { method: 'GET', path: '/api/custom-models', desc: '获取已配置的自定义模型映射' }, + { method: 'POST', path: '/api/custom-models', desc: '新增自定义模型映射规则' }, + { method: 'PUT', path: '/api/custom-models/{id}', desc: '更新指定的模型映射规则' }, + { method: 'DELETE', path: '/api/custom-models/{id}', desc: '删除指定的模型映射规则' }, + { method: 'GET', path: '/api/check-update', desc: '检查 GitHub 仓库的远程更新' }, + { method: 'POST', path: '/api/update', desc: '执行系统版本自动更新' } ]}, { group: '7. 远程文档与自发现接口 (Remote Docs)', routes: [ { method: 'GET', path: '/api/help', desc: '获取启动帮助信息 (JSON/Text)' }, diff --git a/static/app/app.js b/static/app/app.js index f7b440f76..e487bab83 100644 --- a/static/app/app.js +++ b/static/app/app.js @@ -19,7 +19,8 @@ import { } from './file-upload.js'; import { - initNavigation + initNavigation, + setSectionLoaders } from './navigation.js'; import { @@ -38,6 +39,7 @@ import { loadSystemInfo, updateTimeDisplay, loadProviders, + loadProvidersPageData, openProviderManager, showAuthModal, executeGenerateAuthUrl, @@ -77,6 +79,7 @@ import { import { initUsageManager, + loadUsagePageData, refreshUsage } from './usage-manager.js'; @@ -86,6 +89,7 @@ import { import { initPluginManager, + loadPlugins, togglePlugin } from './plugin-manager.js'; @@ -98,26 +102,29 @@ import { } from './custom-models-manager.js'; import { - initPlaygroundManager + initPlaygroundManager, + loadPlaygroundData } from './playground-manager.js'; +let isAppInitialized = false; + /** * 加载初始数据 */ function loadInitialData() { loadSystemInfo(); loadProviders(); - loadConfiguration(); - loadAccessInfo(); - if (window.customModelsManager) { - window.customModelsManager.load(); - } } /** * 初始化应用 */ function initApp() { + if (isAppInitialized) { + return; + } + isAppInitialized = true; + // 设置数据加载器 setDataLoaders(loadInitialData, saveConfiguration); @@ -129,7 +136,21 @@ function initApp() { // 设置配置加载器 setConfigLoaders(loadConfigList); + + setSectionLoaders({ + access: loadAccessInfo, + config: loadConfiguration, + providers: loadProvidersPageData, + 'custom-models': () => window.customModelsManager?.load(), + 'upload-config': loadConfigList, + usage: loadUsagePageData, + plugins: loadPlugins, + playground: loadPlaygroundData + }); + // 初始化自定义模型管理 + window.customModelsManager = new CustomModelsManager(); + // 初始化各个模块 initNavigation(); initEventListeners(); @@ -142,10 +163,6 @@ function initApp() { initPluginManager(); // 初始化插件管理功能 initTutorialManager(); // 初始化教程管理功能 initPlaygroundManager(); // 初始化 Playground - - // 初始化自定义模型管理 - window.customModelsManager = new CustomModelsManager(); - initMobileMenu(); // 初始化移动端菜单 loadInitialData(); diff --git a/static/app/auth.js b/static/app/auth.js index a9fc412e7..fe3a12b65 100644 --- a/static/app/auth.js +++ b/static/app/auth.js @@ -79,6 +79,7 @@ class ApiClient { constructor() { this.authManager = new AuthManager(); this.baseURL = window.location.origin; + this.inflightGetRequests = new Map(); } /** @@ -176,7 +177,19 @@ class ApiClient { async get(endpoint, params = {}) { const queryString = new URLSearchParams(params).toString(); const url = queryString ? `${endpoint}?${queryString}` : endpoint; - return this.request(url, { method: 'GET' }); + const requestKey = `GET ${url}`; + + if (this.inflightGetRequests.has(requestKey)) { + return this.inflightGetRequests.get(requestKey); + } + + const requestPromise = this.request(url, { method: 'GET' }) + .finally(() => { + this.inflightGetRequests.delete(requestKey); + }); + + this.inflightGetRequests.set(requestKey, requestPromise); + return requestPromise; } /** @@ -366,4 +379,4 @@ export { getAuthHeaders }; -console.log('认证模块已加载'); \ No newline at end of file +console.log('认证模块已加载'); diff --git a/static/app/config-manager.js b/static/app/config-manager.js index 0591df22c..530c51466 100644 --- a/static/app/config-manager.js +++ b/static/app/config-manager.js @@ -1,9 +1,9 @@ // 配置管理模块 -import { showToast, formatUptime, copyToClipboard } from './utils.js'; +import { showToast, formatUptime, copyToClipboard, bindOnce } from './utils.js'; import { handleProviderChange, handleGeminiCredsTypeChange, handleKiroCredsTypeChange } from './event-handlers.js'; -import { loadProviders } from './provider-manager.js'; import { t } from './i18n.js'; +import { loadSectionIfActive } from './navigation.js'; // 提供商配置缓存 let currentProviderConfigs = null; @@ -72,18 +72,12 @@ function initConfigPageHelpers() { } const openAccessBtn = document.getElementById('configOpenQuickAccess'); - if (openAccessBtn && !openAccessBtn.dataset.bound) { - openAccessBtn.addEventListener('click', () => navigateToSection('access')); - openAccessBtn.dataset.bound = 'true'; - } + bindOnce(openAccessBtn, 'click', () => navigateToSection('access'), 'configOpenQuickAccess'); const saveAndAccessBtn = document.getElementById('configSaveAndAccess'); - if (saveAndAccessBtn && !saveAndAccessBtn.dataset.bound) { - saveAndAccessBtn.addEventListener('click', async () => { - await saveConfiguration({ navigateToAccess: true }); - }); - saveAndAccessBtn.dataset.bound = 'true'; - } + bindOnce(saveAndAccessBtn, 'click', async () => { + await saveConfiguration({ navigateToAccess: true }); + }, 'configSaveAndAccess'); } /** @@ -116,9 +110,6 @@ function updateConfigProviderConfigs(configs) { if (scheduledHealthCheckProvidersEl) { renderProviderTags(scheduledHealthCheckProvidersEl, configs, false); } - - // 重新加载当前配置以恢复选中状态 - loadConfiguration(); } /** @@ -660,10 +651,8 @@ async function saveConfiguration(options = {}) { updateConfigHandoffSummary(); // 检查当前是否在提供商池管理页面,如果是则刷新数据 - const providersSection = document.getElementById('providers'); - if (providersSection && providersSection.classList.contains('active')) { - // 当前在提供商池页面,刷新数据 - await loadProviders(); + const refreshedProviders = await loadSectionIfActive('providers'); + if (refreshedProviders) { showToast(t('common.success'), t('common.providerPoolRefreshed'), 'success'); } diff --git a/static/app/event-handlers.js b/static/app/event-handlers.js index 4a93d271f..8ed8ba461 100644 --- a/static/app/event-handlers.js +++ b/static/app/event-handlers.js @@ -4,6 +4,7 @@ import { elements, autoScroll, setAutoScroll, clearLogs } from './constants.js'; import { showToast } from './utils.js'; import { t } from './i18n.js'; import { checkUpdate, performUpdate, loadProviders } from './provider-manager.js'; +import { switchSectionIfActive } from './navigation.js'; /** * 初始化所有事件监听器 @@ -447,17 +448,7 @@ function handleProviderPoolsConfigChange(event) { if (providersMenuItem) providersMenuItem.style.display = 'none'; // 如果当前在提供商池页面,切换到仪表盘 - if (providersMenuItem && providersMenuItem.classList.contains('active')) { - const dashboardItem = document.querySelector('.nav-item[data-section="dashboard"]'); - const dashboardSection = document.getElementById('dashboard'); - - // 更新导航状态 - document.querySelectorAll('.nav-item').forEach(nav => nav.classList.remove('active')); - document.querySelectorAll('.section').forEach(section => section.classList.remove('active')); - - if (dashboardItem) dashboardItem.classList.add('active'); - if (dashboardSection) dashboardSection.classList.add('active'); - } + switchSectionIfActive('providers', 'dashboard'); } } @@ -600,4 +591,4 @@ export { handlePasswordToggle, handleProviderPoolsConfigChange, handleProviderPasswordToggle -}; \ No newline at end of file +}; diff --git a/static/app/navigation.js b/static/app/navigation.js index d48666960..8a682af58 100644 --- a/static/app/navigation.js +++ b/static/app/navigation.js @@ -2,6 +2,32 @@ import { elements } from './constants.js'; +let sectionLoaders = {}; + +function setSectionLoaders(loaders = {}) { + sectionLoaders = loaders; +} + +function isSectionActive(sectionId) { + return document.getElementById(sectionId)?.classList.contains('active') === true; +} + +function loadSection(sectionId) { + if (typeof sectionLoaders[sectionId] !== 'function') { + return Promise.resolve(false); + } + + return Promise.resolve(sectionLoaders[sectionId]()).then(() => true); +} + +function loadSectionIfActive(sectionId) { + if (!isSectionActive(sectionId)) { + return Promise.resolve(false); + } + + return loadSection(sectionId); +} + /** * 初始化导航功能 */ @@ -74,10 +100,10 @@ function activateSection(sectionId, options = {}) { window.location.hash = sectionId; } - // 只有在哈希不改变时(例如初始加载、hashchange 事件触发、或点击当前已激活的项)才调用 loadAccessInfo - // 这样可以防止在 hashchange 触发时产生重复请求 - if (sectionId === 'access' && !hashWillChange && typeof window.loadAccessInfo === 'function') { - window.loadAccessInfo(); + // Hash changes will re-enter activateSection through hashchange, so load only when + // the current activation is final. + if (!hashWillChange) { + loadSection(sectionId); } } @@ -89,6 +115,15 @@ function switchToSection(sectionId) { activateSection(sectionId, { updateHash: true }); } +function switchSectionIfActive(currentSectionId, targetSectionId) { + if (isSectionActive(currentSectionId)) { + switchToSection(targetSectionId); + return true; + } + + return false; +} + /** * 滚动到页面顶部 */ @@ -119,6 +154,9 @@ function switchToProviders() { export { initNavigation, + setSectionLoaders, + loadSectionIfActive, + switchSectionIfActive, switchToSection, switchToDashboard, switchToProviders diff --git a/static/app/playground-manager.js b/static/app/playground-manager.js index db1b2541e..be3476f08 100644 --- a/static/app/playground-manager.js +++ b/static/app/playground-manager.js @@ -1,6 +1,7 @@ // Playground 管理模块 import { getAuthHeaders } from './auth.js'; +import { markOnce } from './utils.js'; import { t } from './i18n.js'; let providerModels = {}; // { providerType: [model1, model2, ...] } @@ -34,10 +35,13 @@ function getStreamCheckbox() { return el('pg-stream-checkbox'); } // ── Initialisation ─────────────────────────────────────────────────────────── export function initPlaygroundManager() { - loadProviderData(); bindEvents(); } +export async function loadPlaygroundData() { + return loadProviderData(); +} + async function loadProviderData() { try { const headers = getAuthHeaders(); @@ -82,6 +86,10 @@ function renderProviderOptions(providers) { // ── Events ─────────────────────────────────────────────────────────────────── function bindEvents() { + if (!markOnce(document.body, 'playgroundEvents')) { + return; + } + document.addEventListener('change', (e) => { if (e.target.id === 'pg-provider-select') onProviderChange(e.target.value); }); diff --git a/static/app/plugin-manager.js b/static/app/plugin-manager.js index b0e21cec5..7c5a71c6f 100644 --- a/static/app/plugin-manager.js +++ b/static/app/plugin-manager.js @@ -1,5 +1,5 @@ import { t } from './i18n.js'; -import { showToast, apiRequest } from './utils.js'; +import { showToast, apiRequest, bindOnce } from './utils.js'; // 插件列表状态 let pluginsList = []; @@ -9,12 +9,7 @@ let pluginsList = []; */ export function initPluginManager() { const refreshBtn = document.getElementById('refreshPluginsBtn'); - if (refreshBtn) { - refreshBtn.addEventListener('click', loadPlugins); - } - - // 初始加载 - loadPlugins(); + bindOnce(refreshBtn, 'click', loadPlugins, 'refreshPlugins'); } /** @@ -140,4 +135,4 @@ export async function togglePlugin(pluginName, enabled) { // 恢复开关状态 loadPlugins(); } -} \ No newline at end of file +} diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js index b5ad5c911..29bf6b0cd 100644 --- a/static/app/provider-manager.js +++ b/static/app/provider-manager.js @@ -1,7 +1,7 @@ // 提供商管理功能模块 import { providerStats, updateProviderStats } from './constants.js'; -import { showToast, formatUptime, getProviderConfigs, getBaseProviderConfigs } from './utils.js'; +import { showToast, formatUptime, getProviderConfigs, getBaseProviderConfigs, bindOnce } from './utils.js'; import { fileUploadHandler } from './file-upload.js'; import { t, getCurrentLanguage } from './i18n.js'; import { renderRoutingExamples } from './routing-examples.js'; @@ -32,16 +32,10 @@ function navigateToSection(sectionId) { function initProvidersPageHelpers() { const openAccessBtn = document.getElementById('providersOpenQuickAccess'); - if (openAccessBtn && !openAccessBtn.dataset.bound) { - openAccessBtn.addEventListener('click', () => navigateToSection('access')); - openAccessBtn.dataset.bound = 'true'; - } + bindOnce(openAccessBtn, 'click', () => navigateToSection('access'), 'providersOpenQuickAccess'); const openConfigBtn = document.getElementById('providersOpenConfig'); - if (openConfigBtn && !openConfigBtn.dataset.bound) { - openConfigBtn.addEventListener('click', () => navigateToSection('config')); - openConfigBtn.dataset.bound = 'true'; - } + bindOnce(openConfigBtn, 'click', () => navigateToSection('config'), 'providersOpenConfig'); } function updateProvidersHandoffSummary(providers = {}, supportedProviders = []) { @@ -299,12 +293,19 @@ async function loadProviders(forceRefreshSupported = false) { } renderProviders(providers, cachedSupportedProviders); - await refreshProvidersHandoffSummary(providers, cachedSupportedProviders); + return data; } catch (error) { console.error('Failed to load providers:', error); } } +async function loadProvidersPageData(forceRefreshSupported = false) { + const data = await loadProviders(forceRefreshSupported); + if (data?.providers) { + await refreshProvidersHandoffSummary(data.providers, data.supportedProviders || cachedSupportedProviders || []); + } +} + /** * 渲染提供商列表 * @param {Object} providers - 提供商数据 @@ -4015,6 +4016,7 @@ export { loadSystemInfo, updateTimeDisplay, loadProviders, + loadProvidersPageData, openProviderManager, showAuthModal, executeGenerateAuthUrl, diff --git a/static/app/upload-config-manager.js b/static/app/upload-config-manager.js index 4b3a23df4..f5b2350d6 100644 --- a/static/app/upload-config-manager.js +++ b/static/app/upload-config-manager.js @@ -1,6 +1,6 @@ // 配置管理功能模块 -import { showToast } from './utils.js'; +import { showToast, bindOnce } from './utils.js'; import { t } from './i18n.js'; let allConfigs = []; // 存储所有配置数据 @@ -1002,65 +1002,45 @@ function initUploadConfigManager() { const refreshBtn = document.getElementById('refreshConfigList'); const downloadAllBtn = document.getElementById('downloadAllConfigs'); - if (searchInput) { - searchInput.addEventListener('input', debounce(() => { - const searchTerm = searchInput.value.trim(); - const currentStatusFilter = statusFilter?.value || ''; - const currentProviderFilter = providerFilter?.value || ''; - searchConfigs(searchTerm, currentStatusFilter, currentProviderFilter); - }, 300)); - } - - if (searchBtn) { - searchBtn.addEventListener('click', () => { - const searchTerm = searchInput?.value.trim() || ''; - const currentStatusFilter = statusFilter?.value || ''; - const currentProviderFilter = providerFilter?.value || ''; - // 点击搜索按钮时,调接口刷新数据 - loadConfigList(searchTerm, currentStatusFilter, currentProviderFilter); - }); - } - - if (statusFilter) { - statusFilter.addEventListener('change', () => { - const searchTerm = searchInput?.value.trim() || ''; - const currentStatusFilter = statusFilter.value; - const currentProviderFilter = providerFilter?.value || ''; - searchConfigs(searchTerm, currentStatusFilter, currentProviderFilter); - }); - } - - if (providerFilter) { - providerFilter.addEventListener('change', () => { - const searchTerm = searchInput?.value.trim() || ''; - const currentStatusFilter = statusFilter?.value || ''; - const currentProviderFilter = providerFilter.value; - searchConfigs(searchTerm, currentStatusFilter, currentProviderFilter); - }); - } - - if (refreshBtn) { - refreshBtn.addEventListener('click', () => loadConfigList()); - } - - if (downloadAllBtn) { - downloadAllBtn.addEventListener('click', downloadAllConfigs); - } + bindOnce(searchInput, 'input', debounce(() => { + const searchTerm = searchInput.value.trim(); + const currentStatusFilter = statusFilter?.value || ''; + const currentProviderFilter = providerFilter?.value || ''; + searchConfigs(searchTerm, currentStatusFilter, currentProviderFilter); + }, 300), 'configSearch'); + + bindOnce(searchBtn, 'click', () => { + const searchTerm = searchInput?.value.trim() || ''; + const currentStatusFilter = statusFilter?.value || ''; + const currentProviderFilter = providerFilter?.value || ''; + // 点击搜索按钮时,调接口刷新数据 + loadConfigList(searchTerm, currentStatusFilter, currentProviderFilter); + }, 'configSearchButton'); + + bindOnce(statusFilter, 'change', () => { + const searchTerm = searchInput?.value.trim() || ''; + const currentStatusFilter = statusFilter.value; + const currentProviderFilter = providerFilter?.value || ''; + searchConfigs(searchTerm, currentStatusFilter, currentProviderFilter); + }, 'configStatusFilter'); + + bindOnce(providerFilter, 'change', () => { + const searchTerm = searchInput?.value.trim() || ''; + const currentStatusFilter = statusFilter?.value || ''; + const currentProviderFilter = providerFilter.value; + searchConfigs(searchTerm, currentStatusFilter, currentProviderFilter); + }, 'configProviderFilter'); + + bindOnce(refreshBtn, 'click', () => loadConfigList(), 'refreshConfigList'); + bindOnce(downloadAllBtn, 'click', downloadAllConfigs, 'downloadAllConfigs'); // 批量关联配置按钮 const batchLinkBtn = document.getElementById('batchLinkKiroBtn') || document.getElementById('batchLinkProviderBtn'); - if (batchLinkBtn) { - batchLinkBtn.addEventListener('click', batchLinkProviderConfigs); - } + bindOnce(batchLinkBtn, 'click', batchLinkProviderConfigs, 'batchLinkProvider'); // 删除未绑定配置按钮 const deleteUnboundBtn = document.getElementById('deleteUnboundBtn'); - if (deleteUnboundBtn) { - deleteUnboundBtn.addEventListener('click', deleteUnboundConfigs); - } - - // 初始加载配置列表 - loadConfigList(); + bindOnce(deleteUnboundBtn, 'click', deleteUnboundConfigs, 'deleteUnboundConfigs'); } /** diff --git a/static/app/usage-manager.js b/static/app/usage-manager.js index 685ef57f6..158dc5e59 100644 --- a/static/app/usage-manager.js +++ b/static/app/usage-manager.js @@ -1,6 +1,6 @@ // 用量管理模块 -import { showToast } from './utils.js'; +import { showToast, bindOnce } from './utils.js'; import { getAuthHeaders } from './auth.js'; import { t, getCurrentLanguage } from './i18n.js'; @@ -15,6 +15,7 @@ const PROVIDERS_WITHOUT_USAGE_DISPLAY = [ // 提供商配置缓存 let currentProviderConfigs = null; +let usagePageDataPromise = null; /** * 更新提供商配置 @@ -22,9 +23,6 @@ let currentProviderConfigs = null; */ export function updateUsageProviderConfigs(configs) { currentProviderConfigs = configs; - // 重新触发列表加载,以应用最新的可见性过滤、名称和图标 - loadSupportedProviders(); - loadUsage(); } /** @@ -41,13 +39,22 @@ function shouldShowUsage(providerType) { */ export function initUsageManager() { const refreshBtn = document.getElementById('refreshUsageBtn'); - if (refreshBtn) { - refreshBtn.addEventListener('click', refreshUsage); + bindOnce(refreshBtn, 'click', refreshUsage, 'refreshUsage'); +} + +export function loadUsagePageData() { + if (usagePageDataPromise) { + return usagePageDataPromise; } - - // 初始化时自动加载缓存数据 - loadUsage(); - loadSupportedProviders(); + + usagePageDataPromise = Promise.all([ + loadUsage(), + loadSupportedProviders() + ]).finally(() => { + usagePageDataPromise = null; + }); + + return usagePageDataPromise; } /** diff --git a/static/app/utils.js b/static/app/utils.js index 844a5c5bf..eb0fbab84 100644 --- a/static/app/utils.js +++ b/static/app/utils.js @@ -533,6 +533,47 @@ async function copyToClipboard(text) { } } +/** + * 只为元素绑定一次事件 + * @param {HTMLElement|null} element - 需要绑定事件的元素 + * @param {string} eventName - 事件名称 + * @param {Function} handler - 事件处理函数 + * @param {string} key - 绑定标识,避免同一元素重复绑定同一类事件 + * @returns {boolean} 是否完成了本次绑定 + */ +function bindOnce(element, eventName, handler, key = eventName) { + if (!element) { + return false; + } + + if (!markOnce(element, key)) { + return false; + } + + element.addEventListener(eventName, handler); + return true; +} + +/** + * 为元素记录一次性初始化状态 + * @param {HTMLElement|null} element - 需要标记的元素 + * @param {string} key - 标识名称 + * @returns {boolean} 是否是首次标记 + */ +function markOnce(element, key) { + if (!element) { + return false; + } + + const boundKey = `bound${key.charAt(0).toUpperCase()}${key.slice(1)}`; + if (element.dataset[boundKey]) { + return false; + } + + element.dataset[boundKey] = 'true'; + return true; +} + // 导出所有工具函数 export { formatUptime, @@ -544,5 +585,7 @@ export { getBaseProviderConfigs, getProviderStats, apiRequest, - copyToClipboard -}; \ No newline at end of file + copyToClipboard, + bindOnce, + markOnce +}; From b7809ca05f6458673bb02da0aac0daac099556d3 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Tue, 5 May 2026 12:21:24 +0800 Subject: [PATCH 090/135] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E8=87=B33.0.2=E5=B9=B6=E4=BC=98=E5=8C=96=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=92=8C=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新项目版本号至3.0.2 - 优化中、英、日文README中的快速指令说明,增加服务地址和密码提示 - 在敏感字段识别逻辑中增加"SSO"关键词 - 调整服务状态接口返回字段,包含uuid和isDisabled - 重构技能文档,明确本地与远程模式区别及认证要求 --- README-JA.md | 6 ++++- README-ZH.md | 2 +- README.md | 2 +- VERSION | 2 +- docs/skills/aiclient-cli-usage.md | 39 ++++++++++++++++++++----------- src/services/service-manager.js | 8 ++++--- src/ui-modules/provider-api.js | 4 ++-- 7 files changed, 40 insertions(+), 23 deletions(-) diff --git a/README-JA.md b/README-JA.md index 78130600d..192ad5e75 100644 --- a/README-JA.md +++ b/README-JA.md @@ -142,8 +142,12 @@ > **💡 クイックコマンド**:AI に直接以下の文章を伝えると、本プロジェクトのすべての使用方法を自动的にマスターします: > > ```text -> https://raw.githubusercontent.com/justlovemaki/AIClient2API/main/docs/skills/aiclient-cli-usage.md にある Skill をロードして学習し、AIClient2API のすべての起動パラメータと 50 以上の API エンドポイントの使用方法をマスターしてください。 +> https://raw.githubusercontent.com/justlovemaki/AIClient2API/main/docs/skills/aiclient-cli-usage.md にある Skill をロードして学習し(サービスアドレス:実際のドメインまたは IP、ログインパスワード:実際のパスワード)、AIClient2API のすべての使用方法をマスターしてください。 > ``` +> - **リモートデプロイ**: +> ```text +> https://raw.githubusercontent.com/justlovemaki/AIClient2API/main/docs/skills/aiclient-cli-usage.md にある Skill をロードして学習し(サービスアドレス:実際のドメインまたは IP、ログインパスワード:admin123)、AIClient2API のすべての使用方法をマスターしてください。 +> ``` ### 🎯 統一アクセス、ワンストップ管理 * **マルチモデル統一インターフェース**:標準OpenAI互換プロトコルを通じて、一度の設定でGemini、Claude、Grok、Codex、 K2、MiniMax M2などの主流大規模モデルにアクセス diff --git a/README-ZH.md b/README-ZH.md index 88676c1e1..0f8dfab96 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -140,7 +140,7 @@ > **💡 快速指令**:你可以直接对 AI 说下面这句话,它将自动掌握本项目的所有用法: > > ```text -> 请加载并学习 https://raw.githubusercontent.com/justlovemaki/AIClient2API/main/docs/skills/aiclient-cli-usage.md 中的 Skill,以掌握 AIClient2API 的所有启动参数和 50+ 个 API 接口用法。 +> 请加载并学习 https://raw.githubusercontent.com/justlovemaki/AIClient2API/main/docs/skills/aiclient-cli-usage.md 中的 Skill(服务地址为你的实际域名或 IP,登录密码为你的实际密码),以掌握 AIClient2API 的所有用法。 > ``` ### 🎯 统一接入,一站式管理 diff --git a/README.md b/README.md index 76d40390a..6e7a355cc 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ > **💡 Quick Command**: You can tell the AI this sentence directly, and it will automatically master all usage of this project: > > ```text -> Please load and learn the Skill in https://raw.githubusercontent.com/justlovemaki/AIClient2API/main/docs/skills/aiclient-cli-usage.md to master all startup parameters and 50+ API endpoints of AIClient2API. +> Please load and learn the Skill in https://raw.githubusercontent.com/justlovemaki/AIClient2API/main/docs/skills/aiclient-cli-usage.md (Service Address: your actual domain or IP, Login Password: your actual password) to master all usage of AIClient2API. > ``` ### 🎯 Unified Access, One-Stop Management diff --git a/VERSION b/VERSION index 13d683ccb..d9c62ed92 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.1 \ No newline at end of file +3.0.2 \ No newline at end of file diff --git a/docs/skills/aiclient-cli-usage.md b/docs/skills/aiclient-cli-usage.md index 28165a59f..15698ecbe 100644 --- a/docs/skills/aiclient-cli-usage.md +++ b/docs/skills/aiclient-cli-usage.md @@ -10,16 +10,19 @@ This skill provides instructions for AI agents to interact with the AIClient2API ## Self-Discovery Modes -### 1. Local Mode (CLI) -Use these when you have shell access to the server environment: -- **Help**: `npm run help` (Add `--json` for structured data) -- **API Guide**: `npm run example:api` (Add `--json` for structured data) +**CRITICAL**: Regardless of how you access the service, using REST APIs (for management or AI tasks) requires the `Server Address` and appropriate credentials. **Always ask the user for the `Server Address` (use `http://localhost:3000` as default for local) and `Admin Password` before attempting API-based tasks.** -### 2. Remote Mode (REST API) -Use these when interacting with a running instance over HTTP. **Ask the user for the `Server Address`. Credential requirements vary by category (see below).** +### 1. Local Mode (CLI & REST API) +Use these when you have shell access to the server environment. In this mode, you can use both local CLI tools and REST APIs: +- **CLI Help**: `npm run help` (Add `--json` for structured data) +- **CLI API Guide**: `npm run example:api` (Add `--json` for structured data) +- **REST API**: You can access all endpoints via `http://localhost:` (Default: 3000). + +### 2. Remote Mode (REST API Only) +Use these when interacting with a running instance over the network without shell access: - **Help JSON**: `GET /api/help` (Public, No Auth) - **API Guide JSON**: `GET /api/example` (Public, No Auth) -- **Plain Text**: Append `?format=text` to either endpoint (e.g., `GET /api/help?format=text`) +- **REST API**: Use the user-provided `Server Address`. ## When to Use - When you need to understand, configure, or call the AIClient2API service. @@ -28,6 +31,13 @@ Use these when interacting with a running instance over HTTP. **Ask the user for ## Core API Categories (AI vs. Management) +### 0. Public Endpoints (No Auth Required) +Use these for self-discovery or system monitoring: +- `GET /api/help`: Get full API help documentation (JSON). +- `GET /api/example`: Get API calling examples (JSON). +- `GET /provider_health`: Detailed health status of all model providers. +- `POST /api/login`: Exchange `Admin Password` for a dynamic `Token`. + ### 1. AI Business Path (`/v1/*`, `/v1beta/*`, `/count_tokens`) - **Purpose**: AI model inference (chat, image, token counting). - **Auth**: Static `API Key`. **Ask the user for this if not provided.** @@ -35,7 +45,7 @@ Use these when interacting with a running instance over HTTP. **Ask the user for ### 2. Management Path (`/api/*`, `/health`, `/provider_health`) - **Purpose**: Server config, node pool management, logs, stats. -- **Auth**: Dynamic `Token` via `/api/login`. **Ask the user for the `Admin Password`.** +- **Auth**: Dynamic `Token` via `/api/login`. **Ask the user for the `Server Address` and `Admin Password`.** - **Header**: `Authorization: Bearer ` (Except for `/api/login` and public health checks). ## Advanced Patterns @@ -50,12 +60,13 @@ Subscribe to `GET /api/events` via `EventSource` for live system output. ## Quick Reference -| Mode | Command/Endpoint | Format | -|------|------------------|--------| -| Local | `npm run help -- --json` | JSON | -| Local | `npm run example:api` | Text | -| Remote | `GET /api/help` | JSON | -| Remote | `GET /api/example?format=text` | Text | +| Mode | Method | Command/Endpoint | Format | +|------|--------|------------------|--------| +| Local | CLI | `npm run help -- --json` | JSON | +| Local | CLI | `npm run example:api` | Text | +| Local | REST | `GET http://localhost:3000/api/help` | JSON | +| Remote | REST | `GET /api/help` | JSON | +| Remote | REST | `GET /api/example?format=text` | Text | ## Common Mistakes - **Wrong Key**: Using the static AI Key for management APIs (causes 401). diff --git a/src/services/service-manager.js b/src/services/service-manager.js index 1430bb2b1..3cc41f6ac 100644 --- a/src/services/service-manager.js +++ b/src/services/service-manager.js @@ -563,10 +563,12 @@ export async function getProviderStatus(config, options = {}) { logger.warn('[API Service] Failed to load provider pools:', error.message); } - // providerPoolsSlim 只保留顶级 key 及部分字段,过滤 isDisabled 为 true 的元素 + // providerPoolsSlim 只保留顶级 key 及部分字段 const slimFields = [ + 'uuid', 'customName', 'isHealthy', + 'isDisabled', 'lastErrorTime', 'lastErrorMessage', 'needsRefresh' @@ -620,8 +622,8 @@ export async function getProviderStatus(config, options = {}) { } // identify 字段 if (identifyField && item.hasOwnProperty(identifyField)) { - let tmpCustomName = item.customName ? `${item.customName}` : 'NoCustomName'; - let identifyStr = `${tmpCustomName}::${key}::${item[identifyField]}`; + let tmpCustomName = item.customName ? `${item.customName}` : (item.uuid || 'NoUUID'); + let identifyStr = `${tmpCustomName}::${key}`; slim.identify = identifyStr; } else { slim.identify = null; diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index 6a8c422e2..1d44e5ea5 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -32,9 +32,9 @@ function sanitizeProviderData(provider, maskSensitive = false) { const val = sanitized[key]; if (typeof val !== 'string' || !val) continue; - // 识别敏感字段:包含 KEY, TOKEN, SECRET, PASSWORD, CLEARANCE 等关键词 + // 识别敏感字段:包含 KEY, TOKEN, SSO, SECRET, PASSWORD, CLEARANCE 等关键词 // 同时排除包含 PATH, URL, DIR, ENDPOINT 等关键词的路径/地址字段 - const isSensitive = /API_KEY|TOKEN|SECRET|PASSWORD|CLEARANCE|ACCESS_KEY|credentials/i.test(key); + const isSensitive = /API_KEY|TOKEN|SSO|SECRET|PASSWORD|CLEARANCE|ACCESS_KEY|credentials/i.test(key); const isPath = /PATH|URL|DIR|ENDPOINT|REGION/i.test(key); if (isSensitive && !isPath) { From b8588932b3aaaacc1e2c41e50025437a6b766f74 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Tue, 5 May 2026 12:38:28 +0800 Subject: [PATCH 091/135] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0README?= =?UTF-8?q?=E4=B8=ADAI=E4=BA=A4=E4=BA=92=E6=8C=87=E5=8D=97=E4=BB=A5?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=9C=AC=E5=9C=B0=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将章节标题从“AI交互与Skill指南”更新为“AI优先,Agent交互支持” - 在快速指令部分增加本地模式的使用说明 - 更新支持的AI Agent列表,添加OpenClaw和Hermes - 同步更新中文、英文和日文版本的文档 --- README-JA.md | 18 ++++++++++-------- README-ZH.md | 18 ++++++++++++------ README.md | 18 ++++++++++++------ 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/README-JA.md b/README-JA.md index 192ad5e75..1fe1407f1 100644 --- a/README-JA.md +++ b/README-JA.md @@ -135,18 +135,20 @@ ## 💡 コアアドバンテージ -### 🤖 AI 連携と Skill ガイド +### 🤖 AI 優先、Agent 連携サポート -> **AI 優先設計**:本プロジェクトは、AI Agent(Claude Code, Cursor, OpenCode など)との効率的な連携をネイティブにサポートしています。 +> **AI 優先設計**:本プロジェクトは、OpenClaw, Hermes, Claude Code などの主要な AI Agent との効率的な連携をネイティブにサポートしています。 > > **💡 クイックコマンド**:AI に直接以下の文章を伝えると、本プロジェクトのすべての使用方法を自动的にマスターします: -> -> ```text -> https://raw.githubusercontent.com/justlovemaki/AIClient2API/main/docs/skills/aiclient-cli-usage.md にある Skill をロードして学習し(サービスアドレス:実際のドメインまたは IP、ログインパスワード:実際のパスワード)、AIClient2API のすべての使用方法をマスターしてください。 -> ``` -> - **リモートデプロイ**: +> +> - **リモートデプロイ**: +> ```text +> https://raw.githubusercontent.com/justlovemaki/AIClient2API/main/docs/skills/aiclient-cli-usage.md にある Skill をロードして学習し(サービスアドレス:実際のドメインまたは IP、ログインパスワード:実際のパスワード)、AIClient2API のすべての使用方法をマスターしてください。 +> ``` +> - **ローカルモード**: +> ローカル環境で AI 代理を直接実行している場合は、以下を送信してください: > ```text -> https://raw.githubusercontent.com/justlovemaki/AIClient2API/main/docs/skills/aiclient-cli-usage.md にある Skill をロードして学習し(サービスアドレス:実際のドメインまたは IP、ログインパスワード:admin123)、AIClient2API のすべての使用方法をマスターしてください。 +> docs/skills/aiclient-cli-usage.md にある Skill をロードして学習し、AIClient2API サービスのローカルでの起動、設定、管理をサポートしてください。 > ``` ### 🎯 統一アクセス、ワンストップ管理 diff --git a/README-ZH.md b/README-ZH.md index 0f8dfab96..a3f691ef9 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -133,15 +133,21 @@ ## 💡 核心优势 -### 🤖 AI 交互与 Skill 指南 +### 🤖 AI 优先,Agent 交互支持 -> **AI 优先设计**:本项目原生支持与 AI Agent(如 Claude Code, Cursor, OpenCode)的高效交互。 +> **AI 优先设计**:本项目原生支持与 OpenClaw, Hermes, Claude Code 等主流 AI Agent 的高效交互。 > > **💡 快速指令**:你可以直接对 AI 说下面这句话,它将自动掌握本项目的所有用法: -> -> ```text -> 请加载并学习 https://raw.githubusercontent.com/justlovemaki/AIClient2API/main/docs/skills/aiclient-cli-usage.md 中的 Skill(服务地址为你的实际域名或 IP,登录密码为你的实际密码),以掌握 AIClient2API 的所有用法。 -> ``` +> +> - **远程部署**: +> ```text +> 请加载并学习 https://raw.githubusercontent.com/justlovemaki/AIClient2API/main/docs/skills/aiclient-cli-usage.md 中的 Skill(服务地址为你的实际域名或 IP,登录密码为你的实际密码),以掌握 AIClient2API 的所有用法。 +> ``` +> - **本地模式**: +> 如果你在本地环境直接运行 AI 代理,可以直接发送: +> ```text +> 请加载并学习本项目的 docs/skills/aiclient-cli-usage.md 这一 Skill,以协助我本地启动、配置并管理 AIClient2API 服务。 +> ``` ### 🎯 统一接入,一站式管理 * **多模型统一接口**:通过标准 OpenAI 兼容协议,一次配置即可接入 Gemini、Claude、Grok、Codex、Kimi K2、MiniMax M2 等主流大模型 diff --git a/README.md b/README.md index 6e7a355cc..aa1547e3b 100644 --- a/README.md +++ b/README.md @@ -135,15 +135,21 @@ ## 💡 Core Advantages -### 🤖 AI Interaction & Skill Guide +### 🤖 AI-First, Agent Interaction Support -> **AI-First Design**: This project natively supports efficient interaction with AI Agents (e.g., Claude Code, Cursor, OpenCode). +> **AI-First Design**: This project natively supports efficient interaction with mainstream AI Agents such as OpenClaw, Hermes, and Claude Code. > > **💡 Quick Command**: You can tell the AI this sentence directly, and it will automatically master all usage of this project: -> -> ```text -> Please load and learn the Skill in https://raw.githubusercontent.com/justlovemaki/AIClient2API/main/docs/skills/aiclient-cli-usage.md (Service Address: your actual domain or IP, Login Password: your actual password) to master all usage of AIClient2API. -> ``` +> +> - **Remote Deployment**: +> ```text +> Please load and learn the Skill in https://raw.githubusercontent.com/justlovemaki/AIClient2API/main/docs/skills/aiclient-cli-usage.md (Service Address: your actual domain or IP, Login Password: your actual password) to master all usage of AIClient2API. +> ``` +> - **Local Mode**: +> If you are running the AI agent directly in your local environment, just send: +> ```text +> Please load and learn the Skill in docs/skills/aiclient-cli-usage.md to help me start, configure, and manage the AIClient2API service locally. +> ``` ### 🎯 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, Codex, Kimi K2, MiniMax M2 From 720952802f77f2a006ac4d42e2413b31d5495a49 Mon Sep 17 00:00:00 2001 From: SantaDiegoKairos Date: Wed, 6 May 2026 06:56:47 +0300 Subject: [PATCH 092/135] fix: inherit global proxy settings in health checks Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/providers/provider-pool-manager.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index 5bda298a9..97ab07795 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -2238,9 +2238,11 @@ export class ProviderPoolManager { // ========== 实际 API 健康检查(带超时保护)========== const tempConfig = { + ...this.globalConfig, ...providerConfig, MODEL_PROVIDER: providerType }; + delete tempConfig.providerPools; const serviceAdapter = getServiceAdapter(tempConfig); // 获取所有可能的请求格式 From 3a0de3e9b80df4a38422b5e992d555a609ef568c Mon Sep 17 00:00:00 2001 From: hex2077 Date: Thu, 7 May 2026 12:18:20 +0800 Subject: [PATCH 093/135] =?UTF-8?q?fix(rate-limit):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=A4=84=E7=90=86429=E9=94=99=E8=AF=AF=E7=9A=84=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E9=80=BB=E8=BE=91=E5=B9=B6=E6=94=B9=E8=BF=9B=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E6=8F=90=E7=A4=BA=E5=BA=8F=E5=88=97=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在多个提供商核心中统一处理429错误:优先使用Retry-After头部,否则执行指数退避 - 修复系统提示处理逻辑,支持对象和数组类型的序列化 - 更新OpenAI Responses和Codex转换器以支持缓存令牌计数 - 从README中移除过期的赞助商信息 - 将gemini-antigravity添加到不显示使用量的提供商列表 - 提升getRetryAfterMs函数的错误消息解析能力 --- README-JA.md | 4 + README-ZH.md | 4 + README.md | 4 + VERSION | 2 +- src/converters/strategies/CodexConverter.js | 33 +++++-- .../strategies/OpenAIResponsesConverter.js | 13 ++- src/providers/claude/claude-core.js | 42 +++++--- src/providers/forward/forward-core.js | 35 ++++++- src/providers/gemini/antigravity-core.js | 16 ++- src/providers/gemini/gemini-core.js | 99 ++++++------------- src/providers/grok/grok-core.js | 21 ++-- src/providers/openai/iflow-core.js | 42 +++++--- src/providers/openai/openai-core.js | 42 +++++--- src/providers/openai/openai-responses-core.js | 42 +++++--- src/providers/openai/qwen-core.js | 18 +++- src/utils/common.js | 20 +++- src/utils/provider-strategy.js | 11 ++- static/app/usage-manager.js | 1 + 18 files changed, 292 insertions(+), 157 deletions(-) diff --git a/README-JA.md b/README-JA.md index 1fe1407f1..ea5398c2b 100644 --- a/README-JA.md +++ b/README-JA.md @@ -52,6 +52,7 @@ AICodeMirror の本プロジェクトへのスポンサーシップに感謝します!AICodeMirror は、Claude Code / Codex / Gemini CLI 向けに公式の高安定性リレーサービスを提供しており、企業レベルの同時実行性、迅速な請求書発行、24時間365日の専用技術サポートを備えています。Claude Code / Codex / Gemini の公式チャンネルを、元の価格の 38% / 2% / 9% で利用でき、チャージ時にはさらなる割引もあります!AICodeMirror は AIClient2API ユーザーに特別な特典を提供しています:このリンクから登録すると、初回チャージが 20% オフになり、法人のお客様は最大 25% オフになります!
+ + + + + + + + - - - - + - - - - + - - - - + + 小卖部 + 连接中... diff --git a/static/components/section-plugins.css b/static/components/section-plugins.css index 7924e4a05..798913f49 100644 --- a/static/components/section-plugins.css +++ b/static/components/section-plugins.css @@ -1,3 +1,147 @@ +/* 标签页切换 */ +.tabs-container { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--border-color); + padding-bottom: 0.5rem; +} + +.tab-item { + padding: 0.5rem 1.5rem; + cursor: pointer; + border-radius: var(--radius-md); + font-weight: 600; + color: var(--text-secondary); + transition: var(--transition); +} + +.tab-item:hover { + background: var(--bg-tertiary); + color: var(--primary-color); +} + +.tab-item.active { + background: var(--primary-10); + color: var(--primary-color); +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +.market-panel { + background: var(--bg-primary); + padding: 2rem; + border-radius: 0.5rem; + box-shadow: var(--shadow-md); +} + +.market-controls { + display: flex; + justify-content: flex-end; + margin-bottom: 1.5rem; +} + +.market-list-container { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + overflow: hidden; +} + +/* 支付弹窗样式 */ +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--overlay-bg); + z-index: 2000; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); +} + +.modal.show { + display: flex; + animation: fadeIn 0.3s ease; +} + +.modal-content { + background: var(--bg-primary); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-xl); + width: 100%; + margin: 1.5rem; + overflow: hidden; + transform: translateY(20px); + transition: transform 0.3s ease; +} + +.modal.show .modal-content { + transform: translateY(0); +} + +.modal-header { + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-secondary); +} + +.modal-header h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 700; +} + +.modal-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--text-tertiary); + transition: var(--transition); +} + +.modal-close:hover { + color: var(--danger-color); +} + +.modal-body { + padding: 1.5rem; +} + +.btn-install { + width: 100%; + margin-top: auto; +} + +.installing-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(var(--bg-primary-rgb), 0.8); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + z-index: 10; + border-radius: 0.5rem; +} + /* 插件管理样式 */ .plugins-panel { background: var(--bg-primary); diff --git a/static/components/section-plugins.html b/static/components/section-plugins.html index e9a042356..cb3887181 100644 --- a/static/components/section-plugins.html +++ b/static/components/section-plugins.html @@ -2,65 +2,138 @@

插件管理

-
-
-
- - 插件系统允许您扩展系统功能,启用或禁用插件需要重启服务才能生效 + + +
+
已安装
+
插件市场
+
+ +
+
+
+
+ + 插件系统允许您扩展系统功能,启用或禁用插件需要重启服务才能生效 +
-
- - -
-
-
- + + +
+
+
+ +
+
+

0

+

总插件数

+
+
+
+
+ +
+
+

0

+

已启用

+
-
-

0

-

总插件数

+
+
+ +
+
+

0

+

已禁用

+
-
-
- + + +
+ +
+ + + + + +
+
+
-
-

0

-

已启用

+
+ +

暂无已安装的插件

-
-
- +
+
+ +
+
+
+
+ + 在这里发现并安装官方或社区提供的增强插件
-
-

0

-

已禁用

+
+ +
+
+ + +
+
+ + + +
+
+ +
+
+ +

市场暂无可用插件

- - -
- -
- - - - - -
-
- +
+ + +
diff --git a/static/app/upload-config-manager.js b/static/app/upload-config-manager.js index f5b2350d6..1f09c5e19 100644 --- a/static/app/upload-config-manager.js +++ b/static/app/upload-config-manager.js @@ -1111,6 +1111,12 @@ function detectProviderFromPath(filePath) { displayName: 'OpenAI Codex OAuth', shortName: 'codex-oauth' }, + { + patterns: ['configs/grok-cli/', '/grok-cli/'], + providerType: 'grok-cli-oauth', + displayName: 'Grok CLI OAuth', + shortName: 'grok-cli-oauth' + }, { patterns: ['configs/iflow/', '/iflow/'], providerType: 'openai-iflow', diff --git a/static/app/usage-manager.js b/static/app/usage-manager.js index d3bc7c83c..856b819f3 100644 --- a/static/app/usage-manager.js +++ b/static/app/usage-manager.js @@ -481,7 +481,7 @@ function getProviderDisplayName(type) { const config = currentProviderConfigs.find(c => c.id === type); if (config?.name) return config.name; } - const names = { 'claude-kiro-oauth': 'Claude Kiro', 'gemini-cli-oauth': 'Gemini CLI', 'gemini-antigravity': 'Antigravity', 'openai-codex-oauth': 'Codex', 'grok-web': 'Grok Web' }; + const names = { 'claude-kiro-oauth': 'Claude Kiro', 'gemini-cli-oauth': 'Gemini CLI', 'gemini-antigravity': 'Antigravity', 'openai-codex-oauth': 'Codex', 'grok-cli-oauth': 'Grok CLI', 'grok-web': 'Grok Web' }; return names[type] || type; } @@ -490,7 +490,7 @@ function getProviderIcon(type) { const config = currentProviderConfigs.find(c => c.id === type); if (config?.icon) return config.icon.startsWith('fa-') ? `fas ${config.icon}` : config.icon; } - const icons = { 'claude-kiro-oauth': 'fas fa-robot', 'gemini-cli-oauth': 'fas fa-gem', 'gemini-antigravity': 'fas fa-rocket', 'openai-codex-oauth': 'fas fa-terminal', 'grok-web': 'fas fa-brain' }; + const icons = { 'claude-kiro-oauth': 'fas fa-robot', 'gemini-cli-oauth': 'fas fa-gem', 'gemini-antigravity': 'fas fa-rocket', 'openai-codex-oauth': 'fas fa-terminal', 'grok-cli-oauth': 'fas fa-terminal', 'grok-web': 'fas fa-brain' }; return icons[type] || 'fas fa-server'; } diff --git a/static/app/utils.js b/static/app/utils.js index 92e331f54..2b7223713 100644 --- a/static/app/utils.js +++ b/static/app/utils.js @@ -42,6 +42,12 @@ function getBaseProviderConfigs() { icon: 'fa-code', defaultPath: 'configs/codex/' }, + { + id: 'grok-cli-oauth', + name: t('dashboard.routing.nodeName.grokCli'), + icon: 'fa-terminal', + defaultPath: 'configs/grok-cli/' + }, { id: 'openai-qwen-oauth', name: t('dashboard.routing.nodeName.qwen'), @@ -215,6 +221,7 @@ function getFieldLabel(key) { 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH': t('modal.provider.field.oauthPath'), 'IFLOW_OAUTH_CREDS_FILE_PATH': t('modal.provider.field.oauthPath'), 'CODEX_OAUTH_CREDS_FILE_PATH': t('modal.provider.field.oauthPath'), + 'GROK_CLI_OAUTH_CREDS_FILE_PATH': t('modal.provider.field.oauthPath'), 'GROK_COOKIE_TOKEN': t('modal.provider.field.ssoToken'), 'GROK_CF_CLEARANCE': t('modal.provider.field.cfClearance'), 'GROK_CF_BM': t('modal.provider.field.cfBm'), @@ -431,6 +438,26 @@ function getProviderTypeFields(providerType) { placeholder: 'https://api.openai.com/v1/codex' } ], + 'grok-cli-oauth': [ + { + id: 'GROK_CLI_OAUTH_CREDS_FILE_PATH', + label: t('modal.provider.field.oauthPath'), + type: 'text', + placeholder: 'configs/grok-cli/..._xai-..._oauth_creds.json' + }, + { + id: 'GROK_CLI_EMAIL', + label: `${t('modal.provider.field.email')} ${t('config.optional')}`, + type: 'email', + placeholder: t('modal.provider.field.email.placeholder') + }, + { + id: 'GROK_CLI_BASE_URL', + label: `xAI Base URL ${t('config.optional')}`, + type: 'text', + placeholder: 'https://api.x.ai/v1' + } + ], 'grok-web': [ { id: 'GROK_COOKIE_TOKEN', diff --git a/static/components/section-config.html b/static/components/section-config.html index cce5a77bf..b03a5ccc2 100644 --- a/static/components/section-config.html +++ b/static/components/section-config.html @@ -113,6 +113,10 @@

基础设置

OpenAI Codex OAuth + +
From 9bad556e9888f23ec3e190fe6b4405a158e6f998 Mon Sep 17 00:00:00 2001 From: zouyifan Date: Mon, 27 Apr 2026 09:26:07 -0500 Subject: [PATCH 077/135] feat: enhance image generation request handling with retry logic and improved error management --- src/services/api-manager.js | 130 +++++++++++++++++++++++++----------- 1 file changed, 92 insertions(+), 38 deletions(-) diff --git a/src/services/api-manager.js b/src/services/api-manager.js index a5bfcfd8c..0276a41f4 100644 --- a/src/services/api-manager.js +++ b/src/services/api-manager.js @@ -3,7 +3,8 @@ import { handleContentGenerationRequest, API_ACTIONS, ENDPOINT_TYPE, - getRequestBody + getRequestBody, + getRateLimitCooldownRecoveryTime } from '../utils/common.js'; import { getProviderPoolManager, getApiServiceWithFallback } from './service-manager.js'; import logger from '../utils/logger.js'; @@ -105,45 +106,62 @@ export function initializeAPIManagement(services) { /** * Handle POST /v1/images/generations - OpenAI 标准生图接口 */ -async function handleImageGenerationRequest(req, res, currentConfig, providerPoolManager) { +async function handleImageGenerationRequest(req, res, currentConfig, providerPoolManager, retryContext = null) { const IMAGE_GEN_MAX_N = 4; const VALID_RESPONSE_FORMATS = new Set(['b64_json', 'url']); + const maxRetries = retryContext?.maxRetries ?? 3; + const currentRetry = retryContext?.currentRetry ?? 0; + const CONFIG = retryContext?.CONFIG ?? currentConfig; + let slotProviderType = null; let slotUuid = null; + let model, n, response_format, size, codexRequestBody; try { - const body = await getRequestBody(req); - const { model = 'gpt-image-2', prompt, response_format = 'b64_json', size } = body; - // cap n:至少 1,最多 IMAGE_GEN_MAX_N,非数字降级为 1 - const n = Math.min(Math.max(1, parseInt(body.n) || 1), IMAGE_GEN_MAX_N); - - if (!prompt) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'prompt is required', type: 'invalid_request_error' } })); - return; - } + if (retryContext?.parsedBody) { + ({model, n, response_format, size, codexRequestBody} = retryContext.parsedBody); + } else { + const body = await getRequestBody(req); + model = body.model || 'gpt-image-2'; + response_format = body.response_format || 'b64_json'; + size = body.size; + // cap n:至少 1,最多 IMAGE_GEN_MAX_N,非数字降级为 1 + n = Math.min(Math.max(1, parseInt(body.n) || 1), IMAGE_GEN_MAX_N); + const prompt = body.prompt; - if (!VALID_RESPONSE_FORMATS.has(response_format)) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: `response_format must be 'b64_json' or 'url'`, type: 'invalid_request_error' } })); - return; - } + if (!prompt) { + res.writeHead(400, {'Content-Type': 'application/json'}); + res.end(JSON.stringify({error: {message: 'prompt is required', type: 'invalid_request_error'}})); + return; + } - // 构造 Codex 格式请求,prepareRequestBody 会自动处理 gpt-image-2 → gpt-5.4 + image_generation tool - const codexRequestBody = { - model, - input: [{ - type: 'message', - role: 'user', - content: [{ type: 'input_text', text: prompt }] - }], - ...(size ? { _imageSize: size } : {}) - }; + if (!VALID_RESPONSE_FORMATS.has(response_format)) { + res.writeHead(400, {'Content-Type': 'application/json'}); + res.end(JSON.stringify({ + error: { + message: `response_format must be 'b64_json' or 'url'`, + type: 'invalid_request_error' + } + })); + return; + } + + // 构造 Codex 格式请求,prepareRequestBody 会自动处理 gpt-image-2 → gpt-5.4 + image_generation tool + codexRequestBody = { + model, + input: [{ + type: 'message', + role: 'user', + content: [{type: 'input_text', text: prompt}] + }], + ...(size ? {_imageSize: size} : {}) + }; + } // 从号池获取服务实例,acquireSlot 与其他接口保持一致 - const shouldUsePool = !!(providerPoolManager && currentConfig.providerPools); - const result = await getApiServiceWithFallback(currentConfig, model, { acquireSlot: shouldUsePool }); + const shouldUsePool = !!(providerPoolManager && CONFIG.providerPools); + const result = await getApiServiceWithFallback(CONFIG, model, {acquireSlot: shouldUsePool}); const service = result.service; if (!service) { @@ -154,21 +172,16 @@ async function handleImageGenerationRequest(req, res, currentConfig, providerPoo // 记录 slot 信息,供 finally 释放 if (shouldUsePool && result.uuid) { - slotProviderType = result.actualProviderType || currentConfig.MODEL_PROVIDER; + slotProviderType = result.actualProviderType || CONFIG.MODEL_PROVIDER; slotUuid = result.uuid; } logger.info(`[Image Generation] model=${model}, n=${n}, response_format=${response_format}${size ? `, size=${size}` : ''}`); - // n 张图并发发起,每张独立 generateContent 调用 - const imageRequests = Array.from({ length: n }, () => - service.generateContent(model, { ...codexRequestBody }) - ); - const completedEvents = await Promise.all(imageRequests); - - // 从 response.output 中提取 image_generation_call 结果 + // 串行发起 n 张图请求,每张独立占用一次上游调用,与号池 slot 计数对应 const data = []; - for (const completedEvent of completedEvents) { + for (let i = 0; i < n; i++) { + const completedEvent = await service.generateContent(model, {...codexRequestBody}); const output = completedEvent?.response?.output || []; for (const item of output) { if (item.type === 'image_generation_call' && item.result) { @@ -192,6 +205,47 @@ async function handleImageGenerationRequest(req, res, currentConfig, providerPoo res.end(JSON.stringify({ created: Math.floor(Date.now() / 1000), data })); } catch (error) { logger.error('[Image Generation] Error:', error.message); + + const shouldSwitchCredential = error.shouldSwitchCredential === true; + let credentialMarkedUnhealthy = error.credentialMarkedUnhealthy === true; + + if (providerPoolManager && slotUuid) { + const rateLimitRecoveryTime = getRateLimitCooldownRecoveryTime(error, CONFIG); + if (rateLimitRecoveryTime) { + logger.info(`[Provider Pool] Applying 429 cooldown for ${slotProviderType} (${slotUuid})`); + providerPoolManager.markProviderUnhealthyWithRecoveryTime(slotProviderType, {uuid: slotUuid}, '429 Too Many Requests - short cooldown', rateLimitRecoveryTime); + credentialMarkedUnhealthy = true; + } else if (!credentialMarkedUnhealthy && !error.skipErrorCount) { + if (error.response?.status !== 400) { + logger.info(`[Provider Pool] Marking ${slotProviderType} as unhealthy due to image generation error (status: ${error.response?.status || 'unknown'})`); + providerPoolManager.markProviderUnhealthy(slotProviderType, {uuid: slotUuid}, error.message); + credentialMarkedUnhealthy = true; + } + } + } + + if (shouldSwitchCredential && !credentialMarkedUnhealthy) { + credentialMarkedUnhealthy = true; + } + + if (credentialMarkedUnhealthy && currentRetry < maxRetries && providerPoolManager && CONFIG) { + const randomDelay = Math.floor(Math.random() * 10000); + logger.info(`[Image Generation Retry] Credential marked unhealthy. Waiting ${randomDelay}ms before retry ${currentRetry + 1}/${maxRetries}...`); + await new Promise(resolve => setTimeout(resolve, randomDelay)); + + try { + return await handleImageGenerationRequest(req, res, CONFIG, providerPoolManager, { + ...retryContext, + CONFIG, + currentRetry: currentRetry + 1, + maxRetries, + parsedBody: {model, n, response_format, size, codexRequestBody} + }); + } catch (retryError) { + logger.error('[Image Generation Retry] Failed to get alternative service:', retryError.message); + } + } + if (!res.writableEnded) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { message: error.message, type: 'server_error' } })); From dfee33827eaecd7d2bcead31a1b96f07506fdb12 Mon Sep 17 00:00:00 2001 From: zouyifan Date: Tue, 28 Apr 2026 13:49:13 -0500 Subject: [PATCH 078/135] feat: implement image generation handling with support for image uploads and error management --- static/app/playground-manager.js | 99 +++++++++++++++++++++++++++++++- 1 file changed, 96 insertions(+), 3 deletions(-) diff --git a/static/app/playground-manager.js b/static/app/playground-manager.js index 815648350..0d65495cb 100644 --- a/static/app/playground-manager.js +++ b/static/app/playground-manager.js @@ -159,6 +159,10 @@ function updateInputState() { // ── Chat logic ──────────────────────────────────────────────────────────────── +function isImageGenModel(provider, model) { + return provider === 'openai-codex-oauth' && model.includes('image'); +} + async function handleSend() { if (isStreaming) return; @@ -173,8 +177,8 @@ async function handleSend() { return; } - const userContent = buildUserContent(text, pendingFiles); - messages.push({ role: 'user', content: userContent }); + const isImageModel = isImageGenModel(provider, model); + const filesToSend = [...pendingFiles]; const displayText = [ text, @@ -182,12 +186,21 @@ async function handleSend() { ].filter(Boolean).join('\n'); appendMessage('user', displayText); + if (!isImageModel) { + const userContent = buildUserContent(text, pendingFiles); + messages.push({role: 'user', content: userContent}); + } + if (input) { input.value = ''; input.style.height = 'auto'; } pendingFiles = []; renderAttachmentPreview(); const assistantBubble = appendMessage('assistant', ''); - await streamResponse(provider, model, assistantBubble); + if (isImageModel) { + await imageResponse(provider, model, text, filesToSend, assistantBubble); + } else { + await streamResponse(provider, model, assistantBubble); + } } function buildUserContent(text, files) { @@ -210,6 +223,86 @@ function buildUserContent(text, files) { return parts; } +function dataUrlToBlob(dataUrl) { + const [meta, data] = dataUrl.split(','); + const mime = (meta.match(/:(.*?);/) || [])[1] || 'image/png'; + const bytes = Uint8Array.from(atob(data), c => c.charCodeAt(0)); + return new Blob([bytes], {type: mime}); +} + +async function imageResponse(provider, model, prompt, files, bubble) { + isStreaming = true; + updateInputState(); + + let errorMsg = ''; + + try { + const imageFiles = files.filter(f => f.type.startsWith('image/')); + const hasImages = imageFiles.length > 0; + let response; + + if (hasImages) { + const formData = new FormData(); + formData.append('model', model); + formData.append('prompt', prompt || ''); + formData.append('response_format', 'b64_json'); + imageFiles.forEach(f => formData.append('image', dataUrlToBlob(f.dataUrl), f.name)); + + response = await fetch('/v1/images/edits', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'model-provider': provider + }, + body: formData + }); + } else { + response = await fetch('/v1/images/generations', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + 'model-provider': provider + }, + body: JSON.stringify({model, prompt, response_format: 'b64_json'}) + }); + } + + if (!response.ok) { + const errText = await response.text(); + let msg = `${t('playground.reqFailed')} (${response.status})`; + try { + msg = JSON.parse(errText)?.error?.message || msg; + } catch { + } + throw new Error(msg); + } + + const json = await response.json(); + const images = json.data || []; + if (images.length === 0) throw new Error(t('playground.reqFailed')); + + bubble.innerHTML = images.map((img, i) => { + const src = img.b64_json ? `data:image/png;base64,${img.b64_json}` : img.url; + const alt = escapeHtml(img.revised_prompt ? `generated image ${i + 1}` : 'generated image'); + return `${alt}`; + }).join(''); + + } catch (e) { + console.error('[Playground] Image generation error:', e.message); + errorMsg = e.message || t('playground.reqFailed'); + } finally { + if (errorMsg) { + bubble.textContent = errorMsg; + bubble.closest('.pg-message')?.classList.add('error'); + } + isStreaming = false; + currentAbortController = null; + updateInputState(); + scrollToBottom(); + } +} + async function streamResponse(provider, model, bubble) { isStreaming = true; updateInputState(); From 456f28fa911c133127122d772b8353505d5ba1af Mon Sep 17 00:00:00 2001 From: zouyifan Date: Tue, 28 Apr 2026 13:53:08 -0500 Subject: [PATCH 079/135] feat: add cursor element to playground during image generation and remove on error --- static/app/playground-manager.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/static/app/playground-manager.js b/static/app/playground-manager.js index 0d65495cb..8d7ff9ae7 100644 --- a/static/app/playground-manager.js +++ b/static/app/playground-manager.js @@ -234,6 +234,10 @@ async function imageResponse(provider, model, prompt, files, bubble) { isStreaming = true; updateInputState(); + const cursor = document.createElement('span'); + cursor.className = 'pg-cursor'; + bubble.appendChild(cursor); + let errorMsg = ''; try { @@ -292,6 +296,7 @@ async function imageResponse(provider, model, prompt, files, bubble) { console.error('[Playground] Image generation error:', e.message); errorMsg = e.message || t('playground.reqFailed'); } finally { + cursor.remove(); if (errorMsg) { bubble.textContent = errorMsg; bubble.closest('.pg-message')?.classList.add('error'); From a53722523872697091ce99c33f81c9c6cae8dd64 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Wed, 29 Apr 2026 14:22:09 +0800 Subject: [PATCH 080/135] =?UTF-8?q?feat(playground):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=B5=8B=E8=AF=95=E7=95=8C=E9=9D=A2=E5=B9=B6?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E5=9B=BE=E5=83=8F=E7=94=9F=E6=88=90=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重写 playground 界面为现代化三栏布局,增加系统角色设定和超参数调优面板 - 扩展图像生成接口支持,新增图像生成和编辑接口类型选择 - 改进 CodexConverter 以正确处理图像相关字段和响应格式 - 增强 provider-pool-manager 的配置同步逻辑,注释掉部分错误统计字段 - 修复 Gemini Antigravity 提供商的项目 ID 管理和初始化问题 - 更新国际化文件,添加新的界面翻译文本 - 升级版本号至 2.15.9 --- VERSION | 2 +- src/converters/strategies/CodexConverter.js | 25 +- src/providers/gemini/antigravity-core.js | 7 +- src/providers/provider-pool-manager.js | 4 +- src/services/api-manager.js | 215 +++++-- src/utils/constants.js | 8 + static/app/i18n.js | 20 + static/app/playground-manager.js | 436 +++++++------ static/app/usage-manager.js | 1 - static/components/section-playground.css | 654 ++++++++++++-------- static/components/section-playground.html | 138 +++-- 11 files changed, 965 insertions(+), 545 deletions(-) diff --git a/VERSION b/VERSION index 0580f07ff..327c961a4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.15.8 +2.15.9 diff --git a/src/converters/strategies/CodexConverter.js b/src/converters/strategies/CodexConverter.js index 412041b05..87c2eb788 100644 --- a/src/converters/strategies/CodexConverter.js +++ b/src/converters/strategies/CodexConverter.js @@ -222,6 +222,18 @@ export class CodexConverter extends BaseConverter { return item; }); } + // 确保 text.format 是对象而非字符串 + if (codexRequest.text?.format && typeof codexRequest.text.format === 'string') { + const fmt = codexRequest.text.format; + if (fmt === 'json_object') { + delete codexRequest.text.format; + } else { + codexRequest.text.format = { type: fmt }; + } + } + if (codexRequest.text && Object.keys(codexRequest.text).length === 0) { + delete codexRequest.text; + } return codexRequest; } @@ -248,13 +260,16 @@ export class CodexConverter extends BaseConverter { include: ['reasoning.encrypted_content'] }; - // 保留监控相关字段 + // 保留监控和图片相关字段 if (data._monitorRequestId) { codexRequest._monitorRequestId = data._monitorRequestId; } if (data._requestBaseUrl) { codexRequest._requestBaseUrl = data._requestBaseUrl; } + if (data._imageSize) { + codexRequest._imageSize = data._imageSize; + } codexRequest.service_tier = data.service_tier || 'default'; if (codexRequest.service_tier !== 'priority') { @@ -607,6 +622,14 @@ export class CodexConverter extends BaseConverter { * 转换响应格式 */ convertResponseFormat(responseFormat) { + if (!responseFormat) return null; + + // 如果是字符串(可能是图像接口透传过来的 'url' 或 'b64_json'),忽略它 + // 因为 Codex 的 text.format 期望的是一个对象(如 {type: "text"}) + if (typeof responseFormat === 'string') { + return null; + } + if (responseFormat.type === 'json_schema') { return { type: 'json_schema', diff --git a/src/providers/gemini/antigravity-core.js b/src/providers/gemini/antigravity-core.js index cbcc69b9c..b2a93e492 100644 --- a/src/providers/gemini/antigravity-core.js +++ b/src/providers/gemini/antigravity-core.js @@ -957,6 +957,7 @@ export class AntigravityApiService { // Check if we already have a project ID from the response if (loadResponse.cloudaicompanionProject) { logger.info(`[Antigravity] Discovered existing Project ID: ${loadResponse.cloudaicompanionProject}`); + this.projectId = loadResponse.cloudaicompanionProject; // 获取可用模型 await this.fetchAvailableModels(); return loadResponse.cloudaicompanionProject; @@ -990,6 +991,7 @@ export class AntigravityApiService { const discoveredProjectId = lroResponse.response?.cloudaicompanionProject?.id || initialProjectId; logger.info(`[Antigravity] Onboarded and discovered Project ID: ${discoveredProjectId}`); + this.projectId = discoveredProjectId; // 获取可用模型 await this.fetchAvailableModels(); return discoveredProjectId; @@ -998,6 +1000,7 @@ export class AntigravityApiService { logger.info('[Antigravity] Falling back to generated Project ID as last resort...'); const fallbackProjectId = generateProjectID(); logger.info(`[Antigravity] Generated fallback Project ID: ${fallbackProjectId}`); + this.projectId = fallbackProjectId; // 获取可用模型 await this.fetchAvailableModels(); return fallbackProjectId; @@ -1018,7 +1021,7 @@ export class AntigravityApiService { 'User-Agent': this.userAgent }, responseType: 'json', - body: JSON.stringify({}) + body: JSON.stringify(this.projectId ? { project: this.projectId } : {}) }; const res = await this.authClient.request(requestOptions); @@ -1312,6 +1315,7 @@ export class AntigravityApiService { } async generateContent(model, requestBody) { + if (!this.isInitialized) await this.initialize(); logger.info(`[Antigravity Auth Token] Time until expiry: ${formatExpiryTime(this.authClient.credentials.expiry_date)}`); // 临时存储 monitorRequestId @@ -1390,6 +1394,7 @@ export class AntigravityApiService { } async * generateContentStream(model, requestBody) { + if (!this.isInitialized) await this.initialize(); logger.info(`[Antigravity Auth Token] Time until expiry: ${formatExpiryTime(this.authClient.credentials.expiry_date)}`); // 临时存储 monitorRequestId diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index e788a119f..a9ea5fa59 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -797,8 +797,8 @@ export class ProviderPoolManager { providerConfig.lastUsed = null; providerConfig.usageCount = 0; providerConfig.errorCount = 0; - providerConfig.lastErrorTime = null; - providerConfig.lastErrorMessage = null; + // providerConfig.lastErrorTime = null; + // providerConfig.lastErrorMessage = null; } else if (syncFromConfig) { // 强制同步:从配置中恢复统计数据 providerConfig.lastUsed = providerConfig.lastUsed || null; diff --git a/src/services/api-manager.js b/src/services/api-manager.js index be1c93ceb..ddd18ac62 100644 --- a/src/services/api-manager.js +++ b/src/services/api-manager.js @@ -4,12 +4,15 @@ import { API_ACTIONS, ENDPOINT_TYPE, getRequestBody, - getRateLimitCooldownRecoveryTime + getRateLimitCooldownRecoveryTime, + getProtocolPrefix, + MODEL_PROTOCOL_PREFIX } from '../utils/common.js'; import { getProviderPoolManager, getApiServiceWithFallback } from './service-manager.js'; import logger from '../utils/logger.js'; import busboy from 'busboy'; -import { IMAGE_MODELS as SUPPORTED_IMAGE_MODELS } from '../providers/openai/codex-core.js'; +import { SUPPORTED_IMAGE_MODELS } from '../utils/constants.js'; +import { convertData } from '../convert/convert.js'; const IMAGE_GEN_MAX_N = 4; const VALID_RESPONSE_FORMATS = new Set(['b64_json', 'url']); @@ -125,11 +128,12 @@ async function handleImageGenerationRequest(req, res, currentConfig, providerPoo const CONFIG = retryContext?.CONFIG ?? currentConfig; let slotProviderType = null; let slotUuid = null; - let model, n, response_format, size, codexRequestBody; + let model, n, response_format, size, codexRequestBody, virtualOpenAIRequest; try { if (retryContext?.parsedBody) { - ({model, n, response_format, size, codexRequestBody} = retryContext.parsedBody); + ({model, n, response_format, size, virtualOpenAIRequest} = retryContext.parsedBody); + codexRequestBody = virtualOpenAIRequest; } else { const body = await getRequestBody(req); model = body.model || 'gpt-image-2'; @@ -162,19 +166,21 @@ async function handleImageGenerationRequest(req, res, currentConfig, providerPoo return; } - // 构造 Codex 格式请求,prepareRequestBody 会自动处理 gpt-image-2 → gpt-5.4 + image_generation tool - codexRequestBody = { + // 构造虚拟 OpenAI 对话请求,参考对话接口实现自动转换 + virtualOpenAIRequest = { model, - input: [{ - type: 'message', - role: 'user', - content: [{type: 'input_text', text: prompt}] - }], - ...(size ? {_imageSize: size} : {}) + messages: [{ role: 'user', content: prompt }], + n, + size, + response_format, + _imageSize: size // 兼容 Codex 内部使用的字段 }; + + // 预留变量,在获取到 service 确认协议后再转换 + codexRequestBody = virtualOpenAIRequest; } - // 从号池获取服务实例,acquireSlot 与其他接口保持一致 + // 从号池获取服务实例 const shouldUsePool = !!(providerPoolManager && CONFIG.providerPools); const result = await getApiServiceWithFallback(CONFIG, model, {acquireSlot: shouldUsePool}); const service = result.service; @@ -185,32 +191,35 @@ async function handleImageGenerationRequest(req, res, currentConfig, providerPoo return; } - // 记录 slot 信息,供 finally 释放 + // 记录 slot 信息 if (shouldUsePool && result.uuid) { slotProviderType = result.actualProviderType || CONFIG.MODEL_PROVIDER; slotUuid = result.uuid; } + + const finalProviderProtocol = getProtocolPrefix(slotProviderType || CONFIG.MODEL_PROVIDER); + + // 执行自动转换:OpenAI -> 目标协议 + const fromProtocol = MODEL_PROTOCOL_PREFIX.OPENAI; + if (fromProtocol !== finalProviderProtocol) { + logger.info(`[Image Generation] Converting request from ${fromProtocol} to ${finalProviderProtocol}`); + codexRequestBody = convertData(codexRequestBody, 'request', fromProtocol, slotProviderType || CONFIG.MODEL_PROVIDER); + } - logger.info(`[Image Generation] model=${model}, n=${n}, response_format=${response_format}${size ? `, size=${size}` : ''}`); + logger.info(`[Image Generation] model=${model}, protocol=${finalProviderProtocol}, n=${n}, response_format=${response_format}${size ? `, size=${size}` : ''}`); // 串行发起 n 张图请求,每张独立占用一次上游调用,与号池 slot 计数对应 const data = []; + const responses = []; for (let i = 0; i < n; i++) { - const completedEvent = await service.generateContent(model, {...codexRequestBody}); - const output = completedEvent?.response?.output || []; - for (const item of output) { - if (item.type === 'image_generation_call' && item.result) { - const dataItem = response_format === 'url' - ? { url: `data:image/${item.output_format || 'png'};base64,${item.result}` } - : { b64_json: item.result }; - if (item.revised_prompt) dataItem.revised_prompt = item.revised_prompt; - data.push(dataItem); - } - } + const response = await service.generateContent(model, {...codexRequestBody}); + responses.push(response); + const extracted = extractImagesFromServiceResponse(response, finalProviderProtocol, response_format); + data.push(...extracted); } if (data.length === 0) { - const rejectionText = extractRejectionMessage(completedEvents); + const rejectionText = extractRejectionMessage(responses, finalProviderProtocol); if (rejectionText) { logger.warn(`[Image Generation] Content policy rejection: ${rejectionText.slice(0, 100)}`); res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -261,7 +270,7 @@ async function handleImageGenerationRequest(req, res, currentConfig, providerPoo CONFIG, currentRetry: currentRetry + 1, maxRetries, - parsedBody: {model, n, response_format, size, codexRequestBody} + parsedBody: {model, n, response_format, size, virtualOpenAIRequest} }); } catch (retryError) { logger.error('[Image Generation Retry] Failed to get alternative service:', retryError.message); @@ -281,16 +290,36 @@ async function handleImageGenerationRequest(req, res, currentConfig, providerPoo } /** - * Extract assistant rejection text from Codex output items. + * Extract assistant rejection text from different provider responses. * Returns the text if a policy/safety rejection message is found, otherwise null. */ -function extractRejectionMessage(completedEvents) { - for (const completedEvent of completedEvents) { - const output = completedEvent?.response?.output || []; - for (const item of output) { - if (item.type === 'message' && item.role === 'assistant') { - const textPart = (item.content || []).find(c => c.type === 'output_text' && c.text); - if (textPart?.text) return textPart.text; +function extractRejectionMessage(responses, providerProtocol) { + for (const response of responses) { + // Codex/OpenAI Responses style + if (providerProtocol === MODEL_PROTOCOL_PREFIX.CODEX || providerProtocol === MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES) { + const output = response?.response?.output || response?.output || []; + for (const item of output) { + if (item.type === 'message' && item.role === 'assistant') { + const textPart = (item.content || []).find(c => c.type === 'output_text' && c.text); + if (textPart?.text) return textPart.text; + } + } + } + + // Grok style + if (providerProtocol === MODEL_PROTOCOL_PREFIX.GROK) { + if (response.message) return response.message; + if (response.modelResponse?.message) return response.modelResponse.message; + } + + // Gemini style + if (providerProtocol === MODEL_PROTOCOL_PREFIX.GEMINI) { + const candidates = response?.response?.candidates || response?.candidates || []; + for (const cand of candidates) { + const parts = cand.content?.parts || []; + for (const part of parts) { + if (part.text) return part.text; + } } } } @@ -405,18 +434,20 @@ async function handleImageEditsRequest(req, res, currentConfig, providerPoolMana const {buffer, mimetype} = imageFile; const imageUrl = `data:${mimetype || 'image/png'};base64,${buffer.toString('base64')}`; - // 构造 Codex 请求:input_image + input_text,prepareRequestBody 自动处理 gpt-image-2 → gpt-5.4 + image_generation tool - const codexRequestBody = { + // 构造虚拟 OpenAI 对话请求,参考对话接口实现自动转换 + const virtualOpenAIRequest = { model, - input: [{ - type: 'message', + messages: [{ role: 'user', content: [ - { type: 'input_image', image_url: imageUrl }, - { type: 'input_text', text: prompt } + { type: 'text', text: prompt }, + { type: 'image_url', image_url: { url: imageUrl } } ] }], - ...(size ? { _imageSize: size } : {}) + n, + size, + response_format, + _imageSize: size }; const shouldUsePool = !!(providerPoolManager && currentConfig.providerPools); @@ -434,29 +465,31 @@ async function handleImageEditsRequest(req, res, currentConfig, providerPoolMana slotUuid = result.uuid; } - logger.info(`[Image Edits] model=${model}, n=${n}, response_format=${response_format}, imageSize=${Math.round(buffer.length / 1024)}KB${size ? `, size=${size}` : ''}`); + const finalProviderProtocol = getProtocolPrefix(slotProviderType || currentConfig.MODEL_PROVIDER); + + // 执行自动转换:OpenAI -> 目标协议 + let codexRequestBody = virtualOpenAIRequest; + const fromProtocol = MODEL_PROTOCOL_PREFIX.OPENAI; + if (fromProtocol !== finalProviderProtocol) { + logger.info(`[Image Edits] Converting request from ${fromProtocol} to ${finalProviderProtocol}`); + codexRequestBody = convertData(codexRequestBody, 'request', fromProtocol, slotProviderType || currentConfig.MODEL_PROVIDER); + } + + logger.info(`[Image Edits] model=${model}, protocol=${finalProviderProtocol}, n=${n}, response_format=${response_format}, imageSize=${Math.round(buffer.length / 1024)}KB${size ? `, size=${size}` : ''}`); const imageRequests = Array.from({ length: n }, () => service.generateContent(model, { ...codexRequestBody }) ); - const completedEvents = await Promise.all(imageRequests); + const responses = await Promise.all(imageRequests); const data = []; - for (const completedEvent of completedEvents) { - const output = completedEvent?.response?.output || []; - for (const item of output) { - if (item.type === 'image_generation_call' && item.result) { - const dataItem = response_format === 'url' - ? { url: `data:image/${item.output_format || 'png'};base64,${item.result}` } - : { b64_json: item.result }; - if (item.revised_prompt) dataItem.revised_prompt = item.revised_prompt; - data.push(dataItem); - } - } + for (const response of responses) { + const extracted = extractImagesFromServiceResponse(response, finalProviderProtocol, response_format); + data.push(...extracted); } if (data.length === 0) { - const rejectionText = extractRejectionMessage(completedEvents); + const rejectionText = extractRejectionMessage(responses, finalProviderProtocol); if (rejectionText) { logger.warn(`[Image Edits] Content policy rejection: ${rejectionText.slice(0, 100)}`); res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -484,6 +517,74 @@ async function handleImageEditsRequest(req, res, currentConfig, providerPoolMana } } +/** + * Extract image data from a service's generateContent response. + * Handles different provider output formats. + */ +function extractImagesFromServiceResponse(response, providerProtocol, responseFormat) { + const data = []; + + if (providerProtocol === MODEL_PROTOCOL_PREFIX.CODEX || providerProtocol === MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES) { + const output = response?.response?.output || response?.output || []; + for (const item of output) { + if (item.type === 'image_generation_call' && item.result) { + const dataItem = responseFormat === 'url' + ? { url: `data:image/${item.output_format || 'png'};base64,${item.result}` } + : { b64_json: item.result }; + if (item.revised_prompt) dataItem.revised_prompt = item.revised_prompt; + data.push(dataItem); + } + } + } else if (providerProtocol === MODEL_PROTOCOL_PREFIX.GROK) { + // Grok returns collected object with generatedImageUrls or cardAttachments + const imageUrls = response.generatedImageUrls || []; + for (const url of imageUrls) { + if (responseFormat === 'url') { + data.push({ url }); + } else if (url.startsWith('data:image/')) { + const b64 = url.split(',')[1]; + data.push({ b64_json: b64 }); + } else { + data.push({ url }); + } + } + // Also check cardAttachments for images + const cards = response.cardAttachments || []; + for (const card of cards) { + try { + const jsonData = typeof card.jsonData === 'string' ? JSON.parse(card.jsonData) : card.jsonData; + const imgUrl = jsonData?.image?.original; + if (imgUrl) { + if (responseFormat === 'url') { + data.push({ url: imgUrl }); + } else if (imgUrl.startsWith('data:image/')) { + const b64 = imgUrl.split(',')[1]; + data.push({ b64_json: b64 }); + } else { + data.push({ url: imgUrl }); + } + } + } catch (e) {} + } + } else if (providerProtocol === MODEL_PROTOCOL_PREFIX.GEMINI) { + // Gemini/Antigravity returns candidates with parts containing inlineData (images) + const candidates = response?.response?.candidates || response?.candidates || []; + for (const cand of candidates) { + const parts = cand.content?.parts || []; + for (const part of parts) { + if (part.inlineData) { + const dataItem = responseFormat === 'url' + ? { url: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}` } + : { b64_json: part.inlineData.data }; + data.push(dataItem); + } + } + } + } + + return data; +} + /** * Helper function to read request body * @param {http.IncomingMessage} req The HTTP request object. diff --git a/src/utils/constants.js b/src/utils/constants.js index 0429b6af3..ee80e416a 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -67,3 +67,11 @@ export const MODEL_PROVIDER = { GROK_CUSTOM: 'grok-custom', AUTO: 'auto', }; + +// 图像生成模型常量 +export const SUPPORTED_IMAGE_MODELS = new Set([ + 'gpt-image-2', + 'grok-imagine-1.0', + 'grok-imagine-1.0-edit', + 'gemini-3.1-flash-image' +]); diff --git a/static/app/i18n.js b/static/app/i18n.js index b4b76af89..9ca3b8efc 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -798,6 +798,10 @@ const translations = { 'playground.loading': '加载中...', 'playground.selectProvider': '— 选择提供商 —', 'playground.selectModel': '— 选择模型 —', + 'playground.interface': '接口类型', + 'playground.interface.chat': '对话接口 (Chat)', + 'playground.interface.image': '生图接口 (Generations)', + 'playground.interface.imageEdit': '修图接口 (Edits)', 'playground.providerFirst': '请先选择提供商', 'playground.clearChat': '清空对话', 'playground.emptyHint': '选择提供商和模型后开始对话', @@ -810,6 +814,12 @@ const translations = { 'playground.reqFailed': '请求失败', 'playground.selectFirst': '← 请先在左侧选择提供商和模型', 'playground.generating': '正在生成回复,请稍候...', + 'playground.welcome': '模型测试', + 'playground.systemRole': '系统设定 (System Role)', + 'playground.systemRolePlaceholder': '在此定义 AI 的角色、语气和背景知识...', + 'playground.hyperparameters': '超参数调优 (Hyperparameters)', + 'playground.status.ready': '已就绪', + 'playground.status.unready': '未就绪', // Plugins 'plugins.title': '插件管理', @@ -1899,6 +1909,10 @@ const translations = { 'playground.loading': 'Loading...', 'playground.selectProvider': '— Select Provider —', 'playground.selectModel': '— Select Model —', + 'playground.interface': 'Interface Type', + 'playground.interface.chat': 'Chat Completion', + 'playground.interface.image': 'Image Generation', + 'playground.interface.imageEdit': 'Image Editing', 'playground.providerFirst': 'Select a provider first', 'playground.clearChat': 'Clear Chat', 'playground.emptyHint': 'Select a provider and model to start chatting', @@ -1911,6 +1925,12 @@ const translations = { 'playground.reqFailed': 'Request failed', 'playground.selectFirst': '← Please select a provider and model on the left', 'playground.generating': 'Generating response, please wait...', + 'playground.welcome': 'Playground', + 'playground.systemRole': 'System Role', + 'playground.systemRolePlaceholder': 'Define AI role, tone, and background here...', + 'playground.hyperparameters': 'Hyperparameters', + 'playground.status.ready': 'Ready', + 'playground.status.unready': 'Not Ready', // Plugins 'plugins.title': 'Plugin Management', diff --git a/static/app/playground-manager.js b/static/app/playground-manager.js index 8d7ff9ae7..80fc0b6e5 100644 --- a/static/app/playground-manager.js +++ b/static/app/playground-manager.js @@ -18,11 +18,16 @@ function el(id) { function getProviderSelect() { return el('pg-provider-select'); } function getModelSelect() { return el('pg-model-select'); } +function getInterfaceSelect(){ return el('pg-interface-select'); } function getInput() { return el('pg-input'); } function getSendBtn() { return el('pg-send-btn'); } function getMessages() { return el('pg-messages'); } function getEmpty() { return el('pg-empty'); } function getAttachPreview() { return el('pg-attachments-preview'); } +function getSystemInput() { return el('pg-system-input'); } +function getTempSlider() { return el('pg-temp-slider'); } +function getTempVal() { return el('pg-temp-val'); } +function getMaxTokens() { return el('pg-max-tokens'); } // ── Initialisation ─────────────────────────────────────────────────────────── @@ -52,7 +57,6 @@ async function loadProviderData() { } catch (e) { console.error('[Playground] Failed to load provider data:', e); } finally { - // re-evaluate send button state after data loads updateInputState(); } } @@ -81,7 +85,16 @@ function bindEvents() { }); document.addEventListener('change', (e) => { - if (e.target.id === 'pg-model-select') updateInputState(); + if (e.target.id === 'pg-model-select') { + updateInputState(); + } + }); + + document.addEventListener('input', (e) => { + if (e.target.id === 'pg-temp-slider') { + const val = getTempVal(); + if (val) val.textContent = e.target.value; + } }); document.addEventListener('keydown', (e) => { @@ -94,7 +107,7 @@ function bindEvents() { document.addEventListener('input', (e) => { if (e.target.id === 'pg-input') { e.target.style.height = 'auto'; - e.target.style.height = Math.min(e.target.scrollHeight, 160) + 'px'; + e.target.style.height = Math.min(e.target.scrollHeight, 240) + 'px'; } }); @@ -135,91 +148,92 @@ function onProviderChange(providerType) { function updateInputState() { const provider = getProviderSelect()?.value; const model = getModelSelect()?.value; - const selected = !!(provider && model); - const ready = selected && !isStreaming; + const ready = !!(provider && model && !isStreaming); const input = getInput(); const sendBtn = getSendBtn(); if (input) input.disabled = !ready; if (sendBtn) sendBtn.disabled = !ready; - const inputArea = document.querySelector('.playground-input-area'); - const hint = el('pg-hint'); - if (inputArea) inputArea.classList.toggle('pg-input-disabled', !ready); - if (hint) { + // Update status indicator + const indicator = el('pg-active-indicator'); + const statusText = el('pg-status-text'); + if (indicator && statusText) { if (ready) { - hint.textContent = t('playground.hint'); - } else if (isStreaming) { - hint.textContent = t('playground.generating'); + indicator.className = 'pg-indicator active'; + statusText.textContent = t('playground.status.ready'); } else { - hint.textContent = t('playground.selectFirst'); + indicator.className = 'pg-indicator inactive'; + statusText.textContent = isStreaming ? t('playground.generating') : t('playground.status.unready'); } } } // ── Chat logic ──────────────────────────────────────────────────────────────── -function isImageGenModel(provider, model) { - return provider === 'openai-codex-oauth' && model.includes('image'); -} - async function handleSend() { if (isStreaming) return; const provider = getProviderSelect()?.value; const model = getModelSelect()?.value; + const interfaceType = getInterfaceSelect()?.value || 'chat'; const input = getInput(); const text = input?.value.trim(); if (!provider || !model || (!text && pendingFiles.length === 0)) return; - if (!apiKey) { - console.warn('[Playground] API key not loaded yet, aborting send'); - return; - } + if (!apiKey) return; + + const sysPrompt = getSystemInput()?.value.trim(); + const temp = parseFloat(getTempSlider()?.value || '0.7'); + const maxTokens = parseInt(getMaxTokens()?.value || '4096'); + + // Build history for request + const requestMessages = []; + if (sysPrompt) requestMessages.push({ role: 'system', content: sysPrompt }); + messages.slice(-20).forEach(m => requestMessages.push(m)); - const isImageModel = isImageGenModel(provider, model); const filesToSend = [...pendingFiles]; + const userContent = buildUserContent(text, filesToSend); + requestMessages.push({ role: 'user', content: userContent }); + // UI: User message const displayText = [ text, - ...pendingFiles.map(f => `${t('playground.attachPrefix')}${f.name}]`) + ...filesToSend.map(f => `[附件: ${f.name}]`) ].filter(Boolean).join('\n'); appendMessage('user', displayText); - if (!isImageModel) { - const userContent = buildUserContent(text, pendingFiles); - messages.push({role: 'user', content: userContent}); - } + // Save to history + messages.push({ role: 'user', content: userContent }); if (input) { input.value = ''; input.style.height = 'auto'; } pendingFiles = []; renderAttachmentPreview(); const assistantBubble = appendMessage('assistant', ''); - if (isImageModel) { - await imageResponse(provider, model, text, filesToSend, assistantBubble); + + if (interfaceType === 'image' || interfaceType === 'image-edit') { + await imageResponse(provider, model, text, filesToSend, assistantBubble, interfaceType); } else { - await streamResponse(provider, model, assistantBubble); + await streamResponse(provider, model, assistantBubble, { + messages: requestMessages, + temperature: temp, + max_tokens: maxTokens + }); } } function buildUserContent(text, files) { if (files.length === 0) return text; - const parts = []; if (text) parts.push({ type: 'text', text }); - files.forEach(f => { if (f.type.startsWith('image/')) { - parts.push({ - type: 'image_url', - image_url: { url: f.dataUrl } - }); + parts.push({ type: 'image_url', image_url: { url: f.dataUrl } }); } else { parts.push({ type: 'text', text: `[File: ${f.name}]\n${f.dataUrl}` }); } }); - return parts; } @@ -230,22 +244,28 @@ function dataUrlToBlob(dataUrl) { return new Blob([bytes], {type: mime}); } -async function imageResponse(provider, model, prompt, files, bubble) { +async function imageResponse(provider, model, prompt, files, bubble, interfaceType) { isStreaming = true; updateInputState(); + + const msgWrapper = bubble.closest('.pg-message'); + if (msgWrapper) msgWrapper.style.display = 'flex'; // Image response doesn't need to hide - const cursor = document.createElement('span'); - cursor.className = 'pg-cursor'; - bubble.appendChild(cursor); + // const cursor = document.createElement('span'); + // cursor.className = 'pg-cursor'; + // bubble.appendChild(cursor); let errorMsg = ''; - try { const imageFiles = files.filter(f => f.type.startsWith('image/')); - const hasImages = imageFiles.length > 0; let response; - - if (hasImages) { + + // 如果显式选择了 image-edit,或者选了 image 且带了图片附件,则走 edits 接口 + const isEdit = interfaceType === 'image-edit' || (interfaceType === 'image' && imageFiles.length > 0); + + if (isEdit) { + if (imageFiles.length === 0) throw new Error('请先上传需要修改的图片'); + const formData = new FormData(); formData.append('model', model); formData.append('prompt', prompt || ''); @@ -254,46 +274,32 @@ async function imageResponse(provider, model, prompt, files, bubble) { response = await fetch('/v1/images/edits', { method: 'POST', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'model-provider': provider - }, + headers: { 'Authorization': `Bearer ${apiKey}`, 'model-provider': provider }, body: formData }); } else { response = await fetch('/v1/images/generations', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKey}`, - 'model-provider': provider - }, + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, 'model-provider': provider }, body: JSON.stringify({model, prompt, response_format: 'b64_json'}) }); } if (!response.ok) { - const errText = await response.text(); - let msg = `${t('playground.reqFailed')} (${response.status})`; - try { - msg = JSON.parse(errText)?.error?.message || msg; - } catch { - } - throw new Error(msg); + errorMsg = await parseResponseError(response); + throw new Error(errorMsg); } - + const json = await response.json(); const images = json.data || []; if (images.length === 0) throw new Error(t('playground.reqFailed')); bubble.innerHTML = images.map((img, i) => { const src = img.b64_json ? `data:image/png;base64,${img.b64_json}` : img.url; - const alt = escapeHtml(img.revised_prompt ? `generated image ${i + 1}` : 'generated image'); - return `${alt}`; + return `generated`; }).join(''); } catch (e) { - console.error('[Playground] Image generation error:', e.message); errorMsg = e.message || t('playground.reqFailed'); } finally { cursor.remove(); @@ -302,19 +308,18 @@ async function imageResponse(provider, model, prompt, files, bubble) { bubble.closest('.pg-message')?.classList.add('error'); } isStreaming = false; - currentAbortController = null; updateInputState(); scrollToBottom(); } } -async function streamResponse(provider, model, bubble) { +async function streamResponse(provider, model, bubble, params) { isStreaming = true; updateInputState(); - const cursor = document.createElement('span'); - cursor.className = 'pg-cursor'; - bubble.appendChild(cursor); + // const cursor = document.createElement('span'); + // cursor.className = 'pg-cursor'; + // bubble.appendChild(cursor); currentAbortController = new AbortController(); let accumulated = ''; @@ -330,79 +335,56 @@ async function streamResponse(provider, model, bubble) { }, body: JSON.stringify({ model, - messages, + messages: params.messages, + temperature: params.temperature, + max_tokens: params.max_tokens, stream: true }), signal: currentAbortController.signal }); - + if (!response.ok) { - const errText = await response.text(); - let msg = `${t('playground.reqFailed')} (${response.status})`; - try { msg = JSON.parse(errText)?.error?.message || msg; } catch {} - throw new Error(msg); + errorMsg = await parseResponseError(response); + throw new Error(errorMsg); } - + const reader = response.body.getReader(); const decoder = new TextDecoder(); let sseBuffer = ''; - let streamDone = false; outer: while (true) { const { done, value } = await reader.read(); if (done) break; - // buffer across chunks so a large data: line isn't split mid-JSON sseBuffer += decoder.decode(value, {stream: true}); const lines = sseBuffer.split('\n'); - sseBuffer = lines.pop(); // keep the (possibly incomplete) last line + sseBuffer = lines.pop(); for (const line of lines) { if (!line.startsWith('data: ')) continue; const data = line.slice(6).trim(); - if (data === '[DONE]') { - streamDone = true; - break outer; - } + if (data === '[DONE]') break outer; try { const json = JSON.parse(data); - // detect server-side stream error event - if (json.error) { - throw new Error(json.error.message || t('playground.reqFailed')); - } + if (json.error) throw new Error(json.error.message || t('playground.reqFailed')); const delta = json.choices?.[0]?.delta?.content || ''; if (delta) { + if (!accumulated) { + const msgWrapper = bubble.closest('.pg-message'); + if (msgWrapper) msgWrapper.style.display = 'flex'; + } accumulated += delta; bubble.textContent = accumulated; bubble.appendChild(cursor); scrollToBottom(); } - } catch (parseErr) { - if (parseErr.message && !parseErr.message.startsWith('Unexpected')) { - // re-throw real stream errors, swallow JSON parse errors - throw parseErr; - } + } catch (e) { + if (e.message && !e.message.startsWith('Unexpected')) throw e; } } } - // flush whatever remains in the buffer - if (!streamDone && sseBuffer.trim().startsWith('data: ')) { - const data = sseBuffer.slice(6).trim(); - if (data && data !== '[DONE]') { - try { - const json = JSON.parse(data); - if (json.error) throw new Error(json.error.message || t('playground.reqFailed')); - const delta = json.choices?.[0]?.delta?.content || ''; - if (delta) accumulated += delta; - } catch (parseErr) { - if (parseErr.message && !parseErr.message.startsWith('Unexpected')) throw parseErr; - } - } - } - - // strip base64 data URLs before storing in history to avoid context overflow const historyContent = accumulated.replace(/data:[^;]+;base64,[A-Za-z0-9+/=]+/g, '[图片]'); messages.push({role: 'assistant', content: historyContent}); @@ -415,6 +397,9 @@ async function streamResponse(provider, model, bubble) { } } finally { cursor.remove(); + const msgWrapper = bubble.closest('.pg-message'); + if (msgWrapper) msgWrapper.style.display = 'flex'; + if (errorMsg) { bubble.textContent = errorMsg; bubble.closest('.pg-message')?.classList.add('error'); @@ -428,65 +413,24 @@ async function streamResponse(provider, model, bubble) { } } -// ── Markdown renderer ───────────────────────────────────────────────────────── - -function escapeHtml(str) { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -function isSafeImageUrl(url) { - return url.startsWith('data:image/') || /^https?:\/\//.test(url); -} - -function renderMarkdown(text) { - const blocks = []; - - // pull out fenced code blocks first to protect them from further processing - text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => { - const escaped = escapeHtml(code.trimEnd()); - const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : ''; - const html = `
${escaped}
`; - blocks.push(html); - return `\x00BLOCK${blocks.length - 1}\x00`; - }); - - // inline code `...` - text = text.replace(/`([^`]+)`/g, (_, code) => - `${escapeHtml(code)}` - ); - - // markdown images ![alt](url) — only render safe URLs as - text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => { - if (!isSafeImageUrl(url)) return escapeHtml(match); - const safeAlt = escapeHtml(alt); - const safeUrl = url.startsWith('data:image/') ? url : escapeHtml(url); - return `${safeAlt}`; - }); - - // markdown links [text](url) - text = text.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, (_, label, url) => - `${escapeHtml(label)}` - ); - - // **bold** and *italic* - text = text.replace(/\*\*([^*]+)\*\*/g, (_, s) => `${escapeHtml(s)}`); - text = text.replace(/\*([^*\n]+)\*/g, (_, s) => `${escapeHtml(s)}`); - - // newlines →
- text = text.replace(/\n/g, '
'); - - // restore protected code blocks - text = text.replace(/\x00BLOCK(\d+)\x00/g, (_, i) => blocks[+i]); +// ── UI helpers ──────────────────────────────────────────────────────────────── - return text; +async function parseResponseError(response) { + const status = response.status; + const defaultMsg = `${t('playground.reqFailed')} (${status})`; + try { + const text = await response.text(); + try { + const json = JSON.parse(text); + return json.error?.message || json.message || text || defaultMsg; + } catch (e) { + return text || defaultMsg; + } + } catch (e) { + return defaultMsg; + } } -// ── UI helpers ──────────────────────────────────────────────────────────────── - function appendMessage(role, text) { const empty = getEmpty(); if (empty) empty.style.display = 'none'; @@ -497,40 +441,106 @@ function appendMessage(role, text) { const wrapper = document.createElement('div'); wrapper.className = `pg-message ${role}`; - const roleLabel = document.createElement('div'); - roleLabel.className = 'pg-message-role'; - roleLabel.textContent = role === 'user' ? t('playground.you') : 'AI'; - wrapper.appendChild(roleLabel); + // Avatar + const avatar = document.createElement('div'); + avatar.className = 'pg-avatar'; + avatar.innerHTML = role === 'user' ? '' : ''; + wrapper.appendChild(avatar); + + const contentWrapper = document.createElement('div'); + contentWrapper.className = 'pg-message-content'; const bubble = document.createElement('div'); bubble.className = 'pg-message-bubble'; - bubble.textContent = text; - wrapper.appendChild(bubble); + + if (role === 'assistant' && !text) { + bubble.innerHTML = '
'; + wrapper.style.display = 'none'; // Waiting state hidden until first chunk + } else { + bubble.textContent = text; + } + + contentWrapper.appendChild(bubble); + + if (role === 'assistant') { + const actions = document.createElement('div'); + actions.className = 'pg-message-actions'; + actions.innerHTML = ` + + `; + actions.querySelector('.btn-copy-msg').addEventListener('click', async () => { + const rawText = bubble.innerText; + const success = await copyToClipboard(rawText); + if (success) { + const icon = actions.querySelector('.fa-copy'); + const oldClass = icon.className; + icon.className = 'fas fa-check'; + setTimeout(() => icon.className = oldClass, 2000); + } + }); + contentWrapper.appendChild(actions); + } + wrapper.appendChild(contentWrapper); container.appendChild(wrapper); scrollToBottom(); return bubble; } +async function copyToClipboard(text) { + if (!text) return false; + try { + await navigator.clipboard.writeText(text); + return true; + } catch (e) { + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-9999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(textArea); + return ok; + } +} + function clearChat() { messages = []; pendingFiles = []; renderAttachmentPreview(); - const container = getMessages(); - if (!container) return; - container.innerHTML = ''; - - const empty = document.createElement('div'); - empty.className = 'playground-empty'; - empty.id = 'pg-empty'; - empty.innerHTML = `

${t('playground.emptyHint')}

`; - container.appendChild(empty); - + if (container) { + container.innerHTML = ''; + container.appendChild(createEmptyState()); + } + const empty = getEmpty(); + if (empty) empty.style.display = 'flex'; if (currentAbortController) { currentAbortController.abort(); currentAbortController = null; } + updateInputState(); +} + +function createEmptyState() { + const empty = document.createElement('div'); + empty.className = 'pg-welcome'; + empty.id = 'pg-empty'; + empty.innerHTML = ` +
+
+ +
+

${t('playground.welcome')}

+

${t('playground.emptyHint')}

+
+ `; + if (window.i18n) window.i18n.translateElement(empty); + return empty; } function scrollToBottom() { @@ -538,27 +548,67 @@ function scrollToBottom() { if (container) container.scrollTop = container.scrollHeight; } +function escapeHtml(str) { + return str.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); +} + +function isSafeImageUrl(url) { + return url.startsWith('data:image/') || /^https?:\/\//.test(url); +} + +function renderMarkdown(text) { + const blocks = []; + // Protect code blocks + text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => { + const escaped = escapeHtml(code.trimEnd()); + const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : ''; + blocks.push(`
${escaped}
`); + return `\x00BLOCK${blocks.length - 1}\x00`; + }); + + text = escapeHtml(text); + + // Inline code + text = text.replace(/`([^`]+)`/g, '$1'); + + // Bold/Italic + text = text.replace(/\*\*([^*]+)\*\*/g, '$1'); + text = text.replace(/\*([^*]+)\*/g, '$1'); + + // Basic images/links + text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => { + if (!isSafeImageUrl(url)) return match; + return `${alt}`; + }); + text = text.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '$1'); + + // Newlines + text = text.replace(/\n/g, '
'); + + // Restore code blocks + text = text.replace(/\x00BLOCK(\d+)\x00/g, (_, i) => blocks[+i]); + + return text; +} + // ── File handling ───────────────────────────────────────────────────────────── async function handleFiles(fileList) { if (!fileList?.length) return; - for (const file of fileList) { const dataUrl = await readFileAsDataUrl(file); pendingFiles.push({ name: file.name, type: file.type, dataUrl }); } - const fileInput = el('pg-file-input'); if (fileInput) fileInput.value = ''; - renderAttachmentPreview(); } function readFileAsDataUrl(file) { - return new Promise((resolve, reject) => { + return new Promise((r, j) => { const reader = new FileReader(); - reader.onload = e => resolve(e.target.result); - reader.onerror = reject; + reader.onload = e => r(e.target.result); + reader.onerror = j; reader.readAsDataURL(file); }); } @@ -567,19 +617,15 @@ function renderAttachmentPreview() { const preview = getAttachPreview(); if (!preview) return; preview.innerHTML = ''; - pendingFiles.forEach((f, i) => { const tag = document.createElement('div'); tag.className = 'pg-attachment-tag'; - tag.innerHTML = ` - - ${f.name} - - `; - tag.querySelector('button').addEventListener('click', () => { + tag.style = "display:inline-flex;align-items:center;background:var(--bg-tertiary);padding:4px 10px;border-radius:8px;margin-right:8px;font-size:0.8rem;border:1px solid var(--border-color);"; + tag.innerHTML = `${f.name}`; + tag.querySelector('button').onclick = () => { pendingFiles.splice(i, 1); renderAttachmentPreview(); - }); + }; preview.appendChild(tag); }); } diff --git a/static/app/usage-manager.js b/static/app/usage-manager.js index a735418c3..617e70a63 100644 --- a/static/app/usage-manager.js +++ b/static/app/usage-manager.js @@ -11,7 +11,6 @@ import { t, getCurrentLanguage } from './i18n.js'; * 注:gemini-antigravity 已支持 remainingPercent,移除限制 */ const PROVIDERS_WITHOUT_USAGE_DISPLAY = [ - 'gemini-antigravity' ]; // 提供商配置缓存 diff --git a/static/components/section-playground.css b/static/components/section-playground.css index cfb26cf63..a401a838b 100644 --- a/static/components/section-playground.css +++ b/static/components/section-playground.css @@ -1,372 +1,542 @@ -/* Playground Section */ -.playground-layout { - display: flex; - gap: 1.5rem; - height: calc(100vh - 160px); - min-height: 500px; -} - -/* Left panel */ -.playground-config { - width: 260px; - flex-shrink: 0; +/* 严格限制作用域的 Playground 布局 */ +.pg-workspace.active { display: flex; flex-direction: column; - gap: 1rem; + height: calc(100vh - 120px); + background: var(--bg-primary); + border-radius: var(--radius-xl); + border: 1px solid var(--border-color); + overflow: hidden; + margin-top: -0.5rem; + animation: fadeIn 0.4s cubic-bezier(0.4, 0, 0.2, 1); } -.playground-config-card { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1rem; +/* 顶部导航限制 */ +.pg-workspace .pg-workspace-header { + height: 64px; + padding: 0 1.75rem; display: flex; - flex-direction: column; - gap: 0.75rem; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--border-color); + background: var(--bg-glass-strong); + backdrop-filter: blur(12px); + z-index: 10; + box-shadow: 0 1px 3px rgba(0,0,0,0.02); } -.playground-config-card label { - font-size: 0.75rem; - font-weight: 600; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.05em; +.pg-workspace .pg-title-group { + display: flex; + align-items: center; + gap: 1.25rem; } -.playground-select { - width: 100%; - padding: 0.5rem 0.75rem; - border: 1px solid var(--border-color); - border-radius: 0.375rem; - background: var(--bg-primary); +.pg-workspace .pg-workspace-header h2 { + font-size: 1.15rem !important; + font-weight: 700 !important; + margin-bottom: 0 !important; color: var(--text-primary); - font-size: 0.875rem; - cursor: pointer; - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b7280' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 0.75rem center; - padding-right: 2rem; -} - -.playground-select:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.1); + display: flex; + align-items: center; + gap: 0.85rem; + letter-spacing: -0.02em; } -.playground-select:disabled { - opacity: 0.5; - cursor: not-allowed; +.pg-workspace .pg-workspace-header h2 i { + color: var(--primary-color); + filter: drop-shadow(0 0 4px var(--primary-20)); } -.playground-provider-item { +.pg-workspace .pg-indicator { display: flex; align-items: center; - gap: 0.375rem; + gap: 0.6rem; + font-size: 0.75rem; + font-weight: 600; + padding: 0.35rem 0.85rem; + border-radius: var(--radius-full); + background: var(--bg-tertiary); + transition: all 0.3s ease; + border: 1px solid transparent; } -.provider-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; +.pg-workspace .pg-indicator.active { + color: var(--primary-color); + background: var(--primary-10); + border: 1px solid var(--primary-20); + box-shadow: 0 0 12px var(--primary-10); } -.provider-dot.healthy { - background: #10b981; +.pg-workspace .pg-indicator .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; + position: relative; } -.provider-dot.unhealthy { - background: #d1d5db; +.pg-workspace .pg-indicator.active .dot::after { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + background: currentColor; + border-radius: 50%; + animation: pulse 2s infinite; } -.playground-clear-btn { - margin-top: auto; - width: 100%; - padding: 0.5rem; - background: transparent; +.pg-workspace .pg-header-tools { + display: flex; + gap: 0.75rem; +} + +.pg-workspace .pg-btn-icon { + width: 38px; + height: 38px; + border-radius: 10px; border: 1px solid var(--border-color); - border-radius: 0.375rem; + background: var(--bg-primary); color: var(--text-secondary); - font-size: 0.875rem; cursor: pointer; - transition: all 0.15s; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); display: flex; align-items: center; justify-content: center; - gap: 0.5rem; + font-size: 0.95rem; } -.playground-clear-btn:hover { - background: var(--bg-hover); - color: var(--text-primary); +.pg-workspace .pg-btn-icon:hover { + background: var(--bg-secondary); + color: var(--primary-color); + border-color: var(--primary-color); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); } -/* Right panel - chat area */ -.playground-chat { +/* 主布局限制 */ +.pg-workspace .pg-main-layout { flex: 1; + display: grid; + grid-template-columns: 280px 1fr 320px; + overflow: hidden; + background: var(--bg-secondary); +} + +/* 侧边栏 & 配置面板 */ +.pg-workspace .pg-side-nav, +.pg-workspace .pg-config-panel { + background: var(--bg-secondary); + padding: 1.75rem; + overflow-y: auto; display: flex; flex-direction: column; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - overflow: hidden; + gap: 1.75rem; +} + +.pg-workspace .pg-side-nav { border-right: 1px solid var(--border-color); } +.pg-workspace .pg-config-panel { border-left: 1px solid var(--border-color); } + +.pg-workspace .pg-nav-divider { + height: 1px; + background: linear-gradient(to right, transparent, var(--border-color), transparent); + margin: 0.5rem 0; +} + +/* 对话区限制 */ +.pg-workspace .pg-chat-column { + display: flex; + flex-direction: column; + background: var(--bg-primary); + position: relative; min-width: 0; + box-shadow: inset 0 0 40px rgba(0,0,0,0.01); } -.playground-messages { +.pg-workspace .pg-messages-wrapper { flex: 1; overflow-y: auto; - padding: 1.5rem; + padding: 2.5rem; display: flex; flex-direction: column; - gap: 1rem; + gap: 2.5rem; + scroll-behavior: smooth; } -.playground-empty { +/* 欢迎页 */ +.pg-workspace .pg-welcome { flex: 1; display: flex; - flex-direction: column; align-items: center; justify-content: center; - color: var(--text-secondary); - gap: 0.75rem; } -.playground-empty i { - font-size: 2.5rem; - opacity: 0.3; +.pg-workspace .welcome-card { + text-align: center; + max-width: 440px; + padding: 3rem 2rem; + background: var(--bg-primary); + border-radius: 24px; + border: 1px solid var(--border-color); + box-shadow: var(--shadow-lg); + animation: slideIn 0.6s cubic-bezier(0.16, 1, 0.3, 1); } -.playground-empty p { - font-size: 0.9rem; - opacity: 0.6; +.pg-workspace .welcome-icon { + width: 72px; + height: 72px; + background: var(--primary-10); + color: var(--primary-color); + border-radius: 20px; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 1.75rem; + font-size: 1.75rem; + transform: rotate(-5deg); + box-shadow: 0 8px 16px var(--primary-10); } -/* Message bubbles */ -.pg-message { +/* 消息气泡 */ +.pg-workspace .pg-message { display: flex; - flex-direction: column; - max-width: 85%; + gap: 1.25rem; + max-width: 90%; + margin-bottom: 0.5rem; } -.pg-message.user { - align-self: flex-end; - align-items: flex-end; -} +.pg-workspace .pg-message.user { align-self: flex-end; flex-direction: row-reverse; } +.pg-workspace .pg-message.assistant { align-self: flex-start; } -.pg-message.assistant { - align-self: flex-start; - align-items: flex-start; +.pg-workspace .pg-avatar { + width: 38px; + height: 38px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 1.2rem; + box-shadow: var(--shadow-sm); } -.pg-message-role { - font-size: 0.7rem; - font-weight: 600; - color: var(--text-secondary); - margin-bottom: 0.25rem; - text-transform: uppercase; - letter-spacing: 0.05em; +.pg-workspace .pg-message.user .pg-avatar { background: var(--primary-color); color: white; } +.pg-workspace .pg-message.assistant .pg-avatar { + background: var(--bg-tertiary); + color: var(--primary-color); + border: 1px solid var(--border-color); } -.pg-message-bubble { - padding: 0.625rem 0.875rem; - border-radius: 0.75rem; - font-size: 0.9rem; - line-height: 1.6; - white-space: pre-wrap; - word-break: break-word; +.pg-workspace .pg-message-bubble { + padding: 1.1rem 1.4rem; + border-radius: 18px; + line-height: 1.75; + font-size: 1rem; + word-wrap: break-word; + position: relative; + transition: all 0.2s ease; } -.pg-message.user .pg-message-bubble { +.pg-workspace .pg-message.user .pg-message-bubble { background: var(--primary-color); color: white; - border-bottom-right-radius: 0.25rem; + border-top-right-radius: 4px; + box-shadow: 0 4px 12px var(--primary-20); } -.pg-message.assistant .pg-message-bubble { - background: var(--bg-primary); +.pg-workspace .pg-message.assistant .pg-message-bubble { + background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-color); - border-bottom-left-radius: 0.25rem; -} - -.pg-message.error .pg-message-bubble { - background: #fef2f2; - color: #dc2626; - border: 1px solid #fecaca; - border-bottom-left-radius: 0.25rem; + border-top-left-radius: 4px; + box-shadow: var(--shadow-sm); } -.pg-cursor { - display: inline-block; - width: 2px; - height: 1em; - background: var(--text-primary); - margin-left: 1px; - vertical-align: text-bottom; - animation: pg-blink 1s step-end infinite; -} - -@keyframes pg-blink { - 0%, 100% { opacity: 1; } - 50% { opacity: 0; } +/* 消息操作栏 */ +.pg-workspace .pg-message-actions { + display: flex; + gap: 0.75rem; + padding-top: 0.5rem; + opacity: 0; + transition: opacity 0.2s ease; } -/* Attachment preview */ -.pg-attachments-preview { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - padding: 0.5rem 1rem 0; +.pg-workspace .pg-message:hover .pg-message-actions { + opacity: 1; } -.pg-attachment-tag { +.pg-workspace .pg-action-link { + width: 28px; + height: 28px; display: flex; align-items: center; - gap: 0.375rem; - padding: 0.25rem 0.5rem; - background: var(--bg-primary); + justify-content: center; + border-radius: 6px; + color: var(--text-tertiary); + cursor: pointer; + transition: all 0.2s ease; + background: var(--bg-tertiary); border: 1px solid var(--border-color); - border-radius: 0.25rem; - font-size: 0.75rem; - color: var(--text-secondary); + font-size: 0.85rem; } -.pg-attachment-tag button { - background: none; - border: none; - cursor: pointer; - color: var(--text-secondary); - padding: 0; - line-height: 1; - font-size: 0.875rem; +.pg-workspace .pg-action-link:hover { + color: var(--primary-color); + background: var(--primary-10); + border-color: var(--primary-20); } -.pg-attachment-tag button:hover { - color: #dc2626; +/* 输入框区域 */ +.pg-workspace .pg-input-boundary { + padding: 1rem 4rem 2.5rem; + background: linear-gradient(to top, var(--bg-primary) 60%, transparent); + z-index: 5; } -/* Input area */ -.playground-input-area { - border-top: 1px solid var(--border-color); - padding: 1rem; - display: flex; - flex-direction: column; - gap: 0.5rem; +.pg-workspace .pg-input-box { + background: var(--bg-primary); + border: 1.5px solid var(--border-color); + border-radius: 22px; + box-shadow: var(--shadow-lg); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + overflow: hidden; } -.playground-input-row { +.pg-workspace .pg-input-box:focus-within { + border-color: var(--primary-color); + box-shadow: 0 12px 24px -8px var(--primary-20), var(--shadow-xl); + transform: translateY(-2px); +} + +.pg-workspace .pg-input-inner { display: flex; align-items: flex-end; - gap: 0.5rem; + padding: 0.75rem 1.4rem; + gap: 1.1rem; } -.pg-attach-btn { - flex-shrink: 0; +.pg-workspace .pg-textarea-auto { + flex: 1; + border: none; + background: transparent; + padding: 0.5rem 0; + min-height: 40px; + max-height: 240px; + outline: none; + font-size: 1.05rem; + color: var(--text-primary); + line-height: 1.6; + resize: none; +} + +.pg-workspace .pg-send-pill { + background: var(--primary-color); + color: white; width: 36px; height: 36px; - border: 1px solid var(--border-color); - border-radius: 0.375rem; - background: var(--bg-primary); - color: var(--text-secondary); + border-radius: 12px; + border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; - transition: all 0.15s; + transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); } -.pg-attach-btn:hover { - background: var(--bg-hover); - color: var(--text-primary); +.pg-workspace .pg-send-pill:hover { + background: var(--primary-hover); + transform: scale(1.05); } -.playground-textarea { - flex: 1; - padding: 0.5rem 0.75rem; - border: 1px solid var(--border-color); - border-radius: 0.375rem; +.pg-workspace .pg-inner-btn { + width: 38px; + height: 38px; + border: none; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + font-size: 1.1rem; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + border-radius: 10px; +} + +.pg-workspace .pg-inner-btn:hover { + color: var(--primary-color); + background: var(--primary-10); +} + +/* 表单元素样式 */ +.pg-workspace .pg-label { + display: block; + font-size: 0.75rem; + font-weight: 800; + color: var(--text-tertiary); + text-transform: uppercase; + margin-bottom: 0.85rem; + letter-spacing: 0.1em; +} + +.pg-workspace .pg-input-field { + width: 100%; + padding: 0.85rem; + border: 1.5px solid var(--border-color); + border-radius: 12px; background: var(--bg-primary); color: var(--text-primary); font-size: 0.9rem; - resize: none; - min-height: 36px; - max-height: 160px; - line-height: 1.5; - font-family: inherit; - overflow-y: auto; + appearance: none; + transition: all 0.2s ease; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748b' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 1rem center; +} + +.pg-workspace .pg-system-textarea { + width: 100%; + height: 240px; + padding: 1rem; + border: 1.5px solid var(--border-color); + border-radius: 14px; + background: var(--bg-primary); + resize: vertical; + font-size: 0.9rem; + line-height: 1.6; + color: var(--text-primary); + transition: all 0.2s ease; +} + +/* --- 超参数调优 (Hyperparameters) 专项美化 --- */ +.pg-workspace .pg-slider-group { + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--bg-primary); + border-radius: var(--radius-lg); + border: 1px solid var(--border-color); + transition: all 0.2s ease; +} + +.pg-workspace .pg-slider-group:hover { + border-color: var(--primary-30); + box-shadow: var(--shadow-sm); +} + +.pg-workspace .slider-info { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; + font-size: 0.85rem; + color: var(--text-secondary); + font-weight: 600; +} + +.pg-workspace .slider-info .val { + background: var(--primary-10); + color: var(--primary-color); + padding: 0.2rem 0.6rem; + border-radius: 6px; + font-family: 'JetBrains Mono', monospace; + font-size: 0.8rem; } -.playground-textarea:focus { +.pg-workspace .pg-num-input { + width: 80px; + border: 1.5px solid var(--border-color); + background: var(--bg-secondary); + text-align: center; + border-radius: 8px; + font-size: 0.85rem; + padding: 0.4rem; + color: var(--text-primary); + font-family: 'JetBrains Mono', monospace; + font-weight: 700; outline: none; + transition: all 0.2s ease; +} + +.pg-workspace .pg-num-input:focus { border-color: var(--primary-color); - box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.1); + background: var(--bg-primary); + box-shadow: 0 0 0 3px var(--primary-10); } -.playground-textarea:disabled { - background: var(--bg-secondary); - color: var(--text-secondary); - cursor: not-allowed; +/* Range Input 美化 */ +.pg-workspace input[type=range] { + width: 100%; + -webkit-appearance: none; + height: 6px; + background: var(--bg-tertiary); + border-radius: 10px; + outline: none; + cursor: pointer; } -.pg-send-btn { - flex-shrink: 0; - width: 36px; - height: 36px; - border: none; - border-radius: 0.375rem; +.pg-workspace input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 18px; + height: 18px; background: var(--primary-color); - color: white; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: background 0.15s; + border-radius: 50%; + border: 3px solid var(--bg-primary); + box-shadow: 0 2px 6px rgba(0,0,0,0.15); + transition: all 0.2s ease; } -.pg-send-btn:hover:not(:disabled) { - background: var(--primary-hover, #047857); +.pg-workspace input[type=range]::-webkit-slider-thumb:hover { + transform: scale(1.15); + box-shadow: 0 0 0 6px var(--primary-10); } -.pg-send-btn:disabled { - opacity: 0.4; - cursor: not-allowed; - background: var(--text-secondary, #9ca3af); +/* 响应式 */ +@media (max-width: 1400px) { + .pg-workspace .pg-main-layout { grid-template-columns: 240px 1fr 300px; } } -/* Input area disabled state */ -.playground-input-area.pg-input-disabled .playground-input-row { - opacity: 0.55; - pointer-events: none; +@media (max-width: 1100px) { + .pg-workspace .pg-main-layout { grid-template-columns: 1fr; } + .pg-workspace .pg-side-nav, .pg-workspace .pg-config-panel { display: none; } + .pg-workspace .pg-input-boundary { padding: 1rem 2rem; } } -.playground-input-area.pg-input-disabled .pg-hint { - opacity: 1; - color: var(--primary-color, #059669); - font-weight: 500; +/* 其他动画 & 辅助类 */ +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } +@keyframes slideIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } +@keyframes pulse { 0% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.5); opacity: 0; } 100% { transform: scale(1); opacity: 0; } } +@keyframes pg-blink { 0%, 100% { opacity: 1; transform: scaleY(1); } 50% { opacity: 0.3; transform: scaleY(0.8); } } + +/* 流式光标美化 */ +.pg-cursor { + display: inline-block; + width: 6px; + height: 1.2em; + background: var(--primary-light); + margin-left: 4px; + border-radius: 3px; + animation: pg-blink 0.8s infinite; + vertical-align: middle; } -.pg-hint { - font-size: 0.7rem; - color: var(--text-secondary); - opacity: 0.6; - text-align: right; +/* 思考中动画 */ +.pg-thinking { + display: flex; + gap: 0.4rem; + padding: 0.5rem 0; } -/* Responsive */ -@media (max-width: 768px) { - .playground-layout { - flex-direction: column; - height: auto; - } +.pg-thinking span { + width: 6px; + height: 6px; + background: var(--primary-color); + border-radius: 50%; + opacity: 0.4; + animation: pg-think 1.4s infinite; +} - .playground-config { - width: 100%; - } +.pg-thinking span:nth-child(2) { animation-delay: 0.2s; } +.pg-thinking span:nth-child(3) { animation-delay: 0.4s; } - .playground-chat { - height: 60vh; - } +@keyframes pg-think { + 0%, 100% { transform: translateY(0); opacity: 0.4; } + 50% { transform: translateY(-4px); opacity: 1; } } diff --git a/static/components/section-playground.html b/static/components/section-playground.html index 682fbf803..1591241b3 100644 --- a/static/components/section-playground.html +++ b/static/components/section-playground.html @@ -1,62 +1,110 @@ - -
-

模型测试

-
- -
-
- - + +
+
+ + +
- - + + +
+ +
+ - -
+ +
+
+ +
+
+
+ +
+

推理实验室

+

在右侧配置设定并选择模型后,即可开始高阶推理测试。

+
+
+
+ + +
+
+
+
+ + + +
+
+
+
- -
-
-
- -

选择提供商和模型后开始对话

+ +
From 66087de26606706fc46e1226acdd10f300b07be0 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Wed, 29 Apr 2026 15:08:45 +0800 Subject: [PATCH 081/135] =?UTF-8?q?feat(playground):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=98=BE=E7=A4=BAAI=E6=80=9D=E8=80=83=E8=BF=87=E7=A8=8B?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=E6=B5=81=E5=BC=8F=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在流式响应中解析并单独显示AI的思考过程(reasoning_content/thinking字段) - 新增思考过程专用样式,包括标题、边框和代码字体 - 添加中英文“思考过程”/“Thinking”翻译键 - 移除等待状态时隐藏消息容器的逻辑,优化用户体验 - 更新版本号至2.15.9.1 --- VERSION | 2 +- static/app/i18n.js | 2 + static/app/playground-manager.js | 70 ++++++++++++++++++------ static/components/section-playground.css | 36 ++++++++++++ 4 files changed, 91 insertions(+), 19 deletions(-) diff --git a/VERSION b/VERSION index 327c961a4..419129d33 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.15.9 +2.15.9.1 diff --git a/static/app/i18n.js b/static/app/i18n.js index 9ca3b8efc..26189ff0f 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -818,6 +818,7 @@ const translations = { 'playground.systemRole': '系统设定 (System Role)', 'playground.systemRolePlaceholder': '在此定义 AI 的角色、语气和背景知识...', 'playground.hyperparameters': '超参数调优 (Hyperparameters)', + 'playground.thinking': '思考过程', 'playground.status.ready': '已就绪', 'playground.status.unready': '未就绪', @@ -1929,6 +1930,7 @@ const translations = { 'playground.systemRole': 'System Role', 'playground.systemRolePlaceholder': 'Define AI role, tone, and background here...', 'playground.hyperparameters': 'Hyperparameters', + 'playground.thinking': 'Thinking', 'playground.status.ready': 'Ready', 'playground.status.unready': 'Not Ready', diff --git a/static/app/playground-manager.js b/static/app/playground-manager.js index 80fc0b6e5..bb2befe6f 100644 --- a/static/app/playground-manager.js +++ b/static/app/playground-manager.js @@ -251,10 +251,6 @@ async function imageResponse(provider, model, prompt, files, bubble, interfaceTy const msgWrapper = bubble.closest('.pg-message'); if (msgWrapper) msgWrapper.style.display = 'flex'; // Image response doesn't need to hide - // const cursor = document.createElement('span'); - // cursor.className = 'pg-cursor'; - // bubble.appendChild(cursor); - let errorMsg = ''; try { const imageFiles = files.filter(f => f.type.startsWith('image/')); @@ -302,7 +298,6 @@ async function imageResponse(provider, model, prompt, files, bubble, interfaceTy } catch (e) { errorMsg = e.message || t('playground.reqFailed'); } finally { - cursor.remove(); if (errorMsg) { bubble.textContent = errorMsg; bubble.closest('.pg-message')?.classList.add('error'); @@ -317,12 +312,12 @@ async function streamResponse(provider, model, bubble, params) { isStreaming = true; updateInputState(); - // const cursor = document.createElement('span'); - // cursor.className = 'pg-cursor'; - // bubble.appendChild(cursor); - + const cursor = document.createElement('span'); + cursor.className = 'pg-cursor'; + currentAbortController = new AbortController(); let accumulated = ''; + let accumulatedReasoning = ''; let errorMsg = ''; try { @@ -368,15 +363,43 @@ async function streamResponse(provider, model, bubble, params) { try { const json = JSON.parse(data); if (json.error) throw new Error(json.error.message || t('playground.reqFailed')); - const delta = json.choices?.[0]?.delta?.content || ''; - if (delta) { - if (!accumulated) { + + const delta = json.choices?.[0]?.delta; + const content = delta?.content || ''; + const reasoning = delta?.reasoning_content || delta?.thinking || ''; + + if (content || reasoning) { + if (!accumulated && !accumulatedReasoning) { const msgWrapper = bubble.closest('.pg-message'); if (msgWrapper) msgWrapper.style.display = 'flex'; } - accumulated += delta; - bubble.textContent = accumulated; - bubble.appendChild(cursor); + + if (reasoning) accumulatedReasoning += reasoning; + if (content) accumulated += content; + + let html = ''; + if (accumulatedReasoning) { + html += `
+
${t('playground.thinking')}
+
+
`; + } + + bubble.innerHTML = html; + if (accumulatedReasoning) { + const resContent = bubble.querySelector('.pg-reasoning-content'); + resContent.textContent = accumulatedReasoning; + if (!accumulated) { + resContent.appendChild(cursor); + } + } + + if (accumulated || !accumulatedReasoning) { + const contentSpan = document.createElement('span'); + contentSpan.textContent = accumulated; + bubble.appendChild(contentSpan); + bubble.appendChild(cursor); + } scrollToBottom(); } } catch (e) { @@ -403,8 +426,20 @@ async function streamResponse(provider, model, bubble, params) { if (errorMsg) { bubble.textContent = errorMsg; bubble.closest('.pg-message')?.classList.add('error'); - } else if (accumulated) { - bubble.innerHTML = renderMarkdown(accumulated); + } else { + bubble.innerHTML = ''; + if (accumulatedReasoning) { + const resDiv = document.createElement('div'); + resDiv.className = 'pg-reasoning'; + resDiv.innerHTML = `
${t('playground.thinking')}
`; + resDiv.querySelector('.pg-reasoning-content').textContent = accumulatedReasoning; + bubble.appendChild(resDiv); + } + if (accumulated) { + const contentDiv = document.createElement('div'); + contentDiv.innerHTML = renderMarkdown(accumulated); + while (contentDiv.firstChild) bubble.appendChild(contentDiv.firstChild); + } } isStreaming = false; currentAbortController = null; @@ -455,7 +490,6 @@ function appendMessage(role, text) { if (role === 'assistant' && !text) { bubble.innerHTML = '
'; - wrapper.style.display = 'none'; // Waiting state hidden until first chunk } else { bubble.textContent = text; } diff --git a/static/components/section-playground.css b/static/components/section-playground.css index a401a838b..f19bd5199 100644 --- a/static/components/section-playground.css +++ b/static/components/section-playground.css @@ -524,6 +524,42 @@ padding: 0.5rem 0; } +.pg-reasoning { + background: var(--bg-tertiary); + border-left: 3px solid var(--primary-color); + padding: 0.75rem 1rem; + margin-bottom: 1rem; + border-radius: 6px; + font-size: 0.9rem; + color: var(--text-secondary); + opacity: 0.9; + border: 1px solid var(--border-color); + border-left-width: 4px; +} + +.pg-reasoning-title { + font-size: 0.7rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.5rem; + color: var(--text-tertiary); + display: flex; + align-items: center; + gap: 0.4rem; +} + +.pg-reasoning-title i { + color: var(--primary-color); + font-size: 0.8rem; +} + +.pg-reasoning-content { + line-height: 1.6; + white-space: pre-wrap; + font-family: var(--font-mono); +} + .pg-thinking span { width: 6px; height: 6px; From 2f972229f67b85a1748e650f252de26778f12356 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Wed, 29 Apr 2026 16:23:51 +0800 Subject: [PATCH 082/135] =?UTF-8?q?feat:=20=E5=85=A8=E9=9D=A2=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20OpenAI=20=E6=A0=87=E5=87=86=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=94=9F=E6=88=90=E4=B8=8E=E7=BC=96=E8=BE=91=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=B9=B6=E6=96=B0=E5=A2=9E=20playground=20=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 扩展 AI 监控插件以支持 `/v1/images/generations` 和 `/v1/images/edits` 接口 - 在 playground 界面新增流式输出切换开关,支持非流式(unary)响应 - 完善图片生成与编辑请求的协议转换逻辑,保持内部监控 ID 传递 - 更新多语言 README 文档,添加版本历史记录 - 升级项目版本号至 2.15.9.2 --- README-JA.md | 1 + README-ZH.md | 1 + README.md | 1 + VERSION | 2 +- src/plugins/ai-monitor/index.js | 9 +- src/providers/grok/grok-core.js | 7 +- src/services/api-manager.js | 120 ++++++++++++++++++---- static/app/i18n.js | 2 + static/app/playground-manager.js | 90 +++++++++++++++- static/components/section-playground.css | 63 ++++++++++++ static/components/section-playground.html | 10 ++ 11 files changed, 283 insertions(+), 23 deletions(-) diff --git a/README-JA.md b/README-JA.md index 481b75683..88dc332cc 100644 --- a/README-JA.md +++ b/README-JA.md @@ -106,6 +106,7 @@ >
> クリックして詳細なバージョン履歴を展開 > +> - **2026.04.29** - OpenAI 標準の画像生成 (`/v1/images/generations`) および画像編集 (`/v1/images/edits`) インターフェースを完全にサポート。OpenAI 形式のリクエストを各モデルのネイティブ画像生成プロトコルに自動変換し、プロバイダープールのポーリングや自動リトライメカニズムに完全対応。マルチモーダル制作の安定性を大幅に向上。 > - **2026.03.02** - Grokプロトコルサポートを追加:Cookie/SSO方式でxAI Grokシリーズモデル(Grok 3/4)へのアクセスに対応し、マルチモーダル入力、画像/動画生成、自動トークンリフレッシュ、ストリーミング出力をサポート > - **2026.01.26** - Codexプロトコルサポートを追加:OpenAI Codex OAuth認証での接続に対応 > - **2026.01.25** - AI 監視プラグインの強化:AI プロトコル変換前後のリクエストパラメータとレスポンスの監視をサポート。ログ管理の最適化:統一されたログ形式、ビジュアル設定 diff --git a/README-ZH.md b/README-ZH.md index 1965b60bb..16628945f 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -105,6 +105,7 @@ >
> 点击展开查看详细版本历史 > +> - **2026.04.29** - 全面支持 OpenAI 标准的图片生成 (`/v1/images/generations`) 与编辑 (`/v1/images/edits`) 接口。支持自动将 OpenAI 格式请求转换为各模型对应的原生生图协议,并适配号池轮询与自动重试机制,大幅提升多模态创作的稳定性。 > - **2026.03.02** - 新增 Grok 协议支持,支持通过 Cookie/SSO 方式访问 xAI Grok 系列模型(Grok 3/4),支持多模态输入、图片/视频生成、自动 token 刷新及流式输出 > - **2026.01.26** - 新增 Codex 协议支持:支持 OpenAI Codex OAuth 授权接入 > - **2026.01.25** - 增强 AI 监控插件:支持监控 AI 协议转换前后的请求参数和响应。优化日志管理:统一日志格式,可视化配置 diff --git a/README.md b/README.md index 267501e33..272c59f6b 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ >
> Click to expand detailed version history > +> - **2026.04.29** - Comprehensive support for OpenAI standard Image Generation (`/v1/images/generations`) and Image Editing (`/v1/images/edits`) interfaces. Supports automatic conversion from OpenAI format to native image generation protocols of various models, fully compatible with provider pool polling and retry mechanisms, significantly improving the stability of multimodal creation. > - **2026.03.02** - Added Grok protocol support, supporting access to xAI Grok series models (Grok 3/4) via Cookie/SSO, supporting multimodal input, image/video generation, automatic token refresh and streaming output > - **2026.01.26** - Added Codex protocol support: supports OpenAI Codex OAuth authorization access > - **2026.01.25** - Enhanced AI Monitor plugin: supports monitoring request parameters and responses before and after AI protocol conversion. Optimized log management: unified log format, visual configuration diff --git a/VERSION b/VERSION index 419129d33..ad6907ae2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.15.9.1 +2.15.9.2 diff --git a/src/plugins/ai-monitor/index.js b/src/plugins/ai-monitor/index.js index 3a24473bf..9db7a8fa4 100644 --- a/src/plugins/ai-monitor/index.js +++ b/src/plugins/ai-monitor/index.js @@ -24,7 +24,14 @@ const aiMonitorPlugin = { * 中间件:初始化请求上下文 */ async middleware(req, res, requestUrl, config) { - const aiPaths = ['/v1/chat/completions', '/v1/responses', '/v1/messages', '/v1beta/models']; + const aiPaths = [ + '/v1/chat/completions', + '/v1/responses', + '/v1/messages', + '/v1beta/models', + '/v1/images/generations', + '/v1/images/edits' + ]; const isAiPath = aiPaths.some(path => requestUrl.pathname.includes(path)); if (isAiPath && req.method === 'POST') { diff --git a/src/providers/grok/grok-core.js b/src/providers/grok/grok-core.js index df5a3662e..7d20b5b19 100644 --- a/src/providers/grok/grok-core.js +++ b/src/providers/grok/grok-core.js @@ -941,6 +941,11 @@ export class GrokApiService { } async generateContent(model, requestBody) { + if (requestBody._monitorRequestId) { + this.config._monitorRequestId = requestBody._monitorRequestId; + delete requestBody._monitorRequestId; + } + logger.info(`[Grok] Starting generateContent (unified processing)`); const n = parseInt(requestBody.n || 1); @@ -1382,7 +1387,7 @@ export class GrokApiService { resp.isThinking = false; delete resp.messageStepId; } - + // 1. 处理 cardAttachment (根据最新指令,若是图片则不处理) if (resp.cardAttachment) { try { diff --git a/src/services/api-manager.js b/src/services/api-manager.js index ddd18ac62..19bfa143d 100644 --- a/src/services/api-manager.js +++ b/src/services/api-manager.js @@ -173,7 +173,8 @@ async function handleImageGenerationRequest(req, res, currentConfig, providerPoo n, size, response_format, - _imageSize: size // 兼容 Codex 内部使用的字段 + _imageSize: size, // 兼容 Codex 内部使用的字段 + _monitorRequestId: currentConfig._monitorRequestId // 注入监控 ID }; // 预留变量,在获取到 service 确认协议后再转换 @@ -198,12 +199,21 @@ async function handleImageGenerationRequest(req, res, currentConfig, providerPoo } const finalProviderProtocol = getProtocolPrefix(slotProviderType || CONFIG.MODEL_PROVIDER); + const fromProvider = MODEL_PROTOCOL_PREFIX.OPENAI; + const toProvider = slotProviderType || CONFIG.MODEL_PROVIDER; // 执行自动转换:OpenAI -> 目标协议 const fromProtocol = MODEL_PROTOCOL_PREFIX.OPENAI; if (fromProtocol !== finalProviderProtocol) { logger.info(`[Image Generation] Converting request from ${fromProtocol} to ${finalProviderProtocol}`); - codexRequestBody = convertData(codexRequestBody, 'request', fromProtocol, slotProviderType || CONFIG.MODEL_PROVIDER); + codexRequestBody = convertData(codexRequestBody, 'request', fromProvider, toProvider, model, currentConfig._monitorRequestId); + + // 保持以 _ 开头的内部属性 + Object.keys(virtualOpenAIRequest).forEach(key => { + if (key.startsWith('_') && codexRequestBody[key] === undefined) { + codexRequestBody[key] = virtualOpenAIRequest[key]; + } + }); } logger.info(`[Image Generation] model=${model}, protocol=${finalProviderProtocol}, n=${n}, response_format=${response_format}${size ? `, size=${size}` : ''}`); @@ -219,21 +229,52 @@ async function handleImageGenerationRequest(req, res, currentConfig, providerPoo } if (data.length === 0) { - const rejectionText = extractRejectionMessage(responses, finalProviderProtocol); - if (rejectionText) { - logger.warn(`[Image Generation] Content policy rejection: ${rejectionText.slice(0, 100)}`); + // 检查是否有拒绝消息 + const rejection = extractRejectionMessage(responses, finalProviderProtocol); + if (rejection) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { code: 'content_policy_violation', message: rejectionText, type: 'invalid_request_error' } })); + res.end(JSON.stringify({ error: { message: `Image generation rejected: ${rejection}`, type: 'invalid_request_error' } })); } else { - logger.error('[Image Generation] No image found in response output'); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'Image generation failed: no image in response', type: 'server_error' } })); } return; } + const clientResponse = { created: Math.floor(Date.now() / 1000), data }; + + // 监控钩子:内容生成后与一元响应 + if (currentConfig._monitorRequestId) { + try { + const { getPluginManager } = await import('../core/plugin-manager.js'); + const pluginManager = getPluginManager(); + if (pluginManager) { + await pluginManager.executeHook('onContentGenerated', { + ...currentConfig, + originalRequestBody: { model, prompt, n, size, response_format }, + processedRequestBody: codexRequestBody, + fromProvider, + toProvider, + model, + isStream: false + }); + + await pluginManager.executeHook('onUnaryResponse', { + nativeResponse: responses.length === 1 ? responses[0] : responses, + clientResponse, + fromProvider, + toProvider, + model, + requestId: currentConfig._monitorRequestId + }); + } + } catch (e) { + logger.error('[Image Generation] Hook error:', e.message); + } + } + res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ created: Math.floor(Date.now() / 1000), data })); + res.end(JSON.stringify(clientResponse)); } catch (error) { logger.error('[Image Generation] Error:', error.message); @@ -447,7 +488,8 @@ async function handleImageEditsRequest(req, res, currentConfig, providerPoolMana n, size, response_format, - _imageSize: size + _imageSize: size, + _monitorRequestId: currentConfig._monitorRequestId // 注入监控 ID }; const shouldUsePool = !!(providerPoolManager && currentConfig.providerPools); @@ -466,13 +508,22 @@ async function handleImageEditsRequest(req, res, currentConfig, providerPoolMana } const finalProviderProtocol = getProtocolPrefix(slotProviderType || currentConfig.MODEL_PROVIDER); + const fromProvider = MODEL_PROTOCOL_PREFIX.OPENAI; + const toProvider = slotProviderType || currentConfig.MODEL_PROVIDER; // 执行自动转换:OpenAI -> 目标协议 let codexRequestBody = virtualOpenAIRequest; const fromProtocol = MODEL_PROTOCOL_PREFIX.OPENAI; if (fromProtocol !== finalProviderProtocol) { logger.info(`[Image Edits] Converting request from ${fromProtocol} to ${finalProviderProtocol}`); - codexRequestBody = convertData(codexRequestBody, 'request', fromProtocol, slotProviderType || currentConfig.MODEL_PROVIDER); + codexRequestBody = convertData(codexRequestBody, 'request', fromProtocol, toProvider, model, currentConfig._monitorRequestId); + + // 保持以 _ 开头的内部属性 + Object.keys(virtualOpenAIRequest).forEach(key => { + if (key.startsWith('_') && codexRequestBody[key] === undefined) { + codexRequestBody[key] = virtualOpenAIRequest[key]; + } + }); } logger.info(`[Image Edits] model=${model}, protocol=${finalProviderProtocol}, n=${n}, response_format=${response_format}, imageSize=${Math.round(buffer.length / 1024)}KB${size ? `, size=${size}` : ''}`); @@ -481,29 +532,60 @@ async function handleImageEditsRequest(req, res, currentConfig, providerPoolMana service.generateContent(model, { ...codexRequestBody }) ); const responses = await Promise.all(imageRequests); - const data = []; - for (const response of responses) { - const extracted = extractImagesFromServiceResponse(response, finalProviderProtocol, response_format); + + for (let i = 0; i < responses.length; i++) { + const extracted = extractImagesFromServiceResponse(responses[i], finalProviderProtocol, response_format); data.push(...extracted); } if (data.length === 0) { - const rejectionText = extractRejectionMessage(responses, finalProviderProtocol); - if (rejectionText) { - logger.warn(`[Image Edits] Content policy rejection: ${rejectionText.slice(0, 100)}`); + // 检查是否有拒绝消息 + const rejection = extractRejectionMessage(responses, finalProviderProtocol); + if (rejection) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { code: 'content_policy_violation', message: rejectionText, type: 'invalid_request_error' } })); + res.end(JSON.stringify({ error: { message: `Image editing rejected: ${rejection}`, type: 'invalid_request_error' } })); } else { - logger.error('[Image Edits] No image found in response output'); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'Image editing failed: no image in response', type: 'server_error' } })); } return; } + const clientResponse = { created: Math.floor(Date.now() / 1000), data }; + + // 监控钩子 + if (currentConfig._monitorRequestId) { + try { + const { getPluginManager } = await import('../core/plugin-manager.js'); + const pluginManager = getPluginManager(); + if (pluginManager) { + await pluginManager.executeHook('onContentGenerated', { + ...currentConfig, + originalRequestBody: { model, prompt, n, size, response_format }, + processedRequestBody: codexRequestBody, + fromProvider, + toProvider, + model, + isStream: false + }); + + await pluginManager.executeHook('onUnaryResponse', { + nativeResponse: responses.length === 1 ? responses[0] : responses, + clientResponse, + fromProvider, + toProvider, + model, + requestId: currentConfig._monitorRequestId + }); + } + } catch (e) { + logger.error('[Image Edits] Hook error:', e.message); + } + } + res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ created: Math.floor(Date.now() / 1000), data })); + res.end(JSON.stringify(clientResponse)); } catch (error) { logger.error('[Image Edits] Error:', error.message); if (!res.writableEnded) { diff --git a/static/app/i18n.js b/static/app/i18n.js index 26189ff0f..5b1231954 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -819,6 +819,7 @@ const translations = { 'playground.systemRolePlaceholder': '在此定义 AI 的角色、语气和背景知识...', 'playground.hyperparameters': '超参数调优 (Hyperparameters)', 'playground.thinking': '思考过程', + 'playground.stream': '流式输出 (Stream)', 'playground.status.ready': '已就绪', 'playground.status.unready': '未就绪', @@ -1931,6 +1932,7 @@ const translations = { 'playground.systemRolePlaceholder': 'Define AI role, tone, and background here...', 'playground.hyperparameters': 'Hyperparameters', 'playground.thinking': 'Thinking', + 'playground.stream': 'Stream Output', 'playground.status.ready': 'Ready', 'playground.status.unready': 'Not Ready', diff --git a/static/app/playground-manager.js b/static/app/playground-manager.js index bb2befe6f..ffcbf9f92 100644 --- a/static/app/playground-manager.js +++ b/static/app/playground-manager.js @@ -28,6 +28,7 @@ function getSystemInput() { return el('pg-system-input'); } function getTempSlider() { return el('pg-temp-slider'); } function getTempVal() { return el('pg-temp-val'); } function getMaxTokens() { return el('pg-max-tokens'); } +function getStreamCheckbox() { return el('pg-stream-checkbox'); } // ── Initialisation ─────────────────────────────────────────────────────────── @@ -90,6 +91,18 @@ function bindEvents() { } }); + document.addEventListener('change', (e) => { + if (e.target.id === 'pg-interface-select') { + const isChat = e.target.value === 'chat'; + const streamBox = getStreamCheckbox(); + if (streamBox) { + streamBox.disabled = !isChat; + const wrap = streamBox.closest('.pg-stream-toggle-wrap'); + if (wrap) wrap.style.opacity = isChat ? '1' : '0.5'; + } + } + }); + document.addEventListener('input', (e) => { if (e.target.id === 'pg-temp-slider') { const val = getTempVal(); @@ -186,6 +199,7 @@ async function handleSend() { const sysPrompt = getSystemInput()?.value.trim(); const temp = parseFloat(getTempSlider()?.value || '0.7'); const maxTokens = parseInt(getMaxTokens()?.value || '4096'); + const useStream = getStreamCheckbox()?.checked ?? true; // Build history for request const requestMessages = []; @@ -214,12 +228,18 @@ async function handleSend() { if (interfaceType === 'image' || interfaceType === 'image-edit') { await imageResponse(provider, model, text, filesToSend, assistantBubble, interfaceType); - } else { + } else if (useStream) { await streamResponse(provider, model, assistantBubble, { messages: requestMessages, temperature: temp, max_tokens: maxTokens }); + } else { + await unaryResponse(provider, model, assistantBubble, { + messages: requestMessages, + temperature: temp, + max_tokens: maxTokens + }); } } @@ -308,6 +328,74 @@ async function imageResponse(provider, model, prompt, files, bubble, interfaceTy } } +async function unaryResponse(provider, model, bubble, params) { + isStreaming = true; + updateInputState(); + + let errorMsg = ''; + try { + const response = await fetch('/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + 'model-provider': provider + }, + body: JSON.stringify({ + model, + messages: params.messages, + temperature: params.temperature, + max_tokens: params.max_tokens, + stream: false + }) + }); + + if (!response.ok) { + errorMsg = await parseResponseError(response); + throw new Error(errorMsg); + } + + const json = await response.json(); + const content = json.choices?.[0]?.message?.content || ''; + const reasoning = json.choices?.[0]?.message?.reasoning_content || json.choices?.[0]?.message?.thinking || ''; + + bubble.innerHTML = ''; + const msgWrapper = bubble.closest('.pg-message'); + if (msgWrapper) msgWrapper.style.display = 'flex'; + + if (reasoning) { + const resDiv = document.createElement('div'); + resDiv.className = 'pg-reasoning'; + resDiv.innerHTML = `
${t('playground.thinking')}
`; + resDiv.querySelector('.pg-reasoning-content').textContent = reasoning; + bubble.appendChild(resDiv); + } + + if (content) { + const contentDiv = document.createElement('div'); + contentDiv.innerHTML = renderMarkdown(content); + while (contentDiv.firstChild) bubble.appendChild(contentDiv.firstChild); + + const historyContent = content.replace(/data:[^;]+;base64,[A-Za-z0-9+/=]+/g, '[图片]'); + messages.push({role: 'assistant', content: historyContent}); + } + + } catch (e) { + console.error('[Playground] Unary error:', e.message); + errorMsg = e.message || t('playground.reqFailed'); + } finally { + if (errorMsg) { + bubble.textContent = errorMsg; + bubble.closest('.pg-message')?.classList.add('error'); + const msgWrapper = bubble.closest('.pg-message'); + if (msgWrapper) msgWrapper.style.display = 'flex'; + } + isStreaming = false; + updateInputState(); + scrollToBottom(); + } +} + async function streamResponse(provider, model, bubble, params) { isStreaming = true; updateInputState(); diff --git a/static/components/section-playground.css b/static/components/section-playground.css index f19bd5199..9f3c4b4b0 100644 --- a/static/components/section-playground.css +++ b/static/components/section-playground.css @@ -461,6 +461,69 @@ box-shadow: 0 0 0 3px var(--primary-10); } +/* Switch 样式 */ +.pg-workspace .pg-switch { + position: relative; + display: inline-block; + width: 42px; + height: 22px; +} + +.pg-workspace .pg-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.pg-workspace .pg-switch-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--bg-tertiary); + transition: .4s; + border-radius: 34px; + border: 1px solid var(--border-color); +} + +.pg-workspace .pg-switch-slider:before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 2px; + bottom: 2px; + background-color: white; + transition: .4s; + border-radius: 50%; + box-shadow: var(--shadow-sm); +} + +.pg-workspace .pg-switch input:checked + .pg-switch-slider { + background-color: var(--primary-color); + border-color: var(--primary-hover); +} + +.pg-workspace .pg-switch input:focus + .pg-switch-slider { + box-shadow: 0 0 1px var(--primary-color); +} + +.pg-workspace .pg-switch input:checked + .pg-switch-slider:before { + transform: translateX(20px); +} + +.pg-workspace .pg-stream-toggle-wrap { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-primary); + padding: 0.75rem 1rem; + border-radius: 12px; + border: 1px solid var(--border-color); +} + /* Range Input 美化 */ .pg-workspace input[type=range] { width: 100%; diff --git a/static/components/section-playground.html b/static/components/section-playground.html index 1591241b3..e46444af0 100644 --- a/static/components/section-playground.html +++ b/static/components/section-playground.html @@ -39,6 +39,16 @@

推理实验

+ +
+
+ + +
+
From 153e87c17f3919bd8ee63ba347c5427580ef56e0 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Wed, 29 Apr 2026 17:59:29 +0800 Subject: [PATCH 083/135] =?UTF-8?q?feat(grok):=20=E5=B0=86=20Grok=20?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=E5=95=86=E4=BB=8E=20custom=20=E9=87=8D?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E4=B8=BA=20web=20=E5=B9=B6=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 `MODEL_PROVIDER.GROK_CUSTOM` 重命名为 `GROK_WEB` - 更新所有相关配置文件、前端显示、路由示例和文档 - 简化请求追踪逻辑,移除 `_pluginRequestId` 仅保留 `_monitorRequestId` - 将版本号从 2.15.9.2 更新至 2.16.0 --- README-JA.md | 7 ++++--- README-ZH.md | 7 ++++--- README.md | 7 ++++--- VERSION | 2 +- src/auth/grok-auth.js | 2 +- src/handlers/request-handler.js | 2 +- src/plugins/ai-monitor/index.js | 6 +++--- src/plugins/api-potluck/index.js | 3 +-- src/providers/adapter.js | 2 +- src/providers/grok/grok-core.js | 10 +++++----- src/providers/grok/grok-strategy.js | 2 +- src/providers/grok/ws-imagine.js | 2 +- src/providers/provider-models.js | 2 +- src/providers/provider-pool-manager.js | 2 +- src/services/service-manager.js | 2 +- src/services/usage-service.js | 8 ++++---- src/ui-modules/usage-api.js | 4 ++-- src/utils/common.js | 2 +- src/utils/constants.js | 2 +- src/utils/grok-assets-proxy.js | 2 +- src/utils/provider-utils.js | 6 +++--- static/app/access-manager.js | 2 +- static/app/i18n.js | 13 +++++++------ static/app/modal.js | 2 +- static/app/models-manager.js | 2 +- static/app/provider-manager.js | 4 ++-- static/app/routing-examples.js | 12 ++++++------ static/app/usage-manager.js | 4 ++-- static/app/utils.js | 5 +++-- static/components/section-config.html | 8 ++++---- 30 files changed, 69 insertions(+), 65 deletions(-) diff --git a/README-JA.md b/README-JA.md index 88dc332cc..56eff530c 100644 --- a/README-JA.md +++ b/README-JA.md @@ -441,10 +441,11 @@ curl http://localhost:3000/claude-kiro-oauth/v1/chat/completions \ "gemini-cli-oauth", "gemini-antigravity", "claude-kiro-oauth", - "grok-custom" + "grok-web" ] -} - ``` + } + ``` + 3. **プロバイダー独自のプロキシ済みエンドポイント**:一部のプロバイダー(OpenAI、Claudeなど)はプロキシ済みAPIエンドポイントの設定をサポートしています diff --git a/README-ZH.md b/README-ZH.md index 16628945f..5d3fd4d10 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -439,10 +439,11 @@ curl http://localhost:3000/claude-kiro-oauth/v1/chat/completions \ "gemini-cli-oauth", "gemini-antigravity", "claude-kiro-oauth", - "grok-custom" + "grok-web" ] -} - ``` + } + ``` + 3. **提供商自带代理端点**:某些提供商(如 OpenAI、Claude)支持配置已代理的 API 端点 diff --git a/README.md b/README.md index 272c59f6b..7f8e96203 100644 --- a/README.md +++ b/README.md @@ -441,10 +441,11 @@ This project supports flexible proxy configuration, allowing you to configure a "gemini-cli-oauth", "gemini-antigravity", "claude-kiro-oauth", - "grok-custom" + "grok-web" ] -} - ``` + } + ``` + 3. **Provider-Specific Proxied Endpoints**: Some providers (like OpenAI, Claude) support configuring proxied API endpoints diff --git a/VERSION b/VERSION index ad6907ae2..752490696 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.15.9.2 +2.16.0 diff --git a/src/auth/grok-auth.js b/src/auth/grok-auth.js index 40ded754a..30ce04d52 100644 --- a/src/auth/grok-auth.js +++ b/src/auth/grok-auth.js @@ -23,7 +23,7 @@ export async function batchImportGrokTokensStream(tokens, onProgress = null, ski details: [] }; - const providerType = 'grok-custom'; + const providerType = 'grok-web'; const poolManager = getProviderPoolManager(); const allPools = poolManager ? poolManager.providerPools : (CONFIG.providerPools || {}); if (!allPools[providerType]) allPools[providerType] = []; diff --git a/src/handlers/request-handler.js b/src/handlers/request-handler.js index 6b63254c1..d39b473df 100644 --- a/src/handlers/request-handler.js +++ b/src/handlers/request-handler.js @@ -54,7 +54,7 @@ export function createRequestHandler(config, providerPoolManager) { return logger.runWithContext(requestId, async () => { // Deep copy the config for each request to allow dynamic modification const currentConfig = deepmerge({}, config); - currentConfig._pluginRequestId = requestId; + currentConfig._monitorRequestId = requestId; // 计算当前请求的基础 URL const protocol = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'; diff --git a/src/plugins/ai-monitor/index.js b/src/plugins/ai-monitor/index.js index 9db7a8fa4..8a6166f76 100644 --- a/src/plugins/ai-monitor/index.js +++ b/src/plugins/ai-monitor/index.js @@ -34,7 +34,7 @@ const aiMonitorPlugin = { ]; const isAiPath = aiPaths.some(path => requestUrl.pathname.includes(path)); - if (isAiPath && req.method === 'POST') { + if (isAiPath && req.method === 'POST' && !config._monitorRequestId) { // 在监控插件中生成请求标识,并存入 config 以供全链路追踪 const requestId = Date.now() + Math.random().toString(36).substring(2, 10); config._monitorRequestId = requestId; @@ -48,9 +48,9 @@ const aiMonitorPlugin = { * 请求转换后的钩子 */ async onContentGenerated(config) { - const { originalRequestBody, processedRequestBody, fromProvider, toProvider, model, _monitorRequestId, _pluginRequestId, isStream } = config; + const { originalRequestBody, processedRequestBody, fromProvider, toProvider, model, _monitorRequestId, isStream } = config; if (!originalRequestBody) return; - const traceRequestId = _pluginRequestId || _monitorRequestId; + const traceRequestId = _monitorRequestId; setImmediate(() => { const hasConversion = JSON.stringify(originalRequestBody) !== JSON.stringify(processedRequestBody); diff --git a/src/plugins/api-potluck/index.js b/src/plugins/api-potluck/index.js index cf84325aa..0c4acc576 100644 --- a/src/plugins/api-potluck/index.js +++ b/src/plugins/api-potluck/index.js @@ -120,8 +120,7 @@ function extractUsage(...candidates) { function getTrackedRequestIds(hookContext = {}) { return [...new Set([ - hookContext._monitorRequestId, - hookContext._pluginRequestId + hookContext._monitorRequestId ].filter(Boolean))]; } diff --git a/src/providers/adapter.js b/src/providers/adapter.js index 8e429bed9..c8c028a4c 100644 --- a/src/providers/adapter.js +++ b/src/providers/adapter.js @@ -708,7 +708,7 @@ registerAdapter(MODEL_PROVIDER.GEMINI_CLI, GeminiApiServiceAdapter); registerAdapter(MODEL_PROVIDER.ANTIGRAVITY, AntigravityApiServiceAdapter); registerAdapter(MODEL_PROVIDER.KIRO_API, KiroApiServiceAdapter); registerAdapter(MODEL_PROVIDER.CODEX_API, CodexApiServiceAdapter); -registerAdapter(MODEL_PROVIDER.GROK_CUSTOM, GrokApiServiceAdapter); +registerAdapter(MODEL_PROVIDER.GROK_WEB, GrokApiServiceAdapter); // registerAdapter(MODEL_PROVIDER.FORWARD_API, ForwardApiServiceAdapter); // registerAdapter(MODEL_PROVIDER.QWEN_API, QwenApiServiceAdapter); // registerAdapter(MODEL_PROVIDER.IFLOW_API, IFlowApiServiceAdapter); diff --git a/src/providers/grok/grok-core.js b/src/providers/grok/grok-core.js index 7d20b5b19..5c9d5eb82 100644 --- a/src/providers/grok/grok-core.js +++ b/src/providers/grok/grok-core.js @@ -204,7 +204,7 @@ export class GrokApiService { } _applySidecar(axiosConfig) { - return configureTLSSidecar(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM); + return configureTLSSidecar(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_WEB); } /** @@ -231,7 +231,7 @@ export class GrokApiService { } = options; // 检查是否启用了 TLS Sidecar - const isTLSSidecarEnabled = isTLSSidecarEnabledForProvider(this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM); + const isTLSSidecarEnabled = isTLSSidecarEnabledForProvider(this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_WEB); const axiosConfig = { method, @@ -247,7 +247,7 @@ export class GrokApiService { if (!isTLSSidecarEnabled) { axiosConfig.httpAgent = httpAgent; axiosConfig.httpsAgent = httpsAgent; - configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM); + configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_WEB); } this._applySidecar(axiosConfig); @@ -344,7 +344,7 @@ export class GrokApiService { // await this.getUsageLimits(); return Promise.resolve(); const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { - poolManager.resetProviderRefreshStatus(this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM, this.uuid); + poolManager.resetProviderRefreshStatus(this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_WEB, this.uuid); } return true; } catch (error) { @@ -1297,7 +1297,7 @@ export class GrokApiService { if (requestBody._requestBaseUrl) delete requestBody._requestBaseUrl; if (this.isExpiryDateNear() && getProviderPoolManager() && this.uuid) { - getProviderPoolManager().markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM, { uuid: this.uuid }); + getProviderPoolManager().markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_WEB, { uuid: this.uuid }); } const rawModel = typeof model === 'string' ? model : ''; diff --git a/src/providers/grok/grok-strategy.js b/src/providers/grok/grok-strategy.js index e03702d0b..509f625a5 100644 --- a/src/providers/grok/grok-strategy.js +++ b/src/providers/grok/grok-strategy.js @@ -34,7 +34,7 @@ class GrokStrategy extends ProviderStrategy { return requestBody; } - // Grok reverse interface combines system prompt into message + // Grok web interface combines system prompt into message // Here we can prepend it if needed, or handle it during request conversion. // Since requestBody already contains the converted message, we might need to prepend it here. diff --git a/src/providers/grok/ws-imagine.js b/src/providers/grok/ws-imagine.js index a0e2fb617..d265ff953 100644 --- a/src/providers/grok/ws-imagine.js +++ b/src/providers/grok/ws-imagine.js @@ -26,7 +26,7 @@ export class ImagineWebSocketService { * @returns {AsyncGenerator} */ async *stream(token, prompt, aspectRatio = '1:1', n = 1, enableNsfw = true) { - const proxyConfig = getProxyConfigForProvider(this.config, MODEL_PROVIDER.GROK_CUSTOM); + const proxyConfig = getProxyConfigForProvider(this.config, MODEL_PROVIDER.GROK_WEB); const agent = proxyConfig?.httpsAgent; let ssoToken = token || ""; diff --git a/src/providers/provider-models.js b/src/providers/provider-models.js index 1bc900a3c..806327dc5 100644 --- a/src/providers/provider-models.js +++ b/src/providers/provider-models.js @@ -122,7 +122,7 @@ export const PROVIDER_MODELS = { 'gpt-image-2', ], 'forward-api': [], - 'grok-custom': [ + 'grok-web': [ 'grok-4.1-mini', 'grok-4.1-thinking', 'grok-4.20', diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index a9ea5fa59..a4884ede8 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -58,7 +58,7 @@ export class ProviderPoolManager { 'openai-codex-oauth': 'gpt-5-codex-mini', 'openaiResponses-custom': 'gpt-4o-mini', 'forward-api': 'gpt-4o-mini', - 'grok-custom': 'grok-4.1-mini', + 'grok-web': 'grok-4.1-mini', }; constructor(providerPools, options = {}) { diff --git a/src/services/service-manager.js b/src/services/service-manager.js index 9f62e603c..1430bb2b1 100644 --- a/src/services/service-manager.js +++ b/src/services/service-manager.js @@ -582,7 +582,7 @@ export async function getProviderStatus(config, options = {}) { 'gemini-antigravity': 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH', 'openai-iflow': 'IFLOW_TOKEN_FILE_PATH', 'forward-api': 'FORWARD_BASE_URL', - 'grok-custom': 'GROK_COOKIE_TOKEN', + 'grok-web': 'GROK_COOKIE_TOKEN', 'openai-codex-oauth': 'CODEX_OAUTH_CREDS_FILE_PATH' }; let providerPoolsSlim = []; diff --git a/src/services/usage-service.js b/src/services/usage-service.js index e4603470f..1864db4d8 100644 --- a/src/services/usage-service.js +++ b/src/services/usage-service.js @@ -18,7 +18,7 @@ export class UsageService { [MODEL_PROVIDER.GEMINI_CLI]: this.getGeminiUsage.bind(this), [MODEL_PROVIDER.ANTIGRAVITY]: this.getAntigravityUsage.bind(this), [MODEL_PROVIDER.CODEX_API]: this.getCodexUsage.bind(this), - [MODEL_PROVIDER.GROK_CUSTOM]: this.getGrokUsage.bind(this), + [MODEL_PROVIDER.GROK_WEB]: this.getGrokUsage.bind(this), }; } @@ -192,7 +192,7 @@ export class UsageService { * @returns {Promise} Grok 用量信息 */ async getGrokUsage(uuid = null) { - const providerKey = uuid ? MODEL_PROVIDER.GROK_CUSTOM + uuid : MODEL_PROVIDER.GROK_CUSTOM; + const providerKey = uuid ? MODEL_PROVIDER.GROK_WEB + uuid : MODEL_PROVIDER.GROK_WEB; const adapter = serviceInstances[providerKey]; if (!adapter) { @@ -550,8 +550,8 @@ export function formatGrokUsage(usageData) { // 订阅信息 subscription: { - title: 'Grok Custom', - type: 'grok-custom', + title: 'Grok Web', + type: 'grok-web', upgradeCapability: null, overageCapability: null }, diff --git a/src/ui-modules/usage-api.js b/src/ui-modules/usage-api.js index 5dbe14883..5c2db3991 100644 --- a/src/ui-modules/usage-api.js +++ b/src/ui-modules/usage-api.js @@ -7,7 +7,7 @@ import { PROVIDER_MAPPINGS } from '../utils/provider-utils.js'; import path from 'path'; import { existsSync, readFileSync } from 'fs'; -const supportedProviders = ['claude-kiro-oauth', 'gemini-cli-oauth', 'gemini-antigravity', 'openai-codex-oauth', 'grok-custom']; +const supportedProviders = ['claude-kiro-oauth', 'gemini-cli-oauth', 'gemini-antigravity', 'openai-codex-oauth', 'grok-web']; /** @@ -210,7 +210,7 @@ async function getAdapterUsage(adapter, providerType) { throw new Error('This adapter does not support usage query'); } - if (providerType === 'grok-custom') { + if (providerType === 'grok-web') { if (typeof adapter.getUsageLimits === 'function') { const rawUsage = await adapter.getUsageLimits(); return formatGrokUsage(rawUsage); diff --git a/src/utils/common.js b/src/utils/common.js index b6f7e8df1..a60fd419f 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -589,7 +589,7 @@ export async function handleUnifiedResponse(res, responsePayload, isStream, stat } function getPluginHookRequestId(config) { - return config?._monitorRequestId || config?._pluginRequestId || null; + return config?._monitorRequestId || null; } export async function handleStreamRequest(res, service, model, requestBody, fromProvider, toProvider, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME, providerPoolManager, pooluuid, customName, retryContext = null) { diff --git a/src/utils/constants.js b/src/utils/constants.js index ee80e416a..23e440992 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -64,7 +64,7 @@ export const MODEL_PROVIDER = { IFLOW_API: 'openai-iflow', CODEX_API: 'openai-codex-oauth', FORWARD_API: 'forward-api', - GROK_CUSTOM: 'grok-custom', + GROK_WEB: 'grok-web', AUTO: 'auto', }; diff --git a/src/utils/grok-assets-proxy.js b/src/utils/grok-assets-proxy.js index 1ade394eb..1ac5a6092 100644 --- a/src/utils/grok-assets-proxy.js +++ b/src/utils/grok-assets-proxy.js @@ -83,7 +83,7 @@ export async function handleGrokAssetsProxy(req, res, config, providerPoolManage }; // 配置代理 - configureAxiosProxy(axiosConfig, config, MODEL_PROVIDER.GROK_CUSTOM); + configureAxiosProxy(axiosConfig, config, MODEL_PROVIDER.GROK_WEB); logger.debug(`[Grok Proxy] Proxying request to: ${finalTargetUrl}`); diff --git a/src/utils/provider-utils.js b/src/utils/provider-utils.js index de2852ce1..59ea5dab3 100644 --- a/src/utils/provider-utils.js +++ b/src/utils/provider-utils.js @@ -80,13 +80,13 @@ export const PROVIDER_MAPPINGS = [ urlKeys: ['CODEX_BASE_URL'] }, { - // Grok Reverse 配置 + // Grok Web 配置 dirName: 'grok', patterns: ['configs/grok/', '/grok/'], - providerType: 'grok-custom', + providerType: 'grok-web', credPathKey: 'GROK_COOKIE_TOKEN', defaultCheckModel: 'grok-4.1-mini', - displayName: 'Grok Reverse', + displayName: 'Grok Web', needsProjectId: false, urlKeys: ['GROK_BASE_URL', 'GROK_CF_CLEARANCE', 'GROK_USER_AGENT'] } diff --git a/static/app/access-manager.js b/static/app/access-manager.js index 7c72320b1..3a6e62970 100644 --- a/static/app/access-manager.js +++ b/static/app/access-manager.js @@ -14,7 +14,7 @@ const recommendedModelMap = { 'openai-qwen-oauth': 'qwen3-coder-plus', 'openai-iflow': 'qwen3-max', 'openai-codex-oauth': 'gpt-5', - 'grok-custom': 'grok-4.1-mini', + 'grok-web': 'grok-4.1-mini', 'openaiResponses-custom': 'gpt-4o', 'forward-api': 'gpt-4o' }; diff --git a/static/app/i18n.js b/static/app/i18n.js index 5b1231954..1c4002a4e 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -103,7 +103,7 @@ const translations = { 'dashboard.routing.nodeName.responses': 'OpenAI Responses', 'dashboard.routing.description.responses': '结构化对话API', 'dashboard.routing.badge.responses': 'Responses', - 'dashboard.routing.nodeName.grok': 'Grok Reverse', + 'dashboard.routing.nodeName.grok': 'Grok Web', 'dashboard.contact.title': '联系与赞助', 'dashboard.contact.wechat': '扫码进群,注明来意', 'dashboard.contact.wechatDesc': '添加微信获取更多技术支持和交流', @@ -501,7 +501,7 @@ const translations = { 'upload.providerFilter.antigravity': 'Antigravity', 'upload.providerFilter.codex': 'Codex OAuth', 'upload.providerFilter.iflow': 'iFlow OAuth', - 'upload.providerFilter.grok': 'Grok Reverse', + 'upload.providerFilter.grok': 'Grok Web', 'upload.providerFilter.other': '其他/未识别', 'upload.statusFilter': '关联状态', 'upload.statusFilter.all': '全部状态', @@ -906,7 +906,7 @@ const translations = { 'guide.providers.claude.desc': '使用 Claude 官方 API 或第三方代理访问 Claude 系列模型', 'guide.providers.openai.desc': '使用 OpenAI 官方 API 或第三方代理访问 GPT 系列模型', 'guide.providers.iflow.desc': '通过 iFlow OAuth 认证访问 Qwen、Kimi、DeepSeek、GLM 等模型', - 'guide.providers.grok.desc': '通过 Grok 逆向接口访问 Grok-3、Grok-4 等模型,支持生图与视频生成', + 'guide.providers.grok.desc': '通过 Grok Web 接口访问 Grok-3、Grok-4 等模型,支持生图与视频生成', 'guide.client.title': '客户端配置指南', 'guide.client.desc': '以下是常见 AI 客户端的配置方法,将 API 端点设置为本服务地址即可使用:', 'guide.client.cherry.step1': '打开设置 → 模型服务商', @@ -1207,8 +1207,9 @@ const translations = { 'dashboard.routing.nodeName.responses': 'OpenAI Responses', 'dashboard.routing.description.responses': 'Structured Dialogue API', 'dashboard.routing.badge.responses': 'Responses', - 'dashboard.routing.nodeName.grok': 'Grok Reverse', + 'dashboard.routing.nodeName.grok': 'Grok Web', 'dashboard.contact.title': 'Contact & Support', + 'dashboard.contact.wechat': 'Scan to Join Group', 'dashboard.contact.wechatDesc': 'Add WeChat for more technical support and communication', 'dashboard.contact.x': 'Follow on X.com', @@ -1615,7 +1616,7 @@ const translations = { 'upload.providerFilter.antigravity': 'Antigravity', 'upload.providerFilter.codex': 'Codex OAuth', 'upload.providerFilter.iflow': 'iFlow OAuth', - 'upload.providerFilter.grok': 'Grok Reverse', + 'upload.providerFilter.grok': 'Grok Web', 'upload.providerFilter.other': 'Other/Unknown', 'upload.statusFilter': 'Association Status', 'upload.statusFilter.all': 'All Status', @@ -2019,7 +2020,7 @@ const translations = { 'guide.providers.claude.desc': 'Access Claude models via official API or third-party proxy', 'guide.providers.openai.desc': 'Access GPT models via official API or third-party proxy', 'guide.providers.iflow.desc': 'Access Qwen, Kimi, DeepSeek, GLM via iFlow OAuth', - 'guide.providers.grok.desc': 'Access Grok-3, Grok-4 models via Grok reverse interface, supports image and video generation', + 'guide.providers.grok.desc': 'Access Grok-3, Grok-4 models via Grok web interface, supports image and video generation', 'guide.client.title': 'Client Configuration Guide', 'guide.client.desc': 'Here are configuration methods for common AI clients. Set the API endpoint to this service address:', 'guide.client.cherry.step1': 'Open Settings → Model Providers', diff --git a/static/app/modal.js b/static/app/modal.js index a40f0a9fd..f5d5110a7 100644 --- a/static/app/modal.js +++ b/static/app/modal.js @@ -1272,7 +1272,7 @@ function getFieldOrder(provider) { } else if (provider.CODEX_OAUTH_CREDS_FILE_PATH) { providerType = 'openai-codex-oauth'; } else if (provider.GROK_COOKIE_TOKEN) { - providerType = 'grok-custom'; + providerType = 'grok-web'; } else if (provider.FORWARD_API_KEY) { providerType = 'forward-api'; } diff --git a/static/app/models-manager.js b/static/app/models-manager.js index 7d0e5d995..f77b048ba 100644 --- a/static/app/models-manager.js +++ b/static/app/models-manager.js @@ -191,7 +191,7 @@ function getProviderDisplayName(providerType) { 'openai-qwen-oauth': 'Qwen (OAuth)', 'openai-iflow': 'iFlow', 'openai-codex-oauth': 'OpenAI Codex (OAuth)', - 'grok-custom': 'Grok Reverse' + 'grok-web': 'Grok Web' }; if (displayNames[providerType]) { diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js index e94ec264a..b5ad5c911 100644 --- a/static/app/provider-manager.js +++ b/static/app/provider-manager.js @@ -759,7 +759,7 @@ async function openProviderManager(providerType, searchTerm = '') { */ function generateAuthButton(providerType) { // 只为支持OAuth或批量导入的提供商显示授权按钮 - const oauthProviders = ['gemini-cli-oauth', 'gemini-antigravity', 'openai-qwen-oauth', 'claude-kiro-oauth', 'openai-iflow', 'openai-codex-oauth', 'grok-custom']; + const oauthProviders = ['gemini-cli-oauth', 'gemini-antigravity', 'openai-qwen-oauth', 'claude-kiro-oauth', 'openai-iflow', 'openai-codex-oauth', 'grok-web']; if (!oauthProviders.includes(providerType)) { return ''; @@ -873,7 +873,7 @@ async function handleGenerateAuthUrl(providerType) { } // 如果是 Grok,显示认证方式选择对话框(目前仅支持批量导入,因为没有标准 OAuth) - if (providerType === 'grok-custom') { + if (providerType === 'grok-web') { showGrokAuthMethodSelector(providerType); return; } diff --git a/static/app/routing-examples.js b/static/app/routing-examples.js index d983ade20..870a08a16 100644 --- a/static/app/routing-examples.js +++ b/static/app/routing-examples.js @@ -233,11 +233,11 @@ function getAvailableRoutes() { badgeClass: 'responses' }, { - provider: 'grok-custom', + provider: 'grok-web', name: t('dashboard.routing.nodeName.grok'), paths: { - openai: '/grok-custom/v1/chat/completions', - claude: '/grok-custom/v1/messages' + openai: '/grok-web/v1/chat/completions', + claude: '/grok-web/v1/messages' }, description: t('dashboard.routing.free'), badge: t('dashboard.routing.free'), @@ -404,7 +404,7 @@ async function copyCurlExample(provider, options = {}) { }'`; } break; - case 'grok-custom': + case 'grok-web': if (protocol === 'openai') { curlCommand = `curl ${hostname}${path} \\ -H "Content-Type: application/json" \\ @@ -479,7 +479,7 @@ function renderRoutingExamples(providerConfigs) { 'openaiResponses-custom': 'fa-comment-alt', 'openai-iflow': 'fa-wind', 'openai-codex-oauth': 'fa-keyboard', - 'grok-custom': 'fa-search' + 'grok-web': 'fa-search' }; // 默认模型映射 (用于 curl 示例) @@ -492,7 +492,7 @@ function renderRoutingExamples(providerConfigs) { 'openai-qwen-oauth': 'qwen3-coder-plus', 'openai-iflow': 'qwen3-max', 'openai-codex-oauth': 'gpt-5', - 'grok-custom': 'grok-4.1-mini', + 'grok-web': 'grok-4.1-mini', 'openaiResponses-custom': 'gpt-4o' }; diff --git a/static/app/usage-manager.js b/static/app/usage-manager.js index 617e70a63..685ef57f6 100644 --- a/static/app/usage-manager.js +++ b/static/app/usage-manager.js @@ -917,7 +917,7 @@ function getProviderDisplayName(providerType) { 'gemini-antigravity': 'Gemini Antigravity', 'openai-codex-oauth': 'Codex OAuth', 'openai-qwen-oauth': 'Qwen OAuth', - 'grok-custom': 'Grok Reverse' + 'grok-web': 'Grok Web' }; return names[providerType] || providerType; } @@ -943,7 +943,7 @@ function getProviderIcon(providerType) { 'gemini-antigravity': 'fas fa-rocket', 'openai-codex-oauth': 'fas fa-terminal', 'openai-qwen-oauth': 'fas fa-code', - 'grok-custom': 'fas fa-brain' + 'grok-web': 'fas fa-brain' }; return icons[providerType] || 'fas fa-server'; } diff --git a/static/app/utils.js b/static/app/utils.js index a68476cf0..844a5c5bf 100644 --- a/static/app/utils.js +++ b/static/app/utils.js @@ -55,7 +55,7 @@ function getBaseProviderConfigs() { defaultPath: 'configs/iflow/' }, { - id: 'grok-custom', + id: 'grok-web', name: t('dashboard.routing.nodeName.grok'), icon: 'fa-user-secret' }, @@ -201,6 +201,7 @@ function getFieldLabel(key) { 'CODEX_OAUTH_CREDS_FILE_PATH': t('modal.provider.field.oauthPath'), 'GROK_COOKIE_TOKEN': t('modal.provider.field.ssoToken'), 'GROK_CF_CLEARANCE': t('modal.provider.field.cfClearance'), + 'GROK_USER_AGENT': t('modal.provider.field.userAgent'), 'GEMINI_BASE_URL': 'Gemini Base URL', 'KIRO_BASE_URL': t('modal.provider.field.baseUrl'), @@ -399,7 +400,7 @@ function getProviderTypeFields(providerType) { placeholder: 'https://api.openai.com/v1/codex' } ], - 'grok-custom': [ + 'grok-web': [ { id: 'GROK_COOKIE_TOKEN', label: t('modal.provider.field.ssoToken'), diff --git a/static/components/section-config.html b/static/components/section-config.html index e6a6ad21f..91b5a0002 100644 --- a/static/components/section-config.html +++ b/static/components/section-config.html @@ -99,9 +99,9 @@

基础设置

OpenAI Codex OAuth - 点击选择启动时初始化的模型提供商 (必须至少选择一个) @@ -152,9 +152,9 @@

代理设置

OpenAI Codex OAuth - 点击选择需要通过代理访问的提供商,未选中的提供商将直接连接 From 6ab7b9676259c2c541d61f5b825f1f5144b55b35 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Thu, 30 Apr 2026 16:31:51 +0800 Subject: [PATCH 084/135] =?UTF-8?q?feat(playground):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=81=9C=E6=AD=A2=E5=93=8D=E5=BA=94=E6=8C=89=E9=92=AE=E5=92=8C?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E9=87=8D=E8=AF=95=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增停止按钮,允许用户中止正在进行的流式响应 - 为用户消息添加重试功能,可重新发送并清除后续对话 - 新增推理内容折叠/展开交互,优化长推理显示 - 修复 tool_use 输入中空键名导致 API 调用失败的问题 - 移除已注释的 AI账号购买链接,优化界面布局 --- VERSION | 2 +- src/providers/claude/claude-kiro.js | 30 ++++- static/app/playground-manager.js | 136 +++++++++++++++++++--- static/components/header.html | 4 +- static/components/section-playground.css | 85 +++++++++++++- static/components/section-playground.html | 3 + 6 files changed, 234 insertions(+), 26 deletions(-) diff --git a/VERSION b/VERSION index 752490696..0e7079b69 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.16.0 +2.16.1 diff --git a/src/providers/claude/claude-kiro.js b/src/providers/claude/claude-kiro.js index 2da5d06d7..5273bfaca 100644 --- a/src/providers/claude/claude-kiro.js +++ b/src/providers/claude/claude-kiro.js @@ -49,6 +49,7 @@ const KIRO_CONSTANTS = { // Per-model context window sizes for accurate token estimation const MODEL_CONTEXT_TOKENS = { + "claude-opus-4-7": 1000000, "claude-opus-4-6": 1000000, "claude-opus-4-5": 1000000, "claude-opus-4-5-20251101": 1000000, @@ -834,6 +835,25 @@ async saveCredentialsToFile(filePath, newData) { return getContentTextUtil(message); } + /** + * 清洗 tool_use 的 input 对象,移除空字符串 key 等不合法字段 + * Kiro API 不接受空字符串 key 的 JSON 对象(如 {"": "value"}) + */ + _sanitizeToolInput(input) { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return input; + } + const sanitized = {}; + for (const [key, value] of Object.entries(input)) { + if (key === '') { + logger.info(`[Kiro] Removed empty-string key from tool input, value: ${String(value).substring(0, 100)}`); + continue; + } + sanitized[key] = value; + } + return sanitized; + } + /** * 统一处理内容,将不同格式的内容转换为文本 * @param {any} content - 内容对象或数组 @@ -1228,7 +1248,7 @@ async saveCredentialsToFile(filePath, newData) { thinkingText += (part.thinking ?? part.text ?? ''); } else if (part.type === 'tool_use') { toolUses.push({ - input: part.input, + input: this._sanitizeToolInput(part.input), name: part.name, toolUseId: part.id }); @@ -1237,7 +1257,7 @@ async saveCredentialsToFile(filePath, newData) { } else { assistantResponseMessage.content = this.getContentText(message); } - + if (thinkingText) { assistantResponseMessage.content = assistantResponseMessage.content ? `${KIRO_THINKING.START_TAG}${thinkingText}${KIRO_THINKING.END_TAG}\n\n${assistantResponseMessage.content}` @@ -1248,7 +1268,7 @@ async saveCredentialsToFile(filePath, newData) { if (toolUses.length > 0) { assistantResponseMessage.toolUses = toolUses; } - + history.push({ assistantResponseMessage }); } } @@ -1279,7 +1299,7 @@ async saveCredentialsToFile(filePath, newData) { thinkingText += (part.thinking ?? part.text ?? ''); } else if (part.type === 'tool_use') { assistantResponseMessage.toolUses.push({ - input: part.input, + input: this._sanitizeToolInput(part.input), name: part.name, toolUseId: part.id }); @@ -1329,7 +1349,7 @@ async saveCredentialsToFile(filePath, newData) { }); } else if (part.type === 'tool_use') { currentToolUses.push({ - input: part.input, + input: this._sanitizeToolInput(part.input), name: part.name, toolUseId: part.id }); diff --git a/static/app/playground-manager.js b/static/app/playground-manager.js index ffcbf9f92..db1b2541e 100644 --- a/static/app/playground-manager.js +++ b/static/app/playground-manager.js @@ -21,6 +21,7 @@ function getModelSelect() { return el('pg-model-select'); } function getInterfaceSelect(){ return el('pg-interface-select'); } function getInput() { return el('pg-input'); } function getSendBtn() { return el('pg-send-btn'); } +function getStopBtn() { return el('pg-stop-btn'); } function getMessages() { return el('pg-messages'); } function getEmpty() { return el('pg-empty'); } function getAttachPreview() { return el('pg-attachments-preview'); } @@ -126,6 +127,7 @@ function bindEvents() { document.addEventListener('click', (e) => { if (e.target.closest('#pg-send-btn')) handleSend(); + if (e.target.closest('#pg-stop-btn')) handleStop(); if (e.target.closest('#pg-clear-btn')) clearChat(); if (e.target.closest('#pg-attach-btn')) el('pg-file-input')?.click(); }); @@ -165,8 +167,20 @@ function updateInputState() { const input = getInput(); const sendBtn = getSendBtn(); - if (input) input.disabled = !ready; - if (sendBtn) sendBtn.disabled = !ready; + const stopBtn = getStopBtn(); + + if (input) input.disabled = isStreaming || !(provider && model); + + if (isStreaming) { + if (sendBtn) sendBtn.style.display = 'none'; + if (stopBtn) stopBtn.style.display = 'flex'; + } else { + if (sendBtn) { + sendBtn.style.display = 'flex'; + sendBtn.disabled = !ready; + } + if (stopBtn) stopBtn.style.display = 'none'; + } // Update status indicator const indicator = el('pg-active-indicator'); @@ -243,6 +257,13 @@ async function handleSend() { } } +function handleStop() { + if (currentAbortController) { + currentAbortController.abort(); + currentAbortController = null; + } +} + function buildUserContent(text, files) { if (files.length === 0) return text; const parts = []; @@ -268,6 +289,8 @@ async function imageResponse(provider, model, prompt, files, bubble, interfaceTy isStreaming = true; updateInputState(); + currentAbortController = new AbortController(); + const msgWrapper = bubble.closest('.pg-message'); if (msgWrapper) msgWrapper.style.display = 'flex'; // Image response doesn't need to hide @@ -291,13 +314,15 @@ async function imageResponse(provider, model, prompt, files, bubble, interfaceTy response = await fetch('/v1/images/edits', { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'model-provider': provider }, - body: formData + body: formData, + signal: currentAbortController.signal }); } else { response = await fetch('/v1/images/generations', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, 'model-provider': provider }, - body: JSON.stringify({model, prompt, response_format: 'b64_json'}) + body: JSON.stringify({model, prompt, response_format: 'b64_json'}), + signal: currentAbortController.signal }); } @@ -316,13 +341,18 @@ async function imageResponse(provider, model, prompt, files, bubble, interfaceTy }).join(''); } catch (e) { - errorMsg = e.message || t('playground.reqFailed'); + if (e.name === 'AbortError') { + errorMsg = t('playground.aborted'); + } else { + errorMsg = e.message || t('playground.reqFailed'); + } } finally { if (errorMsg) { bubble.textContent = errorMsg; bubble.closest('.pg-message')?.classList.add('error'); } isStreaming = false; + currentAbortController = null; updateInputState(); scrollToBottom(); } @@ -331,6 +361,8 @@ async function imageResponse(provider, model, prompt, files, bubble, interfaceTy async function unaryResponse(provider, model, bubble, params) { isStreaming = true; updateInputState(); + + currentAbortController = new AbortController(); let errorMsg = ''; try { @@ -347,7 +379,8 @@ async function unaryResponse(provider, model, bubble, params) { temperature: params.temperature, max_tokens: params.max_tokens, stream: false - }) + }), + signal: currentAbortController.signal }); if (!response.ok) { @@ -364,10 +397,7 @@ async function unaryResponse(provider, model, bubble, params) { if (msgWrapper) msgWrapper.style.display = 'flex'; if (reasoning) { - const resDiv = document.createElement('div'); - resDiv.className = 'pg-reasoning'; - resDiv.innerHTML = `
${t('playground.thinking')}
`; - resDiv.querySelector('.pg-reasoning-content').textContent = reasoning; + const resDiv = createReasoningBlock(reasoning); bubble.appendChild(resDiv); } @@ -381,8 +411,12 @@ async function unaryResponse(provider, model, bubble, params) { } } catch (e) { - console.error('[Playground] Unary error:', e.message); - errorMsg = e.message || t('playground.reqFailed'); + if (e.name === 'AbortError') { + errorMsg = t('playground.aborted'); + } else { + console.error('[Playground] Unary error:', e.message); + errorMsg = e.message || t('playground.reqFailed'); + } } finally { if (errorMsg) { bubble.textContent = errorMsg; @@ -391,6 +425,7 @@ async function unaryResponse(provider, model, bubble, params) { if (msgWrapper) msgWrapper.style.display = 'flex'; } isStreaming = false; + currentAbortController = null; updateInputState(); scrollToBottom(); } @@ -517,10 +552,7 @@ async function streamResponse(provider, model, bubble, params) { } else { bubble.innerHTML = ''; if (accumulatedReasoning) { - const resDiv = document.createElement('div'); - resDiv.className = 'pg-reasoning'; - resDiv.innerHTML = `
${t('playground.thinking')}
`; - resDiv.querySelector('.pg-reasoning-content').textContent = accumulatedReasoning; + const resDiv = createReasoningBlock(accumulatedReasoning); bubble.appendChild(resDiv); } if (accumulated) { @@ -603,6 +635,18 @@ function appendMessage(role, text) { } }); contentWrapper.appendChild(actions); + } else if (role === 'user') { + const actions = document.createElement('div'); + actions.className = 'pg-message-actions'; + actions.innerHTML = ` + + `; + actions.querySelector('.btn-retry-msg').addEventListener('click', () => { + retryMessage(wrapper, text); + }); + contentWrapper.appendChild(actions); } wrapper.appendChild(contentWrapper); @@ -715,6 +759,66 @@ function renderMarkdown(text) { // ── File handling ───────────────────────────────────────────────────────────── +function retryMessage(messageWrapper, originalDisplayText) { + if (isStreaming) return; + + const container = getMessages(); + const allWrappers = Array.from(container.querySelectorAll('.pg-message')); + const index = allWrappers.indexOf(messageWrapper); + + if (index === -1) return; + + // Extract original text from history if possible (to avoid [Attachment: ...] markers) + let retryText = originalDisplayText; + const historyMsg = messages[index]; + if (historyMsg && historyMsg.role === 'user') { + if (typeof historyMsg.content === 'string') { + retryText = historyMsg.content; + } else if (Array.isArray(historyMsg.content)) { + const textPart = historyMsg.content.find(p => p.type === 'text'); + if (textPart) retryText = textPart.text; + } + } + + // 1. Remove all subsequent messages from DOM + for (let i = allWrappers.length - 1; i >= index; i--) { + allWrappers[i].remove(); + } + + // 2. Remove from messages array + messages.splice(index); + + // 3. Put text back to input and trigger send + const input = getInput(); + if (input) { + input.value = retryText; + input.style.height = 'auto'; + input.style.height = Math.min(input.scrollHeight, 240) + 'px'; + input.disabled = false; + input.focus(); + + // Show empty state if no messages left + if (messages.length === 0) { + const empty = getEmpty(); + if (empty) empty.style.display = 'flex'; + } + + handleSend(); + } +} + +function createReasoningBlock(content, collapsed = true) { + const resDiv = document.createElement('div'); + resDiv.className = 'pg-reasoning' + (collapsed ? ' collapsed' : ''); + resDiv.innerHTML = `
${t('playground.thinking')}
`; + resDiv.querySelector('.pg-reasoning-content').textContent = content; + resDiv.addEventListener('click', (e) => { + e.stopPropagation(); + resDiv.classList.toggle('collapsed'); + }); + return resDiv; +} + async function handleFiles(fileList) { if (!fileList?.length) return; for (const file of fileList) { diff --git a/static/components/header.html b/static/components/header.html index 6d12a81db..43e4880e3 100644 --- a/static/components/header.html +++ b/static/components/header.html @@ -7,9 +7,9 @@

- + 连接中... diff --git a/static/components/section-playground.css b/static/components/section-playground.css index 9f3c4b4b0..f546440b5 100644 --- a/static/components/section-playground.css +++ b/static/components/section-playground.css @@ -117,6 +117,7 @@ flex: 1; display: grid; grid-template-columns: 280px 1fr 320px; + grid-template-rows: 1fr; overflow: hidden; background: var(--bg-secondary); } @@ -130,6 +131,7 @@ display: flex; flex-direction: column; gap: 1.75rem; + min-height: 0; } .pg-workspace .pg-side-nav { border-right: 1px solid var(--border-color); } @@ -148,13 +150,14 @@ background: var(--bg-primary); position: relative; min-width: 0; + min-height: 0; box-shadow: inset 0 0 40px rgba(0,0,0,0.01); } .pg-workspace .pg-messages-wrapper { flex: 1; overflow-y: auto; - padding: 2.5rem; + padding: 2.5rem 4rem; display: flex; flex-direction: column; gap: 2.5rem; @@ -199,13 +202,18 @@ .pg-workspace .pg-message { display: flex; gap: 1.25rem; - max-width: 90%; + max-width: 85%; margin-bottom: 0.5rem; } .pg-workspace .pg-message.user { align-self: flex-end; flex-direction: row-reverse; } .pg-workspace .pg-message.assistant { align-self: flex-start; } +.pg-workspace .pg-message-content { + min-width: 0; + max-width: 100%; +} + .pg-workspace .pg-avatar { width: 38px; height: 38px; @@ -284,6 +292,16 @@ border-color: var(--primary-20); } +.pg-workspace .pg-message.user .pg-message-actions { + flex-direction: row-reverse; +} + +.pg-workspace .pg-action-link.btn-retry-msg:hover { + color: var(--warning-color); + background: var(--warning-15); + border-color: var(--warning-20); +} + /* 输入框区域 */ .pg-workspace .pg-input-boundary { padding: 1rem 4rem 2.5rem; @@ -346,6 +364,25 @@ transform: scale(1.05); } +.pg-workspace .pg-stop-pill { + background: var(--danger-color); + color: white; + width: 36px; + height: 36px; + border-radius: 12px; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.pg-workspace .pg-stop-pill:hover { + background: var(--danger-hover, #dc2626); + transform: scale(1.05); +} + .pg-workspace .pg-inner-btn { width: 38px; height: 38px; @@ -560,6 +597,7 @@ .pg-workspace .pg-main-layout { grid-template-columns: 1fr; } .pg-workspace .pg-side-nav, .pg-workspace .pg-config-panel { display: none; } .pg-workspace .pg-input-boundary { padding: 1rem 2rem; } + .pg-workspace .pg-messages-wrapper { padding: 1.5rem 2rem; } } /* 其他动画 & 辅助类 */ @@ -598,6 +636,49 @@ opacity: 0.9; border: 1px solid var(--border-color); border-left-width: 4px; + cursor: pointer; + transition: all 0.2s ease; + position: relative; +} + +.pg-reasoning:hover { + background: var(--bg-secondary); +} + +.pg-reasoning.collapsed .pg-reasoning-content { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + max-height: calc(1.6em * 3); + padding-bottom: 0.5rem; +} + +.pg-reasoning.collapsed::after { + content: '\f107'; + font-family: 'Font Awesome 6 Free'; + font-weight: 900; + position: absolute; + bottom: 0.4rem; + left: 50%; + transform: translateX(-50%); + font-size: 0.8rem; + opacity: 0.5; + background: linear-gradient(to top, var(--bg-tertiary) 40%, transparent); + width: 100%; + text-align: center; + padding-top: 1rem; +} + +.pg-reasoning:not(.collapsed)::after { + content: '\f106'; + font-family: 'Font Awesome 6 Free'; + font-weight: 900; + position: absolute; + bottom: 0.2rem; + right: 1rem; + font-size: 0.8rem; + opacity: 0.3; } .pg-reasoning-title { diff --git a/static/components/section-playground.html b/static/components/section-playground.html index e46444af0..ff95b1295 100644 --- a/static/components/section-playground.html +++ b/static/components/section-playground.html @@ -78,6 +78,9 @@

推理实验室

+
From 0a8254723289815bcefecded7d949896a1a5e37d Mon Sep 17 00:00:00 2001 From: hex2077 Date: Sun, 3 May 2026 13:46:46 +0800 Subject: [PATCH 085/135] =?UTF-8?q?fix:=20=E5=B0=86=E5=8D=81=E4=BA=BF?= =?UTF-8?q?=E5=8D=95=E4=BD=8D=E5=90=8E=E7=BC=80=E4=BB=8E'G'=E6=9B=B4?= =?UTF-8?q?=E6=AD=A3=E4=B8=BA'B'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在多个统计页面中,用于格式化大数字的单位后缀存在错误。十亿(1e9)的后缀应为'B'(代表Billion),但被错误地设置为'G'。此更改修正了model-usage-stats.html、potluck.html和potluck-user.html中的显示问题,以确保单位缩写的准确性。 --- VERSION | 2 +- static/model-usage-stats.html | 2 +- static/potluck-user.html | 2 +- static/potluck.html | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/VERSION b/VERSION index 0e7079b69..43c85e792 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.16.1 +2.16.2 diff --git a/static/model-usage-stats.html b/static/model-usage-stats.html index b17975bdc..0449b7621 100644 --- a/static/model-usage-stats.html +++ b/static/model-usage-stats.html @@ -867,7 +867,7 @@

模型明 const abs = Math.abs(value); const units = [{ threshold: 1e9, - suffix: 'G' + suffix: 'B' }, { threshold: 1e6, suffix: 'M' diff --git a/static/potluck-user.html b/static/potluck-user.html index d01241955..5014ccff7 100644 --- a/static/potluck-user.html +++ b/static/potluck-user.html @@ -528,7 +528,7 @@

API 密钥

if (!Number.isFinite(value)) return '0'; const abs = Math.abs(value); const units = [ - { threshold: 1e9, suffix: 'G' }, + { threshold: 1e9, suffix: 'B' }, { threshold: 1e6, suffix: 'M' }, { threshold: 1e3, suffix: 'K' } ]; diff --git a/static/potluck.html b/static/potluck.html index bf7415219..94725d623 100644 --- a/static/potluck.html +++ b/static/potluck.html @@ -862,7 +862,7 @@

批量应用每日限额

if (!Number.isFinite(value)) return '0'; const abs = Math.abs(value); const units = [ - { threshold: 1e9, suffix: 'G' }, + { threshold: 1e9, suffix: 'B' }, { threshold: 1e6, suffix: 'M' }, { threshold: 1e3, suffix: 'K' } ]; From a4a9481f40d5f15219917b6ac38e329c033b416c Mon Sep 17 00:00:00 2001 From: hex2077 Date: Mon, 4 May 2026 13:42:36 +0800 Subject: [PATCH 086/135] =?UTF-8?q?fix:=20=E7=A1=AE=E4=BF=9D=E6=95=8F?= =?UTF-8?q?=E6=84=9F=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6=E7=9A=84=E5=86=99?= =?UTF-8?q?=E5=85=A5=E5=AE=89=E5=85=A8=E6=80=A7=E4=B8=8E=E6=9D=83=E9=99=90?= =?UTF-8?q?=E4=B8=80=E8=87=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一使用 atomicWriteFile 函数写入配置文件,确保原子性和文件权限。 修复多个模块中敏感配置文件(如 token、credentials)的写入方式,避免写入过程中文件损坏或权限不当。 同时优化前端健康检查逻辑,仅在状态实际变化时重新加载配置。 --- src/core/plugin-manager.js | 3 +- src/plugins/api-potluck/key-manager.js | 11 +++--- .../model-usage-stats/stats-manager.js | 10 +++--- src/providers/claude/claude-kiro.js | 3 +- src/providers/gemini/antigravity-core.js | 3 +- src/providers/gemini/gemini-core.js | 3 +- src/providers/openai/codex-core.js | 5 +-- src/providers/openai/iflow-core.js | 3 +- src/providers/openai/qwen-core.js | 5 +-- src/providers/provider-pool-manager.js | 2 +- src/ui-modules/auth.js | 3 +- src/ui-modules/config-api.js | 4 +-- src/ui-modules/usage-cache.js | 3 +- src/utils/file-lock.js | 36 ++++++++++++++++--- static/app/modal.js | 25 +++++++++---- 15 files changed, 83 insertions(+), 36 deletions(-) diff --git a/src/core/plugin-manager.js b/src/core/plugin-manager.js index 50b2b21c6..d9650e8d9 100644 --- a/src/core/plugin-manager.js +++ b/src/core/plugin-manager.js @@ -8,6 +8,7 @@ * 4. 插件配置管理 */ +import { atomicWriteFile } from '../utils/file-lock.js'; import { promises as fs } from 'fs'; import logger from '../utils/logger.js'; import { existsSync } from 'fs'; @@ -162,7 +163,7 @@ class PluginManager { if (!existsSync(dir)) { await fs.mkdir(dir, { recursive: true }); } - await fs.writeFile(PLUGINS_CONFIG_FILE, JSON.stringify(this.pluginsConfig, null, 2), 'utf8'); + await atomicWriteFile(PLUGINS_CONFIG_FILE, JSON.stringify(this.pluginsConfig, null, 2), { encoding: 'utf8', mode: 0o600 }); } catch (error) { logger.error('[PluginManager] Failed to save config:', error.message); } diff --git a/src/plugins/api-potluck/key-manager.js b/src/plugins/api-potluck/key-manager.js index 550367e26..7d136a26b 100644 --- a/src/plugins/api-potluck/key-manager.js +++ b/src/plugins/api-potluck/key-manager.js @@ -3,9 +3,10 @@ * 使用内存缓存 + 写锁 + 定期持久化,解决并发安全问题 */ +import { atomicWriteFile, atomicWriteFileSync } from '../../utils/file-lock.js'; import { promises as fs } from 'fs'; import logger from '../../utils/logger.js'; -import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import path from 'path'; import crypto from 'crypto'; import { RateManager } from '../../utils/rate-tracker.js'; @@ -233,7 +234,7 @@ function syncWriteToFile() { if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - writeFileSync(KEYS_STORE_FILE, JSON.stringify(keyStore, null, 2), 'utf8'); + atomicWriteFileSync(KEYS_STORE_FILE, JSON.stringify(keyStore, null, 2), { encoding: 'utf8', mode: 0o600 }); } catch (error) { logger.error('[API Potluck] Sync write failed:', error.message); } @@ -250,10 +251,8 @@ async function persistIfDirty() { if (!existsSync(dir)) { await fs.mkdir(dir, { recursive: true }); } - // 写入临时文件再重命名,防止写入中断导致文件损坏 - const tempFile = KEYS_STORE_FILE + '.tmp'; - await fs.writeFile(tempFile, JSON.stringify(keyStore, null, 2), 'utf8'); - await fs.rename(tempFile, KEYS_STORE_FILE); + // 写入临时文件再重命名,并确保刷盘 + await atomicWriteFile(KEYS_STORE_FILE, JSON.stringify(keyStore, null, 2), { encoding: 'utf8', mode: 0o600 }); isDirty = false; } catch (error) { logger.error('[API Potluck] Persist failed:', error.message); diff --git a/src/plugins/model-usage-stats/stats-manager.js b/src/plugins/model-usage-stats/stats-manager.js index 2abfe09e1..9b91c9a97 100644 --- a/src/plugins/model-usage-stats/stats-manager.js +++ b/src/plugins/model-usage-stats/stats-manager.js @@ -1,5 +1,6 @@ +import { atomicWriteFile, atomicWriteFileSync } from '../../utils/file-lock.js'; import { promises as fs } from 'fs'; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { existsSync, mkdirSync, readFileSync } from 'fs'; import path from 'path'; import logger from '../../utils/logger.js'; import { RateManager } from '../../utils/rate-tracker.js'; @@ -165,7 +166,7 @@ export function syncWriteToFile() { if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - writeFileSync(STATS_STORE_FILE, JSON.stringify(statsStore, null, 2), 'utf8'); + atomicWriteFileSync(STATS_STORE_FILE, JSON.stringify(statsStore, null, 2), { encoding: 'utf8', mode: 0o600 }); isDirty = false; logger.info('[Model Usage Stats] Sync persisted stats store'); } catch (error) { @@ -193,9 +194,8 @@ async function persistIfDirty() { while (isDirty) { const versionAtStart = mutationVersion; const snapshot = JSON.stringify(statsStore, null, 2); - const tempFile = STATS_STORE_FILE + '.tmp'; - await fs.writeFile(tempFile, snapshot, 'utf8'); - await fs.rename(tempFile, STATS_STORE_FILE); + + await atomicWriteFile(STATS_STORE_FILE, snapshot, { encoding: 'utf8', mode: 0o600 }); if (mutationVersion === versionAtStart) { isDirty = false; diff --git a/src/providers/claude/claude-kiro.js b/src/providers/claude/claude-kiro.js index 5273bfaca..55984e2c8 100644 --- a/src/providers/claude/claude-kiro.js +++ b/src/providers/claude/claude-kiro.js @@ -1,3 +1,4 @@ +import { atomicWriteFile } from '../../utils/file-lock.js'; import axios from 'axios'; import logger from '../../utils/logger.js'; import { v4 as uuidv4 } from 'uuid'; @@ -716,7 +717,7 @@ async saveCredentialsToFile(filePath, newData) { } } const mergedData = { ...existingData, ...newData }; - await fs.writeFile(filePath, JSON.stringify(mergedData, null, 2), 'utf8'); + await atomicWriteFile(filePath, JSON.stringify(mergedData, null, 2), { encoding: 'utf8', mode: 0o600 }); logger.info(`[Kiro Auth] Updated token file: ${filePath}`); }; diff --git a/src/providers/gemini/antigravity-core.js b/src/providers/gemini/antigravity-core.js index b2a93e492..9e86d1a1a 100644 --- a/src/providers/gemini/antigravity-core.js +++ b/src/providers/gemini/antigravity-core.js @@ -1,4 +1,5 @@ +import { atomicWriteFile } from '../../utils/file-lock.js'; import { OAuth2Client } from 'google-auth-library'; import logger from '../../utils/logger.js'; import * as http from 'http'; @@ -921,7 +922,7 @@ export class AntigravityApiService { */ async _saveCredentialsToFile(filePath, credentials) { try { - await fs.writeFile(filePath, JSON.stringify(credentials, null, 2)); + await atomicWriteFile(filePath, JSON.stringify(credentials, null, 2), { mode: 0o600 }); logger.info(`[Antigravity Auth] Credentials saved to ${filePath}`); } catch (error) { logger.error(`[Antigravity Auth] Failed to save credentials to ${filePath}: ${error.message}`); diff --git a/src/providers/gemini/gemini-core.js b/src/providers/gemini/gemini-core.js index a583db87d..ad49eeb7b 100644 --- a/src/providers/gemini/gemini-core.js +++ b/src/providers/gemini/gemini-core.js @@ -1,3 +1,4 @@ +import { atomicWriteFile } from '../../utils/file-lock.js'; import { OAuth2Client } from 'google-auth-library'; import logger from '../../utils/logger.js'; import * as http from 'http'; @@ -879,7 +880,7 @@ export class GeminiApiService { */ async _saveCredentialsToFile(filePath, credentials) { try { - await fs.writeFile(filePath, JSON.stringify(credentials, null, 2)); + await atomicWriteFile(filePath, JSON.stringify(credentials, null, 2), { mode: 0o600 }); logger.info(`[Gemini Auth] Credentials saved to ${filePath}`); } catch (error) { logger.error(`[Gemini Auth] Failed to save credentials to ${filePath}: ${error.message}`); diff --git a/src/providers/openai/codex-core.js b/src/providers/openai/codex-core.js index 2f529b887..dde436a5d 100644 --- a/src/providers/openai/codex-core.js +++ b/src/providers/openai/codex-core.js @@ -1,3 +1,4 @@ +import { atomicWriteFile } from '../../utils/file-lock.js'; import axios from 'axios'; import logger from '../../utils/logger.js'; import crypto from 'crypto'; @@ -569,7 +570,7 @@ export class CodexApiService { } await fs.mkdir(credsDir, {recursive: true}); - await fs.writeFile( + await atomicWriteFile( credsPath, JSON.stringify( { @@ -585,7 +586,7 @@ export class CodexApiService { null, 2 ), - {mode: 0o600} + { encoding: 'utf8', mode: 0o600 } ); // 更新缓存路径(例如首次无 credsPath 兜底生成了新文件) diff --git a/src/providers/openai/iflow-core.js b/src/providers/openai/iflow-core.js index 74acbf5de..b03338295 100644 --- a/src/providers/openai/iflow-core.js +++ b/src/providers/openai/iflow-core.js @@ -16,6 +16,7 @@ * - DeepSeek R1: 内置推理能力 */ +import { atomicWriteFile } from '../../utils/file-lock.js'; import axios from 'axios'; import logger from '../../utils/logger.js'; import * as http from 'http'; @@ -134,7 +135,7 @@ async function saveTokenToFile(filePath, tokenStorage, uuid = null) { logger.error('[iFlow] WARNING: Attempting to save token file with empty apiKey!'); } - await fs.writeFile(absolutePath, JSON.stringify(json, null, 2), 'utf-8'); + await atomicWriteFile(absolutePath, JSON.stringify(json, null, 2), { encoding: 'utf-8', mode: 0o600 }); logger.info(`[iFlow] Token saved to: ${filePath} (refresh_token: ${json.refresh_token ? json.refresh_token.substring(0, 8) + '...' : 'EMPTY'})`); } catch (error) { diff --git a/src/providers/openai/qwen-core.js b/src/providers/openai/qwen-core.js index 08adffa82..b7b36cc67 100644 --- a/src/providers/openai/qwen-core.js +++ b/src/providers/openai/qwen-core.js @@ -1,3 +1,4 @@ +import { atomicWriteFile } from '../../utils/file-lock.js'; import axios from 'axios'; import logger from '../../utils/logger.js'; import crypto from 'crypto'; @@ -570,7 +571,7 @@ export class QwenApiService { try { await fs.mkdir(path.dirname(filePath), { recursive: true }); const credString = JSON.stringify(credentials, null, 2); - await fs.writeFile(filePath, credString); + await atomicWriteFile(filePath, credString, { mode: 0o600 }); logger.info(`[Qwen Auth] Credentials cached to ${filePath}`); } catch (error) { logger.error(`[Qwen Auth] Failed to cache credentials to ${filePath}: ${error.message}`); @@ -1080,7 +1081,7 @@ class SharedTokenManager { async saveCredentialsToFile(context, credentials) { try { await fs.mkdir(path.dirname(context.credentialFilePath), { recursive: true, mode: 0o700 }); - await fs.writeFile(context.credentialFilePath, JSON.stringify(credentials, null, 2), { mode: 0o600 }); + await atomicWriteFile(context.credentialFilePath, JSON.stringify(credentials, null, 2), { mode: 0o600 }); const stats = await fs.stat(context.credentialFilePath); context.memoryCache.fileModTime = stats.mtimeMs; } catch (error) { diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index a4884ede8..5bda298a9 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -2360,7 +2360,7 @@ export class ProviderPoolManager { } // 一次性写入文件(使用原子化写入) - await atomicWriteFile(filePath, JSON.stringify(currentPools, null, 2), 'utf8'); + await atomicWriteFile(filePath, JSON.stringify(currentPools, null, 2), { encoding: 'utf8', mode: 0o600 }); this._log('info', `configs/provider_pools.json updated successfully for types: ${typesToSave.join(', ')}`); } catch (error) { diff --git a/src/ui-modules/auth.js b/src/ui-modules/auth.js index 490a757e9..ae4494fe6 100644 --- a/src/ui-modules/auth.js +++ b/src/ui-modules/auth.js @@ -1,3 +1,4 @@ +import { atomicWriteFile } from '../utils/file-lock.js'; import { existsSync } from 'fs'; import logger from '../utils/logger.js'; import { promises as fs } from 'fs'; @@ -137,7 +138,7 @@ async function readTokenStore() { */ async function writeTokenStore(tokenStore) { try { - await fs.writeFile(TOKEN_STORE_FILE, JSON.stringify(tokenStore, null, 2), 'utf8'); + await atomicWriteFile(TOKEN_STORE_FILE, JSON.stringify(tokenStore, null, 2), { encoding: 'utf8', mode: 0o600 }); } catch (error) { logger.error('[Token Store] Failed to write token store file:', error); } diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js index 8cf392103..a789a8084 100644 --- a/src/ui-modules/config-api.js +++ b/src/ui-modules/config-api.js @@ -366,7 +366,7 @@ async function _handleUpdateConfig(req, res, currentConfig, body) { SCHEDULED_HEALTH_CHECK: currentConfig.SCHEDULED_HEALTH_CHECK }; - await atomicWriteFile(configPath, JSON.stringify(configToSave, null, 2), 'utf-8'); + await atomicWriteFile(configPath, JSON.stringify(configToSave, null, 2), { encoding: 'utf-8', mode: 0o600 }); logger.info('[UI API] Configuration saved to configs/config.json'); // 广播更新事件 @@ -487,7 +487,7 @@ export async function handleUpdateAdminPassword(req, res) { // 使用文件锁和原子化写入 await withFileLock(pwdFilePath, async () => { - await atomicWriteFile(pwdFilePath, stored, 'utf-8'); + await atomicWriteFile(pwdFilePath, stored, { encoding: 'utf-8', mode: 0o600 }); }); logger.info('[UI API] Admin password updated successfully'); diff --git a/src/ui-modules/usage-cache.js b/src/ui-modules/usage-cache.js index 1de626b0f..968720897 100644 --- a/src/ui-modules/usage-cache.js +++ b/src/ui-modules/usage-cache.js @@ -1,3 +1,4 @@ +import { atomicWriteFile } from '../utils/file-lock.js'; import { existsSync } from 'fs'; import logger from '../utils/logger.js'; import { promises as fs } from 'fs'; @@ -29,7 +30,7 @@ export async function readUsageCache() { */ export async function writeUsageCache(usageData) { try { - await fs.writeFile(USAGE_CACHE_FILE, JSON.stringify(usageData, null, 2), 'utf8'); + await atomicWriteFile(USAGE_CACHE_FILE, JSON.stringify(usageData, null, 2), { encoding: 'utf8', mode: 0o600 }); logger.info('[Usage Cache] Usage data cached to', USAGE_CACHE_FILE); } catch (error) { logger.error('[Usage Cache] Failed to write usage cache:', error.message); diff --git a/src/utils/file-lock.js b/src/utils/file-lock.js index 739a32f56..d4d48ed13 100644 --- a/src/utils/file-lock.js +++ b/src/utils/file-lock.js @@ -1,5 +1,5 @@ import logger from './logger.js'; -import { writeFileSync, renameSync, unlinkSync, promises as pfs } from 'fs'; +import { writeFileSync, renameSync, unlinkSync, openSync, fsyncSync, closeSync, promises as pfs } from 'fs'; /** * 文件锁管理器:支持按文件路径隔离的异步锁 @@ -109,14 +109,27 @@ export function withFileLock(filePath, fn) { /** * 原子化写入文件:先写临时文件,成功后再 rename + * @param {string} filePath - 目标路径 + * @param {string|Buffer} data - 数据 + * @param {string|Object} options - 编码字符串或选项对象 { encoding, mode } */ -export function atomicWriteFileSync(filePath, data, encoding = 'utf-8') { +export function atomicWriteFileSync(filePath, data, options = 'utf-8') { + const encoding = typeof options === 'string' ? options : (options?.encoding || 'utf-8'); + const mode = typeof options === 'object' ? options.mode : undefined; const tempPath = `${filePath}.${Date.now()}.${Math.random().toString(36).substring(2, 7)}.tmp`; + let fd; try { - writeFileSync(tempPath, data, encoding); + fd = openSync(tempPath, 'w', mode); + writeFileSync(fd, data, encoding); + fsyncSync(fd); + closeSync(fd); + fd = null; renameSync(tempPath, filePath); } catch (error) { logger.error(`[FileLock] Atomic write failed for ${filePath}:`, error.message); + if (fd) { + try { closeSync(fd); } catch (e) {} + } try { unlinkSync(tempPath); } catch (e) {} throw error; } @@ -124,14 +137,27 @@ export function atomicWriteFileSync(filePath, data, encoding = 'utf-8') { /** * 原子化写入文件(异步版,带 Windows 重试支持) + * @param {string} filePath - 目标路径 + * @param {string|Buffer} data - 数据 + * @param {string|Object} options - 编码字符串或选项对象 { encoding, mode } */ -export async function atomicWriteFile(filePath, data, encoding = 'utf-8') { +export async function atomicWriteFile(filePath, data, options = 'utf-8') { + const encoding = typeof options === 'string' ? options : (options?.encoding || 'utf-8'); + const mode = typeof options === 'object' ? options.mode : undefined; const tempPath = `${filePath}.${Date.now()}.${Math.random().toString(36).substring(2, 7)}.tmp`; + let handle; try { - await pfs.writeFile(tempPath, data, encoding); + handle = await pfs.open(tempPath, 'w', mode); + await handle.writeFile(data, encoding); + await handle.sync(); + await handle.close(); + handle = null; await retryRename(tempPath, filePath); } catch (error) { logger.error(`[FileLock] Atomic write (async) failed for ${filePath}:`, error.message); + if (handle) { + try { await handle.close(); } catch (e) {} + } try { await pfs.unlink(tempPath); } catch (e) {} throw error; } diff --git a/static/app/modal.js b/static/app/modal.js index f5d5110a7..8221420b2 100644 --- a/static/app/modal.js +++ b/static/app/modal.js @@ -1912,8 +1912,11 @@ async function resetAllProvidersHealth(providerType) { if (response.success) { showToast(t('common.success'), t('modal.provider.resetHealth.success', { count: response.resetCount }), 'success'); - // 重新加载配置 - await window.apiClient.post('/reload-config'); + // 只有当确实有节点的健康状态被重置时,才重新加载配置以刷新适配器实例 + if (response.resetCount > 0) { + console.log(`[UI] ${response.resetCount} node(s) health status reset, reloading configuration...`); + await window.apiClient.post('/reload-config'); + } // 刷新提供商配置显示 await refreshProviderConfig(providerType); @@ -1955,10 +1958,13 @@ async function performHealthCheck(providerType) { showToast(t('common.info'), message, failCount > 0 ? 'warning' : 'success'); - // 重新加载配置 - await window.apiClient.post('/reload-config'); + // 只有当有节点从不健康恢复为健康时,才需要重新加载配置以刷新适配器实例 + if (successCount > 0) { + console.log(`[UI] ${successCount} node(s) recovered, reloading configuration...`); + await window.apiClient.post('/reload-config'); + } - // 刷新提供商配置显示 + // 无论如何都要刷新显示 await refreshProviderConfig(providerType); } else { showToast(t('common.error'), t('modal.provider.healthCheck') + ' ' + t('common.error'), 'error'); @@ -1996,6 +2002,8 @@ async function performSingleHealthCheck(uuid, event) { showToast(t('common.info'), t('modal.provider.healthCheck') + '...', 'info'); + const isCurrentlyHealthy = providerDetail.classList.contains('healthy'); + const response = await window.apiClient.post( `/providers/${encodeURIComponent(providerType)}/${uuid}/health-check`, {} @@ -2018,7 +2026,12 @@ async function performSingleHealthCheck(uuid, event) { response.healthy ? 'success' : 'warning' ); - await window.apiClient.post('/reload-config'); + // 只有当健康状态确实发生变化时才重新加载配置 + if (isCurrentlyHealthy !== response.healthy) { + console.log(`[UI] Provider ${uuid} health status changed (from ${isCurrentlyHealthy} to ${response.healthy}), reloading configuration...`); + await window.apiClient.post('/reload-config'); + } + await refreshProviderConfig(providerType); } catch (error) { console.error('Single provider health check failed:', error); From 54726fff9784069e23512637c1081a2d04fe7530 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Mon, 4 May 2026 15:19:45 +0800 Subject: [PATCH 087/135] =?UTF-8?q?build:=20=E6=9B=B4=E6=96=B0=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=8F=B7=E8=87=B32.16.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 43c85e792..37b36c19d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.16.2 +2.16.3 From c055d3a2c90da7f6a4ed5662268608630dde7023 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Mon, 4 May 2026 16:02:29 +0800 Subject: [PATCH 088/135] =?UTF-8?q?feat:=20=E5=8F=91=E5=B8=83=20v3.0.0=20?= =?UTF-8?q?=E7=89=88=E6=9C=AC=EF=BC=8C=E6=96=B0=E5=A2=9E=20AI=20=E8=87=AA?= =?UTF-8?q?=E5=8F=91=E7=8E=B0=E6=9E=B6=E6=9E=84=E4=B8=8E=20UI=20=E5=BC=80?= =?UTF-8?q?=E5=85=B3=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 升级版本至 3.0.0,统一项目名称为 AIClient2API - 新增 AI 优先的自发现架构:添加 `/api/help` 和 `/api/example` 远程文档接口,支持 AI 代理自动学习全部 API - 新增 CLI 工具 `npm run help` 和 `npm run example:api`,提供本地帮助和 API 指南 - 增加 `--no-ui` 启动参数,支持禁用前端管理界面以运行纯后端模式 - 新增 UI 路径检测工具函数,优化静态资源和 API 请求的路由逻辑 - 更新所有文档中的项目引用,保持命名一致性 - 修复 OAuth 用户代理字符串中的项目名称 --- README-JA.md | 40 ++++-- README-ZH.md | 36 +++-- README.md | 40 ++++-- VERSION | 2 +- configs/config.json.example | 3 +- docker/VERSION | 2 +- docs/OPENCLAW_CONFIG_GUIDE-JA.md | 10 +- docs/OPENCLAW_CONFIG_GUIDE-ZH.md | 10 +- docs/OPENCLAW_CONFIG_GUIDE.md | 10 +- docs/skills/aiclient-cli-usage.md | 78 +++++++++++ package.json | 6 +- src/auth/kiro-oauth.js | 2 +- src/core/config-manager.js | 12 +- src/handlers/request-handler.js | 45 ++++-- src/providers/claude/claude-kiro.js | 2 +- src/scripts/example-api.js | 15 ++ src/scripts/help.js | 10 ++ src/services/api-server.js | 9 +- src/services/ui-manager.js | 38 +++++- src/ui-modules/update-api.js | 2 +- src/utils/constants.js | 12 ++ src/utils/docs-data.js | 204 ++++++++++++++++++++++++++++ src/utils/ui-utils.js | 34 +++++ 23 files changed, 550 insertions(+), 72 deletions(-) create mode 100644 docs/skills/aiclient-cli-usage.md create mode 100644 src/scripts/example-api.js create mode 100644 src/scripts/help.js create mode 100644 src/utils/docs-data.js create mode 100644 src/utils/ui-utils.js diff --git a/README-JA.md b/README-JA.md index 56eff530c..78130600d 100644 --- a/README-JA.md +++ b/README-JA.md @@ -2,7 +2,7 @@ logo -# AIClient-2-API(A2)🚀 +# AIClient2API(A2)🚀 **複数のクライアント専用大規模言語モデルAPI(Gemini CLI、Antigravity、Codex, Grok、Kiro ...)を模擬リクエストし、ローカルのOpenAI互換インターフェースに統一的にラッピングする強力なプロキシ。** @@ -24,6 +24,9 @@ +--- + + ## 💎 スポンサー *スポンサーは先着順に掲載されており、すべてのアカウント登録と利用を推奨します。* @@ -46,7 +49,7 @@

- AICodeMirror の本プロジェクトへのスポンサーシップに感謝します!AICodeMirror は、Claude Code / Codex / Gemini CLI 向けに公式の高安定性リレーサービスを提供しており、企業レベルの同時実行性、迅速な請求書発行、24時間365日の専用技術サポートを備えています。Claude Code / Codex / Gemini の公式チャンネルを、元の価格の 38% / 2% / 9% で利用でき、チャージ時にはさらなる割引もあります!AICodeMirror は AIClient-2-API ユーザーに特別な特典を提供しています:このリンクから登録すると、初回チャージが 20% オフになり、法人のお客様は最大 25% オフになります! + AICodeMirror の本プロジェクトへのスポンサーシップに感謝します!AICodeMirror は、Claude Code / Codex / Gemini CLI 向けに公式の高安定性リレーサービスを提供しており、企業レベルの同時実行性、迅速な請求書発行、24時間365日の専用技術サポートを備えています。Claude Code / Codex / Gemini の公式チャンネルを、元の価格の 38% / 2% / 9% で利用でき、チャージ時にはさらなる割引もあります!AICodeMirror は AIClient2API ユーザーに特別な特典を提供しています:このリンクから登録すると、初回チャージが 20% オフになり、法人のお客様は最大 25% オフになります!
- Poixe AI は信頼性の高い LLM API サービスを提供しています。プラットフォームが提供する API エンドポイントを活用して、AI 製品をシームレスに構築できます。また、AI API リソースをプラットフォームに提供するベンダーになり、収益を得ることも可能です。AIClient-2-API 専用リンクから登録すると、初回チャージ時に $5 USD のボーナスを受け取れます。 + Poixe AI は信頼性の高い LLM API サービスを提供しています。プラットフォームが提供する API エンドポイントを活用して、AI 製品をシームレスに構築できます。また、AI API リソースをプラットフォームに提供するベンダーになり、収益を得ることも可能です。AIClient2API 専用リンクから登録すると、初回チャージ時に $5 USD のボーナスを受け取れます。
- 感谢 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)! + 感谢 AICodeMirror 赞助本项目!AICodeMirror 为 Claude Code / Codex / Gemini CLI 提供官方高稳定性中转服务,具备企业级并发能力、快速开票和 7/24 专属技术支持。Claude Code / Codex / Gemini 官方渠道价格仅为原价的 38% / 2% / 9%,充值还有额外优惠!AICodeMirror 为 AIClient2API 用户提供专属福利:通过此链接注册即可享受首充 8折(20% off) 优惠,企业客户最高可享 75折(25% off)!
- Poixe AI 提供可靠的 AI 模型接口服务,您可以使用平台提供的 LLM API 接口轻松构建 AI 产品,同时也可以成为供应商,为平台提供大模型资源以赚取收益。通过 AIClient-2-API 专属链接注册,充值额外赠送 $5 美金。 + Poixe AI 提供可靠的 AI 模型接口服务,您可以使用平台提供的 LLM API 接口轻松构建 AI 产品,同时也可以成为供应商,为平台提供大模型资源以赚取收益。通过 AIClient2API 专属链接注册,充值额外赠送 $5 美金
- 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! + 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 AIClient2API users: register via this link to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!
- Poixe AI provides reliable LLM API services. You can leverage the platform's API endpoints to seamlessly build AI-powered products. Additionally, you can become a vendor by providing AI API resources to the platform and earn revenue. Register through the exclusive AIClient-2-API referral link and receive a bonus of $5 USD on your first top-up. + Poixe AI provides reliable LLM API services. You can leverage the platform's API endpoints to seamlessly build AI-powered products. Additionally, you can become a vendor by providing AI API resources to the platform and earn revenue. Register through the exclusive AIClient2API referral link and receive a bonus of $5 USD on your first top-up.
@@ -72,6 +74,7 @@ VisionCoder による本プロジェクトへのスポンサーに感謝します!VisionCoder 開発プラットフォームは信頼性が高く効率的な API 中継サービスプロバイダーであり、Claude Code、Codex、Gemini などの主要な AI モデルへのアクセスを提供しています。開発者やチームが AI 機能をより簡単に統合し、生産性を向上させるのを支援します。VisionCoder は本ソフトウェアのユーザー向けに期間限定の Token Plan 特典を提供しています:1ヶ月の購入で1ヶ月分を無料で進呈
Sponsor Contact diff --git a/README-ZH.md b/README-ZH.md index a3f691ef9..a47d0ac4b 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -51,6 +51,7 @@ 感谢 AICodeMirror 赞助本项目!AICodeMirror 为 Claude Code / Codex / Gemini CLI 提供官方高稳定性中转服务,具备企业级并发能力、快速开票和 7/24 专属技术支持。Claude Code / Codex / Gemini 官方渠道价格仅为原价的 38% / 2% / 9%,充值还有额外优惠!AICodeMirror 为 AIClient2API 用户提供专属福利:通过此链接注册即可享受首充 8折(20% off) 优惠,企业客户最高可享 75折(25% off)!
@@ -71,6 +73,7 @@ 感谢 VisionCoder 对本项目的支持。VisionCoder 开发平台 是一个可靠高效的 API 中继服务提供商,提供 Claude Code、Codex、Gemini 等主流 AI 模型,帮助开发者和团队更轻松地集成 AI 功能,提升工作效率。VisionCoder 还为我们的用户提供 Token Plan 限时活动:购买 1 个月,赠送 1 个月
Sponsor Contact diff --git a/README.md b/README.md index aa1547e3b..7dc96a8d0 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ 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 AIClient2API users: register via this link to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!
@@ -72,6 +74,7 @@ Thanks to VisionCoder for supporting this project. VisionCoder Developer Platform is a reliable and efficient API relay service provider, offering access to mainstream AI models such as Claude Code, Codex, and Gemini. It helps developers and teams integrate AI capabilities more easily and improve productivity. VisionCoder is also offering our users a limited-time Token Plan promotion: buy 1 month and get 1 month free.
Sponsor Contact diff --git a/VERSION b/VERSION index d9c62ed92..282895a8f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.2 \ No newline at end of file +3.0.3 \ No newline at end of file diff --git a/src/converters/strategies/CodexConverter.js b/src/converters/strategies/CodexConverter.js index 87c2eb788..cdc68abc3 100644 --- a/src/converters/strategies/CodexConverter.js +++ b/src/converters/strategies/CodexConverter.js @@ -673,7 +673,10 @@ export class CodexConverter extends BaseConverter { usage: { prompt_tokens: response.usage?.input_tokens || 0, completion_tokens: response.usage?.output_tokens || 0, - total_tokens: response.usage?.total_tokens || 0 + total_tokens: response.usage?.total_tokens || 0, + prompt_tokens_details: { + cached_tokens: response.usage?.input_tokens_details?.cached_tokens || 0 + } } }; @@ -827,6 +830,9 @@ export class CodexConverter extends BaseConverter { input_tokens: response.usage?.input_tokens || 0, output_tokens: response.usage?.output_tokens || 0, total_tokens: response.usage?.total_tokens || 0, + input_tokens_details: { + cached_tokens: response.usage?.input_tokens_details?.cached_tokens || 0 + }, output_tokens_details: { reasoning_tokens: response.usage?.output_tokens_details?.reasoning_tokens || 0 } @@ -893,7 +899,8 @@ export class CodexConverter extends BaseConverter { usageMetadata: { promptTokenCount: response.usage?.input_tokens || 0, candidatesTokenCount: response.usage?.output_tokens || 0, - totalTokenCount: response.usage?.total_tokens || 0 + totalTokenCount: response.usage?.total_tokens || 0, + cachedContentTokenCount: response.usage?.input_tokens_details?.cached_tokens || 0 }, modelVersion: response.model || model, responseId: response.id @@ -959,7 +966,8 @@ export class CodexConverter extends BaseConverter { stop_reason: stopReason, usage: { input_tokens: response.usage?.input_tokens || 0, - output_tokens: response.usage?.output_tokens || 0 + output_tokens: response.usage?.output_tokens || 0, + cache_read_input_tokens: response.usage?.input_tokens_details?.cached_tokens || 0 } }; } @@ -1154,7 +1162,10 @@ export class CodexConverter extends BaseConverter { template.usage = { prompt_tokens: chunk.response.usage?.input_tokens || 0, completion_tokens: chunk.response.usage?.output_tokens || 0, - total_tokens: chunk.response.usage?.total_tokens || 0 + total_tokens: chunk.response.usage?.total_tokens || 0, + prompt_tokens_details: { + cached_tokens: chunk.response.usage?.input_tokens_details?.cached_tokens || 0 + } }; if (chunk.response.usage?.output_tokens_details?.reasoning_tokens) { template.usage.completion_tokens_details = { @@ -1259,7 +1270,13 @@ export class CodexConverter extends BaseConverter { completedEvent.response.usage = { input_tokens: chunk.response.usage?.input_tokens || 0, output_tokens: chunk.response.usage?.output_tokens || 0, - total_tokens: chunk.response.usage?.total_tokens || 0 + total_tokens: chunk.response.usage?.total_tokens || 0, + input_tokens_details: { + cached_tokens: chunk.response.usage?.input_tokens_details?.cached_tokens || 0 + }, + output_tokens_details: { + reasoning_tokens: chunk.response.usage?.output_tokens_details?.reasoning_tokens || 0 + } }; events.push(completedEvent); this.streamParams.delete(resId); @@ -1345,7 +1362,8 @@ export class CodexConverter extends BaseConverter { template.usageMetadata = { promptTokenCount: chunk.response.usage?.input_tokens || 0, candidatesTokenCount: chunk.response.usage?.output_tokens || 0, - totalTokenCount: chunk.response.usage?.total_tokens || 0 + totalTokenCount: chunk.response.usage?.total_tokens || 0, + cachedContentTokenCount: chunk.response.usage?.input_tokens_details?.cached_tokens || 0 }; this.streamParams.delete(resId); return template; @@ -1586,7 +1604,8 @@ export class CodexConverter extends BaseConverter { delta: { stop_reason: "end_turn" }, usage: { input_tokens: chunk.response.usage?.input_tokens || 0, - output_tokens: chunk.response.usage?.output_tokens || 0 + output_tokens: chunk.response.usage?.output_tokens || 0, + cache_read_input_tokens: chunk.response.usage?.input_tokens_details?.cached_tokens || 0 } }, { type: "message_stop" } diff --git a/src/converters/strategies/OpenAIResponsesConverter.js b/src/converters/strategies/OpenAIResponsesConverter.js index 943ba4eb0..990fe3d9c 100644 --- a/src/converters/strategies/OpenAIResponsesConverter.js +++ b/src/converters/strategies/OpenAIResponsesConverter.js @@ -539,7 +539,7 @@ export class OpenAIResponsesConverter extends BaseConverter { usage: { input_tokens: responsesResponse.usage?.input_tokens || 0, output_tokens: responsesResponse.usage?.output_tokens || 0, - total_tokens: responsesResponse.usage?.total_tokens || 0 + cache_read_input_tokens: responsesResponse.usage?.input_tokens_details?.cached_tokens || 0 } }; } @@ -761,7 +761,8 @@ export class OpenAIResponsesConverter extends BaseConverter { usageMetadata: { promptTokenCount: responsesResponse.usage?.input_tokens || 0, candidatesTokenCount: responsesResponse.usage?.output_tokens || 0, - totalTokenCount: responsesResponse.usage?.total_tokens || 0 + totalTokenCount: responsesResponse.usage?.total_tokens || 0, + cachedContentTokenCount: responsesResponse.usage?.input_tokens_details?.cached_tokens || 0 } }; } @@ -945,7 +946,13 @@ export class OpenAIResponsesConverter extends BaseConverter { usage: { input_tokens: responseData.usage?.input_tokens || 0, output_tokens: responseData.usage?.output_tokens || 0, - total_tokens: responseData.usage?.total_tokens || 0 + total_tokens: responseData.usage?.total_tokens || 0, + input_tokens_details: { + cached_tokens: responseData.usage?.input_tokens_details?.cached_tokens || 0 + }, + output_tokens_details: { + reasoning_tokens: responseData.usage?.output_tokens_details?.reasoning_tokens || 0 + } } }; } diff --git a/src/providers/claude/claude-core.js b/src/providers/claude/claude-core.js index 36bb87e79..2e90e3b6b 100644 --- a/src/providers/claude/claude-core.js +++ b/src/providers/claude/claude-core.js @@ -3,7 +3,7 @@ import logger from '../../utils/logger.js'; import * as http from 'http'; import * as https from 'https'; import { configureAxiosProxy, configureTLSSidecar, isTLSSidecarEnabledForProvider } from '../../utils/proxy-utils.js'; -import { isRetryableNetworkError, MODEL_PROVIDER } from '../../utils/common.js'; +import { isRetryableNetworkError, MODEL_PROVIDER, getRetryAfterMs } from '../../utils/common.js'; /** * Claude API Core Service Class. @@ -107,12 +107,19 @@ export class ClaudeApiService { throw error; } - // 处理 429 (Too Many Requests) 与指数退避 - if (status === 429 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[Claude API] Received 429 (Too Many Requests). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(endpoint, body, isRetry, retryCount + 1); + // 处理 429 (Too Many Requests) + if (status === 429) { + const retryAfter = getRetryAfterMs(error); + if (retryAfter !== null) { + logger.warn(`[Claude API] Received 429 with Retry-After: ${retryAfter}ms. Throwing to upper layer.`); + throw error; + } + if (retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + logger.info(`[Claude API] Received 429 (Too Many Requests). No Retry-After found. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + return this.callApi(endpoint, body, isRetry, retryCount + 1); + } } // 处理其他可重试错误 (5xx 服务器错误) @@ -203,13 +210,20 @@ export class ClaudeApiService { throw error; } - // 处理 429 (Too Many Requests) 与指数退避 - if (status === 429 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[Claude API] Received 429 (Too Many Requests) during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.streamApi(endpoint, body, isRetry, retryCount + 1); - return; + // 处理 429 (Too Many Requests) + if (status === 429) { + const retryAfter = getRetryAfterMs(error); + if (retryAfter !== null) { + logger.warn(`[Claude API] Received 429 with Retry-After: ${retryAfter}ms during stream. Throwing to upper layer.`); + throw error; + } + if (retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + logger.info(`[Claude API] Received 429 (Too Many Requests) during stream. No Retry-After found. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + yield* this.streamApi(endpoint, body, isRetry, retryCount + 1); + return; + } } // 处理其他可重试错误 (5xx 服务器错误) diff --git a/src/providers/forward/forward-core.js b/src/providers/forward/forward-core.js index 6a5ada66e..700e6d765 100644 --- a/src/providers/forward/forward-core.js +++ b/src/providers/forward/forward-core.js @@ -3,7 +3,7 @@ import logger from '../../utils/logger.js'; import * as http from 'http'; import * as https from 'https'; import { configureAxiosProxy, configureTLSSidecar, isTLSSidecarEnabledForProvider } from '../../utils/proxy-utils.js'; -import { isRetryableNetworkError, MODEL_PROVIDER } from '../../utils/common.js'; +import { isRetryableNetworkError, MODEL_PROVIDER, getRetryAfterMs } from '../../utils/common.js'; /** * ForwardApiService - A provider that forwards requests to a specified API endpoint. @@ -92,7 +92,21 @@ export class ForwardApiService { throw error; } - if ((status === 429 || (status >= 500 && status < 600) || isNetworkError) && retryCount < maxRetries) { + if (status === 429) { + const retryAfter = getRetryAfterMs(error); + if (retryAfter !== null) { + logger.warn(`[Forward API] Received 429 with Retry-After: ${retryAfter}ms. Throwing to upper layer.`); + throw error; + } + if (retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + logger.info(`[Forward API] Received 429 (Too Many Requests). No Retry-After found. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + return this.callApi(endpoint, body, isRetry, retryCount + 1); + } + } + + if (((status >= 500 && status < 600) || isNetworkError) && retryCount < maxRetries) { const delay = baseDelay * Math.pow(2, retryCount); logger.info(`[Forward API] Error ${status || errorCode}. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); await new Promise(resolve => setTimeout(resolve, delay)); @@ -148,7 +162,22 @@ export class ForwardApiService { const errorCode = error.code; const isNetworkError = isRetryableNetworkError(error); - if ((status === 429 || (status >= 500 && status < 600) || isNetworkError) && retryCount < maxRetries) { + if (status === 429) { + const retryAfter = getRetryAfterMs(error); + if (retryAfter !== null) { + logger.warn(`[Forward API] Received 429 with Retry-After: ${retryAfter}ms during stream. Throwing to upper layer.`); + throw error; + } + if (retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + logger.info(`[Forward API] Received 429 (Too Many Requests) during stream. No Retry-After found. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + yield* this.streamApi(endpoint, body, isRetry, retryCount + 1); + return; + } + } + + if (((status >= 500 && status < 600) || isNetworkError) && retryCount < maxRetries) { const delay = baseDelay * Math.pow(2, retryCount); logger.info(`[Forward API] Stream error ${status || errorCode}. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); await new Promise(resolve => setTimeout(resolve, delay)); diff --git a/src/providers/gemini/antigravity-core.js b/src/providers/gemini/antigravity-core.js index 9e86d1a1a..b0b363ef4 100644 --- a/src/providers/gemini/antigravity-core.js +++ b/src/providers/gemini/antigravity-core.js @@ -12,7 +12,7 @@ import * as readline from 'readline'; import { v4 as uuidv4 } from 'uuid'; import open from 'open'; import { configureTLSSidecar } from '../../utils/proxy-utils.js'; -import { formatExpiryTime, isRetryableNetworkError, formatExpiryLog } from '../../utils/common.js'; +import { formatExpiryTime, isRetryableNetworkError, formatExpiryLog, getRetryAfterMs } from '../../utils/common.js'; import { getProviderModels } from '../provider-models.js'; import { handleGeminiAntigravityOAuth } from '../../auth/oauth-handlers.js'; import { getProxyConfigForProvider, getGoogleAuthProxyConfig, isTLSSidecarEnabledForProvider } from '../../utils/proxy-utils.js'; @@ -1139,12 +1139,17 @@ export class AntigravityApiService { } if (status === 429) { + const retryAfter = getRetryAfterMs(error); + if (retryAfter !== null) { + logger.warn(`[Antigravity API] Received 429 with Retry-After: ${retryAfter}ms. Throwing to upper layer.`); + throw error; + } if (baseURLIndex + 1 < this.baseURLs.length) { logger.info(`[Antigravity API] Rate limited on ${baseURL}. Trying next base URL...`); return this.callApi(method, body, isRetry, retryCount, baseURLIndex + 1); } else if (retryCount < maxRetries) { const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[Antigravity API] Rate limited. Retrying in ${delay}ms...`); + logger.info(`[Antigravity API] Received 429 (Too Many Requests). No Retry-After found. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); await new Promise(resolve => setTimeout(resolve, delay)); return this.callApi(method, body, isRetry, retryCount + 1, 0); } @@ -1242,13 +1247,18 @@ export class AntigravityApiService { } if (status === 429) { + const retryAfter = getRetryAfterMs(error); + if (retryAfter !== null) { + logger.warn(`[Antigravity API] Received 429 with Retry-After: ${retryAfter}ms during stream. Throwing to upper layer.`); + throw error; + } if (baseURLIndex + 1 < this.baseURLs.length) { logger.info(`[Antigravity API] Rate limited on ${baseURL}. Trying next base URL...`); yield* this.streamApi(method, body, isRetry, retryCount, baseURLIndex + 1); return; } else if (retryCount < maxRetries) { const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[Antigravity API] Rate limited during stream. Retrying in ${delay}ms...`); + logger.info(`[Antigravity API] Received 429 (Too Many Requests) during stream. No Retry-After found. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); await new Promise(resolve => setTimeout(resolve, delay)); yield* this.streamApi(method, body, isRetry, retryCount + 1, 0); return; diff --git a/src/providers/gemini/gemini-core.js b/src/providers/gemini/gemini-core.js index ad49eeb7b..060458269 100644 --- a/src/providers/gemini/gemini-core.js +++ b/src/providers/gemini/gemini-core.js @@ -9,7 +9,7 @@ import * as os from 'os'; import * as readline from 'readline'; import open from 'open'; import { configureTLSSidecar } from '../../utils/proxy-utils.js'; -import { API_ACTIONS, formatExpiryTime, isRetryableNetworkError, formatExpiryLog } from '../../utils/common.js'; +import { API_ACTIONS, formatExpiryTime, isRetryableNetworkError, formatExpiryLog, getRetryAfterMs } from '../../utils/common.js'; import { getProviderModels } from '../provider-models.js'; import { handleGeminiCliOAuth } from '../../auth/oauth-handlers.js'; import { getProxyConfigForProvider, getGoogleAuthProxyConfig, isTLSSidecarEnabledForProvider } from '../../utils/proxy-utils.js'; @@ -46,63 +46,6 @@ function applyGeminiCLIHeaders(headers, model) { } -/** - * 从 Google API 的 429 错误响应中提取重试延迟 - * @param {Object|string} errorBody - 错误响应体 - * @returns {number|null} 延迟毫秒数 - */ -function parseRetryDelay(errorBody) { - try { - const data = typeof errorBody === 'string' ? JSON.parse(errorBody) : errorBody; - const details = data?.error?.details; - if (Array.isArray(details)) { - for (const detail of details) { - if (detail['@type'] === 'type.googleapis.com/google.rpc.RetryInfo') { - const retryDelay = detail.retryDelay; - if (retryDelay) { - const match = retryDelay.match(/^([\d.]+)s$/); - if (match) return parseFloat(match[1]) * 1000; - } - } - } - for (const detail of details) { - if (detail['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo') { - const quotaResetDelay = detail.metadata?.quotaResetDelay; - if (quotaResetDelay) { - const match = quotaResetDelay.match(/^([\d.]+)(ms|s)$/); - if (match) { - let ms = parseFloat(match[1]); - if (match[2] === 's') ms *= 1000; - return ms; - } - } - } - } - } - const message = data?.error?.message; - if (message) { - const match = message.match(/after\s+(\d+)s\.?/); - if (match) return parseInt(match[1]) * 1000; - } - } catch (e) {} - return null; -} - -function is_anti_truncation_model(model) { - return ANTI_TRUNCATION_MODELS.some(antiModel => model.includes(antiModel)); -} - -// 从防截断模型名中提取实际模型名 -function extract_model_from_anti_model(model) { - if (model.startsWith('anti-')) { - const originalModel = model.substring(5); // 移除 'anti-' 前缀 - if (GEMINI_MODELS.includes(originalModel)) { - return originalModel; - } - } - return model; // 如果不是anti-前缀或不在原模型列表中,则返回原模型名 -} - function modelSupportsThinking(modelName) { if (!modelName) return false; const name = String(modelName).toLowerCase(); @@ -623,12 +566,19 @@ export class GeminiApiService { throw error; } - // Handle 429 (Too Many Requests) with exponential backoff - if (status === 429 && retryCount < maxRetries) { - const delay = parseRetryDelay(error.response?.data) || (baseDelay * Math.pow(2, retryCount)); - logger.info(`[Gemini API] Received 429 (Too Many Requests). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(method, body, isRetry, retryCount + 1, model); + // Handle 429 (Too Many Requests) + if (status === 429) { + const retryAfter = getRetryAfterMs(error); + if (retryAfter !== null) { + logger.warn(`[Gemini API] Received 429 with Retry-After: ${retryAfter}ms. Throwing to upper layer.`); + throw error; + } + if (retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + logger.info(`[Gemini API] Received 429 (Too Many Requests). No Retry-After found. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + return this.callApi(method, body, isRetry, retryCount + 1, model); + } } // Handle other retryable errors (5xx server errors) @@ -706,13 +656,20 @@ export class GeminiApiService { throw error; } - // Handle 429 (Too Many Requests) with exponential backoff - if (status === 429 && retryCount < maxRetries) { - const delay = parseRetryDelay(error.response?.data) || (baseDelay * Math.pow(2, retryCount)); - logger.info(`[Gemini API] Received 429 (Too Many Requests) during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.streamApi(method, body, isRetry, retryCount + 1, model); - return; + // Handle 429 (Too Many Requests) + if (status === 429) { + const retryAfter = getRetryAfterMs(error); + if (retryAfter !== null) { + logger.warn(`[Gemini API] Received 429 with Retry-After: ${retryAfter}ms during stream. Throwing to upper layer.`); + throw error; + } + if (retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + logger.info(`[Gemini API] Received 429 (Too Many Requests) during stream. No Retry-After found. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + yield* this.streamApi(method, body, isRetry, retryCount + 1, model); + return; + } } // Handle other retryable errors (5xx server errors) diff --git a/src/providers/grok/grok-core.js b/src/providers/grok/grok-core.js index 5c9d5eb82..881086a9d 100644 --- a/src/providers/grok/grok-core.js +++ b/src/providers/grok/grok-core.js @@ -3,7 +3,7 @@ import logger from '../../utils/logger.js'; import * as http from 'http'; import * as https from 'https'; import { v4 as uuidv4 } from 'uuid'; -import { MODEL_PROTOCOL_PREFIX, isRetryableNetworkError } from '../../utils/common.js'; +import { MODEL_PROTOCOL_PREFIX, isRetryableNetworkError, getRetryAfterMs } from '../../utils/common.js'; import { getProviderModels } from '../provider-models.js'; import { configureAxiosProxy, configureTLSSidecar, isTLSSidecarEnabledForProvider } from '../../utils/proxy-utils.js'; import { MODEL_PROVIDER } from '../../utils/common.js'; @@ -1482,12 +1482,19 @@ export class GrokApiService { } } - if (status === 429 && canRetryInRequest) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[Grok API] Received 429 during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.generateContentStream(model, requestBody, retryCount + 1); - return; + if (status === 429) { + const retryAfter = getRetryAfterMs(error); + if (retryAfter !== null) { + logger.warn(`[Grok API] Received 429 with Retry-After: ${retryAfter}ms during stream. Throwing to upper layer.`); + throw error; + } + if (canRetryInRequest) { + const delay = baseDelay * Math.pow(2, retryCount); + logger.info(`[Grok API] Received 429 (Too Many Requests) during stream. No Retry-After found. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + yield* this.generateContentStream(model, requestBody, retryCount + 1); + return; + } } if (status >= 500 && status < 600 && canRetryInRequest) { diff --git a/src/providers/openai/iflow-core.js b/src/providers/openai/iflow-core.js index b03338295..73a3c1b1f 100644 --- a/src/providers/openai/iflow-core.js +++ b/src/providers/openai/iflow-core.js @@ -26,7 +26,7 @@ import * as path from 'path'; import * as os from 'os'; import * as crypto from 'crypto'; import { configureAxiosProxy } from '../../utils/proxy-utils.js'; -import { isRetryableNetworkError, MODEL_PROVIDER, formatExpiryLog } from '../../utils/common.js'; +import { isRetryableNetworkError, MODEL_PROVIDER, formatExpiryLog, getRetryAfterMs } from '../../utils/common.js'; import { getProviderPoolManager } from '../../services/service-manager.js'; import { getProviderModels } from '../provider-models.js'; @@ -839,12 +839,19 @@ export class IFlowApiService { throw error; } - // Handle 429 (Too Many Requests) with exponential backoff - if (status === 429 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[iFlow] Received 429 (Too Many Requests). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(endpoint, body, model, isRetry, retryCount + 1); + // Handle 429 (Too Many Requests) + if (status === 429) { + const retryAfter = getRetryAfterMs(error); + if (retryAfter !== null) { + logger.warn(`[iFlow] Received 429 with Retry-After: ${retryAfter}ms. Throwing to upper layer.`); + throw error; + } + if (retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + logger.info(`[iFlow] Received 429 (Too Many Requests). No Retry-After found. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + return this.callApi(endpoint, body, model, isRetry, retryCount + 1); + } } // Handle other retryable errors (5xx server errors) @@ -997,13 +1004,20 @@ export class IFlowApiService { throw error; } - // Handle 429 (Too Many Requests) with exponential backoff - if (status === 429 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[iFlow] Received 429 (Too Many Requests) during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.streamApi(endpoint, body, model, isRetry, retryCount + 1); - return; + // Handle 429 (Too Many Requests) + if (status === 429) { + const retryAfter = getRetryAfterMs(error); + if (retryAfter !== null) { + logger.warn(`[iFlow] Received 429 with Retry-After: ${retryAfter}ms during stream. Throwing to upper layer.`); + throw error; + } + if (retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + logger.info(`[iFlow] Received 429 (Too Many Requests) during stream. No Retry-After found. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + yield* this.streamApi(endpoint, body, model, isRetry, retryCount + 1); + return; + } } // Handle other retryable errors (5xx server errors) diff --git a/src/providers/openai/openai-core.js b/src/providers/openai/openai-core.js index 50bbca321..cb997a0e6 100644 --- a/src/providers/openai/openai-core.js +++ b/src/providers/openai/openai-core.js @@ -3,7 +3,7 @@ import logger from '../../utils/logger.js'; import * as http from 'http'; import * as https from 'https'; import { configureAxiosProxy, configureTLSSidecar, isTLSSidecarEnabledForProvider } from '../../utils/proxy-utils.js'; -import { isRetryableNetworkError, MODEL_PROVIDER } from '../../utils/common.js'; +import { isRetryableNetworkError, MODEL_PROVIDER, getRetryAfterMs } from '../../utils/common.js'; // Assumed OpenAI API specification service for interacting with third-party models export class OpenAIApiService { @@ -83,12 +83,19 @@ export class OpenAIApiService { throw error; } - // Handle 429 (Too Many Requests) with exponential backoff - if (status === 429 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[OpenAI API] Received 429 (Too Many Requests). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(endpoint, body, isRetry, retryCount + 1); + // Handle 429 (Too Many Requests) + if (status === 429) { + const retryAfter = getRetryAfterMs(error); + if (retryAfter !== null) { + logger.warn(`[OpenAI API] Received 429 with Retry-After: ${retryAfter}ms. Throwing to upper layer.`); + throw error; + } + if (retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + logger.info(`[OpenAI API] Received 429 (Too Many Requests). No Retry-After found. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + return this.callApi(endpoint, body, isRetry, retryCount + 1); + } } // Handle other retryable errors (5xx server errors) @@ -170,13 +177,20 @@ export class OpenAIApiService { throw error; } - // Handle 429 (Too Many Requests) with exponential backoff - if (status === 429 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[OpenAI API] Received 429 (Too Many Requests) during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.streamApi(endpoint, body, isRetry, retryCount + 1); - return; + // Handle 429 (Too Many Requests) + if (status === 429) { + const retryAfter = getRetryAfterMs(error); + if (retryAfter !== null) { + logger.warn(`[OpenAI API] Received 429 with Retry-After: ${retryAfter}ms during stream. Throwing to upper layer.`); + throw error; + } + if (retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + logger.info(`[OpenAI API] Received 429 (Too Many Requests) during stream. No Retry-After found. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + yield* this.streamApi(endpoint, body, isRetry, retryCount + 1); + return; + } } // Handle other retryable errors (5xx server errors) diff --git a/src/providers/openai/openai-responses-core.js b/src/providers/openai/openai-responses-core.js index f9e60d5bb..58dc25653 100644 --- a/src/providers/openai/openai-responses-core.js +++ b/src/providers/openai/openai-responses-core.js @@ -3,7 +3,7 @@ import logger from '../../utils/logger.js'; import * as http from 'http'; import * as https from 'https'; import { configureAxiosProxy, configureTLSSidecar, isTLSSidecarEnabledForProvider } from '../../utils/proxy-utils.js'; -import { MODEL_PROVIDER } from '../../utils/common.js'; +import { MODEL_PROVIDER, getRetryAfterMs } from '../../utils/common.js'; // OpenAI Responses API specification service for interacting with third-party models export class OpenAIResponsesApiService { @@ -79,12 +79,19 @@ export class OpenAIResponsesApiService { throw error; } - // Handle 429 (Too Many Requests) with exponential backoff - if (status === 429 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[API] Received 429 (Too Many Requests). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(endpoint, body, isRetry, retryCount + 1); + // Handle 429 (Too Many Requests) + if (status === 429) { + const retryAfter = getRetryAfterMs(error); + if (retryAfter !== null) { + logger.warn(`[API] Received 429 with Retry-After: ${retryAfter}ms. Throwing to upper layer.`); + throw error; + } + if (retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + logger.info(`[API] Received 429 (Too Many Requests). No Retry-After found. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + return this.callApi(endpoint, body, isRetry, retryCount + 1); + } } // Handle other retryable errors (5xx server errors) @@ -151,13 +158,20 @@ export class OpenAIResponsesApiService { throw error; } - // Handle 429 (Too Many Requests) with exponential backoff - if (status === 429 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[API] Received 429 (Too Many Requests) during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.streamApi(endpoint, body, isRetry, retryCount + 1); - return; + // Handle 429 (Too Many Requests) + if (status === 429) { + const retryAfter = getRetryAfterMs(error); + if (retryAfter !== null) { + logger.warn(`[API] Received 429 with Retry-After: ${retryAfter}ms during stream. Throwing to upper layer.`); + throw error; + } + if (retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + logger.info(`[API] Received 429 (Too Many Requests) during stream. No Retry-After found. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + yield* this.streamApi(endpoint, body, isRetry, retryCount + 1); + return; + } } // Handle other retryable errors (5xx server errors) diff --git a/src/providers/openai/qwen-core.js b/src/providers/openai/qwen-core.js index b7b36cc67..661d8b408 100644 --- a/src/providers/openai/qwen-core.js +++ b/src/providers/openai/qwen-core.js @@ -13,7 +13,7 @@ import { randomUUID } from 'node:crypto'; import { getProviderModels } from '../provider-models.js'; import { handleQwenOAuth } from '../../auth/oauth-handlers.js'; import { configureAxiosProxy, configureTLSSidecar } from '../../utils/proxy-utils.js'; -import { isRetryableNetworkError, MODEL_PROVIDER, formatExpiryLog } from '../../utils/common.js'; +import { isRetryableNetworkError, MODEL_PROVIDER, formatExpiryLog, getRetryAfterMs } from '../../utils/common.js'; import { getProviderPoolManager } from '../../services/service-manager.js'; // --- Constants --- @@ -778,7 +778,21 @@ export class QwenApiService { throw error; } - if ((status === 429 || (status >= 500 && status < 600) || isRetryableNetworkError(error)) && retryCount < maxRetries) { + if (status === 429) { + const retryAfter = getRetryAfterMs(error); + if (retryAfter !== null) { + logger.warn(`[QwenApiService] Received 429 with Retry-After: ${retryAfter}ms. Throwing to upper layer.`); + throw error; + } + if (retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + logger.info(`[QwenApiService] Received 429 (Too Many Requests). No Retry-After found. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + return this.callApiWithAuthAndRetry(endpoint, body, isStream, retryCount + 1); + } + } + + if (((status >= 500 && status < 600) || isRetryableNetworkError(error)) && retryCount < maxRetries) { const delay = baseDelay * Math.pow(2, retryCount); logger.info(`[QwenApiService] Request failed (${status || errorCode}). Retrying in ${delay}ms... (${retryCount + 1}/${maxRetries})`); await new Promise(resolve => setTimeout(resolve, delay)); diff --git a/src/utils/common.js b/src/utils/common.js index a60fd419f..d13fc1f93 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -140,12 +140,21 @@ function getRetryDelayFromBody(errorBody) { if (retryDelay !== null) return retryDelay; } } + + const message = data?.error?.message; + if (message) { + const match = message.match(/after\s+([\d.]+)\s*(ms|s)?\.?/i); + if (match) { + const amount = parseFloat(match[1]); + return Math.max(0, Math.round(match[2]?.toLowerCase() === 'ms' ? amount : amount * 1000)); + } + } } catch {} return null; } -function getRetryAfterMs(error, now = Date.now()) { +export function getRetryAfterMs(error, now = Date.now()) { const headerDelay = parseRetryAfterMs(getHeaderValue(error?.response?.headers, 'retry-after'), now); if (headerDelay !== null) return headerDelay; @@ -1779,6 +1788,15 @@ export function extractSystemPromptFromRequestBody(requestBody, provider) { incomingSystemText = userMessage.content; } } + if (typeof incomingSystemText === 'object' && incomingSystemText !== null) { + if (Array.isArray(incomingSystemText)) { + incomingSystemText = incomingSystemText + .map(item => (typeof item === 'string' ? item : item.text || JSON.stringify(item))) + .join('\n'); + } else { + incomingSystemText = JSON.stringify(incomingSystemText); + } + } break; case MODEL_PROTOCOL_PREFIX.GEMINI: const geminiSystemInstruction = requestBody.system_instruction || requestBody.systemInstruction; diff --git a/src/utils/provider-strategy.js b/src/utils/provider-strategy.js index f408f9628..a42c6b60f 100644 --- a/src/utils/provider-strategy.js +++ b/src/utils/provider-strategy.js @@ -70,10 +70,15 @@ export class ProviderStrategy { } try { - if (incomingSystemText && incomingSystemText !== currentSystemText) { - await fs.writeFile(FETCH_SYSTEM_PROMPT_FILE, incomingSystemText); + let textToWrite = incomingSystemText; + if (typeof textToWrite === 'object' && textToWrite !== null) { + textToWrite = JSON.stringify(textToWrite); + } + + if (textToWrite !== undefined && textToWrite !== null && textToWrite !== currentSystemText) { + await fs.writeFile(FETCH_SYSTEM_PROMPT_FILE, textToWrite); logger.info(`[System Prompt Manager] System prompt updated in file for provider '${providerName}'.`); - } else if (!incomingSystemText && currentSystemText) { + } else if ((textToWrite === '' || textToWrite === undefined || textToWrite === null) && currentSystemText) { await fs.writeFile(FETCH_SYSTEM_PROMPT_FILE, ''); logger.info('[System Prompt Manager] System prompt cleared from file.'); } diff --git a/static/app/usage-manager.js b/static/app/usage-manager.js index 158dc5e59..6e302320c 100644 --- a/static/app/usage-manager.js +++ b/static/app/usage-manager.js @@ -11,6 +11,7 @@ import { t, getCurrentLanguage } from './i18n.js'; * 注:gemini-antigravity 已支持 remainingPercent,移除限制 */ const PROVIDERS_WITHOUT_USAGE_DISPLAY = [ + 'gemini-antigravity', ]; // 提供商配置缓存 From 7c1d9b7bececee9f3d7b9aeed58ade7bb0e9e37e Mon Sep 17 00:00:00 2001 From: hex2077 Date: Thu, 7 May 2026 12:37:34 +0800 Subject: [PATCH 094/135] =?UTF-8?q?fix:=20=E5=9C=A8=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E5=92=8C=E4=BB=A4=E7=89=8C=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E5=89=8D=E7=A7=BB=E9=99=A4providerPools=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复providerPools配置在临时配置对象中残留导致服务适配器初始化失败的问题。在handleDetectProviderModels和ProviderPoolManager的refreshToken、listModels方法中,创建临时配置后立即删除providerPools字段,避免适配器因意外字段而报错。 --- VERSION | 2 +- src/providers/provider-pool-manager.js | 2 ++ src/ui-modules/provider-api.js | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 282895a8f..b38ebbfce 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.3 \ No newline at end of file +3.0.4 \ No newline at end of file diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index 97ab07795..c35d36e91 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -471,6 +471,7 @@ export class ProviderPoolManager { ...config, MODEL_PROVIDER: providerType }; + delete tempConfig.providerPools; const serviceAdapter = getServiceAdapter(tempConfig); // 调用适配器的 refreshToken 方法(内部封装了具体的刷新逻辑) @@ -1454,6 +1455,7 @@ export class ProviderPoolManager { ...targetConfig, MODEL_PROVIDER: providerType }; + delete tempConfig.providerPools; const serviceAdapter = getServiceAdapter(tempConfig); if (typeof serviceAdapter.listModels === 'function') { diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index 1d44e5ea5..23649e591 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -415,6 +415,7 @@ export async function handleDetectProviderModels(req, res, currentConfig, provid MODEL_PROVIDER: providerType, uuid: detectionUuid }; + delete tempConfig.providerPools; let models = []; try { From 2bebbd86d11360300777c97e50b73ed957d92c36 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Thu, 7 May 2026 13:10:40 +0800 Subject: [PATCH 095/135] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E4=BB=A4=E7=89=8C=E5=AD=97=E6=AE=B5=E5=A4=84=E7=90=86?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E5=B9=B6=E6=9B=B4=E6=96=B0=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E8=87=B33.0.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在多个插件和转换器中添加对 `prompt_tokens_details.cached_tokens` 和 `input_tokens_details.cached_tokens` 字段的支持,以处理不同API响应中缓存令牌字段的命名差异。同时提取公共方法 `getCachedInputTokens` 来统一处理逻辑,避免代码重复。 --- VERSION | 2 +- src/converters/strategies/CodexConverter.js | 25 +++++++++++++------ src/plugins/api-potluck/index.js | 4 +++ .../model-usage-stats/stats-manager.js | 4 +++ 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/VERSION b/VERSION index b38ebbfce..7da3c1687 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.4 \ No newline at end of file +3.0.5 \ No newline at end of file diff --git a/src/converters/strategies/CodexConverter.js b/src/converters/strategies/CodexConverter.js index cdc68abc3..f3e1626f9 100644 --- a/src/converters/strategies/CodexConverter.js +++ b/src/converters/strategies/CodexConverter.js @@ -149,6 +149,15 @@ export class CodexConverter extends BaseConverter { return data; } + getCachedInputTokens(usage = {}) { + return usage.input_tokens_details?.cached_tokens ?? + usage.prompt_tokens_details?.cached_tokens ?? + usage.cache_read_input_tokens ?? + usage.cached_tokens ?? + usage.cachedContentTokenCount ?? + 0; + } + /** * OpenAI Responses → Codex 请求转换 */ @@ -675,7 +684,7 @@ export class CodexConverter extends BaseConverter { completion_tokens: response.usage?.output_tokens || 0, total_tokens: response.usage?.total_tokens || 0, prompt_tokens_details: { - cached_tokens: response.usage?.input_tokens_details?.cached_tokens || 0 + cached_tokens: this.getCachedInputTokens(response.usage) } } }; @@ -831,7 +840,7 @@ export class CodexConverter extends BaseConverter { output_tokens: response.usage?.output_tokens || 0, total_tokens: response.usage?.total_tokens || 0, input_tokens_details: { - cached_tokens: response.usage?.input_tokens_details?.cached_tokens || 0 + cached_tokens: this.getCachedInputTokens(response.usage) }, output_tokens_details: { reasoning_tokens: response.usage?.output_tokens_details?.reasoning_tokens || 0 @@ -900,7 +909,7 @@ export class CodexConverter extends BaseConverter { promptTokenCount: response.usage?.input_tokens || 0, candidatesTokenCount: response.usage?.output_tokens || 0, totalTokenCount: response.usage?.total_tokens || 0, - cachedContentTokenCount: response.usage?.input_tokens_details?.cached_tokens || 0 + cachedContentTokenCount: this.getCachedInputTokens(response.usage) }, modelVersion: response.model || model, responseId: response.id @@ -967,7 +976,7 @@ export class CodexConverter extends BaseConverter { usage: { input_tokens: response.usage?.input_tokens || 0, output_tokens: response.usage?.output_tokens || 0, - cache_read_input_tokens: response.usage?.input_tokens_details?.cached_tokens || 0 + cache_read_input_tokens: this.getCachedInputTokens(response.usage) } }; } @@ -1164,7 +1173,7 @@ export class CodexConverter extends BaseConverter { completion_tokens: chunk.response.usage?.output_tokens || 0, total_tokens: chunk.response.usage?.total_tokens || 0, prompt_tokens_details: { - cached_tokens: chunk.response.usage?.input_tokens_details?.cached_tokens || 0 + cached_tokens: this.getCachedInputTokens(chunk.response.usage) } }; if (chunk.response.usage?.output_tokens_details?.reasoning_tokens) { @@ -1272,7 +1281,7 @@ export class CodexConverter extends BaseConverter { output_tokens: chunk.response.usage?.output_tokens || 0, total_tokens: chunk.response.usage?.total_tokens || 0, input_tokens_details: { - cached_tokens: chunk.response.usage?.input_tokens_details?.cached_tokens || 0 + cached_tokens: this.getCachedInputTokens(chunk.response.usage) }, output_tokens_details: { reasoning_tokens: chunk.response.usage?.output_tokens_details?.reasoning_tokens || 0 @@ -1363,7 +1372,7 @@ export class CodexConverter extends BaseConverter { promptTokenCount: chunk.response.usage?.input_tokens || 0, candidatesTokenCount: chunk.response.usage?.output_tokens || 0, totalTokenCount: chunk.response.usage?.total_tokens || 0, - cachedContentTokenCount: chunk.response.usage?.input_tokens_details?.cached_tokens || 0 + cachedContentTokenCount: this.getCachedInputTokens(chunk.response.usage) }; this.streamParams.delete(resId); return template; @@ -1605,7 +1614,7 @@ export class CodexConverter extends BaseConverter { usage: { input_tokens: chunk.response.usage?.input_tokens || 0, output_tokens: chunk.response.usage?.output_tokens || 0, - cache_read_input_tokens: chunk.response.usage?.input_tokens_details?.cached_tokens || 0 + cache_read_input_tokens: this.getCachedInputTokens(chunk.response.usage) } }, { type: "message_stop" } diff --git a/src/plugins/api-potluck/index.js b/src/plugins/api-potluck/index.js index 0c4acc576..e92e257eb 100644 --- a/src/plugins/api-potluck/index.js +++ b/src/plugins/api-potluck/index.js @@ -87,6 +87,10 @@ function normalizeUsageCandidate(candidate) { const cachedTokens = toNumber( candidate.cached_tokens ?? usage?.cached_tokens ?? + candidate.prompt_tokens_details?.cached_tokens ?? + candidate.input_tokens_details?.cached_tokens ?? + usage?.prompt_tokens_details?.cached_tokens ?? + usage?.input_tokens_details?.cached_tokens ?? usage?.cache_read_input_tokens ?? usage?.cachedContentTokenCount ); diff --git a/src/plugins/model-usage-stats/stats-manager.js b/src/plugins/model-usage-stats/stats-manager.js index 9b91c9a97..e413b324a 100644 --- a/src/plugins/model-usage-stats/stats-manager.js +++ b/src/plugins/model-usage-stats/stats-manager.js @@ -284,6 +284,10 @@ function normalizeUsageCandidate(candidate) { const cachedTokens = toNumber( candidate.cached_tokens ?? usage?.cached_tokens ?? + candidate.prompt_tokens_details?.cached_tokens ?? + candidate.input_tokens_details?.cached_tokens ?? + usage?.prompt_tokens_details?.cached_tokens ?? + usage?.input_tokens_details?.cached_tokens ?? usage?.cache_read_input_tokens ?? usage?.cachedContentTokenCount ); From cafcd7a53cc9c414aeeece5b841e2ab2fcada2fb Mon Sep 17 00:00:00 2001 From: hex2077 Date: Thu, 7 May 2026 14:42:15 +0800 Subject: [PATCH 096/135] =?UTF-8?q?fix(gemini):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E9=98=B2=E6=88=AA=E6=96=AD=E6=A8=A1=E5=9E=8B=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E5=B9=B6=E6=9B=B4=E6=96=B0=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加 is_anti_truncation_model 和 extract_model_from_anti_model 函数以处理带有 'anti-' 前缀的模型名称,确保能正确识别并提取原始模型名。同时将版本号从 3.0.5 更新至 3.0.5.1。 --- VERSION | 2 +- src/providers/gemini/gemini-core.js | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 7da3c1687..cd7ee35d9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.5 \ No newline at end of file +3.0.5.1 \ No newline at end of file diff --git a/src/providers/gemini/gemini-core.js b/src/providers/gemini/gemini-core.js index 060458269..384faec4c 100644 --- a/src/providers/gemini/gemini-core.js +++ b/src/providers/gemini/gemini-core.js @@ -45,6 +45,23 @@ function applyGeminiCLIHeaders(headers, model) { headers['X-Goog-Api-Client'] = GEMINI_CLI_API_CLIENT_HEADER; } +function is_anti_truncation_model(model) { + if (!model) return false; + return ANTI_TRUNCATION_MODELS.some(antiModel => model.includes(antiModel)); +} + +// 从防截断模型名中提取实际模型名 +function extract_model_from_anti_model(model) { + if (!model) return model; + if (model.startsWith('anti-')) { + const originalModel = model.substring(5); // 移除 'anti-' 前缀 + if (GEMINI_MODELS.includes(originalModel)) { + return originalModel; + } + } + return model; // 如果不是anti-前缀或不在原模型列表中,则返回原模型名 +} + function modelSupportsThinking(modelName) { if (!modelName) return false; From b0b8b5735908eb773c1d811a14a38ec6132530b0 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Thu, 7 May 2026 15:18:12 +0800 Subject: [PATCH 097/135] =?UTF-8?q?docs:=20=E7=A7=BB=E9=99=A4=20AICodeMirr?= =?UTF-8?q?or=20=E8=B5=9E=E5=8A=A9=E5=95=86=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从 README 文档(包括英文、中文、日文版本)中注释掉 AICodeMirror 赞助商信息。 --- README-JA.md | 2 ++ README-ZH.md | 2 ++ README.md | 2 ++ 3 files changed, 6 insertions(+) diff --git a/README-JA.md b/README-JA.md index ea5398c2b..0b6e0991c 100644 --- a/README-JA.md +++ b/README-JA.md @@ -42,6 +42,7 @@ PackyCode は信頼性が高く効率的な API リレーサービスプロバイダーであり、Claude Code、Codex、Gemini などのリレーサービス提供しています。PackyCode は当ソフトウェアユーザーに特别割引を提供しています:このリンクから登録し、チャージ時に AIClient2API プロモーションコードを入力すると 10% オフになります。
- - VisionCoder Sponsor - - - VisionCoder による本プロジェクトへのスポンサーに感謝します!VisionCoder 開発プラットフォームは信頼性が高く効率的な API 中継サービスプロバイダーであり、Claude Code、Codex、Gemini などの主要な AI モデルへのアクセスを提供しています。開発者やチームが AI 機能をより簡単に統合し、生産性を向上させるのを支援します。VisionCoder は本ソフトウェアのユーザー向けに期間限定の Token Plan 特典を提供しています:1ヶ月の購入で1ヶ月分を無料で進呈。 -
- - VisionCoder Sponsor - - - 感谢 VisionCoder 对本项目的支持。VisionCoder 开发平台 是一个可靠高效的 API 中继服务提供商,提供 Claude Code、Codex、Gemini 等主流 AI 模型,帮助开发者和团队更轻松地集成 AI 功能,提升工作效率。VisionCoder 还为我们的用户提供 Token Plan 限时活动:购买 1 个月,赠送 1 个月。 -
- - VisionCoder Sponsor - - - Thanks to VisionCoder for supporting this project. VisionCoder Developer Platform is a reliable and efficient API relay service provider, offering access to mainstream AI models such as Claude Code, Codex, and Gemini. It helps developers and teams integrate AI capabilities more easily and improve productivity. VisionCoder is also offering our users a limited-time Token Plan promotion: buy 1 month and get 1 month free. -

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≀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 literal 0 HcmV?d00001 From c45a6c795bfc9e23722b09d664dcd230304f6458 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Mon, 20 Apr 2026 12:40:20 +0800 Subject: [PATCH 029/135] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E7=89=88=E6=9C=AC=E5=8F=B7=E8=87=B3=202.15.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index d6961c361..68e69e405 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.14.9 +2.15.0 From b1876550817142dd5dfbe066d57a48976349867f Mon Sep 17 00:00:00 2001 From: hex2077 Date: Mon, 20 Apr 2026 13:03:37 +0800 Subject: [PATCH 030/135] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0Grok=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E6=A8=A1=E5=9E=8B=E8=87=B34.1-mini=E5=B9=B6=E6=94=B9?= =?UTF-8?q?=E8=BF=9B=E5=81=A5=E5=BA=B7=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将Grok自定义提供商的默认检查模型从grok-3升级为grok-4.1-mini - 新增resetAllHealthInType方法用于批量重置提供商健康状态 - 优化initializeProviderStatus方法,支持从配置同步统计数据 - 改进健康状态重置逻辑,优先使用内存管理器操作 - 更新相关示例文档中的模型引用 - 替换部分logo图片资源 --- VERSION | 2 +- src/img/logo-mid.webp | Bin 10422 -> 24134 bytes src/img/logo-mid0.webp | Bin 0 -> 35816 bytes src/img/logo-min.webp | Bin 23414 -> 0 bytes src/img/logo.webp | Bin 28094 -> 0 bytes src/providers/provider-pool-manager.js | 43 +++++++++--- src/ui-modules/config-api.js | 2 +- src/ui-modules/provider-api.js | 91 +++++++++++++------------ src/utils/provider-utils.js | 2 +- static/app/routing-examples.js | 6 +- 10 files changed, 88 insertions(+), 58 deletions(-) create mode 100644 src/img/logo-mid0.webp delete mode 100644 src/img/logo-min.webp delete mode 100644 src/img/logo.webp diff --git a/VERSION b/VERSION index 68e69e405..3b1fc7950 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.15.0 +2.15.1 diff --git a/src/img/logo-mid.webp b/src/img/logo-mid.webp index 3861e26df9aafe5df6879ca07022dafa5227acae..bb4736f3a4aca13c6e5815ebfbcd8fdc715ef986 100644 GIT binary patch literal 24134 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

e3$XvuafoEnKS${{uGe&k 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 MfB*mi6c+#h0O5i`6#xJL literal 10422 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 diff --git a/src/img/logo-mid0.webp b/src/img/logo-mid0.webp new file mode 100644 index 0000000000000000000000000000000000000000..a4c7bf91b0c05eb0c4968963aebc3696ddd293b6 GIT binary patch literal 35816 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 Lg>Hw}@Bjb+tr5$T literal 0 HcmV?d00001 diff --git a/src/img/logo-min.webp b/src/img/logo-min.webp deleted file mode 100644 index 4f55ca17282e126b21627aa3cf8d1fcea3b0b65a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23414 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 diff --git a/src/img/logo.webp b/src/img/logo.webp deleted file mode 100644 index 1cb2a0edabf8e5cea8ab833c0b6c7455ef5990f0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28094 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