From 5290fa4423ef0d9dab850875d9a07ebd2f53e51d Mon Sep 17 00:00:00 2001 From: cat_shark <1716967236@qq.com> Date: Sat, 4 Jul 2026 12:32:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(lpt-ai)=EF=BC=9A=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E9=9D=A2=E6=9D=BF=20=E2=80=94=E2=80=94=20=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E6=97=A5=E5=BF=97=E8=AE=B0=E5=BD=95=E4=B8=8E=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增文件: - 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 个测试通过 --- .gitignore | 1 + src/admin/index.ts | 2 + src/admin/routes.ts | 209 ++++++++++++++++++++++++++++++++++++++++++++ src/admin/store.ts | 124 ++++++++++++++++++++++++++ src/index.ts | 2 + src/routes/ai.ts | 43 ++++++++- 6 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 src/admin/index.ts create mode 100644 src/admin/routes.ts create mode 100644 src/admin/store.ts diff --git a/.gitignore b/.gitignore index 893cde7..ad850b0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ dist/ .env.* !.env.example *.log +data/ .DS_Store .idea/ diff --git a/src/admin/index.ts b/src/admin/index.ts new file mode 100644 index 0000000..66bb516 --- /dev/null +++ b/src/admin/index.ts @@ -0,0 +1,2 @@ +export { logStore, type LogEntry, type LogSummary } from "./store.js"; +export { adminRoutes } from "./routes.js"; diff --git a/src/admin/routes.ts b/src/admin/routes.ts new file mode 100644 index 0000000..6b16d0c --- /dev/null +++ b/src/admin/routes.ts @@ -0,0 +1,209 @@ +/** + * 管理员路由:日志查询 API + 自包含 HTML 面板。 + */ + +import type { FastifyInstance } from "fastify"; +import { logStore } from "./store.js"; + +const PANEL_HTML = ` + + + + +lpt-ai · 管理面板 + + + + +
+

lpt-ai 管理面板

请求日志 & 调试
+ +
+ +
+
+ +
+ + + +
+ +
+
暂无请求记录
+
+ + + +`; + +export async function adminRoutes(app: FastifyInstance): Promise { + + // —— 面板页面 —— + 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 }; + }); +} diff --git a/src/admin/store.ts b/src/admin/store.ts new file mode 100644 index 0000000..dbab103 --- /dev/null +++ b/src/admin/store.ts @@ -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 { + 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(); diff --git a/src/index.ts b/src/index.ts index 938aa64..5dda5ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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); diff --git a/src/routes/ai.ts b/src/routes/ai.ts index 47cf164..6466040 100644 --- a/src/routes/ai.ts +++ b/src/routes/ai.ts @@ -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 { */ 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 { 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 { 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 }); }