mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
feat(webui): 请求历史记录与请求模型页面
后端: - 新增 SQLite 存储请求/响应历史 (src/utils/history.js) - Admin API 历史查询、删除、媒体重试等端点 - PoolManager.downloadMedia 用于媒体下载 WebUI: - 请求历史页面:筛选、分页、详情、媒体预览 - 请求历史升级为请求模型页面,支持直接发送请求 - 移动端适配与体验优化 - 批量代理设置与删除实例 - 实例配置编辑删除改用 name 匹配
This commit is contained in:
@@ -313,6 +313,30 @@ export class PoolManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Worker 的浏览器上下文下载媒体
|
||||
* @param {string} url - 媒体 URL
|
||||
* @returns {Promise<{image?: string, error?: string}>}
|
||||
*/
|
||||
async downloadMedia(url) {
|
||||
// 找一个空闲的 worker
|
||||
let worker = this.workers.find(w => w.page && w.busyCount === 0);
|
||||
if (!worker) {
|
||||
// 如果没有空闲的,用第一个有 page 的
|
||||
worker = this.workers.find(w => w.page);
|
||||
}
|
||||
if (!worker || !worker.page) {
|
||||
return { error: '没有可用的浏览器实例' };
|
||||
}
|
||||
|
||||
try {
|
||||
const { useContextDownload } = await import('../utils/download.js');
|
||||
return await useContextDownload(url, worker.page);
|
||||
} catch (e) {
|
||||
return { error: `下载失败: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取第一个 Worker 的 page
|
||||
*/
|
||||
|
||||
@@ -36,6 +36,18 @@ 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';
|
||||
|
||||
/**
|
||||
* 读取请求体
|
||||
@@ -458,6 +470,143 @@ 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;
|
||||
|
||||
// 使用 queueManager 的浏览器下载(如果可用)
|
||||
let downloadFn = null;
|
||||
try {
|
||||
if (queueManager && queueManager.downloadMedia) {
|
||||
downloadFn = (url) => queueManager.downloadMedia(url);
|
||||
}
|
||||
} catch { /* queueManager 不可用,使用后备方案 */ }
|
||||
|
||||
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' }));
|
||||
|
||||
+85
-12
@@ -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,
|
||||
@@ -291,6 +351,18 @@ export function createQueueManager(queueConfig, callbacks) {
|
||||
return await getCookies(workerName, domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Worker 的浏览器下载媒体
|
||||
* @param {string} url - 媒体 URL
|
||||
* @returns {Promise<{image?: string, imageUrl?: string, error?: string}>}
|
||||
*/
|
||||
async function downloadMedia(url) {
|
||||
if (!poolContext || !poolContext.downloadMedia) {
|
||||
throw new Error('Pool 未初始化或不支持 downloadMedia');
|
||||
}
|
||||
return await poolContext.downloadMedia(url);
|
||||
}
|
||||
|
||||
return {
|
||||
addTask,
|
||||
getStatus,
|
||||
@@ -298,6 +370,7 @@ export function createQueueManager(queueConfig, callbacks) {
|
||||
canAcceptNonStreaming,
|
||||
initializePool,
|
||||
getPoolContext,
|
||||
getWorkerCookies
|
||||
getWorkerCookies,
|
||||
downloadMedia
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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('服务器', '登录模式已就绪,请在浏览器中完成登录操作');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user