|
|
|
@@ -0,0 +1,276 @@
|
|
|
|
|
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 {
|
|
|
|
|
MessageInputSchema,
|
|
|
|
|
ModelConnectionInputSchema,
|
|
|
|
|
} from "@agent-studio/shared";
|
|
|
|
|
import { SessionRegistry, SkillCatalog } 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 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: skills.items,
|
|
|
|
|
requestId: req.id,
|
|
|
|
|
}));
|
|
|
|
|
app.post("/api/sessions", async (req, reply) => {
|
|
|
|
|
const s = 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 !== "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.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<typeof registry.events.append>) =>
|
|
|
|
|
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<string, unknown> = {},
|
|
|
|
|
) {
|
|
|
|
|
return { error: { code, message, requestId, details } };
|
|
|
|
|
}
|