feat(lpt-ai):管理员面板 —— 请求日志记录与查看

新增文件:
- src/admin/store.ts:环形缓冲区(200条),文件持久化为 data/logs.json
- src/admin/routes.ts:admin API(列表/详情/清除)+ 自包含 HTML 面板
- src/admin/index.ts:统一导出

面板功能:
- GET /admin:绿白配色自包含面板,零外部依赖
- 统计栏:总请求/成功/失败/平均耗时
- 日志表格:时间、端点、状态、错误、耗时、模型
- 点击行展开查看完整请求体/响应体
- 端点筛选下拉、一键清除
- 响应式布局

修改文件:
- src/routes/ai.ts:两个 AI handler 均注入日志记录
  (记录时间戳、端点、模型、耗时、状态码、请求体、响应体、错误)
- src/index.ts:注册 adminRoutes

验证:
- npm run typecheck 通过
- npm test 4 个测试通过
This commit is contained in:
2026-07-04 12:32:07 +08:00
parent 29c8b0be4d
commit 5290fa4423
6 changed files with 379 additions and 2 deletions
+1
View File
@@ -4,5 +4,6 @@ dist/
.env.* .env.*
!.env.example !.env.example
*.log *.log
data/
.DS_Store .DS_Store
.idea/ .idea/
+2
View File
@@ -0,0 +1,2 @@
export { logStore, type LogEntry, type LogSummary } from "./store.js";
export { adminRoutes } from "./routes.js";
+209
View File
@@ -0,0 +1,209 @@
/**
* 管理员路由:日志查询 API + 自包含 HTML 面板。
*/
import type { FastifyInstance } from "fastify";
import { logStore } from "./store.js";
const PANEL_HTML = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>lpt-ai · 管理面板</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f5f8f5;color:#2e3a2e;min-height:100vh}
header{background:linear-gradient(135deg,#2e7d32,#43a047);color:#fff;padding:16px 24px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px}
header h1{font-size:20px;font-weight:600}
header .sub{font-size:13px;opacity:.85}
main{padding:14px 24px 40px;max-width:1400px;margin:0 auto}
.stats-bar{display:flex;gap:16px;flex-wrap:wrap;margin-bottom:16px}
.stat-card{flex:1;min-width:130px;background:#fff;border-radius:10px;padding:14px 16px;border:1px solid #dce8dc;box-shadow:0 1px 3px rgba(0,0,0,.04)}
.stat-card .label{font-size:11px;text-transform:uppercase;color:#6b8a6b;letter-spacing:.4px;margin-bottom:4px}
.stat-card .value{font-size:24px;font-weight:700}
.stat-card .value.ok{color:#2e7d32}.stat-card .value.err{color:#c62828}.stat-card .value.neutral{color:#546e7a}
.toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:14px}
.toolbar select,.toolbar button{padding:6px 14px;border-radius:6px;border:1px solid #c8d8c8;background:#fff;font-size:13px;cursor:pointer}
.toolbar button.danger{color:#c62828;border-color:#ef9a9a}
.toolbar button.danger:hover{background:#ffebee}
.toolbar button:hover{background:#f0f4f0}
.log-table{width:100%;border-collapse:collapse;background:#fff;border-radius:10px;overflow:hidden;border:1px solid #dce8dc;box-shadow:0 1px 3px rgba(0,0,0,.04)}
.log-table th,.log-table td{padding:10px 14px;text-align:left;font-size:13px;border-bottom:1px solid #eef3ee}
.log-table th{background:#f7faf7;color:#4a6a4a;font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:.3px}
.log-table tr:hover td{background:#f4faf5}
.log-table .row-expand{max-width:20px;text-align:center;cursor:pointer;user-select:none;color:#6b8a6b;font-weight:700}
.log-table .status-badge{padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600}
.status-ok{background:#e8f5e9;color:#2e7d32}.status-err{background:#ffebee;color:#c62828}
.detail-row td{padding:0;border-bottom:2px solid #c8e6c9}
.detail-card{margin:8px 14px 12px;padding:12px 16px;background:#fafdfa;border-radius:8px;border:1px solid #dce8dc;max-height:460px;overflow-y:auto}
.detail-card h4{font-size:13px;color:#4a6a4a;margin-bottom:6px}
.detail-card pre{white-space:pre-wrap;word-break:break-word;font-size:12px;line-height:1.6;color:#3a4a3a;background:#fff;padding:10px 12px;border-radius:6px;border:1px solid #e8ede8;max-height:200px;overflow-y:auto}
.detail-card .side{display:grid;grid-template-columns:1fr 1fr;gap:10px}
@media(max-width:700px){.detail-card .side{grid-template-columns:1fr}}
.empty{text-align:center;padding:48px 20px;color:#8a9a8a;font-size:14px}
.hidden{display:none}
</style>
</head>
<body>
<header>
<div><h1>lpt-ai 管理面板</h1><span class="sub">请求日志 &amp; 调试</span></div>
<span style="font-size:12px;opacity:.7" id="serverInfo"></span>
</header>
<main>
<div class="stats-bar" id="statsBar"></div>
<div class="toolbar">
<select id="filterEndpoint"><option value="">全部端点</option></select>
<button id="btnRefresh" onclick="loadLogs()">刷新</button>
<button class="danger" id="btnClear" onclick="clearLogs()">清除全部</button>
</div>
<table class="log-table" id="logTable"><tbody></tbody></table>
<div class="empty" id="empty">暂无请求记录</div>
</main>
<script>
const $ = (s) => document.querySelector(s);
const $$ = (s) => document.querySelectorAll(s);
async function fetchJSON(url, opts) {
const res = await fetch(url, opts);
if (!res.ok) throw new Error(res.status+' '+res.statusText);
return res.json();
}
async function loadLogs() {
const ep = $('#filterEndpoint').value;
const url = ep ? '/admin/logs?endpoint='+encodeURIComponent(ep) : '/admin/logs';
const { logs, stats } = await fetchJSON(url);
// stats
$('#statsBar').innerHTML = [
{ label:'总请求', value:stats.total, cls:'neutral' },
{ label:'成功', value:stats.ok, cls:'ok' },
{ label:'失败', value:stats.err, cls:'err' },
{ label:'平均耗时', value:stats.avgMs+'ms', cls:(stats.avgMs>60000?'err':'neutral') },
].map(s=>'<div class="stat-card"><div class="label">'+s.label+'</div><div class="value '+s.cls+'">'+s.value+'</div></div>').join('');
// endpoints dropdown
const sel = $('#filterEndpoint');
const cur = sel.value;
sel.innerHTML = '<option value="">全部端点</option>' + stats.endpoints.map(e=>'<option value="'+e+'"'+(e===cur?' selected':'')+'>'+e+'</option>').join('');
sel.value = cur;
// table
const tbody = $('#logTable tbody');
if (logs.length === 0) {
tbody.innerHTML = '';
$('#empty').classList.remove('hidden');
} else {
$('#empty').classList.add('hidden');
tbody.innerHTML = logs.map(l => {
const statusCls = l.status >= 200 && l.status < 300 ? 'status-ok' : 'status-err';
return '<tr class="log-row" data-id="'+l.id+'">'+
'<td class="row-expand" onclick="toggleDetail('+l.id+',this)">▸</td>'+
'<td>'+escapeHtml(l.timestamp.replace('T',' ').slice(0,19))+'</td>'+
'<td>'+escapeHtml(l.endpoint)+'</td>'+
'<td><span class="status-badge '+statusCls+'">'+l.status+'</span></td>'+
'<td>'+(l.error ? '<span style="color:#c62828;font-size:12px">'+escapeHtml(l.error.slice(0,40))+'</span>' : '—')+'</td>'+
'<td>'+l.durationMs+'ms</td>'+
'<td style="font-size:12px;color:#6b8a6b">'+escapeHtml(l.model)+'</td>'+
'</tr><tr class="detail-row hidden" id="detail-'+l.id+'"><td colspan="7"><div class="detail-card"><span style="color:#8a9a8a;font-size:13px">加载中…</span></div></td></tr>';
}).join('');
}
}
let loadedDetails = {};
async function toggleDetail(id, el) {
const row = $('#detail-'+id);
if (!row.classList.contains('hidden')) {
row.classList.add('hidden');
el.textContent = '▸';
return;
}
row.classList.remove('hidden');
el.textContent = '▾';
if (loadedDetails[id]) return;
const log = await fetchJSON('/admin/logs/'+id);
loadedDetails[id] = true;
const reqStr = JSON.stringify(log.requestBody ?? {}, null, 2);
const respStr = typeof log.responseBody === 'string' ? log.responseBody : JSON.stringify(log.responseBody ?? {}, null, 2);
let html = '<div class="side">';
html += '<div><h4>📤 请求体 ('+escapeHtml(log.endpoint)+')</h4><pre>'+escapeHtml(reqStr)+'</pre></div>';
html += '<div><h4>📥 响应体</h4><pre>'+(log.error ? '<span style="color:#c62828">'+escapeHtml(log.error)+'</span><br><br>' : '')+escapeHtml(respStr)+'</pre></div>';
html += '</div>';
row.querySelector('.detail-card').innerHTML = html;
}
async function clearLogs() {
if (!confirm('确定清除全部请求日志?')) return;
await fetch('/admin/logs', { method:'DELETE' });
loadedDetails = {};
await loadLogs();
}
function escapeHtml(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
$('#filterEndpoint').addEventListener('change', () => { loadedDetails = {}; loadLogs(); });
async function init() {
try {
const h = await fetchJSON('/health');
$('#serverInfo').textContent = '模型: '+h.model+' | API: '+(h.llmAvailable?'已配置':'未配置');
} catch(e) {}
await loadLogs();
}
init();
</script>
</body>
</html>`;
export async function adminRoutes(app: FastifyInstance): Promise<void> {
// —— 面板页面 ——
app.get("/admin", async (_request, reply) => {
return reply.type("text/html; charset=utf-8").send(PANEL_HTML);
});
// —— 日志列表 ——
app.get<{ Querystring: { endpoint?: string } }>("/admin/logs", async (request) => {
const endpoint = request.query.endpoint || undefined;
const summaries = logStore.list(endpoint);
const stats = {
total: summaries.length,
ok: summaries.filter((s) => s.status >= 200 && s.status < 300).length,
err: summaries.filter((s) => s.status < 200 || s.status >= 300).length,
avgMs: summaries.length > 0
? Math.round(summaries.reduce((a, s) => a + s.durationMs, 0) / summaries.length)
: 0,
endpoints: logStore.endpoints(),
};
return { logs: summaries, stats };
});
// —— 日志详情 ——
app.get<{ Params: { id: string } }>("/admin/logs/:id", async (request, reply) => {
const id = parseInt(request.params.id, 10);
if (isNaN(id)) {
return reply.status(400).send({ error: "无效 ID" });
}
const entry = logStore.get(id);
if (!entry) {
return reply.status(404).send({ error: "日志不存在" });
}
return entry;
});
// —— 清空日志 ——
app.delete("/admin/logs", async () => {
logStore.clear();
return { ok: true };
});
}
+124
View File
@@ -0,0 +1,124 @@
/**
* 请求日志存储 —— 环形缓冲区 + 可选文件持久化。
*
* 每条日志记录 AI 路由的一次完整请求/响应,供 /admin 面板查看。
*/
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
// ---- 类型 ----
export interface LogEntry {
id: number;
timestamp: string; // ISO 8601
endpoint: string; // 如 "/ai/aggregate-report"
model: string;
durationMs: number;
status: number; // HTTP status code
requestBody: unknown;
responseBody: unknown;
error?: string;
}
export interface LogSummary {
id: number;
timestamp: string;
endpoint: string;
model: string;
durationMs: number;
status: number;
error?: string;
}
// ---- 存储实现 ----
const MAX_ENTRIES = 200;
const DATA_DIR = resolve(process.cwd(), "data");
const LOG_FILE = resolve(DATA_DIR, "logs.json");
class LogStore {
private entries: LogEntry[] = [];
private nextId = 1;
private dirty = false;
constructor() {
this.load();
}
/** 追加一条日志,超出上限时移除最旧的 */
add(entry: Omit<LogEntry, "id">): LogEntry {
const full: LogEntry = { id: this.nextId++, ...entry };
this.entries.push(full);
while (this.entries.length > MAX_ENTRIES) {
this.entries.shift();
}
this.dirty = true;
this.flush(); // fire-and-forget
return full;
}
/** 列出摘要(不含请求/响应体,减少面板传输量) */
list(endpoint?: string): LogSummary[] {
let result = [...this.entries];
if (endpoint) {
result = result.filter((e) => e.endpoint === endpoint);
}
// 倒序:最新在前
result.reverse();
return result.map(({ id, timestamp, endpoint, model, durationMs, status, error }) => ({
id, timestamp, endpoint, model, durationMs, status, error,
}));
}
/** 获取单条完整日志 */
get(id: number): LogEntry | undefined {
return this.entries.find((e) => e.id === id);
}
/** 清空 */
clear(): void {
this.entries = [];
this.dirty = true;
this.flush();
}
/** 端点列表(用于筛选下拉) */
endpoints(): string[] {
return [...new Set(this.entries.map((e) => e.endpoint))].sort();
}
// ---- 持久化 ----
private load(): void {
try {
if (existsSync(LOG_FILE)) {
const raw = readFileSync(LOG_FILE, "utf-8");
const data = JSON.parse(raw) as { nextId: number; entries: LogEntry[] };
this.nextId = data.nextId ?? 1;
this.entries = data.entries ?? [];
// 裁剪
while (this.entries.length > MAX_ENTRIES) {
this.entries.shift();
}
}
} catch {
// 文件损坏或不存在,使用空存储
}
}
private flush(): void {
if (!this.dirty) return;
this.dirty = false;
try {
if (!existsSync(DATA_DIR)) {
mkdirSync(DATA_DIR, { recursive: true });
}
writeFileSync(LOG_FILE, JSON.stringify({ nextId: this.nextId, entries: this.entries }), "utf-8");
} catch {
// 写入失败不阻塞服务
}
}
}
export const logStore = new LogStore();
+2
View File
@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
import Fastify from "fastify"; import Fastify from "fastify";
import { aiRoutes } from "./routes/ai.js"; import { aiRoutes } from "./routes/ai.js";
import { adminRoutes } from "./admin/index.js";
// ===== 加载 .env(零依赖,兼容 dev/start 两种启动方式)===== // ===== 加载 .env(零依赖,兼容 dev/start 两种启动方式)=====
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -29,6 +30,7 @@ const app = Fastify({
}); });
await app.register(aiRoutes); await app.register(aiRoutes);
await app.register(adminRoutes);
const port = Number(process.env.PORT ?? 5199); const port = Number(process.env.PORT ?? 5199);
+41 -2
View File
@@ -5,6 +5,7 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { chat, isAvailable, loadConfig, LlmError } from "../llm/client.js"; import { chat, isAvailable, loadConfig, LlmError } from "../llm/client.js";
import { aggregateReportPrompt, generateMindMapPrompt, type FragmentInput } from "../llm/prompts.js"; import { aggregateReportPrompt, generateMindMapPrompt, type FragmentInput } from "../llm/prompts.js";
import { logStore } from "../admin/store.js";
interface AggregateReportBody { interface AggregateReportBody {
taskName: string; taskName: string;
@@ -33,16 +34,35 @@ export async function aiRoutes(app: FastifyInstance): Promise<void> {
*/ */
app.post<{ Body: AggregateReportBody }>("/ai/aggregate-report", async (request, reply) => { app.post<{ Body: AggregateReportBody }>("/ai/aggregate-report", async (request, reply) => {
const { taskName, fragments, expectation } = request.body ?? ({} as AggregateReportBody); const { taskName, fragments, expectation } = request.body ?? ({} as AggregateReportBody);
const t0 = Date.now();
if (!taskName || !Array.isArray(fragments) || fragments.length === 0) { if (!taskName || !Array.isArray(fragments) || fragments.length === 0) {
logStore.add({
timestamp: new Date().toISOString(), endpoint: "/ai/aggregate-report",
model: config.model, durationMs: Date.now() - t0, status: 400,
requestBody: request.body ?? {}, responseBody: { error: "taskName 与 fragments 不可为空" },
});
return reply.status(400).send({ error: "taskName 与 fragments 不可为空" }); return reply.status(400).send({ error: "taskName 与 fragments 不可为空" });
} }
try { try {
const messages = aggregateReportPrompt(taskName, fragments, expectation); const messages = aggregateReportPrompt(taskName, fragments, expectation);
const report = await chat(config, messages); const report = await chat(config, messages);
return { report: report.trim(), model: config.model }; const resp = { report: report.trim(), model: config.model };
logStore.add({
timestamp: new Date().toISOString(), endpoint: "/ai/aggregate-report",
model: config.model, durationMs: Date.now() - t0, status: 200,
requestBody: request.body ?? {}, responseBody: resp,
});
return resp;
} catch (err) { } catch (err) {
const status = err instanceof LlmError ? err.statusCode : 500;
const errMsg = err instanceof Error ? err.message : String(err);
logStore.add({
timestamp: new Date().toISOString(), endpoint: "/ai/aggregate-report",
model: config.model, durationMs: Date.now() - t0, status,
requestBody: request.body ?? {}, responseBody: {}, error: errMsg,
});
if (err instanceof LlmError) { if (err instanceof LlmError) {
return reply.status(err.statusCode).send({ error: err.message }); return reply.status(err.statusCode).send({ error: err.message });
} }
@@ -56,8 +76,14 @@ export async function aiRoutes(app: FastifyInstance): Promise<void> {
app.post<{ Body: GenerateMindMapBody }>("/ai/generate-mind-map", async (request, reply) => { app.post<{ Body: GenerateMindMapBody }>("/ai/generate-mind-map", async (request, reply) => {
const { taskName, taskDescription, reports, fragments } = const { taskName, taskDescription, reports, fragments } =
request.body ?? ({} as GenerateMindMapBody); request.body ?? ({} as GenerateMindMapBody);
const t0 = Date.now();
if (!taskName || ((reports?.length ?? 0) === 0 && (fragments?.length ?? 0) === 0)) { if (!taskName || ((reports?.length ?? 0) === 0 && (fragments?.length ?? 0) === 0)) {
logStore.add({
timestamp: new Date().toISOString(), endpoint: "/ai/generate-mind-map",
model: config.model, durationMs: Date.now() - t0, status: 400,
requestBody: request.body ?? {}, responseBody: { error: "taskName 不可为空且 reports/fragments 至少一项非空" },
});
return reply.status(400).send({ error: "taskName 不可为空且 reports/fragments 至少一项非空" }); return reply.status(400).send({ error: "taskName 不可为空且 reports/fragments 至少一项非空" });
} }
@@ -69,8 +95,21 @@ export async function aiRoutes(app: FastifyInstance): Promise<void> {
fragments ?? [], fragments ?? [],
); );
const outline = await chat(config, messages, { maxTokens: 4096 }); const outline = await chat(config, messages, { maxTokens: 4096 });
return { outline: outline.trim(), model: config.model }; const resp = { outline: outline.trim(), model: config.model };
logStore.add({
timestamp: new Date().toISOString(), endpoint: "/ai/generate-mind-map",
model: config.model, durationMs: Date.now() - t0, status: 200,
requestBody: request.body ?? {}, responseBody: resp,
});
return resp;
} catch (err) { } catch (err) {
const status = err instanceof LlmError ? err.statusCode : 500;
const errMsg = err instanceof Error ? err.message : String(err);
logStore.add({
timestamp: new Date().toISOString(), endpoint: "/ai/generate-mind-map",
model: config.model, durationMs: Date.now() - t0, status,
requestBody: request.body ?? {}, responseBody: {}, error: errMsg,
});
if (err instanceof LlmError) { if (err instanceof LlmError) {
return reply.status(err.statusCode).send({ error: err.message }); return reply.status(err.statusCode).send({ error: err.message });
} }