import Fastify from "fastify"; import multipart from "@fastify/multipart"; import cors from "@fastify/cors"; import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { AdsMonitor } from "./monitor.js"; import { MessageInputSchema, ModelConnectionInputSchema, } from "@agent-studio/shared"; import { SessionRegistry, SkillCatalog, validatedPpt } from "@agent-studio/harness"; const root = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "../../../", ); export async function buildApp() { const app = Fastify({ logger: { redact: ["req.headers.authorization", "req.body.apiKey"] }, bodyLimit: 6 * 1024 * 1024, genReqId: () => `req_${randomUUID()}`, }); await app.register(cors, { origin: true }); await app.register(multipart, { limits: { fileSize: Number(process.env.MAX_IMAGE_BYTES) || 5 * 1024 * 1024, files: 1, }, }); const registry = new SessionRegistry(root); const monitor = new AdsMonitor(path.join(root,"workspace/monitoring")); await monitor.init(); registry.monitorSnapshot = () => ({ ...monitor.state, busy: monitor.busy }); registry.monitoringBusy = () => monitor.busy; monitor.canCheck = () => !registry.hasActiveRuns(); app.addHook("onClose",async()=>monitor.close()); app.get("/api/monitoring",async()=>({ ...monitor.state,busy:monitor.busy })); app.post("/api/monitoring/check",async()=>monitor.check()); app.put("/api/monitoring/settings",async req=>monitor.configure(req.body)); app.post("/api/monitoring/handled",async req=>{ const key=(req.body as {key?:unknown})?.key; if(typeof key!=="string") throw new Error("INVALID_ALERT_KEY"); return monitor.handle(key); }); await registry.restore(); const skills = new SkillCatalog(root); await skills.reload(); app.decorate("registry", registry); app.get("/health", async (req) => ({ ok: true, requestId: req.id })); app.get("/api/skills", async (req) => ({ skills: await skills.reload(), requestId: req.id, })); app.post("/api/sessions", async (req, reply) => { const resumeId = (req.body as { resumeSessionId?: unknown } | undefined)?.resumeSessionId; const s = typeof resumeId === "string" ? registry.get(resumeId) ?? await registry.create() : await registry.create(); return reply.code(201).send({ sessionId: s.sessionId, requestId: req.id }); }); app.delete("/api/sessions/:id", async (req, reply) => { await registry.delete((req.params as { id: string }).id); return reply.send({ ok: true, requestId: req.id }); }); app.get("/api/sessions/:id/model-config", async (req) => { const id = (req.params as { id: string }).id; registry.require(id); return { config: registry.models.getRedacted(id) ?? { configured: false, source: "missing", }, requestId: req.id, }; }); app.put("/api/sessions/:id/model-config", async (req, reply) => { const id = (req.params as { id: string }).id; const s = registry.require(id); if (s.busy) throw Object.assign(new Error("SESSION_BUSY"), { statusCode: 409 }); const parsed = ModelConnectionInputSchema.parse(req.body); const environmentKey = parsed.api === "anthropic-messages" ? process.env.ANTHROPIC_API_KEY : process.env.OPENAI_API_KEY; const apiKey = parsed.apiKey?.trim() || environmentKey?.trim(); if (!apiKey) return reply .code(400) .send( error( "API_KEY_REQUIRED", `请填写 ${parsed.api === "anthropic-messages" ? "Anthropic" : "OpenAI"} API Key`, req.id, ), ); const input = { ...parsed, apiKey }; registry.resetAgent(id); const config = await registry.models.configure(id, input); registry.events.append(id, { type: "model.configured", payload: { ...config }, }); return reply.send({ config, requestId: req.id }); }); app.delete("/api/sessions/:id/model-config/credentials", async (req) => { const id = (req.params as { id: string }).id; registry.require(id); registry.models.clearCredentials(id); return { ok: true, requestId: req.id }; }); app.post("/api/sessions/:id/model-config/test", async (req) => { registry.require((req.params as { id: string }).id); return { status: "deferred", message: "配置已保存,将在首次运行时验证", requestId: req.id, }; }); app.post("/api/sessions/:id/attachments", async (req, reply) => { const id = (req.params as { id: string }).id; const s = registry.require(id); const part = await req.file(); if (!part) throw new Error("UPLOAD_REQUIRED"); const data = await part.toBuffer(); const item = await registry.attachments.add( id, s.workspace.root, part.filename, part.mimetype, data, ); registry.events.append(id, { type: "attachment.uploaded", payload: { ...item }, }); return reply.code(201).send({ ...item, requestId: req.id }); }); app.delete("/api/sessions/:id/attachments/:att", async (req) => { const p = req.params as { id: string; att: string }; registry.require(p.id); await registry.attachments.remove(p.id, p.att); return { ok: true, requestId: req.id }; }); app.get("/api/sessions/:id/reports/:kind", async (req, reply) => { const params = req.params as { id: string; kind: string }; const session = registry.require(params.id); if (params.kind === "document") { const body = await readFile(session.workspace.resolve("report.md")); return reply.header("cache-control", "no-store") .header("content-disposition", 'attachment; filename="report.md"') .type("text/markdown; charset=utf-8").send(body); } if (params.kind !== "daily" && params.kind !== "weekly") throw Object.assign(new Error("REPORT_KIND_NOT_FOUND"), { statusCode: 404 }); const file = session.workspace.resolve(`swads-${params.kind}-report.png`); const png = await readFile(file); return reply.header("cache-control", "no-store").type("image/png").send(png); }); app.post("/api/sessions/:id/messages", async (req, reply) => { const id = (req.params as { id: string }).id; const s = registry.require(id); if (s.busy) return reply .code(409) .send(error("SESSION_BUSY", "Session is busy", req.id)); const input = MessageInputSchema.parse(req.body); const config = registry.models.get(id); if (!config) return reply .code(400) .send(error("MODEL_NOT_CONFIGURED", "Model is not configured", req.id)); if (input.attachmentIds.length && !config.supportsImages) return reply .code(422) .send( error( "MODEL_DOES_NOT_SUPPORT_IMAGES", "Selected model does not support images", req.id, ), ); const attachmentMeta = input.attachmentIds.map((attachmentId) => { const meta = registry.attachments.get(id, attachmentId); if (!meta) throw new Error("ATTACHMENT_NOT_FOUND"); return meta; }); const images = await Promise.all( attachmentMeta.map(async (meta) => { return { type: "image" as const, mimeType: meta.mediaType, data: (await registry.attachments.content(id, meta.attachmentId)).toString( "base64", ), }; }), ); const localFiles = attachmentMeta.map( (meta) => `attachments/${meta.attachmentId}.${meta.mediaType.split("/")[1]}`, ); const promptText = localFiles.length ? `${input.text}\n\nLocal attachment paths in this Session workspace:\n${localFiles.map((file) => `- ${file}`).join("\n")}` : input.text; const runId = await registry.run(s, promptText, images); return reply.code(202).send({ runId, requestId: req.id }); }); app.get("/api/sessions/:id/presentations/:artifact", async (req, reply) => { const { id, artifact } = req.params as { id: string; artifact: string }; const bytes = await validatedPpt(registry.require(id).workspace, artifact); return reply.header("cache-control", "no-store") .header("content-disposition", 'attachment; filename="beryl-report.pptx"') .type("application/vnd.openxmlformats-officedocument.presentationml.presentation").send(bytes); }); app.post("/api/sessions/:id/cancel", async (req) => { await registry.cancel((req.params as { id: string }).id); return { ok: true, requestId: req.id }; }); app.post("/api/approvals/:id/approve", async (req) => ({ approval: registry.approvals.resolve( (req.params as { id: string }).id, "approved", ), requestId: req.id, })); app.post("/api/approvals/:id/deny", async (req) => ({ approval: registry.approvals.resolve( (req.params as { id: string }).id, "denied", ), requestId: req.id, })); app.get("/api/sessions/:id/events", async (req, reply) => { const id = (req.params as { id: string }).id; registry.require(id); const query = req.query as { afterSequence?: string }; const last = typeof req.headers["last-event-id"] === "string" ? req.headers["last-event-id"] : undefined; const previous = registry.events.listAfter(id, 0); const byId = last ? previous.find((e) => e.eventId === last)?.sequence : undefined; const cursor = Number(query.afterSequence ?? byId ?? 0); reply.raw.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive", "x-accel-buffering": "no", }); const send = (e: ReturnType) => reply.raw.write( `id: ${e.eventId}\nevent: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`, ); registry.events.listAfter(id, cursor).forEach(send); const unsub = registry.events.subscribe(id, send); const heartbeat = setInterval( () => reply.raw.write(": heartbeat\n\n"), 15000, ); req.raw.on("close", () => { clearInterval(heartbeat); unsub(); }); return reply; }); app.setErrorHandler((err, req, reply) => { const failure = err as Error & { statusCode?: number; issues?: unknown }; const code = failure.name === "ZodError" ? "VALIDATION_ERROR" : failure.message || "INTERNAL_ERROR"; const status = failure.statusCode ?? (code.includes("NOT_FOUND") ? 404 : code.includes("TOO_LARGE") ? 413 : code.includes("PRIVATE") || code.includes("UNSAFE") ? 400 : 500); reply .code(status) .send( error( code, status === 500 ? "Internal server error" : failure.message, req.id, failure.name === "ZodError" ? { issues: failure.issues } : {}, ), ); }); app.addHook("onClose", async () => { for (const id of [] as string[]) await registry.delete(id); }); return app; } function error( code: string, message: string, requestId: string, details: Record = {}, ) { return { error: { code, message, requestId, details } }; }