diff --git a/api-monitor.code-workspace b/api-monitor.code-workspace index ef9f5d2..56de368 100644 --- a/api-monitor.code-workspace +++ b/api-monitor.code-workspace @@ -1,7 +1,11 @@ { - "folders": [ - { - "path": "." - } - ] + "folders": [ + { + "path": "." + }, + { + "path": "../AmGo" + } + ], + "settings": {} } \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index ae50a37..cdc6add 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -98,7 +98,7 @@ module.exports = [ }, rules: { // 基础规则 - // TODO: 后续逐步修复未使用变量后,可将此规则改为 'error' + // TODO: 后续逐步修复未使用变量后,可将 varsIgnorePattern 收窄 'no-unused-vars': [ 'warn', { @@ -121,9 +121,9 @@ module.exports = [ 'eol-last': 'off', // 最佳实践 - eqeqeq: 'off', // 允许 == (项目中很多是有意的) - 'no-var': 'warn', - 'prefer-const': 'off', + eqeqeq: ['error', 'always', { null: 'ignore' }], // 强制 ===,但允许 == null 简写 + 'no-var': 'error', + 'prefer-const': ['warn', { destructuring: 'all' }], 'no-throw-literal': 'warn', 'no-prototype-builtins': 'off', 'no-useless-escape': 'off', // 很多正则有意使用转义 diff --git a/modules/ai-draw-api/service.js b/modules/ai-draw-api/service.js index dc9ab15..e0d8751 100644 --- a/modules/ai-draw-api/service.js +++ b/modules/ai-draw-api/service.js @@ -349,7 +349,7 @@ class AIDrawService { const title = titleMatch ? titleMatch[1].trim() : 'Untitled'; // 移除脚本和样式标签,提取文本内容 - let content = html + const content = html .replace(/]*>[\s\S]*?<\/script>/gi, '') .replace(/]*>[\s\S]*?<\/style>/gi, '') .replace(/<[^>]+>/g, ' ') diff --git a/modules/antigravity-api/antigravity-client.js b/modules/antigravity-api/antigravity-client.js index fbe177c..6f7089b 100644 --- a/modules/antigravity-api/antigravity-client.js +++ b/modules/antigravity-api/antigravity-client.js @@ -655,7 +655,7 @@ function convertOpenAIToAntigravityRequest(openaiRequest, token) { const isGemini3Pro = model.includes('gemini-3-pro'); // 处理 thinking 配置 - let thinkingConfig = generationConfig.thinkingConfig; + const thinkingConfig = generationConfig.thinkingConfig; if (thinkingConfig && !model.startsWith('gemini-3-')) { // 非 Gemini-3 模型:移除 thinkingLevel,设置 thinkingBudget: -1 if (thinkingConfig.thinkingLevel) { diff --git a/modules/antigravity-api/router.js b/modules/antigravity-api/router.js index 7b73bf3..2fb6075 100644 --- a/modules/antigravity-api/router.js +++ b/modules/antigravity-api/router.js @@ -1725,7 +1725,7 @@ router.post('/v1/chat/completions', requireApiAuth, async (req, res) => { res.end(); // 记录成功日志 (包含累计的回复内容和思考过程) - let originalMessages = JSON.parse(JSON.stringify(req.body.messages || [])); + const originalMessages = JSON.parse(JSON.stringify(req.body.messages || [])); // === 日志净化:移除 Base64 图片 === originalMessages.forEach(msg => { @@ -1787,7 +1787,7 @@ router.post('/v1/chat/completions', requireApiAuth, async (req, res) => { // 确保返回结果中的 model 是带前缀的 if (result && result.model) result.model = modelWithPrefix; - let originalMessages = JSON.parse(JSON.stringify(req.body.messages || [])); + const originalMessages = JSON.parse(JSON.stringify(req.body.messages || [])); // === 日志净化:移除 Base64 图片 === originalMessages.forEach(msg => { @@ -1841,7 +1841,7 @@ router.post('/v1/chat/completions', requireApiAuth, async (req, res) => { lastError = error; // 如果是 401 之外的错误(通常是 429 或 5xx),记录日志并继续循环 - let originalMessages = JSON.parse(JSON.stringify(req.body.messages || [])); + const originalMessages = JSON.parse(JSON.stringify(req.body.messages || [])); // === 日志净化:移除 Base64 图片 === originalMessages.forEach(msg => { diff --git a/modules/deepseek-api/deepseek-client.js b/modules/deepseek-api/deepseek-client.js index 6e1cc23..088d610 100644 --- a/modules/deepseek-api/deepseek-client.js +++ b/modules/deepseek-api/deepseek-client.js @@ -21,6 +21,8 @@ const DS_SESSION_URL = 'https://chat.deepseek.com/api/v0/chat_session/create'; const DS_POW_URL = 'https://chat.deepseek.com/api/v0/chat/create_pow_challenge'; const DS_COMPLETION_URL = 'https://chat.deepseek.com/api/v0/chat/completion'; const DS_UPLOAD_URL = 'https://chat.deepseek.com/api/v0/chat/upload_file'; +const DS_DELETE_SESSION_URL = 'https://chat.deepseek.com/api/v0/chat_session/delete'; +const DS_DELETE_ALL_SESSIONS_URL = 'https://chat.deepseek.com/api/v0/chat_session/delete_all'; const BASE_HEADERS = { 'Host': 'chat.deepseek.com', @@ -265,11 +267,34 @@ async function uploadFile(token, sessionId, fileBuffer, fileName) { /** * 检查 token 是否失效 */ -function isTokenInvalid(status, code, msg) { +function isTokenInvalid(status, code, msg, bizCode, bizMsg) { if (status === 401 || status === 403) return true; if (code === 40001 || code === 40002 || code === 40003) return true; - const m = (msg || '').toLowerCase(); - return m.includes('token') || m.includes('unauthorized'); + if (bizCode === 40001 || bizCode === 40002 || bizCode === 40003) return true; + const m = ((msg || '') + ' ' + (bizMsg || '')).toLowerCase(); + return m.includes('token') || m.includes('unauthorized') || + m.includes('expired') || m.includes('not login') || + m.includes('login required') || m.includes('invalid jwt'); +} + +/** + * 判断是否应尝试刷新 Token(更精确的判断) + * 对 HTTP 200 但 biz_code 异常的情况做认证相关性检测 + */ +function shouldAttemptRefresh(status, code, bizCode, msg, bizMsg) { + if (isTokenInvalid(status, code, msg, bizCode, bizMsg)) return true; + // HTTP 200/code=0 但 biz_code 非零时,检查是否是认证相关的失败 + if (status === 200 && code === 0 && bizCode !== 0) { + const combined = ((msg || '') + ' ' + (bizMsg || '')).toLowerCase(); + const authKeywords = [ + 'auth', 'authorization', 'credential', 'expired', + 'invalid jwt', 'jwt', 'login', 'not login', + 'session expired', 'token', 'unauthorized', + '登录', '未登录', '认证', '凭证', '会话过期', '令牌', + ]; + return authKeywords.some(kw => combined.includes(kw)); + } + return false; } // ==================== 账号令牌管理 ==================== @@ -323,34 +348,110 @@ async function refreshToken(accountId) { return token; } +// ==================== 消息格式化 (移植自 ds2api/internal/prompt/messages.go) ==================== + +// Markdown 图片模式 - 移除 ! 前缀防止 DeepSeek 渲染异常 +const MARKDOWN_IMAGE_RE = /!\[([^\]]*)\]\(([^)]+)\)/g; + +/** + * 标准化消息内容,支持字符串、数组和其他格式 + * 移植自 ds2api NormalizeContent() + */ +function normalizeContent(content) { + if (!content) return ''; + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + const parts = []; + for (const item of content) { + if (!item || typeof item !== 'object') continue; + const type = (item.type || '').toLowerCase().trim(); + // 支持 text / output_text / input_text 类型 + if (type === 'text' || type === 'output_text' || type === 'input_text') { + if (item.text) parts.push(item.text); + else if (item.content) parts.push(item.content); + } + } + return parts.join('\n'); + } + return JSON.stringify(content); +} + +/** + * 使用 DeepSeek 特殊标记格式化消息 + * 移植自 ds2api MessagesPrepare() + * + * 核心改进:使用 DeepSeek 原生的特殊 token 标记来构建 prompt + * 这对 R1 深度思考的上下文理解有显著提升 + */ +function messagesPrepare(messages) { + // 1. 预处理:标准化每条消息 + const processed = messages.map(m => ({ + role: m.role || 'user', + text: normalizeContent(m.content), + })); + + if (processed.length === 0) return ''; + + // 2. 合并连续相同角色的消息 + const merged = []; + for (const msg of processed) { + if (merged.length > 0 && merged[merged.length - 1].role === msg.role) { + merged[merged.length - 1].text += '\n\n' + msg.text; + } else { + merged.push({ ...msg }); + } + } + + // 3. 使用 DeepSeek 特殊标记格式化 + const parts = []; + for (let i = 0; i < merged.length; i++) { + const m = merged[i]; + switch (m.role) { + case 'assistant': + parts.push(`<|Assistant|>${m.text}<|end▁of▁sentence|>`); + break; + case 'tool': + if (i > 0) { + parts.push(`<|Tool|>${m.text}`); + } else { + parts.push(m.text); + } + break; + case 'system': + // 清晰的 system 边界能显著改善 R1 和 V3 的上下文理解 + if (m.text.trim()) { + parts.push(`\n${m.text.trim()}\n\n\n`); + } + break; + case 'user': + // 始终为 user 消息添加标记,R1 推理在显式标记用户回合时效果最佳 + parts.push(`<|User|>${m.text}`); + break; + default: + parts.push(m.text); + break; + } + } + + // 4. 移除 Markdown 图片的 ! 前缀 + return parts.join('').replace(MARKDOWN_IMAGE_RE, '[$1]($2)'); +} + /** * 构建 DeepSeek Completion 请求体 + * 使用增强的消息格式化(DeepSeek 特殊标记) */ function buildCompletionPayload(sessionId, messages, model, options = {}) { - const settings = storage.getSettings(); - // 确定是否启用思考模式 const isReasoner = model.includes('reasoner'); const isSearch = model.includes('search'); - // 构建提示词 - const prompt = messages.map(m => { - if (typeof m.content === 'string') return m.content; - if (Array.isArray(m.content)) { - return m.content.map(p => p.text || '').join('\n'); - } - return ''; - }).join('\n\n'); - - // 最后一条用户消息作为 prompt - const lastUserMsg = [...messages].reverse().find(m => m.role === 'user'); - const userPrompt = lastUserMsg - ? (typeof lastUserMsg.content === 'string' ? lastUserMsg.content : JSON.stringify(lastUserMsg.content)) - : ''; + // 使用增强的格式化器构建 prompt + const prompt = messagesPrepare(messages); const payload = { chat_session_id: sessionId, - prompt: userPrompt, + prompt: prompt, ref_file_ids: options.file_ids || [], thinking_enabled: isReasoner, search_enabled: isSearch, @@ -364,6 +465,33 @@ function buildCompletionPayload(sessionId, messages, model, options = {}) { return payload; } +// ==================== 会话清理 (移植自 ds2api/internal/deepseek/client_session_delete.go) ==================== + +/** + * 删除单个 DeepSeek 会话 + */ +async function deleteSession(token, sessionId) { + if (!sessionId) return; + try { + const headers = { ...BASE_HEADERS, authorization: `Bearer ${token}` }; + await postJSON(DS_DELETE_SESSION_URL, headers, { chat_session_id: sessionId }); + } catch (e) { + logger.warn(`删除会话失败: ${e.message}`); + } +} + +/** + * 删除所有 DeepSeek 会话 + */ +async function deleteAllSessions(token) { + try { + const headers = { ...BASE_HEADERS, authorization: `Bearer ${token}` }; + await postJSON(DS_DELETE_ALL_SESSIONS_URL, headers, {}); + } catch (e) { + logger.warn(`删除所有会话失败: ${e.message}`); + } +} + module.exports = { login, createSession, @@ -373,5 +501,8 @@ module.exports = { refreshToken, buildCompletionPayload, uploadFile, + deleteSession, + deleteAllSessions, + normalizeContent, BASE_HEADERS, }; diff --git a/modules/deepseek-api/router.js b/modules/deepseek-api/router.js index 2e6029d..007c000 100644 --- a/modules/deepseek-api/router.js +++ b/modules/deepseek-api/router.js @@ -10,12 +10,156 @@ const router = express.Router(); const storage = require('./storage'); const client = require('./deepseek-client'); const { parseSSEStream, collectStream } = require('./sse-parser'); +const { estimateMessagesTokens, estimateTokens } = require('./tokenizer'); const { createLogger } = require('../../src/utils/logger'); const logger = createLogger('DS-Router'); const { getSession, getSessionById } = require('../../src/services/session'); const fs = require('fs'); const path = require('path'); +// ==================== 常量 (移植自 ds2api) ==================== + +const KEEPALIVE_INTERVAL_MS = 5000; // KeepAlive 心跳间隔 +const STREAM_IDLE_TIMEOUT_MS = 30000; // 流式空闲超时 +const MAX_KEEPALIVE_NO_CONTENT = 10; // 最大无内容心跳次数 + +// ==================== 智能模型解析 (移植自 ds2api/config/models.go) ==================== + +// 内置默认模型别名 - 让客户端用任何主流模型名都能通过 +const DEFAULT_MODEL_ALIASES = { + 'gpt-4o': 'deepseek-chat', + 'gpt-4.1': 'deepseek-chat', + 'gpt-4.1-mini': 'deepseek-chat', + 'gpt-4.1-nano': 'deepseek-chat', + 'gpt-5': 'deepseek-chat', + 'gpt-5-mini': 'deepseek-chat', + 'gpt-5-codex': 'deepseek-reasoner', + 'o1': 'deepseek-reasoner', + 'o1-mini': 'deepseek-reasoner', + 'o3': 'deepseek-reasoner', + 'o3-mini': 'deepseek-reasoner', + 'claude-sonnet-4-5': 'deepseek-chat', + 'claude-haiku-4-5': 'deepseek-chat', + 'claude-opus-4-6': 'deepseek-reasoner', + 'claude-3-5-sonnet': 'deepseek-chat', + 'claude-3-5-haiku': 'deepseek-chat', + 'claude-3-opus': 'deepseek-reasoner', + 'gemini-2.5-pro': 'deepseek-chat', + 'gemini-2.5-flash': 'deepseek-chat', + 'llama-3.1-70b-instruct': 'deepseek-chat', + 'qwen-max': 'deepseek-chat', +}; + +const SUPPORTED_DS_MODELS = new Set([ + 'deepseek-chat', 'deepseek-reasoner', + 'deepseek-chat-search', 'deepseek-reasoner-search', +]); + +const KNOWN_FAMILY_PREFIXES = [ + 'gpt-', 'o1', 'o3', 'claude-', 'gemini-', 'llama-', 'qwen-', 'mistral-', 'command-', +]; + +/** + * 解析模型名称 → 真实 DeepSeek 模型 + * 优先级: 数据库重定向 > 默认别名 > 智能推断 > 原始值 + */ +function resolveModel(requestedModel) { + if (!requestedModel) return { resolved: 'deepseek-chat', original: '' }; + const model = requestedModel.toLowerCase().trim(); + + // 1. 原生支持的 DeepSeek 模型 + if (SUPPORTED_DS_MODELS.has(model)) { + return { resolved: model, original: requestedModel }; + } + + // 2. 数据库存储的重定向 + const redirects = storage.getModelRedirects(); + const redirect = redirects.find(r => r.source_model.toLowerCase() === model); + if (redirect && SUPPORTED_DS_MODELS.has(redirect.target_model.toLowerCase())) { + return { resolved: redirect.target_model.toLowerCase(), original: requestedModel }; + } + + // 3. 默认别名 + if (DEFAULT_MODEL_ALIASES[model]) { + return { resolved: DEFAULT_MODEL_ALIASES[model], original: requestedModel }; + } + + // 4. 智能推断:对已知模型系列进行关键词匹配 + const isKnownFamily = KNOWN_FAMILY_PREFIXES.some(p => model.startsWith(p)); + if (isKnownFamily) { + const useReasoner = model.includes('reason') || model.includes('reasoner') || + model.startsWith('o1') || model.startsWith('o3') || + model.includes('opus') || model.includes('r1'); + const useSearch = model.includes('search'); + + if (useReasoner && useSearch) return { resolved: 'deepseek-reasoner-search', original: requestedModel }; + if (useReasoner) return { resolved: 'deepseek-reasoner', original: requestedModel }; + if (useSearch) return { resolved: 'deepseek-chat-search', original: requestedModel }; + return { resolved: 'deepseek-chat', original: requestedModel }; + } + + // 5. 未知模型:默认 deepseek-chat + return { resolved: 'deepseek-chat', original: requestedModel }; +} + +// ==================== 智能账号选择器 (移植自 ds2api/account/pool) ==================== + +const accountInUse = new Map(); // accountId -> 当前并发数 +let lastSelectedIdx = -1; // 轮转索引 +const MAX_INFLIGHT_PER_ACCOUNT = 2; // 每个账号最大并发 + +/** + * 智能选择可用账号 + * 策略: 有Token优先 → 并发控制 → 轮转使用 + */ +function selectAccount(allAccounts, preferAccountId = null) { + if (allAccounts.length === 0) return null; + + // 如果指定了优先账号且可用 + if (preferAccountId) { + const preferred = allAccounts.find(a => a.id === preferAccountId); + if (preferred && canUseAccount(preferred.id)) { + return preferred; + } + } + + // 分离有Token和无Token的账号 + const withToken = allAccounts.filter(a => a.token && a.token.trim()); + const withoutToken = allAccounts.filter(a => !a.token || !a.token.trim()); + + // 先尝试有Token的,再尝试无Token的 + for (const pool of [withToken, withoutToken]) { + if (pool.length === 0) continue; + // 轮转选择 (round-robin) + for (let i = 0; i < pool.length; i++) { + const idx = (lastSelectedIdx + 1 + i) % pool.length; + const account = pool[idx]; + if (canUseAccount(account.id)) { + lastSelectedIdx = idx; + accountInUse.set(account.id, (accountInUse.get(account.id) || 0) + 1); + return account; + } + } + } + + // 所有账号都满负载,不再强行回退(避免挤占官方单账号并发导致静默失败) + return null; +} + +function canUseAccount(accountId) { + return (accountInUse.get(accountId) || 0) < MAX_INFLIGHT_PER_ACCOUNT; +} + +function releaseAccount(accountId) { + if (!accountId) return; + const count = accountInUse.get(accountId) || 0; + if (count <= 1) { + accountInUse.delete(accountId); + } else { + accountInUse.set(accountId, count - 1); + } +} + // ==================== Session 缓存 (用于维系上下文对话) ==================== // 外部客户端可能将 reasoning_content 与 content 合并发回 // 因此使用子串包含匹配,同时持久化到数据库以支持重启后续接 @@ -484,39 +628,75 @@ router.post(['/v1/chat/completions', '/chat/completions'], requireApiKey, async try { let { model, messages, stream, file_ids } = req.body; - // 处理模型重定向 - const redirects = storage.getModelRedirects(); - const redirect = redirects.find(r => r.source_model === model); - if (redirect) model = redirect.target_model; + // 智能模型解析 (移植自 ds2api) + const { resolved: resolvedModel, original: originalModel } = resolveModel(model); + model = resolvedModel; + if (originalModel !== resolvedModel) { + logger.info(`[模型映射] ${originalModel} → ${resolvedModel}`); + } // 如果 file_ids 是数组但为空,忽略它;如果是字符串,转为数组 if (typeof file_ids === 'string') file_ids = [file_ids]; const hasFiles = Array.isArray(file_ids) && file_ids.length > 0; - // 获取可用账号 - let account; + // 1. 获取可用账号列表 const allAccounts = storage.getAccounts().filter(a => a.enable !== 0); if (allAccounts.length === 0) { return res.status(503).json({ error: { message: 'No enabled accounts available', type: 'service_unavailable' } }); } - // 如果有文件,强制选择拥有该文件的账号 + // 2. 智能选择账号 (移植自 ds2api account pool) + let account; + // 如果有文件,优先选择拥有该文件的账号 if (hasFiles) { const fileCache = storage.getFileCache(file_ids[0]); if (fileCache) { - account = allAccounts.find(a => a.id === fileCache.account_id); - if (!account) { - return res.status(400).json({ error: { message: `Account for file ${file_ids[0]} not found or disabled`, type: 'invalid_request_error' } }); - } - logger.info(`[文件对话] 强制使用账号: ${account.name || account.id} (文件 ID: ${file_ids[0]})`); + account = selectAccount(allAccounts, fileCache.account_id); } } - - // 如果没选定账号(无文件或文件缓存未命中),则随机选 + // 智能轮转选择 + if (!account) { + account = selectAccount(allAccounts); + } if (!account) { - account = allAccounts[Math.floor(Math.random() * allAccounts.length)]; + return res.status(503).json({ error: { message: 'All accounts are busy', type: 'service_unavailable' } }); } + // 3. 自动视觉:解析并上传 Base64 图片 + const uploadedFileIds = []; + for (const msg of messages) { + if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === 'image_url' && part.image_url?.url?.startsWith('data:')) { + try { + const base64Data = part.image_url.url.split(',')[1]; + const buffer = Buffer.from(base64Data, 'base64'); + const mimeMatch = part.image_url.url.match(/^data:(image\/[a-z]+);base64,/); + const ext = mimeMatch ? mimeMatch[1].split('/')[1] : 'png'; + const fileName = `vision_${Date.now()}.${ext}`; + + const token = await client.getAccessToken(account.id); + // 视觉上传需要一个临时会话,如果此时还没有上下文,先创建一个 + const uploadSessionId = await client.createSession(token); + const fileId = await client.uploadFile(token, uploadSessionId, buffer, fileName); + uploadedFileIds.push(fileId); + logger.info(`[自动视觉] 账号 ${account.name} 上传成功: ${fileId}`); + // 记录缓存 + storage.saveFileCache(fileId, account.id, uploadSessionId, fileName, buffer.length); + } catch (uploadErr) { + logger.warn(`[自动视觉] 上传失败: ${uploadErr.message}`); + } + } + } + } + } + if (uploadedFileIds.length > 0) { + file_ids = [...(file_ids || []), ...uploadedFileIds]; + } + + // 计算预估 Prompt Token + const promptTokens = estimateMessagesTokens(messages); + try { // 1. 获取 Token let token; @@ -601,12 +781,49 @@ router.post(['/v1/chat/completions', '/chat/completions'], requireApiKey, async let fullReasoning = ''; let firstTokenTime = null; let responseMessageId = null; + let hasReceivedContent = false; + let lastContentTime = Date.now(); + let keepaliveCount = 0; + + // 流式超时保护 (移植自 ds2api stream engine) + const keepaliveTimer = setInterval(() => { + if (!hasReceivedContent) { + keepaliveCount++; + if (keepaliveCount >= MAX_KEEPALIVE_NO_CONTENT) { + logger.warn(`[流式超时] 连续 ${MAX_KEEPALIVE_NO_CONTENT} 次心跳无内容,终止流`); + clearInterval(keepaliveTimer); + if (!res.writableEnded) { + res.write(`: keepalive timeout\n\n`); + res.end(); + } + releaseAccount(account.id); + return; + } + } + if (hasReceivedContent && (Date.now() - lastContentTime) > STREAM_IDLE_TIMEOUT_MS) { + logger.warn(`[流式超时] 空闲超过 ${STREAM_IDLE_TIMEOUT_MS}ms,终止流`); + clearInterval(keepaliveTimer); + if (!res.writableEnded) { + res.end(); + } + releaseAccount(account.id); + return; + } + // 发送 SSE 注释保持连接 + if (!res.writableEnded) { + res.write(`: keepalive\n\n`); + if (res.flush) res.flush(); + } + }, KEEPALIVE_INTERVAL_MS); parseSSEStream( dsResponse, isReasoner, (type, text) => { if (firstTokenTime === null) firstTokenTime = Date.now() - startTime; + hasReceivedContent = true; + lastContentTime = Date.now(); + keepaliveCount = 0; if (type === 'thinking') { fullReasoning += text; @@ -626,10 +843,17 @@ router.post(['/v1/chat/completions', '/chat/completions'], requireApiKey, async if (res.flush) res.flush(); }, () => { + clearInterval(keepaliveTimer); + // 发送结束标记 const doneChunk = { id: completionId, object: 'chat.completion.chunk', created, model, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + usage: { + prompt_tokens: promptTokens, + completion_tokens: estimateTokens(fullContent), + total_tokens: promptTokens + estimateTokens(fullContent) + } }; res.write(`data: ${JSON.stringify(doneChunk)}\n\n`); res.write('data: [DONE]\n\n'); @@ -638,6 +862,17 @@ router.post(['/v1/chat/completions', '/chat/completions'], requireApiKey, async // 记录会话继承 (保存 message_id 作为下次的 parent_id) saveToSessionCache(fullContent, sessionId, responseMessageId); + // 会话自动清理 (移植自 ds2api) + const autoDelete = storage.getSetting('AUTO_DELETE_SESSIONS'); + if (autoDelete === '1' || autoDelete === 'true') { + client.deleteAllSessions(token).catch(e => + logger.warn(`[自动清理] 删除会话失败: ${e.message}`) + ); + } + + // 释放账号 + releaseAccount(account.id); + // 记录日志 storage.recordLog({ accountId: account.id, model, is_balanced: req.lb, @@ -652,7 +887,9 @@ router.post(['/v1/chat/completions', '/chat/completions'], requireApiKey, async }); }, (err) => { + clearInterval(keepaliveTimer); logger.error(`Stream error: ${err.message}`); + releaseAccount(account.id); if (!res.headersSent) { res.status(500).json({ error: { message: err.message } }); } else { @@ -675,11 +912,26 @@ router.post(['/v1/chat/completions', '/chat/completions'], requireApiKey, async message: { role: 'assistant', content: result.content, reasoning_content: result.thinking }, finish_reason: 'stop', }], - usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + usage: { + prompt_tokens: promptTokens, + completion_tokens: estimateTokens(result.content), + total_tokens: promptTokens + estimateTokens(result.content) + }, }; // 记录会话继承 (保存 message_id 作为下次的 parent_id) saveToSessionCache(result.content, sessionId, result.message_id); + // 会话自动清理 (移植自 ds2api) + const autoDelete = storage.getSetting('AUTO_DELETE_SESSIONS'); + if (autoDelete === '1' || autoDelete === 'true') { + client.deleteAllSessions(token).catch(e => + logger.warn(`[自动清理] 删除会话失败: ${e.message}`) + ); + } + + // 释放账号 + releaseAccount(account.id); + // 记录日志 storage.recordLog({ accountId: account.id, model, is_balanced: req.lb, @@ -694,6 +946,9 @@ router.post(['/v1/chat/completions', '/chat/completions'], requireApiKey, async } catch (error) { logger.error(`[DS] Account ${account.name} failed: ${error.message}`); + // 释放账号 + releaseAccount(account.id); + // 记录错误日志 storage.recordLog({ accountId: account.id, model, is_balanced: req.lb, diff --git a/modules/deepseek-api/sse-parser.js b/modules/deepseek-api/sse-parser.js index 5edca74..0af6cdc 100644 --- a/modules/deepseek-api/sse-parser.js +++ b/modules/deepseek-api/sse-parser.js @@ -21,6 +21,7 @@ const logger = createLogger('DS-SSE'); const SKIP_PATHS = new Set([ 'quasi_status', 'response/status', + 'response/search_status', ]); const SKIP_CONTAINS = [ @@ -28,8 +29,31 @@ const SKIP_CONTAINS = [ 'elapsed_secs', 'pending_fragment', 'conversation_mode', + 'fragments/-1/status', + 'fragments/-2/status', + 'fragments/-3/status', ]; +// Unicode 上标数字映射 +const SUPERSCRIPT_DIGITS = { '0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴', '5': '⁵', '6': '⁶', '7': '⁷', '8': '⁸', '9': '⁹' }; + +/** + * 将 [citation:N] 转换为带超链接的 Unicode 上标 + * 有 URL 时: [citation:8] → [⁽⁸⁾](url) + * 无 URL 时: [citation:8] → ⁽⁸⁾ + */ +function formatCitations(text, searchResults) { + return text.replace(/\[citation:(\d+)\]/g, (_, num) => { + const idx = parseInt(num, 10); + const superNum = num.split('').map(d => SUPERSCRIPT_DIGITS[d] || d).join(''); + const sup = `⁽${superNum}⁾`; + // 在已收集的搜索结果中查找对应来源 URL + const ref = searchResults.find(r => (r.cite_index || r.index) === idx); + const url = ref && (ref.url || ref.link || ref.href); + return url ? `[${sup}](${url})` : sup; + }); +} + /** * 解析 DeepSeek SSE 流 */ @@ -37,10 +61,16 @@ function parseSSEStream(response, isReasoner, onData, onEnd, onError, onMeta) { let buffer = ''; response.setEncoding('utf8'); + // 包装 onData:自动转换 [citation:N] → [⁽ᴺ⁾](url) + const originalOnData = onData; + onData = (type, text) => { + originalOnData(type, formatCitations(text, searchResults)); + }; + // 深度思考模式下从 thinking 开始,否则从 content 开始 let currentType = isReasoner ? 'thinking' : 'content'; let currentEvent = ''; - let searchResults = []; + const searchResults = []; response.on('data', (chunk) => { buffer += chunk; @@ -62,9 +92,7 @@ function parseSSEStream(response, isReasoner, onData, onEnd, onError, onMeta) { if (!trimmed.startsWith('data: ')) continue; const dataStr = trimmed.slice(6).trim(); - try { - require('fs').appendFileSync('ds_raw_log.txt', dataStr + '\n'); - } catch (e) {} + if (!dataStr || dataStr === '[DONE]' || dataStr === '{}') continue; @@ -76,30 +104,45 @@ function parseSSEStream(response, isReasoner, onData, onEnd, onError, onMeta) { const data = JSON.parse(dataStr); let handled = false; - // --- 1. 初始 response 对象 --- - if (data.v && typeof data.v === 'object' && data.v.response) { - const resp = data.v.response; - if (onMeta && resp.message_id) { - onMeta({ message_id: resp.message_id }); + // --- 1. 初始 response 对象与业务错误检测 --- + if (data.v && typeof data.v === 'object') { + const bizCode = data.v.code || data.v.response?.code; + const bizMsg = data.v.msg || data.v.response?.msg; + + if (bizCode !== undefined && bizCode !== 0 && bizCode !== '0') { + logger.warn(`[DeepSeek 业务错误] 代码: ${bizCode}, 信息: ${bizMsg}`); + onError(new Error(bizMsg || `DeepSeek Error (${bizCode})`)); + return; } - if (resp.fragments && Array.isArray(resp.fragments)) { - for (const frag of resp.fragments) { - const fType = frag.type; - const fContent = frag.content || frag.v || ""; - if (fContent) { - if (fType === 'THINK') { - onData('thinking', fContent); - handled = true; - } else if (['RESPONSE', 'CONTENT', 'TEXT', 'ANSWER'].includes(fType)) { - onData('content', fContent); - handled = true; - } else if (fType === 'SEARCH' && Array.isArray(frag.results)) { - frag.results.forEach(r => searchResults.push(r)); + + if (data.v.response) { + const resp = data.v.response; + if (onMeta && resp.message_id) { + onMeta({ message_id: resp.message_id }); + } + if (resp.fragments && Array.isArray(resp.fragments)) { + for (const frag of resp.fragments) { + const fType = frag.type; + const fContent = frag.content || frag.v || ''; + if (fContent) { + if (fType === 'THINK') { + onData('thinking', fContent); + handled = true; + } else if (['RESPONSE', 'CONTENT', 'TEXT', 'ANSWER'].includes(fType)) { + onData('content', fContent); + handled = true; + } else if (fType === 'SEARCH') { + const results = frag.results || frag.search_results || frag.references || []; + if (Array.isArray(results)) { + logger.debug(`[搜索] 初始 SEARCH fragment 收集到 ${results.length} 条结果`); + results.forEach(r => searchResults.push(r)); + } + } } } } + if (handled) continue; } - if (handled) continue; } // --- 2. 识别路径并动态确定类型 --- @@ -116,8 +159,16 @@ function parseSSEStream(response, isReasoner, onData, onEnd, onError, onMeta) { if (handled) continue; } - // 搜索结果 - if (data.p.match(/response\/fragments\/\d+\/results/) && Array.isArray(data.v)) { + // 搜索结果 (覆盖各种可能的路径) + if (data.p.match(/response\/fragments\/(-?\d+)\/(results|search_results|references)/) && Array.isArray(data.v)) { + logger.debug(`[搜索] 增量路径 ${data.p} 收集到 ${data.v.length} 条结果`); + data.v.forEach(item => searchResults.push(item)); + continue; + } + + // 搜索结果也可能出现在 response/search_results 等路径 + if ((data.p === 'response/search_results' || data.p === 'search_results') && Array.isArray(data.v)) { + logger.debug(`[搜索] 顶层路径 ${data.p} 收集到 ${data.v.length} 条结果`); data.v.forEach(item => searchResults.push(item)); continue; } @@ -127,7 +178,7 @@ function parseSSEStream(response, isReasoner, onData, onEnd, onError, onMeta) { if (data.p === 'response/fragments' && (data.o === 'APPEND' || data.o === 'SET') && Array.isArray(data.v)) { for (const frag of data.v) { const fType = frag.type; - const fContent = frag.content || frag.v || ""; + const fContent = frag.content || frag.v || ''; if (fType === 'RESPONSE' || fType === 'CONTENT' || fType === 'TEXT') { currentType = 'content'; if (fContent) onData('content', fContent); @@ -158,7 +209,7 @@ function parseSSEStream(response, isReasoner, onData, onEnd, onError, onMeta) { } } - if (!handled && typeof data.v === 'string' && data.v.length > 0 && !shouldSkip(data.p || "")) { + if (!handled && typeof data.v === 'string' && data.v.length > 0 && !shouldSkip(data.p || '')) { // 最后的兜底:如果没处理但看起来像文本,且不在跳过列表中,也尝试收集 // logger.debug(`[Low-Confidence] Collected suspect data: path=${data.p}, val=${data.v}`); onData(currentType, data.v); @@ -185,15 +236,18 @@ function parseSSEStream(response, isReasoner, onData, onEnd, onError, onMeta) { } } + logger.debug(`[搜索] 流结束, 共收集 ${searchResults.length} 条搜索结果`); if (searchResults.length > 0) { + logger.info(`[搜索引用] 共 ${searchResults.length} 条来源, 样本: ${JSON.stringify(searchResults[0])}`); let refText = '\n\n---\n**参考资料:**\n'; const seen = new Set(); - searchResults.slice().sort((a, b) => (a.cite_index || 0) - (b.cite_index || 0)).forEach(item => { - const idx = item.cite_index || 0; - if (!seen.has(idx)) { + searchResults.slice().sort((a, b) => (a.cite_index || a.index || 0) - (b.cite_index || b.index || 0)).forEach(item => { + const idx = item.cite_index || item.index || 0; + const url = item.url || item.link || item.href || ''; + if (!seen.has(idx) && url) { seen.add(idx); - const title = item.title || item.site_name || item.url; - refText += `[${idx}] [${title}](${item.url})\n`; + const title = item.title || item.site_name || item.name || url; + refText += `[${idx}] [${title}](${url})\n`; } }); onData('content', refText); diff --git a/modules/deepseek-api/test-stability.js b/modules/deepseek-api/test-stability.js new file mode 100644 index 0000000..36379af --- /dev/null +++ b/modules/deepseek-api/test-stability.js @@ -0,0 +1,137 @@ +/** + * DeepSeek 模型稳定性测试脚本 + * 测试所有 4 个模型 + 别名映射 + */ + +const http = require('http'); + +const BASE_URL = 'http://localhost:5173'; +const API_KEY = 'ssln5014.'; + +function request(model, message, stream = false) { + return new Promise((resolve, reject) => { + const body = JSON.stringify({ + model, + messages: [{ role: 'user', content: message }], + stream, + }); + + const options = { + hostname: 'localhost', + port: 5173, + path: '/v1/chat/completions', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${API_KEY}`, + 'Content-Length': Buffer.byteLength(body), + }, + }; + + const startTime = Date.now(); + const req = http.request(options, (res) => { + let data = ''; + res.setEncoding('utf8'); + res.on('data', chunk => { data += chunk; }); + res.on('end', () => { + const elapsed = Date.now() - startTime; + resolve({ status: res.statusCode, data, elapsed, headers: res.headers }); + }); + }); + + req.on('error', (e) => reject(e)); + req.setTimeout(120000, () => { + req.destroy(new Error('timeout')); + }); + req.write(body); + req.end(); + }); +} + +async function testModel(model, label, stream = false) { + const prefix = stream ? '[流式]' : '[非流式]'; + console.log(`\n${'='.repeat(60)}`); + console.log(`${prefix} 测试模型: ${model} (${label})`); + console.log('='.repeat(60)); + + try { + const prompt = stream + ? '用一句话介绍你自己' + : '回复:TEST_OK'; + + const result = await request(model, prompt, stream); + + if (result.status === 200) { + if (stream) { + // 解析 SSE 数据 + const lines = result.data.split('\n').filter(l => l.startsWith('data: ')); + let content = ''; + let reasoning = ''; + let hasUsage = false; + for (const line of lines) { + const dataStr = line.slice(6).trim(); + if (dataStr === '[DONE]') continue; + try { + const chunk = JSON.parse(dataStr); + const delta = chunk.choices?.[0]?.delta || {}; + if (delta.content) content += delta.content; + if (delta.reasoning_content) reasoning += delta.reasoning_content; + if (chunk.usage) hasUsage = true; + } catch (_) { } + } + console.log(`✅ 成功 | ${result.elapsed}ms`); + if (reasoning) console.log(`💭 推理: ${reasoning.slice(0, 100)}...`); + console.log(`📝 回复: ${content.slice(0, 200)}`); + console.log(`📊 SSE chunks: ${lines.length}, usage: ${hasUsage ? '✅' : '❌'}`); + } else { + const json = JSON.parse(result.data); + const msg = json.choices?.[0]?.message || {}; + console.log(`✅ 成功 | ${result.elapsed}ms`); + if (msg.reasoning_content) { + console.log(`💭 推理: ${msg.reasoning_content.slice(0, 100)}...`); + } + console.log(`📝 回复: ${(msg.content || '').slice(0, 200)}`); + console.log(`📊 用量: prompt=${json.usage?.prompt_tokens}, completion=${json.usage?.completion_tokens}, total=${json.usage?.total_tokens}`); + } + } else { + console.log(`❌ 失败 | HTTP ${result.status} | ${result.elapsed}ms`); + console.log(` 错误: ${result.data.slice(0, 300)}`); + } + } catch (e) { + console.log(`❌ 异常: ${e.message}`); + } +} + +async function main() { + console.log('🚀 DeepSeek 模型稳定性测试'); + console.log(`🔗 端点: ${BASE_URL}/v1/chat/completions`); + console.log(`🔑 API Key: ${API_KEY.slice(0, 4)}****`); + console.log(`⏰ 时间: ${new Date().toLocaleString()}`); + + // 1. 基础模型 - 非流式 + await testModel('deepseek-chat', '基础对话', false); + + // 2. 基础模型 - 流式 + await testModel('deepseek-chat', '基础对话-流式', true); + + // 3. 推理模型 - 流式 + await testModel('deepseek-reasoner', '深度思考', true); + + // 4. 搜索模型 - 流式 + await testModel('deepseek-chat-search', '联网搜索', true); + + // 5. 模型别名测试 — gpt-4o → deepseek-chat + await testModel('gpt-4o', '别名映射 gpt-4o→chat', true); + + // 6. 模型别名测试 — o3 → deepseek-reasoner + await testModel('o3', '别名映射 o3→reasoner', true); + + console.log('\n' + '='.repeat(60)); + console.log('🏁 测试完成'); + console.log('='.repeat(60)); +} + +main().catch(e => { + console.error('测试脚本异常:', e); + process.exit(1); +}); diff --git a/modules/deepseek-api/tokenizer.js b/modules/deepseek-api/tokenizer.js new file mode 100644 index 0000000..b669b69 --- /dev/null +++ b/modules/deepseek-api/tokenizer.js @@ -0,0 +1,74 @@ +/** + * 简易 Token 估算工具 + * 用于在网页反代中模拟 OpenAI 的 usage 统计 + */ + +/** + * 估算文本的 Token 数量 + * 规则: + * 1. 中文字符(包括标点)约为 2 tokens + * 2. 英文单词(按空格切分)约为 1.3 tokens + * 3. 其他非空白字符约为 1 token + */ +function estimateTokens(text) { + if (!text || typeof text !== 'string') return 0; + + let count = 0; + + // 1. 处理中文、日文、韩文(CJK)字符 + const cjkMatches = text.match(/[\u4e00-\u9fa5\u3040-\u30ff\uac00-\ud7af\uff01-\uffee]/g); + if (cjkMatches) { + count += cjkMatches.length * 2; + } + + // 2. 移除 CJK 字符后处理英文/数字 + const remaining = text.replace(/[\u4e00-\u9fa5\u3040-\u30ff\uac00-\ud7af\uff01-\uffee]/g, ' '); + const words = remaining.trim().split(/\s+/); + + for (const word of words) { + if (word.length === 0) continue; + // 简单模拟:长度超过 4 的单词按 [长/3] 计,短单词计 1 + if (word.length > 4) { + count += Math.ceil(word.length / 3) * 1.3; + } else { + count += 1.3; + } + } + + return Math.ceil(count); +} + +/** + * 估算消息列表的总 Token 数 + */ +function estimateMessagesTokens(messages) { + if (!Array.isArray(messages)) return 0; + + let total = 0; + for (const msg of messages) { + // 角色名开销 + total += 4; + + if (typeof msg.content === 'string') { + total += estimateTokens(msg.content); + } else if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === 'text') { + total += estimateTokens(part.text); + } else if (part.type === 'image_url') { + // 图片在 OpenAI 中通常固定计费(如 85-1105 tokens) + // 网页版作为附件,这里统一模拟计为 500 + total += 500; + } + } + } + } + // API 响应的基本开销 + total += 3; + return total; +} + +module.exports = { + estimateTokens, + estimateMessagesTokens +}; diff --git a/modules/gemini-cli-api/gemini-client.js b/modules/gemini-cli-api/gemini-client.js index a05eaed..2259292 100644 --- a/modules/gemini-cli-api/gemini-client.js +++ b/modules/gemini-cli-api/gemini-client.js @@ -551,7 +551,7 @@ class GeminiCliClient { : `${endpoint}:${action}`; // 修正模型名称 (Gemini 3 不需要改名,但需要 Thinking Config) - let apiModel = baseModel; + const apiModel = baseModel; const requestBody = { @@ -618,7 +618,7 @@ class GeminiCliClient { if (streamStatus !== 200) { // Wait for full error text const errorText = await new Promise((resolve) => { - let text = ''; + const text = ''; // We need to capture chunks from passThrough or s? // s is already piping to passThrough. We can't double read s. // But passThrough is readable. @@ -644,7 +644,7 @@ class GeminiCliClient { } else { // --- Non-Stream Request (or Forced Stream) --- if (shouldUseStreamEndpoint) { - let chunks = []; + const chunks = []; let finished = false; let streamError = null; let streamStatus = 200; diff --git a/modules/gemini-cli-api/router.js b/modules/gemini-cli-api/router.js index 7e06193..65ba95b 100644 --- a/modules/gemini-cli-api/router.js +++ b/modules/gemini-cli-api/router.js @@ -71,7 +71,38 @@ const autoCheckService = { return; } - // 获取要检测的模型列表(复用现有逻辑) + // 1. 自动全量更新模型矩阵 (每 12 小时执行一次) + const settings = storage.getSettings(); + const lastSyncTime = parseInt(settings.lastAutoSyncTime) || 0; + const nowMs = Date.now(); + const twelveHoursMs = 12 * 3600 * 1000; + + if (nowMs - lastSyncTime > twelveHoursMs) { + logger.info('[GCLI AutoCheck] 达到同步周期,开始自动全量更新模型矩阵...'); + try { + // 仅拉取启用账号的额度信息进行聚合 + const enabledAccounts = accounts.filter(a => a.enable !== 0); + const results = await Promise.all( + enabledAccounts.map(async (account) => { + try { + return await getAccountQuota(account, true); // 强制刷新 + } catch (e) { + return null; + } + }) + ); + const allUpstreamModels = aggregateUpstreamModels(results); + if (allUpstreamModels.length > 0) { + syncModelsToMatrix(allUpstreamModels, true); // 全量同步并自动清理 + storage.updateSetting('lastAutoSyncTime', nowMs.toString()); + logger.info(`[GCLI AutoCheck] 自动清理完成,当前上游可用模型数: ${allUpstreamModels.length}`); + } + } catch (syncErr) { + logger.error(`[GCLI AutoCheck] 自动同步矩阵失败: ${syncErr.message}`); + } + } + + // 2. 获取要检测的模型列表 (原有常规逻辑) const set = new Set(); const redirects = storage.getModelRedirects(); if (Array.isArray(redirects)) { @@ -87,7 +118,6 @@ const autoCheckService = { let modelsToCheck = Array.from(set); // 应用禁用模型过滤 - const settings = storage.getSettings(); if (settings.disabledCheckModels) { try { const disabledModels = JSON.parse(settings.disabledCheckModels); @@ -99,8 +129,8 @@ const autoCheckService = { if (modelsToCheck.length === 0) { modelsToCheck = [ - 'gemini-2.5-pro', - 'gemini-2.5-flash', + 'gemini-2.1-pro', + 'gemini-2.0-flash-exp', 'gemini-1.5-pro', 'gemini-1.5-flash', ]; @@ -292,6 +322,14 @@ const DEFAULT_MATRIX = { fakeStream: false, antiTrunc: false, }, + 'gemini-3.1-flash-lite-preview': { + base: true, + maxThinking: false, + noThinking: false, + search: false, + fakeStream: false, + antiTrunc: false, + }, }; // 辅助函数:读取矩阵配置 @@ -317,6 +355,64 @@ function saveMatrixConfig(config) { } } +// 辅助函数:聚合上游获取到的模型 ID +function aggregateUpstreamModels(results) { + const modelIds = new Set(); + if (!Array.isArray(results)) return []; + + results.forEach(result => { + if (result && result.buckets) { + result.buckets.forEach(bucket => { + if (bucket && bucket.modelId) { + modelIds.add(bucket.modelId); + } + }); + } + }); + + return Array.from(modelIds); +} + +// 辅助函数:将模型同步到矩阵 (支持全量同步/自动清理) +function syncModelsToMatrix(upstreamModelIds, isFullSync = false) { + if (!upstreamModelIds || upstreamModelIds.length === 0) return; + const matrixConfig = getMatrixConfig(); + let matrixUpdated = false; + + // 1. 添加并更新上游存在的模型 + upstreamModelIds.forEach(modelId => { + // 忽略带特殊后缀的变体模型 + if (modelId && !modelId.includes('/') && !modelId.includes('-search') && !modelId.includes('-thinking') && !matrixConfig[modelId]) { + matrixConfig[modelId] = { + base: true, + maxThinking: false, + noThinking: false, + search: false, + fakeStream: false, + antiTrunc: false, + }; + matrixUpdated = true; + logger.info(`[GCLI] Auto-added new model from quota to matrix: ${modelId}`); + } + }); + + // 2. 全量同步模式下:自动清理上游已不存在的模型 + if (isFullSync) { + Object.keys(matrixConfig).forEach(modelId => { + // 如果模型不在上游列表,则执行清理 (移除 DEFAULT_MATRIX 强制保留限制) + if (!upstreamModelIds.includes(modelId)) { + delete matrixConfig[modelId]; + matrixUpdated = true; + logger.info(`[GCLI] Auto-removed stale model from matrix: ${modelId}`); + } + }); + } + + if (matrixUpdated) { + saveMatrixConfig(matrixConfig); + } +} + /** * 获取模型矩阵配置 (内部 API) */ @@ -594,6 +690,16 @@ router.get('/quotas', async (req, res) => { // 使用 client 获取模型列表和额度 const quotas = await client.getQuotas(account); + + // 自动将新获取的模型加入系统矩阵配置 (只增不减) + try { + if (quotas) { + syncModelsToMatrix(Object.keys(quotas), false); + } + } catch (e) { + logger.error(`[quotas] Auto add models error: ${e.message}`); + } + res.json(quotas); } catch (e) { console.error('获取额度失败:', e); @@ -664,6 +770,16 @@ router.get('/quotas/all', async (req, res) => { }) ); + // 自动将新获取的模型加入系统矩阵配置 (全量同步/自动清理) + try { + const modelIds = aggregateUpstreamModels(results); + if (modelIds.length > 0) { + syncModelsToMatrix(modelIds, true); + } + } catch (e) { + logger.error(`[quotas/all] Auto add models error: ${e.message}`); + } + res.json(results.filter(Boolean)); } catch (e) { console.error('[GCLI] Quota fetch error:', e); @@ -1540,7 +1656,7 @@ router.post(['/v1/chat/completions', '/chat/completions'], requireApiKey, async res.end(); // 记录成功日志(包含累积的回复内容) - let originalMessages = JSON.parse(JSON.stringify(req.body.messages || [])); + const originalMessages = JSON.parse(JSON.stringify(req.body.messages || [])); // === 日志净化:移除 Base64 图片 === originalMessages.forEach(msg => { @@ -1647,7 +1763,7 @@ router.post(['/v1/chat/completions', '/chat/completions'], requireApiKey, async }; // 记录成功日志 - let originalMessages = JSON.parse(JSON.stringify(req.body.messages || [])); + const originalMessages = JSON.parse(JSON.stringify(req.body.messages || [])); // === 日志净化:移除 Base64 图片 === originalMessages.forEach(msg => { @@ -1730,7 +1846,7 @@ router.post(['/v1/chat/completions', '/chat/completions'], requireApiKey, async } // 记录错误日志 - let originalMessages = JSON.parse(JSON.stringify(req.body.messages || [])); + const originalMessages = JSON.parse(JSON.stringify(req.body.messages || [])); // === 日志净化:移除 Base64 图片 === originalMessages.forEach(msg => { diff --git a/modules/notification-api/migrate.js b/modules/notification-api/migrate.js index 3f1b73e..25c0b83 100644 --- a/modules/notification-api/migrate.js +++ b/modules/notification-api/migrate.js @@ -16,7 +16,7 @@ function migrate(db) { { table: 'alert_rules', column: 'backup_channels', sql: "ALTER TABLE alert_rules ADD COLUMN backup_channels TEXT DEFAULT '[]'" }, // alert_state_tracking 缺失列 { table: 'alert_state_tracking', column: 'state_history', sql: "ALTER TABLE alert_state_tracking ADD COLUMN state_history TEXT DEFAULT '[]'" }, - { table: 'alert_state_tracking', column: 'is_flapping', sql: "ALTER TABLE alert_state_tracking ADD COLUMN is_flapping INTEGER DEFAULT 0" }, + { table: 'alert_state_tracking', column: 'is_flapping', sql: 'ALTER TABLE alert_state_tracking ADD COLUMN is_flapping INTEGER DEFAULT 0' }, ]; for (const m of migrations) { diff --git a/modules/server-api/agent-service.js b/modules/server-api/agent-service.js index dd7c666..0c8f12d 100644 --- a/modules/server-api/agent-service.js +++ b/modules/server-api/agent-service.js @@ -80,7 +80,7 @@ class AgentService extends EventEmitter { loadOrGenerateGlobalKey() { try { const { SystemConfig } = require('../../src/db/models'); - let savedKey = SystemConfig.getConfigValue('agent_global_key'); + const savedKey = SystemConfig.getConfigValue('agent_global_key'); if (savedKey) { this.globalAgentKey = savedKey; diff --git a/modules/tencent-api/tencent-api.js b/modules/tencent-api/tencent-api.js index c79b50b..2acbd46 100644 --- a/modules/tencent-api/tencent-api.js +++ b/modules/tencent-api/tencent-api.js @@ -54,7 +54,7 @@ function createClient(ClientClass, auth, region = 'ap-guangzhou') { region: region, profile: { httpProfile: { - endpoint: "", + endpoint: '', }, }, }; @@ -135,7 +135,7 @@ async function addDomainRecord(auth, domain, record) { Value: record.value, TTL: record.ttl || 600, MX: record.mx, - Status: "ENABLE" + Status: 'ENABLE' }); } catch (e) { throw new Error(`CreateRecord Failed: ${e.message}`); diff --git a/modules/uptime-api/storage.js b/modules/uptime-api/storage.js index 7e215c6..47cae78 100644 --- a/modules/uptime-api/storage.js +++ b/modules/uptime-api/storage.js @@ -17,6 +17,25 @@ class UptimeStorage { constructor() { // 延迟初始化,在首次调用时检查迁移 this._migrated = false; + this._columnsChecked = false; + } + + _checkColumns() { + if (this._columnsChecked) return; + this._columnsChecked = true; + const db = getDb(); + try { + // 检查 uptime_monitors 是否有 created_at 列 + const info = db.prepare('PRAGMA table_info(uptime_monitors)').all(); + const hasCreatedAt = info.some(c => c.name === 'created_at'); + if (!hasCreatedAt) { + logger.info('正在为 uptime_monitors 添加 created_at 和 updated_at 列...'); + db.prepare('ALTER TABLE uptime_monitors ADD COLUMN created_at DATETIME DEFAULT CURRENT_TIMESTAMP').run(); + db.prepare('ALTER TABLE uptime_monitors ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP').run(); + } + } catch (e) { + logger.warn(`检查列定义失败: ${e.message}`); + } } /** @@ -122,6 +141,7 @@ class UptimeStorage { // ==================== 监控项 CRUD ==================== getAll() { + this._checkColumns(); this._ensureMigrated(); const db = getDb(); const monitors = db.prepare('SELECT * FROM uptime_monitors ORDER BY created_at DESC').all(); @@ -129,6 +149,7 @@ class UptimeStorage { } getActive() { + this._checkColumns(); this._ensureMigrated(); const db = getDb(); const monitors = db.prepare('SELECT * FROM uptime_monitors WHERE active = 1').all(); @@ -136,6 +157,7 @@ class UptimeStorage { } getById(id) { + this._checkColumns(); this._ensureMigrated(); const db = getDb(); const m = db.prepare('SELECT * FROM uptime_monitors WHERE id = ?').get(id); @@ -143,6 +165,7 @@ class UptimeStorage { } create(data) { + this._checkColumns(); this._ensureMigrated(); const db = getDb(); const result = db.prepare(` @@ -166,6 +189,7 @@ class UptimeStorage { } update(id, data) { + this._checkColumns(); this._ensureMigrated(); const db = getDb(); const fields = []; diff --git a/modules/zeabur-api/zeabur-api.js b/modules/zeabur-api/zeabur-api.js index 9e98aac..10f5830 100644 --- a/modules/zeabur-api/zeabur-api.js +++ b/modules/zeabur-api/zeabur-api.js @@ -217,10 +217,11 @@ async function fetchAccountData(token) { } `; + // serviceCostsThisMonth 已被 Zeabur 移除,此处不再查询 const serviceCostsQuery = ` query { me { - serviceCostsThisMonth + _id } } `; @@ -265,7 +266,7 @@ async function fetchAccountData(token) { } const aihub = aihubData?.data?.aihubTenant || {}; - const serviceCosts = serviceCostsData?.data?.me?.serviceCostsThisMonth || 0; + const serviceCosts = 0; // 该字段已废弃,默认返回 0 // 在后端直接转换地域为中文 const projects = queryProjects.map(project => { diff --git a/package-lock.json b/package-lock.json index f396eb3..a91a1b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1661,6 +1661,7 @@ "integrity": "sha512-ugkH3kOgjT8P1mTMY29yCOgEh+KuVMAn8uBxeY0aMqaUgIMysfpnFv+Aepp2CtvI9ygr22NC+OiKl+u+eEaQHw==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@pixi/core": "7.4.2", "@pixi/display": "7.4.2" @@ -1696,6 +1697,7 @@ "integrity": "sha512-UbMtgSEnyCOFPzbE6ThB9qopXxbZ5GCof2ArB4FXOC5Xi/83MOIIYg5kf5M8689C5HJMhg2SrJu3xLKppF+CMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@pixi/color": "7.4.2", "@pixi/constants": "7.4.2", @@ -1717,6 +1719,7 @@ "integrity": "sha512-DaD0J7gIlNlzO0Fdlby/0OH+tB5LtCY6rgFeCBKVDnzmn8wKW3zYZRenWBSFJ0Psx6vLqXYkSIM/rcokaKviIw==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@pixi/core": "7.4.2" } @@ -1800,6 +1803,7 @@ "integrity": "sha512-gOXBbIUx6CRZP1fmsis2wLzzSsofrqmIHhbf1gIkZMIQaLsc9T7brj+PaLTTiOiyJgnvGN5j20RZnkERWWKV0Q==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@pixi/core": "7.4.2" } @@ -1810,6 +1814,7 @@ "integrity": "sha512-80I3g813td7Fnzi7IJSiR3z8gZlKblk6WN+5z6WnscQROcNEpck6lgWS/Lf/IdeHB/FtUKJCbx7RzxkUhiRTvA==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@pixi/core": "^7.0.0-X" } @@ -1840,6 +1845,7 @@ "integrity": "sha512-ykZiR59Gvj80UKs9qm7jeUTKvn+wWk6HBVJOmJbK9jFK5juakDWp7BbH26U78Q61EWj97kI1FdfcbMkuQ7rqkA==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@pixi/core": "7.4.2" } @@ -2149,6 +2155,7 @@ "integrity": "sha512-Ccf/OVQsB+HQV0Fyf5lwD+jk1jeU7uSIqEjbxenNNssmEdB7S5qlkTBV2EJTHT83+T6Z9OMOHsreJZerydpjeg==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@pixi/core": "7.4.2", "@pixi/display": "7.4.2" @@ -3260,7 +3267,8 @@ "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/accepts": { "version": "1.3.8", @@ -3280,6 +3288,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4369,6 +4378,7 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -4790,6 +4800,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -5470,6 +5481,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5807,6 +5819,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -8739,6 +8752,7 @@ "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -9880,6 +9894,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10181,6 +10196,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -10298,6 +10314,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10311,6 +10328,7 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -10457,6 +10475,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/compiler-sfc": "3.5.26", diff --git a/src/css/dashboard.css b/src/css/dashboard.css index 57b7b24..8519246 100644 --- a/src/css/dashboard.css +++ b/src/css/dashboard.css @@ -1,4 +1,4 @@ -/* Dashboard 2.0 - Strict Alignment & Spacing */ +/* Dashboard 2.0 - Strict Alignment & Spacing */ /* 全局容器 @@ -13,8 +13,6 @@ font-family: var(--font-main, 'Inter', system-ui, sans-serif); color: var(--text-primary); box-sizing: border-box; - user-select: none; - -webkit-user-select: none; } /* ========================================= @@ -160,7 +158,7 @@ .stats-overview-grid { display: grid; - grid-template-columns: repeat(5, 1fr); + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--dashboard-grid-gap); margin-bottom: var(--dashboard-grid-gap); } @@ -1497,12 +1495,10 @@ /* 只显示图?*/ } - /* --- Stats Grid: 2 Columns on Mobile --- */ + /* --- Stats Grid: 流式布局自动适配,仅微调间距 --- */ .stats-overview-grid { - display: grid !important; - grid-template-columns: repeat(2, 1fr) !important; - gap: var(--space-sm) !important; - margin-bottom: var(--space-md) !important; + gap: var(--space-sm); + margin-bottom: var(--space-md); } /* --- Overview Cards: Mobile Card Style --- */ diff --git a/src/css/styles.css b/src/css/styles.css index 2185f68..2fe3aa1 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -1,3 +1,4 @@ +/* 全站隐藏滚动条 */ *::-webkit-scrollbar { display: none !important; width: 0 !important; @@ -411,85 +412,25 @@ pre, color: var(--text-primary); } - /* 模块主题色边框悬停配 */ - .theme-openai .tab-btn:hover:not(.active) { - border-color: var(--openai-primary) !important; - } - - .theme-antigravity .tab-btn:hover:not(.active) { - border-color: var(--ag-primary) !important; - } - - .theme-gemini-cli .tab-btn:hover:not(.active) { - border-color: var(--gemini-primary) !important; - } - - .theme-cf-dns .tab-btn:hover:not(.active) { - border-color: var(--cf-primary) !important; - } - - .theme-self-h .tab-btn:hover:not(.active) { - border-color: var(--selfh-primary) !important; - } - - .theme-server .tab-btn:hover:not(.active) { - border-color: var(--server-primary) !important; - } - - .theme-paas .tab-btn:hover:not(.active) { - border-color: var(--paas-primary) !important; - } - - .theme-totp .tab-btn:hover:not(.active) { - border-color: var(--totp-primary) !important; - } - - .theme-music .tab-btn:hover:not(.active) { - border-color: #f43f5e !important; - } - .tab-btn.active { border-color: rgba(0, 0, 0, 0.1) !important; - /* 濢活时保持细微边框?*/ } - /* --- 模块主题类定?--- */ - - /* 二级标签页激活状态用逻辑 */ + /* 二级标签页激活状态 */ .tab-content .sec-tabs .tab-btn.active { color: white !important; transition: all 0.3s ease; } - /* 通用元素颜色覆盖逻辑 */ + /* --- 模块主题变量定义 --- */ + /* 每个模块只需声明 3 个变量,通用规则自动应用到 hover/active/icon 等 */ .theme-dashboard, - .theme-openai, - .theme-antigravity, - .theme-gemini-cli, - .theme-cf-dns, - .theme-self-h, - .theme-server, - .theme-paas, - .theme-totp, - .theme-music, - .theme-uptime, - .theme-notification, .theme-ai-chat { --current-primary: var(--primary-color); --current-dark: var(--primary-dark); --current-rgb: 99, 102, 241; } - .theme-dashboard { - --current-primary: var(--primary-color); - --current-rgb: 99, 102, 241; - } - - .theme-ai-chat { - --current-primary: var(--primary-color); - --current-rgb: 100, 102, 241; - } - .theme-openai { --current-primary: var(--openai-primary); --current-dark: var(--openai-dark); @@ -514,16 +455,6 @@ pre, --current-rgb: 77, 107, 254; } - .theme-deepseek .tab-btn:hover:not(.active) { - border-color: var(--current-primary) !important; - } - - .theme-deepseek .tab-btn.active { - background: linear-gradient(135deg, var(--current-primary), var(--current-dark)) !important; - color: white !important; - box-shadow: 0 2px 8px rgba(77, 107, 254, 0.3); - } - .theme-cf-dns { --current-primary: var(--cf-primary); --current-dark: var(--cf-dark); @@ -584,6 +515,11 @@ pre, --current-rgb: 0, 82, 217; } + /* 通用模块悬停样式 — 使用 --current-primary 变量 */ + [class*='theme-'] .tab-btn:hover:not(.active) { + border-color: var(--current-primary) !important; + } + /* 通用输入框图标样?*/ .input-with-icon { position: relative; @@ -606,23 +542,14 @@ pre, width: 100%; } - .theme-aliyun .tab-btn:hover:not(.active) { - border-color: #ff6a00 !important; - } - - .theme-aliyun .tab-btn.active { - background: linear-gradient(135deg, #ff6a00, #e55c00) !important; - box-shadow: 0 2px 8px rgba(255, 106, 0, 0.3); - } - .theme-aliyun .zone-card.active { - border-color: #ff6a00 !important; + border-color: var(--current-primary) !important; background: rgba(255, 106, 0, 0.05); box-shadow: 0 2px 8px rgba(255, 106, 0, 0.1); } .theme-aliyun .zone-card.active i { - color: #ff6a00 !important; + color: var(--current-primary) !important; } /* 应用动颜色到通用组件 */ @@ -652,62 +579,10 @@ pre, background: var(--current-primary) !important; } - /* 模块专属濢活阴影配 */ - .theme-openai .tab-btn.active { - background: linear-gradient(135deg, var(--openai-primary), var(--openai-dark)) !important; - box-shadow: 0 2px 8px rgba(16, 163, 127, 0.3); - } - - .theme-antigravity .tab-btn.active { - background: linear-gradient(135deg, var(--ag-primary), var(--ag-dark)) !important; - box-shadow: 0 2px 8px rgba(244, 63, 94, 0.3); - } - - .theme-gemini-cli .tab-btn.active { - background: linear-gradient(135deg, var(--gemini-primary), var(--gemini-dark)) !important; - box-shadow: 0 2px 8px rgba(66, 133, 244, 0.3); - } - - .theme-cf-dns .tab-btn.active { - background: linear-gradient(135deg, var(--cf-primary), var(--cf-dark)) !important; - box-shadow: 0 2px 8px rgba(243, 128, 32, 0.3); - } - - .theme-self-h .tab-btn.active { - background: linear-gradient(135deg, var(--selfh-primary), var(--selfh-dark)) !important; - box-shadow: 0 2px 8px rgba(6, 182, 212, 0.3); - } - - .theme-server .tab-btn.active { - background: linear-gradient(135deg, - var(--server-primary), - var(--server-primary-dark)) !important; - box-shadow: 0 2px 8px rgba(71, 85, 105, 0.3); - } - - .theme-paas .tab-btn.active { - background: linear-gradient(135deg, var(--paas-primary), var(--paas-dark)) !important; - box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3); - } - - .theme-totp .tab-btn.active { - background: linear-gradient(135deg, var(--totp-primary), var(--totp-dark)) !important; - box-shadow: 0 2px 8px rgba(139, 92, 246, 0.3); - } - - .theme-music .tab-btn.active { - background: linear-gradient(135deg, #f43f5e, #e11d48) !important; - box-shadow: 0 2px 8px rgba(244, 63, 94, 0.3); - } - - .theme-uptime .tab-btn.active { - background: linear-gradient(135deg, #10b981, #059669) !important; - box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3); - } - - .theme-notification .tab-btn.active { - background: linear-gradient(135deg, var(--notification-primary), var(--notification-dark)) !important; - box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3); + /* 通用模块激活样式 — 使用 --current-primary/--current-dark 变量,无需逐模块硬编码 */ + [class*='theme-'] .tab-btn.active { + background: linear-gradient(135deg, var(--current-primary), var(--current-dark)) !important; + box-shadow: 0 2px 8px rgba(var(--current-rgb), 0.3); } /* ======================================== @@ -884,7 +759,7 @@ pre, } } -/* ֶɫģʽ֧ (与媒体查询保持同? */ +/* 手动深色模式开关:复用同一套变量,避免重复维护 */ [data-theme='dark'] { --bg-primary: #111827; --bg-secondary: #1f2937; @@ -893,10 +768,16 @@ pre, --text-primary: #f9fafb; --text-secondary: #d1d5db; --text-tertiary: #9ca3af; - --border-color: #374151; + --border-color: rgba(255, 255, 255, 0.08); --card-bg: #1f2937; - --card-shadow: 2px 2px 2px 0px rgba(0, 0, 0, 0.3), 2px 2px 2px 0px rgba(0, 0, 0, 0.2); - --card-hover-shadow: 5px 5px 6px 0px rgba(0, 0, 0, 0.3), 4px 4px 5px 0px rgba(0, 0, 0, 0.2); + --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.2); + --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3), 0 0 1px rgba(0, 0, 0, 0.2); + --shadow-md: 0 4px 8px rgba(0, 0, 0, 0.3), 0 0 1px rgba(0, 0, 0, 0.2); + --shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.35), 0 0 1px rgba(0, 0, 0, 0.2); + --shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.06); + --card-shadow: var(--shadow-sm); + --card-hover-shadow: var(--shadow-md); + --modal-shadow: var(--shadow-xl); --modal-overlay: rgba(0, 0, 0, 0.7); --input-bg: transparent; --input-border: #4b5563; diff --git a/src/db/models/System.js b/src/db/models/System.js index 65553b4..070e70e 100644 --- a/src/db/models/System.js +++ b/src/db/models/System.js @@ -607,7 +607,7 @@ class LoginAttempt extends BaseModel { const lockDurationMinutes = 15; // 获取或创建记录 - let record = this.findOneWhere({ ip_address: ip }); + const record = this.findOneWhere({ ip_address: ip }); if (!record) { // 首次失败 diff --git a/src/js/composables/usePagination.js b/src/js/composables/usePagination.js index 34ff12a..8377d9c 100644 --- a/src/js/composables/usePagination.js +++ b/src/js/composables/usePagination.js @@ -47,7 +47,7 @@ export function usePagination(options = {}) { const half = Math.floor(maxVisible / 2); let start = Math.max(1, page.value - half); - let end = Math.min(totalPages.value, start + maxVisible - 1); + const end = Math.min(totalPages.value, start + maxVisible - 1); if (end - start + 1 < maxVisible) { start = Math.max(1, end - maxVisible + 1); diff --git a/src/js/modules/dashboard.js b/src/js/modules/dashboard.js index 233b5c2..33aaca1 100644 --- a/src/js/modules/dashboard.js +++ b/src/js/modules/dashboard.js @@ -274,7 +274,7 @@ export const dashboardMethods = { drawTrendChart(refName, data, color) { const app = document.querySelector('#app')?.__vue_app__?._instance; - let canvas = document.getElementById(refName); + const canvas = document.getElementById(refName); if (!canvas) return; diff --git a/src/js/modules/host.js b/src/js/modules/host.js index 1b590ac..5ae8125 100644 --- a/src/js/modules/host.js +++ b/src/js/modules/host.js @@ -1024,7 +1024,7 @@ export const hostMethods = { throw new Error(data.error || '加载 Docker 概览失败'); } - let dockerServers = (data.data?.servers || []) + const dockerServers = (data.data?.servers || []) .filter(server => server.docker && server.docker.installed) .map(server => ({ id: server.serverId, diff --git a/src/js/modules/openai.js b/src/js/modules/openai.js index d5e421a..8347ba9 100644 --- a/src/js/modules/openai.js +++ b/src/js/modules/openai.js @@ -591,7 +591,7 @@ ${conversationText} // 模型健康检测 async testModelHealth(model) { // 找到该模型所属的端点 - let modelId = typeof model === 'string' ? model : model.id; + const modelId = typeof model === 'string' ? model : model.id; const endpoint = store.openaiEndpoints.find(ep => ep.models && ep.models.includes(modelId) ); @@ -660,7 +660,7 @@ ${conversationText} try { let url = '/api/openai/health-check-all'; - let payload = { timeout: timeout * 1000, concurrency }; + const payload = { timeout: timeout * 1000, concurrency }; // 如果选择“单个”,且有选中的端点 if (useKey === 'single') { diff --git a/src/js/modules/ssh.js b/src/js/modules/ssh.js index 2fdc155..94198e2 100644 --- a/src/js/modules/ssh.js +++ b/src/js/modules/ssh.js @@ -883,7 +883,7 @@ export const sshMethods = { } const sessionId = 'session_' + Date.now(); - let type = (server.monitor_mode === 'agent') ? 'agent' : 'ssh'; + const type = (server.monitor_mode === 'agent') ? 'agent' : 'ssh'; const session = { id: sessionId, @@ -948,7 +948,7 @@ export const sshMethods = { server.host.startsWith('172.') || server.host.startsWith('10.') || server.host.startsWith('192.168.'); - let type = (server.monitor_mode === 'agent' || (isInvalidHost && server.status === 'online')) ? 'agent' : 'ssh'; + const type = (server.monitor_mode === 'agent' || (isInvalidHost && server.status === 'online')) ? 'agent' : 'ssh'; session = { id: sessionId, diff --git a/src/routes/v1.js b/src/routes/v1.js index 43b8c4e..9a90de1 100644 --- a/src/routes/v1.js +++ b/src/routes/v1.js @@ -336,14 +336,20 @@ const dispatch = async (req, res, next) => { // --- A. 精确匹配前缀优先 --- - // 尝试匹配 DeepSeek 前缀或 deepseek-* 模型名 + // 尝试匹配 DeepSeek 前缀或 deepseek-* 模型名及已知别名 (gpt-*, o1, o3, claude- 等) if (dsEnabled) { - if (dsPrefix && fullModelId.startsWith(dsPrefix)) { - req.body.model = fullModelId.substring(dsPrefix.length); - return dsRouter(req, res, next); - } - // 无前缀时,通过模型名关键词匹配 - if (!dsPrefix && (fullModelId.startsWith('deepseek-') || fullModelId.startsWith('deepseek/'))) { + const isDsPrefixMatch = dsPrefix && fullModelId.startsWith(dsPrefix); + const dsAliases = ['gpt-', 'o1', 'o3', 'claude-', 'llama-', 'qwen-']; + const isDsAliasMatch = !dsPrefix && ( + fullModelId.startsWith('deepseek-') || + fullModelId.startsWith('deepseek/') || + dsAliases.some(p => fullModelId.startsWith(p)) + ); + + if (isDsPrefixMatch || isDsAliasMatch) { + if (isDsPrefixMatch) { + req.body.model = fullModelId.substring(dsPrefix.length); + } return dsRouter(req, res, next); } } diff --git a/vite.config.mjs b/vite.config.mjs index 54c76a8..551d2d1 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -63,8 +63,11 @@ export default defineConfig(({ mode }) => { globals: globals, // 代码分割策略 manualChunks: id => { - // 注意:已经在 externalDeps 中的包(CDN 引用的包)不能在此处分包 if (id.includes('node_modules')) { + // 防御性检查:已被标记为外部依赖的包(CDN 引用)不参与分包 + if (externalDeps.some(dep => id.includes(`/node_modules/${dep}/`) || id.includes(`/node_modules/${dep.replace('/', '+')}/`))) { + return; + } // 终端组件 if (id.includes('@xterm')) { return 'vendor-xterm'; @@ -82,7 +85,7 @@ export default defineConfig(({ mode }) => { if (id.includes('@pixi') || id.includes('pixi-filters')) { return 'vendor-pixi'; } - // 其他大型工具库 (且不在 CDN 中的) + // 其他大型工具库 if ( id.includes('axios') || id.includes('marked') || @@ -90,8 +93,6 @@ export default defineConfig(({ mode }) => { id.includes('uuid') || id.includes('vue') ) { - // 如果启用了 CDN 且 vue/axios 在 external 中,Vite 会自动忽略它们 - // 这里我们显式将非 CDN 的大库打包 return 'vendor-utils'; } } @@ -100,9 +101,9 @@ export default defineConfig(({ mode }) => { }, terserOptions: { compress: { - drop_console: false, // 暂时禁用,确保调试信息可见 drop_debugger: isProduction, - pure_funcs: [], // 不要优化掉任何函数调用 + // 生产环境移除 console.log/debug,保留 error/warn 用于线上排障 + pure_funcs: isProduction ? ['console.log', 'console.debug'] : [], }, mangle: { reserved: ['compile', 'compileToFunction', 'baseCompile'], // 保留编译器函数名 @@ -138,12 +139,18 @@ export default defineConfig(({ mode }) => { host: 'localhost', port: 5173, }, - // SPA 历史回退:所有非静态资源路由都返回 index.html - historyApiFallback: { - rewrites: [ - { from: /^\/api\/.*$/, to: '/index.html' }, // 仅做兜底,实际应由 proxy 处理 + // 文件系统访问控制:阻止 dev server 暴露后端源代码 + fs: { + deny: [ + '**/db/**', + '**/middleware/**', + '**/routes/**', + '**/services/**', + '**/utils/**', + '**/views/**', + '**/scripts/**', + '**/*.sql', ], - disableDotRule: true, // 允许路径中带点 }, proxy: { '/api': {