@waterwx/dsh-novel-forge 1.1.4 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -65,8 +65,12 @@ const NOVEL_API = {
65
65
  cover: "/api/dsh-novel-forge/blurb/cover",
66
66
  /** 剧情线管理:增删改 + 关联章节。 */
67
67
  plotlines: "/api/dsh-novel-forge/plotlines",
68
+ /** 角色库:AI 提炼 / 采纳 / 更新 / 删除。 */
69
+ roles: "/api/dsh-novel-forge/roles",
68
70
  /** 作者复盘补跑:对已写章节补齐 authorReview(全书流式 / 单章 JSON)。 */
69
71
  reviewBackfill: "/api/dsh-novel-forge/review/backfill",
72
+ /** 章节复位:generating 卡死 → pending(可重新生成)。 */
73
+ chapterReset: "/api/dsh-novel-forge/chapter/reset",
70
74
  /** 敏感词检查:全书已写章节或指定文本。 */
71
75
  sensitiveCheck: "/api/dsh-novel-forge/sensitive-check",
72
76
  config: "/api/dsh-novel-forge/config",
@@ -1426,6 +1430,19 @@ function writeSystemPrompt(project) {
1426
1430
  if (Array.isArray(card.knowledge) && card.knowledge.length > 0) sections.push(` 已知信息(该角色知道的:${card.knowledge.join(";")};未列出的信息该角色一律不知道,不得写其知晓或提及)`);
1427
1431
  }
1428
1432
  }
1433
+ const roleLib = project.roles ?? [];
1434
+ if (roleLib.length > 0) {
1435
+ const labelName = {
1436
+ protagonist: "主角",
1437
+ female_lead: "女主",
1438
+ female_support: "女配",
1439
+ support: "配角",
1440
+ antagonist: "反派",
1441
+ extra: "路人"
1442
+ };
1443
+ sections.push("角色库(出场角色按定位规格刻画互动):");
1444
+ for (const r of roleLib) sections.push(`- ${r.name}(${labelName[r.roleLabel]}):${r.identity}${r.relations.length > 0 ? `;关系:${r.relations.join("、")}` : ""}`);
1445
+ }
1429
1446
  if (bible.redLines.length > 0) sections.push("写作红线(违反即失败):\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
1430
1447
  if (bible.style.length > 0) sections.push("风格要求:\n" + bible.style.map((r) => `- ${r}`).join("\n"));
1431
1448
  }
@@ -1772,6 +1789,70 @@ async function refreshPlotlineProgress(ctx, config, project, line) {
1772
1789
  }));
1773
1790
  return typeof raw.progress === "string" ? raw.progress.trim().slice(0, 300) : "";
1774
1791
  }
1792
+ /** ✨ AI 从全书提炼角色库:大纲 + 道藏 + 编年录 + 章节摘要 → 结构化角色清单。 */
1793
+ async function extractRoles(ctx, config, project) {
1794
+ const system = [
1795
+ "你是一位网文角色库管理员。请根据本书的大纲、设定、编年录与章节摘要,提炼完整的角色库。",
1796
+ "覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如\"矮胖姑娘\")用其身份简称收录并标注。",
1797
+ "数量控制:最多输出 10 个角色,宁缺毋滥;路人级一次带过的不要收录。",
1798
+ "每个角色输出:",
1799
+ "1. name:角色名(或身份简称)。",
1800
+ "2. roleLabel:定位——protagonist=主角;female_lead=女主(唯一知己/感情线核心,无后宫前提下只此一位);female_support=重要女配;support=普通配角;antagonist=反派;extra=路人/背景。",
1801
+ "3. identity:身份一句话(宗门/势力/血脉/职业)。",
1802
+ "4. traits:3-6 个性格标签。",
1803
+ "5. goals:目标与动机一句话。",
1804
+ "6. relations:关系网数组,格式[\"角色名(关系)\", ...]。",
1805
+ "7. arc:成长线数组,格式[\"阶段:说明\", ...](如\"出场:祭品身份\"/\"转折:祭祀被中断脱身\")。",
1806
+ "8. knowledge:该角色已经知道的关键信息(3-8 条),不知道的信息不要写进去。",
1807
+ "精简要求:identity 控制在 30 字内;traits 3-6 个短标签;goals 60 字内;relations 2-5 条;arc 2-4 条;knowledge 每条 40 字内。整体输出量要紧凑,避免冗长。",
1808
+ "重要:用户消息里列出的「已收录角色」绝不要再次输出——这些角色已经在角色库里,跳过它们,只提炼未收录的。",
1809
+ "输出必须是合法 JSON 数组,不要输出其他文字:[{\"name\":\"...\", \"roleLabel\":\"...\", \"identity\":\"...\", \"traits\":[...], \"goals\":\"...\", \"relations\":[...], \"arc\":[...], \"knowledge\":[...]}]",
1810
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
1811
+ "重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
1812
+ ].join("\n");
1813
+ const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
1814
+ const existingRoles = project.roles ?? [];
1815
+ const raw = parseJsonArray(await complete(ctx, config, {
1816
+ system,
1817
+ user: [
1818
+ `书名:《${project.bookName}》`,
1819
+ existingRoles.length > 0 ? `已收录角色(跳过,不要输出):${existingRoles.map((r) => r.name).join("、")}` : "",
1820
+ `大纲(节选前 3000 字):\n${project.outline.slice(0, 3e3)}`,
1821
+ project.bible !== void 0 && project.bible.characters.length > 0 ? `已有角色卡(补充信息):\n${project.bible.characters.map((c) => `- ${c.name}(${c.role}):${c.traits.join("、")}${c.goals !== "" ? `;目标:${c.goals}` : ""}`).join("\n")}` : "",
1822
+ (project.facts ?? []).length > 0 ? `编年录(最近 60 条):\n${(project.facts ?? []).slice(-60).map((f) => `[第${f.chapterNo}章] ${f.text.slice(0, 80)}`).join("\n")}` : "",
1823
+ written.length > 0 ? `已写章节标题(${written.length} 章):\n${written.map((c) => `第${c.no}章《${c.title}》`).join("、")}` : "",
1824
+ "只输出 JSON 数组。"
1825
+ ].join("\n\n"),
1826
+ temperature: .3,
1827
+ maxTokens: Math.max(config.maxTokens, 24e3)
1828
+ }));
1829
+ const labels = /* @__PURE__ */ new Set([
1830
+ "protagonist",
1831
+ "female_lead",
1832
+ "female_support",
1833
+ "support",
1834
+ "antagonist",
1835
+ "extra"
1836
+ ]);
1837
+ const strArr = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.trim() !== "") : [];
1838
+ const roles = [];
1839
+ for (const entry of raw) {
1840
+ if (typeof entry !== "object" || entry === null) continue;
1841
+ const name = typeof entry.name === "string" ? entry.name.trim().slice(0, 30) : "";
1842
+ if (name === "") continue;
1843
+ roles.push({
1844
+ name,
1845
+ roleLabel: labels.has(entry.roleLabel) ? entry.roleLabel : "support",
1846
+ identity: typeof entry.identity === "string" ? entry.identity.slice(0, 100) : "",
1847
+ traits: strArr(entry.traits).map((t) => t.slice(0, 20)).slice(0, 8),
1848
+ goals: typeof entry.goals === "string" ? entry.goals.slice(0, 200) : "",
1849
+ relations: strArr(entry.relations).map((r) => r.slice(0, 60)).slice(0, 10),
1850
+ arc: strArr(entry.arc).map((a) => a.slice(0, 120)).slice(0, 10),
1851
+ knowledge: strArr(entry.knowledge).map((k) => k.slice(0, 120)).slice(0, 12)
1852
+ });
1853
+ }
1854
+ return roles;
1855
+ }
1775
1856
  /** 🩺 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
1776
1857
  async function analyzePlotlineHealth(ctx, config, project) {
1777
1858
  const system = [
@@ -3144,8 +3225,19 @@ async function chatOnce(ctx, config, system, history) {
3144
3225
  const messages = historyToMessages(history);
3145
3226
  const last = messages[messages.length - 1];
3146
3227
  if (last?.role === "user" && Array.isArray(last.content)) {
3147
- const text = last.content.find((b) => b.type === "text");
3148
- if (text !== void 0 && typeof text.text === "string") text.text += "\n\n(回复格式提醒:如果你需要执行任何操作,你的回复必须【只包含】一个 <dsh-action name=\"工具名\">{\"参数\":值}</dsh-action> 标签,禁止先说话、禁止解释、禁止铺垫;如果你只是在回答或讨论,正常回复即可,不要输出标签。)";
3228
+ const blocks = last.content;
3229
+ const idx = blocks.findIndex((b) => b.type === "text");
3230
+ if (idx !== -1) {
3231
+ const textBlock = blocks[idx];
3232
+ const newBlocks = blocks.map((b, i) => i === idx ? {
3233
+ ...textBlock,
3234
+ text: textBlock.text + "\n\n(回复格式提醒:如果你需要执行任何操作,你的回复必须【只包含】一个 <dsh-action name=\"工具名\">{\"参数\":值}</dsh-action> 标签,禁止先说话、禁止解释、禁止铺垫;如果你只是在回答或讨论,正常回复即可,不要输出标签。)"
3235
+ } : b);
3236
+ messages[messages.length - 1] = {
3237
+ ...last,
3238
+ content: newBlocks
3239
+ };
3240
+ }
3149
3241
  }
3150
3242
  const request = {
3151
3243
  provider: config.provider,
@@ -3182,9 +3274,29 @@ async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
3182
3274
  /** 已提示模型输出动作标签的次数(0 = 尚未提示;超过上限则按纯文字回复结束,防死循环)。 */
