diff --git a/src/backend/utils/download.js b/src/backend/utils/download.js index 5021aa4..e7ab9c7 100644 --- a/src/backend/utils/download.js +++ b/src/backend/utils/download.js @@ -3,6 +3,17 @@ * @description 图片下载与 Base64 转换 */ +import { logger } from '../../utils/logger.js'; + +/** + * 判断错误是否可重试 + * @param {string} message - 错误消息 + * @returns {boolean} + */ +function isRetryableError(message) { + return /timeout|network|econnreset|econnrefused|etimedout|disconnected|tls|socket/i.test(message); +} + /** * 使用页面上下文下载图片并转换为 Base64 * 自动继承页面的 Cookie 和 Session,解决鉴权问题 @@ -10,19 +21,26 @@ * @param {import('playwright-core').Page} page - Playwright 页面对象 * @param {object} [options] - 可选配置 * @param {number} [options.timeout=60000] - 超时时间(毫秒) - * @param {number} [options.retries=0] - 下载失败时的重试次数 - * @returns {Promise<{ image?: string, error?: string }>} 下载结果 + * @param {number} [options.retries=3] - 最大重试次数 + * @param {number} [options.retryDelay=1000] - 重试延迟基数(毫秒) + * @returns {Promise<{ image?: string, imageUrl?: string, error?: string }>} 下载结果(包含原始 URL) */ export async function useContextDownload(url, page, options = {}) { - const { timeout = 60000, retries = 0 } = options; + const { timeout = 120000, retries = 3, retryDelay = 1000 } = options; - for (let attempt = 0; attempt <= retries; attempt++) { + for (let attempt = 1; attempt <= retries; attempt++) { try { const response = await page.request.get(url, { timeout }); if (!response.ok()) { - if (attempt < retries) continue; - return { error: `下载失败: HTTP ${response.status()}` }; + const status = response.status(); + // 5xx 错误可重试 + if (status >= 500 && attempt < retries) { + logger.warn('下载', `HTTP ${status},重试 ${attempt}/${retries}...`); + await new Promise(r => setTimeout(r, retryDelay * attempt)); + continue; + } + return { error: `下载失败: HTTP ${status}`, imageUrl: url }; } const buffer = await response.body(); @@ -30,10 +48,16 @@ export async function useContextDownload(url, page, options = {}) { const contentType = response.headers()['content-type'] || 'image/png'; const mimeType = contentType.split(';')[0].trim(); - return { image: `data:${mimeType};base64,${base64}` }; + return { image: `data:${mimeType};base64,${base64}`, imageUrl: url }; } catch (e) { - if (attempt < retries) continue; - return { error: `已获取结果,但图片下载时遇到错误: ${e.message}` }; + if (isRetryableError(e.message) && attempt < retries) { + logger.warn('下载', `${e.message},重试 ${attempt}/${retries}...`); + await new Promise(r => setTimeout(r, retryDelay * attempt)); + continue; + } + return { error: `已获取结果,但图片下载时遇到错误: ${e.message}`, imageUrl: url }; } } + + return { error: '下载失败: 已达最大重试次数', imageUrl: url }; } diff --git a/src/backend/utils/error.js b/src/backend/utils/error.js index 24d6992..7a3fbfa 100644 --- a/src/backend/utils/error.js +++ b/src/backend/utils/error.js @@ -96,6 +96,36 @@ export function normalizePageError(err, meta = {}) { export function normalizeHttpError(response, content = null) { const status = response.status(); + // 尝试从响应体中提取具体错误信息 + let detailError = null; + if (content) { + try { + const json = JSON.parse(content); + // 格式: {"error": "Request rejected: ..."} + if (json.error && typeof json.error === 'string') { + detailError = json.error; + } + // 格式: {"error": {"message": "..."}} + else if (json.error?.message) { + detailError = json.error.message; + } + } catch { + // 非 JSON 格式,尝试直接使用内容 + if (content.length < 200) { + detailError = content; + } + } + } + + // 检查是否是内容审核拒绝 (通常返回 422 或 429 但含有拒绝信息) + const isContentRejection = detailError && ( + /reject|violat|terms|blocked|forbidden|unsafe|moderat/i.test(detailError) || + detailError === 'prompt failed' + ); + if (isContentRejection) { + return { error: `内容被拒绝: ${detailError}`, code: ADAPTER_ERRORS.CONTENT_BLOCKED, retryable: false }; + } + // 429 限流检查 if (status === 429 || content?.includes('Too Many Requests')) { return { error: '触发限流/上游繁忙', code: ADAPTER_ERRORS.RATE_LIMITED, retryable: true }; @@ -108,12 +138,18 @@ export function normalizeHttpError(response, content = null) { // 5xx 服务端错误(可重试) if (status >= 500) { - return { error: `上游服务器错误,HTTP错误码: ${status}`, code: ADAPTER_ERRORS.HTTP_ERROR, retryable: true }; + const msg = detailError + ? `上游服务器错误 (${status}): ${detailError}` + : `上游服务器错误,HTTP错误码: ${status}`; + return { error: msg, code: ADAPTER_ERRORS.HTTP_ERROR, retryable: true }; } // 4xx 客户端错误(不可重试) if (status >= 400) { - return { error: `请求错误,HTTP错误码: ${status}`, code: ADAPTER_ERRORS.HTTP_ERROR, retryable: false }; + const msg = detailError + ? `请求被拒绝 (${status}): ${detailError}` + : `请求错误,HTTP错误码: ${status}`; + return { error: msg, code: ADAPTER_ERRORS.HTTP_ERROR, retryable: false }; } return null; diff --git a/src/server/api/admin/routes.js b/src/server/api/admin/routes.js index 5391b51..4cd3273 100644 --- a/src/server/api/admin/routes.js +++ b/src/server/api/admin/routes.js @@ -36,6 +36,19 @@ import { import { registry } from '../../../backend/registry.js'; import { sendRestartSignal, sendStopSignal, isUnderSupervisor, getVncInfo } from '../../../utils/ipc.js'; import { getTodayStats, getStatsRange, clearStatsRange } from '../../../utils/stats.js'; +import { + getList as getHistoryList, + getDetail as getHistoryDetail, + deleteRecords as deleteHistoryRecords, + deleteByDateRange as deleteHistoryByDateRange, + retryMediaDownload, + getStats as getHistoryStats, + getModelList as getHistoryModelList, + getMediaDir +} from '../../../utils/history.js'; +import path from 'path'; +import fs from 'fs/promises'; +import { useContextDownload } from '../../../backend/utils/download.js'; /** * 读取请求体 @@ -458,6 +471,148 @@ export function createAdminRouter(context) { return; } + // ==================== 请求历史 ==================== + + // GET /admin/history - 历史记录列表 + if (method === 'GET' && pathname === '/history') { + const url = new URL(req.url, `http://${req.headers.host}`); + const page = parseInt(url.searchParams.get('page') || '1', 10); + const pageSize = parseInt(url.searchParams.get('pageSize') || '20', 10); + const filters = { + status: url.searchParams.get('status') || null, + modelId: url.searchParams.get('model') || null, + search: url.searchParams.get('search') || null, + startDate: url.searchParams.get('startDate') || null, + endDate: url.searchParams.get('endDate') || null + }; + + const result = getHistoryList(filters, page, pageSize); + sendJson(res, 200, result); + return; + } + + // GET /admin/history/stats - 历史统计摘要 + if (method === 'GET' && pathname === '/history/stats') { + const url = new URL(req.url, `http://${req.headers.host}`); + const filters = { + startDate: url.searchParams.get('startDate') || null, + endDate: url.searchParams.get('endDate') || null + }; + + const stats = getHistoryStats(filters); + sendJson(res, 200, stats); + return; + } + + // GET /admin/history/models - 获取历史中使用过的模型列表 + if (method === 'GET' && pathname === '/history/models') { + const models = getHistoryModelList(); + sendJson(res, 200, models); + return; + } + + // GET /admin/history/media/:filename - 静态媒体文件服务 + if (method === 'GET' && pathname.startsWith('/history/media/')) { + const filename = pathname.replace('/history/media/', ''); + if (!filename || filename.includes('..') || filename.includes('/')) { + sendApiError(res, { code: ERROR_CODES.INVALID_REQUEST_BODY, message: '无效的文件名' }); + return; + } + + const mediaDir = getMediaDir(); + const filePath = path.join(mediaDir, filename); + + try { + const data = await fs.readFile(filePath); + const ext = path.extname(filename).toLowerCase(); + const mimeTypes = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.mp4': 'video/mp4', + '.webm': 'video/webm' + }; + res.writeHead(200, { + 'Content-Type': mimeTypes[ext] || 'application/octet-stream', + 'Content-Length': data.length, + 'Cache-Control': 'public, max-age=31536000' + }); + res.end(data); + } catch (e) { + sendApiError(res, { code: ERROR_CODES.NOT_FOUND, message: '文件不存在', status: 404 }); + } + return; + } + + // GET /admin/history/:id - 单条记录详情 + const historyDetailMatch = pathname.match(/^\/history\/([^/]+)$/); + if (method === 'GET' && historyDetailMatch && !pathname.includes('/retry-media')) { + const id = historyDetailMatch[1]; + const record = getHistoryDetail(id); + if (record) { + sendJson(res, 200, record); + } else { + sendApiError(res, { code: ERROR_CODES.NOT_FOUND, message: '记录不存在', status: 404 }); + } + return; + } + + // POST /admin/history/:id/retry-media - 重试下载媒体 + const retryMediaMatch = pathname.match(/^\/history\/([^/]+)\/retry-media$/); + if (method === 'POST' && retryMediaMatch) { + const id = retryMediaMatch[1]; + const body = await readBody(req); + const mediaIndex = body.mediaIndex ?? 0; + + // 使用 Pool 的浏览器下载(如果可用) + let downloadFn = null; + try { + const poolContext = queueManager?.getPoolContext?.(); + const page = poolContext?.getFirstPage?.(); + if (page) { + const imgDlCfg = config?.backend?.pool?.failover || {}; + downloadFn = (url) => useContextDownload(url, page, { + retries: imgDlCfg.imgDlRetry ? (imgDlCfg.imgDlRetryMaxRetries || 3) : 1 + }); + } + } catch { /* Pool 未初始化,使用后备方案 */ } + + const result = await retryMediaDownload(id, mediaIndex, downloadFn); + if (result.success) { + sendJson(res, 200, result); + } else { + sendApiError(res, { code: ERROR_CODES.INTERNAL_ERROR, message: result.message }); + } + return; + } + + // DELETE /admin/history - 批量删除记录 + if (method === 'DELETE' && pathname === '/history') { + const url = new URL(req.url, `http://${req.headers.host}`); + const startDate = url.searchParams.get('startDate'); + const endDate = url.searchParams.get('endDate'); + + // 支持按日期范围删除 + if (startDate && endDate) { + const deleted = await deleteHistoryByDateRange(startDate, endDate); + sendJson(res, 200, { success: true, deleted }); + return; + } + + // 支持按 ID 列表删除 + const body = await readBody(req); + if (body.ids && Array.isArray(body.ids)) { + const deleted = await deleteHistoryRecords(body.ids); + sendJson(res, 200, { success: true, deleted }); + return; + } + + sendApiError(res, { code: ERROR_CODES.INVALID_REQUEST_BODY, message: '缺少 ids 数组或日期范围参数' }); + return; + } + // 404 res.writeHead(404); res.end(JSON.stringify({ error: 'Not Found' })); diff --git a/src/server/queue.js b/src/server/queue.js index 564ffb0..1e2a20e 100644 --- a/src/server/queue.js +++ b/src/server/queue.js @@ -15,6 +15,7 @@ import { } from './respond.js'; import { ERROR_CODES } from './errors.js'; import { incrementSuccess, incrementFailed } from '../utils/stats.js'; +import { createRecord, updateRecord, processResponseMedia } from '../utils/history.js'; /** * @typedef {object} TaskContext @@ -94,9 +95,25 @@ export function createQueueManager(queueConfig, callbacks) { */ async function processTask(task) { const { res, prompt, imagePaths, modelId, modelName, id, isStreaming } = task; + const startTime = Date.now(); logger.info('服务器', '[队列] 开始处理任务', { id, remaining: queue.length }); + // 创建历史记录 + try { + createRecord({ + id, + modelId, + modelName, + prompt, + inputImages: imagePaths, + isStreaming, + status: 'pending' + }); + } catch (e) { + logger.debug('服务器', `创建历史记录失败: ${e.message}`); + } + // 启动心跳(流式请求) let heartbeatInterval = null; if (isStreaming) { @@ -123,8 +140,17 @@ export function createQueueManager(queueConfig, callbacks) { // 处理结果 if (result.error) { - // 生成失败:记录统计并返回错误 + // 生成失败:记录统计和历史 await incrementFailed(); + try { + updateRecord(id, { + status: 'failed', + errorMessage: result.error, + durationMs: Date.now() - startTime + }); + } catch (e) { + logger.debug('服务器', `更新历史记录失败: ${e.message}`); + } sendApiError(res, { code: ERROR_CODES.GENERATION_FAILED, message: result.error, @@ -136,28 +162,53 @@ export function createQueueManager(queueConfig, callbacks) { // 生成成功 let finalContent = ''; + let reasoningContent = null; // 思考过程内容 + let historyResponseText = ''; // 历史记录中存储的文本(不含 base64) + if (result.image) { - // 只有图片格式才使用 markdown,视频等其他格式直接返回 data URI - if (result.image.startsWith('data:image/')) { - finalContent = `![generated](${result.image})`; - } else { - finalContent = result.image; - } + // 直接返回 base64 数据,不加 Markdown 包装 + finalContent = result.image; + // 历史记录只存原始 URL,不存 base64 + historyResponseText = result.imageUrl || ''; } else { finalContent = result.text || '生成失败'; + historyResponseText = result.text || ''; } + + // 提取思考过程(如果有) + if (result.reasoning) { + reasoningContent = result.reasoning; + } + logger.info('服务器', '结果已准备就绪', { id }); await incrementSuccess(); + // 更新历史记录(异步处理媒体,不阻塞响应) + processResponseMedia(result, id).then(responseMedia => { + try { + updateRecord(id, { + status: 'success', + responseText: historyResponseText, + reasoningContent, + responseMedia, + durationMs: Date.now() - startTime + }); + } catch (e) { + logger.debug('服务器', `更新历史记录失败: ${e.message}`); + } + }).catch(e => { + logger.debug('服务器', `处理响应媒体失败: ${e.message}`); + }); + // 发送成功响应 - logger.info('服务器', '准备发送响应...', { id, isStreaming, contentLength: finalContent.length }); + logger.info('服务器', '准备发送响应...', { id, isStreaming, contentLength: finalContent.length, hasReasoning: !!reasoningContent }); if (isStreaming) { - const chunk = buildChatCompletionChunk(finalContent, modelName); + const chunk = buildChatCompletionChunk(finalContent, modelName, 'stop', reasoningContent); sendSse(res, chunk); sendSseDone(res); logger.info('服务器', '流式响应已结束', { id }); } else { - const response = buildChatCompletion(finalContent, modelName); + const response = buildChatCompletion(finalContent, modelName, reasoningContent); sendJson(res, 200, response); logger.info('服务器', 'JSON 响应已发送', { id }); } @@ -166,8 +217,17 @@ export function createQueueManager(queueConfig, callbacks) { // 清除心跳 if (heartbeatInterval) clearInterval(heartbeatInterval); - // 记录失败统计 + // 记录失败统计和历史 await incrementFailed(); + try { + updateRecord(id, { + status: 'failed', + errorMessage: err.message, + durationMs: Date.now() - startTime + }); + } catch (e) { + logger.debug('服务器', `更新历史记录失败: ${e.message}`); + } logger.error('服务器', '任务处理失败', { id, error: err.message }); sendApiError(res, { code: ERROR_CODES.INTERNAL_ERROR, diff --git a/src/server/server.js b/src/server/server.js index 630c9c1..5d964d5 100644 --- a/src/server/server.js +++ b/src/server/server.js @@ -26,6 +26,7 @@ const { logger } = await import('../utils/logger.js'); const { createQueueManager, createGlobalRouter } = await import('./index.js'); const { isUnderSupervisor } = await import('../utils/ipc.js'); const { loadTodayStats } = await import('../utils/stats.js'); +const { initHistoryDb } = await import('../utils/history.js'); // ==================== 初始化配置 ==================== @@ -135,6 +136,13 @@ async function startServer() { // 加载今日统计 await loadTodayStats(); + // 初始化历史记录数据库 + try { + await initHistoryDb(); + } catch (err) { + logger.warn('服务器', '历史记录数据库初始化失败,功能可能不可用', { error: err.message }); + } + // 登录模式提示 if (isLoginMode) { logger.info('服务器', '登录模式已就绪,请在浏览器中完成登录操作'); diff --git a/src/utils/history.js b/src/utils/history.js new file mode 100644 index 0000000..e5f0ee1 --- /dev/null +++ b/src/utils/history.js @@ -0,0 +1,508 @@ +/** + * @fileoverview 请求历史记录管理模块 + * @description 使用 SQLite 存储请求/响应历史,支持媒体文件本地存储 + */ + +import Database from 'better-sqlite3'; +import fs from 'fs/promises'; +import path from 'path'; +import { logger } from './logger.js'; + +const DATA_DIR = 'data/history'; +const DB_PATH = path.join(DATA_DIR, 'history.db'); +const MEDIA_DIR = path.join(DATA_DIR, 'media'); + +/** @type {Database.Database|null} */ +let db = null; + +/** + * 初始化历史记录数据库 + * @returns {Database.Database} + */ +export async function initHistoryDb() { + if (db) return db; + + // 确保目录存在 + await fs.mkdir(DATA_DIR, { recursive: true }); + await fs.mkdir(MEDIA_DIR, { recursive: true }); + + db = new Database(DB_PATH); + + // 创建表 + db.exec(` + CREATE TABLE IF NOT EXISTS requests ( + id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + model_id TEXT, + model_name TEXT, + prompt TEXT, + input_images TEXT, + response_text TEXT, + reasoning_content TEXT, + response_media TEXT, + status TEXT DEFAULT 'pending', + error_message TEXT, + duration_ms INTEGER, + is_streaming INTEGER DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_created_at ON requests(created_at DESC); + CREATE INDEX IF NOT EXISTS idx_status ON requests(status); + CREATE INDEX IF NOT EXISTS idx_model_id ON requests(model_id); + `); + + logger.info('历史记录', '数据库初始化完成'); + return db; +} + +/** + * 获取数据库实例 + * @returns {Database.Database} + */ +function getDb() { + if (!db) { + throw new Error('历史记录数据库未初始化,请先调用 initHistoryDb()'); + } + return db; +} + +/** + * 创建历史记录 + * @param {object} data - 记录数据 + * @returns {string} 记录 ID + */ +export function createRecord(data) { + const db = getDb(); + const stmt = db.prepare(` + INSERT INTO requests (id, created_at, model_id, model_name, prompt, input_images, status, is_streaming) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + + stmt.run( + data.id, + Date.now(), + data.modelId || null, + data.modelName || null, + data.prompt || null, + data.inputImages ? JSON.stringify(data.inputImages) : null, + data.status || 'pending', + data.isStreaming ? 1 : 0 + ); + + return data.id; +} + +/** + * 更新历史记录 + * @param {string} id - 记录 ID + * @param {object} updates - 更新数据 + */ +export function updateRecord(id, updates) { + const db = getDb(); + + const fields = []; + const values = []; + + if (updates.status !== undefined) { + fields.push('status = ?'); + values.push(updates.status); + } + if (updates.responseText !== undefined) { + fields.push('response_text = ?'); + values.push(updates.responseText); + } + if (updates.reasoningContent !== undefined) { + fields.push('reasoning_content = ?'); + values.push(updates.reasoningContent); + } + if (updates.responseMedia !== undefined) { + fields.push('response_media = ?'); + values.push(typeof updates.responseMedia === 'string' ? updates.responseMedia : JSON.stringify(updates.responseMedia)); + } + if (updates.errorMessage !== undefined) { + fields.push('error_message = ?'); + values.push(updates.errorMessage); + } + if (updates.durationMs !== undefined) { + fields.push('duration_ms = ?'); + values.push(updates.durationMs); + } + + if (fields.length === 0) return; + + values.push(id); + const stmt = db.prepare(`UPDATE requests SET ${fields.join(', ')} WHERE id = ?`); + stmt.run(...values); +} + +/** + * 获取历史记录列表 + * @param {object} filters - 筛选条件 + * @param {number} page - 页码(从 1 开始) + * @param {number} pageSize - 每页数量 + * @returns {{items: object[], total: number, page: number, pageSize: number}} + */ +export function getList(filters = {}, page = 1, pageSize = 20) { + const db = getDb(); + + const conditions = []; + const params = []; + + if (filters.status && filters.status !== 'all') { + conditions.push('status = ?'); + params.push(filters.status); + } + if (filters.modelId) { + conditions.push('model_id LIKE ?'); + params.push(`%${filters.modelId}%`); + } + if (filters.search) { + conditions.push('(prompt LIKE ? OR response_text LIKE ?)'); + params.push(`%${filters.search}%`, `%${filters.search}%`); + } + if (filters.startDate) { + conditions.push('created_at >= ?'); + params.push(new Date(filters.startDate).setHours(0, 0, 0, 0)); + } + if (filters.endDate) { + conditions.push('created_at <= ?'); + params.push(new Date(filters.endDate).setHours(23, 59, 59, 999)); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + // 获取总数 + const countStmt = db.prepare(`SELECT COUNT(*) as count FROM requests ${whereClause}`); + const { count: total } = countStmt.get(...params); + + // 获取分页数据 + const offset = (page - 1) * pageSize; + const dataStmt = db.prepare(` + SELECT * FROM requests ${whereClause} + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `); + const items = dataStmt.all(...params, pageSize, offset).map(row => ({ + ...row, + inputImages: row.input_images ? JSON.parse(row.input_images) : [], + responseMedia: row.response_media ? JSON.parse(row.response_media) : [], + isStreaming: row.is_streaming === 1 + })); + + return { items, total, page, pageSize }; +} + +/** + * 获取单条记录详情 + * @param {string} id - 记录 ID + * @returns {object|null} + */ +export function getDetail(id) { + const db = getDb(); + const stmt = db.prepare('SELECT * FROM requests WHERE id = ?'); + const row = stmt.get(id); + + if (!row) return null; + + return { + ...row, + inputImages: row.input_images ? JSON.parse(row.input_images) : [], + responseMedia: row.response_media ? JSON.parse(row.response_media) : [], + isStreaming: row.is_streaming === 1 + }; +} + +/** + * 删除记录 + * @param {string[]} ids - 要删除的记录 ID 数组 + * @returns {number} 删除的记录数 + */ +export async function deleteRecords(ids) { + if (!ids || ids.length === 0) return 0; + + const db = getDb(); + + // 先获取要删除记录的媒体文件 + const placeholders = ids.map(() => '?').join(','); + const selectStmt = db.prepare(`SELECT response_media FROM requests WHERE id IN (${placeholders})`); + const rows = selectStmt.all(...ids); + + // 删除关联的媒体文件 + for (const row of rows) { + if (row.response_media) { + try { + const media = JSON.parse(row.response_media); + for (const item of media) { + if (item.localPath) { + try { + await fs.unlink(item.localPath); + } catch (e) { + // 文件可能不存在,忽略 + } + } + } + } catch (e) { + // JSON 解析失败,忽略 + } + } + } + + // 删除数据库记录 + const deleteStmt = db.prepare(`DELETE FROM requests WHERE id IN (${placeholders})`); + const result = deleteStmt.run(...ids); + + return result.changes; +} + +/** + * 按日期范围删除记录 + * @param {string} startDate - 开始日期 (YYYY-MM-DD) + * @param {string} endDate - 结束日期 (YYYY-MM-DD) + * @returns {number} 删除的记录数 + */ +export async function deleteByDateRange(startDate, endDate) { + const db = getDb(); + + const start = new Date(startDate).setHours(0, 0, 0, 0); + const end = new Date(endDate).setHours(23, 59, 59, 999); + + // 先获取要删除的记录 ID + const selectStmt = db.prepare('SELECT id FROM requests WHERE created_at >= ? AND created_at <= ?'); + const rows = selectStmt.all(start, end); + const ids = rows.map(r => r.id); + + return await deleteRecords(ids); +} + +/** + * 保存媒体文件到本地 + * @param {string} dataUri - data URI 格式的媒体数据 + * @param {string} requestId - 请求 ID + * @param {string|null} originalUrl - 原始下载 URL(用于重试) + * @returns {{type: string, originalUrl: string, localPath: string, status: string}} + */ +export async function saveMediaToFile(dataUri, requestId, originalUrl = null) { + const result = { + type: 'unknown', + originalUrl: originalUrl, // 保存原始 URL 用于重试 + localPath: null, + status: 'pending' + }; + + try { + // 解析 data URI + const match = dataUri.match(/^data:([^;]+);base64,(.+)$/); + if (!match) { + // 如果不是 data URI,可能是普通 URL + result.originalUrl = dataUri; + result.status = 'external'; + return result; + } + + const [, mimeType, base64Data] = match; + result.type = mimeType.startsWith('image/') ? 'image' : mimeType.startsWith('video/') ? 'video' : 'file'; + + // 确定文件扩展名 + const extMap = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'video/mp4': 'mp4', + 'video/webm': 'webm' + }; + const ext = extMap[mimeType] || mimeType.split('/')[1] || 'bin'; + + // 生成文件名 + const filename = `${requestId}_${Date.now()}.${ext}`; + const filePath = path.join(MEDIA_DIR, filename); + + // 写入文件 + const buffer = Buffer.from(base64Data, 'base64'); + await fs.writeFile(filePath, buffer); + + result.localPath = filePath; + result.status = 'downloaded'; + + logger.debug('历史记录', `媒体文件已保存: ${filename}`); + } catch (error) { + logger.error('历史记录', `保存媒体文件失败: ${error.message}`); + result.status = 'failed'; + } + + return result; +} + +/** + * 处理响应中的媒体内容 + * @param {object} result - 生成结果 {text, image, imageUrl, reasoning} + * @param {string} requestId - 请求 ID + * @returns {object[]} 媒体信息数组 + */ +export async function processResponseMedia(result, requestId) { + const media = []; + + // 处理直接返回的图片/视频(带原始 URL) + if (result.image) { + const mediaInfo = await saveMediaToFile(result.image, requestId, result.imageUrl || null); + media.push(mediaInfo); + } + + // 从 markdown 中提取图片(这些没有原始 URL) + if (result.text) { + const mdImagePattern = /!\[([^\]]*)\]\((data:[^)]+)\)/g; + let match; + while ((match = mdImagePattern.exec(result.text)) !== null) { + const dataUri = match[2]; + const mediaInfo = await saveMediaToFile(dataUri, requestId, null); + media.push(mediaInfo); + } + } + + return media; +} + +/** + * 重试下载失败的媒体 + * @param {string} id - 记录 ID + * @param {number} mediaIndex - 媒体索引 + * @param {Function} [downloadFn] - 可选的下载函数(使用浏览器上下文下载) + * @returns {{success: boolean, message: string}} + */ +export async function retryMediaDownload(id, mediaIndex, downloadFn = null) { + const record = getDetail(id); + if (!record) { + return { success: false, message: '记录不存在' }; + } + + const media = record.responseMedia; + if (!media || mediaIndex >= media.length) { + return { success: false, message: '媒体索引无效' }; + } + + const item = media[mediaIndex]; + if (item.status === 'downloaded' && item.localPath) { + // 检查文件是否存在 + try { + await fs.access(item.localPath); + return { success: true, message: '媒体已下载' }; + } catch { + // 文件不存在,继续重试 + } + } + + if (!item.originalUrl) { + return { success: false, message: '无原始 URL,无法重试下载' }; + } + + // 使用浏览器上下文下载(推荐)或简单 HTTP 下载(后备) + try { + let dataUri = null; + + if (downloadFn) { + // 使用浏览器上下文下载 + logger.info('历史记录', `使用浏览器下载: ${item.originalUrl}`); + const result = await downloadFn(item.originalUrl); + if (result.error) { + return { success: false, message: result.error }; + } + dataUri = result.image; + } else { + // 后备:简单 HTTP 下载(对于需要认证的 URL 可能会失败) + logger.info('历史记录', `使用 HTTP 下载: ${item.originalUrl}`); + const response = await fetch(item.originalUrl, { + timeout: 60000, + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + } + }); + + if (!response.ok) { + return { success: false, message: `下载失败: HTTP ${response.status}(可能需要认证)` }; + } + + const buffer = Buffer.from(await response.arrayBuffer()); + const contentType = response.headers.get('content-type') || 'application/octet-stream'; + const mimeType = contentType.split(';')[0].trim(); + dataUri = `data:${mimeType};base64,${buffer.toString('base64')}`; + } + + // 保存到文件 + const saved = await saveMediaToFile(dataUri, id, item.originalUrl); + + if (saved.status === 'downloaded') { + // 更新记录 + media[mediaIndex] = { + ...item, + ...saved + }; + updateRecord(id, { responseMedia: media }); + + logger.info('历史记录', `媒体重试下载成功: ${saved.localPath}`); + return { success: true, message: '下载成功' }; + } else { + return { success: false, message: '保存文件失败' }; + } + + } catch (error) { + logger.error('历史记录', `媒体重试下载失败: ${error.message}`); + return { success: false, message: `下载失败: ${error.message}` }; + } +} + +/** + * 获取统计摘要 + * @param {object} filters - 筛选条件 + * @returns {{total: number, success: number, failed: number, avgDuration: number}} + */ +export function getStats(filters = {}) { + const db = getDb(); + + const conditions = []; + const params = []; + + if (filters.startDate) { + conditions.push('created_at >= ?'); + params.push(new Date(filters.startDate).setHours(0, 0, 0, 0)); + } + if (filters.endDate) { + conditions.push('created_at <= ?'); + params.push(new Date(filters.endDate).setHours(23, 59, 59, 999)); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + const stmt = db.prepare(` + SELECT + COUNT(*) as total, + SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success, + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed, + AVG(CASE WHEN duration_ms IS NOT NULL THEN duration_ms ELSE NULL END) as avgDuration + FROM requests ${whereClause} + `); + + const result = stmt.get(...params); + return { + total: result.total || 0, + success: result.success || 0, + failed: result.failed || 0, + avgDuration: Math.round(result.avgDuration || 0) + }; +} + +/** + * 获取可用的模型列表(从历史记录中) + * @returns {string[]} + */ +export function getModelList() { + const db = getDb(); + const stmt = db.prepare('SELECT DISTINCT model_id FROM requests WHERE model_id IS NOT NULL ORDER BY model_id'); + return stmt.all().map(r => r.model_id); +} + +/** + * 获取媒体目录路径 + * @returns {string} + */ +export function getMediaDir() { + return MEDIA_DIR; +} diff --git a/webui/src/App.vue b/webui/src/App.vue index 7993d52..c7bc5a7 100644 --- a/webui/src/App.vue +++ b/webui/src/App.vue @@ -13,7 +13,10 @@ import { CloseCircleOutlined, LoadingOutlined, InboxOutlined, - PictureOutlined + PictureOutlined, + HistoryOutlined, + RocketOutlined, + MenuOutlined } from '@ant-design/icons-vue'; import { useSettingsStore } from '@/stores/settings'; import LoginModal from '@/components/auth/LoginModal.vue'; @@ -23,6 +26,7 @@ const settingsStore = useSettingsStore(); const selectedKeys = ref(['dash']); const collapsed = ref(false); +const isMobile = ref(false); const loginVisible = ref(false); const iconLoading = ref(false); @@ -292,6 +296,7 @@ const openApiTestDrawer = () => { // 菜单 key 到路由路径的映射 const menuRoutes = { 'dash': '/', + 'history': '/tools/request', 'settings-server': '/settings/server', 'settings-workers': '/settings/workers', 'settings-browser': '/settings/browser', @@ -306,6 +311,7 @@ const handleMenuClick = ({ key }) => { const route = menuRoutes[key]; if (route) { router.push(route); + if (isMobile.value) collapsed.value = true; } }; @@ -344,7 +350,8 @@ async function checkConnection() { onMounted(async () => { // 响应式侧边栏 const checkScreenSize = () => { - if (window.innerWidth <= 768) { + isMobile.value = window.innerWidth <= 768; + if (isMobile.value) { collapsed.value = true; } }; @@ -391,33 +398,43 @@ onMounted(async () => { -