import { sanitizeTitle } from "./utils/text.js"; import { MAX_IMAGES_PER_REQUEST } from "./skills/params.js"; import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"; import cors from "@fastify/cors"; import { z } from "zod"; import { AgentError, type AgentService, type AgentStreamEvent } from "./agent/service.js"; import { AuthError, resolveIdentity } from "./auth/jwt.js"; import { PricingError, quoteSkill } from "./billing/pricing.js"; import type { UsageLedger } from "./billing/ledger.js"; import type { AppConfig } from "./config.js"; import { GatewayError } from "./gateway/client.js"; import type { Logger } from "./logger.js"; import type { SessionStoreLike } from "./sessions/store.js"; import type { SkillCatalog } from "./skills/catalog.js"; export class HttpError extends Error { constructor( readonly statusCode: number, message: string, ) { super(message); this.name = "HttpError"; } } export interface ServerDeps { config: AppConfig; logger: Logger; catalog: SkillCatalog; ledger: UsageLedger; store: SessionStoreLike; service: AgentService; } const createSessionSchema = z.object({ title: z.string().max(200).optional(), type: z.number().int().min(0).max(99).optional(), team_id: z.string().optional(), uid: z.string().optional(), }); const messageSchema = z.object({ message: z.string().min(1).max(20_000), direct: z.boolean().optional(), reference_images: z.array(z.url()).max(6).optional(), skill_id: z.string().min(1).optional(), industry: z.string().max(50).optional(), stream: z.boolean().optional(), team_id: z.string().optional(), uid: z.string().optional(), }); const runSchema = messageSchema.extend({ session_id: z.string().optional(), title: z.string().max(200).optional(), }); const quoteSchema = z.object({ skill_id: z.string().min(1), size: z.string().optional(), n: z.number().int().min(1).max(MAX_IMAGES_PER_REQUEST).optional(), }); const confirmationSchema = z.object({ approved: z.boolean(), content: z.string().max(20_000).optional(), reason: z.string().max(2000).optional(), team_id: z.string().optional(), uid: z.string().optional(), }); const listQuerySchema = z.object({ team_id: z.string().optional(), uid: z.string().optional(), limit: z.coerce.number().int().min(1).max(200).optional(), offset: z.coerce.number().int().min(0).optional(), }); const usageQuerySchema = listQuerySchema.extend({ skill_id: z.string().optional(), status: z.enum(["pending", "succeeded", "failed"]).optional(), }); export async function buildServer(deps: ServerDeps): Promise { const { config, logger, catalog, ledger, store, service } = deps; const app = Fastify({ logger: false, bodyLimit: 2 * 1024 * 1024 }); await app.register(cors, { origin: config.corsOrigin === "*" ? true : config.corsOrigin.split(",").map((item) => item.trim()), credentials: true, }); app.setErrorHandler((error, _request, reply) => { const status = error instanceof HttpError ? error.statusCode : mapErrorStatus(error); const message = error instanceof Error ? error.message : String(error); if (status >= 500) logger.error({ error }, "request failed"); else logger.warn({ error: message }, "request rejected"); void reply.status(status).send({ detail: message || "internal error" }); }); app.get("/health", async () => ({ ok: true, service: "laimiao-skill-agent", version: "0.1.0", node: process.version, llm_configured: service.isLlmConfigured(), billing_mode: config.billing.mode, currency: config.billing.currency, gateway_mock: config.gateway.mock, image_models: [...new Set(catalog.list().map((skill) => skill.manifest.model))], skills: catalog.list().length, skill_source: "mysql", skill_diagnostics: catalog.diagnostics(), })); app.get("/skills", async () => { await catalog.refresh(); return { skills: catalog.publicSkills(), diagnostics: catalog.diagnostics(), }; }); app.get("/skills/:key", async (request) => { const key = (request.params as { key: string }).key; await catalog.refresh(); const skill = catalog.get(key); if (!skill) throw new HttpError(404, `skill 不存在:${key}`); return { ...catalog.toPublic(skill), content: skill.content }; }); app.post("/images/quote", async (request) => { const body = parse(quoteSchema, request.body ?? {}); await catalog.refresh(); const skill = catalog.get(body.skill_id); if (!skill) throw new HttpError(404, `skill 不存在:${body.skill_id}`); const defaults = catalog.defaultsFor(skill); return quoteSkill( skill, { size: defaults.size, quantity: defaults.n }, { size: body.size, quantity: body.n }, config.billing.mode, ); }); app.post("/sessions", async (request) => { const body = parse(createSessionSchema, request.body ?? {}); const identity = resolveIdentity(config.auth, { headers: request.headers, teamId: body.team_id, uid: body.uid, }); return await store.create( { ...identity, team_id: identity.team_id || identity.uid }, { title: sanitizeTitle(body.title), type: body.type }, ); }); app.get("/sessions", async (request) => { const query = parse(listQuerySchema, request.query); const identity = resolveIdentity(config.auth, { headers: request.headers, teamId: query.team_id, uid: query.uid }); return await store.list({ uid: identity.uid, team_id: identity.team_id || query.team_id, limit: query.limit, offset: query.offset, }); }); app.get("/sessions/:id", async (request) => { const sessionId = (request.params as { id: string }).id; const identity = resolveIdentity(config.auth, { headers: request.headers }); const session = await store.get(sessionId); if (!session) throw new HttpError(404, "会话不存在"); if (session.uid !== identity.uid) throw new HttpError(403, "无权访问该会话"); return { session, messages: await store.messages(sessionId) }; }); app.get("/sessions/:id/messages", async (request) => { const sessionId = (request.params as { id: string }).id; const identity = resolveIdentity(config.auth, { headers: request.headers }); const session = await store.get(sessionId); if (!session) throw new HttpError(404, "会话不存在"); if (session.uid !== identity.uid) throw new HttpError(403, "无权访问该会话"); return { messages: await store.messages(sessionId) }; }); const runInSession = async ( request: FastifyRequest, sessionId: string, body: z.infer, ) => { const identity = resolveIdentity(config.auth, { headers: request.headers, teamId: body.team_id, uid: body.uid, }); const session = await store.get(sessionId); if (!session) throw new HttpError(404, "会话不存在"); if (session.uid !== identity.uid) throw new HttpError(403, "无权访问该会话"); return { identity: { ...identity, team_id: identity.team_id || session.team_id }, session }; }; const handleRun = async ( request: FastifyRequest, reply: FastifyReply, sessionId: string, body: z.infer, ) => { const { identity } = await runInSession(request, sessionId, body); if (!body.stream) { return service.run({ identity, sessionId, message: body.message, referenceImages: body.reference_images, skillKey: body.skill_id, industry: body.industry, directPrompt: body.direct === true, }); } reply.hijack(); const raw = reply.raw; // hijack 后不再经过 @fastify/cors 的 onSend 钩子,跨域调用必须手动补 CORS 头 const origin = request.headers.origin; const allowOrigin = config.corsOrigin === "*" ? origin || "*" : config.corsOrigin.split(",").map((item) => item.trim()).includes(origin || "") ? origin : ""; const corsHeaders: Record = allowOrigin ? { "access-control-allow-origin": allowOrigin, "access-control-allow-credentials": "true", vary: "Origin" } : {}; raw.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache, no-transform", connection: "keep-alive", "x-accel-buffering": "no", ...corsHeaders, }); raw.write(": connected\n\n"); let clientClosed = false; const heartbeat = setInterval(() => { if (!clientClosed && !raw.destroyed && !raw.writableEnded) raw.write(": ping\n\n"); }, 15_000); raw.on("close", () => { clientClosed = true; clearInterval(heartbeat); }); let emittedError = false; const writeSse = (event: AgentStreamEvent): void => { if (event.type === "error") emittedError = true; if (clientClosed || raw.destroyed || raw.writableEnded) return; raw.write(`data: ${JSON.stringify(event)}\n\n`); }; writeSse({ type: "session", session_id: sessionId }); try { await service.run({ identity, sessionId, message: body.message, referenceImages: body.reference_images, skillKey: body.skill_id, industry: body.industry, directPrompt: body.direct === true, onEvent: writeSse, }); } catch (error) { if (!emittedError) { writeSse({ type: "error", message: error instanceof Error ? error.message : String(error) }); } } finally { clearInterval(heartbeat); if (!raw.destroyed && !raw.writableEnded) raw.end(); } }; app.post("/sessions/:id/messages", async (request, reply) => { const sessionId = (request.params as { id: string }).id; const body = parse(messageSchema, request.body ?? {}); return handleRun(request, reply, sessionId, body); }); app.post("/agent/run", async (request, reply) => { const body = parse(runSchema, request.body ?? {}); if (body.session_id) { return handleRun(request, reply, body.session_id, body); } const identity = resolveIdentity(config.auth, { headers: request.headers, teamId: body.team_id, uid: body.uid, }); const session = await store.create({ ...identity, team_id: identity.team_id || identity.uid }, { title: sanitizeTitle(body.title) }); if (!body.stream) { const result = await service.run({ identity, sessionId: session.session_id, message: body.message, referenceImages: body.reference_images, skillKey: body.skill_id, industry: body.industry, }); return { ...result, session: (await store.get(session.session_id)) ?? session }; } return handleRun(request, reply, session.session_id, body); }); app.post("/sessions/:id/tool-confirmations/:toolCallId", async (request) => { const sessionId = (request.params as { id: string }).id; const toolCallId = (request.params as { toolCallId: string }).toolCallId; const body = parse(confirmationSchema, request.body ?? {}); const identity = resolveIdentity(config.auth, { headers: request.headers, teamId: body.team_id, uid: body.uid, }); return service.confirmTool({ identity, sessionId, toolCallId, approved: body.approved, content: body.content, reason: body.reason, }); }); app.get("/usage", async (request) => { const query = parse(usageQuerySchema, request.query); const identity = resolveIdentity(config.auth, { headers: request.headers, teamId: query.team_id, uid: query.uid }); const records = ledger.list({ uid: identity.uid, team_id: identity.team_id || query.team_id, skill_id: query.skill_id, status: query.status, }); const limit = query.limit ?? 50; return { records: records.slice(0, limit), total: records.length }; }); app.get("/usage/summary", async (request) => { const query = parse(usageQuerySchema, request.query); const identity = resolveIdentity(config.auth, { headers: request.headers, teamId: query.team_id, uid: query.uid }); const summary = ledger.summary({ uid: identity.uid, team_id: identity.team_id || query.team_id, skill_id: query.skill_id, status: query.status, }); return { ...summary, billing_mode: config.billing.mode, currency: config.billing.currency }; }); app.get("/billing", async () => ({ mode: config.billing.mode, currency: config.billing.currency, default_skill_price: config.billing.defaultSkillPrice, deduction_enabled: false, note: config.billing.mode === "dry_run" ? "试点期按 skill 单价记账,不实际扣减团队积分" : "计费已关闭:不扣减费用,用量仅用于估算与审计", ledger_file: config.ledgerFile, })); return app; } function parse(schema: z.ZodType, value: unknown): T { const result = schema.safeParse(value); if (!result.success) { const issue = result.error.issues[0]; throw new HttpError(400, `${issue?.path.join(".") || "body"}: ${issue?.message ?? "参数不合法"}`); } return result.data; } function mapErrorStatus(error: unknown): number { if (error instanceof HttpError) return error.statusCode; if (error instanceof AgentError) return error.statusCode; if (error instanceof AuthError) return 401; if (error instanceof PricingError) return 400; if (error instanceof GatewayError) return 502; if (error && typeof error === "object" && "statusCode" in error) { const statusCode = (error as { statusCode?: unknown }).statusCode; if (typeof statusCode === "number" && statusCode >= 400 && statusCode < 600) return statusCode; } return 500; }