feat(webui): 请求历史记录与请求模型页面

后端:
- 新增 SQLite 存储请求/响应历史 (src/utils/history.js)
- Admin API 历史查询、删除、媒体重试等端点
- PoolManager.downloadMedia 用于媒体下载

WebUI:
- 请求历史页面:筛选、分页、详情、媒体预览
- 请求历史升级为请求模型页面,支持直接发送请求
- 移动端适配与体验优化
- 批量代理设置与删除实例
- 实例配置编辑删除改用 name 匹配
This commit is contained in:
daidai
2026-03-27 19:46:16 +08:00
Unverified
parent c3163729f4
commit 50b09ed740
10 changed files with 2430 additions and 94 deletions
+24
View File
@@ -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
*/
+149
View File
@@ -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
View File
@@ -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,
@@ -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
};
}
+8
View File
@@ -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('服务器', '登录模式已就绪,请在浏览器中完成登录操作');
+508
View File
@@ -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;
}