Files
writing-agent/src/server/web-server.ts
moranzhi 94f67fa744 引入上下文片段、固定槽与投影排序,并完善机遇裁定与游玩期 UI。
把创作产物收敛为可挂载片段与 play_slots/context_order,同步修订世界模拟器模块与运行时拼装。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 01:36:32 +08:00

465 lines
15 KiB
TypeScript

import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadDotEnv } from "../config/env.js";
import { sessionManager } from "./session-manager.js";
import { handleSettingsApi } from "./settings-handlers.js";
import { handleBooksApi } from "./book-handlers.js";
import { handleStatsApi } from "./stats-handlers.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(__dirname, "../..");
loadDotEnv(PROJECT_ROOT);
const WEB_ROOT = path.resolve(__dirname, "../../web");
const PORT = Number(process.env.PORT ?? 23337);
async function readBody(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(chunk as Buffer);
}
return Buffer.concat(chunks).toString("utf8");
}
function json(res: ServerResponse, status: number, data: unknown): void {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(data));
}
async function serveStatic(res: ServerResponse, filePath: string): Promise<void> {
const ext = path.extname(filePath);
const types: Record<string, string> = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
};
const content = await readFile(filePath);
res.writeHead(200, { "Content-Type": types[ext] ?? "application/octet-stream" });
res.end(content);
}
const server = createServer(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
try {
if (req.method === "GET" && url.pathname === "/api/health") {
json(res, 200, {
ok: true,
version: "0.1.0",
routes: [
"GET /api/health",
"GET /api/settings",
"GET /api/profiles",
"POST /api/profiles",
"GET /api/presets",
"GET /api/books",
"GET /api/skills",
"GET /api/directors",
"GET /api/modules",
"GET /api/stats/tokens",
],
});
return;
}
if (await handleSettingsApi(req, res, url.pathname)) {
return;
}
if (await handleBooksApi(req, res, url.pathname)) {
return;
}
if (await handleStatsApi(req, res, url.pathname, url.searchParams)) {
return;
}
if (req.method === "POST" && url.pathname === "/api/sessions") {
const body = await readBody(req).catch(() => "");
let bookId: string | undefined;
let orchestratorId: string | undefined;
if (body) {
try {
const parsed = JSON.parse(body) as {
bookId?: string;
orchestratorId?: string;
};
bookId = parsed.bookId;
orchestratorId = parsed.orchestratorId;
} catch {
/* empty body ok */
}
}
const view = await sessionManager.createForBook(bookId, orchestratorId);
json(res, 201, view);
return;
}
const sessionMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)(\/.*)?$/);
if (sessionMatch) {
const sessionId = decodeURIComponent(sessionMatch[1]);
const sub = sessionMatch[2] ?? "";
if (req.method === "GET" && sub === "") {
const view = sessionManager.get(sessionId);
if (!view) {
json(res, 404, { error: "会话不存在" });
return;
}
json(res, 200, view);
return;
}
if (req.method === "POST" && sub === "/messages") {
const body = JSON.parse(await readBody(req)) as { text?: string };
if (!body.text?.trim()) {
json(res, 400, { error: "text 不能为空" });
return;
}
const view = await sessionManager.sendMessage(sessionId, body.text.trim());
json(res, 200, view);
return;
}
if (req.method === "POST" && sub === "/answers") {
const body = JSON.parse(await readBody(req)) as {
answers?: Array<{
questionId?: string;
optionId?: string;
text?: string;
}>;
note?: string;
};
const answers = (body.answers ?? [])
.filter(
(a) =>
typeof a?.questionId === "string" &&
a.questionId.trim() &&
typeof a?.text === "string" &&
a.text.trim(),
)
.map((a) => ({
questionId: a.questionId!.trim(),
optionId:
typeof a.optionId === "string" && a.optionId.trim()
? a.optionId.trim()
: undefined,
text: a.text!.trim(),
}));
if (!answers.length) {
json(res, 400, { error: "answers 不能为空" });
return;
}
const note =
typeof body.note === "string" && body.note.trim()
? body.note.trim()
: undefined;
try {
const view = await sessionManager.answerQuestions(
sessionId,
answers,
note,
);
json(res, 200, view);
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "提交失败",
});
}
return;
}
const messageActionMatch = sub.match(
/^\/messages\/([^/]+)\/(edit|refresh|variant|delete)$/,
);
if (req.method === "POST" && messageActionMatch) {
const messageId = decodeURIComponent(messageActionMatch[1]);
const action = messageActionMatch[2];
const body = JSON.parse(await readBody(req).catch(() => "{}")) as {
text?: string;
direction?: string;
};
let view;
try {
if (action === "edit") {
if (!body.text?.trim()) {
json(res, 400, { error: "text 不能为空" });
return;
}
view = await sessionManager.editMessage(
sessionId,
messageId,
body.text.trim(),
);
} else if (action === "refresh") {
view = await sessionManager.refreshMessage(sessionId, messageId);
} else if (action === "variant") {
if (body.direction !== "prev" && body.direction !== "next") {
json(res, 400, { error: "direction 须为 prev 或 next" });
return;
}
view = await sessionManager.switchMessageVariant(
sessionId,
messageId,
body.direction,
);
} else if (action === "delete") {
view = await sessionManager.deleteMessage(sessionId, messageId);
} else {
json(res, 400, { error: "未知 action" });
return;
}
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "操作失败",
});
return;
}
json(res, 200, view);
return;
}
if (req.method === "POST" && sub === "/lifecycle") {
const body = JSON.parse(await readBody(req)) as { stage?: string };
if (body.stage !== "design" && body.stage !== "play") {
json(res, 400, { error: "stage 须为 design 或 play" });
return;
}
try {
const view = sessionManager.setLifecycleStage(sessionId, body.stage);
json(res, 200, view);
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "无法切换模式",
});
}
return;
}
if (req.method === "POST" && sub === "/recipe") {
const body = JSON.parse(await readBody(req)) as { recipeId?: string };
if (!body.recipeId?.trim()) {
json(res, 400, { error: "缺少 recipeId" });
return;
}
try {
const view = await sessionManager.setSelectedRecipe(
sessionId,
body.recipeId.trim(),
);
json(res, 200, view);
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "选定配方失败",
});
}
return;
}
if (req.method === "POST" && sub === "/board") {
const body = JSON.parse(await readBody(req)) as {
tag?: string;
content?: string;
};
if (!body.tag?.trim()) {
json(res, 400, { error: "缺少 tag" });
return;
}
try {
const view = sessionManager.writeUserBoardTag(
sessionId,
body.tag,
body.content ?? "",
);
json(res, 200, view);
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "写入失败",
});
}
return;
}
if (req.method === "POST" && sub === "/context-order") {
const body = JSON.parse(await readBody(req)) as {
action?: string;
slotRef?: string;
index?: number;
delta?: number;
anchor?: string;
projection?: string;
context_order?: unknown;
};
try {
const action = body.action?.trim();
if (action === "replace") {
const view = sessionManager.patchContextOrder(sessionId, {
action: "replace",
context_order: body.context_order,
});
json(res, 200, view);
return;
}
if (action === "move") {
const delta = body.delta === 1 || body.delta === -1 ? body.delta : 0;
if (!body.slotRef?.trim() || typeof body.index !== "number" || !delta) {
json(res, 400, { error: "move 需要 slotRef、index、delta(±1)" });
return;
}
const view = sessionManager.patchContextOrder(sessionId, {
action: "move",
slotRef: body.slotRef.trim(),
index: body.index,
delta,
});
json(res, 200, view);
return;
}
if (action === "set_anchor") {
if (
!body.slotRef?.trim() ||
typeof body.index !== "number" ||
(body.anchor !== "pre_history" && body.anchor !== "post_history")
) {
json(res, 400, {
error: "set_anchor 需要 slotRef、index、anchor(pre_history|post_history)",
});
return;
}
const view = sessionManager.patchContextOrder(sessionId, {
action: "set_anchor",
slotRef: body.slotRef.trim(),
index: body.index,
anchor: body.anchor,
});
json(res, 200, view);
return;
}
if (action === "set_projection") {
const proj = body.projection;
if (
!body.slotRef?.trim() ||
typeof body.index !== "number" ||
(proj !== "fixed" &&
proj !== "full" &&
proj !== "summary" &&
proj !== "fields")
) {
json(res, 400, {
error: "set_projection 需要 slotRef、index、projection",
});
return;
}
const view = sessionManager.patchContextOrder(sessionId, {
action: "set_projection",
slotRef: body.slotRef.trim(),
index: body.index,
projection: proj,
});
json(res, 200, view);
return;
}
json(res, 400, {
error: "action 须为 move | set_anchor | set_projection | replace",
});
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "编排失败",
});
}
return;
}
if (req.method === "POST" && sub === "/context-traces/prune") {
try {
const result = sessionManager.pruneSessionContextTraces(sessionId);
json(res, 200, { ...result, session: sessionManager.get(sessionId) });
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "修剪失败",
});
}
return;
}
if (req.method === "POST" && sub === "/context-traces/clear") {
try {
const result = sessionManager.clearSessionContextTraces(sessionId);
json(res, 200, { ...result, session: sessionManager.get(sessionId) });
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "清除失败",
});
}
return;
}
if (req.method === "POST" && sub === "/actions") {
const body = JSON.parse(await readBody(req)) as { action?: string };
let view;
switch (body.action) {
case "approve":
view = await sessionManager.approve(sessionId);
break;
case "confirm_intake":
view = await sessionManager.confirmIntake(sessionId);
break;
case "accept":
view = await sessionManager.accept(sessionId);
break;
case "skip_questions":
view = await sessionManager.skipQuestions(sessionId);
break;
case "reject":
view = await sessionManager.reject(sessionId);
break;
case "run_outline":
view = await sessionManager.runOutline(sessionId);
break;
case "finish":
view = await sessionManager.finish(sessionId);
break;
default:
json(res, 400, { error: "未知 action" });
return;
}
json(res, 200, view);
return;
}
}
let file = url.pathname === "/" ? "/index.html" : url.pathname;
const safe = path.normalize(file).replace(/^(\.\.[/\\])+/, "");
const full = path.join(WEB_ROOT, safe);
if (!full.startsWith(WEB_ROOT)) {
json(res, 403, { error: "Forbidden" });
return;
}
try {
await serveStatic(res, full);
} catch {
json(res, 404, { error: "Not found" });
}
} catch (err) {
console.error("[api]", req.method, url.pathname, err);
json(res, 500, {
error: err instanceof Error ? err.message : "服务器错误",
});
}
});
server.listen(PORT, () => {
console.log(`Writing Agent 对话页: http://localhost:${PORT}`);
});