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 = ``;
- } 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 () => {