From 1635b660bbb613477a3f58ed55ab7b12cc46d5cf Mon Sep 17 00:00:00 2001 From: 0xsline Date: Mon, 29 Dec 2025 00:07:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8C=89=E6=A8=A1=E5=9E=8B=E7=BB=84?= =?UTF-8?q?=E7=A6=81=E7=94=A8=E9=A2=9D=E5=BA=A6=E8=80=97=E5=B0=BD=E7=9A=84?= =?UTF-8?q?=E5=87=AD=E8=AF=81=20+=20=E4=BF=AE=E5=A4=8D=20tools=20400=20?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 429 + 额度为0时,禁用该凭证的整个模型组(如 Claude/GPT) - 不影响同一凭证的其他模型组(如 Gemini) - 额度>1%时先重试同一账号,再次429才切换 - 所有账号耗尽时返回友好提示 - 前端额度卡片直接显示冷却倒计时 - 修复 tools/function_declarations 中 required 引用不存在属性导致的 400 错误 - docker-compose.yml 添加 platform: linux/amd64 适配 macOS --- docker-compose.yml | 5 +- public/admin/panel.css | 91 +++++++ public/admin/panel.js | 218 +++++++++++++++- src/api/client.js | 108 +++++++- src/auth/model_cooldown_manager.js | 396 +++++++++++++++++++++++++++++ src/utils/utils.js | 5 + 6 files changed, 804 insertions(+), 19 deletions(-) create mode 100644 src/auth/model_cooldown_manager.js diff --git a/docker-compose.yml b/docker-compose.yml index acf7e334..f41dc8ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,7 @@ version: '3.8' services: antigravity-api: + platform: linux/amd64 image: ghcr.io/zhongruan0522/antigravity2api-node-js:latest container_name: antigravity-api restart: unless-stopped @@ -11,10 +12,6 @@ services: - PANEL_PASSWORD=设置你的密码 - API_KEY=sk-text - IMAGE_BASE_URL=设置图像访问地址,如果是服务器部署有域名写域名,无域名写IP:8045,本地不写 - # DEBUG 调试日志级别(可选,默认关闭) - # - low: 显示客户端完整请求与响应 - # - high: 额外显示后端 API 请求与响应 - # - DEBUG=high volumes: - ./data:/app/data healthcheck: diff --git a/public/admin/panel.css b/public/admin/panel.css index 16ed4824..0959c110 100644 --- a/public/admin/panel.css +++ b/public/admin/panel.css @@ -807,6 +807,76 @@ button:disabled { border-color: var(--info-bg); } +/* 模型冷却样式 */ +.cooldown-models { + margin-top: 12px; + padding: 10px; + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 8px; +} + +[data-theme="dark"] .cooldown-models { + background: rgba(239, 68, 68, 0.15); + border-color: rgba(239, 68, 68, 0.4); +} + +.cooldown-header { + font-size: 12px; + font-weight: 600; + color: #dc2626; + margin-bottom: 8px; +} + +[data-theme="dark"] .cooldown-header { + color: #fca5a5; +} + +.cooldown-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: rgba(255, 255, 255, 0.5); + border-radius: 6px; + margin-bottom: 6px; + font-size: 12px; +} + +[data-theme="dark"] .cooldown-item { + background: rgba(0, 0, 0, 0.2); +} + +.cooldown-item:last-child { + margin-bottom: 0; +} + +.cooldown-icon { + font-size: 14px; +} + +.cooldown-model { + flex: 1; + font-weight: 500; + color: var(--text); + word-break: break-all; +} + +.cooldown-timer { + font-family: 'SF Mono', Monaco, 'Courier New', monospace; + font-size: 11px; + padding: 2px 8px; + background: #fef2f2; + color: #dc2626; + border-radius: 4px; + font-weight: 600; +} + +[data-theme="dark"] .cooldown-timer { + background: rgba(220, 38, 38, 0.2); + color: #fca5a5; +} + .logs { display: flex; flex-direction: column; @@ -1204,3 +1274,24 @@ button:disabled { gap: 8px; } } + +/* 额度卡片中的冷却倒计时 */ +.quota-cooldown { + margin-left: 8px; + padding: 2px 8px; + background: rgba(239, 68, 68, 0.2); + color: #f87171; + border-radius: 4px; + font-size: 12px; + font-weight: 500; +} + +.quota-cooldown-static { + margin-left: 8px; + padding: 2px 8px; + background: rgba(239, 68, 68, 0.2); + color: #f87171; + border-radius: 4px; + font-size: 12px; + font-weight: 500; +} diff --git a/public/admin/panel.js b/public/admin/panel.js index dd9382a8..d572ec43 100644 --- a/public/admin/panel.js +++ b/public/admin/panel.js @@ -52,6 +52,8 @@ const logDetailCache = new Map(); let logLevelSelect = null; let replaceIndex = null; +let modelCooldownsData = []; // 模型冷却数据 +let cooldownTimerInterval = null; // 倒计时定时器 if (window.AgTheme) { window.AgTheme.initTheme(); @@ -105,6 +107,166 @@ function formatJson(value) { } } +// 格式化剩余时间 +function formatRemainingTime(remainingMs) { + if (remainingMs <= 0) return '已解禁'; + const totalSeconds = Math.floor(remainingMs / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) { + return `${hours}h ${minutes}m ${seconds}s`; + } else if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } + return `${seconds}s`; +} + +// 获取模型冷却数据 +async function fetchModelCooldowns() { + try { + const data = await fetchJson('/auth/model-cooldowns'); + modelCooldownsData = data.cooldowns || []; + return modelCooldownsData; + } catch (e) { + console.warn('获取模型冷却数据失败:', e.message); + return []; + } +} + +// 获取指定账号的冷却模型 +function getCooldownsForProject(projectId) { + if (!projectId || !modelCooldownsData.length) return []; + return modelCooldownsData.filter(c => c.projectId === projectId); +} + +// 模型组定义 +const MODEL_GROUPS = { + 'Claude/GPT': ['claude-sonnet-4-5-thinking', 'claude-opus-4-5-thinking', 'claude-sonnet-4-5', 'gpt-oss-120b-medium'], + 'Tab补全': ['chat_23310', 'chat_20706'], + '香蕉绘图': ['gemini-2.5-flash-image'], + '香蕉Pro': ['gemini-3-pro-image'], + 'Gemini其他': ['gemini-3-pro-high', 'rev19-uic3-1p', 'gemini-2.5-flash', 'gemini-3-pro-low', 'gemini-2.5-flash-thinking', 'gemini-2.5-pro', 'gemini-2.5-flash-lite'] +}; + +// 根据模型名获取所属组 +function getModelGroup(model) { + for (const [group, models] of Object.entries(MODEL_GROUPS)) { + if (models.includes(model)) return group; + } + return null; +} + +// 渲染冷却模型列表(按组显示) +function renderCooldownModels(projectId) { + const cooldowns = getCooldownsForProject(projectId); + if (!cooldowns.length) return ''; + + // 按组聚合 + const groups = {}; + const ungrouped = []; + + for (const c of cooldowns) { + const group = getModelGroup(c.model); + if (group) { + if (!groups[group]) { + groups[group] = { models: [], resetTimestamp: c.resetTimestamp }; + } + groups[group].models.push(c.model); + // 取最晚的重置时间 + if (new Date(c.resetTimestamp) > new Date(groups[group].resetTimestamp)) { + groups[group].resetTimestamp = c.resetTimestamp; + } + } else { + ungrouped.push(c); + } + } + + let items = ''; + + // 渲染分组 + for (const [groupName, data] of Object.entries(groups)) { + const remaining = new Date(data.resetTimestamp).getTime() - Date.now(); + items += ` +
+ 🚫 + ${escapeHtml(groupName)} (${data.models.length}个模型) + ${formatRemainingTime(remaining)} +
+ `; + } + + // 渲染未分组的 + for (const c of ungrouped) { + const remaining = new Date(c.resetTimestamp).getTime() - Date.now(); + items += ` +
+ 🚫 + ${escapeHtml(c.model)} + ${formatRemainingTime(remaining)} +
+ `; + } + + return ` +
+
⏳ 冷却中的模块
+ ${items} +
+ `; +} + +// 更新所有倒计时显示 +function updateCooldownTimers() { + const now = Date.now(); + document.querySelectorAll('.cooldown-item').forEach(item => { + const resetTime = new Date(item.dataset.reset).getTime(); + const remaining = resetTime - now; + const timerEl = item.querySelector('.cooldown-timer'); + if (timerEl) { + if (remaining <= 0) { + item.remove(); + } else { + timerEl.textContent = formatRemainingTime(remaining); + } + } + }); + + // 更新额度卡片中的冷却倒计时 + document.querySelectorAll('.quota-cooldown').forEach(el => { + const resetTime = new Date(el.dataset.reset).getTime(); + const remaining = resetTime - now; + if (remaining <= 0) { + el.remove(); + } else { + el.textContent = `🚫 ${formatRemainingTime(remaining)}`; + } + }); + + // 清理空的冷却容器 + document.querySelectorAll('.cooldown-models').forEach(container => { + if (!container.querySelector('.cooldown-item')) { + container.remove(); + } + }); +} + +// 启动倒计时定时器 +function startCooldownTimer() { + if (cooldownTimerInterval) { + clearInterval(cooldownTimerInterval); + } + cooldownTimerInterval = setInterval(updateCooldownTimers, 1000); +} + +// 停止倒计时定时器 +function stopCooldownTimer() { + if (cooldownTimerInterval) { + clearInterval(cooldownTimerInterval); + cooldownTimerInterval = null; + } +} + function getAccountDisplayName(acc) { if (!acc) return '未知账号'; if (acc.email) return acc.email; @@ -267,6 +429,7 @@ function bindAccountActions() { document.querySelectorAll('[data-action="toggleQuota"]')?.forEach(btn => { btn.addEventListener('click', async () => { const idx = btn.dataset.index; + const projectId = btn.dataset.project; if (idx === undefined) return; const quotaSection = document.getElementById(`quota-${idx}`); @@ -274,12 +437,12 @@ function bindAccountActions() { quotaSection.style.display = 'block'; btn.textContent = '📊 刷新额度'; - await loadQuota(idx, true); + await loadQuota(idx, true, projectId); }); }); } -async function loadQuota(accountIndex, showLoading = false) { +async function loadQuota(accountIndex, showLoading = false, projectId = null) { const quotaSection = document.getElementById(`quota-${accountIndex}`); if (!quotaSection) return; @@ -288,13 +451,13 @@ async function loadQuota(accountIndex, showLoading = false) { quotaSection.innerHTML = '
加载中...
'; } const data = await fetchJson(`/admin/tokens/${accountIndex}/quotas`, { cache: 'no-store' }); - renderQuota(quotaSection, data.data); + renderQuota(quotaSection, data.data, projectId); } catch (e) { quotaSection.innerHTML = `
加载失败: ${e.message}
`; } } -function renderQuota(container, quotaData) { +function renderQuota(container, quotaData, projectId = null) { if (!quotaData || !quotaData.models) { container.innerHTML = '
暂无额度数据
'; return; @@ -401,12 +564,46 @@ function renderQuota(container, quotaData) { const colorClass = remainingPercentage > 50 ? 'quota-high' : remainingPercentage > 20 ? 'quota-medium' : 'quota-low'; + // 检查该模型组是否在冷却中,或者额度为0 + let cooldownHtml = ''; + if (projectId) { + const cooldowns = getCooldownsForProject(projectId); + const groupModels = groupData.modelIds; + const cooldown = cooldowns.find(c => groupModels.includes(c.model)); + + if (cooldown) { + // 有冷却记录,用冷却记录的时间 + const remaining = new Date(cooldown.resetTimestamp).getTime() - Date.now(); + if (remaining > 0) { + cooldownHtml = `🚫 ${formatRemainingTime(remaining)}`; + } + } else if (remainingPercentage === 0 && resetTime && resetTime !== '未知时间') { + // 额度为0但没在冷却列表中,用 API 返回的重置时间计算倒计时 + // resetTime 格式如 "12-29 01:25",需要转换为完整日期 + const now = new Date(); + const [monthDay, time] = resetTime.split(' '); + const [month, day] = monthDay.split('-').map(Number); + const [hour, minute] = time.split(':').map(Number); + const resetDate = new Date(now.getFullYear(), month - 1, day, hour, minute); + // 如果重置时间已过,可能是明年 + if (resetDate < now) resetDate.setFullYear(now.getFullYear() + 1); + const remaining = resetDate.getTime() - now.getTime(); + if (remaining > 0) { + cooldownHtml = `🚫 ${formatRemainingTime(remaining)}`; + } else { + cooldownHtml = `🚫 已耗尽`; + } + } else if (remainingPercentage === 0) { + cooldownHtml = `🚫 已耗尽`; + } + } + html += `
${groupData.icon}
-
${escapeHtml(groupName)}
+
${escapeHtml(groupName)}${cooldownHtml}
(${groupData.modelIds.map(id => escapeHtml(id)).join(', ')})
${escapeHtml(groupData.description)}
@@ -488,10 +685,15 @@ function renderQuota(container, quotaData) { async function refreshAccounts() { try { - const data = await fetchJson('/auth/accounts'); - accountsData = data.accounts || []; + // 同时获取账号和冷却数据 + const [accountData] = await Promise.all([ + fetchJson('/auth/accounts'), + fetchModelCooldowns() + ]); + accountsData = accountData.accounts || []; updateFilteredAccounts(); loadHourlyUsage(); + startCooldownTimer(); // 启动倒计时 } catch (e) { listEl.textContent = '加载失败: ' + e.message; } @@ -551,7 +753,7 @@ function renderAccountsList() {
- +