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 });
}