Laimiao agent v2 service: agent orchestration, sessions, billing, gateway client, skills catalog, auth, config, plus tests.
394 lines
14 KiB
TypeScript
394 lines
14 KiB
TypeScript
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<FastifyInstance> {
|
|
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<typeof messageSchema>,
|
|
) => {
|
|
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<typeof messageSchema>,
|
|
) => {
|
|
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<string, string> = 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<T>(schema: z.ZodType<T>, 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;
|
|
}
|