Files
writing-agent/src/server/book-handlers.ts
T
chenmoranzhi 9dc927282f 预设兼容:按真实抓包对齐装配与包装层,补上声明式工具与预设正则。
- 装配只认 prompt_order.enabled;squash_system_messages 只合并相邻 system(新增 system 档,连接符按酒馆用单换行)
- 包装层:new_chat_prompt、示例对话(new_example_chat_prompt + <START> 切段 + name)、角色卡三块与 personality_format/scenario_format、{{user}}/{{char}} 取值、任务契约并进预设条目
- 工具协议:按 ToolBindings / 伪工具协议数据声明真 tools(presetToolPolicy=preset),工具描述用到的条目不再重复作为消息发出
- 正则:promptOnly 作用于对话历史(含 depth 过滤)、写入时脚本改写回复;{{//}} 注释按配平扫描
- tools/st-request-diff:真假上游抓包对比工具、三份预设的机制结论与实测报告
2026-10-23 00:13:08 +08:00

721 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { IncomingMessage, ServerResponse } from "node:http";
import { listSkills } from "../skills/loader.js";
import type { ModuleCatalogEntry } from "../skills/creation-flow.js";
import { createBook, deleteBook, duplicateBook, getBook, listBooks, updateBook } from "../book/store.js";
import { sessionManager } from "./session-manager.js";
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));
}
type ModuleStatus = "ready" | "partial" | "skeleton" | "missing";
function moduleStatusFromSections(
hasPrompt: boolean,
present: string[],
taskBody?: string,
): ModuleStatus {
if (!hasPrompt) return "missing";
const hasTask = present.includes("task");
const hasOutput = present.includes("output");
if (!hasTask || !hasOutput) return "skeleton";
const task = taskBody?.trim() ?? "";
const stub =
!task ||
/待作者细写|待完善|(待/.test(task) ||
task.length < 120;
if (stub) return "skeleton";
const depth = ["principles", "probe", "checklist", "examples"].filter((id) =>
present.includes(id),
).length;
return depth >= 2 ? "ready" : "partial";
}
type ModuleBrowseRow = {
id: string;
name: string;
declaration: string;
artifact: string;
hasPrompt: boolean;
sectionsPresent: string[];
status: ModuleStatus;
group: string;
presentMode: "message" | "zero_layer" | null;
layer: "intermediate" | "final" | null;
repeatable: boolean;
closer: boolean;
priorArtifact: boolean;
programSide: boolean;
libraries: string[];
land: string;
};
async function moduleBrowseFields(
entry: ModuleCatalogEntry,
): Promise<
Omit<
ModuleBrowseRow,
| "id"
| "name"
| "declaration"
| "artifact"
| "hasPrompt"
| "sectionsPresent"
| "status"
>
> {
const { dictateModuleLandHint, isDictateProgramModule } = await import(
"../skills/creation-flow.js"
);
const { moduleBrowseGroup, modulePresentationMode } = await import(
"../dictate/creation-plan.js"
);
const { getLibrary } = await import("../libraries/registry.js");
return {
group: moduleBrowseGroup(entry),
presentMode: modulePresentationMode(entry.id),
layer: entry.layer ?? null,
repeatable: Boolean(entry.repeatable),
closer: Boolean(entry.closer),
priorArtifact: entry.kind === "prior-artifact",
programSide: isDictateProgramModule(entry),
libraries: (entry.libraries ?? []).map(
(id) => getLibrary(id)?.label ?? id,
),
land: dictateModuleLandHint(entry),
};
}
async function loadModulesPayload(skillId: string): Promise<{
groups: Array<{ id: string; label: string; hint: string }>;
modules: ModuleBrowseRow[];
}> {
const { loadSkill } = await import("../skills/loader.js");
const {
loadModuleCatalog,
loadModulePrompt,
parseModulePromptSections,
catalogModulesForIntake,
MODULE_SECTION_IDS,
} = await import("../skills/creation-flow.js");
const { MODULE_BROWSE_GROUPS } = await import("../dictate/creation-plan.js");
const skill = await loadSkill(skillId);
if (!skill.skillPackRoot) {
return { groups: [...MODULE_BROWSE_GROUPS], modules: [] };
}
const catalog = await loadModuleCatalog(skill.skillPackRoot);
const modules: ModuleBrowseRow[] = [];
for (const m of catalogModulesForIntake(catalog ?? { modules: [] }, "dictate")) {
const prompt = await loadModulePrompt(skill.skillPackRoot, m.id);
const sections = prompt
? parseModulePromptSections(prompt)
: { raw: "", blocks: {} };
const sectionsPresent = MODULE_SECTION_IDS.filter((id) =>
Boolean(sections.blocks[id]?.trim()),
);
const hasPrompt = Boolean(prompt?.trim());
const taskBody = sections.blocks.task?.trim();
modules.push({
id: m.id,
name: m.name,
declaration: m.declaration,
artifact: m.artifact,
hasPrompt,
sectionsPresent: [...sectionsPresent],
status: moduleStatusFromSections(hasPrompt, sectionsPresent, taskBody),
...(await moduleBrowseFields(m)),
});
}
return { groups: [...MODULE_BROWSE_GROUPS], modules };
}
async function loadModuleDetailPayload(
skillId: string,
moduleId: string,
): Promise<
| (ModuleBrowseRow & {
sections: Record<string, string>;
meta: Record<string, unknown> | null;
raw: string;
})
| null
> {
const { loadSkill } = await import("../skills/loader.js");
const {
loadModuleCatalog,
loadModulePrompt,
parseModulePromptSections,
MODULE_SECTION_IDS,
} = await import("../skills/creation-flow.js");
const { parse: parseYaml } = await import("yaml");
const skill = await loadSkill(skillId);
if (!skill.skillPackRoot) return null;
const catalog = await loadModuleCatalog(skill.skillPackRoot);
const entry = catalog?.modules.find((m) => m.id === moduleId) ?? null;
if (!entry) return null;
const prompt = await loadModulePrompt(skill.skillPackRoot, moduleId);
const parsed = prompt
? parseModulePromptSections(prompt)
: { raw: "", blocks: {} };
const sections: Record<string, string> = {};
for (const id of MODULE_SECTION_IDS) {
const body = parsed.blocks[id]?.trim();
if (body) sections[id] = body;
}
const sectionsPresent = Object.keys(sections);
const hasPrompt = Boolean(prompt?.trim());
let meta: Record<string, unknown> | null = null;
const metaRaw = sections.meta;
if (metaRaw) {
try {
const doc = parseYaml(metaRaw);
if (doc && typeof doc === "object" && !Array.isArray(doc)) {
meta = doc as Record<string, unknown>;
}
} catch {
meta = null;
}
}
return {
id: entry.id,
name: entry.name,
declaration: entry.declaration,
artifact: entry.artifact,
hasPrompt,
sectionsPresent,
status: moduleStatusFromSections(
hasPrompt,
sectionsPresent,
sections.task,
),
...(await moduleBrowseFields(entry)),
sections,
meta,
raw: prompt ?? "",
};
}
export async function handleBooksApi(
req: IncomingMessage,
res: ServerResponse,
pathname: string,
): Promise<boolean> {
if (pathname === "/api/skills" && req.method === "GET") {
const skills = await listSkills();
json(res, 200, {
skills: skills.map((s) => ({
id: s.name,
name: s.name,
description: s.description,
category: s.category,
bookKind: s.bookKind,
})),
});
return true;
}
/** 新建作品:配方一层选型(节点流程 / 转述各一本目录,不共享) */
if (pathname === "/api/directors" && req.method === "GET") {
const { DEFAULT_ORCHESTRATOR_ID } = await import(
"../config/default-orchestrator.js"
);
const skillId = DEFAULT_ORCHESTRATOR_ID;
try {
const { loadSkill } = await import("../skills/loader.js");
const { loadRecipeCatalog } = await import("../skills/creation-flow.js");
const skill = await loadSkill(skillId);
if (!skill.skillPackRoot) {
json(res, 200, {
directors: [],
recipeDirectors: [],
dictateDirectors: [],
skillPackId: skillId,
});
return true;
}
const [recipeCat, dictateCat] = await Promise.all([
loadRecipeCatalog(skill.skillPackRoot, undefined, "recipe"),
loadRecipeCatalog(skill.skillPackRoot, undefined, "dictate"),
]);
const mapRow = (r: {
id: string;
name: string;
declaration: string;
}) => ({
id: r.id,
name: r.name,
declaration: r.declaration,
});
const recipeDirectors = (recipeCat?.recipes ?? []).map(mapRow);
const dictateDirectors = (dictateCat?.recipes ?? []).map(mapRow);
json(res, 200, {
skillPackId: skillId,
/** @deprecated 兼容旧前端:默认节点流程列表 */
directors: recipeDirectors,
recipeDirectors,
dictateDirectors,
});
} catch (err) {
json(res, 404, {
error: err instanceof Error ? err.message : "未找到配方列表",
});
}
return true;
}
const recipesMatch = pathname.match(/^\/api\/skills\/([^/]+)\/recipes$/);
if (recipesMatch && req.method === "GET") {
const skillId = decodeURIComponent(recipesMatch[1]);
try {
const { loadSkill } = await import("../skills/loader.js");
const { loadRecipeCatalog } = await import("../skills/creation-flow.js");
const skill = await loadSkill(skillId);
if (!skill.skillPackRoot) {
json(res, 200, { recipes: [] });
return true;
}
const catalog = await loadRecipeCatalog(skill.skillPackRoot);
json(res, 200, {
recipes: (catalog?.recipes ?? []).map((r) => ({
id: r.id,
name: r.name,
declaration: r.declaration,
})),
});
} catch (err) {
json(res, 404, {
error: err instanceof Error ? err.message : "未找到技能包",
});
}
return true;
}
/** 对话落盘节点:按角色分组,不含旧配方包 */
if (pathname === "/api/modules" && req.method === "GET") {
const { DEFAULT_ORCHESTRATOR_ID } = await import(
"../config/default-orchestrator.js"
);
try {
const payload = await loadModulesPayload(DEFAULT_ORCHESTRATOR_ID);
json(res, 200, payload);
} catch (err) {
json(res, 404, {
error: err instanceof Error ? err.message : "未找到能力目录",
});
}
return true;
}
const moduleDetailMatch = pathname.match(/^\/api\/modules\/([^/]+)$/);
if (moduleDetailMatch && req.method === "GET") {
const { DEFAULT_ORCHESTRATOR_ID } = await import(
"../config/default-orchestrator.js"
);
const moduleId = decodeURIComponent(moduleDetailMatch[1]);
try {
const detail = await loadModuleDetailPayload(
DEFAULT_ORCHESTRATOR_ID,
moduleId,
);
if (!detail) {
json(res, 404, { error: `未找到能力:${moduleId}` });
return true;
}
json(res, 200, detail);
} catch (err) {
json(res, 404, {
error: err instanceof Error ? err.message : "未找到能力",
});
}
return true;
}
const skillModulesMatch = pathname.match(
/^\/api\/skills\/([^/]+)\/modules$/,
);
if (skillModulesMatch && req.method === "GET") {
const skillId = decodeURIComponent(skillModulesMatch[1]);
try {
const payload = await loadModulesPayload(skillId);
json(res, 200, payload);
} catch (err) {
json(res, 404, {
error: err instanceof Error ? err.message : "未找到能力目录",
});
}
return true;
}
const skillModuleDetailMatch = pathname.match(
/^\/api\/skills\/([^/]+)\/modules\/([^/]+)$/,
);
if (skillModuleDetailMatch && req.method === "GET") {
const skillId = decodeURIComponent(skillModuleDetailMatch[1]);
const moduleId = decodeURIComponent(skillModuleDetailMatch[2]);
try {
const detail = await loadModuleDetailPayload(skillId, moduleId);
if (!detail) {
json(res, 404, { error: `未找到能力:${moduleId}` });
return true;
}
json(res, 200, detail);
} catch (err) {
json(res, 404, {
error: err instanceof Error ? err.message : "未找到能力",
});
}
return true;
}
if (pathname === "/api/books" && req.method === "GET") {
json(res, 200, { books: listBooks() });
return true;
}
if (pathname === "/api/books" && req.method === "POST") {
const body = JSON.parse(await readBody(req)) as {
title?: string;
/** 编排器包 id(registry name) */
orchestratorId?: string;
/** 旧客户端可传;新建作品改由创作对话判断路径 */
recipeId?: string;
/** 进料方式:对话落盘(默认)| 听书连载 | 旧工序编排 */
creationMode?: "recipe" | "dictate" | "listen_serial";
};
const skills = await listSkills();
const requested = body.orchestratorId?.trim();
const director =
(requested && skills.find((s) => s.name === requested)) ||
skills.find((s) => s.name === "world-simulator") ||
skills[0];
if (!director) {
json(res, 400, { error: "没有可用的编排器技能包" });
return true;
}
const rawMode = body.creationMode;
const creationMode =
rawMode === "listen_serial" || rawMode === "recipe" || rawMode === "dictate"
? rawMode
: "dictate";
const book = createBook({ title: body.title, creationMode });
updateBook(book.id, {
activeSkillId: director.name,
activeSkillName: director.description?.split("\n")[0]?.slice(0, 80) || director.name,
orchestratorId: director.name,
orchestratorName: director.name,
creationMode,
});
const session = await sessionManager.createForBook(
book.id,
director.name,
body.recipeId?.trim() || undefined,
{ creationMode },
);
json(res, 201, { book: getBook(book.id) ?? book, session });
return true;
}
if (pathname === "/api/books/batch" && req.method === "DELETE") {
const body = JSON.parse(await readBody(req)) as { ids?: string[] };
const ids = (body.ids ?? []).filter(Boolean);
const deleted: string[] = [];
for (const id of ids) {
const book = getBook(id);
if (!book) continue;
sessionManager.dropBookSessions(id);
deleteBook(id);
deleted.push(id);
}
json(res, 200, { deleted });
return true;
}
const duplicateMatch = pathname.match(/^\/api\/books\/([^/]+)\/duplicate$/);
if (duplicateMatch && req.method === "POST") {
const bookId = decodeURIComponent(duplicateMatch[1]);
const body = JSON.parse(await readBody(req)) as { title?: string };
try {
const book = duplicateBook(bookId, body.title);
json(res, 201, { book });
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "复制失败",
});
}
return true;
}
const playNewMatch = pathname.match(/^\/api\/books\/([^/]+)\/play\/new$/);
if (playNewMatch && req.method === "POST") {
const bookId = decodeURIComponent(playNewMatch[1]);
const book = getBook(bookId);
if (!book) {
json(res, 404, { error: "Book 不存在" });
return true;
}
const active = sessionManager.getActiveSessionForBook(bookId);
if (!active?.id) {
json(res, 400, { error: "请先打开该作品" });
return true;
}
try {
let instanceId: string | undefined;
let useOpeningPersona: boolean | undefined;
try {
const raw = await readBody(req);
if (raw.trim()) {
const body = JSON.parse(raw) as {
instanceId?: string;
useOpeningPersona?: boolean;
};
instanceId = body.instanceId?.trim() || undefined;
useOpeningPersona =
typeof body.useOpeningPersona === "boolean"
? body.useOpeningPersona
: undefined;
}
} catch {
instanceId = undefined;
useOpeningPersona = undefined;
}
const session = await sessionManager.startNewPlayRun(active.id, instanceId, {
useOpeningPersona,
});
json(res, 200, { session });
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "无法新建游玩",
});
}
return true;
}
const savesListMatch = pathname.match(/^\/api\/books\/([^/]+)\/saves$/);
if (savesListMatch) {
const bookId = decodeURIComponent(savesListMatch[1]);
const book = getBook(bookId);
if (!book) {
json(res, 404, { error: "Book 不存在" });
return true;
}
if (req.method === "GET") {
try {
const saves = sessionManager.listGameSnapshots(bookId).map((s) => ({
...s,
kindLabel: s.kind === "instance" ? "创作定稿" : "游玩进度",
}));
json(res, 200, {
saves,
playWorkingInstanceId:
sessionManager.playWorkingInstanceId(bookId) ?? null,
});
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "读取存档失败",
});
}
return true;
}
if (req.method === "POST") {
const body = JSON.parse(await readBody(req)) as {
label?: string;
note?: string;
sessionId?: string;
/** instance=创作定稿截面;run=游玩进度(默认) */
kind?: "instance" | "run";
};
const sessionId =
body.sessionId?.trim() ||
sessionManager.getActiveSessionForBook(bookId)?.id;
if (!sessionId) {
json(res, 400, { error: "当前作品没有活跃会话,无法存档" });
return true;
}
const kind = body.kind === "instance" ? "instance" : "run";
try {
const save =
kind === "run"
? sessionManager.savePlaySnapshot(sessionId, body.label ?? "", body.note)
: sessionManager.saveGameSnapshot(
sessionId,
body.label ?? "",
"instance",
body.note,
);
json(res, 201, {
save: {
...save,
kindLabel: save.kind === "instance" ? "创作定稿" : "游玩进度",
},
});
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "存档失败",
});
}
return true;
}
}
const saveItemMatch = pathname.match(
/^\/api\/books\/([^/]+)\/saves\/([^/]+)(\/load)?$/,
);
if (saveItemMatch) {
const bookId = decodeURIComponent(saveItemMatch[1]);
const saveId = decodeURIComponent(saveItemMatch[2]);
const isLoad = saveItemMatch[3] === "/load";
if (isLoad && req.method === "POST") {
try {
const session = await sessionManager.loadGameSnapshot(bookId, saveId);
json(res, 200, { session });
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "读档失败",
});
}
return true;
}
if (req.method === "GET" && !isLoad) {
try {
const data = sessionManager.getGameSnapshotProducts(bookId, saveId);
json(res, 200, {
save: {
...data.save,
kindLabel:
data.save.kind === "instance" ? "创作定稿" : "游玩进度",
},
products: data.products,
});
} catch (err) {
json(res, 404, {
error: err instanceof Error ? err.message : "读取存档产物失败",
});
}
return true;
}
if ((req.method === "PATCH" || req.method === "PUT") && !isLoad) {
const body = JSON.parse(await readBody(req)) as {
label?: string;
tag?: string;
content?: string;
};
try {
if (typeof body.tag === "string") {
const data = sessionManager.patchGameSnapshotProduct(
bookId,
saveId,
body.tag,
typeof body.content === "string" ? body.content : "",
);
json(res, 200, {
save: {
...data.save,
kindLabel:
data.save.kind === "instance" ? "创作定稿" : "游玩进度",
},
products: data.products,
});
return true;
}
const save = sessionManager.renameGameSnapshot(
bookId,
saveId,
body.label ?? "",
);
json(res, 200, {
save: {
...save,
kindLabel: save.kind === "instance" ? "创作定稿" : "游玩进度",
},
});
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "更新失败",
});
}
return true;
}
if (req.method === "DELETE") {
try {
sessionManager.deleteGameSnapshot(bookId, saveId);
json(res, 200, { ok: true });
} catch (err) {
json(res, 404, {
error: err instanceof Error ? err.message : "删除失败",
});
}
return true;
}
}
const bookMatch = pathname.match(/^\/api\/books\/([^/]+)(\/open)?$/);
if (bookMatch) {
const bookId = decodeURIComponent(bookMatch[1]);
const isOpen = bookMatch[2] === "/open";
if (isOpen && req.method === "POST") {
const book = getBook(bookId);
if (!book) {
json(res, 404, { error: "Book 不存在" });
return true;
}
const session = await sessionManager.openBook(book.id);
json(res, 200, { book, session });
return true;
}
if (req.method === "GET") {
const book = getBook(bookId);
if (!book) {
json(res, 404, { error: "Book 不存在" });
return true;
}
json(res, 200, { book });
return true;
}
if (req.method === "PUT") {
const body = JSON.parse(await readBody(req)) as { title?: string };
try {
const book = updateBook(bookId, { title: body.title?.trim() });
sessionManager.syncBookTitle(bookId, book.title);
json(res, 200, { book });
} catch (err) {
json(res, 404, {
error: err instanceof Error ? err.message : "更新失败",
});
}
return true;
}
if (req.method === "DELETE") {
const book = getBook(bookId);
if (!book) {
json(res, 404, { error: "Book 不存在" });
return true;
}
sessionManager.dropBookSessions(bookId);
deleteBook(bookId);
json(res, 200, { ok: true });
return true;
}
}
return false;
}