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:
@@ -4,5 +4,6 @@ dist/
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
data/
|
||||
.DS_Store
|
||||
.idea/
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { logStore, type LogEntry, type LogSummary } from "./store.js";
|
||||
export { adminRoutes } from "./routes.js";
|
||||
@@ -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">请求日志 & 调试</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
$('#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 };
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import Fastify from "fastify";
|
||||
import { aiRoutes } from "./routes/ai.js";
|
||||
import { adminRoutes } from "./admin/index.js";
|
||||
|
||||
// ===== 加载 .env(零依赖,兼容 dev/start 两种启动方式)=====
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -29,6 +30,7 @@ const app = Fastify({
|
||||
});
|
||||
|
||||
await app.register(aiRoutes);
|
||||
await app.register(adminRoutes);
|
||||
|
||||
const port = Number(process.env.PORT ?? 5199);
|
||||
|
||||
|
||||
+41
-2
@@ -5,6 +5,7 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { chat, isAvailable, loadConfig, LlmError } from "../llm/client.js";
|
||||
import { aggregateReportPrompt, generateMindMapPrompt, type FragmentInput } from "../llm/prompts.js";
|
||||
import { logStore } from "../admin/store.js";
|
||||
|
||||
interface AggregateReportBody {
|
||||
taskName: string;
|
||||
@@ -33,16 +34,35 @@ export async function aiRoutes(app: FastifyInstance): Promise<void> {
|
||||
*/
|
||||
app.post<{ Body: AggregateReportBody }>("/ai/aggregate-report", async (request, reply) => {
|
||||
const { taskName, fragments, expectation } = request.body ?? ({} as AggregateReportBody);
|
||||
const t0 = Date.now();
|
||||
|
||||
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 不可为空" });
|
||||
}
|
||||
|
||||
try {
|
||||
const messages = aggregateReportPrompt(taskName, fragments, expectation);
|
||||
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) {
|
||||
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) {
|
||||
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) => {
|
||||
const { taskName, taskDescription, reports, fragments } =
|
||||
request.body ?? ({} as GenerateMindMapBody);
|
||||
const t0 = Date.now();
|
||||
|
||||
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 至少一项非空" });
|
||||
}
|
||||
|
||||
@@ -69,8 +95,21 @@ export async function aiRoutes(app: FastifyInstance): Promise<void> {
|
||||
fragments ?? [],
|
||||
);
|
||||
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) {
|
||||
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) {
|
||||
return reply.status(err.statusCode).send({ error: err.message });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user