3183
3275
  let nudged = 0;
3184
3276
  const MAX_NUDGES = 6;
3277
+ /** 连续收到 hex 乱码回复的次数(≥2 次判定 LLM 侧异常,放弃本轮避免死循环)。 */
3278
+ let garbleCount = 0;
3185
3279
  for (;;) {
3186
3280
  if (round++ > 20) break;
3187
3281
  const reply = await chatOnce(ctx, config, system, history);
3282
+ if (reply.length > 120 && /^[0-9a-fA-F\s]+$/.test(reply.slice(0, 2e3))) {
3283
+ garbleCount++;
3284
+ if (garbleCount >= 2) {
3285
+ const garbleEntry = {
3286
+ role: "assistant",
3287
+ content: "(模型本次返回了异常编码内容,已忽略;请重新描述你的问题。)",
3288
+ ts: (/* @__PURE__ */ new Date()).toISOString()
3289
+ };
3290
+ history.push(garbleEntry);
3291
+ appendHistory(outputDir, garbleEntry);
3292
+ yield {
3293
+ frame: "delta",
3294
+ text: garbleEntry.content
3295
+ };
3296
+ return;
3297
+ }
3298
+ continue;
3299
+ }
3188
3300
  const action = extractAction(reply);
3189
3301
  if (action === void 0) {
3190
3302
  const intendsAction = /(改|修改|修订|重写|替换|调整|生成|新增|删除|导出|看看|查看|调出|读一下|加上|加一个|去掉|删掉|把.+改成|定位|处理|转轨|检查|确认|搜索|找一下|列一下|查一下|查一遍|查一查|再查|查查|核实|核对|清点|盘点|看一下|看下|继续)/.test(reply);
@@ -3530,6 +3642,15 @@ function makeRoutes(deps) {
3530
3642
  seedBookshelfFromOutputDir(config.outputDir);
3531
3643
  const project = loadProject(config.outputDir);
3532
3644
  if (project !== void 0) {
3645
+ const staleMs = 600 * 1e3;
3646
+ for (const c of project.chapters) if (c.status === "generating" && c.generatingAt !== void 0) {
3647
+ const started = new Date(c.generatingAt).getTime();
3648
+ if (Number.isFinite(started) && Date.now() - started > staleMs) {
3649
+ c.status = "pending";
3650
+ c.error = void 0;
3651
+ c.generatingAt = void 0;
3652
+ }
3653
+ }
3533
3654
  syncProjectWithDisk(project, config.outputDir);
3534
3655
  saveProject(config.outputDir, project);
3535
3656
  }
@@ -3718,6 +3839,7 @@ function makeRoutes(deps) {
3718
3839
  });
3719
3840
  chapter.status = "generating";
3720
3841
  chapter.error = void 0;
3842
+ chapter.generatingAt = (/* @__PURE__ */ new Date()).toISOString();
3721
3843
  saveProject(config.outputDir, project);
3722
3844
  const send = (frame) => {
3723
3845
  res.write(JSON.stringify(frame) + "\n");
@@ -4233,604 +4355,700 @@ function makeRoutes(deps) {
4233
4355
  writeJson(res, 200, { ok: true });
4234
4356
  }
4235
4357
  };
4236
- return [
4237
- statusRoute,
4238
- loadOutlineRoute,
4239
- saveOutlineRoute,
4240
- bibleRoute,
4241
- volumesRoute,
4242
- planRoute,
4243
- generateRoute,
4244
- reviewRoute,
4245
- rewriteRoute,
4246
- polishRoute,
4247
- draftApplyRoute,
4248
- draftDiscardRoute,
4249
- summaryRoute,
4250
- foreshadowRoute,
4251
- exportRoute,
4252
- chapterRoute,
4253
- chapterCheckRoute,
4254
- chapterSaveRoute,
4255
- {
4256
- kind: "exact",
4257
- path: NOVEL_API.assets,
4258
- handler: async (req, res) => {
4259
- if (req.method !== "GET" && req.method !== "POST") {
4260
- writeJson(res, 405, { error: "method not allowed (expected GET or POST)" });
4358
+ const assetsRoute = {
4359
+ kind: "exact",
4360
+ path: NOVEL_API.assets,
4361
+ handler: async (req, res) => {
4362
+ if (req.method !== "GET" && req.method !== "POST") {
4363
+ writeJson(res, 405, { error: "method not allowed (expected GET or POST)" });
4364
+ return;
4365
+ }
4366
+ if (!isLoopbackRequest(req)) {
4367
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
4368
+ return;
4369
+ }
4370
+ const config = getConfig();
4371
+ const project = loadProject(config.outputDir);
4372
+ const projectAssets = project?.assets ?? emptyProjectAssets();
4373
+ if (req.method === "POST") {
4374
+ const body = await readJsonBody(req);
4375
+ if (body === void 0) {
4376
+ writeJson(res, 400, { error: "无效的 JSON" });
4261
4377
  return;
4262
4378
  }
4263
- if (!isLoopbackRequest(req)) {
4264
- writeJson(res, 403, { error: "forbidden: loopback-only" });
4379
+ if (project === void 0) {
4380
+ writeJson(res, 400, { error: "请先加载大纲创建项目" });
4265
4381
  return;
4266
4382
  }
4267
- const config = getConfig();
4268
- const project = loadProject(config.outputDir);
4269
- const projectAssets = project?.assets ?? emptyProjectAssets();
4270
- if (req.method === "POST") {
4271
- const body = await readJsonBody(req);
4272
- if (body === void 0) {
4273
- writeJson(res, 400, { error: "无效的 JSON" });
4274
- return;
4275
- }
4276
- if (project === void 0) {
4277
- writeJson(res, 400, { error: "请先加载大纲创建项目" });
4278
- return;
4279
- }
4280
- if (body.genre !== void 0) projectAssets.genre = body.genre;
4281
- if (body.primaryProgression !== void 0) projectAssets.primaryProgression = body.primaryProgression;
4282
- if (body.auxiliaryProgressions !== void 0) projectAssets.auxiliaryProgressions = body.auxiliaryProgressions;
4283
- if (body.antiAiRules !== void 0) projectAssets.antiAiRules = body.antiAiRules;
4284
- if (body.styleAssets !== void 0) projectAssets.styleAssets = body.styleAssets;
4285
- projectAssets.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4286
- project.assets = projectAssets;
4383
+ if (body.genre !== void 0) projectAssets.genre = body.genre;
4384
+ if (body.primaryProgression !== void 0) projectAssets.primaryProgression = body.primaryProgression;
4385
+ if (body.auxiliaryProgressions !== void 0) projectAssets.auxiliaryProgressions = body.auxiliaryProgressions;
4386
+ if (body.antiAiRules !== void 0) projectAssets.antiAiRules = body.antiAiRules;
4387
+ if (body.styleAssets !== void 0) projectAssets.styleAssets = body.styleAssets;
4388
+ projectAssets.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4389
+ project.assets = projectAssets;
4390
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4391
+ saveProject(config.outputDir, project);
4392
+ }
4393
+ writeJson(res, 200, {
4394
+ projectAssets,
4395
+ genreLibrary: BUILTIN_GENRE_LIBRARY,
4396
+ antiAiLibrary: BUILTIN_ANTI_AI_RULES,
4397
+ styleTemplates: BUILTIN_STYLE_TEMPLATES,
4398
+ progressionLibrary: BUILTIN_PROGRESSION_MODES
4399
+ });
4400
+ }
4401
+ };
4402
+ const styleEngineRoute = {
4403
+ kind: "exact",
4404
+ path: NOVEL_API.styleEngine,
4405
+ handler: async (req, res) => {
4406
+ if (!guard(req, res, "POST")) return;
4407
+ const config = getConfig();
4408
+ const project = loadProject(config.outputDir);
4409
+ const body = await readJsonBody(req);
4410
+ const sample = body?.sampleText?.trim();
4411
+ if (sample === void 0 || sample.length < 50) {
4412
+ writeJson(res, 400, { error: "样本文本过短(<50 字符),请粘贴一段能代表目标风格的文字" });
4413
+ return;
4414
+ }
4415
+ try {
4416
+ const rules = await extractStyleAsset(ctx, config, sample);
4417
+ const styleAsset = {
4418
+ name: (body?.name?.trim() !== "" && body?.name !== void 0 ? body.name : `风格资产 ${Date.now().toString(36)}`).slice(0, 40),
4419
+ ...rules,
4420
+ sourceText: sample.slice(0, 3e3),
4421
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
4422
+ };
4423
+ if (project !== void 0) {
4424
+ project.assets ??= emptyProjectAssets();
4425
+ project.assets.styleAssets ??= [];
4426
+ project.assets.styleAssets.push(styleAsset);
4427
+ project.assets.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4287
4428
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4288
4429
  saveProject(config.outputDir, project);
4289
4430
  }
4290
- writeJson(res, 200, {
4291
- projectAssets,
4292
- genreLibrary: BUILTIN_GENRE_LIBRARY,
4293
- antiAiLibrary: BUILTIN_ANTI_AI_RULES,
4294
- styleTemplates: BUILTIN_STYLE_TEMPLATES,
4295
- progressionLibrary: BUILTIN_PROGRESSION_MODES
4296
- });
4431
+ writeJson(res, 200, { styleAsset });
4432
+ } catch (error) {
4433
+ writeJson(res, 500, { error: error.message });
4297
4434
  }
4298
- },
4299
- {
4300
- kind: "exact",
4301
- path: NOVEL_API.styleEngine,
4302
- handler: async (req, res) => {
4303
- if (!guard(req, res, "POST")) return;
4304
- const config = getConfig();
4305
- const project = loadProject(config.outputDir);
4306
- const body = await readJsonBody(req);
4307
- const sample = body?.sampleText?.trim();
4308
- if (sample === void 0 || sample.length < 50) {
4309
- writeJson(res, 400, { error: "样本文本过短(<50 字符),请粘贴一段能代表目标风格的文字" });
4435
+ }
4436
+ };
4437
+ const bookshelfRoute = {
4438
+ kind: "exact",
4439
+ path: NOVEL_API.bookshelf,
4440
+ handler: async (req, res) => {
4441
+ if (req.method === "GET") {
4442
+ if (!isLoopbackRequest(req)) {
4443
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
4310
4444
  return;
4311
4445
  }
4312
- try {
4313
- const rules = await extractStyleAsset(ctx, config, sample);
4314
- const styleAsset = {
4315
- name: (body?.name?.trim() !== "" && body?.name !== void 0 ? body.name : `风格资产 ${Date.now().toString(36)}`).slice(0, 40),
4316
- ...rules,
4317
- sourceText: sample.slice(0, 3e3),
4318
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
4319
- };
4320
- if (project !== void 0) {
4321
- project.assets ??= emptyProjectAssets();
4322
- project.assets.styleAssets ??= [];
4323
- project.assets.styleAssets.push(styleAsset);
4324
- project.assets.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4325
- project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4326
- saveProject(config.outputDir, project);
4327
- }
4328
- writeJson(res, 200, { styleAsset });
4329
- } catch (error) {
4330
- writeJson(res, 500, { error: error.message });
4331
- }
4446
+ seedBookshelfFromOutputDir(getConfig().outputDir);
4447
+ writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
4448
+ return;
4332
4449
  }
4333
- },
4334
- assistantRoute,
4335
- assistantHistoryRoute,
4336
- assistantClearRoute,
4337
- {
4338
- kind: "exact",
4339
- path: NOVEL_API.bookshelf,
4340
- handler: async (req, res) => {
4341
- if (req.method === "GET") {
4342
- if (!isLoopbackRequest(req)) {
4343
- writeJson(res, 403, { error: "forbidden: loopback-only" });
4344
- return;
4345
- }
4346
- seedBookshelfFromOutputDir(getConfig().outputDir);
4347
- writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
4348
- return;
4349
- }
4350
- if (req.method === "POST") {
4351
- if (!isLoopbackRequest(req)) {
4352
- writeJson(res, 403, { error: "forbidden: loopback-only" });
4353
- return;
4354
- }
4355
- const body = await readJsonBody(req);
4356
- const bookName = body?.bookName?.trim();
4357
- if (bookName === void 0 || bookName === "") {
4358
- writeJson(res, 400, { error: "bookName 不能为空" });
4359
- return;
4360
- }
4361
- const outputDir = body?.outputDir?.trim() !== "" && body?.outputDir !== void 0 ? body.outputDir : defaultOutputDirFor(bookName);
4362
- const book = createBook(bookName, outputDir);
4363
- const outline = body?.outline?.trim();
4364
- if (outline !== void 0 && outline.length >= 50) {
4365
- const project = createProject(outline);
4366
- saveProject(outputDir, project);
4367
- renameBook(book.id, project.bookName);
4368
- }
4369
- writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
4450
+ if (req.method === "POST") {
4451
+ if (!isLoopbackRequest(req)) {
4452
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
4370
4453
  return;
4371
4454
  }
4372
- writeJson(res, 405, { error: "method not allowed (expected GET or POST)" });
4373
- }
4374
- },
4375
- {
4376
- kind: "exact",
4377
- path: "/api/dsh-novel-forge/bookshelf/activate",
4378
- handler: async (req, res) => {
4379
- if (!guard(req, res, "POST")) return;
4380
4455
  const body = await readJsonBody(req);
4381
- if (body?.id === void 0 || body.id === "") {
4382
- writeJson(res, 400, { error: "id 不能为空" });
4456
+ const bookName = body?.bookName?.trim();
4457
+ if (bookName === void 0 || bookName === "") {
4458
+ writeJson(res, 400, { error: "bookName 不能为空" });
4383
4459
  return;
4384
4460
  }
4385
- if (activateBook(body.id) === void 0) {
4386
- writeJson(res, 404, { error: `书 ${body.id} 不存在` });
4387
- return;
4461
+ const outputDir = body?.outputDir?.trim() !== "" && body?.outputDir !== void 0 ? body.outputDir : defaultOutputDirFor(bookName);
4462
+ const book = createBook(bookName, outputDir);
4463
+ const outline = body?.outline?.trim();
4464
+ if (outline !== void 0 && outline.length >= 50) {
4465
+ const project = createProject(outline);
4466
+ saveProject(outputDir, project);
4467
+ renameBook(book.id, project.bookName);
4388
4468
  }
4389
4469
  writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
4470
+ return;
4390
4471
  }
4391
- },
4392
- {
4393
- kind: "exact",
4394
- path: "/api/dsh-novel-forge/bookshelf/remove",
4395
- handler: async (req, res) => {
4396
- if (!guard(req, res, "POST")) return;
4397
- const body = await readJsonBody(req);
4398
- if (body?.id === void 0 || body.id === "") {
4399
- writeJson(res, 400, { error: "id 不能为空" });
4400
- return;
4401
- }
4402
- if (!removeBook(body.id)) {
4403
- writeJson(res, 404, { error: `书 ${body.id} 不存在` });
4404
- return;
4405
- }
4406
- writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
4472
+ writeJson(res, 405, { error: "method not allowed (expected GET or POST)" });
4473
+ }
4474
+ };
4475
+ /** 重置项目:清空设定/卷/章节计划/正文/伏笔/资产/事实库(可携带新大纲)。 */
4476
+ const resetRoute = {
4477
+ kind: "exact",
4478
+ path: NOVEL_API.reset,
4479
+ handler: async (req, res) => {
4480
+ if (!guard(req, res, "POST")) return;
4481
+ const config = getConfig();
4482
+ const project = loadProject(config.outputDir);
4483
+ if (project === void 0) {
4484
+ writeJson(res, 400, { error: "输出目录中没有项目,无需重置" });
4485
+ return;
4407
4486
  }
4408
- },
4409
- {
4410
- kind: "exact",
4411
- path: NOVEL_API.reset,
4412
- handler: async (req, res) => {
4413
- if (!guard(req, res, "POST")) return;
4414
- const config = getConfig();
4415
- const project = loadProject(config.outputDir);
4416
- if (project === void 0) {
4417
- writeJson(res, 400, { error: "输出目录中没有项目,无需重置" });
4418
- return;
4419
- }
4420
- const outline = (await readJsonBody(req))?.outline?.trim();
4421
- if (outline !== void 0 && outline.length >= 50) {
4422
- project.outline = outline;
4423
- project.bookName = createProject(outline).bookName;
4424
- }
4425
- project.bible = void 0;
4426
- project.volumes = void 0;
4427
- project.chapters = [];
4428
- project.foreshadows = [];
4429
- project.assets = emptyProjectAssets();
4430
- project.facts = [];
4487
+ const outline = (await readJsonBody(req))?.outline?.trim();
4488
+ if (outline !== void 0 && outline.length >= 50) {
4489
+ project.outline = outline;
4490
+ project.bookName = createProject(outline).bookName;
4491
+ }
4492
+ project.bible = void 0;
4493
+ project.volumes = void 0;
4494
+ project.chapters = [];
4495
+ project.foreshadows = [];
4496
+ project.assets = emptyProjectAssets();
4497
+ project.facts = [];
4498
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4499
+ saveProject(config.outputDir, project);
4500
+ writeJson(res, 200, {
4501
+ ok: true,
4502
+ bookName: project.bookName
4503
+ });
4504
+ }
4505
+ };
4506
+ const bookshelfActivateRoute = {
4507
+ kind: "exact",
4508
+ path: "/api/dsh-novel-forge/bookshelf/activate",
4509
+ handler: async (req, res) => {
4510
+ if (!guard(req, res, "POST")) return;
4511
+ const body = await readJsonBody(req);
4512
+ if (body?.id === void 0 || body.id === "") {
4513
+ writeJson(res, 400, { error: "id 不能为空" });
4514
+ return;
4515
+ }
4516
+ if (activateBook(body.id) === void 0) {
4517
+ writeJson(res, 404, { error: `书 ${body.id} 不存在` });
4518
+ return;
4519
+ }
4520
+ writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
4521
+ }
4522
+ };
4523
+ const bookshelfRemoveRoute = {
4524
+ kind: "exact",
4525
+ path: "/api/dsh-novel-forge/bookshelf/remove",
4526
+ handler: async (req, res) => {
4527
+ if (!guard(req, res, "POST")) return;
4528
+ const body = await readJsonBody(req);
4529
+ if (body?.id === void 0 || body.id === "") {
4530
+ writeJson(res, 400, { error: "id 不能为空" });
4531
+ return;
4532
+ }
4533
+ if (!removeBook(body.id)) {
4534
+ writeJson(res, 404, { error: `书 ${body.id} 不存在` });
4535
+ return;
4536
+ }
4537
+ writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
4538
+ }
4539
+ };
4540
+ /** 全书一致性质检。 */
4541
+ const auditRoute = {
4542
+ kind: "exact",
4543
+ path: NOVEL_API.audit,
4544
+ handler: async (req, res) => {
4545
+ if (!guard(req, res, "POST")) return;
4546
+ const config = getConfig();
4547
+ const project = requireProject(res);
4548
+ if (project === void 0) return;
4549
+ try {
4550
+ writeJson(res, 200, {
4551
+ issues: await auditBook(ctx, config, project, config.outputDir),
4552
+ auditedChapters: project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating").length,
4553
+ auditedAt: (/* @__PURE__ */ new Date()).toISOString()
4554
+ });
4555
+ } catch (error) {
4556
+ writeJson(res, 500, { error: error.message });
4557
+ }
4558
+ }
4559
+ };
4560
+ /** 角色卡刷新(出场统计精确化 + LLM 聚合状态)。 */
4561
+ const charactersRefreshRoute = {
4562
+ kind: "exact",
4563
+ path: NOVEL_API.charactersRefresh,
4564
+ handler: async (req, res) => {
4565
+ if (!guard(req, res, "POST")) return;
4566
+ const config = getConfig();
4567
+ const project = requireProject(res);
4568
+ if (project === void 0) return;
4569
+ try {
4570
+ const cards = await refreshCharacters(ctx, config, project, config.outputDir);
4571
+ project.roleStatus = cards;
4431
4572
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4432
4573
  saveProject(config.outputDir, project);
4574
+ writeJson(res, 200, { cards });
4575
+ } catch (error) {
4576
+ writeJson(res, 500, { error: error.message });
4577
+ }
4578
+ }
4579
+ };
4580
+ /** 事实库回填:对历史已生成章节批量抽取事实。 */
4581
+ const factsBackfillRoute = {
4582
+ kind: "exact",
4583
+ path: NOVEL_API.factsBackfill,
4584
+ handler: async (req, res) => {
4585
+ if (!guard(req, res, "POST")) return;
4586
+ const config = getConfig();
4587
+ const project = requireProject(res);
4588
+ if (project === void 0) return;
4589
+ try {
4433
4590
  writeJson(res, 200, {
4434
4591
  ok: true,
4435
- bookName: project.bookName
4592
+ filled: await backfillFacts(ctx, config, project, config.outputDir)
4436
4593
  });
4594
+ } catch (error) {
4595
+ writeJson(res, 500, { error: error.message });
4437
4596
  }
4438
- },
4439
- {
4440
- kind: "exact",
4441
- path: NOVEL_API.audit,
4442
- handler: async (req, res) => {
4443
- if (!guard(req, res, "POST")) return;
4444
- const config = getConfig();
4445
- const project = requireProject(res);
4446
- if (project === void 0) return;
4447
- try {
4448
- writeJson(res, 200, {
4449
- issues: await auditBook(ctx, config, project, config.outputDir),
4450
- auditedChapters: project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating").length,
4451
- auditedAt: (/* @__PURE__ */ new Date()).toISOString()
4452
- });
4453
- } catch (error) {
4454
- writeJson(res, 500, { error: error.message });
4455
- }
4597
+ }
4598
+ };
4599
+ /** 设定圣经局部修补(世界观规则/红线/风格)。 */
4600
+ const biblePatchRoute = {
4601
+ kind: "exact",
4602
+ path: NOVEL_API.biblePatch,
4603
+ handler: async (req, res) => {
4604
+ if (!guard(req, res, "POST")) return;
4605
+ const config = getConfig();
4606
+ const project = requireProject(res);
4607
+ if (project === void 0) return;
4608
+ if (project.bible === void 0) {
4609
+ writeJson(res, 400, { error: "尚未生成道藏,请先生成" });
4610
+ return;
4456
4611
  }
4457
- },
4458
- {
4459
- kind: "exact",
4460
- path: NOVEL_API.charactersRefresh,
4461
- handler: async (req, res) => {
4462
- if (!guard(req, res, "POST")) return;
4463
- const config = getConfig();
4464
- const project = requireProject(res);
4465
- if (project === void 0) return;
4466
- try {
4467
- writeJson(res, 200, { cards: await refreshCharacters(ctx, config, project, config.outputDir) });
4468
- } catch (error) {
4469
- writeJson(res, 500, { error: error.message });
4612
+ const body = await readJsonBody(req);
4613
+ if (Array.isArray(body?.worldRules)) project.bible.worldRules = body.worldRules.filter((r) => r.trim() !== "");
4614
+ if (Array.isArray(body?.redLines)) project.bible.redLines = body.redLines.filter((r) => r.trim() !== "");
4615
+ if (Array.isArray(body?.style)) project.bible.style = body.style.filter((r) => r.trim() !== "");
4616
+ if (Array.isArray(body?.characters)) project.bible.characters = body.characters.filter((c) => c !== void 0 && c !== null && typeof c.name === "string" && c.name.trim() !== "").map((c) => ({
4617
+ name: c.name.trim(),
4618
+ role: [
4619
+ "protagonist",
4620
+ "supporting",
4621
+ "antagonist",
4622
+ "other"
4623
+ ].includes(c.role) ? c.role : "other",
4624
+ traits: Array.isArray(c.traits) ? c.traits.filter((t) => typeof t === "string" && t.trim() !== "") : [],
4625
+ goals: typeof c.goals === "string" ? c.goals : "",
4626
+ relations: typeof c.relations === "string" ? c.relations : "",
4627
+ knowledge: Array.isArray(c.knowledge) ? c.knowledge.filter((k) => typeof k === "string" && k.trim() !== "") : void 0
4628
+ }));
4629
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4630
+ saveProject(config.outputDir, project);
4631
+ writeJson(res, 200, { bible: project.bible });
4632
+ }
4633
+ };
4634
+ /** 小说简介:AI 生成/补全,或手动保存。 */
4635
+ const blurbRoute = {
4636
+ kind: "exact",
4637
+ path: NOVEL_API.blurb,
4638
+ handler: async (req, res) => {
4639
+ if (!guard(req, res, "POST")) return;
4640
+ const config = getConfig();
4641
+ const project = requireProject(res);
4642
+ if (project === void 0) return;
4643
+ const body = await readJsonBody(req);
4644
+ try {
4645
+ if (body?.action === "save") {
4646
+ const text = body.text?.trim() ?? "";
4647
+ project.blurb = text;
4648
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4649
+ saveProject(config.outputDir, project);
4650
+ writeJson(res, 200, { blurb: text });
4651
+ return;
4470
4652
  }
4653
+ const partial = body?.partial?.trim() ?? "";
4654
+ const blurb = await generateBlurb(ctx, config, project, partial);
4655
+ project.blurb = blurb;
4656
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4657
+ saveProject(config.outputDir, project);
4658
+ writeJson(res, 200, { blurb });
4659
+ } catch (error) {
4660
+ writeJson(res, 500, { error: error.message });
4471
4661
  }
4472
- },
4473
- {
4474
- kind: "exact",
4475
- path: NOVEL_API.factsBackfill,
4476
- handler: async (req, res) => {
4477
- if (!guard(req, res, "POST")) return;
4478
- const config = getConfig();
4479
- const project = requireProject(res);
4480
- if (project === void 0) return;
4481
- try {
4482
- writeJson(res, 200, {
4483
- ok: true,
4484
- filled: await backfillFacts(ctx, config, project, config.outputDir)
4485
- });
4486
- } catch (error) {
4487
- writeJson(res, 500, { error: error.message });
4662
+ }
4663
+ };
4664
+ /** 封面:GET 读取(dataUrl);POST 上传(base64)或移除。 */
4665
+ const coverRoute = {
4666
+ kind: "exact",
4667
+ path: NOVEL_API.cover,
4668
+ handler: async (req, res) => {
4669
+ const config = getConfig();
4670
+ if (req.method === "GET") {
4671
+ if (!isLoopbackRequest(req)) {
4672
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
4673
+ return;
4488
4674
  }
4489
- }
4490
- },
4491
- {
4492
- kind: "exact",
4493
- path: NOVEL_API.biblePatch,
4494
- handler: async (req, res) => {
4495
- if (!guard(req, res, "POST")) return;
4496
- const config = getConfig();
4497
- const project = requireProject(res);
4498
- if (project === void 0) return;
4499
- if (project.bible === void 0) {
4500
- writeJson(res, 400, { error: "尚未生成道藏,请先生成" });
4675
+ const dirParam = new URL(req.url ?? "/", "http://localhost").searchParams.get("dir");
4676
+ const targetDir = dirParam !== null && dirParam !== "" ? dirParam : config.outputDir;
4677
+ const coverPath = loadProject(targetDir)?.coverPath;
4678
+ if (coverPath === void 0 || coverPath === "") {
4679
+ writeJson(res, 200, { dataUrl: null });
4501
4680
  return;
4502
4681
  }
4503
- const body = await readJsonBody(req);
4504
- if (Array.isArray(body?.worldRules)) project.bible.worldRules = body.worldRules.filter((r) => r.trim() !== "");
4505
- if (Array.isArray(body?.redLines)) project.bible.redLines = body.redLines.filter((r) => r.trim() !== "");
4506
- if (Array.isArray(body?.style)) project.bible.style = body.style.filter((r) => r.trim() !== "");
4507
- if (Array.isArray(body?.characters)) project.bible.characters = body.characters.filter((c) => c !== void 0 && c !== null && typeof c.name === "string" && c.name.trim() !== "").map((c) => ({
4508
- name: c.name.trim(),
4509
- role: [
4510
- "protagonist",
4511
- "supporting",
4512
- "antagonist",
4513
- "other"
4514
- ].includes(c.role) ? c.role : "other",
4515
- traits: Array.isArray(c.traits) ? c.traits.filter((t) => typeof t === "string" && t.trim() !== "") : [],
4516
- goals: typeof c.goals === "string" ? c.goals : "",
4517
- relations: typeof c.relations === "string" ? c.relations : "",
4518
- knowledge: Array.isArray(c.knowledge) ? c.knowledge.filter((k) => typeof k === "string" && k.trim() !== "") : void 0
4519
- }));
4520
- project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4521
- saveProject(config.outputDir, project);
4522
- writeJson(res, 200, { bible: project.bible });
4682
+ const file = join(targetDir, coverPath);
4683
+ if (!existsSync(file)) {
4684
+ writeJson(res, 200, { dataUrl: null });
4685
+ return;
4686
+ }
4687
+ writeJson(res, 200, { dataUrl: `data:${coverPath.toLowerCase().endsWith(".png") ? "image/png" : coverPath.toLowerCase().endsWith(".jpg") || coverPath.toLowerCase().endsWith(".jpeg") ? "image/jpeg" : coverPath.toLowerCase().endsWith(".webp") ? "image/webp" : "image/png"};base64,${readFileSync(file).toString("base64")}` });
4688
+ return;
4523
4689
  }
4524
- },
4525
- {
4526
- kind: "exact",
4527
- path: NOVEL_API.blurb,
4528
- handler: async (req, res) => {
4690
+ if (req.method === "POST") {
4529
4691
  if (!guard(req, res, "POST")) return;
4530
- const config = getConfig();
4531
4692
  const project = requireProject(res);
4532
4693
  if (project === void 0) return;
4533
4694
  const body = await readJsonBody(req);
4534
4695
  try {
4535
- if (body?.action === "save") {
4536
- const text = body.text?.trim() ?? "";
4537
- project.blurb = text;
4538
- project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4539
- saveProject(config.outputDir, project);
4540
- writeJson(res, 200, { blurb: text });
4541
- return;
4542
- }
4543
- const partial = body?.partial?.trim() ?? "";
4544
- const blurb = await generateBlurb(ctx, config, project, partial);
4545
- project.blurb = blurb;
4546
- project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4547
- saveProject(config.outputDir, project);
4548
- writeJson(res, 200, { blurb });
4549
- } catch (error) {
4550
- writeJson(res, 500, { error: error.message });
4551
- }
4552
- }
4553
- },
4554
- {
4555
- kind: "exact",
4556
- path: NOVEL_API.cover,
4557
- handler: async (req, res) => {
4558
- const config = getConfig();
4559
- if (req.method === "GET") {
4560
- if (!isLoopbackRequest(req)) {
4561
- writeJson(res, 403, { error: "forbidden: loopback-only" });
4562
- return;
4563
- }
4564
- const dirParam = new URL(req.url ?? "/", "http://localhost").searchParams.get("dir");
4565
- const targetDir = dirParam !== null && dirParam !== "" ? dirParam : config.outputDir;
4566
- const coverPath = loadProject(targetDir)?.coverPath;
4567
- if (coverPath === void 0 || coverPath === "") {
4568
- writeJson(res, 200, { dataUrl: null });
4569
- return;
4570
- }
4571
- const file = join(targetDir, coverPath);
4572
- if (!existsSync(file)) {
4573
- writeJson(res, 200, { dataUrl: null });
4574
- return;
4575
- }
4576
- writeJson(res, 200, { dataUrl: `data:${coverPath.toLowerCase().endsWith(".png") ? "image/png" : coverPath.toLowerCase().endsWith(".jpg") || coverPath.toLowerCase().endsWith(".jpeg") ? "image/jpeg" : coverPath.toLowerCase().endsWith(".webp") ? "image/webp" : "image/png"};base64,${readFileSync(file).toString("base64")}` });
4577
- return;
4578
- }
4579
- if (req.method === "POST") {
4580
- if (!guard(req, res, "POST")) return;
4581
- const project = requireProject(res);
4582
- if (project === void 0) return;
4583
- const body = await readJsonBody(req);
4584
- try {
4585
- if (body?.action === "remove") {
4586
- if (project.coverPath !== void 0 && project.coverPath !== "") {
4587
- const oldFile = join(config.outputDir, project.coverPath);
4588
- if (existsSync(oldFile)) rmSync(oldFile, { force: true });
4589
- }
4590
- project.coverPath = void 0;
4591
- project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4592
- saveProject(config.outputDir, project);
4593
- writeJson(res, 200, {
4594
- ok: true,
4595
- coverPath: null
4596
- });
4597
- return;
4598
- }
4599
- const dataUrl = body?.dataUrl ?? "";
4600
- const match = /^data:(image\/(?:png|jpeg|jpg|webp));base64,(.+)$/s.exec(dataUrl);
4601
- if (match === null) {
4602
- writeJson(res, 400, { error: "封面须为 PNG/JPEG/WebP 的 base64 data URL" });
4603
- return;
4604
- }
4605
- const mime = match[1];
4606
- const fileName = `cover.${mime === "image/png" ? "png" : mime === "image/webp" ? "webp" : "jpg"}`;
4607
- const targetPath = join(config.outputDir, fileName);
4608
- const oldPath = project.coverPath;
4609
- mkdirSync(config.outputDir, { recursive: true });
4610
- writeFileSync(targetPath, Buffer.from(match[2], "base64"));
4611
- if (oldPath !== void 0 && oldPath !== "" && oldPath !== fileName) {
4612
- const oldFile = join(config.outputDir, oldPath);
4696
+ if (body?.action === "remove") {
4697
+ if (project.coverPath !== void 0 && project.coverPath !== "") {
4698
+ const oldFile = join(config.outputDir, project.coverPath);
4613
4699
  if (existsSync(oldFile)) rmSync(oldFile, { force: true });
4614
4700
  }
4615
- project.coverPath = fileName;
4701
+ project.coverPath = void 0;
4616
4702
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4617
4703
  saveProject(config.outputDir, project);
4618
4704
  writeJson(res, 200, {
4619
4705
  ok: true,
4620
- coverPath: fileName
4706
+ coverPath: null
4621
4707
  });
4622
- } catch (error) {
4623
- writeJson(res, 500, { error: error.message });
4708
+ return;
4624
4709
  }
4625
- return;
4626
- }
4627
- writeJson(res, 405, { error: "method not allowed (expected GET or POST)" });
4628
- }
4629
- },
4630
- {
4631
- kind: "exact",
4632
- path: NOVEL_API.world,
4633
- handler: async (req, res) => {
4634
- if (!guard(req, res, "POST")) return;
4635
- const config = getConfig();
4636
- const project = requireProject(res);
4637
- if (project === void 0) return;
4638
- const body = await readJsonBody(req);
4639
- try {
4640
- if (body?.action === "save" && body.world !== void 0) {
4641
- project.world = {
4642
- realms: Array.isArray(body.world.realms) ? body.world.realms : [],
4643
- regions: Array.isArray(body.world.regions) ? body.world.regions : [],
4644
- factions: Array.isArray(body.world.factions) ? body.world.factions : []
4645
- };
4646
- project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4647
- saveProject(config.outputDir, project);
4648
- writeJson(res, 200, { world: project.world });
4710
+ const dataUrl = body?.dataUrl ?? "";
4711
+ const match = /^data:(image\/(?:png|jpeg|jpg|webp));base64,(.+)$/s.exec(dataUrl);
4712
+ if (match === null) {
4713
+ writeJson(res, 400, { error: "封面须为 PNG/JPEG/WebP 的 base64 data URL" });
4649
4714
  return;
4650
4715
  }
4651
- const world = await extractWorld(ctx, config, project);
4652
- project.world = world;
4716
+ const mime = match[1];
4717
+ const fileName = `cover.${mime === "image/png" ? "png" : mime === "image/webp" ? "webp" : "jpg"}`;
4718
+ const targetPath = join(config.outputDir, fileName);
4719
+ const oldPath = project.coverPath;
4720
+ mkdirSync(config.outputDir, { recursive: true });
4721
+ writeFileSync(targetPath, Buffer.from(match[2], "base64"));
4722
+ if (oldPath !== void 0 && oldPath !== "" && oldPath !== fileName) {
4723
+ const oldFile = join(config.outputDir, oldPath);
4724
+ if (existsSync(oldFile)) rmSync(oldFile, { force: true });
4725
+ }
4726
+ project.coverPath = fileName;
4653
4727
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4654
4728
  saveProject(config.outputDir, project);
4655
- writeJson(res, 200, { world });
4729
+ writeJson(res, 200, {
4730
+ ok: true,
4731
+ coverPath: fileName
4732
+ });
4656
4733
  } catch (error) {
4657
4734
  writeJson(res, 500, { error: error.message });
4658
4735
  }
4736
+ return;
4659
4737
  }
4660
- },
4661
- {
4662
- kind: "exact",
4663
- path: NOVEL_API.rename,
4664
- handler: async (req, res) => {
4665
- if (!guard(req, res, "POST")) return;
4666
- const config = getConfig();
4667
- const project = requireProject(res);
4668
- if (project === void 0) return;
4669
- const bookName = (await readJsonBody(req))?.bookName?.trim();
4670
- if (bookName === void 0 || bookName === "") {
4671
- writeJson(res, 400, { error: "书名不能为空" });
4738
+ writeJson(res, 405, { error: "method not allowed (expected GET or POST)" });
4739
+ }
4740
+ };
4741
+ /** 大世界:AI 提炼或手动保存(境界体系/区域/势力)。 */
4742
+ const worldRoute = {
4743
+ kind: "exact",
4744
+ path: NOVEL_API.world,
4745
+ handler: async (req, res) => {
4746
+ if (!guard(req, res, "POST")) return;
4747
+ const config = getConfig();
4748
+ const project = requireProject(res);
4749
+ if (project === void 0) return;
4750
+ const body = await readJsonBody(req);
4751
+ try {
4752
+ if (body?.action === "save" && body.world !== void 0) {
4753
+ project.world = {
4754
+ realms: Array.isArray(body.world.realms) ? body.world.realms : [],
4755
+ regions: Array.isArray(body.world.regions) ? body.world.regions : [],
4756
+ factions: Array.isArray(body.world.factions) ? body.world.factions : []
4757
+ };
4758
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4759
+ saveProject(config.outputDir, project);
4760
+ writeJson(res, 200, { world: project.world });
4672
4761
  return;
4673
4762
  }
4674
- project.bookName = bookName.slice(0, 60);
4763
+ const world = await extractWorld(ctx, config, project);
4764
+ project.world = world;
4675
4765
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4676
4766
  saveProject(config.outputDir, project);
4677
- const store = loadBookshelf();
4678
- if (store.activeBookId !== null) renameBook(store.activeBookId, project.bookName);
4679
- writeJson(res, 200, { bookName: project.bookName });
4767
+ writeJson(res, 200, { world });
4768
+ } catch (error) {
4769
+ writeJson(res, 500, { error: error.message });
4680
4770
  }
4681
- },
4682
- {
4683
- kind: "exact",
4684
- path: NOVEL_API.plotlines,
4685
- handler: async (req, res) => {
4686
- if (!guard(req, res, "POST")) return;
4687
- const config = getConfig();
4688
- const project = requireProject(res);
4689
- if (project === void 0) return;
4690
- const body = await readJsonBody(req);
4691
- if (project.plotlines === void 0) project.plotlines = [];
4692
- const op = body?.op;
4693
- if (op === "add" && body?.line !== void 0) {
4771
+ }
4772
+ };
4773
+ /** 重命名当前书:同步项目 bookName 与书架条目。 */
4774
+ const renameRoute = {
4775
+ kind: "exact",
4776
+ path: NOVEL_API.rename,
4777
+ handler: async (req, res) => {
4778
+ if (!guard(req, res, "POST")) return;
4779
+ const config = getConfig();
4780
+ const project = requireProject(res);
4781
+ if (project === void 0) return;
4782
+ const bookName = (await readJsonBody(req))?.bookName?.trim();
4783
+ if (bookName === void 0 || bookName === "") {
4784
+ writeJson(res, 400, { error: "书名不能为空" });
4785
+ return;
4786
+ }
4787
+ project.bookName = bookName.slice(0, 60);
4788
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4789
+ saveProject(config.outputDir, project);
4790
+ const store = loadBookshelf();
4791
+ if (store.activeBookId !== null) renameBook(store.activeBookId, project.bookName);
4792
+ writeJson(res, 200, { bookName: project.bookName });
4793
+ }
4794
+ };
4795
+ /** 剧情线管理:增删改 + 关联章节(主线/支线/人物线/悬念线)。 */
4796
+ const plotlinesRoute = {
4797
+ kind: "exact",
4798
+ path: NOVEL_API.plotlines,
4799
+ handler: async (req, res) => {
4800
+ if (!guard(req, res, "POST")) return;
4801
+ const config = getConfig();
4802
+ const project = requireProject(res);
4803
+ if (project === void 0) return;
4804
+ const body = await readJsonBody(req);
4805
+ if (project.plotlines === void 0) project.plotlines = [];
4806
+ const op = body?.op;
4807
+ if (op === "add" && body?.line !== void 0) {
4808
+ const line = body.line;
4809
+ project.plotlines.push({
4810
+ id: line.id !== "" ? line.id : `pl-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
4811
+ name: line.name.slice(0, 40),
4812
+ kind: line.kind,
4813
+ goal: line.goal.slice(0, 300),
4814
+ progress: line.progress.slice(0, 300),
4815
+ status: line.status,
4816
+ chapters: Array.isArray(line.chapters) ? line.chapters.filter((n) => typeof n === "number") : [],
4817
+ createdAt: line.createdAt !== "" ? line.createdAt : (/* @__PURE__ */ new Date()).toISOString()
4818
+ });
4819
+ } else if (op === "update" && body?.line !== void 0 && body.line.id !== "") {
4820
+ const idx = project.plotlines.findIndex((l) => l.id === body.line.id);
4821
+ if (idx !== -1) {
4694
4822
  const line = body.line;
4695
- project.plotlines.push({
4696
- id: line.id !== "" ? line.id : `pl-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
4823
+ project.plotlines[idx] = {
4824
+ ...project.plotlines[idx],
4697
4825
  name: line.name.slice(0, 40),
4698
4826
  kind: line.kind,
4699
4827
  goal: line.goal.slice(0, 300),
4700
4828
  progress: line.progress.slice(0, 300),
4701
- status: line.status,
4702
- chapters: Array.isArray(line.chapters) ? line.chapters.filter((n) => typeof n === "number") : [],
4703
- createdAt: line.createdAt !== "" ? line.createdAt : (/* @__PURE__ */ new Date()).toISOString()
4704
- });
4705
- } else if (op === "update" && body?.line !== void 0 && body.line.id !== "") {
4706
- const idx = project.plotlines.findIndex((l) => l.id === body.line.id);
4707
- if (idx !== -1) {
4708
- const line = body.line;
4709
- project.plotlines[idx] = {
4710
- ...project.plotlines[idx],
4711
- name: line.name.slice(0, 40),
4712
- kind: line.kind,
4713
- goal: line.goal.slice(0, 300),
4714
- progress: line.progress.slice(0, 300),
4715
- status: line.status
4716
- };
4717
- }
4718
- } else if (op === "remove" && body?.id !== void 0) project.plotlines = project.plotlines.filter((l) => l.id !== body.id);
4719
- else if (op === "link" && body?.id !== void 0 && typeof body.chapterNo === "number" && body.chapterNo > 0) {
4720
- const line = project.plotlines.find((l) => l.id === body.id);
4721
- if (line !== void 0 && !line.chapters.includes(body.chapterNo)) {
4722
- line.chapters.push(body.chapterNo);
4723
- if (line.status === "active" && line.progress === "") line.progress = `推进至第 ${body.chapterNo} 章`;
4724
- }
4725
- } else if (op === "suggest") try {
4726
- const suggestions = await suggestPlotlines(ctx, config, project);
4727
- writeJson(res, 200, {
4728
- plotlines: project.plotlines,
4729
- suggestions
4730
- });
4731
- return;
4732
- } catch (error) {
4733
- writeJson(res, 500, { error: `AI 建议失败:${error.message}` });
4734
- return;
4829
+ status: line.status
4830
+ };
4735
4831
  }
4736
- else if (op === "refresh" && body?.id !== void 0) {
4737
- const line = project.plotlines.find((l) => l.id === body.id);
4738
- if (line === void 0) {
4739
- writeJson(res, 404, { error: "剧情线不存在" });
4740
- return;
4741
- }
4742
- try {
4743
- line.progress = await refreshPlotlineProgress(ctx, config, project, line);
4744
- project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4745
- saveProject(config.outputDir, project);
4746
- writeJson(res, 200, { plotlines: project.plotlines });
4747
- return;
4748
- } catch (error) {
4749
- writeJson(res, 500, { error: `刷新进度失败:${error.message}` });
4750
- return;
4751
- }
4752
- } else if (op === "health") try {
4753
- const health = await analyzePlotlineHealth(ctx, config, project);
4754
- writeJson(res, 200, {
4755
- plotlines: project.plotlines,
4756
- health
4757
- });
4758
- return;
4759
- } catch (error) {
4760
- writeJson(res, 500, { error: `健康检查失败:${error.message}` });
4832
+ } else if (op === "remove" && body?.id !== void 0) project.plotlines = project.plotlines.filter((l) => l.id !== body.id);
4833
+ else if (op === "link" && body?.id !== void 0 && typeof body.chapterNo === "number" && body.chapterNo > 0) {
4834
+ const line = project.plotlines.find((l) => l.id === body.id);
4835
+ if (line !== void 0 && !line.chapters.includes(body.chapterNo)) {
4836
+ line.chapters.push(body.chapterNo);
4837
+ if (line.status === "active" && line.progress === "") line.progress = `推进至第 ${body.chapterNo} 章`;
4838
+ }
4839
+ } else if (op === "suggest") try {
4840
+ const suggestions = await suggestPlotlines(ctx, config, project);
4841
+ writeJson(res, 200, {
4842
+ plotlines: project.plotlines,
4843
+ suggestions
4844
+ });
4845
+ return;
4846
+ } catch (error) {
4847
+ writeJson(res, 500, { error: `AI 建议失败:${error.message}` });
4848
+ return;
4849
+ }
4850
+ else if (op === "refresh" && body?.id !== void 0) {
4851
+ const line = project.plotlines.find((l) => l.id === body.id);
4852
+ if (line === void 0) {
4853
+ writeJson(res, 404, { error: "剧情线不存在" });
4761
4854
  return;
4762
4855
  }
4763
- else if (op === "plan") try {
4764
- const health = await analyzePlotlineHealth(ctx, config, project);
4765
- const plan = await designPlotlinePlan(ctx, config, project, health);
4766
- writeJson(res, 200, {
4767
- plotlines: project.plotlines,
4768
- health,
4769
- plan
4770
- });
4856
+ try {
4857
+ line.progress = await refreshPlotlineProgress(ctx, config, project, line);
4858
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4859
+ saveProject(config.outputDir, project);
4860
+ writeJson(res, 200, { plotlines: project.plotlines });
4771
4861
  return;
4772
4862
  } catch (error) {
4773
- writeJson(res, 500, { error: `剧情方案生成失败:${error.message}` });
4863
+ writeJson(res, 500, { error: `刷新进度失败:${error.message}` });
4774
4864
  return;
4775
4865
  }
4776
- project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4777
- saveProject(config.outputDir, project);
4778
- writeJson(res, 200, { plotlines: project.plotlines });
4866
+ } else if (op === "health") try {
4867
+ const health = await analyzePlotlineHealth(ctx, config, project);
4868
+ writeJson(res, 200, {
4869
+ plotlines: project.plotlines,
4870
+ health
4871
+ });
4872
+ return;
4873
+ } catch (error) {
4874
+ writeJson(res, 500, { error: `健康检查失败:${error.message}` });
4875
+ return;
4779
4876
  }
4780
- },
4781
- {
4782
- kind: "exact",
4783
- path: NOVEL_API.sensitiveCheck,
4784
- handler: async (req, res) => {
4785
- if (!guard(req, res, "POST")) return;
4786
- const config = getConfig();
4787
- const project = requireProject(res);
4788
- if (project === void 0) return;
4789
- const body = await readJsonBody(req);
4790
- const hits = [];
4791
- let scanned = 0;
4792
- if (body?.text !== void 0) for (const hit of checkSensitiveText(body.text)) hits.push({
4793
- chapterNo: 0,
4877
+ else if (op === "plan") try {
4878
+ const health = await analyzePlotlineHealth(ctx, config, project);
4879
+ const plan = await designPlotlinePlan(ctx, config, project, health);
4880
+ writeJson(res, 200, {
4881
+ plotlines: project.plotlines,
4882
+ health,
4883
+ plan
4884
+ });
4885
+ return;
4886
+ } catch (error) {
4887
+ writeJson(res, 500, { error: `剧情方案生成失败:${error.message}` });
4888
+ return;
4889
+ }
4890
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4891
+ saveProject(config.outputDir, project);
4892
+ writeJson(res, 200, { plotlines: project.plotlines });
4893
+ }
4894
+ };
4895
+ /** 敏感词检查:指定章节 / 任意文本 / 全书已写章节。 */
4896
+ const sensitiveRoute = {
4897
+ kind: "exact",
4898
+ path: NOVEL_API.sensitiveCheck,
4899
+ handler: async (req, res) => {
4900
+ if (!guard(req, res, "POST")) return;
4901
+ const config = getConfig();
4902
+ const project = requireProject(res);
4903
+ if (project === void 0) return;
4904
+ const body = await readJsonBody(req);
4905
+ const hits = [];
4906
+ let scanned = 0;
4907
+ if (body?.text !== void 0) for (const hit of checkSensitiveText(body.text)) hits.push({
4908
+ chapterNo: 0,
4909
+ word: hit.word,
4910
+ category: hit.category,
4911
+ count: hit.count
4912
+ });
4913
+ else if (typeof body?.chapterNo === "number") {
4914
+ const chapter = project.chapters.find((c) => c.no === body.chapterNo);
4915
+ if (chapter !== void 0) {
4916
+ const text = readChapterFile(config.outputDir, chapter);
4917
+ if (text !== void 0) {
4918
+ scanned = 1;
4919
+ for (const hit of checkSensitiveText(text)) hits.push({
4920
+ chapterNo: chapter.no,
4921
+ word: hit.word,
4922
+ category: hit.category,
4923
+ count: hit.count
4924
+ });
4925
+ }
4926
+ }
4927
+ } else if (body?.all === true) for (const chapter of project.chapters) {
4928
+ if (chapter.status === "pending" || chapter.status === "generating") continue;
4929
+ const text = readChapterFile(config.outputDir, chapter);
4930
+ if (text === void 0) continue;
4931
+ scanned++;
4932
+ for (const hit of checkSensitiveText(text)) hits.push({
4933
+ chapterNo: chapter.no,
4794
4934
  word: hit.word,
4795
4935
  category: hit.category,
4796
4936
  count: hit.count
4797
4937
  });
4798
- else if (typeof body?.chapterNo === "number") {
4799
- const chapter = project.chapters.find((c) => c.no === body.chapterNo);
4800
- if (chapter !== void 0) {
4801
- const text = readChapterFile(config.outputDir, chapter);
4802
- if (text !== void 0) {
4803
- scanned = 1;
4804
- for (const hit of checkSensitiveText(text)) hits.push({
4805
- chapterNo: chapter.no,
4806
- word: hit.word,
4807
- category: hit.category,
4808
- count: hit.count
4809
- });
4810
- }
4811
- }
4812
- } else if (body?.all === true) for (const chapter of project.chapters) {
4813
- if (chapter.status === "pending" || chapter.status === "generating") continue;
4814
- const text = readChapterFile(config.outputDir, chapter);
4815
- if (text === void 0) continue;
4816
- scanned++;
4817
- for (const hit of checkSensitiveText(text)) hits.push({
4818
- chapterNo: chapter.no,
4819
- word: hit.word,
4820
- category: hit.category,
4821
- count: hit.count
4822
- });
4823
- }
4824
- else {
4825
- writeJson(res, 400, { error: "请提供 chapterNo / text / all 之一" });
4826
- return;
4827
- }
4938
+ }
4939
+ else {
4940
+ writeJson(res, 400, { error: "请提供 chapterNo / text / all 之一" });
4941
+ return;
4942
+ }
4943
+ writeJson(res, 200, {
4944
+ hits,
4945
+ scannedChapters: scanned
4946
+ });
4947
+ }
4948
+ };
4949
+ /** 角色库:AI 提炼 / 采纳 / 更新 / 删除。 */
4950
+ const rolesRoute = {
4951
+ kind: "exact",
4952
+ path: NOVEL_API.roles,
4953
+ handler: async (req, res) => {
4954
+ if (!guard(req, res, "POST")) return;
4955
+ const config = getConfig();
4956
+ const project = requireProject(res);
4957
+ if (project === void 0) return;
4958
+ const body = await readJsonBody(req);
4959
+ if (project.roles === void 0) project.roles = [];
4960
+ const op = body?.op;
4961
+ if (op === "extract") try {
4962
+ const candidates = await extractRoles(ctx, config, project);
4828
4963
  writeJson(res, 200, {
4829
- hits,
4830
- scannedChapters: scanned
4964
+ roles: project.roles,
4965
+ candidates
4831
4966
  });
4967
+ return;
4968
+ } catch (error) {
4969
+ writeJson(res, 500, { error: `角色提炼失败:${error.message}` });
4970
+ return;
4832
4971
  }
4833
- },
4972
+ else if ((op === "adopt" || op === "update") && body?.role !== void 0) {
4973
+ const r = body.role;
4974
+ const idx = project.roles.findIndex((x) => x.name === r.name);
4975
+ if (idx === -1) project.roles.push(r);
4976
+ else project.roles[idx] = r;
4977
+ } else if (op === "remove" && body?.name !== void 0) project.roles = project.roles.filter((x) => x.name !== body.name);
4978
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4979
+ saveProject(config.outputDir, project);
4980
+ writeJson(res, 200, { roles: project.roles });
4981
+ }
4982
+ };
4983
+ /** 章节复位:generating 卡死 → pending(可重新生成)。 */
4984
+ const chapterResetRoute = {
4985
+ kind: "exact",
4986
+ path: NOVEL_API.chapterReset,
4987
+ handler: async (req, res) => {
4988
+ if (!guard(req, res, "POST")) return;
4989
+ const config = getConfig();
4990
+ const project = requireProject(res);
4991
+ if (project === void 0) return;
4992
+ const no = (await readJsonBody(req))?.chapterNo;
4993
+ if (!Number.isInteger(no) || no === void 0 || no < 1) {
4994
+ writeJson(res, 400, { error: "chapterNo 须为正整数" });
4995
+ return;
4996
+ }
4997
+ const chapter = project.chapters.find((c) => c.no === no);
4998
+ if (chapter === void 0) {
4999
+ writeJson(res, 404, { error: `章节 ${no} 不在计划中` });
5000
+ return;
5001
+ }
5002
+ chapter.status = "pending";
5003
+ chapter.error = void 0;
5004
+ chapter.generatingAt = void 0;
5005
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
5006
+ saveProject(config.outputDir, project);
5007
+ writeJson(res, 200, {
5008
+ ok: true,
5009
+ no
5010
+ });
5011
+ }
5012
+ };
5013
+ return [
5014
+ statusRoute,
5015
+ loadOutlineRoute,
5016
+ saveOutlineRoute,
5017
+ bibleRoute,
5018
+ volumesRoute,
5019
+ planRoute,
5020
+ generateRoute,
5021
+ reviewRoute,
5022
+ rewriteRoute,
5023
+ polishRoute,
5024
+ draftApplyRoute,
5025
+ draftDiscardRoute,
5026
+ summaryRoute,
5027
+ foreshadowRoute,
5028
+ exportRoute,
5029
+ chapterRoute,
5030
+ chapterCheckRoute,
5031
+ chapterSaveRoute,
5032
+ assetsRoute,
5033
+ styleEngineRoute,
5034
+ assistantRoute,
5035
+ assistantHistoryRoute,
5036
+ assistantClearRoute,
5037
+ bookshelfRoute,
5038
+ bookshelfActivateRoute,
5039
+ bookshelfRemoveRoute,
5040
+ resetRoute,
5041
+ auditRoute,
5042
+ charactersRefreshRoute,
5043
+ factsBackfillRoute,
5044
+ biblePatchRoute,
5045
+ blurbRoute,
5046
+ coverRoute,
5047
+ worldRoute,
5048
+ renameRoute,
5049
+ plotlinesRoute,
5050
+ rolesRoute,
5051
+ sensitiveRoute,
4834
5052
  {
4835
5053
  kind: "exact",
4836
5054
  path: NOVEL_API.reviewBackfill,
@@ -4914,6 +5132,7 @@ function makeRoutes(deps) {
4914
5132
  res.end();
4915
5133
  }
4916
5134
  },
5135
+ chapterResetRoute,
4917
5136
  {
4918
5137
  kind: "exact",
4919
5138
  path: NOVEL_API.config,