@waterwx/dsh-novel-forge 2.1.5 → 2.3.0

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.
Files changed (42) hide show
  1. package/README.md +13 -9
  2. package/lib/client.js +3825 -2159
  3. package/lib/index.js +1935 -1300
  4. package/lib/types/assistant.d.ts +1 -1
  5. package/lib/types/client/api.d.ts +3 -1
  6. package/lib/types/client/llmLive.d.ts +7 -2
  7. package/lib/types/client/locales.d.ts +1 -0
  8. package/lib/types/client/panel/AuthorInbox.d.ts +23 -0
  9. package/lib/types/client/panel/CabinetSnapshot.d.ts +16 -0
  10. package/lib/types/client/panel/ChapterPlanPanel.d.ts +24 -0
  11. package/lib/types/client/panel/ConfirmDialog.d.ts +2 -0
  12. package/lib/types/client/panel/HomeHero.d.ts +60 -0
  13. package/lib/types/client/panel/ReviewIssueCard.d.ts +24 -0
  14. package/lib/types/client/panel/RunPanel.d.ts +3 -1
  15. package/lib/types/client/panel/Skeleton.d.ts +6 -0
  16. package/lib/types/client/panel/helpers.d.ts +12 -0
  17. package/lib/types/engine.d.ts +31 -14
  18. package/lib/types/llm-live.d.ts +2 -2
  19. package/lib/types/llm-retry.d.ts +11 -0
  20. package/lib/types/prompt-cache.d.ts +43 -0
  21. package/lib/types/prompts/adapt.d.ts +21 -0
  22. package/lib/types/prompts/audit.d.ts +15 -0
  23. package/lib/types/prompts/bible.d.ts +10 -0
  24. package/lib/types/prompts/catalog.d.ts +40 -0
  25. package/lib/types/prompts/characters.d.ts +19 -0
  26. package/lib/types/prompts/common.d.ts +9 -0
  27. package/lib/types/prompts/digest.d.ts +27 -0
  28. package/lib/types/prompts/facts.d.ts +26 -0
  29. package/lib/types/prompts/foreshadow.d.ts +10 -0
  30. package/lib/types/prompts/market.d.ts +18 -0
  31. package/lib/types/prompts/outline.d.ts +25 -0
  32. package/lib/types/prompts/plan.d.ts +22 -0
  33. package/lib/types/prompts/plotlines.d.ts +21 -0
  34. package/lib/types/prompts/polish.d.ts +22 -0
  35. package/lib/types/prompts/review.d.ts +30 -0
  36. package/lib/types/prompts/world.d.ts +13 -0
  37. package/lib/types/prompts/writing.d.ts +29 -0
  38. package/lib/types/protocol.d.ts +53 -2
  39. package/lib/types/routes.d.ts +6 -0
  40. package/lib/types/run-selection.d.ts +5 -2
  41. package/lib/types/stream-fallback.d.ts +8 -0
  42. package/package.json +2 -1
package/lib/index.js CHANGED
@@ -3741,6 +3741,7 @@ const usageTotals = {
3741
3741
  inputTokens: 0,
3742
3742
  outputTokens: 0,
3743
3743
  reasoningTokens: 0,
3744
+ cacheReadTokens: 0,
3744
3745
  totalTokens: 0,
3745
3746
  elapsedMs: 0,
3746
3747
  callsWithoutUsage: 0,
@@ -3788,6 +3789,7 @@ function recordUsage(frame) {
3788
3789
  usageTotals.inputTokens += usage.inputTokens;
3789
3790
  usageTotals.outputTokens += usage.outputTokens;
3790
3791
  usageTotals.reasoningTokens += usage.reasoningTokens ?? 0;
3792
+ usageTotals.cacheReadTokens += usage.cacheReadTokens ?? 0;
3791
3793
  usageTotals.totalTokens += usage.totalTokens ?? usage.inputTokens + usage.outputTokens;
3792
3794
  }
3793
3795
  const label = frame.label ?? "未标注调用";
@@ -3798,6 +3800,7 @@ function recordUsage(frame) {
3798
3800
  inputTokens: 0,
3799
3801
  outputTokens: 0,
3800
3802
  reasoningTokens: 0,
3803
+ cacheReadTokens: 0,
3801
3804
  elapsedMs: 0
3802
3805
  };
3803
3806
  bucket.calls += 1;
@@ -3807,6 +3810,7 @@ function recordUsage(frame) {
3807
3810
  bucket.inputTokens += usage.inputTokens;
3808
3811
  bucket.outputTokens += usage.outputTokens;
3809
3812
  bucket.reasoningTokens += usage.reasoningTokens ?? 0;
3813
+ bucket.cacheReadTokens = (bucket.cacheReadTokens ?? 0) + (usage.cacheReadTokens ?? 0);
3810
3814
  }
3811
3815
  usageTotals.byLabel.set(label, bucket);
3812
3816
  }
@@ -3819,6 +3823,7 @@ function liveUsage() {
3819
3823
  outputTokens: usageTotals.outputTokens,
3820
3824
  reasoningTokens: usageTotals.reasoningTokens,
3821
3825
  totalTokens: usageTotals.totalTokens,
3826
+ cacheReadTokens: usageTotals.cacheReadTokens,
3822
3827
  elapsedMs: usageTotals.elapsedMs,
3823
3828
  callsWithoutUsage: usageTotals.callsWithoutUsage,
3824
3829
  byLabel: [...usageTotals.byLabel.values()].sort((a, b) => b.inputTokens + b.outputTokens + b.reasoningTokens - (a.inputTokens + a.outputTokens + a.reasoningTokens))
@@ -3831,6 +3836,7 @@ function resetLiveUsage() {
3831
3836
  usageTotals.inputTokens = 0;
3832
3837
  usageTotals.outputTokens = 0;
3833
3838
  usageTotals.reasoningTokens = 0;
3839
+ usageTotals.cacheReadTokens = 0;
3834
3840
  usageTotals.totalTokens = 0;
3835
3841
  usageTotals.elapsedMs = 0;
3836
3842
  usageTotals.callsWithoutUsage = 0;
@@ -3975,6 +3981,24 @@ function isRetryableLlmError(error) {
3975
3981
  return RETRYABLE_PATTERNS.some((re) => re.test(message));
3976
3982
  }
3977
3983
  /**
3984
+ * 判断一次失败是「取消」(作者关页面 / 主动中断)还是模型故障。
3985
+ *
3986
+ * 取消有两种现场:适配器给出 `aborted` 收尾块,或抛出 `AbortError` / 本插件自己的
3987
+ * 断开错误。它必须和真实失败分开记账——否则面板「本次运行失败 N 次」会把作者的正常
3988
+ * 中断统计成模型不稳定,进而误导备用模型档位判断(与规则 1「用户取消不重试」同一口径)。
3989
+ *
3990
+ * @param input 收尾块 kind(字符串)或捕获到的异常。
3991
+ * @returns 属于取消时为 true。
3992
+ */
3993
+ function isCancellation(input) {
3994
+ if (typeof input === "string") return input === "aborted";
3995
+ if (input instanceof Error) {
3996
+ if (input.name === "AbortError") return true;
3997
+ return /已取消|用户取消|客户端已断开|\baborted\b/i.test(input.message);
3998
+ }
3999
+ return false;
4000
+ }
4001
+ /**
3978
4002
  * 判断是否应当切换到备用模型。
3979
4003
  *
3980
4004
  * @param fallbackModel 配置的备用模型(空串 = 未配置)。
@@ -4205,6 +4229,16 @@ function applyModelCapability(base, model, options = {}) {
4205
4229
  //#endregion
4206
4230
  //#region src/stream-fallback.ts
4207
4231
  /**
4232
+ * 统一的取消错误:与 `DOMException('AbortError')` 同名,便于上层用 `name` 判定。
4233
+ * @param reason 取消原因(signal.reason,或适配器抛出的原始异常)。
4234
+ */
4235
+ function abortError(reason) {
4236
+ if (reason instanceof Error) return reason;
4237
+ const err = new Error(typeof reason === "string" ? reason : "客户端已断开连接");
4238
+ err.name = "AbortError";
4239
+ return err;
4240
+ }
4241
+ /**
4208
4242
  * 执行流式生成,主模型失败且「一个字都没产出」时换备用模型重试一次。
4209
4243
  *
4210
4244
  * 第三泛型(`TNext`)取 `unknown` 而非 `void`:三处调用方的外层生成器都声明为
@@ -4215,11 +4249,13 @@ function applyModelCapability(base, model, options = {}) {
4215
4249
  * @throws 两次都失败时抛最后一次的错误;成功但文本过短时抛「结果过短」。
4216
4250
  */
4217
4251
  async function* streamWithFallback(options) {
4218
- const { ctx, config, request, liveLabel, maxTokensError, minChars } = options;
4252
+ const { ctx, config, request, liveLabel, maxTokensError, minChars, signal } = options;
4219
4253
  const primaryModel = request.model ?? config.model;
4220
4254
  let text = "";
4221
4255
  let lastError;
4256
+ const abortedByCaller = () => signal?.aborted === true;
4222
4257
  for (let attempt = 0; attempt < 2; attempt++) {
4258
+ if (abortedByCaller()) throw abortError(signal?.reason);
4223
4259
  const model = attempt === 0 ? primaryModel : (config.fallbackModel ?? "").trim();
4224
4260
  if (model === "") break;
4225
4261
  const live = beginLiveCall({
@@ -4231,29 +4267,44 @@ async function* streamWithFallback(options) {
4231
4267
  const assembler = new BlockAssembler();
4232
4268
  let produced = 0;
4233
4269
  const converged = applyModelCapability(request, model, { maxTokensFor: (m) => fullTextBudget(config, m, primaryModel) });
4234
- for await (const chunk of ctx.llm.stream(converged)) {
4235
- assembler.push(chunk);
4236
- if (chunk.type === "text-delta") {
4237
- produced += chunk.text.length;
4238
- markFirstToken(live);
4239
- yield {
4240
- frame: "delta",
4241
- text: chunk.text
4242
- };
4270
+ try {
4271
+ for await (const chunk of ctx.llm.stream(signal === void 0 ? converged : {
4272
+ ...converged,
4273
+ signal
4274
+ })) {
4275
+ assembler.push(chunk);
4276
+ if (chunk.type === "text-delta") {
4277
+ produced += chunk.text.length;
4278
+ markFirstToken(live);
4279
+ yield {
4280
+ frame: "delta",
4281
+ text: chunk.text
4282
+ };
4283
+ }
4243
4284
  }
4285
+ } catch (error) {
4286
+ const aborted = isCancellation(error) || abortedByCaller();
4287
+ endLiveCall(live, assembler, {
4288
+ chars: produced,
4289
+ phase: aborted ? "cancelled" : "failed",
4290
+ error: error instanceof Error ? error.message : String(error)
4291
+ });
4292
+ if (aborted) throw abortError(signal?.reason ?? error);
4293
+ throw error;
4244
4294
  }
4245
4295
  const finish = assembler.finish;
4246
4296
  lastError = void 0;
4247
- if (finish.kind === "error" || finish.kind === "aborted") lastError = /* @__PURE__ */ new Error(`${liveLabel}失败(${finish.kind}): ${finish.failure.message}`);
4297
+ if (finish.kind === "error" || finish.kind === "aborted") lastError = /* @__PURE__ */ new Error(`${isCancellation(finish.kind) ? "已取消" : `${liveLabel}失败`}(${finish.kind}): ${finish.failure.message}`);
4248
4298
  else if (finish.kind === "max-tokens") lastError = new Error(maxTokensError);
4249
4299
  text = assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
4300
+ const cancelled = lastError !== void 0 && isCancellation(lastError);
4250
4301
  endLiveCall(live, assembler, lastError === void 0 ? {
4251
4302
  chars: text.length,
4252
4303
  preview: text.slice(0, 320),
4253
4304
  phase: text === "" ? "failed" : "completed"
4254
4305
  } : {
4255
4306
  chars: text.length,
4256
- phase: "failed",
4307
+ phase: cancelled ? "cancelled" : "failed",
4257
4308
  error: lastError.message
4258
4309
  });
4259
4310
  if (lastError === void 0) break;
@@ -4400,422 +4451,70 @@ function renderLengthContract(entries) {
4400
4451
  if (entries.length === 0) return "";
4401
4452
  return `为保证输出完整:${entries.map(([label, limit]) => `${label}不超过 ${limit} 字`).join(";")}。每个字段只保留完成本阶段决策所需的信息,写完后立即结束输出,不要展开细纲或重复说明。`;
4402
4453
  }
4403
- /**
4404
- * 本项目设计的参考曲线形状(0-100 的采样序列)。
4405
- * 只描述"形状",不含任何外部数值表。
4406
- */
4407
- const REFERENCE_SHAPES = {
4408
- escalation: [
4409
- 20,
4410
- 26,
4411
- 34,
4412
- 32,
4413
- 44,
4414
- 52,
4415
- 48,
4416
- 62,
4417
- 70,
4418
- 66,
4419
- 82,
4420
- 92
4421
- ],
4422
- suspense: [
4423
- 30,
4424
- 42,
4425
- 38,
4426
- 50,
4427
- 46,
4428
- 58,
4429
- 62,
4430
- 56,
4431
- 70,
4432
- 78,
4433
- 86,
4434
- 74
4435
- ],
4436
- wave: [
4437
- 45,
4438
- 30,
4439
- 55,
4440
- 34,
4441
- 62,
4442
- 40,
4443
- 70,
4444
- 46,
4445
- 78,
4446
- 52,
4447
- 84,
4448
- 60
4449
- ],
4450
- frontLoad: [
4451
- 78,
4452
- 66,
4453
- 58,
4454
- 50,
4455
- 46,
4456
- 44,
4457
- 42,
4458
- 44,
4459
- 46,
4460
- 48,
4461
- 50,
4462
- 52
4463
- ],
4464
- flat: [
4465
- 40,
4466
- 42,
4467
- 44,
4468
- 42,
4469
- 46,
4470
- 44,
4471
- 48,
4472
- 46,
4473
- 50,
4474
- 48,
4475
- 52,
4476
- 50
4477
- ]
4478
- };
4479
- /** 参考曲线的中文标签(面板展示)。 */
4480
- const TENSION_PRESET_LABELS = {
4481
- escalation: "递进爬升",
4482
- suspense: "悬疑加压",
4483
- wave: "波浪呼吸",
4484
- frontLoad: "前紧后稳",
4485
- flat: "平稳推进",
4486
- custom: "自定义"
4487
- };
4488
- /** 全部可选预设(UI 下拉用)。 */
4489
- const TENSION_PRESETS = [
4490
- "escalation",
4491
- "suspense",
4492
- "wave",
4493
- "frontLoad",
4494
- "flat",
4495
- "custom"
4496
- ];
4497
- /** 把参考形状按章节数重采样(最近邻,保持形状不引入平滑失真)。 */
4498
- function referenceCurve(preset, chapterCount) {
4499
- if (chapterCount <= 0) return [];
4500
- if (preset === "custom") return [];
4501
- const shape = REFERENCE_SHAPES[preset];
4502
- if (chapterCount === 1) return [shape[Math.floor(shape.length / 2)] ?? 50];
4503
- return Array.from({ length: chapterCount }, (_, i) => {
4504
- const pos = i / (chapterCount - 1) * (shape.length - 1);
4505
- const lo = Math.floor(pos);
4506
- const hi = Math.min(shape.length - 1, lo + 1);
4507
- const t = pos - lo;
4508
- const a = shape[lo] ?? 50;
4509
- const b = shape[hi] ?? a;
4510
- return Math.round(a + (b - a) * t);
4511
- });
4512
- }
4513
- /** 读一章的目标张力(未设置时返回 undefined)。 */
4514
- function targetTension(chapter) {
4515
- return typeof chapter.tension === "number" ? clampTension(chapter.tension) : void 0;
4516
- }
4517
- /** 读一章的实际张力(审稿时模型打分,未设置时返回 undefined)。 */
4518
- function actualTension(chapter) {
4519
- const fromReview = chapter.review?.tension;
4520
- if (typeof fromReview === "number") return clampTension(fromReview);
4521
- return typeof chapter.tensionActual === "number" ? clampTension(chapter.tensionActual) : void 0;
4454
+ //#endregion
4455
+ //#region src/timeline.ts
4456
+ /** 时间线矛盾初筛的最少事件数(太少没有意义)。 */
4457
+ const MIN_EVENTS_FOR_CHECK = 2;
4458
+ /** 归一化事件(清洗字符串、补 id/时间戳)。 */
4459
+ function normalizeTimelineEvent(raw, chapterNo, index, now = (/* @__PURE__ */ new Date()).toISOString()) {
4460
+ const characters = Array.isArray(raw.characters) ? raw.characters.filter((c) => typeof c === "string" && c.trim() !== "") : [];
4461
+ return {
4462
+ id: typeof raw.id === "string" && raw.id !== "" ? raw.id : `tl-${chapterNo}-${index}-${Math.random().toString(36).slice(2, 8)}`,
4463
+ chapterNo: typeof raw.chapterNo === "number" ? raw.chapterNo : chapterNo,
4464
+ time: (raw.time ?? "").trim(),
4465
+ order: typeof raw.order === "number" && Number.isFinite(raw.order) ? raw.order : void 0,
4466
+ place: (raw.place ?? "").trim(),
4467
+ characters: characters.map((c) => c.trim()),
4468
+ event: (raw.event ?? "").trim(),
4469
+ source: raw.source === "manual" ? "manual" : "extracted",
4470
+ createdAt: raw.createdAt ?? now
4471
+ };
4522
4472
  }
4523
- /** 收敛到合法区间并取整。 */
4524
- function clampTension(value) {
4525
- if (!Number.isFinite(value)) return 0;
4526
- return Math.max(0, Math.min(100, Math.round(value)));
4473
+ /** 时间线排序:先章节、再 order(缺省排在该章末尾)、最后 createdAt。 */
4474
+ function sortTimeline(events) {
4475
+ return [...events].sort((a, b) => {
4476
+ if (a.chapterNo !== b.chapterNo) return a.chapterNo - b.chapterNo;
4477
+ const ao = a.order ?? Number.MAX_SAFE_INTEGER;
4478
+ const bo = b.order ?? Number.MAX_SAFE_INTEGER;
4479
+ if (ao !== bo) return ao - bo;
4480
+ return a.createdAt.localeCompare(b.createdAt);
4481
+ });
4527
4482
  }
4528
4483
  /**
4529
- * 渲染注入写作提示词的「本章张力」块。
4484
+ * 渲染注入到「写作/规划」提示词的时间线锚点块。
4530
4485
  *
4531
4486
  * @param project 项目状态。
4532
- * @param chapterNo 本章章号。
4533
- * @returns 提示词片段;没有张力信息时返回空串。
4487
+ * @param beforeChapter 只注入该章之前的锚点(含该章本身不注入,避免剧透)。
4488
+ * @returns 提示词片段(无事件时返回空串)。带显式截断声明。
4534
4489
  */
4535
- function renderTensionBlock(project, chapterNo) {
4536
- const preset = project.tensionCurve?.preset;
4537
- const chapters = [...project.chapters ?? []].sort((a, b) => a.no - b.no);
4538
- const idx = chapters.findIndex((c) => c.no === chapterNo);
4539
- if (idx === -1) return "";
4540
- const target = targetTension(chapters[idx]);
4541
- const prev = idx > 0 ? targetTension(chapters[idx - 1]) : void 0;
4542
- if (target === void 0 && prev === void 0) return "";
4543
- const ref = preset !== void 0 && preset !== "custom" ? referenceCurve(preset, chapters.length)[idx] : void 0;
4544
- const parts = [];
4545
- if (target !== void 0) parts.push(`本章目标张力 ${target}/100`);
4546
- if (prev !== void 0 && target !== void 0) {
4547
- const delta = target - prev;
4548
- parts.push(delta >= 10 ? "比上一章明显更紧" : delta <= -10 ? "比上一章明显放松(给读者呼吸口)" : "与上一章持平");
4549
- }
4550
- if (ref !== void 0) parts.push(`参考曲线(${TENSION_PRESET_LABELS[preset ?? "custom"]})在此处约为 ${ref}`);
4551
- if (parts.length === 0) return "";
4552
- return [
4553
- "==================== 本章张力(写作时把握松紧) ====================",
4554
- parts.join(";") + "。",
4555
- "低张力段用于铺垫关系、交代信息、留呼吸;高张力段用于对抗、揭示、抉择与代价。",
4556
- "同一章内可以有起伏,但整章的平均松紧要贴近目标值;不要连续多章都停在同一个紧张度。"
4557
- ].join("\n");
4490
+ function renderTimelineBlock(project, beforeChapter) {
4491
+ const all = sortTimeline(project.timeline ?? []).filter((e) => e.chapterNo < beforeChapter);
4492
+ if (all.length === 0) return "";
4493
+ const shown = all.slice(-12);
4494
+ const omitted = all.length - shown.length;
4495
+ return `${omitted > 0 ? `【故事时间线(共 ${all.length} 条,此处只列最近 ${shown.length} 条;更早 ${omitted} 条未列出)】` : `【故事时间线(共 ${shown.length} 条)】`}\n${shown.map((e) => {
4496
+ const parts = [`[第${e.chapterNo}章]`];
4497
+ if (e.time !== "") parts.push(e.time);
4498
+ if (e.place !== "") parts.push(`@${e.place}`);
4499
+ if (e.characters.length > 0) parts.push(`(${e.characters.join("、")})`);
4500
+ parts.push(e.event);
4501
+ return "- " + parts.join(" ");
4502
+ }).join("\n")}\n写作时必须与前文时间顺序、地点衔接一致;不得让时间倒流或让人物瞬移。`;
4558
4503
  }
4559
4504
  /**
4560
- * 规则核对张力曲线(不调模型)。
4505
+ * 规则初筛时间线矛盾(不调模型,瞬时计算)。
4561
4506
  *
4562
- * 检查四类曲线级问题:
4563
- * 1) 实际与目标偏差过大(>25);
4564
- * 2) 连续多章几乎同值(≥4 章落差 <6,平台期无起伏);
4565
- * 3) 高点之后没有回落(连续 ≥3 章处在高位 ≥80);
4566
- * 4) 长期低位(连续 ≥5 章 <40,容易拖沓)。
4507
+ * 覆盖三类最容易犯又最容易用规则抓到的错:
4508
+ * 1) 同一章内事件顺序号倒退;
4509
+ * 2) 全书顺序号在章节推进后倒退(时间倒流);
4510
+ * 3) 相邻两章之间地点瞬移(同一角色在未交代移动的情况下跨地点)。
4567
4511
  *
4568
4512
  * @param project 项目状态。
4569
4513
  * @returns 问题清单(可能为空)。
4570
4514
  */
4571
- /**
4572
- * run 型问题(连续同值 / 连续高位 / 连续低位)的受影响章号。
4573
- * 返回**完整区间**而不是首尾两点:中间章同样受这条曲线问题影响,
4574
- * 面板要能在每一章上给出「一键按建议修订」,合并层也要能把建议落到中间章。
4575
- */
4576
- function runChapters(from, to) {
4577
- const out = [];
4578
- for (let no = from; no <= to; no++) out.push(no);
4579
- return out;
4580
- }
4581
- function detectTensionIssues(project) {
4582
- const values = [...project.chapters ?? []].sort((a, b) => a.no - b.no).map((c) => ({
4583
- no: c.no,
4584
- target: targetTension(c),
4585
- actual: actualTension(c)
4586
- }));
4587
- if (values.filter((v) => v.target !== void 0 || v.actual !== void 0).length < 3) return [];
4588
- const issues = [];
4589
- for (const v of values) {
4590
- if (v.target === void 0 || v.actual === void 0) continue;
4591
- const gap = v.actual - v.target;
4592
- if (Math.abs(gap) > 25) issues.push({
4593
- severity: Math.abs(gap) > 40 ? "high" : "medium",
4594
- chapters: [v.no],
4595
- item: `第${v.no}章张力偏差较大:目标 ${v.target},实际 ${v.actual}(${gap > 0 ? "比目标更紧" : "比目标更松"})`,
4596
- suggestion: gap > 0 ? "本章对抗/揭示密度偏高,考虑把部分冲突后移,留出呼吸段。" : "本章推进偏平,考虑加一处对抗、揭示或代价。"
4597
- });
4598
- }
4599
- let run = 1;
4600
- for (let i = 1; i < values.length; i++) {
4601
- const a = values[i - 1].target ?? values[i - 1].actual;
4602
- const b = values[i].target ?? values[i].actual;
4603
- if (a !== void 0 && b !== void 0 && Math.abs(b - a) < 6) {
4604
- run++;
4605
- if (run === 4) {
4606
- const from = values[i - run + 1].no;
4607
- issues.push({
4608
- severity: "medium",
4609
- chapters: runChapters(from, values[i].no),
4610
- item: `第${from}-${values[i].no}章张力几乎无变化(连续 ${run} 章)`,
4611
- suggestion: "安排一次明显抬升或一次释放,让曲线有起伏,避免读者疲劳。"
4612
- });
4613
- }
4614
- } else run = 1;
4615
- }
4616
- let highRun = 0;
4617
- for (const v of values) {
4618
- const val = v.target ?? v.actual;
4619
- if (val !== void 0 && val >= 80) {
4620
- highRun++;
4621
- if (highRun === 3) issues.push({
4622
- severity: "medium",
4623
- chapters: runChapters(v.no - 2, v.no),
4624
- item: `连续 ${highRun} 章处在高位(≥80),缺少呼吸口`,
4625
- suggestion: "高潮之后补一章缓冲(关系推进、信息消化或日常),再进入下一轮对抗。"
4626
- });
4627
- } else highRun = 0;
4628
- }
4629
- let lowRun = 0;
4630
- for (const v of values) {
4631
- const val = v.target ?? v.actual;
4632
- if (val !== void 0 && val < 40) {
4633
- lowRun++;
4634
- if (lowRun === 5) issues.push({
4635
- severity: "high",
4636
- chapters: runChapters(v.no - 4, v.no),
4637
- item: `连续 ${lowRun} 章张力低于 40,推进可能偏慢`,
4638
- suggestion: "提前兑现一个目标、抛出一个新风险,或让配角线产生冲撞。"
4639
- });
4640
- } else lowRun = 0;
4641
- }
4642
- return issues;
4643
- }
4644
- /**
4645
- * 组装面板需要的曲线数据(章节号 / 目标 / 实际 / 参考)。
4646
- *
4647
- * @param project 项目状态。
4648
- * @returns 逐章数据点与预设名。
4649
- */
4650
- function buildCurveData(project) {
4651
- const preset = project.tensionCurve?.preset ?? "escalation";
4652
- const chapters = [...project.chapters ?? []].sort((a, b) => a.no - b.no);
4653
- const custom = project.tensionCurve?.custom ?? [];
4654
- const reference = preset === "custom" ? custom : referenceCurve(preset, chapters.length);
4655
- return {
4656
- preset,
4657
- points: chapters.map((c, i) => {
4658
- const point = {
4659
- no: c.no,
4660
- title: c.title
4661
- };
4662
- const t = targetTension(c);
4663
- const a = actualTension(c);
4664
- if (t !== void 0) point.target = t;
4665
- if (a !== void 0) point.actual = a;
4666
- if (reference[i] !== void 0) point.reference = reference[i];
4667
- return point;
4668
- })
4669
- };
4670
- }
4671
- //#endregion
4672
- //#region src/prompt-slots.ts
4673
- /** 允许作者编辑的槽位(白名单;不在此列表的提示词一律不可改)。 */
4674
- const PROMPT_SLOTS = [
4675
- {
4676
- id: "styleExtra",
4677
- label: "文风补充",
4678
- hint: "追加到写作提示词末尾的风格偏好(句式、叙述习惯、用词倾向)。",
4679
- placeholder: "例如:多用短句和对话推进,少用成语;环境描写只写能影响动作的部分。",
4680
- maxChars: 1200
4681
- },
4682
- {
4683
- id: "antiAiExtra",
4684
- label: "反 AI 补充",
4685
- hint: "本书额外的\"别这么写\"清单,与内置反 AI 规则叠加(不能覆盖内置规则)。",
4686
- placeholder: "例如:不要每段都用比喻;不要用\"空气中弥漫着\"这类句式。",
4687
- maxChars: 1200
4688
- },
4689
- {
4690
- id: "dialogueExtra",
4691
- label: "对话补充",
4692
- hint: "角色说话方式的额外约束,写入时会与角色卡一起注入。",
4693
- placeholder: "例如:长辈说话带方言词;主角对熟人用绰号称呼。",
4694
- maxChars: 800
4695
- },
4696
- {
4697
- id: "endingHookExtra",
4698
- label: "章末钩子偏好",
4699
- hint: "对结尾钩子的额外要求(与章节计划里的 endingHook 叠加)。",
4700
- placeholder: "例如:结尾尽量落在一个未闭合的选择上,而不是新出现的敌人。",
4701
- maxChars: 600
4702
- }
4703
- ];
4704
- /** 槽位 id → 规格。 */
4705
- const SLOT_BY_ID = new Map(PROMPT_SLOTS.map((s) => [s.id, s]));
4706
- /**
4707
- * 读取并规整项目里的槽位内容(去空白、按上限截断)。
4708
- *
4709
- * @param project 项目状态。
4710
- * @returns 非空槽位列表(保持 PROMPT_SLOTS 的声明顺序)。
4711
- */
4712
- function readPromptSlots(project) {
4713
- const stored = project.promptSlots ?? {};
4714
- const out = [];
4715
- for (const spec of PROMPT_SLOTS) {
4716
- const raw = (stored[spec.id] ?? "").trim();
4717
- if (raw === "") continue;
4718
- const truncated = raw.length > spec.maxChars;
4719
- out.push({
4720
- id: spec.id,
4721
- label: spec.label,
4722
- text: truncated ? raw.slice(0, spec.maxChars) : raw,
4723
- truncated,
4724
- chars: raw.length
4725
- });
4726
- }
4727
- return out;
4728
- }
4729
- /**
4730
- * 把槽位渲染成注入提示词的段落。
4731
- *
4732
- * @param project 项目状态。
4733
- * @returns 提示词片段;无槽位时返回空串。
4734
- */
4735
- function renderPromptSlots(project) {
4736
- const slots = readPromptSlots(project);
4737
- if (slots.length === 0) return "";
4738
- const lines = ["==================== 作者偏好(本书自定义,优先级低于道藏/红线/合规) ===================="];
4739
- for (const slot of slots) lines.push(`【${slot.label}】${slot.text}${slot.truncated ? `(已按 ${SLOT_BY_ID.get(slot.id)?.maxChars ?? 0} 字上限截断,原 ${slot.chars} 字)` : ""}`);
4740
- lines.push("以上偏好只影响表达方式;与道藏、写作红线、内容合规红线冲突时,一律以后者为准。");
4741
- return lines.join("\n");
4742
- }
4743
- /**
4744
- * 校验并规整一次槽位写入。
4745
- *
4746
- * @param id 槽位 id。
4747
- * @param value 内容(空串表示清空该槽位)。
4748
- * @returns 归一化后的内容。
4749
- * @throws 槽位 id 不在白名单时抛错。
4750
- */
4751
- function normalizeSlotValue(id, value) {
4752
- if (SLOT_BY_ID.get(id) === void 0) throw new Error(`未知提示词槽位:${id}`);
4753
- return value.replace(/\r\n/g, "\n").trim();
4754
- }
4755
- //#endregion
4756
- //#region src/timeline.ts
4757
- /** 时间线矛盾初筛的最少事件数(太少没有意义)。 */
4758
- const MIN_EVENTS_FOR_CHECK = 2;
4759
- /** 归一化事件(清洗字符串、补 id/时间戳)。 */
4760
- function normalizeTimelineEvent(raw, chapterNo, index, now = (/* @__PURE__ */ new Date()).toISOString()) {
4761
- const characters = Array.isArray(raw.characters) ? raw.characters.filter((c) => typeof c === "string" && c.trim() !== "") : [];
4762
- return {
4763
- id: typeof raw.id === "string" && raw.id !== "" ? raw.id : `tl-${chapterNo}-${index}-${Math.random().toString(36).slice(2, 8)}`,
4764
- chapterNo: typeof raw.chapterNo === "number" ? raw.chapterNo : chapterNo,
4765
- time: (raw.time ?? "").trim(),
4766
- order: typeof raw.order === "number" && Number.isFinite(raw.order) ? raw.order : void 0,
4767
- place: (raw.place ?? "").trim(),
4768
- characters: characters.map((c) => c.trim()),
4769
- event: (raw.event ?? "").trim(),
4770
- source: raw.source === "manual" ? "manual" : "extracted",
4771
- createdAt: raw.createdAt ?? now
4772
- };
4773
- }
4774
- /** 时间线排序:先章节、再 order(缺省排在该章末尾)、最后 createdAt。 */
4775
- function sortTimeline(events) {
4776
- return [...events].sort((a, b) => {
4777
- if (a.chapterNo !== b.chapterNo) return a.chapterNo - b.chapterNo;
4778
- const ao = a.order ?? Number.MAX_SAFE_INTEGER;
4779
- const bo = b.order ?? Number.MAX_SAFE_INTEGER;
4780
- if (ao !== bo) return ao - bo;
4781
- return a.createdAt.localeCompare(b.createdAt);
4782
- });
4783
- }
4784
- /**
4785
- * 渲染注入到「写作/规划」提示词的时间线锚点块。
4786
- *
4787
- * @param project 项目状态。
4788
- * @param beforeChapter 只注入该章之前的锚点(含该章本身不注入,避免剧透)。
4789
- * @returns 提示词片段(无事件时返回空串)。带显式截断声明。
4790
- */
4791
- function renderTimelineBlock(project, beforeChapter) {
4792
- const all = sortTimeline(project.timeline ?? []).filter((e) => e.chapterNo < beforeChapter);
4793
- if (all.length === 0) return "";
4794
- const shown = all.slice(-12);
4795
- const omitted = all.length - shown.length;
4796
- return `${omitted > 0 ? `【故事时间线(共 ${all.length} 条,此处只列最近 ${shown.length} 条;更早 ${omitted} 条未列出)】` : `【故事时间线(共 ${shown.length} 条)】`}\n${shown.map((e) => {
4797
- const parts = [`[第${e.chapterNo}章]`];
4798
- if (e.time !== "") parts.push(e.time);
4799
- if (e.place !== "") parts.push(`@${e.place}`);
4800
- if (e.characters.length > 0) parts.push(`(${e.characters.join("、")})`);
4801
- parts.push(e.event);
4802
- return "- " + parts.join(" ");
4803
- }).join("\n")}\n写作时必须与前文时间顺序、地点衔接一致;不得让时间倒流或让人物瞬移。`;
4804
- }
4805
- /**
4806
- * 规则初筛时间线矛盾(不调模型,瞬时计算)。
4807
- *
4808
- * 覆盖三类最容易犯又最容易用规则抓到的错:
4809
- * 1) 同一章内事件顺序号倒退;
4810
- * 2) 全书顺序号在章节推进后倒退(时间倒流);
4811
- * 3) 相邻两章之间地点瞬移(同一角色在未交代移动的情况下跨地点)。
4812
- *
4813
- * @param project 项目状态。
4814
- * @returns 问题清单(可能为空)。
4815
- */
4816
- function detectTimelineIssues(project) {
4817
- const stored = project.timeline ?? [];
4818
- if (stored.length < MIN_EVENTS_FOR_CHECK) return [];
4515
+ function detectTimelineIssues(project) {
4516
+ const stored = project.timeline ?? [];
4517
+ if (stored.length < MIN_EVENTS_FOR_CHECK) return [];
4819
4518
  const issues = [];
4820
4519
  const byChapter = /* @__PURE__ */ new Map();
4821
4520
  for (const e of stored) {
@@ -5031,26 +4730,6 @@ function pruneSnapshots(outputDir, keep = 20) {
5031
4730
  return removed;
5032
4731
  }
5033
4732
  //#endregion
5034
- //#region src/prompting.ts
5035
- /** 章节写作约束:把写作骨架渲染成可附加到系统提示词末尾的必达/禁止/自查块。 */
5036
- function renderChapterWriterSkeleton(meta) {
5037
- const pov = meta.pov ?? "第三人称有限视角,严格跟随主角所见所知。";
5038
- const tone = meta.tonePreference ?? "文风贴合本书设定,用具体细节与动作推进。";
5039
- const hook = meta.endingHookPreference ?? "章末留一个明确的钩子(新信息、新风险或未闭合的选择)。";
5040
- const antiAi = meta.antiAiRules ?? "";
5041
- return [
5042
- "==================== 章节生成骨架(必达 / 禁止 / 输出前自查) ====================",
5043
- "【叙事视角】" + pov,
5044
- "【本章必达】本章必须发生实质变化——局面、关系、信息、风险、决策至少动一项;开篇直接进入情境,不复述上一章。",
5045
- "【禁止事项】不写以总结、复盘、解释为主的段落;不新增核心角色,不写入与上下文冲突的设定;不为凑字数注水。",
5046
- "【篇幅】目标 " + meta.targetChars + " 字,区间 " + meta.minChars + "-" + meta.maxChars + " 字;不够就继续推进有效情节,不许草草收尾。",
5047
- "【结尾】" + hook,
5048
- "【表达】用具体动作、对话与可感知细节往前推;" + tone,
5049
- "【反 AI】" + antiAi,
5050
- "【输出前自查】逐项确认:读者的回报、关键转折、章末净变化是否看得见?上一章留下的钩子有没有交代?人物的硬事实有没有被写反?确认无误再输出,正文里不要出现核查过程。"
5051
- ].join("\n");
5052
- }
5053
- //#endregion
5054
4733
  //#region src/action-guard.ts
5055
4734
  /** 带分级的动作失败(executeAction 抛出,供 fail-stop 判定)。 */
5056
4735
  var NovelActionError = class extends Error {
@@ -5280,30 +4959,1326 @@ function buildChapterContext(project, chapter, outputDir, options = {}) {
5280
4959
  chapterTitle: chapter.title
5281
4960
  };
5282
4961
  }
5283
- /**
5284
- * 将上下文渲染为 prompt 文本块
5285
- * 各阶段可按需选择注入哪些块。
5286
- */
5287
- function renderContextBlocks(ctx) {
5288
- const roleName = {
5289
- protagonist: "主角",
5290
- supporting: "配角",
5291
- antagonist: "反派",
5292
- other: "其他",
5293
- female_lead: "女主",
5294
- female_support: "女配",
5295
- support: "配角",
5296
- extra: "路人"
5297
- };
5298
- return {
5299
- rolesBlock: ctx.relevantRoles.length > 0 ? "==================== 相关角色卡 ====================\n" + ctx.relevantRoles.map((r) => `- ${r.name}(${roleName[r.role] ?? r.role}):${r.traits.join("、")}${r.goals ? `;目标:${r.goals}` : ""}${r.knowledge ? `\n 已知信息:${r.knowledge.join(";")}(未列出的该角色不知道)` : ""}`).join("\n") : "",
5300
- worldRulesBlock: ctx.relevantWorldRules.length > 0 ? "==================== 世界规则 ====================\n" + ctx.relevantWorldRules.map((r) => `- ${r}`).join("\n") : "",
5301
- factsBlock: ctx.recentFacts.length > 0 || ctx.relatedFacts.length > 0 ? "==================== 事实库(最近 + 相关) ====================\n" + (ctx.recentFacts.length > 0 ? `【最近 ${ctx.recentFacts.length} 条】\n${ctx.recentFacts.join("\n")}` : "") + (ctx.relatedFacts.length > 0 ? `\n【相关旧事实 ${ctx.relatedFacts.length} 条】\n${ctx.relatedFacts.join("\n")}` : "") : "",
5302
- plotlinesBlock: ctx.activePlotlines.length > 0 ? "==================== 活跃剧情线 ====================\n" + ctx.activePlotlines.map((l) => `- [${l.kind}${l.status === "paused" ? "·暂停" : ""}] ${l.name}:${l.goal}${l.progress ? `(进度:${l.progress})` : ""}`).join("\n") : "",
5303
- foreshadowsBlock: ctx.activeForeshadows.length > 0 ? "==================== 活跃伏笔 ====================\n" + ctx.activeForeshadows.map((f) => `- [${f.status === "planted" ? "已埋设" : "推进中"}] ${f.description}${f.targetChapter ? `(预计第${f.targetChapter}章回收)` : ""}`).join("\n") : "",
5304
- continuityBlock: ctx.prevChapterTail !== "" ? `==================== 上一章结尾(紧接此状态继续) ====================\n${ctx.prevChapterTail}` : "",
5305
- redLinesBlock: ctx.bookRedLines.length > 0 ? "==================== 本书红线 ====================\n" + ctx.bookRedLines.map((r) => `- ${r}`).join("\n") : ""
5306
- };
4962
+ /**
4963
+ * 将上下文渲染为 prompt 文本块
4964
+ * 各阶段可按需选择注入哪些块。
4965
+ */
4966
+ function renderContextBlocks(ctx) {
4967
+ const roleName = {
4968
+ protagonist: "主角",
4969
+ supporting: "配角",
4970
+ antagonist: "反派",
4971
+ other: "其他",
4972
+ female_lead: "女主",
4973
+ female_support: "女配",
4974
+ support: "配角",
4975
+ extra: "路人"
4976
+ };
4977
+ return {
4978
+ rolesBlock: ctx.relevantRoles.length > 0 ? "==================== 相关角色卡 ====================\n" + ctx.relevantRoles.map((r) => `- ${r.name}(${roleName[r.role] ?? r.role}):${r.traits.join("、")}${r.goals ? `;目标:${r.goals}` : ""}${r.knowledge ? `\n 已知信息:${r.knowledge.join(";")}(未列出的该角色不知道)` : ""}`).join("\n") : "",
4979
+ worldRulesBlock: ctx.relevantWorldRules.length > 0 ? "==================== 世界规则 ====================\n" + ctx.relevantWorldRules.map((r) => `- ${r}`).join("\n") : "",
4980
+ factsBlock: ctx.recentFacts.length > 0 || ctx.relatedFacts.length > 0 ? "==================== 事实库(最近 + 相关) ====================\n" + (ctx.recentFacts.length > 0 ? `【最近 ${ctx.recentFacts.length} 条】\n${ctx.recentFacts.join("\n")}` : "") + (ctx.relatedFacts.length > 0 ? `\n【相关旧事实 ${ctx.relatedFacts.length} 条】\n${ctx.relatedFacts.join("\n")}` : "") : "",
4981
+ plotlinesBlock: ctx.activePlotlines.length > 0 ? "==================== 活跃剧情线 ====================\n" + ctx.activePlotlines.map((l) => `- [${l.kind}${l.status === "paused" ? "·暂停" : ""}] ${l.name}:${l.goal}${l.progress ? `(进度:${l.progress})` : ""}`).join("\n") : "",
4982
+ foreshadowsBlock: ctx.activeForeshadows.length > 0 ? "==================== 活跃伏笔 ====================\n" + ctx.activeForeshadows.map((f) => `- [${f.status === "planted" ? "已埋设" : "推进中"}] ${f.description}${f.targetChapter ? `(预计第${f.targetChapter}章回收)` : ""}`).join("\n") : "",
4983
+ continuityBlock: ctx.prevChapterTail !== "" ? `==================== 上一章结尾(紧接此状态继续) ====================\n${ctx.prevChapterTail}` : "",
4984
+ redLinesBlock: ctx.bookRedLines.length > 0 ? "==================== 本书红线 ====================\n" + ctx.bookRedLines.map((r) => `- ${r}`).join("\n") : ""
4985
+ };
4986
+ }
4987
+ //#endregion
4988
+ //#region src/prompts/bible.ts
4989
+ /** System prompt for story-bible extraction. */
4990
+ function bibleSystemPrompt() {
4991
+ return [
4992
+ "你是一位资深网文编辑兼设定架构师。你会收到一份小说大纲,请把它提炼成结构化的「道藏」,供后续写作时严格引用。",
4993
+ "要求:",
4994
+ "1. 忠于大纲,不自行发明大纲之外的设定。",
4995
+ "2. 角色卡覆盖大纲明确出现的角色(主角必含),每个角色给出性格标签、目标、关键关系。",
4996
+ "3. 世界规则覆盖力量体系、金手指机制、势力、地理等所有硬性规则,逐条列出。",
4997
+ "4. 红线列出大纲中明确禁止的内容(如无后宫、不圣母、无无脑碾压等)。",
4998
+ "5. 风格列出叙事基调、节奏、POV 等写作风格要点。",
4999
+ "6. 角色名必须用正文/编年录中的真实姓名;若大纲只写「主角」未点名,而已写章节或编年录中有名字,则用该真实姓名;禁止输出「主角(描述)」这类把身份塞进名字的占位名。",
5000
+ "输出必须是合法 JSON 对象,不要输出任何其他文字或 Markdown 代码块标记。",
5001
+ "重要:所有字符串值内部不得包含换行符(不要用多行字符串),JSON 必须在一段内完整结束。",
5002
+ "重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。",
5003
+ "JSON 结构:",
5004
+ "{\"genre\": \"题材与基调一句话\", \"worldRules\": [\"规则1\", \"规则2\", ...], \"characters\": [{\"name\": \"角色名\", \"role\": \"protagonist|supporting|antagonist|other\", \"traits\": [\"标签1\", ...], \"goals\": \"目标与动机\", \"relations\": \"关键关系\"}], \"redLines\": [\"红线1\", ...], \"style\": [\"风格1\", ...]}"
5005
+ ].join("\n");
5006
+ }
5007
+ //#endregion
5008
+ //#region src/prompts/plan.ts
5009
+ /** System prompt for volume planning. */
5010
+ function volumeSystemPrompt() {
5011
+ return [
5012
+ "你是一位资深网文总编。你会收到一份小说大纲,请把全书划分为若干「卷」(分卷),每卷有明确的剧情定位与起止章节。",
5013
+ "要求:",
5014
+ "1. 大纲已有分卷时,严格遵循大纲的分卷结构;没有时按剧情弧线合理划分(3-8 卷)。",
5015
+ "2. 卷定位一句话说明该卷的剧情重心。",
5016
+ "3. chapterStart/chapterEnd 给出该卷覆盖的章节区间(从 1 开始连续编号)。",
5017
+ "输出必须是合法 JSON 数组,不要输出任何其他文字:",
5018
+ "[{\"no\": 1, \"title\": \"卷名\", \"summary\": \"卷定位与剧情重心\", \"chapterStart\": 1, \"chapterEnd\": 80}]",
5019
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
5020
+ "重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。",
5021
+ renderLengthContract([["卷名", STRUCTURED_FIELD_LIMITS.chapterTitle], ["每卷定位", STRUCTURED_FIELD_LIMITS.chapterSummary]])
5022
+ ].join("\n");
5023
+ }
5024
+ /** The chapter-planning prompt template. */
5025
+ function planSystemPrompt(volumes) {
5026
+ return [
5027
+ "你是一位资深中文网文策划编辑,擅长把小说大纲拆解为可执行的章节计划。",
5028
+ "你会收到一份小说大纲。请根据大纲的设定、主线与节奏,规划出一份章节计划。",
5029
+ "要求:",
5030
+ "1. 每章必须有明确的核心剧情推进(不能只是过渡或凑字数)。",
5031
+ "2. 章节之间要衔接自然,前章结尾为后章埋下钩子。",
5032
+ "3. 严格遵循大纲的人设、金手指规则、战力体系与世界观设定,不得自行发明冲突设定。",
5033
+ "4. 输出必须是合法的 JSON 数组,不要输出任何其他文字或 Markdown 代码块标记。",
5034
+ "5. 数组每个元素格式:{\"title\": \"章节标题(10字以内,有网文感)\", \"beats\": \"结构化剧情要点(150-250字,必须包含四段,段间用换行分隔):\\n本章目标:本章要完成的核心推进;\\n剧情要点:主要情节的起承转合(2-4 句);\\n爽点/钩子:本章的爽点兑现或情绪钩子;\\n结尾钩子:本章结尾为下一章埋下的悬念\"}",
5035
+ "6. 每个章节对象可额外包含以下可选字段(尽量给出,缺失则跳过):tension(0-100 整数:本章张力目标,对抗/揭示/抉择/代价的强度;铺垫与日常章明显偏低,与相邻章节形成起伏)、mustAdvance(数组,本章必须推进的局面/关系/信息/风险/决策变化);mustPreserve(数组,本章必须保持不破坏的项);characterHardFacts(数组,本章不可违背的人物硬事实:身份/阵营/境界/当前位置/知情度);endingHook(字符串,本章结尾钩子要求);obligation(字符串,本章义务合约一句话)。",
5036
+ "重要:beats 字段内部必须使用 \\n 转义表示换行(JSON 字符串内不得有真实换行符),其余字符串值也不得包含真实换行符,JSON 必须在一段内完整结束。",
5037
+ "重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。",
5038
+ volumes !== void 0 && volumes.length > 0 ? ["\n全书分卷结构(规划章节时需落在对应卷内):"].concat(volumes.map((v) => `第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})` + (v.strategy !== void 0 && v.strategy !== "" ? `\n 卷战略:${v.strategy}` : "") + (v.pacing !== void 0 && v.pacing !== "" ? `\n 卷节奏板:${v.pacing}` : ""))).join("\n") : ""
5039
+ ].join("\n");
5040
+ }
5041
+ /**
5042
+ * 续写规划的附加要求(拼在 planSystemPrompt 之后)。
5043
+ *
5044
+ * 与 user 侧的「续写硬性要求」分工:这里管**标题与接续的唯一性**,
5045
+ * user 侧管「已发生事件不得重写」与全书进度感知。
5046
+ */
5047
+ function planContinuationSuffix() {
5048
+ return "\n重要:本次是**续写规划**——已有章节的剧情不得重写或重复,新章节标题不得与已有章节标题相同,新章节的剧情必须从上一章结尾自然接续(人物状态、时间线、地点衔接一致)。";
5049
+ }
5050
+ /** 自动导演:整本编排/节奏/风险/再平衡建议(不输出正文)。 */
5051
+ function directorSystemPrompt() {
5052
+ return "你是一位长篇网文自动导演,负责整本编排、节奏、风险与再平衡。只给出可执行建议,不输出正文。";
5053
+ }
5054
+ //#endregion
5055
+ //#region src/prompt-cache.ts
5056
+ /**
5057
+ * 分层顺序。写死不开放配置——「哪层在前」是缓存语义决定的,不是产品偏好。
5058
+ */
5059
+ const LAYER_ORDER = [
5060
+ "fixed",
5061
+ "book",
5062
+ "chapter"
5063
+ ];
5064
+ /**
5065
+ * 按「固定 → 本书 → 本章」组装系统提示词。
5066
+ *
5067
+ * 同层内保持书写顺序(稳定排序);空白块丢弃,不在结果里留空段。
5068
+ * @param blocks 调用方按书写顺序给出的块(层标可以乱,组装后一定分层有序)。
5069
+ * @param separator 块间分隔符(默认换行)。
5070
+ */
5071
+ function assemblePromptBlocks(blocks, separator = "\n") {
5072
+ const rank = (layer) => LAYER_ORDER.indexOf(layer);
5073
+ return blocks.map((block, index) => ({
5074
+ block,
5075
+ index
5076
+ })).filter((entry) => entry.block.text.trim() !== "").sort((a, b) => rank(a.block.layer) - rank(b.block.layer) || a.index - b.index).map((entry) => entry.block.text).join(separator);
5077
+ }
5078
+ //#endregion
5079
+ //#region src/prompt-slots.ts
5080
+ /** 允许作者编辑的槽位(白名单;不在此列表的提示词一律不可改)。 */
5081
+ const PROMPT_SLOTS = [
5082
+ {
5083
+ id: "styleExtra",
5084
+ label: "文风补充",
5085
+ hint: "追加到写作提示词末尾的风格偏好(句式、叙述习惯、用词倾向)。",
5086
+ placeholder: "例如:多用短句和对话推进,少用成语;环境描写只写能影响动作的部分。",
5087
+ maxChars: 1200
5088
+ },
5089
+ {
5090
+ id: "antiAiExtra",
5091
+ label: "反 AI 补充",
5092
+ hint: "本书额外的\"别这么写\"清单,与内置反 AI 规则叠加(不能覆盖内置规则)。",
5093
+ placeholder: "例如:不要每段都用比喻;不要用\"空气中弥漫着\"这类句式。",
5094
+ maxChars: 1200
5095
+ },
5096
+ {
5097
+ id: "dialogueExtra",
5098
+ label: "对话补充",
5099
+ hint: "角色说话方式的额外约束,写入时会与角色卡一起注入。",
5100
+ placeholder: "例如:长辈说话带方言词;主角对熟人用绰号称呼。",
5101
+ maxChars: 800
5102
+ },
5103
+ {
5104
+ id: "endingHookExtra",
5105
+ label: "章末钩子偏好",
5106
+ hint: "对结尾钩子的额外要求(与章节计划里的 endingHook 叠加)。",
5107
+ placeholder: "例如:结尾尽量落在一个未闭合的选择上,而不是新出现的敌人。",
5108
+ maxChars: 600
5109
+ }
5110
+ ];
5111
+ /** 槽位 id → 规格。 */
5112
+ const SLOT_BY_ID = new Map(PROMPT_SLOTS.map((s) => [s.id, s]));
5113
+ /**
5114
+ * 读取并规整项目里的槽位内容(去空白、按上限截断)。
5115
+ *
5116
+ * @param project 项目状态。
5117
+ * @returns 非空槽位列表(保持 PROMPT_SLOTS 的声明顺序)。
5118
+ */
5119
+ function readPromptSlots(project) {
5120
+ const stored = project.promptSlots ?? {};
5121
+ const out = [];
5122
+ for (const spec of PROMPT_SLOTS) {
5123
+ const raw = (stored[spec.id] ?? "").trim();
5124
+ if (raw === "") continue;
5125
+ const truncated = raw.length > spec.maxChars;
5126
+ out.push({
5127
+ id: spec.id,
5128
+ label: spec.label,
5129
+ text: truncated ? raw.slice(0, spec.maxChars) : raw,
5130
+ truncated,
5131
+ chars: raw.length
5132
+ });
5133
+ }
5134
+ return out;
5135
+ }
5136
+ /**
5137
+ * 把槽位渲染成注入提示词的段落。
5138
+ *
5139
+ * @param project 项目状态。
5140
+ * @returns 提示词片段;无槽位时返回空串。
5141
+ */
5142
+ function renderPromptSlots(project) {
5143
+ const slots = readPromptSlots(project);
5144
+ if (slots.length === 0) return "";
5145
+ const lines = ["==================== 作者偏好(本书自定义,优先级低于道藏/红线/合规) ===================="];
5146
+ for (const slot of slots) lines.push(`【${slot.label}】${slot.text}${slot.truncated ? `(已按 ${SLOT_BY_ID.get(slot.id)?.maxChars ?? 0} 字上限截断,原 ${slot.chars} 字)` : ""}`);
5147
+ lines.push("以上偏好只影响表达方式;与道藏、写作红线、内容合规红线冲突时,一律以后者为准。");
5148
+ return lines.join("\n");
5149
+ }
5150
+ /**
5151
+ * 校验并规整一次槽位写入。
5152
+ *
5153
+ * @param id 槽位 id。
5154
+ * @param value 内容(空串表示清空该槽位)。
5155
+ * @returns 归一化后的内容。
5156
+ * @throws 槽位 id 不在白名单时抛错。
5157
+ */
5158
+ function normalizeSlotValue(id, value) {
5159
+ if (SLOT_BY_ID.get(id) === void 0) throw new Error(`未知提示词槽位:${id}`);
5160
+ return value.replace(/\r\n/g, "\n").trim();
5161
+ }
5162
+ //#endregion
5163
+ //#region src/prompting.ts
5164
+ /** 章节写作约束:把写作骨架渲染成可附加到系统提示词末尾的必达/禁止/自查块。 */
5165
+ function renderChapterWriterSkeleton(meta) {
5166
+ const pov = meta.pov ?? "第三人称有限视角,严格跟随主角所见所知。";
5167
+ const tone = meta.tonePreference ?? "文风贴合本书设定,用具体细节与动作推进。";
5168
+ const hook = meta.endingHookPreference ?? "章末留一个明确的钩子(新信息、新风险或未闭合的选择)。";
5169
+ const antiAi = meta.antiAiRules ?? "";
5170
+ return [
5171
+ "==================== 章节生成骨架(必达 / 禁止 / 输出前自查) ====================",
5172
+ "【叙事视角】" + pov,
5173
+ "【本章必达】本章必须发生实质变化——局面、关系、信息、风险、决策至少动一项;开篇直接进入情境,不复述上一章。",
5174
+ "【禁止事项】不写以总结、复盘、解释为主的段落;不新增核心角色,不写入与上下文冲突的设定;不为凑字数注水。",
5175
+ "【篇幅】目标 " + meta.targetChars + " 字,区间 " + meta.minChars + "-" + meta.maxChars + " 字;不够就继续推进有效情节,不许草草收尾。",
5176
+ "【结尾】" + hook,
5177
+ "【表达】用具体动作、对话与可感知细节往前推;" + tone,
5178
+ "【反 AI】" + antiAi,
5179
+ "【输出前自查】逐项确认:读者的回报、关键转折、章末净变化是否看得见?上一章留下的钩子有没有交代?人物的硬事实有没有被写反?确认无误再输出,正文里不要出现核查过程。"
5180
+ ].join("\n");
5181
+ }
5182
+ /**
5183
+ * 本项目设计的参考曲线形状(0-100 的采样序列)。
5184
+ * 只描述"形状",不含任何外部数值表。
5185
+ */
5186
+ const REFERENCE_SHAPES = {
5187
+ escalation: [
5188
+ 20,
5189
+ 26,
5190
+ 34,
5191
+ 32,
5192
+ 44,
5193
+ 52,
5194
+ 48,
5195
+ 62,
5196
+ 70,
5197
+ 66,
5198
+ 82,
5199
+ 92
5200
+ ],
5201
+ suspense: [
5202
+ 30,
5203
+ 42,
5204
+ 38,
5205
+ 50,
5206
+ 46,
5207
+ 58,
5208
+ 62,
5209
+ 56,
5210
+ 70,
5211
+ 78,
5212
+ 86,
5213
+ 74
5214
+ ],
5215
+ wave: [
5216
+ 45,
5217
+ 30,
5218
+ 55,
5219
+ 34,
5220
+ 62,
5221
+ 40,
5222
+ 70,
5223
+ 46,
5224
+ 78,
5225
+ 52,
5226
+ 84,
5227
+ 60
5228
+ ],
5229
+ frontLoad: [
5230
+ 78,
5231
+ 66,
5232
+ 58,
5233
+ 50,
5234
+ 46,
5235
+ 44,
5236
+ 42,
5237
+ 44,
5238
+ 46,
5239
+ 48,
5240
+ 50,
5241
+ 52
5242
+ ],
5243
+ flat: [
5244
+ 40,
5245
+ 42,
5246
+ 44,
5247
+ 42,
5248
+ 46,
5249
+ 44,
5250
+ 48,
5251
+ 46,
5252
+ 50,
5253
+ 48,
5254
+ 52,
5255
+ 50
5256
+ ]
5257
+ };
5258
+ /** 参考曲线的中文标签(面板展示)。 */
5259
+ const TENSION_PRESET_LABELS = {
5260
+ escalation: "递进爬升",
5261
+ suspense: "悬疑加压",
5262
+ wave: "波浪呼吸",
5263
+ frontLoad: "前紧后稳",
5264
+ flat: "平稳推进",
5265
+ custom: "自定义"
5266
+ };
5267
+ /** 全部可选预设(UI 下拉用)。 */
5268
+ const TENSION_PRESETS = [
5269
+ "escalation",
5270
+ "suspense",
5271
+ "wave",
5272
+ "frontLoad",
5273
+ "flat",
5274
+ "custom"
5275
+ ];
5276
+ /** 把参考形状按章节数重采样(最近邻,保持形状不引入平滑失真)。 */
5277
+ function referenceCurve(preset, chapterCount) {
5278
+ if (chapterCount <= 0) return [];
5279
+ if (preset === "custom") return [];
5280
+ const shape = REFERENCE_SHAPES[preset];
5281
+ if (chapterCount === 1) return [shape[Math.floor(shape.length / 2)] ?? 50];
5282
+ return Array.from({ length: chapterCount }, (_, i) => {
5283
+ const pos = i / (chapterCount - 1) * (shape.length - 1);
5284
+ const lo = Math.floor(pos);
5285
+ const hi = Math.min(shape.length - 1, lo + 1);
5286
+ const t = pos - lo;
5287
+ const a = shape[lo] ?? 50;
5288
+ const b = shape[hi] ?? a;
5289
+ return Math.round(a + (b - a) * t);
5290
+ });
5291
+ }
5292
+ /** 读一章的目标张力(未设置时返回 undefined)。 */
5293
+ function targetTension(chapter) {
5294
+ return typeof chapter.tension === "number" ? clampTension(chapter.tension) : void 0;
5295
+ }
5296
+ /** 读一章的实际张力(审稿时模型打分,未设置时返回 undefined)。 */
5297
+ function actualTension(chapter) {
5298
+ const fromReview = chapter.review?.tension;
5299
+ if (typeof fromReview === "number") return clampTension(fromReview);
5300
+ return typeof chapter.tensionActual === "number" ? clampTension(chapter.tensionActual) : void 0;
5301
+ }
5302
+ /** 收敛到合法区间并取整。 */
5303
+ function clampTension(value) {
5304
+ if (!Number.isFinite(value)) return 0;
5305
+ return Math.max(0, Math.min(100, Math.round(value)));
5306
+ }
5307
+ /**
5308
+ * 渲染注入写作提示词的「本章张力」块。
5309
+ *
5310
+ * @param project 项目状态。
5311
+ * @param chapterNo 本章章号。
5312
+ * @returns 提示词片段;没有张力信息时返回空串。
5313
+ */
5314
+ function renderTensionBlock(project, chapterNo) {
5315
+ const preset = project.tensionCurve?.preset;
5316
+ const chapters = [...project.chapters ?? []].sort((a, b) => a.no - b.no);
5317
+ const idx = chapters.findIndex((c) => c.no === chapterNo);
5318
+ if (idx === -1) return "";
5319
+ const target = targetTension(chapters[idx]);
5320
+ const prev = idx > 0 ? targetTension(chapters[idx - 1]) : void 0;
5321
+ if (target === void 0 && prev === void 0) return "";
5322
+ const ref = preset !== void 0 && preset !== "custom" ? referenceCurve(preset, chapters.length)[idx] : void 0;
5323
+ const parts = [];
5324
+ if (target !== void 0) parts.push(`本章目标张力 ${target}/100`);
5325
+ if (prev !== void 0 && target !== void 0) {
5326
+ const delta = target - prev;
5327
+ parts.push(delta >= 10 ? "比上一章明显更紧" : delta <= -10 ? "比上一章明显放松(给读者呼吸口)" : "与上一章持平");
5328
+ }
5329
+ if (ref !== void 0) parts.push(`参考曲线(${TENSION_PRESET_LABELS[preset ?? "custom"]})在此处约为 ${ref}`);
5330
+ if (parts.length === 0) return "";
5331
+ return [
5332
+ "==================== 本章张力(写作时把握松紧) ====================",
5333
+ parts.join(";") + "。",
5334
+ "低张力段用于铺垫关系、交代信息、留呼吸;高张力段用于对抗、揭示、抉择与代价。",
5335
+ "同一章内可以有起伏,但整章的平均松紧要贴近目标值;不要连续多章都停在同一个紧张度。"
5336
+ ].join("\n");
5337
+ }
5338
+ /**
5339
+ * 规则核对张力曲线(不调模型)。
5340
+ *
5341
+ * 检查四类曲线级问题:
5342
+ * 1) 实际与目标偏差过大(>25);
5343
+ * 2) 连续多章几乎同值(≥4 章落差 <6,平台期无起伏);
5344
+ * 3) 高点之后没有回落(连续 ≥3 章处在高位 ≥80);
5345
+ * 4) 长期低位(连续 ≥5 章 <40,容易拖沓)。
5346
+ *
5347
+ * @param project 项目状态。
5348
+ * @returns 问题清单(可能为空)。
5349
+ */
5350
+ /**
5351
+ * run 型问题(连续同值 / 连续高位 / 连续低位)的受影响章号。
5352
+ * 返回**完整区间**而不是首尾两点:中间章同样受这条曲线问题影响,
5353
+ * 面板要能在每一章上给出「一键按建议修订」,合并层也要能把建议落到中间章。
5354
+ */
5355
+ function runChapters(from, to) {
5356
+ const out = [];
5357
+ for (let no = from; no <= to; no++) out.push(no);
5358
+ return out;
5359
+ }
5360
+ function detectTensionIssues(project) {
5361
+ const values = [...project.chapters ?? []].sort((a, b) => a.no - b.no).map((c) => ({
5362
+ no: c.no,
5363
+ target: targetTension(c),
5364
+ actual: actualTension(c)
5365
+ }));
5366
+ if (values.filter((v) => v.target !== void 0 || v.actual !== void 0).length < 3) return [];
5367
+ const issues = [];
5368
+ for (const v of values) {
5369
+ if (v.target === void 0 || v.actual === void 0) continue;
5370
+ const gap = v.actual - v.target;
5371
+ if (Math.abs(gap) > 25) issues.push({
5372
+ severity: Math.abs(gap) > 40 ? "high" : "medium",
5373
+ chapters: [v.no],
5374
+ item: `第${v.no}章张力偏差较大:目标 ${v.target},实际 ${v.actual}(${gap > 0 ? "比目标更紧" : "比目标更松"})`,
5375
+ suggestion: gap > 0 ? "本章对抗/揭示密度偏高,考虑把部分冲突后移,留出呼吸段。" : "本章推进偏平,考虑加一处对抗、揭示或代价。"
5376
+ });
5377
+ }
5378
+ let run = 1;
5379
+ for (let i = 1; i < values.length; i++) {
5380
+ const a = values[i - 1].target ?? values[i - 1].actual;
5381
+ const b = values[i].target ?? values[i].actual;
5382
+ if (a !== void 0 && b !== void 0 && Math.abs(b - a) < 6) {
5383
+ run++;
5384
+ if (run === 4) {
5385
+ const from = values[i - run + 1].no;
5386
+ issues.push({
5387
+ severity: "medium",
5388
+ chapters: runChapters(from, values[i].no),
5389
+ item: `第${from}-${values[i].no}章张力几乎无变化(连续 ${run} 章)`,
5390
+ suggestion: "安排一次明显抬升或一次释放,让曲线有起伏,避免读者疲劳。"
5391
+ });
5392
+ }
5393
+ } else run = 1;
5394
+ }
5395
+ let highRun = 0;
5396
+ for (const v of values) {
5397
+ const val = v.target ?? v.actual;
5398
+ if (val !== void 0 && val >= 80) {
5399
+ highRun++;
5400
+ if (highRun === 3) issues.push({
5401
+ severity: "medium",
5402
+ chapters: runChapters(v.no - 2, v.no),
5403
+ item: `连续 ${highRun} 章处在高位(≥80),缺少呼吸口`,
5404
+ suggestion: "高潮之后补一章缓冲(关系推进、信息消化或日常),再进入下一轮对抗。"
5405
+ });
5406
+ } else highRun = 0;
5407
+ }
5408
+ let lowRun = 0;
5409
+ for (const v of values) {
5410
+ const val = v.target ?? v.actual;
5411
+ if (val !== void 0 && val < 40) {
5412
+ lowRun++;
5413
+ if (lowRun === 5) issues.push({
5414
+ severity: "high",
5415
+ chapters: runChapters(v.no - 4, v.no),
5416
+ item: `连续 ${lowRun} 章张力低于 40,推进可能偏慢`,
5417
+ suggestion: "提前兑现一个目标、抛出一个新风险,或让配角线产生冲撞。"
5418
+ });
5419
+ } else lowRun = 0;
5420
+ }
5421
+ return issues;
5422
+ }
5423
+ /**
5424
+ * 组装面板需要的曲线数据(章节号 / 目标 / 实际 / 参考)。
5425
+ *
5426
+ * @param project 项目状态。
5427
+ * @returns 逐章数据点与预设名。
5428
+ */
5429
+ function buildCurveData(project) {
5430
+ const preset = project.tensionCurve?.preset ?? "escalation";
5431
+ const chapters = [...project.chapters ?? []].sort((a, b) => a.no - b.no);
5432
+ const custom = project.tensionCurve?.custom ?? [];
5433
+ const reference = preset === "custom" ? custom : referenceCurve(preset, chapters.length);
5434
+ return {
5435
+ preset,
5436
+ points: chapters.map((c, i) => {
5437
+ const point = {
5438
+ no: c.no,
5439
+ title: c.title
5440
+ };
5441
+ const t = targetTension(c);
5442
+ const a = actualTension(c);
5443
+ if (t !== void 0) point.target = t;
5444
+ if (a !== void 0) point.actual = a;
5445
+ if (reference[i] !== void 0) point.reference = reference[i];
5446
+ return point;
5447
+ })
5448
+ };
5449
+ }
5450
+ //#endregion
5451
+ //#region src/prompts/common.ts
5452
+ /**
5453
+ * 提示词共享件:内容合规红线。
5454
+ *
5455
+ * 所有书籍、所有章节无条件生效,优先级高于单书大纲/道藏中的任何设定与作者
5456
+ * 自定义红线。原来住在 engine.ts 里,随二期拆分成域模块后迁到此处——
5457
+ * 各域模块(writing/review/polish…)都注入它,放共享件里避免循环导入。
5458
+ */
5459
+ /** 内容合规红线(平台硬性要求)。注入点:章节生成 + 审稿 + 润色 + 局部修订系统提示(命中即 high)。 */
5460
+ const COMPLIANCE_REDLINES = [
5461
+ "1. 不得出现反对宪法所确定的基本原则的内容。",
5462
+ "2. 不得出现危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的内容。",
5463
+ "3. 不得出现危害国家荣誉和利益的内容。",
5464
+ "4. 不得出现煽动民族仇恨、民族歧视、破坏民族团结的内容。",
5465
+ "5. 不得出现破坏国家宗教政策、宣扬邪教和愚昧迷信的内容(不得以真实宗教、邪教或迷信活动为背景进行宣扬)。",
5466
+ "6. 不得出现散布谣言、扰乱社会秩序、破坏社会稳定的内容。",
5467
+ "7. 不得出现淫秽色情、赌博、暴力、凶杀、恐怖或教唆犯罪的内容(网文语境:禁止露骨性描写、血腥暴力渲染、赌博教唆、犯罪手法详细教学)。",
5468
+ "8. 不得出现侮辱或者诽谤他人、侵害他人合法权益的内容(不得以真实人物、组织为原型进行侮辱或影射攻击)。",
5469
+ "9. 不得出现法律法规禁止的其他内容。"
5470
+ ];
5471
+ //#endregion
5472
+ //#region src/prompts/world.ts
5473
+ /** 把大世界结构化数据渲染成提示词块(境界体系按顺序强约束)。 */
5474
+ function renderWorld(world) {
5475
+ if (world === void 0) return "";
5476
+ const sections = ["==================== 大世界(结构化设定,写作时严格遵守) ===================="];
5477
+ if (world.realms.length > 0) {
5478
+ sections.push("境界体系(由低到高,不得随意跳级或自创境界):");
5479
+ world.realms.forEach((realm, i) => {
5480
+ sections.push(`${i + 1}. ${realm.name}${realm.description !== "" ? ` — ${realm.description}` : ""}`);
5481
+ });
5482
+ }
5483
+ if (world.regions.length > 0) {
5484
+ sections.push("地理区域:");
5485
+ for (const region of world.regions) sections.push(`- ${region.name}${region.description !== "" ? `:${region.description}` : ""}${region.faction !== void 0 && region.faction !== "" ? `(势力:${region.faction})` : ""}`);
5486
+ }
5487
+ if (world.factions.length > 0) {
5488
+ sections.push("势力分布:");
5489
+ for (const faction of world.factions) sections.push(`- ${faction.name}(${faction.kind})${faction.description !== "" ? `:${faction.description}` : ""}${faction.region !== void 0 && faction.region !== "" ? `(驻地:${faction.region})` : ""}`);
5490
+ }
5491
+ return sections.join("\n");
5492
+ }
5493
+ /** 大世界提炼:从大纲 + 道藏生成结构化境界体系/区域/势力。 */
5494
+ function extractWorldSystemPrompt() {
5495
+ return [
5496
+ "你是一位网文世界观架构师。请根据小说大纲与道藏,提炼结构化「大世界」数据。",
5497
+ "输出必须是合法 JSON 对象:",
5498
+ "{\"realms\": [{\"name\": \"境界名\", \"description\": \"突破条件/寿命/标志等\"}], \"regions\": [{\"name\": \"区域名\", \"description\": \"描述\", \"faction\": \"关联势力名或空\"}], \"factions\": [{\"name\": \"势力名\", \"kind\": \"宗门/家族/王朝/组织等\", \"description\": \"描述\", \"region\": \"驻地区域或空\"}]}",
5499
+ "要求:",
5500
+ "1. realms 按由低到高顺序排列(修仙题材必须含完整境界链;无境界设定的题材可输出空数组)。",
5501
+ "2. 数量贴合大纲:realms 3-12 个,regions 2-10 个,factions 2-10 个。",
5502
+ "3. 内容严格来自大纲与道藏,不要凭空发明与大纲冲突的设定。",
5503
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
5504
+ ].join("\n");
5505
+ }
5506
+ //#endregion
5507
+ //#region src/prompts/writing.ts
5508
+ /** Build the writing system prompt (bible + outline + active foreshadows).
5509
+ * `targetChars` 来自每章计划(规划时快照,= 设置的每章目标字数);无则退回默认 3500。
5510
+ * 字数区间按目标动态生成(±15%,取整到百位),避免系统提示词与设置互相冲突。
5511
+ * `lengthRule` 可覆盖第 1 条字数要求(整章修订/改编时按原文长度为准)。 */
5512
+ function writeSystemPrompt(project, targetChars, lengthRule, beforeChapter) {
5513
+ const bible = project.bible;
5514
+ const blocks = [];
5515
+ const target = targetChars !== void 0 && targetChars > 0 ? targetChars : 3500;
5516
+ const lo = Math.max(1e3, Math.round(target * .85 / 100) * 100);
5517
+ const hi = Math.max(lo + 100, Math.round(target * 1.15 / 100) * 100);
5518
+ const rules = ["写作硬性要求:"];
5519
+ rules.push(lengthRule ?? `1. 每章 ${lo}-${hi} 字(目标 ${target} 字,按中文字符计),只输出章节正文,不要输出标题、章回名、作者的话或任何 Markdown 标记。`);
5520
+ rules.push("2. 以主角视角展开,动作、对话、心理描写交替推进,禁止大段设定说明。");
5521
+ rules.push("3. 尊重大纲与道藏:人设不崩、金手指规则不自相矛盾、战力不随意膨胀。");
5522
+ rules.push("4. 章末留一个钩子(悬念、反转或新线索),吸引读者读下一章。");
5523
+ rules.push("5. 语言流畅自然,符合中文网文语感,避免翻译腔与病句。");
5524
+ rules.push("6. 对话与冲突密度:每章至少 1 处实质对话或正面对抗/交锋场面;推理与心理活动必须用动作、环境细节、微表情、对话呈现,禁止整章纯内心独白铺陈(禁止\"解说式\"交代线索)。");
5525
+ rules.push("7. 反派与对手的行动力:本章出现的反派/对手必须有其行动、反制或压迫感(布局、试探、追索、交锋至少占其一),不得作为纯背景板存在。");
5526
+ rules.push("8. 配角辨识度:重要新登场配角应给姓名或可辨识的独有特征;禁止通篇用\"瘦高个/灰衣人/戴面具者\"等身形标签代称同一角色。");
5527
+ rules.push("9. 信息呈现方式:关键线索、设定、局势通过对话、动作、发现物呈现,禁止主角内心\"讲解\"给读者听。");
5528
+ blocks.push({
5529
+ layer: "fixed",
5530
+ text: rules.join("\n")
5531
+ });
5532
+ blocks.push({
5533
+ layer: "fixed",
5534
+ text: [
5535
+ "==================== 内容合规红线(平台硬性要求,最高优先级,违反即失败) ====================",
5536
+ COMPLIANCE_REDLINES.join("\n"),
5537
+ "以上九条为硬性底线,任何情况下不得以任何形式出现或影射;若剧情确需涉及(如批判、反讽),只能以明确否定、揭露、批判的立场呈现,且不得展开细节。"
5538
+ ].join("\n")
5539
+ });
5540
+ const book = [];
5541
+ if (bible !== void 0) {
5542
+ book.push("==================== 道藏(写作时严格遵守) ====================");
5543
+ if (bible.genre !== "") book.push(`题材基调:${bible.genre}`);
5544
+ if (bible.worldRules.length > 0) book.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
5545
+ const roleLib = project.roles ?? [];
5546
+ const labelName = {
5547
+ protagonist: "主角",
5548
+ female_lead: "女主",
5549
+ female_support: "女配",
5550
+ support: "配角",
5551
+ antagonist: "反派",
5552
+ extra: "路人"
5553
+ };
5554
+ const seenRole = /* @__PURE__ */ new Set();
5555
+ const mergedRoles = [];
5556
+ for (const r of roleLib) {
5557
+ seenRole.add(r.name);
5558
+ const card = bible.characters.find((c) => c.name === r.name);
5559
+ const traits = card !== void 0 ? card.traits : Array.isArray(r.traits) ? r.traits : [];
5560
+ const goals = card !== void 0 && card.goals !== "" ? card.goals : r.goals;
5561
+ const relations = card !== void 0 && card.relations !== "" ? card.relations : Array.isArray(r.relations) && r.relations.length > 0 ? r.relations.join("、") : "";
5562
+ mergedRoles.push(`- ${r.name}(${labelName[r.roleLabel]}):${r.identity}${traits.length > 0 ? `;性格:${traits.join("、")}` : ""}${goals !== "" ? `;目标:${goals}` : ""}${relations !== "" ? `;关系:${relations}` : ""}`);
5563
+ if (card !== void 0 && Array.isArray(card.knowledge) && card.knowledge.length > 0) mergedRoles.push(` 已知信息(该角色知道的:${card.knowledge.join(";")};未列出的信息该角色一律不知道,不得写其知晓或提及)`);
5564
+ }
5565
+ for (const card of bible.characters) {
5566
+ if (seenRole.has(card.name)) continue;
5567
+ seenRole.add(card.name);
5568
+ const roleName = {
5569
+ protagonist: "主角",
5570
+ supporting: "配角",
5571
+ antagonist: "反派",
5572
+ other: "其他"
5573
+ }[card.role];
5574
+ mergedRoles.push(`- ${card.name}(${roleName}):${card.traits.join("、")}${card.goals !== "" ? `;目标:${card.goals}` : ""}${card.relations !== "" ? `;关系:${card.relations}` : ""}`);
5575
+ if (Array.isArray(card.knowledge) && card.knowledge.length > 0) mergedRoles.push(` 已知信息(该角色知道的:${card.knowledge.join(";")};未列出的信息该角色一律不知道,不得写其知晓或提及)`);
5576
+ }
5577
+ if (mergedRoles.length > 0) {
5578
+ book.push("角色卡(角色库与道藏已合并去重):");
5579
+ book.push(...mergedRoles);
5580
+ }
5581
+ if (bible.redLines.length > 0) book.push("写作红线(违反即失败):\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
5582
+ if (bible.style.length > 0) book.push("风格要求:\n" + bible.style.map((r) => `- ${r}`).join("\n"));
5583
+ }
5584
+ const worldBlock = renderWorld(project.world);
5585
+ if (worldBlock !== "") book.push(worldBlock);
5586
+ book.push("==================== 全书大纲 ====================");
5587
+ const outlineBlock = project.outline.length > 6e3 ? project.outline.slice(0, 6e3) + "\n…(大纲过长已节选,完整内容见总纲页)" : project.outline;
5588
+ book.push(outlineBlock);
5589
+ book.push("==================== 大纲结束 ====================");
5590
+ const assetsBlock = renderAllAssets(project.assets);
5591
+ if (assetsBlock !== "") book.push(assetsBlock);
5592
+ const slotBlock = renderPromptSlots(project);
5593
+ if (slotBlock !== "") book.push(slotBlock);
5594
+ blocks.push({
5595
+ layer: "book",
5596
+ text: book.join("\n")
5597
+ });
5598
+ const chapter = [];
5599
+ const active = project.foreshadows.filter((f) => f.status === "planted" || f.status === "progressing");
5600
+ if (active.length > 0) {
5601
+ chapter.push("==================== 活跃伏笔(近期需推进或回收的线索) ====================");
5602
+ for (const f of active) chapter.push(`- [${f.status === "planted" ? "已埋设" : "推进中"}] ${f.description}${f.targetChapter !== void 0 ? `(预计 ${f.targetChapter} 章回收)` : ""}`);
5603
+ }
5604
+ const lines = (project.plotlines ?? []).filter((l) => l.status === "active" || l.status === "paused");
5605
+ if (lines.length > 0) {
5606
+ const kindName = {
5607
+ main: "主线",
5608
+ branch: "支线",
5609
+ character: "人物线",
5610
+ mystery: "悬念线"
5611
+ };
5612
+ chapter.push("==================== 剧情线(本章应推进至少一条活跃线) ====================");
5613
+ for (const l of lines) chapter.push(`- [${kindName[l.kind]}${l.status === "paused" ? "·暂停中" : ""}] ${l.name}:${l.goal}${l.progress !== "" ? `(当前进度:${l.progress})` : ""}`);
5614
+ }
5615
+ chapter.push(renderChapterWriterSkeleton({
5616
+ targetChars: target,
5617
+ minChars: lo,
5618
+ maxChars: hi,
5619
+ pov: "第三人称有限视角,严格跟随主角所见所知。",
5620
+ endingHookPreference: "章末留一个明确钩子(新信息、新风险或未闭合选择)。",
5621
+ tonePreference: "动作、对话、心理交替推进;重大信息用对话/动作/发现呈现。",
5622
+ antiAiRules: "严格遵循上方「反 AI 规则」与写法资产,避免套话与 AI 腔。"
5623
+ }));
5624
+ const timelineBlock = renderTimelineBlock(project, beforeChapter ?? Number.MAX_SAFE_INTEGER);
5625
+ if (timelineBlock !== "") chapter.push(timelineBlock);
5626
+ if (beforeChapter !== void 0) {
5627
+ const tensionBlock = renderTensionBlock(project, beforeChapter);
5628
+ if (tensionBlock !== "") chapter.push(tensionBlock);
5629
+ }
5630
+ blocks.push({
5631
+ layer: "chapter",
5632
+ text: chapter.join("\n")
5633
+ });
5634
+ return assemblePromptBlocks(blocks);
5635
+ }
5636
+ /** Build the rewrite system prompt (fix review issues / instructions). */
5637
+ function rewriteSystemPrompt(project, targetChars, beforeChapter) {
5638
+ return writeSystemPrompt(project, targetChars, "1. 输出完整的新正文(不要只输出修改片段、标题、章回名、作者的话或任何 Markdown 标记),字数与原章相当(允许 ±20%)。", beforeChapter) + "\n\n额外要求:你正在【修订】一章已写好的正文。保留原文中好的部分,只修改需要修改的地方,输出完整的新正文(不要只输出修改片段),字数与原文相当。";
5639
+ }
5640
+ /**
5641
+ * 正文续写补齐的附加要求(拼在 writeSystemPrompt 之后)。
5642
+ *
5643
+ * 触发场景:生成的正文不足目标字数 90% 时,最多补两轮。
5644
+ */
5645
+ function bodyContinuationSuffix() {
5646
+ return "\n\n【续写任务】严格承接已有正文往下写,不要重复已有内容、不要重写开头、不要总结前文,直接续写新情节,并在结尾留一个章末钩子。";
5647
+ }
5648
+ //#endregion
5649
+ //#region src/prompts/review.ts
5650
+ /** The review system prompt. */
5651
+ function reviewSystemPrompt(project) {
5652
+ const bible = project.bible;
5653
+ const blocks = [];
5654
+ const fixed = [
5655
+ "你是一位严格的网文审稿编辑。你会收到一章正文以及本书的道藏与红线。",
5656
+ "请从以下维度审查本章:",
5657
+ "1. 人设一致性:角色行为是否符合下方角色卡的设定(性格/目标/知情度/说话方式)。",
5658
+ "2. 设定一致性:金手指规则、战力体系、世界观是否与道藏冲突。",
5659
+ "3. 红线检查:是否触犯下方「本书红线」与「内容合规红线」。",
5660
+ "4. 文笔质量:语病、翻译腔、AI 套话(\"不禁\"\"仿佛\"\"一时间\"等高频词滥用)、流水账。",
5661
+ "5. 节奏与爽点:本章是否有推进、有钩子,是否拖沓灌水。",
5662
+ "6. 逻辑漏洞:前后矛盾、时间线错误、对话失真。",
5663
+ "7. 反 AI 规则:下方「本地 AI 味扫描」里已给出**本轮确定性命中的规则与次数**,直接以它为事实依据判级(禁止类命中即列为问题;鼓励类只作低优先级建议、不阻塞通过)。未列出的规则说明本章无确定性命中,不必逐条复述,也不要凭印象补报。",
5664
+ "8. 呈现方式:整章是否纯内心推理铺陈(无对话/无对抗,推理全靠解说);反派是否纯背景板无行动;重要配角是否无名标签化(瘦高个/灰衣人全程代称)——命中即列为问题。",
5665
+ "9. 内容合规(最高优先级):逐条核对下方「内容合规红线」,任何一条命中(含影射、暗示、详细描写)必须列为 high,并给出改写建议。",
5666
+ "输出必须是合法 JSON 对象,不要输出任何其他文字:",
5667
+ "{\"score\": 0-100的整数, \"riskScore\": 0-100的整数(越高越需人工处理,可结合本地AI味指数), \"tension\": 0-100的整数(本章张力强度:对抗/揭示/抉择/代价;铺垫与日常章应明显偏低), \"verdict\": \"一句话总评\", \"issues\": [{\"severity\": \"high|medium|low\", \"dimension\": \"character|setting|redline|writing|pacing|logic|anti-ai|presentation|compliance\", \"item\": \"问题描述\", \"suggestion\": \"修改建议\", \"ruleName\": \"命中的反AI规则名(见反AI规则清单)\", \"ruleType\": \"forbidden|risk|encourage\", \"category\": \"套话|句式|段落|心理|设定|节奏|对话|其他\", \"excerpt\": \"命中的原文摘录(不超过50字)\", \"reason\": \"判定理由\", \"canAutoRewrite\": true|false}]}",
5668
+ "tension 是本章张力强度评分(不是质量分):对决/揭秘/生死抉择偏高,铺垫章、日常章、过渡章偏低;同一本书内保持同一尺度。",
5669
+ "反 AI 类 issue 尽量给出 ruleName/ruleType/category/excerpt/reason/canAutoRewrite,便于统计与自动改写。",
5670
+ "风格符合度:若下方给出「风格指纹比对」,以它为准判断本章是否偏离绑定写法——偏差显著时按 writing 维度、category 用「风格」上报;比对里说没有显著偏差时,不要报风格问题。",
5671
+ "AI 套话高频模板词示例(集中出现必须整体降密度):仿佛、似乎、极其、完美、深不见底、形成了、莫名、无法形容、难以言喻、精心雕琢、肤光胜雪、眉目如画、歌舞升平、觥筹交错、妙语连珠、不可名状、另一层真相、命运、真相。",
5672
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
5673
+ "重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。",
5674
+ "==================== 内容合规红线(平台硬性要求,最高优先级) ====================",
5675
+ COMPLIANCE_REDLINES.join("\n"),
5676
+ "以上九条为硬性底线:正文中任何一条命中(含影射、暗示、详细展开)都必须列为 high,并给出改写建议;作者自定义红线不得豁免这九条。"
5677
+ ];
5678
+ blocks.push({
5679
+ layer: "fixed",
5680
+ text: fixed.join("\n")
5681
+ });
5682
+ const book = [];
5683
+ const assetsBlock = renderAllAssets(project.assets);
5684
+ if (assetsBlock !== "") book.push("\n" + assetsBlock);
5685
+ if (bible !== void 0) {
5686
+ book.push("\n==================== 道藏 ====================");
5687
+ if (bible.worldRules.length > 0) book.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
5688
+ if (bible.characters.length > 0) {
5689
+ book.push("角色卡:");
5690
+ for (const card of bible.characters) {
5691
+ book.push(`- ${card.name}(${card.role}):${card.traits.join("、")}`);
5692
+ if (Array.isArray(card.knowledge) && card.knowledge.length > 0) book.push(` 该角色知道:${card.knowledge.join(";")}(未列出的信息该角色不知道)`);
5693
+ }
5694
+ }
5695
+ if (bible.redLines.length > 0) book.push("红线:\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
5696
+ }
5697
+ blocks.push({
5698
+ layer: "book",
5699
+ text: book.join("\n")
5700
+ });
5701
+ return assemblePromptBlocks(blocks);
5702
+ }
5703
+ /** 验证模式系统提示:修订后逐条核对原意见是否解决,只挑新增 high,不重复挑剔主观项。 */
5704
+ function verifySystemPrompt(project) {
5705
+ return [
5706
+ "你是一位网文审稿验证员。作者已按上一轮审稿意见修订了本章,你需要验证修订效果。",
5707
+ "你的任务(严格按此执行):",
5708
+ "1. 逐条核对「上一轮意见」中的每一条(按编号 1、2、3...)是否已在修订稿中解决。",
5709
+ "2. 只挑修订【新引入】的 high 级问题(设定矛盾/逻辑硬伤/事实错误)——新引入的 medium/low 主观项(文笔/套话/节奏)不要列。",
5710
+ "3. 禁止重复挑剔上一轮已指出且本次已解决的主观项(如\"缓缓/微微\"等套话、错别字)——即使换个说法再提也不行。",
5711
+ "4. 严禁为了显得专业而新增\"换一批毛病\"式的意见。",
5712
+ "输出必须是合法 JSON 对象,包含以下字段:",
5713
+ "- resolvedIds:已解决的上一轮意见编号数组(如 [1, 3, 5])。",
5714
+ "- unresolvedIds:未解决或部分解决的上一轮意见编号数组(如 [2, 4])。",
5715
+ "- issues:未解决的原意见 + 新引入的 high 级问题列表(格式同审稿:severity/item/suggestion)。未解决的原意见 item 需注明\"未解决(编号N):原意见摘要\"。",
5716
+ "- score:按修订稿整体质量给 50-90 分(解决全部 high 且无新增 high 时给 70 以上)。",
5717
+ "- verdict:一句话结论。",
5718
+ "完整格式:{\"resolvedIds\": [1,3], \"unresolvedIds\": [2], \"score\": 75, \"verdict\": \"一句话\", \"issues\": [{\"severity\": \"high\", \"dimension\": \"character|setting|redline|writing|pacing|logic|anti-ai|presentation|compliance\", \"item\": \"未解决(2):xxx\", \"suggestion\": \"xxx\"}]}",
5719
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
5720
+ "重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。",
5721
+ renderBibleForVerify(project)
5722
+ ].join("\n");
5723
+ }
5724
+ /**
5725
+ * 验证模式下注入道藏。
5726
+ *
5727
+ * 原实现是 `JSON.stringify(project.bible).slice(0, 3000)`——有两个问题:
5728
+ * 1. **腰斩的 JSON 不是 JSON**:`slice` 从中间切断,审稿员会看到语法残缺的
5729
+ * 对象(字符串没闭合、数组没结尾),既浪费 token 又可能误读设定。
5730
+ * 2. **静默截断设定**:世界规则/角色卡排在后面的字段最先被切掉,而那恰恰是
5731
+ * 核对设定冲突最需要的部分。
5732
+ *
5733
+ * 改为结构化渲染 + 按「世界规则/角色/红线」分配独立预算。
5734
+ */
5735
+ function renderBibleForVerify(project) {
5736
+ const bible = project.bible;
5737
+ if (bible === void 0) return "本书道藏(核对设定冲突用):\n(无)";
5738
+ const parts = [];
5739
+ if (bible.worldRules.length > 0) parts.push(`世界规则:\n${bible.worldRules.map((r) => `- ${r}`).join("\n")}`);
5740
+ if (bible.characters.length > 0) parts.push(`角色卡:\n${bible.characters.map((c) => {
5741
+ const goals = c.goals !== "" ? `;目标:${c.goals}` : "";
5742
+ const rel = c.relations !== "" ? `;关系:${c.relations}` : "";
5743
+ return `- ${c.name}:${c.traits.join("、")}${goals}${rel}`;
5744
+ }).join("\n")}`);
5745
+ if (bible.redLines.length > 0) parts.push(`红线:\n${bible.redLines.map((r) => `- ${r}`).join("\n")}`);
5746
+ if (parts.length === 0) return "本书道藏(核对设定冲突用):\n(无)";
5747
+ const block = parts.join("\n");
5748
+ const limit = 6e3;
5749
+ return `本书道藏(核对设定冲突用):\n${block.length > limit ? block.slice(0, limit) + `\n…(道藏过长已节选,但角色名/世界规则已尽量保留;如需完整设定请查总纲页)` : block}`;
5750
+ }
5751
+ /** Build the author-review system prompt (narrative structure, not prose). */
5752
+ function authorReviewSystemPrompt() {
5753
+ return [
5754
+ "你是一位网文作者复盘助手。你会收到:本章正文、上一章结尾(钩子)、上一章作者复盘(如有)、活跃剧情线与编年录近期事实。",
5755
+ "请从叙事结构层面复盘本章(不评文笔,那是审稿的事):",
5756
+ "1. hookHonored:上一章结尾的钩子/悬念是否在本章兑现或推进(true/false)。",
5757
+ "2. hookNote:钩子兑现情况一句话;未兑现时说明并给出\"建议在第几章补\"的建议。",
5758
+ "3. endingHook:本章结尾钩子强度,0-10 的整数(低于 6 说明结尾平淡,读者可能不想看下一章)。",
5759
+ "4. plotlineProgress:本章推进了哪条剧情线(主线/支线名),或\"无实质推进\"(连续无推进要提醒)。",
5760
+ "5. advancedLines:本章实际推进的剧情线名称数组——从「活跃剧情线」清单中选出推进了的线(名称必须与清单中的线名一字不差;没推进任何线则输出空数组)。",
5761
+ "6. continuity:与上一章结尾的衔接检查(人物位置/时间/伤势/资源/对话状态),发现问题要指出。",
5762
+ "7. trend:结合上一章复盘看近期节奏趋势(是否连续拖沓、爽点密度是否下降、是否需要调整)。",
5763
+ "8. stateChanges:本章发生的关键状态变化数组(人物状态/世界局面/关系/资源,每条约 20 字,最多 6 条),供整本与事实库回灌。",
5764
+ "9. newConflicts:本章新引入或明显升级的冲突数组(最多 4 条)。",
5765
+ "10. clues:本章埋下或推进的新线索/伏笔数组(最多 4 条)。",
5766
+ "11. absentRisks:本章缺席但按卷级职责应出场/该推进关系的重要角色,及其缺席带来的风险(最多 4 条)。",
5767
+ "输出必须是合法 JSON 对象,不要输出任何其他文字:",
5768
+ "{\"hookHonored\": true或false, \"hookNote\": \"一句话\", \"endingHook\": 0-10整数, \"plotlineProgress\": \"一句话\", \"advancedLines\": [\"线名\"], \"continuity\": \"一句话\", \"trend\": \"一句话\", \"stateChanges\": [\"状态变化\"], \"newConflicts\": [\"新冲突\"], \"clues\": [\"新线索\"], \"absentRisks\": [\"本章缺席但值得注意的角色及风险(卷级职责/缺席风险,最多4条)\"]}",
5769
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
5770
+ "重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
5771
+ ].join("\n");
5772
+ }
5773
+ //#endregion
5774
+ //#region src/prompts/polish.ts
5775
+ /** The de-AI-ify polish system prompt (with project writing assets injected). */
5776
+ function polishSystemPrompt(project) {
5777
+ const assetsBlock = renderAllAssets(project.assets);
5778
+ const bible = project.bible;
5779
+ const blocks = [];
5780
+ const fixed = [
5781
+ "你是一位中文网文润色师。你会收到一章正文,请做「去 AI 味」润色:",
5782
+ "1. 删除/替换 AI 高频套话与模式词:如\"不禁\"\"仿佛\"\"一时间\"\"不由得\"\"顿时\"\"然而\"\"缓缓\"\"轻轻\"\"微微\"\"默默\"\"似乎\"\"终于\"等滥用。",
5783
+ "2. 把书面翻译腔改成口语化的中文网文语感。",
5784
+ "3. 拆分过长的排比句与堆砌的修饰语。",
5785
+ "4. 保留全部情节、人物、对话内容、已确立事实不变,只改表达。",
5786
+ "5. 输出完整的新正文,不要输出任何说明文字或 Markdown 标记。",
5787
+ "6. 必须遵守下方「反 AI 规则」与「写法资产」的表达边界;写法资产要求保留的风格特征(句式、台词、节奏)不得在润色中丢失。",
5788
+ "7. 必须遵守下方「内容合规红线」与「本书红线」(如有),任何一条命中(含影射、暗示)都必须避免。",
5789
+ "内容合规红线(平台硬性要求,最高优先级):",
5790
+ COMPLIANCE_REDLINES.join("\n")
5791
+ ];
5792
+ blocks.push({
5793
+ layer: "fixed",
5794
+ text: fixed.join("\n")
5795
+ });
5796
+ const book = [];
5797
+ if (bible !== void 0 && bible.redLines.length > 0) book.push("本书红线:\n" + bible.redLines.map((r) => "- " + r).join("\n"));
5798
+ if (assetsBlock !== "") book.push(assetsBlock);
5799
+ blocks.push({
5800
+ layer: "book",
5801
+ text: book.join("\n")
5802
+ });
5803
+ return assemblePromptBlocks(blocks);
5804
+ }
5805
+ /**
5806
+ * 局部修订(只改一个自然段)的系统提示。
5807
+ *
5808
+ * 与整章修订的区别:不套用字数区间、不注入全书资料(大纲/资产/时间线),
5809
+ * 只带合规红线、本书红线、反 AI 套话口径与角色卡——局部改写要的是「表达
5810
+ * 卫生」,不是重新生成。同样按分层组装(约束口径排最前)。
5811
+ */
5812
+ function localRevisionSystemPrompt(project) {
5813
+ const bible = project.bible;
5814
+ const blocks = [];
5815
+ blocks.push({
5816
+ layer: "fixed",
5817
+ text: [
5818
+ "你是一位中文网文润色师。你会收到一章中的一个段落,请按修改要求重写该段。",
5819
+ "硬性约束:",
5820
+ "1. 只改表达,不改情节走向、人物设定、已确立事实、对话核心内容。",
5821
+ "2. 必须遵守下方「内容合规红线」,任何一条命中(含影射、暗示)都必须避免。",
5822
+ "3. 必须遵守下方「本书红线」(如有)。",
5823
+ "4. 避免 AI 套话:不禁、仿佛、一时间、不由得、顿时、然而、缓缓、轻轻、微微、默默、似乎、终于等滥用。",
5824
+ "5. 保留角色口吻与性格,角色行为需符合下方角色卡(如有)。",
5825
+ "6. 只输出修改后的【这一个段落】的完整新文本,不要输出任何说明、标题或 Markdown 标记。",
5826
+ "内容合规红线(平台硬性要求,最高优先级):",
5827
+ COMPLIANCE_REDLINES.join("\n")
5828
+ ].join("\n")
5829
+ });
5830
+ const book = [];
5831
+ if (bible !== void 0) {
5832
+ if (bible.redLines.length > 0) book.push("本书红线:\n" + bible.redLines.map((r) => "- " + r).join("\n"));
5833
+ if (bible.characters.length > 0) {
5834
+ book.push("相关角色卡:");
5835
+ for (const card of bible.characters) book.push("- " + card.name + "(" + card.role + "):" + card.traits.join("、"));
5836
+ }
5837
+ }
5838
+ blocks.push({
5839
+ layer: "book",
5840
+ text: book.join("\n")
5841
+ });
5842
+ return assemblePromptBlocks(blocks);
5843
+ }
5844
+ //#endregion
5845
+ //#region src/prompts/characters.ts
5846
+ /** AI 从全书提炼角色库:大纲 + 道藏 + 编年录 + 章节摘要 → 结构化角色清单。 */
5847
+ function extractRolesSystemPrompt() {
5848
+ return [
5849
+ "你是一位网文角色库管理员。请根据本书的大纲、设定、编年录与章节摘要,提炼完整的角色库。",
5850
+ "覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如\"矮胖姑娘\")用其身份简称收录并标注;反复出现且有剧情作用的身份型角色(站长、律师、警察、法官、店主等)必须收录。",
5851
+ "数量控制:最多输出 20 个角色;覆盖优先——主角、主要反派、重要配角(女主/关键配角)必须全收,所有有名有姓者必收;只有真正一次性路人(无名字、无剧情作用)才可省略。",
5852
+ "重要:正常一部完整故事应提炼 8-20 个角色;若少于 6 个通常说明漏提炼,必须重新核对正文摘录。",
5853
+ "重要:正文中若有明确的主角与主要反派,必须分别以 protagonist / antagonist 收录,禁止遗漏;反派确实未出场时才可省略。",
5854
+ "输出优先级:主角(protagonist)与主要反派(antagonist)必须优先输出并完整刻画,其次女主/重要配角;判断不出名字时用正文中的身份称呼。",
5855
+ "每个角色输出:",
5856
+ "1. name:角色名(或身份简称)。主角/有名配角必须用正文中实际出现的人名(如「沈放」),禁止用「主角(38岁超市理货员)」这类把身份塞进名字的占位名;正文确实没点名时才可用身份简称(如「富商」「灰衣老人」)。",
5857
+ "2. roleLabel:定位——protagonist=主角;female_lead=女主(唯一知己/感情线核心,无后宫前提下只此一位);female_support=重要女配;support=普通配角;antagonist=反派;extra=路人/背景。",
5858
+ "3. identity:身份一句话(宗门/势力/血脉/职业)。",
5859
+ "4. traits:3-6 个性格标签。",
5860
+ "5. goals:目标与动机一句话。",
5861
+ "6. relations:关系网数组,格式[\"角色名(关系)\", ...]。",
5862
+ "7. arc:成长线数组,格式[\"阶段:说明\", ...](如\"出场:祭品身份\"/\"转折:祭祀被中断脱身\")。",
5863
+ "8. knowledge:该角色已经知道的关键信息(3-8 条),不知道的信息不要写进去。",
5864
+ "精简要求:identity 控制在 30 字内;traits 3-6 个短标签;goals 60 字内;relations 2-5 条;arc 2-4 条;knowledge 每条 40 字内。整体输出量要紧凑,避免冗长。",
5865
+ "重要:用户消息里列出的「已收录角色」绝不要再次输出——这些角色已经在角色库里,跳过它们,只提炼未收录的。",
5866
+ "输出必须是合法 JSON 数组,不要输出其他文字:[{\"name\":\"...\", \"roleLabel\":\"...\", \"identity\":\"...\", \"traits\":[...], \"goals\":\"...\", \"relations\":[...], \"arc\":[...], \"knowledge\":[...]}]",
5867
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
5868
+ "重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。"
5869
+ ].join("\n");
5870
+ }
5871
+ /**
5872
+ * 角色补漏后缀(拼在 extractRolesSystemPrompt 之后)。
5873
+ *
5874
+ * 第一轮偶发漏掉「身份型/功能性角色」(站长/律师/警察等反复出现者),
5875
+ * 用这一轮专项补捞;没有遗漏就输出 []。
5876
+ */
5877
+ function roleCatchUpSuffix(names) {
5878
+ return "\n上一次已提炼角色:" + names + "。\n现在只输出「遗漏的角色」JSON 数组:检查正文摘录中反复出现、有固定身份称呼(如站长、律师、警察、法官、店主、老师)且对剧情有作用的角色;一次性路人不要输出。没有遗漏就输出 []。字段与上面相同。";
5879
+ }
5880
+ /** 角色当前状态聚合:角色名单 + 已确立事实库 → 每角色一句话状态。 */
5881
+ function roleStatusSystemPrompt() {
5882
+ return [
5883
+ "你是一位网文角色档案管理员。请根据「角色名单」与「已确立事实库」,为每个角色输出「当前状态」一句话(境界/修为/伤势/资源/心境)。",
5884
+ "输出必须是合法 JSON 数组,格式:[{\"name\": \"角色名\", \"status\": \"当前状态一句话\"}]",
5885
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
5886
+ ].join("\n");
5887
+ }
5888
+ //#endregion
5889
+ //#region src/prompts/plotlines.ts
5890
+ /** 建议剧情线:基于大纲/卷计划/已写章节/编年录,提炼候选线。 */
5891
+ function suggestPlotlinesSystemPrompt() {
5892
+ return [
5893
+ "你是一位网文剧情架构师。根据本书的大纲、卷计划、已写章节标题与编年录,为作者提炼建议的剧情线(主线/支线/人物线/悬念线)。",
5894
+ "每条线要:名称简洁有力;目标写清楚这条线最终要完成什么;progress 写当前推进到哪(没有就空字符串)。",
5895
+ "建议 4-8 条,覆盖:1 条主线、1-2 条人物线、1-2 条悬念线、1-3 条支线。避免与大纲明显重复的废话线。",
5896
+ "输出必须是合法 JSON 数组,格式:[{\"name\": \"线名\", \"kind\": \"main|branch|character|mystery\", \"goal\": \"目标\", \"progress\": \"\"}]",
5897
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
5898
+ "重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。"
5899
+ ].join("\n");
5900
+ }
5901
+ /**
5902
+ * 剧情线进度刷新(批量)。
5903
+ *
5904
+ * @param count - 本次要判断的线数。单条时问单线进度,多条时要求逐条判断、
5905
+ * name 与输入逐字相同(用于对齐回写,见 plotline-align.ts)。
5906
+ */
5907
+ function refreshPlotlinesSystemPrompt(count) {
5908
+ return [
5909
+ count === 1 ? "你是一位网文剧情线管理员。请根据「剧情线信息」与「本书已写章节摘要/编年录」,判断这条线目前推进到了哪一步。" : `你是一位网文剧情线管理员。下面给了 ${count} 条剧情线,请**逐条**判断各自推进到了哪一步。`,
5910
+ "每条线输出一句话(30-60 字):该线当前的状态、最近一次推进发生在第几章、下一步可能的方向。还没开始推进的线,明确说\"尚未推进\"。",
5911
+ count === 1 ? "输出必须是合法 JSON 对象:{\"progress\": \"一句话\"}" : `输出必须是合法 JSON 对象:{"items": [{"name": "线名(与输入完全一致)", "progress": "一句话"}]},items 的条数必须等于 ${count},name 必须与输入里的线名逐字相同(用于对齐)`,
5912
+ "重要:不要输出任何其他文字。"
5913
+ ].join("\n");
5914
+ }
5915
+ /** 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
5916
+ function plotlineHealthSystemPrompt() {
5917
+ return [
5918
+ "你是一位网文剧情架构师。请对本书的「剧情线体系」做健康检查,判断当前是否需要新增剧情线、应在多少章后添加。",
5919
+ "评估维度:各线最近推进到第几章(已写章节与关联章节的差值越大越危险)、各线状态、已写章节总数、卷计划当前进度、编年录近期事实。",
5920
+ "输出规则:",
5921
+ "1. verdict:一句话结论——\"需要新增线\" / \"暂不需要\" / \"再写 N 章后需要\"(N 给出具体章数)。",
5922
+ "2. timing:说明建议添加的时机(如:第 25 章前引入新支线,因为主线预计第 22 章告一段落)。",
5923
+ "3. reasons:3-5 条依据(引用具体数据:哪条线多少章没推进、已写章节数、卷进度等)。",
5924
+ "4. lines:对每条线给健康度——ok(近期推进过)/ warning(超过 5 章未推进)/ stale(超过 10 章未推进或悬置过久)。",
5925
+ "输出必须是合法 JSON 对象:{\"verdict\": \"...\", \"timing\": \"...\", \"reasons\": [\"...\"], \"lines\": [{\"name\": \"线名\", \"health\": \"ok|warning|stale\", \"note\": \"一句说明\"}]}",
5926
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
5927
+ "重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。"
5928
+ ].join("\n");
5929
+ }
5930
+ /** 剧情方案:基于健康检查结果设计下一阶段方向与建议新线。 */
5931
+ function plotlinePlanSystemPrompt() {
5932
+ return [
5933
+ "你是一位网文剧情架构师。请为本书设计「下一阶段的剧情方案」:给出未来 5-10 章的剧情方向,并建议 2-3 条值得新增的剧情线。",
5934
+ "要求:方向必须结合本书大纲/卷计划/现有线/编年录;新线要能落地(和当前主角处境、已有伏笔、下一阶段舞台相关),不得重复已有线。",
5935
+ "输出必须是合法 JSON 对象:{\"direction\": \"下一阶段方向 60-120 字\", \"suggestions\": [{\"name\": \"线名\", \"kind\": \"main|branch|character|mystery\", \"goal\": \"目标\", \"progress\": \"初始进度(可空)\"}]}",
5936
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
5937
+ "重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。"
5938
+ ].join("\n");
5939
+ }
5940
+ //#endregion
5941
+ //#region src/prompts/foreshadow.ts
5942
+ /** System prompt for foreshadow suggestions. */
5943
+ function foreshadowSystemPrompt() {
5944
+ return [
5945
+ "你是一位网文伏笔设计师。你会收到大纲和已写的章节信息,请为小说建议 3-8 条值得埋设的伏笔。",
5946
+ "要求:",
5947
+ "1. 伏笔必须有明确的回收价值(推动主线、人物弧光、世界观揭秘)。",
5948
+ "2. 描述要具体,指出埋设章节与预计回收章节(可空缺)。",
5949
+ "3. 优先从大纲的暗线(如记忆代价、残片收集、身世谜团)中提炼。",
5950
+ "输出必须是合法 JSON 数组:",
5951
+ "[{\"description\": \"伏笔描述\", \"plantedChapter\": 章节号或null, \"targetChapter\": 章节号或null}]",
5952
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
5953
+ ].join("\n");
5954
+ }
5955
+ //#endregion
5956
+ //#region src/prompts/outline.ts
5957
+ /** 创意灵感:一句话/题材 → 多方向开书灵感。 */
5958
+ function ideaInspirationSystemPrompt() {
5959
+ return ["你是中文网文开书灵感策划。根据用户一句话/题材方向,给出多个可开书的差异化创意。", "严禁输出具体人名/作品名;用身份/通用称谓。每个创意都要有明确钩子、题材、视角、长期兑现。"].join("\n");
5960
+ }
5961
+ /**
5962
+ * 大纲方案:作者的一句「想法」→ 2-3 个方向差异明显的完整大纲。
5963
+ *
5964
+ * @param n - 本次要输出的方案数(1-3)。
5965
+ */
5966
+ function outlineCandidatesSystemPrompt(n) {
5967
+ return [
5968
+ "你是一位资深网文策划。作者只给了一句「想法」,你需要把它扩展成 2-3 个【方向差异明显】的完整小说大纲方案,供作者挑选。",
5969
+ "每个方案必须满足:",
5970
+ "1. bookName:书名(6 字以内,抓眼球、点题)。",
5971
+ "2. genre:题材(如 仙侠修真 / 都市异能 / 玄幻 / 悬疑)。",
5972
+ "3. sellingPoint:核心卖点一句话(金手指/爽点/差异化,40 字内)。",
5973
+ "4. outline:完整大纲文本(至少 800 字,可直接作为开书大纲),结构包含:书名与题材、金手指/核心设定、主角人设与动机、主线剧情走向(至少 5 个阶段)、关键配角与势力、卖点与爽点设计、预计分卷(3-5 卷)。",
5974
+ "方向差异要求:",
5975
+ "- 方案之间的金手指/剧情走向必须明显不同(如:苟道发育流 vs 随身老爷爷流 vs 群像争霸流),不能只是换书名。",
5976
+ "- 忠实于作者想法的核心要素,但允许在不同方向上进行合理演绎。",
5977
+ "- 不输出任何与已列「需避开的方向」雷同的方案。",
5978
+ "输出必须是合法 JSON 数组,只输出数组本身:",
5979
+ "[{\"id\": \"唯一id\", \"bookName\": \"...\", \"genre\": \"...\", \"sellingPoint\": \"...\", \"outline\": \"...\"}]",
5980
+ `本次只输出 ${n} 个方案。`,
5981
+ "重要:所有字符串值内部不得包含换行符(大纲内部分段请用「。\n」或「;」自然断句),JSON 必须在一段内完整结束。",
5982
+ "重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。"
5983
+ ].join("\n");
5984
+ }
5985
+ /** 反推大纲 · 阶段一:分批为每章输出一行「事件摘要」。 */
5986
+ function reverseOutlineBatchSystemPrompt() {
5987
+ return "你是一位网文编辑。下面是一本书若干章正文的节选。请为每一章输出一行「事件摘要」,格式严格为:第N章《标题》:关键事件+主角状态变化+新增伏笔或线索。每章恰好一行,不要空行,不要评价,不要输出其他内容。";
5988
+ }
5989
+ /** 反推大纲 · 阶段二:各章事件摘要 → 全书总纲(Markdown)。 */
5990
+ function reverseOutlineSystemPrompt() {
5991
+ return [
5992
+ "你是一位经验丰富的小说主编。根据下面全书各章事件摘要,反推出这本书的总纲(大纲),可直接作为后续写作依据。要求:",
5993
+ "1. 第一行写《书名》(从摘要中的书名或内容推断,若无则用《未命名》)。",
5994
+ "2. 按故事弧线划分卷/部分:每卷给出卷名与主旨(标注覆盖章节范围)。",
5995
+ "3. 每一章列出:章节号 + 标题 + 一句话核心情节(若原章无标题可自拟)。",
5996
+ "4. 最后给出:全书主线、主要人物弧线、已埋设待回收的伏笔清单。",
5997
+ "5. 输出为纯文本 Markdown 结构(# 一级标题、## 二级标题、- 列表),不要多余寒暄。"
5998
+ ].join("\n");
5999
+ }
6000
+ /** 反推大纲 · 改编线:从拆章节选反推总纲,作为改编与续写的骨架。 */
6001
+ function reverseOutlineFromAdaptationSystemPrompt() {
6002
+ return [
6003
+ "你是一位经验丰富的小说主编。根据下面若干章节的正文节选,反推出这本书的总纲(可作为后续改编与章节续写的骨架)。",
6004
+ "要求:",
6005
+ "1. 第一行写《书名》(可从正文推断,否则《未命名》)。",
6006
+ "2. 按故事弧线划分卷/部分:每卷给卷名与主旨(标注覆盖章节范围)。",
6007
+ "3. 对每章列出:章节号 + 标题 + 一句话核心情节。",
6008
+ "4. 最后给出:全书主线、主要人物弧线、已埋设待回收的伏笔清单。",
6009
+ "5. 输出纯文本 Markdown 结构(# 一级标题、## 二级标题、- 列表),不要寒暄,不要其他输出。"
6010
+ ].join("\n");
6011
+ }
6012
+ //#endregion
6013
+ //#region src/prompts/digest.ts
6014
+ /** 拆书阶段一:单章正文 → 结构化笔记(供后续章节级分析复用)。 */
6015
+ function breakdownNoteSystemPrompt() {
6016
+ return [
6017
+ "你是中文网文拆书助手。把单章正文整理成结构化笔记,供后续章节级分析复用。",
6018
+ "只输出 JSON 对象:",
6019
+ "{\"summary\": \"1-2句\", \"plotPoints\": [\"...\"], \"characters\": [\"...\"], \"worldbuilding\": [\"...\"], \"styleTechniques\": [\"...\"], \"marketHighlights\": [\"...\"], \"weaknessSignals\": [\"...\"]}",
6020
+ "硬规则:只提取正文明确出现的信息;每数组最多 4 项;不要补写原文外的动机/意图;evidence 不在此阶段输出。",
6021
+ "重要:直接输出 JSON,不要输出其他文字;字符串内不含换行。"
6022
+ ].join("\n");
6023
+ }
6024
+ /**
6025
+ * 拆书阶段二:分节分析配置。
6026
+ *
6027
+ * @param preset - quick(总览/剧情/人物/文风)| standard(+ 商业化卖点)。
6028
+ */
6029
+ function breakdownSections(preset) {
6030
+ const sections = [
6031
+ {
6032
+ key: "overview",
6033
+ title: "拆书总览",
6034
+ focus: "一句话定位、题材标签、整体优势与短板",
6035
+ system: [
6036
+ "你是资深中文网文拆书分析师,负责《拆书总览》小节。",
6037
+ "基于给定章节笔记做低风险综合判断,输出 JSON:{\"markdown\": \"可直接展示的分析稿(简体中文,先给结论再说明体现在哪、为何成立)\", \"structured\": {\"oneLinePositioning\": \"一句话定位\", \"genreTags\": [\"题材标签\"], \"sellingPointTags\": [\"卖点标签\"], \"strengths\": [\"整体优势\"], \"weaknesses\": [\"整体短板\"]}}",
6038
+ "硬规则:只基于笔记归纳;推断用「更偏向/可能」等谨慎措辞;证据不足写「材料不足」;不虚构原文细节。",
6039
+ "重要:直接输出 JSON,字符串内不含换行。"
6040
+ ].join("\n")
6041
+ },
6042
+ {
6043
+ key: "plot",
6044
+ title: "剧情结构",
6045
+ focus: "主线梗概、阶段推进、冲突升级、节奏风险",
6046
+ system: [
6047
+ "你是资深中文网文拆书分析师,负责《剧情结构》小节。",
6048
+ "基于给定章节笔记分析,输出 JSON:{\"markdown\": \"分析稿(简体中文,先结论后依据)\", \"structured\": {\"mainlineSummary\": \"主线梗概\", \"phaseProgressions\": [\"阶段推进\"], \"escalationDesigns\": [\"冲突升级\"], \"paceRisks\": [\"节奏风险\"], \"reusablePatterns\": [\"可复用套路\"]}}",
6049
+ "硬规则:只基于笔记归纳;推断谨慎措辞;不虚构。",
6050
+ "重要:直接输出 JSON,字符串内不含换行。"
6051
+ ].join("\n")
6052
+ },
6053
+ {
6054
+ key: "character",
6055
+ title: "人物系统",
6056
+ focus: "主角定位、配角功能、关系网络、成长弧线、辨识度风险",
6057
+ system: [
6058
+ "你是资深中文网文拆书分析师,负责《人物系统》小节。",
6059
+ "基于给定章节笔记分析,输出 JSON:{\"markdown\": \"分析稿(简体中文,先结论后依据)\", \"structured\": {\"protagonistPositioning\": \"主角定位\", \"supportingFunctions\": [\"配角功能\"], \"relationshipNetwork\": [\"关系网络\"], \"growthArcs\": [\"成长弧线\"], \"clarityRisks\": [\"辨识度风险\"]}}",
6060
+ "硬规则:只基于笔记归纳;推断谨慎措辞;不虚构。",
6061
+ "重要:直接输出 JSON,字符串内不含换行。"
6062
+ ].join("\n")
6063
+ },
6064
+ {
6065
+ key: "style",
6066
+ title: "文风与技法",
6067
+ focus: "叙事视角、语言风格、描写方式、节奏控制、钩子设计、可复用写法",
6068
+ system: [
6069
+ "你是资深中文网文拆书分析师,负责《文风与技法》小节。",
6070
+ "基于给定章节笔记分析,输出 JSON:{\"markdown\": \"分析稿(简体中文,先结论后依据)\", \"structured\": {\"narrativePov\": \"叙事视角\", \"languageStyle\": \"语言风格\", \"dialoguePatterns\": [\"对话特征\"], \"rhythmControl\": [\"节奏控制\"], \"hookDesigns\": [\"钩子设计\"], \"reusableTechniques\": [\"可复用写法\"]}}",
6071
+ "硬规则:只基于笔记归纳;推断谨慎措辞;不虚构。",
6072
+ "重要:直接输出 JSON,字符串内不含换行。"
6073
+ ].join("\n")
6074
+ }
6075
+ ];
6076
+ if (preset === "standard") sections.push({
6077
+ key: "market",
6078
+ title: "商业化卖点",
6079
+ focus: "读者爽点、点击驱动、人物/题材卖点、商业化风险",
6080
+ system: [
6081
+ "你是资深中文网文拆书分析师,负责《商业化卖点》小节。",
6082
+ "基于给定章节笔记分析,输出 JSON:{\"markdown\": \"分析稿(简体中文,先结论后依据)\", \"structured\": {\"hookPoints\": [\"读者爽点\"], \"clickDrivers\": [\"点击驱动\"], \"characterSellingPoints\": [\"人物卖点\"], \"genreSellingPoints\": [\"题材卖点\"], \"commercialRisks\": [\"商业化风险\"]}}",
6083
+ "硬规则:只基于笔记归纳;推断谨慎措辞;不虚构。",
6084
+ "重要:直接输出 JSON,字符串内不含换行。"
6085
+ ].join("\n")
6086
+ });
6087
+ return sections;
6088
+ }
6089
+ /** 单章摘要(叙事记忆):供后续章节写作时保持连贯性。 */
6090
+ function chapterSummarySystemPrompt() {
6091
+ return [
6092
+ "你是一位网文编辑。请为下面一章写一段 120-200 字的摘要,供后续章节写作时保持连贯性。",
6093
+ "摘要必须包含:本章发生的关键事件、主角状态变化(境界/资源/伤势/心境)、新增的伏笔或线索、角色关系变化。",
6094
+ "用客观陈述句,不要评价,不要剧透式感叹。只输出摘要正文。"
6095
+ ].join("\n");
6096
+ }
6097
+ /** 书分析/拆书:输入一本书/章节文本 → 卖点/结构/可借鉴点/风险。 */
6098
+ function bookAnalysisSystemPrompt() {
6099
+ return ["你是资深中文网文拆书编辑。分析输入的一本书或章节文本,提炼可复用的创作思路。", "严禁照搬具体作品的人名/专有设定/简介句子;只能提炼读者需求、结构手法和风险。"].join("\n");
6100
+ }
6101
+ //#endregion
6102
+ //#region src/prompts/facts.ts
6103
+ /** 摘要 + 事实(两件事一次出)。 */
6104
+ function summaryFactsSystemPrompt() {
6105
+ return [
6106
+ "你是一位网文编辑。请为下面一章做两件事,输出合法 JSON 对象:",
6107
+ "{\"summary\": \"120-200字摘要,含关键事件/主角状态变化(境界资源伤势心境)/新增伏笔线索/角色关系变化,客观陈述不评价\", \"facts\": [\"已确立事实1\", \"…3-6条\"]}",
6108
+ "facts 指:本章明确写出的、对后续有约束力的事实——人物当前状态、重要关系变化、地点与时间线、已落地或新增的伏笔线索、关键道具去向。",
6109
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
6110
+ ].join("\n");
6111
+ }
6112
+ /**
6113
+ * 摘要 + 事实 + 时间线(三件事一次出)。
6114
+ *
6115
+ * @param wantTimeline - 关闭时间线时连 timeline 字段都不问,省掉输出 token
6116
+ * 与模型的无用工作量。
6117
+ */
6118
+ function summaryFactsTimelineSystemPrompt(wantTimeline) {
6119
+ const NL = String.fromCharCode(10);
6120
+ return [
6121
+ "你是一位网文编辑。请为下面一章做三件事,输出**一个**合法 JSON 对象:",
6122
+ "{\"summary\": \"120-200字摘要,含关键事件/主角状态变化(境界资源伤势心境)/新增伏笔线索/角色关系变化,客观陈述不评价\",",
6123
+ " \"facts\": [\"已确立事实1\", \"…3-6条\"],",
6124
+ ...wantTimeline ? [" \"timeline\": [{\"time\":\"第三日黄昏\",\"order\":1,\"place\":\"青云宗外门\",\"characters\":[\"沈青\"],\"event\":\"不超过40字\"}]"] : [],
6125
+ "",
6126
+ "facts 指:本章明确写出的、对后续有约束力的事实——人物当前状态、重要关系变化、地点与时间线、已落地或新增的伏笔线索、关键道具去向。",
6127
+ ...wantTimeline ? [
6128
+ "timeline 指:只抽取正文里真实发生的场景节点(3-8 条),不要抽预告、回忆性泛泛陈述;纯粹的回忆/插叙请在 event 里注明「(回忆)」并给出其故事内时间。",
6129
+ "time 用正文里的说法(如「第三日黄昏」「入宗三个月后」「同日夜」);正文没写时间就填相对说法(如「紧接上一场景」)。",
6130
+ "order 是本章内的先后序号,从 1 递增;place 是一处一个词;characters 只列在场的角色名(用正文里的名字,不要写「主角」)。"
6131
+ ] : [],
6132
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束,不要输出 Markdown 代码块标记。"
6133
+ ].join(NL);
6134
+ }
6135
+ /** 事实抽取:本章正文 → 已确立事实(每行一条,3-6 条)。 */
6136
+ function extractFactsSystemPrompt() {
6137
+ return [
6138
+ "你是一位网文编辑。请从本章正文中抽取「已确立事实」,供后续章节保持一致。",
6139
+ "事实指:人物当前状态(境界/修为/伤势/资源/心境)、重要关系变化、地点与时间线、已落地或新增的伏笔线索、关键道具去向。",
6140
+ "要求:",
6141
+ "1. 只抽取本章明确写出的、对后续有约束力的内容;纯心理活动与无关细节不要。",
6142
+ "2. 每行一条事实,用客观陈述句,不含主观评价。",
6143
+ "3. 输出 3-6 条,每行一条,不要编号、不要前缀、不要解释。"
6144
+ ].join("\n");
6145
+ }
6146
+ /** 时间线抽取:一章正文 → 故事时间线事件(时间/地点/在场角色/事件)。 */
6147
+ function timelineExtractSystemPrompt() {
6148
+ const NL = String.fromCharCode(10);
6149
+ return [
6150
+ "你是小说连续性编辑。任务:把一章正文压缩成「故事时间线」事件,供后续章节保持时间与地点的连续性。",
6151
+ "要求:",
6152
+ "1. 只抽取**正文里真实发生**的场景节点(3-8 条),不要抽取预告、回忆性的泛泛陈述;纯粹的回忆/插叙请在 event 里注明「(回忆)」并给出其故事内时间。",
6153
+ "2. time 用正文里的说法(如「第三日黄昏」「入宗三个月后」「同日夜」);正文没写时间就填相对说法(如「紧接上一场景」)。",
6154
+ "3. order 是本章内的先后序号,从 1 递增;跨章我会用章号排序,你只需要保证章内顺序正确。",
6155
+ "4. place 是场景地点(一处一个词,如「青云宗外门」);characters 只列**在场**的角色名(用正文里的名字,不要写「主角」)。",
6156
+ "5. event 一句话说清发生了什么(不超过 40 字)。",
6157
+ "输出必须是合法 JSON 数组,不要输出任何其他文字或 Markdown 代码块标记:",
6158
+ "[{\"time\":\"第三日黄昏\",\"order\":1,\"place\":\"青云宗外门\",\"characters\":[\"沈青\"],\"event\":\"沈青被外门弟子当众羞辱\"}]",
6159
+ "重要:字符串值内部不得包含换行符;JSON 必须在一段内完整结束。"
6160
+ ].join("@NL@").replace("@NL@", NL);
6161
+ }
6162
+ /** 时间线复核:规则初筛之外,让模型找时序矛盾(时间倒流/瞬移/跨度不合理/季节错位)。 */
6163
+ function timelineReviewSystemPrompt() {
6164
+ const NL = String.fromCharCode(10);
6165
+ return [
6166
+ "你是小说连续性编辑,只做一件事:找**时间线**上的矛盾。",
6167
+ "检查项:1) 时间倒流(后一章早于前一章且没有明确插叙标注);2) 地点瞬移(同一人物无移动交代就换场景);",
6168
+ "3) 时间跨度与事件量明显不匹配(如「一日之内」发生跨越数月的剧情);4) 季节/年龄/节日前后矛盾;5) 回忆与现实混淆。",
6169
+ "只报**能在给定事件表里指认**的问题,不要臆测;没有问题就返回空数组。",
6170
+ "输出必须是合法 JSON 数组,不要任何其他文字:",
6171
+ "[{\"severity\":\"high|medium|low\",\"chapters\":[3,7],\"item\":\"问题一句话\",\"suggestion\":\"怎么修\"}]",
6172
+ "重要:字符串值内部不得包含换行符。"
6173
+ ].join(NL);
6174
+ }
6175
+ //#endregion
6176
+ //#region src/prompts/audit.ts
6177
+ /** 单批质检:设定 + 事实库 + 该批章节节选 → 矛盾清单。 */
6178
+ function auditBatchSystemPrompt() {
6179
+ return [
6180
+ "你是一位严谨的网文连续性审校编辑。你会收到一本小说的道藏、事实库和一批章节正文节选。",
6181
+ "请找出这批章节中的一致性矛盾,例如:",
6182
+ "- 人物状态冲突:境界/修为/伤势/资源在同一章内或跨章前后矛盾。",
6183
+ "- 设定违背:正文与世界观规则、金手指规则、写作红线冲突。",
6184
+ "- 时间线错乱:事件顺序、时间跨度、地点移动不合逻辑。",
6185
+ "- 细节穿帮:人名/地名/物品/数字前后不一致。",
6186
+ "要求:",
6187
+ "1. 只报告有实质证据的矛盾,不要泛泛而谈写作质量问题。",
6188
+ "2. 每条必须定位到具体章节号。",
6189
+ "3. 输出必须是合法 JSON 数组,格式:[{\"chapterNo\": 章节号, \"severity\": \"high|medium|low\", \"item\": \"矛盾描述\", \"suggestion\": \"修改建议\"}]",
6190
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
6191
+ ].join("\n");
6192
+ }
6193
+ /** 影响分析:一处改动 → 全书波及位置清单(设定/大纲/正文/事实库/简介)。 */
6194
+ function impactAnalysisSystemPrompt() {
6195
+ return [
6196
+ "你是一位网文一致性审校。作者要做一处修改,请找出这次改动会波及的所有位置(设定、大纲、已写章节正文、事实库、简介中可能因此过时或矛盾的内容)。",
6197
+ "输出必须是合法 JSON 数组,格式:[{\"location\": \"位置(第N章/大纲/道藏-世界规则/大世界-境界/事实库/简介)\", \"quote\": \"原文片段(20-60字)\", \"suggestion\": \"修改建议\", \"kind\": \"must|optional|note\"}]",
6198
+ "kind 含义:must=必须同步改否则矛盾;optional=建议改(影响观感);note=备注(如旧称保留为古称、或无需改但需知晓)。",
6199
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
6200
+ ].join("\n");
6201
+ }
6202
+ //#endregion
6203
+ //#region src/prompts/adapt.ts
6204
+ /** 改编 P0 分析:system 侧只声明角色(指令主体在 user 侧,位置效应更不易被长上下文稀释)。 */
6205
+ function adaptationAnalyzeSystemPrompt() {
6206
+ return "你是改编策划分析助手。";
6207
+ }
6208
+ /** 改编 P0 分析:user 侧指令(原文设定卡片 + 可改范围矩阵)。 */
6209
+ function adaptationAnalyzeUser(sampleBodies) {
6210
+ return [
6211
+ "你是一位资深网文编辑兼改编策划。下面给你一部已完结/连载小说的若干章正文节选。请通读并输出该书的「原文设定卡片」与「可改范围矩阵」。",
6212
+ "输出合法 JSON 对象:",
6213
+ "{\"bookName\": \"书名\", \"outline\": \"一句话主线梗概(100字内)\", \"dimensions\": [{\"key\":\"realm\",\"title\":\"大世界\",\"mutability\":\"big|small|free|locked|visual\",\"current\":\"当前值\",\"evidence\":\"证据(出现章节/频次)\",\"candidates\":[{\"name\":\"候选体系名\",\"desc\":\"该体系一句话说明\"}],\"impact\":\"改了会影响什么\",\"risk\":\"high|medium|low\"}]}",
6214
+ "dimensions 至少覆盖以下维度(key/title):realm 大世界、cultivation 修为体系、protagonist 主角、goldenFinger 金手指、supporting 配角与势力人物名、faction 势力/组织、style 文风与叙事、ending 结局走向、timeline 时间线/编年、foreshadow 伏笔/暗线。",
6215
+ "mutability 取值:locked=建议保留、big=可改影响大、small=可改影响小、free=可自由改、visual=仅视觉包装。",
6216
+ "每个 dimension.current 必须忠于文本,能引用原文就用原文(尤其角色名/境界名/势力名/金手指名)。",
6217
+ "重要:所有字符串值内部不得包含换行符;JSON 必须在一段内完整结束;直接输出 JSON,不要 Markdown 代码块。"
6218
+ ].join("\n") + "\n\n" + sampleBodies.join("\n\n---\n\n");
6219
+ }
6220
+ /** 改编方案:system 侧角色声明(指令主体在 user 侧)。 */
6221
+ function adaptationProposeSystemPrompt() {
6222
+ return "你是改编策划。";
6223
+ }
6224
+ /** 改编方案:user 侧指令 + 用户勾选的改动与已分析的可改矩阵。 */
6225
+ function adaptationProposeUser(selLines, dimLines) {
6226
+ return [
6227
+ "下面给出改编决策:用户想改哪些维度、改成什么值。请生成一份可执行的「改编方案」。",
6228
+ "要求输出合法 JSON 对象:",
6229
+ "{\"mappings\": [{\"source\":\"原值\",\"target\":\"新值\",\"scope\":\"name|realm|faction|term|other\",\"note\":\"说明\"}], \"rules\": {\"preserve\":[\"必须保留的要素\"],\"change\":[\"允许改变的要素\"],\"constraints\":[\"改编红线/一致性要求\"]}, \"impacts\": [{\"item\":\"受影响项\",\"detail\":\"说明\",\"risk\":\"high|medium|low\",\"chapters\":[章号]}]}",
6230
+ "mappings 需把用户确认的新值展开为「原→新」条目(如主角名/境界名/势力名/术语),并补充用户未填但关联的必改项(如改了修为体系名,相关的境界名一并列映射)。",
6231
+ "rules.preserve 至少包含:故事骨架、人物动机、伏笔逻辑、爽点结构。",
6232
+ "impacts 列出每个改动会影响的内容(术语/角色/章节/伏笔),能定位章号尽量定位;无法定位则给章节区间提示。",
6233
+ "重要:所有字符串值内部不得包含换行符;JSON 必须在一段内完整结束;直接输出 JSON,不要 Markdown 代码块。",
6234
+ "",
6235
+ "用户要改:",
6236
+ selLines,
6237
+ "",
6238
+ "原文可改矩阵(已分析):",
6239
+ dimLines
6240
+ ].join("\n");
6241
+ }
6242
+ /** 改编重写:按映射表与规则把一章正文结构性重写(不只是换词)。 */
6243
+ function adaptationRewriteSystemPrompt() {
6244
+ return [
6245
+ "你是一位资深网文改编编剧。你会收到某一章的正文,以及本书的改编映射表与改编规则。",
6246
+ "任务:把这一章按改编方案**重写**成新版本(结构性改编,不只是换词)。",
6247
+ "要求:",
6248
+ "1. 严格遵守映射表(原值→新值),正文中所有源值都要替换为新值;涉及改名/改体系/改势力时,相关表述一起调整,使上下文自洽。",
6249
+ "2. 改编规则:必须保留的内容不得破坏(故事骨架/人物动机/伏笔逻辑/爽点结构);允许改变的内容可以放开调整;红线/一致性要求必须遵守。",
6250
+ "3. 若某个改动牵动叙事(如结局走向/时间线/世界观),要把这章的叙述顺势改得通顺、可信。",
6251
+ "4. 输出**只包含这一章重写后的正文**,不要重复标题,不要任何解释、开头或结尾。",
6252
+ "5. 字数与原章基本相当(允许 ±20%)。"
6253
+ ].join("\n");
6254
+ }
6255
+ //#endregion
6256
+ //#region src/prompts/market.ts
6257
+ /** 市场雷达系统提示(分析师角色与输出纪律)。 */
6258
+ function marketRadarSystemPrompt() {
6259
+ return [
6260
+ "你是中文网络文学市场分析师。只分析输入中的公开榜单元数据或用户给出的市场线索,不补写作品正文,不假装知道未提供的信息。",
6261
+ "只根据输入归纳热门题材和市场信号;对未提供的信息不要臆测。",
6262
+ "重点分析:热门题材组合、主角身份、金手指机制、开篇危机、关系卖点、标题句式、拥挤套路和差异化机会。",
6263
+ "kind 只能使用 genre、protagonist、advantage、opening、relationship、title_pattern、opportunity、crowding,不得创造近义枚举值。",
6264
+ "榜单高频不等于适合照搬。机会建议必须说明读者满足点,同时避开直接复制具体作品。",
6265
+ "productionFoundation 的题材/推进模式优先引用「资源库」中已有的 existingId(名称一字不差);只有确无合适资产时才给出新资产(existingId 为 null)。",
6266
+ "creativeBrief 严禁复用榜单作品的人名、专有设定、简介句子和完整书名;只能提炼读者需求、爽点机制和结构机会,不得输出任何具体人名/作品名,一律使用身份或通用场景称谓。"
6267
+ ].join("\n");
6268
+ }
6269
+ /** 雷达→灵感:基于市场信号/生产底座/创意简报,生成贴合市场的开书灵感。 */
6270
+ function marketIdeaInspirationSystemPrompt() {
6271
+ return ["你是中文网文开书灵感策划,擅长从市场榜单分析中提炼可开书方向。", "严禁输出具体人名/作品名;用身份/通用称谓。每个创意都要有明确钩子、题材、视角、长期兑现,且要与市场分析呼应但做差异化。"].join("\n");
6272
+ }
6273
+ /** 作品简介:面向读者的门面(卖点 + 开局钩子,不剧透)。 */
6274
+ function blurbSystemPrompt() {
6275
+ return [
6276
+ "你是一位网文平台编辑,擅长写抓人的作品简介。",
6277
+ "要求:",
6278
+ "1. 120-250 字,突出核心卖点(金手指/题材/爽点/人设反差),用一两句抛出开局钩子。",
6279
+ "2. 不剧透结局与关键反转;语气贴合题材(热血/悬疑/轻松/虐心)。",
6280
+ "3. 中文,直接输出简介正文,不要 Markdown、不要引号包裹、不要「简介:」前缀。"
6281
+ ].join("\n");
5307
6282
  }
5308
6283
  //#endregion
5309
6284
  //#region src/protocol.ts
@@ -5368,6 +6343,8 @@ const NOVEL_API = {
5368
6343
  chapterCheck: "/api/dsh-novel-forge/chapter/check",
5369
6344
  /** 保存手动编辑的正文(自动备份 .bak)。 */
5370
6345
  chapterSave: "/api/dsh-novel-forge/chapter/save",
6346
+ /** 补写本章计划的固定六项(目标 / 必达 / 保持 / 硬事实 / 结尾钩子 / 剧情要点)。 */
6347
+ chapterPlan: "/api/dsh-novel-forge/chapter/plan",
5371
6348
  assistant: "/api/dsh-novel-forge/assistant",
5372
6349
  assistantHistory: "/api/dsh-novel-forge/assistant-history",
5373
6350
  /** 清空助手对话记录。 */
@@ -5548,23 +6525,10 @@ const EXPORT_SCOPE_VALUES = [
5548
6525
  * auto-review + rewrite, polish (de-AI-ify), narrative summaries, foreshadow
5549
6526
  * tracking, project persistence, and whole-book export. Pure Node (no
5550
6527
  * web-server dependencies), so routes stay thin and logic is testable.
6528
+ *
6529
+ * 系统提示词全部住在 src/prompts/(按域拆分 + catalog 注册表),本文件只做
6530
+ * 组装与调用——提示词文案的修改去对应域文件,不要在这里新起一段。
5551
6531
  */
5552
- /**
5553
- * 内容合规红线(平台硬性要求):所有书籍、所有章节无条件生效,
5554
- * 优先级高于单书大纲/道藏中的任何设定与作者自定义红线。
5555
- * 注入点:章节生成系统提示 + 审稿系统提示(命中即 high)。
5556
- */
5557
- const COMPLIANCE_REDLINES = [
5558
- "1. 不得出现反对宪法所确定的基本原则的内容。",
5559
- "2. 不得出现危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的内容。",
5560
- "3. 不得出现危害国家荣誉和利益的内容。",
5561
- "4. 不得出现煽动民族仇恨、民族歧视、破坏民族团结的内容。",
5562
- "5. 不得出现破坏国家宗教政策、宣扬邪教和愚昧迷信的内容(不得以真实宗教、邪教或迷信活动为背景进行宣扬)。",
5563
- "6. 不得出现散布谣言、扰乱社会秩序、破坏社会稳定的内容。",
5564
- "7. 不得出现淫秽色情、赌博、暴力、凶杀、恐怖或教唆犯罪的内容(网文语境:禁止露骨性描写、血腥暴力渲染、赌博教唆、犯罪手法详细教学)。",
5565
- "8. 不得出现侮辱或者诽谤他人、侵害他人合法权益的内容(不得以真实人物、组织为原型进行侮辱或影射攻击)。",
5566
- "9. 不得出现法律法规禁止的其他内容。"
5567
- ];
5568
6532
  /** 审稿维度取值:归一化模型输出的 dimension 字段。维度清单以 reviewSystemPrompt 的九条为准。 */
5569
6533
  const REVIEW_DIMENSION_IDS = /* @__PURE__ */ new Set([
5570
6534
  "character",
@@ -5647,26 +6611,44 @@ function saveProject(outputDir, project) {
5647
6611
  writeFileSync(tmp, data, "utf8");
5648
6612
  renameSync(tmp, target);
5649
6613
  }
6614
+ /** `mergeVolatileFromDisk` 会覆盖的易变字段的全集(也是 `keep` 的合法取值域)。 */
6615
+ const VOLATILE_FIELDS = [
6616
+ "bible",
6617
+ "roles",
6618
+ "plotlines",
6619
+ "roleStatus",
6620
+ "blurb",
6621
+ "coverPath",
6622
+ "facts",
6623
+ "assets",
6624
+ "world",
6625
+ "volumes"
6626
+ ];
5650
6627
  /**
5651
6628
  * 并发保护:长任务(章节计划生成/正文生成)在内存中持有旧快照,
5652
6629
  * 期间其他请求可能修改了「易变字段」(道藏/角色库/剧情线/人物志存档/简介/封面)。
5653
6630
  * 保存前用磁盘最新版本合并这些字段,避免旧快照覆盖新修改(曾导致角色卡丢失)。
5654
- * 注意:调用方若自己修改了这些字段,不要使用本函数。
6631
+ *
6632
+ * ⚠️ `keep` 不是可选的礼貌参数,而是本函数唯一的正确用法保证:
6633
+ * 调用方只要自己写过这些字段中的任何一个(哪怕只是 `project.plotlines[0].chapters.push(no)`),
6634
+ * **必须**把它列进 `keep`,否则本函数会用磁盘旧值把本轮成果整块覆盖掉。
6635
+ * 历史上正是漏了 `facts` / `plotlines`,导致作者复盘回灌的事实与剧情线关联被静默丢弃。
6636
+ *
6637
+ * @param outputDir - 项目目录。
6638
+ * @param project - 待保存的内存项目(原地修改)。
6639
+ * @param keep - 本次调用方已自行修改过的易变字段,这些字段不从磁盘覆盖。
5655
6640
  */
5656
- function mergeVolatileFromDisk(outputDir, project) {
6641
+ function mergeVolatileFromDisk(outputDir, project, keep = []) {
5657
6642
  try {
5658
6643
  const disk = loadProject(outputDir);
5659
6644
  if (disk === void 0) return;
5660
- project.bible = disk.bible;
5661
- project.roles = disk.roles;
5662
- project.plotlines = disk.plotlines;
5663
- project.roleStatus = disk.roleStatus;
5664
- project.blurb = disk.blurb;
5665
- project.coverPath = disk.coverPath;
5666
- project.facts = disk.facts;
5667
- project.assets = disk.assets;
5668
- project.world = disk.world;
5669
- project.volumes = disk.volumes;
6645
+ const skip = new Set(keep);
6646
+ const target = project;
6647
+ const source = disk;
6648
+ for (const field of VOLATILE_FIELDS) {
6649
+ if (skip.has(field)) continue;
6650
+ target[field] = source[field];
6651
+ }
5670
6652
  } catch {}
5671
6653
  }
5672
6654
  /**
@@ -6051,6 +7033,22 @@ function readChapterFile(outputDir, chapter) {
6051
7033
  if (!existsSync(path)) return void 0;
6052
7034
  return readFileSync(path, "utf8");
6053
7035
  }
7036
+ /**
7037
+ * 章节正文的**写盘时间**(文件 mtime,ISO)—— 面板「今日产出 / 日环比」的唯一本地事实来源。
7038
+ *
7039
+ * 为什么不把它写进 novel-project.json:它是**派生数据**。落盘后一旦文件被外部替换、
7040
+ * 从备份回拷、或手工改名,存档里的时间戳就开始撒谎;mtime 永远跟着真实文件走。
7041
+ * 代价是每次 /status 要对每个有正文的章 stat 一次 —— 本地磁盘上是微秒级,
7042
+ * 相对它换来的"今天的产出是真的",这个代价可以接受。
7043
+ */
7044
+ function chapterWrittenAt(outputDir, chapter) {
7045
+ if (chapter.file === void 0) return void 0;
7046
+ try {
7047
+ return statSync(join(outputDir, chapter.file)).mtime.toISOString();
7048
+ } catch {
7049
+ return;
7050
+ }
7051
+ }
6054
7052
  /** Create a fresh project from an outline. */
6055
7053
  function createProject(outline, outlinePath) {
6056
7054
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -6105,15 +7103,26 @@ async function complete(ctx, config, options) {
6105
7103
  ...converged.reasoningEffort === void 0 ? {} : { reasoningEffort: ReasoningEffortId(converged.reasoningEffort) }
6106
7104
  };
6107
7105
  const assembler = new BlockAssembler();
6108
- for await (const chunk of ctx.llm.stream(request)) {
6109
- if (chunk.type === "text-delta") markFirstToken(live);
6110
- assembler.push(chunk);
7106
+ try {
7107
+ for await (const chunk of ctx.llm.stream(options.signal === void 0 ? request : {
7108
+ ...request,
7109
+ signal: options.signal
7110
+ })) {
7111
+ if (chunk.type === "text-delta") markFirstToken(live);
7112
+ assembler.push(chunk);
7113
+ }
7114
+ } catch (error) {
7115
+ endLiveCall(live, assembler, {
7116
+ phase: isCancellation(error) ? "cancelled" : "failed",
7117
+ error: error.message
7118
+ });
7119
+ throw error;
6111
7120
  }
6112
7121
  const finish = assembler.finish;
6113
7122
  if (finish.kind === "error" || finish.kind === "aborted") {
6114
- const message = `LLM 调用失败(${finish.kind}): ${finish.failure.message}`;
7123
+ const message = `${isCancellation(finish.kind) ? "调用已取消" : "LLM 调用失败"}(${finish.kind}): ${finish.failure.message}`;
6115
7124
  endLiveCall(live, assembler, {
6116
- phase: "failed",
7125
+ phase: isCancellation(finish.kind) ? "cancelled" : "failed",
6117
7126
  error: message
6118
7127
  });
6119
7128
  throw new Error(message);
@@ -6290,24 +7299,6 @@ function parseJsonObject(text) {
6290
7299
  if (typeof value !== "object" || value === null) throw new Error("模型输出不是 JSON 对象");
6291
7300
  return value;
6292
7301
  }
6293
- /** System prompt for story-bible extraction. */
6294
- function bibleSystemPrompt() {
6295
- return [
6296
- "你是一位资深网文编辑兼设定架构师。你会收到一份小说大纲,请把它提炼成结构化的「道藏」,供后续写作时严格引用。",
6297
- "要求:",
6298
- "1. 忠于大纲,不自行发明大纲之外的设定。",
6299
- "2. 角色卡覆盖大纲明确出现的角色(主角必含),每个角色给出性格标签、目标、关键关系。",
6300
- "3. 世界规则覆盖力量体系、金手指机制、势力、地理等所有硬性规则,逐条列出。",
6301
- "4. 红线列出大纲中明确禁止的内容(如无后宫、不圣母、无无脑碾压等)。",
6302
- "5. 风格列出叙事基调、节奏、POV 等写作风格要点。",
6303
- "6. 角色名必须用正文/编年录中的真实姓名;若大纲只写「主角」未点名,而已写章节或编年录中有名字,则用该真实姓名;禁止输出「主角(描述)」这类把身份塞进名字的占位名。",
6304
- "输出必须是合法 JSON 对象,不要输出任何其他文字或 Markdown 代码块标记。",
6305
- "重要:所有字符串值内部不得包含换行符(不要用多行字符串),JSON 必须在一段内完整结束。",
6306
- "重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。",
6307
- "JSON 结构:",
6308
- "{\"genre\": \"题材与基调一句话\", \"worldRules\": [\"规则1\", \"规则2\", ...], \"characters\": [{\"name\": \"角色名\", \"role\": \"protagonist|supporting|antagonist|other\", \"traits\": [\"标签1\", ...], \"goals\": \"目标与动机\", \"relations\": \"关键关系\"}], \"redLines\": [\"红线1\", ...], \"style\": [\"风格1\", ...]}"
6309
- ].join("\n");
6310
- }
6311
7302
  /** Extract the story bible from an outline. */
6312
7303
  async function extractBible(ctx, config, outline, project) {
6313
7304
  const written = (project?.chapters ?? []).filter((c) => c.status !== "pending" && c.status !== "generating" && c.file !== void 0);
@@ -6360,184 +7351,41 @@ async function extractBible(ctx, config, outline, project) {
6360
7351
  if (bible.worldRules.length === 0 && bible.characters.length === 0 && bible.redLines.length === 0) throw new Error("道藏生成失败:模型没有返回有效内容");
6361
7352
  return bible;
6362
7353
  }
6363
- /** System prompt for volume planning. */
6364
- function volumeSystemPrompt() {
6365
- return [
6366
- "你是一位资深网文总编。你会收到一份小说大纲,请把全书划分为若干「卷」(分卷),每卷有明确的剧情定位与起止章节。",
6367
- "要求:",
6368
- "1. 大纲已有分卷时,严格遵循大纲的分卷结构;没有时按剧情弧线合理划分(3-8 卷)。",
6369
- "2. 卷定位一句话说明该卷的剧情重心。",
6370
- "3. chapterStart/chapterEnd 给出该卷覆盖的章节区间(从 1 开始连续编号)。",
6371
- "输出必须是合法 JSON 数组,不要输出任何其他文字:",
6372
- "[{\"no\": 1, \"title\": \"卷名\", \"summary\": \"卷定位与剧情重心\", \"chapterStart\": 1, \"chapterEnd\": 80}]",
6373
- "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
6374
- "重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。",
6375
- renderLengthContract([["卷名", STRUCTURED_FIELD_LIMITS.chapterTitle], ["每卷定位", STRUCTURED_FIELD_LIMITS.chapterSummary]])
6376
- ].join("\n");
6377
- }
6378
7354
  /** Plan volumes from an outline. */
6379
7355
  async function planVolumes(ctx, config, outline) {
6380
7356
  const user = `请为下面这部小说划分卷:\n\n${outline}`;
6381
- const expectedVolumes = Math.min(24, Math.max(3, Math.round(outline.length / 1200)));
6382
- const parsed = parseJsonArray(await complete(ctx, config, {
6383
- system: volumeSystemPrompt(),
6384
- user,
6385
- temperature: .4,
6386
- maxTokens: resolveOutputBudget("volume-strategy", { volumes: expectedVolumes })
6387
- }));
6388
- const volumes = [];
6389
- for (let i = 0; i < parsed.length; i++) {
6390
- const entry = parsed[i];
6391
- if (typeof entry !== "object" || entry === null) continue;
6392
- const no = typeof entry.no === "number" ? entry.no : i + 1;
6393
- const title = typeof entry.title === "string" ? entry.title.trim() : `第${no}卷`;
6394
- const summary = typeof entry.summary === "string" ? entry.summary.trim() : "";
6395
- const start = typeof entry.chapterStart === "number" ? entry.chapterStart : void 0;
6396
- const end = typeof entry.chapterEnd === "number" ? entry.chapterEnd : void 0;
6397
- volumes.push({
6398
- no,
6399
- title: title.slice(0, STRUCTURED_FIELD_LIMITS.chapterTitle),
6400
- summary: summary.slice(0, STRUCTURED_FIELD_LIMITS.chapterSummary),
6401
- chapterStart: start ?? 1,
6402
- chapterEnd: end ?? 1
6403
- });
6404
- }
6405
- if (volumes.length === 0) throw new Error("卷计划生成失败:模型没有返回有效卷");
6406
- return volumes;
6407
- }
6408
- /** Assign a chapter to its volume by number. */
6409
- function volumeOf(chapterNo, volumes) {
6410
- if (volumes === void 0 || volumes.length === 0) return 0;
6411
- for (const volume of volumes) if (chapterNo >= volume.chapterStart && chapterNo <= volume.chapterEnd) return volume.no;
6412
- return volumes[volumes.length - 1]?.no ?? 0;
6413
- }
6414
- /** The chapter-planning prompt template. */
6415
- function planSystemPrompt(volumes) {
6416
- return [
6417
- "你是一位资深中文网文策划编辑,擅长把小说大纲拆解为可执行的章节计划。",
6418
- "你会收到一份小说大纲。请根据大纲的设定、主线与节奏,规划出一份章节计划。",
6419
- "要求:",
6420
- "1. 每章必须有明确的核心剧情推进(不能只是过渡或凑字数)。",
6421
- "2. 章节之间要衔接自然,前章结尾为后章埋下钩子。",
6422
- "3. 严格遵循大纲的人设、金手指规则、战力体系与世界观设定,不得自行发明冲突设定。",
6423
- "4. 输出必须是合法的 JSON 数组,不要输出任何其他文字或 Markdown 代码块标记。",
6424
- "5. 数组每个元素格式:{\"title\": \"章节标题(10字以内,有网文感)\", \"beats\": \"结构化剧情要点(150-250字,必须包含四段,段间用换行分隔):\\n本章目标:本章要完成的核心推进;\\n剧情要点:主要情节的起承转合(2-4 句);\\n爽点/钩子:本章的爽点兑现或情绪钩子;\\n结尾钩子:本章结尾为下一章埋下的悬念\"}",
6425
- "6. 每个章节对象可额外包含以下可选字段(尽量给出,缺失则跳过):tension(0-100 整数:本章张力目标,对抗/揭示/抉择/代价的强度;铺垫与日常章明显偏低,与相邻章节形成起伏)、mustAdvance(数组,本章必须推进的局面/关系/信息/风险/决策变化);mustPreserve(数组,本章必须保持不破坏的项);characterHardFacts(数组,本章不可违背的人物硬事实:身份/阵营/境界/当前位置/知情度);endingHook(字符串,本章结尾钩子要求);obligation(字符串,本章义务合约一句话)。",
6426
- "重要:beats 字段内部必须使用 \\n 转义表示换行(JSON 字符串内不得有真实换行符),其余字符串值也不得包含真实换行符,JSON 必须在一段内完整结束。",
6427
- "重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。",
6428
- volumes !== void 0 && volumes.length > 0 ? ["\n全书分卷结构(规划章节时需落在对应卷内):"].concat(volumes.map((v) => `第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})` + (v.strategy !== void 0 && v.strategy !== "" ? `\n 卷战略:${v.strategy}` : "") + (v.pacing !== void 0 && v.pacing !== "" ? `\n 卷节奏板:${v.pacing}` : ""))).join("\n") : ""
6429
- ].join("\n");
6430
- }
6431
- /** Build the writing system prompt (bible + outline + active foreshadows).
6432
- * `targetChars` 来自每章计划(规划时快照,= 设置的每章目标字数);无则退回默认 3500。
6433
- * 字数区间按目标动态生成(±15%,取整到百位),避免系统提示词与设置互相冲突。
6434
- * `lengthRule` 可覆盖第 1 条字数要求(整章修订/改编时按原文长度为准)。 */
6435
- function writeSystemPrompt(project, targetChars, lengthRule, beforeChapter) {
6436
- const bible = project.bible;
6437
- const sections = [];
6438
- if (bible !== void 0) {
6439
- sections.push("==================== 道藏(写作时严格遵守) ====================");
6440
- if (bible.genre !== "") sections.push(`题材基调:${bible.genre}`);
6441
- if (bible.worldRules.length > 0) sections.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
6442
- const roleLib = project.roles ?? [];
6443
- const labelName = {
6444
- protagonist: "主角",
6445
- female_lead: "女主",
6446
- female_support: "女配",
6447
- support: "配角",
6448
- antagonist: "反派",
6449
- extra: "路人"
6450
- };
6451
- const seenRole = /* @__PURE__ */ new Set();
6452
- const mergedRoles = [];
6453
- for (const r of roleLib) {
6454
- seenRole.add(r.name);
6455
- const card = bible.characters.find((c) => c.name === r.name);
6456
- const traits = card !== void 0 ? card.traits : Array.isArray(r.traits) ? r.traits : [];
6457
- const goals = card !== void 0 && card.goals !== "" ? card.goals : r.goals;
6458
- const relations = card !== void 0 && card.relations !== "" ? card.relations : Array.isArray(r.relations) && r.relations.length > 0 ? r.relations.join("、") : "";
6459
- mergedRoles.push(`- ${r.name}(${labelName[r.roleLabel]}):${r.identity}${traits.length > 0 ? `;性格:${traits.join("、")}` : ""}${goals !== "" ? `;目标:${goals}` : ""}${relations !== "" ? `;关系:${relations}` : ""}`);
6460
- if (card !== void 0 && Array.isArray(card.knowledge) && card.knowledge.length > 0) mergedRoles.push(` 已知信息(该角色知道的:${card.knowledge.join(";")};未列出的信息该角色一律不知道,不得写其知晓或提及)`);
6461
- }
6462
- for (const card of bible.characters) {
6463
- if (seenRole.has(card.name)) continue;
6464
- seenRole.add(card.name);
6465
- const roleName = {
6466
- protagonist: "主角",
6467
- supporting: "配角",
6468
- antagonist: "反派",
6469
- other: "其他"
6470
- }[card.role];
6471
- mergedRoles.push(`- ${card.name}(${roleName}):${card.traits.join("、")}${card.goals !== "" ? `;目标:${card.goals}` : ""}${card.relations !== "" ? `;关系:${card.relations}` : ""}`);
6472
- if (Array.isArray(card.knowledge) && card.knowledge.length > 0) mergedRoles.push(` 已知信息(该角色知道的:${card.knowledge.join(";")};未列出的信息该角色一律不知道,不得写其知晓或提及)`);
6473
- }
6474
- if (mergedRoles.length > 0) {
6475
- sections.push("角色卡(角色库与道藏已合并去重):");
6476
- sections.push(...mergedRoles);
6477
- }
6478
- if (bible.redLines.length > 0) sections.push("写作红线(违反即失败):\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
6479
- if (bible.style.length > 0) sections.push("风格要求:\n" + bible.style.map((r) => `- ${r}`).join("\n"));
6480
- }
6481
- const worldBlock = renderWorld(project.world);
6482
- if (worldBlock !== "") sections.push(worldBlock);
6483
- sections.push("==================== 全书大纲 ====================");
6484
- const outlineBlock = project.outline.length > 6e3 ? project.outline.slice(0, 6e3) + "\n…(大纲过长已节选,完整内容见总纲页)" : project.outline;
6485
- sections.push(outlineBlock);
6486
- sections.push("==================== 大纲结束 ====================");
6487
- const assetsBlock = renderAllAssets(project.assets);
6488
- if (assetsBlock !== "") sections.push(assetsBlock);
6489
- const active = project.foreshadows.filter((f) => f.status === "planted" || f.status === "progressing");
6490
- if (active.length > 0) {
6491
- sections.push("==================== 活跃伏笔(近期需推进或回收的线索) ====================");
6492
- for (const f of active) sections.push(`- [${f.status === "planted" ? "已埋设" : "推进中"}] ${f.description}${f.targetChapter !== void 0 ? `(预计 ${f.targetChapter} 章回收)` : ""}`);
6493
- }
6494
- const lines = (project.plotlines ?? []).filter((l) => l.status === "active" || l.status === "paused");
6495
- if (lines.length > 0) {
6496
- const kindName = {
6497
- main: "主线",
6498
- branch: "支线",
6499
- character: "人物线",
6500
- mystery: "悬念线"
6501
- };
6502
- sections.push("==================== 剧情线(本章应推进至少一条活跃线) ====================");
6503
- for (const l of lines) sections.push(`- [${kindName[l.kind]}${l.status === "paused" ? "·暂停中" : ""}] ${l.name}:${l.goal}${l.progress !== "" ? `(当前进度:${l.progress})` : ""}`);
6504
- }
6505
- sections.push("");
6506
- sections.push("写作硬性要求:");
6507
- const target = targetChars !== void 0 && targetChars > 0 ? targetChars : 3500;
6508
- const lo = Math.max(1e3, Math.round(target * .85 / 100) * 100);
6509
- const hi = Math.max(lo + 100, Math.round(target * 1.15 / 100) * 100);
6510
- sections.push(lengthRule ?? `1. 每章 ${lo}-${hi} 字(目标 ${target} 字,按中文字符计),只输出章节正文,不要输出标题、章回名、作者的话或任何 Markdown 标记。`);
6511
- sections.push("2. 以主角视角展开,动作、对话、心理描写交替推进,禁止大段设定说明。");
6512
- sections.push("3. 尊重大纲与道藏:人设不崩、金手指规则不自相矛盾、战力不随意膨胀。");
6513
- sections.push("4. 章末留一个钩子(悬念、反转或新线索),吸引读者读下一章。");
6514
- sections.push("5. 语言流畅自然,符合中文网文语感,避免翻译腔与病句。");
6515
- sections.push("6. 对话与冲突密度:每章至少 1 处实质对话或正面对抗/交锋场面;推理与心理活动必须用动作、环境细节、微表情、对话呈现,禁止整章纯内心独白铺陈(禁止\"解说式\"交代线索)。");
6516
- sections.push("7. 反派与对手的行动力:本章出现的反派/对手必须有其行动、反制或压迫感(布局、试探、追索、交锋至少占其一),不得作为纯背景板存在。");
6517
- sections.push("8. 配角辨识度:重要新登场配角应给姓名或可辨识的独有特征;禁止通篇用\"瘦高个/灰衣人/戴面具者\"等身形标签代称同一角色。");
6518
- sections.push("9. 信息呈现方式:关键线索、设定、局势通过对话、动作、发现物呈现,禁止主角内心\"讲解\"给读者听。");
6519
- sections.push("");
6520
- sections.push("==================== 内容合规红线(平台硬性要求,最高优先级,违反即失败) ====================");
6521
- sections.push(COMPLIANCE_REDLINES.join("\n"));
6522
- sections.push("以上九条为硬性底线,任何情况下不得以任何形式出现或影射;若剧情确需涉及(如批判、反讽),只能以明确否定、揭露、批判的立场呈现,且不得展开细节。");
6523
- sections.push(renderChapterWriterSkeleton({
6524
- targetChars: target,
6525
- minChars: lo,
6526
- maxChars: hi,
6527
- pov: "第三人称有限视角,严格跟随主角所见所知。",
6528
- endingHookPreference: "章末留一个明确钩子(新信息、新风险或未闭合选择)。",
6529
- tonePreference: "动作、对话、心理交替推进;重大信息用对话/动作/发现呈现。",
6530
- antiAiRules: "严格遵循上方「反 AI 规则」与写法资产,避免套话与 AI 腔。"
7357
+ const expectedVolumes = Math.min(24, Math.max(3, Math.round(outline.length / 1200)));
7358
+ const parsed = parseJsonArray(await complete(ctx, config, {
7359
+ system: volumeSystemPrompt(),
7360
+ user,
7361
+ temperature: .4,
7362
+ maxTokens: resolveOutputBudget("volume-strategy", { volumes: expectedVolumes })
6531
7363
  }));
6532
- const timelineBlock = renderTimelineBlock(project, beforeChapter ?? Number.MAX_SAFE_INTEGER);
6533
- if (timelineBlock !== "") sections.push(timelineBlock);
6534
- if (beforeChapter !== void 0) {
6535
- const tensionBlock = renderTensionBlock(project, beforeChapter);
6536
- if (tensionBlock !== "") sections.push(tensionBlock);
7364
+ const volumes = [];
7365
+ for (let i = 0; i < parsed.length; i++) {
7366
+ const entry = parsed[i];
7367
+ if (typeof entry !== "object" || entry === null) continue;
7368
+ const no = typeof entry.no === "number" ? entry.no : i + 1;
7369
+ const title = typeof entry.title === "string" ? entry.title.trim() : `第${no}卷`;
7370
+ const summary = typeof entry.summary === "string" ? entry.summary.trim() : "";
7371
+ const start = typeof entry.chapterStart === "number" ? entry.chapterStart : void 0;
7372
+ const end = typeof entry.chapterEnd === "number" ? entry.chapterEnd : void 0;
7373
+ volumes.push({
7374
+ no,
7375
+ title: title.slice(0, STRUCTURED_FIELD_LIMITS.chapterTitle),
7376
+ summary: summary.slice(0, STRUCTURED_FIELD_LIMITS.chapterSummary),
7377
+ chapterStart: start ?? 1,
7378
+ chapterEnd: end ?? 1
7379
+ });
6537
7380
  }
6538
- const slotBlock = renderPromptSlots(project);
6539
- if (slotBlock !== "") sections.push(slotBlock);
6540
- return sections.join("\n");
7381
+ if (volumes.length === 0) throw new Error("卷计划生成失败:模型没有返回有效卷");
7382
+ return volumes;
7383
+ }
7384
+ /** Assign a chapter to its volume by number. */
7385
+ function volumeOf(chapterNo, volumes) {
7386
+ if (volumes === void 0 || volumes.length === 0) return 0;
7387
+ for (const volume of volumes) if (chapterNo >= volume.chapterStart && chapterNo <= volume.chapterEnd) return volume.no;
7388
+ return volumes[volumes.length - 1]?.no ?? 0;
6541
7389
  }
6542
7390
  /**
6543
7391
  * Plan chapters from an outline (optionally for one volume).
@@ -6614,7 +7462,7 @@ async function planChapters(ctx, config, project, chapterCount, volumeNo, output
6614
7462
  `请规划 ${chapterCount} 章。输出 JSON 数组(不要输出其他文字):`
6615
7463
  ].join("\n");
6616
7464
  const parsed = await completeJsonArray(ctx, config, {
6617
- system: planSystemPrompt(project.volumes) + (continuation ? "\n重要:本次是**续写规划**——已有章节的剧情不得重写或重复,新章节标题不得与已有章节标题相同,新章节的剧情必须从上一章结尾自然接续(人物状态、时间线、地点衔接一致)。" : ""),
7465
+ system: planSystemPrompt(project.volumes) + (continuation ? planContinuationSuffix() : ""),
6618
7466
  user,
6619
7467
  temperature: .7,
6620
7468
  maxTokens: resolveOutputBudget("chapter-list", { chapters: chapterCount }),
@@ -6688,49 +7536,6 @@ function retrieveKnowledge(project, query, topN = 3) {
6688
7536
  if (top.length === 0) return "";
6689
7537
  return "【书内知识库参考(与本章相关,写作时须遵守/可引用)】" + (hits.length > top.length ? `(命中 ${hits.length} 条,此处只注入最相关的 ${top.length} 条)` : "") + "\n" + top.map((x) => `- 《${x.d.title}》:${x.d.content.slice(0, 300)}`).join("\n");
6690
7538
  }
6691
- /** The review system prompt. */
6692
- function reviewSystemPrompt(project) {
6693
- const bible = project.bible;
6694
- const sections = [
6695
- "你是一位严格的网文审稿编辑。你会收到一章正文以及本书的道藏与红线。",
6696
- "请从以下维度审查本章:",
6697
- "1. 人设一致性:角色行为是否符合下方角色卡的设定(性格/目标/知情度/说话方式)。",
6698
- "2. 设定一致性:金手指规则、战力体系、世界观是否与道藏冲突。",
6699
- "3. 红线检查:是否触犯下方「本书红线」与「内容合规红线」。",
6700
- "4. 文笔质量:语病、翻译腔、AI 套话(\"不禁\"\"仿佛\"\"一时间\"等高频词滥用)、流水账。",
6701
- "5. 节奏与爽点:本章是否有推进、有钩子,是否拖沓灌水。",
6702
- "6. 逻辑漏洞:前后矛盾、时间线错误、对话失真。",
6703
- "7. 反 AI 规则:下方「本地 AI 味扫描」里已给出**本轮确定性命中的规则与次数**,直接以它为事实依据判级(禁止类命中即列为问题;鼓励类只作低优先级建议、不阻塞通过)。未列出的规则说明本章无确定性命中,不必逐条复述,也不要凭印象补报。",
6704
- "8. 呈现方式:整章是否纯内心推理铺陈(无对话/无对抗,推理全靠解说);反派是否纯背景板无行动;重要配角是否无名标签化(瘦高个/灰衣人全程代称)——命中即列为问题。",
6705
- "9. 内容合规(最高优先级):逐条核对下方「内容合规红线」,任何一条命中(含影射、暗示、详细描写)必须列为 high,并给出改写建议。",
6706
- "输出必须是合法 JSON 对象,不要输出任何其他文字:",
6707
- "{\"score\": 0-100的整数, \"riskScore\": 0-100的整数(越高越需人工处理,可结合本地AI味指数), \"tension\": 0-100的整数(本章张力强度:对抗/揭示/抉择/代价;铺垫与日常章应明显偏低), \"verdict\": \"一句话总评\", \"issues\": [{\"severity\": \"high|medium|low\", \"dimension\": \"character|setting|redline|writing|pacing|logic|anti-ai|presentation|compliance\", \"item\": \"问题描述\", \"suggestion\": \"修改建议\", \"ruleName\": \"命中的反AI规则名(见反AI规则清单)\", \"ruleType\": \"forbidden|risk|encourage\", \"category\": \"套话|句式|段落|心理|设定|节奏|对话|其他\", \"excerpt\": \"命中的原文摘录(不超过50字)\", \"reason\": \"判定理由\", \"canAutoRewrite\": true|false}]}",
6708
- "tension 是本章张力强度评分(不是质量分):对决/揭秘/生死抉择偏高,铺垫章、日常章、过渡章偏低;同一本书内保持同一尺度。",
6709
- "反 AI 类 issue 尽量给出 ruleName/ruleType/category/excerpt/reason/canAutoRewrite,便于统计与自动改写。",
6710
- "风格符合度:若下方给出「风格指纹比对」,以它为准判断本章是否偏离绑定写法——偏差显著时按 writing 维度、category 用「风格」上报;比对里说没有显著偏差时,不要报风格问题。",
6711
- "AI 套话高频模板词示例(集中出现必须整体降密度):仿佛、似乎、极其、完美、深不见底、形成了、莫名、无法形容、难以言喻、精心雕琢、肤光胜雪、眉目如画、歌舞升平、觥筹交错、妙语连珠、不可名状、另一层真相、命运、真相。",
6712
- "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
6713
- "重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
6714
- ];
6715
- const assetsBlock = renderAllAssets(project.assets);
6716
- if (assetsBlock !== "") sections.push("\n" + assetsBlock);
6717
- if (bible !== void 0) {
6718
- sections.push("\n==================== 道藏 ====================");
6719
- if (bible.worldRules.length > 0) sections.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
6720
- if (bible.characters.length > 0) {
6721
- sections.push("角色卡:");
6722
- for (const card of bible.characters) {
6723
- sections.push(`- ${card.name}(${card.role}):${card.traits.join("、")}`);
6724
- if (Array.isArray(card.knowledge) && card.knowledge.length > 0) sections.push(` 该角色知道:${card.knowledge.join(";")}(未列出的信息该角色不知道)`);
6725
- }
6726
- }
6727
- if (bible.redLines.length > 0) sections.push("红线:\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
6728
- }
6729
- sections.push("\n==================== 内容合规红线(平台硬性要求,最高优先级) ====================");
6730
- sections.push(COMPLIANCE_REDLINES.join("\n"));
6731
- sections.push("以上九条为硬性底线:正文中任何一条命中(含影射、暗示、详细展开)都必须列为 high,并给出改写建议;作者自定义红线不得豁免这九条。");
6732
- return sections.join("\n");
6733
- }
6734
7539
  /**
6735
7540
  * 风格基线的取样上限与取法。
6736
7541
  *
@@ -6929,76 +7734,6 @@ async function reviewChapterText(ctx, config, project, text, previousReport) {
6929
7734
  reviewedAt: (/* @__PURE__ */ new Date()).toISOString()
6930
7735
  };
6931
7736
  }
6932
- /** 验证模式系统提示:修订后逐条核对原意见是否解决,只挑新增 high,不重复挑剔主观项。 */
6933
- function verifySystemPrompt(project) {
6934
- return [
6935
- "你是一位网文审稿验证员。作者已按上一轮审稿意见修订了本章,你需要验证修订效果。",
6936
- "你的任务(严格按此执行):",
6937
- "1. 逐条核对「上一轮意见」中的每一条(按编号 1、2、3...)是否已在修订稿中解决。",
6938
- "2. 只挑修订【新引入】的 high 级问题(设定矛盾/逻辑硬伤/事实错误)——新引入的 medium/low 主观项(文笔/套话/节奏)不要列。",
6939
- "3. 禁止重复挑剔上一轮已指出且本次已解决的主观项(如\"缓缓/微微\"等套话、错别字)——即使换个说法再提也不行。",
6940
- "4. 严禁为了显得专业而新增\"换一批毛病\"式的意见。",
6941
- "输出必须是合法 JSON 对象,包含以下字段:",
6942
- "- resolvedIds:已解决的上一轮意见编号数组(如 [1, 3, 5])。",
6943
- "- unresolvedIds:未解决或部分解决的上一轮意见编号数组(如 [2, 4])。",
6944
- "- issues:未解决的原意见 + 新引入的 high 级问题列表(格式同审稿:severity/item/suggestion)。未解决的原意见 item 需注明\"未解决(编号N):原意见摘要\"。",
6945
- "- score:按修订稿整体质量给 50-90 分(解决全部 high 且无新增 high 时给 70 以上)。",
6946
- "- verdict:一句话结论。",
6947
- "完整格式:{\"resolvedIds\": [1,3], \"unresolvedIds\": [2], \"score\": 75, \"verdict\": \"一句话\", \"issues\": [{\"severity\": \"high\", \"dimension\": \"character|setting|redline|writing|pacing|logic|anti-ai|presentation|compliance\", \"item\": \"未解决(2):xxx\", \"suggestion\": \"xxx\"}]}",
6948
- "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
6949
- "重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。",
6950
- renderBibleForVerify(project)
6951
- ].join("\n");
6952
- }
6953
- /**
6954
- * 验证模式下注入道藏。
6955
- *
6956
- * 原实现是 `JSON.stringify(project.bible).slice(0, 3000)`——有两个问题:
6957
- * 1. **腰斩的 JSON 不是 JSON**:`slice` 从中间切断,审稿员会看到语法残缺的
6958
- * 对象(字符串没闭合、数组没结尾),既浪费 token 又可能误读设定。
6959
- * 2. **静默截断设定**:世界规则/角色卡排在后面的字段最先被切掉,而那恰恰是
6960
- * 核对设定冲突最需要的部分。
6961
- *
6962
- * 改为结构化渲染 + 按「世界规则/角色/红线」分配独立预算。
6963
- */
6964
- function renderBibleForVerify(project) {
6965
- const bible = project.bible;
6966
- if (bible === void 0) return "本书道藏(核对设定冲突用):\n(无)";
6967
- const parts = [];
6968
- if (bible.worldRules.length > 0) parts.push(`世界规则:\n${bible.worldRules.map((r) => `- ${r}`).join("\n")}`);
6969
- if (bible.characters.length > 0) parts.push(`角色卡:\n${bible.characters.map((c) => {
6970
- const goals = c.goals !== "" ? `;目标:${c.goals}` : "";
6971
- const rel = c.relations !== "" ? `;关系:${c.relations}` : "";
6972
- return `- ${c.name}:${c.traits.join("、")}${goals}${rel}`;
6973
- }).join("\n")}`);
6974
- if (bible.redLines.length > 0) parts.push(`红线:\n${bible.redLines.map((r) => `- ${r}`).join("\n")}`);
6975
- if (parts.length === 0) return "本书道藏(核对设定冲突用):\n(无)";
6976
- const block = parts.join("\n");
6977
- const limit = 6e3;
6978
- return `本书道藏(核对设定冲突用):\n${block.length > limit ? block.slice(0, limit) + `\n…(道藏过长已节选,但角色名/世界规则已尽量保留;如需完整设定请查总纲页)` : block}`;
6979
- }
6980
- /** Build the author-review system prompt (narrative structure, not prose). */
6981
- function authorReviewSystemPrompt() {
6982
- return [
6983
- "你是一位网文作者复盘助手。你会收到:本章正文、上一章结尾(钩子)、上一章作者复盘(如有)、活跃剧情线与编年录近期事实。",
6984
- "请从叙事结构层面复盘本章(不评文笔,那是审稿的事):",
6985
- "1. hookHonored:上一章结尾的钩子/悬念是否在本章兑现或推进(true/false)。",
6986
- "2. hookNote:钩子兑现情况一句话;未兑现时说明并给出\"建议在第几章补\"的建议。",
6987
- "3. endingHook:本章结尾钩子强度,0-10 的整数(低于 6 说明结尾平淡,读者可能不想看下一章)。",
6988
- "4. plotlineProgress:本章推进了哪条剧情线(主线/支线名),或\"无实质推进\"(连续无推进要提醒)。",
6989
- "5. advancedLines:本章实际推进的剧情线名称数组——从「活跃剧情线」清单中选出推进了的线(名称必须与清单中的线名一字不差;没推进任何线则输出空数组)。",
6990
- "6. continuity:与上一章结尾的衔接检查(人物位置/时间/伤势/资源/对话状态),发现问题要指出。",
6991
- "7. trend:结合上一章复盘看近期节奏趋势(是否连续拖沓、爽点密度是否下降、是否需要调整)。",
6992
- "8. stateChanges:本章发生的关键状态变化数组(人物状态/世界局面/关系/资源,每条约 20 字,最多 6 条),供整本与事实库回灌。",
6993
- "9. newConflicts:本章新引入或明显升级的冲突数组(最多 4 条)。",
6994
- "10. clues:本章埋下或推进的新线索/伏笔数组(最多 4 条)。",
6995
- "11. absentRisks:本章缺席但按卷级职责应出场/该推进关系的重要角色,及其缺席带来的风险(最多 4 条)。",
6996
- "输出必须是合法 JSON 对象,不要输出任何其他文字:",
6997
- "{\"hookHonored\": true或false, \"hookNote\": \"一句话\", \"endingHook\": 0-10整数, \"plotlineProgress\": \"一句话\", \"advancedLines\": [\"线名\"], \"continuity\": \"一句话\", \"trend\": \"一句话\", \"stateChanges\": [\"状态变化\"], \"newConflicts\": [\"新冲突\"], \"clues\": [\"新线索\"], \"absentRisks\": [\"本章缺席但值得注意的角色及风险(卷级职责/缺席风险,最多4条)\"]}",
6998
- "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
6999
- "重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
7000
- ].join("\n");
7001
- }
7002
7737
  /** 作者复盘:对一章做叙事结构复盘(钩子兑现/结尾钩子/推进/连续性/趋势)。 */
7003
7738
  async function authorReviewChapter(ctx, config, project, chapterNo, body, prevTail) {
7004
7739
  const chapter = project.chapters.find((c) => c.no === chapterNo);
@@ -7048,14 +7783,7 @@ function autoLinkPlotlines(project, chapterNo, advancedLines) {
7048
7783
  }
7049
7784
  /** 建议剧情线:基于大纲/卷计划/已写章节/编年录,提炼候选线。 */
7050
7785
  async function suggestPlotlines(ctx, config, project) {
7051
- const system = [
7052
- "你是一位网文剧情架构师。根据本书的大纲、卷计划、已写章节标题与编年录,为作者提炼建议的剧情线(主线/支线/人物线/悬念线)。",
7053
- "每条线要:名称简洁有力;目标写清楚这条线最终要完成什么;progress 写当前推进到哪(没有就空字符串)。",
7054
- "建议 4-8 条,覆盖:1 条主线、1-2 条人物线、1-2 条悬念线、1-3 条支线。避免与大纲明显重复的废话线。",
7055
- "输出必须是合法 JSON 数组,格式:[{\"name\": \"线名\", \"kind\": \"main|branch|character|mystery\", \"goal\": \"目标\", \"progress\": \"\"}]",
7056
- "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
7057
- "重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
7058
- ].join("\n");
7786
+ const system = suggestPlotlinesSystemPrompt();
7059
7787
  const written = project.chapters.filter((c) => c.status !== "pending");
7060
7788
  const raw = parseJsonArray(await complete(ctx, config, {
7061
7789
  system,
@@ -7106,12 +7834,7 @@ async function refreshPlotlineProgress(ctx, config, project, target) {
7106
7834
  }
7107
7835
  /** 实际的批量刷新实现(被上面的重载收敛到一处)。 */
7108
7836
  async function refreshLinesFor(ctx, config, project, lines) {
7109
- const system = [
7110
- lines.length === 1 ? "你是一位网文剧情线管理员。请根据「剧情线信息」与「本书已写章节摘要/编年录」,判断这条线目前推进到了哪一步。" : `你是一位网文剧情线管理员。下面给了 ${lines.length} 条剧情线,请**逐条**判断各自推进到了哪一步。`,
7111
- "每条线输出一句话(30-60 字):该线当前的状态、最近一次推进发生在第几章、下一步可能的方向。还没开始推进的线,明确说\"尚未推进\"。",
7112
- lines.length === 1 ? "输出必须是合法 JSON 对象:{\"progress\": \"一句话\"}" : `输出必须是合法 JSON 对象:{"items": [{"name": "线名(与输入完全一致)", "progress": "一句话"}]},items 的条数必须等于 ${lines.length},name 必须与输入里的线名逐字相同(用于对齐)`,
7113
- "重要:不要输出任何其他文字。"
7114
- ].join("\n");
7837
+ const system = refreshPlotlinesSystemPrompt(lines.length);
7115
7838
  const sharedContext = [`章节摘要(最近 8 章):\n${project.chapters.filter((c) => c.status !== "pending" && c.summary !== void 0 && c.summary !== "").slice(-8).map((c) => `第${c.no}章《${c.title}》:${c.summary.slice(0, 120)}`).join("\n")}`, (project.facts ?? []).length > 0 ? `编年录近期事实(最近 15 条):\n${(project.facts ?? []).slice(-15).map((f) => `[第${f.chapterNo}章] ${f.text.slice(0, 100)}`).join("\n")}` : ""];
7116
7839
  const describe = (l) => [
7117
7840
  `剧情线:${l.name}(${l.kind})`,
@@ -7146,28 +7869,7 @@ async function refreshLinesFor(ctx, config, project, lines) {
7146
7869
  }
7147
7870
  /** AI 从全书提炼角色库:大纲 + 道藏 + 编年录 + 章节摘要 → 结构化角色清单。 */
7148
7871
  async function extractRoles(ctx, config, project) {
7149
- const system = [
7150
- "你是一位网文角色库管理员。请根据本书的大纲、设定、编年录与章节摘要,提炼完整的角色库。",
7151
- "覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如\"矮胖姑娘\")用其身份简称收录并标注;反复出现且有剧情作用的身份型角色(站长、律师、警察、法官、店主等)必须收录。",
7152
- "数量控制:最多输出 20 个角色;覆盖优先——主角、主要反派、重要配角(女主/关键配角)必须全收,所有有名有姓者必收;只有真正一次性路人(无名字、无剧情作用)才可省略。",
7153
- "重要:正常一部完整故事应提炼 8-20 个角色;若少于 6 个通常说明漏提炼,必须重新核对正文摘录。",
7154
- "重要:正文中若有明确的主角与主要反派,必须分别以 protagonist / antagonist 收录,禁止遗漏;反派确实未出场时才可省略。",
7155
- "输出优先级:主角(protagonist)与主要反派(antagonist)必须优先输出并完整刻画,其次女主/重要配角;判断不出名字时用正文中的身份称呼。",
7156
- "每个角色输出:",
7157
- "1. name:角色名(或身份简称)。主角/有名配角必须用正文中实际出现的人名(如「沈放」),禁止用「主角(38岁超市理货员)」这类把身份塞进名字的占位名;正文确实没点名时才可用身份简称(如「富商」「灰衣老人」)。",
7158
- "2. roleLabel:定位——protagonist=主角;female_lead=女主(唯一知己/感情线核心,无后宫前提下只此一位);female_support=重要女配;support=普通配角;antagonist=反派;extra=路人/背景。",
7159
- "3. identity:身份一句话(宗门/势力/血脉/职业)。",
7160
- "4. traits:3-6 个性格标签。",
7161
- "5. goals:目标与动机一句话。",
7162
- "6. relations:关系网数组,格式[\"角色名(关系)\", ...]。",
7163
- "7. arc:成长线数组,格式[\"阶段:说明\", ...](如\"出场:祭品身份\"/\"转折:祭祀被中断脱身\")。",
7164
- "8. knowledge:该角色已经知道的关键信息(3-8 条),不知道的信息不要写进去。",
7165
- "精简要求:identity 控制在 30 字内;traits 3-6 个短标签;goals 60 字内;relations 2-5 条;arc 2-4 条;knowledge 每条 40 字内。整体输出量要紧凑,避免冗长。",
7166
- "重要:用户消息里列出的「已收录角色」绝不要再次输出——这些角色已经在角色库里,跳过它们,只提炼未收录的。",
7167
- "输出必须是合法 JSON 数组,不要输出其他文字:[{\"name\":\"...\", \"roleLabel\":\"...\", \"identity\":\"...\", \"traits\":[...], \"goals\":\"...\", \"relations\":[...], \"arc\":[...], \"knowledge\":[...]}]",
7168
- "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
7169
- "重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
7170
- ].join("\n");
7872
+ const system = extractRolesSystemPrompt();
7171
7873
  const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
7172
7874
  const existingRoles = project.roles ?? [];
7173
7875
  const sampleChapters = written.length <= 10 ? written : (() => {
@@ -7265,8 +7967,7 @@ async function extractRoles(ctx, config, project) {
7265
7967
  if (role !== void 0) roles.push(role);
7266
7968
  }
7267
7969
  if (roles.length > 0) {
7268
- const names = roles.map((r) => r.name).join("、");
7269
- const patchSystem = system + "\n上一次已提炼角色:" + names + "。\n现在只输出「遗漏的角色」JSON 数组:检查正文摘录中反复出现、有固定身份称呼(如站长、律师、警察、法官、店主、老师)且对剧情有作用的角色;一次性路人不要输出。没有遗漏就输出 []。字段与上面相同。";
7970
+ const patchSystem = system + roleCatchUpSuffix(roles.map((r) => r.name).join("、"));
7270
7971
  try {
7271
7972
  const patchRaw = parseJsonArray(await complete(ctx, config, {
7272
7973
  system: patchSystem,
@@ -7542,23 +8243,7 @@ function importBookText(filePath, outputDir) {
7542
8243
  async function suggestOutlines(ctx, config, idea, count = 3, exclude = []) {
7543
8244
  const n = Math.max(1, Math.min(3, Math.floor(count)));
7544
8245
  const parsed = parseJsonArray(await complete(ctx, config, {
7545
- system: [
7546
- "你是一位资深网文策划。作者只给了一句「想法」,你需要把它扩展成 2-3 个【方向差异明显】的完整小说大纲方案,供作者挑选。",
7547
- "每个方案必须满足:",
7548
- "1. bookName:书名(6 字以内,抓眼球、点题)。",
7549
- "2. genre:题材(如 仙侠修真 / 都市异能 / 玄幻 / 悬疑)。",
7550
- "3. sellingPoint:核心卖点一句话(金手指/爽点/差异化,40 字内)。",
7551
- "4. outline:完整大纲文本(至少 800 字,可直接作为开书大纲),结构包含:书名与题材、金手指/核心设定、主角人设与动机、主线剧情走向(至少 5 个阶段)、关键配角与势力、卖点与爽点设计、预计分卷(3-5 卷)。",
7552
- "方向差异要求:",
7553
- "- 方案之间的金手指/剧情走向必须明显不同(如:苟道发育流 vs 随身老爷爷流 vs 群像争霸流),不能只是换书名。",
7554
- "- 忠实于作者想法的核心要素,但允许在不同方向上进行合理演绎。",
7555
- "- 不输出任何与已列「需避开的方向」雷同的方案。",
7556
- "输出必须是合法 JSON 数组,只输出数组本身:",
7557
- "[{\"id\": \"唯一id\", \"bookName\": \"...\", \"genre\": \"...\", \"sellingPoint\": \"...\", \"outline\": \"...\"}]",
7558
- `本次只输出 ${n} 个方案。`,
7559
- "重要:所有字符串值内部不得包含换行符(大纲内部分段请用「。\n」或「;」自然断句),JSON 必须在一段内完整结束。",
7560
- "重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
7561
- ].join("\n"),
8246
+ system: outlineCandidatesSystemPrompt(n),
7562
8247
  user: [
7563
8248
  `作者的想法:${idea}`,
7564
8249
  idea.trim().length < 40 ? "作者的想法非常简短(可能只有一句)。请基于通用网文套路合理扩展补全:为每个方案自洽地设计金手指/核心设定、主角人设与动机、主线走向,使其成为完整可开书的大纲;不同方案的方向仍须明显差异。" : "",
@@ -7620,13 +8305,7 @@ async function breakdownBook(ctx, config, project, outputDir, scope = "recent",
7620
8305
  }
7621
8306
  const notes = [];
7622
8307
  let usedTokens = 0;
7623
- const noteSystem = [
7624
- "你是中文网文拆书助手。把单章正文整理成结构化笔记,供后续章节级分析复用。",
7625
- "只输出 JSON 对象:",
7626
- "{\"summary\": \"1-2句\", \"plotPoints\": [\"...\"], \"characters\": [\"...\"], \"worldbuilding\": [\"...\"], \"styleTechniques\": [\"...\"], \"marketHighlights\": [\"...\"], \"weaknessSignals\": [\"...\"]}",
7627
- "硬规则:只提取正文明确出现的信息;每数组最多 4 项;不要补写原文外的动机/意图;evidence 不在此阶段输出。",
7628
- "重要:直接输出 JSON,不要输出其他文字;字符串内不含换行。"
7629
- ].join("\n");
8308
+ const noteSystem = breakdownNoteSystemPrompt();
7630
8309
  for (const ch of chunks) {
7631
8310
  const noteUser = [
7632
8311
  `第${ch.no}章《${ch.title}》`,
@@ -7646,63 +8325,7 @@ async function breakdownBook(ctx, config, project, outputDir, scope = "recent",
7646
8325
  usedTokens += 800;
7647
8326
  } catch {}
7648
8327
  }
7649
- const sectionsConfig = [
7650
- {
7651
- key: "overview",
7652
- title: "拆书总览",
7653
- focus: "一句话定位、题材标签、整体优势与短板",
7654
- system: [
7655
- "你是资深中文网文拆书分析师,负责《拆书总览》小节。",
7656
- "基于给定章节笔记做低风险综合判断,输出 JSON:{\"markdown\": \"可直接展示的分析稿(简体中文,先给结论再说明体现在哪、为何成立)\", \"structured\": {\"oneLinePositioning\": \"一句话定位\", \"genreTags\": [\"题材标签\"], \"sellingPointTags\": [\"卖点标签\"], \"strengths\": [\"整体优势\"], \"weaknesses\": [\"整体短板\"]}}",
7657
- "硬规则:只基于笔记归纳;推断用「更偏向/可能」等谨慎措辞;证据不足写「材料不足」;不虚构原文细节。",
7658
- "重要:直接输出 JSON,字符串内不含换行。"
7659
- ].join("\n")
7660
- },
7661
- {
7662
- key: "plot",
7663
- title: "剧情结构",
7664
- focus: "主线梗概、阶段推进、冲突升级、节奏风险",
7665
- system: [
7666
- "你是资深中文网文拆书分析师,负责《剧情结构》小节。",
7667
- "基于给定章节笔记分析,输出 JSON:{\"markdown\": \"分析稿(简体中文,先结论后依据)\", \"structured\": {\"mainlineSummary\": \"主线梗概\", \"phaseProgressions\": [\"阶段推进\"], \"escalationDesigns\": [\"冲突升级\"], \"paceRisks\": [\"节奏风险\"], \"reusablePatterns\": [\"可复用套路\"]}}",
7668
- "硬规则:只基于笔记归纳;推断谨慎措辞;不虚构。",
7669
- "重要:直接输出 JSON,字符串内不含换行。"
7670
- ].join("\n")
7671
- },
7672
- {
7673
- key: "character",
7674
- title: "人物系统",
7675
- focus: "主角定位、配角功能、关系网络、成长弧线、辨识度风险",
7676
- system: [
7677
- "你是资深中文网文拆书分析师,负责《人物系统》小节。",
7678
- "基于给定章节笔记分析,输出 JSON:{\"markdown\": \"分析稿(简体中文,先结论后依据)\", \"structured\": {\"protagonistPositioning\": \"主角定位\", \"supportingFunctions\": [\"配角功能\"], \"relationshipNetwork\": [\"关系网络\"], \"growthArcs\": [\"成长弧线\"], \"clarityRisks\": [\"辨识度风险\"]}}",
7679
- "硬规则:只基于笔记归纳;推断谨慎措辞;不虚构。",
7680
- "重要:直接输出 JSON,字符串内不含换行。"
7681
- ].join("\n")
7682
- },
7683
- {
7684
- key: "style",
7685
- title: "文风与技法",
7686
- focus: "叙事视角、语言风格、描写方式、节奏控制、钩子设计、可复用写法",
7687
- system: [
7688
- "你是资深中文网文拆书分析师,负责《文风与技法》小节。",
7689
- "基于给定章节笔记分析,输出 JSON:{\"markdown\": \"分析稿(简体中文,先结论后依据)\", \"structured\": {\"narrativePov\": \"叙事视角\", \"languageStyle\": \"语言风格\", \"dialoguePatterns\": [\"对话特征\"], \"rhythmControl\": [\"节奏控制\"], \"hookDesigns\": [\"钩子设计\"], \"reusableTechniques\": [\"可复用写法\"]}}",
7690
- "硬规则:只基于笔记归纳;推断谨慎措辞;不虚构。",
7691
- "重要:直接输出 JSON,字符串内不含换行。"
7692
- ].join("\n")
7693
- }
7694
- ];
7695
- if (preset === "standard") sectionsConfig.push({
7696
- key: "market",
7697
- title: "商业化卖点",
7698
- focus: "读者爽点、点击驱动、人物/题材卖点、商业化风险",
7699
- system: [
7700
- "你是资深中文网文拆书分析师,负责《商业化卖点》小节。",
7701
- "基于给定章节笔记分析,输出 JSON:{\"markdown\": \"分析稿(简体中文,先结论后依据)\", \"structured\": {\"hookPoints\": [\"读者爽点\"], \"clickDrivers\": [\"点击驱动\"], \"characterSellingPoints\": [\"人物卖点\"], \"genreSellingPoints\": [\"题材卖点\"], \"commercialRisks\": [\"商业化风险\"]}}",
7702
- "硬规则:只基于笔记归纳;推断谨慎措辞;不虚构。",
7703
- "重要:直接输出 JSON,字符串内不含换行。"
7704
- ].join("\n")
7705
- });
8328
+ const sectionsConfig = breakdownSections(preset);
7706
8329
  const notesText = notes.join("\n\n");
7707
8330
  const sections = [];
7708
8331
  const evidence = [];
@@ -7969,18 +8592,7 @@ async function registerLlmModel(ctx, req) {
7969
8592
  }
7970
8593
  /** 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
7971
8594
  async function analyzePlotlineHealth(ctx, config, project) {
7972
- const system = [
7973
- "你是一位网文剧情架构师。请对本书的「剧情线体系」做健康检查,判断当前是否需要新增剧情线、应在多少章后添加。",
7974
- "评估维度:各线最近推进到第几章(已写章节与关联章节的差值越大越危险)、各线状态、已写章节总数、卷计划当前进度、编年录近期事实。",
7975
- "输出规则:",
7976
- "1. verdict:一句话结论——\"需要新增线\" / \"暂不需要\" / \"再写 N 章后需要\"(N 给出具体章数)。",
7977
- "2. timing:说明建议添加的时机(如:第 25 章前引入新支线,因为主线预计第 22 章告一段落)。",
7978
- "3. reasons:3-5 条依据(引用具体数据:哪条线多少章没推进、已写章节数、卷进度等)。",
7979
- "4. lines:对每条线给健康度——ok(近期推进过)/ warning(超过 5 章未推进)/ stale(超过 10 章未推进或悬置过久)。",
7980
- "输出必须是合法 JSON 对象:{\"verdict\": \"...\", \"timing\": \"...\", \"reasons\": [\"...\"], \"lines\": [{\"name\": \"线名\", \"health\": \"ok|warning|stale\", \"note\": \"一句说明\"}]}",
7981
- "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
7982
- "重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
7983
- ].join("\n");
8595
+ const system = plotlineHealthSystemPrompt();
7984
8596
  const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
7985
8597
  const lines = (project.plotlines ?? []).filter((l) => l.status === "active" || l.status === "paused");
7986
8598
  const raw = parseJsonObject(await complete(ctx, config, {
@@ -8015,13 +8627,7 @@ async function analyzePlotlineHealth(ctx, config, project) {
8015
8627
  }
8016
8628
  /** 剧情方案:基于健康检查结果设计下一阶段方向与建议新线。 */
8017
8629
  async function designPlotlinePlan(ctx, config, project, health) {
8018
- const system = [
8019
- "你是一位网文剧情架构师。请为本书设计「下一阶段的剧情方案」:给出未来 5-10 章的剧情方向,并建议 2-3 条值得新增的剧情线。",
8020
- "要求:方向必须结合本书大纲/卷计划/现有线/编年录;新线要能落地(和当前主角处境、已有伏笔、下一阶段舞台相关),不得重复已有线。",
8021
- "输出必须是合法 JSON 对象:{\"direction\": \"下一阶段方向 60-120 字\", \"suggestions\": [{\"name\": \"线名\", \"kind\": \"main|branch|character|mystery\", \"goal\": \"目标\", \"progress\": \"初始进度(可空)\"}]}",
8022
- "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
8023
- "重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
8024
- ].join("\n");
8630
+ const system = plotlinePlanSystemPrompt();
8025
8631
  const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
8026
8632
  const raw = parseJsonObject(await complete(ctx, config, {
8027
8633
  system,
@@ -8062,12 +8668,8 @@ async function designPlotlinePlan(ctx, config, project, health) {
8062
8668
  }
8063
8669
  return {
8064
8670
  direction: typeof raw.direction === "string" ? raw.direction.slice(0, 300) : "",
8065
- suggestions
8066
- };
8067
- }
8068
- /** Build the rewrite system prompt (fix review issues / instructions). */
8069
- function rewriteSystemPrompt(project, targetChars, beforeChapter) {
8070
- return writeSystemPrompt(project, targetChars, "1. 输出完整的新正文(不要只输出修改片段、标题、章回名、作者的话或任何 Markdown 标记),字数与原章相当(允许 ±20%)。", beforeChapter) + "\n\n额外要求:你正在【修订】一章已写好的正文。保留原文中好的部分,只修改需要修改的地方,输出完整的新正文(不要只输出修改片段),字数与原文相当。";
8671
+ suggestions
8672
+ };
8071
8673
  }
8072
8674
  /**
8073
8675
  * Stream a chapter rewrite. With `target` (a passage of the body), only that
@@ -8075,7 +8677,7 @@ function rewriteSystemPrompt(project, targetChars, beforeChapter) {
8075
8677
  * untouched (local revision). Without `target`, the whole chapter is
8076
8678
  * rewritten. Yields delta text; persists when done.
8077
8679
  */
8078
- async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo, instructions, target) {
8680
+ async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo, instructions, target, signal) {
8079
8681
  const chapter = project.chapters.find((c) => c.no === chapterNo);
8080
8682
  if (chapter === void 0) throw new NovelActionError("contract", `章节 ${chapterNo} 不在计划中`);
8081
8683
  const body = readChapterFile(outputDir, chapter);
@@ -8113,29 +8715,7 @@ async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo,
8113
8715
  "2. 保留该段的情节走向与角色口吻,只按修改要求调整。",
8114
8716
  "3. 段落长度与原文相当。"
8115
8717
  ].filter((line) => line !== "").join("\n");
8116
- const system = localTarget === void 0 ? rewriteSystemPrompt(project, chapter.targetChars || config.chapterChars, chapter.no) : (() => {
8117
- const bible = project.bible;
8118
- const lines = [
8119
- "你是一位中文网文润色师。你会收到一章中的一个段落,请按修改要求重写该段。",
8120
- "硬性约束:",
8121
- "1. 只改表达,不改情节走向、人物设定、已确立事实、对话核心内容。",
8122
- "2. 必须遵守下方「内容合规红线」,任何一条命中(含影射、暗示)都必须避免。",
8123
- "3. 必须遵守下方「本书红线」(如有)。",
8124
- "4. 避免 AI 套话:不禁、仿佛、一时间、不由得、顿时、然而、缓缓、轻轻、微微、默默、似乎、终于等滥用。",
8125
- "5. 保留角色口吻与性格,角色行为需符合下方角色卡(如有)。",
8126
- "6. 只输出修改后的【这一个段落】的完整新文本,不要输出任何说明、标题或 Markdown 标记。"
8127
- ];
8128
- if (bible !== void 0) {
8129
- if (bible.redLines.length > 0) lines.push("本书红线:\n" + bible.redLines.map((r) => "- " + r).join("\n"));
8130
- if (bible.characters.length > 0) {
8131
- lines.push("相关角色卡:");
8132
- for (const card of bible.characters) lines.push("- " + card.name + "(" + card.role + "):" + card.traits.join("、"));
8133
- }
8134
- }
8135
- lines.push("内容合规红线(平台硬性要求,最高优先级):");
8136
- lines.push(COMPLIANCE_REDLINES.join("\n"));
8137
- return lines.join("\n");
8138
- })();
8718
+ const system = localTarget === void 0 ? rewriteSystemPrompt(project, chapter.targetChars || config.chapterChars, chapter.no) : localRevisionSystemPrompt(project);
8139
8719
  const messages = [createUserMessage({
8140
8720
  content: [{
8141
8721
  type: "text",
@@ -8160,6 +8740,7 @@ async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo,
8160
8740
  ctx,
8161
8741
  config,
8162
8742
  request,
8743
+ signal,
8163
8744
  liveLabel: `修订 · 第${chapterNo}章`,
8164
8745
  liveUser: messages[0]?.content !== void 0 ? JSON.stringify(messages[0].content).slice(0, 2e3) : void 0,
8165
8746
  maxTokensError: "修订输出达到 maxTokens 上限,请增大配置后重试",
@@ -8183,29 +8764,9 @@ async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo,
8183
8764
  draft: newBody
8184
8765
  };
8185
8766
  }
8186
- /** The de-AI-ify polish system prompt (with project writing assets injected). */
8187
- function polishSystemPrompt(project) {
8188
- const assetsBlock = renderAllAssets(project.assets);
8189
- const bible = project.bible;
8190
- const lines = [
8191
- "你是一位中文网文润色师。你会收到一章正文,请做「去 AI 味」润色:",
8192
- "1. 删除/替换 AI 高频套话与模式词:如\"不禁\"\"仿佛\"\"一时间\"\"不由得\"\"顿时\"\"然而\"\"缓缓\"\"轻轻\"\"微微\"\"默默\"\"似乎\"\"终于\"等滥用。",
8193
- "2. 把书面翻译腔改成口语化的中文网文语感。",
8194
- "3. 拆分过长的排比句与堆砌的修饰语。",
8195
- "4. 保留全部情节、人物、对话内容、已确立事实不变,只改表达。",
8196
- "5. 输出完整的新正文,不要输出任何说明文字或 Markdown 标记。",
8197
- "6. 必须遵守下方「反 AI 规则」与「写法资产」的表达边界;写法资产要求保留的风格特征(句式、台词、节奏)不得在润色中丢失。",
8198
- "7. 必须遵守下方「内容合规红线」与「本书红线」(如有),任何一条命中(含影射、暗示)都必须避免。"
8199
- ];
8200
- if (bible !== void 0 && bible.redLines.length > 0) lines.push("本书红线:\n" + bible.redLines.map((r) => "- " + r).join("\n"));
8201
- lines.push("内容合规红线(平台硬性要求,最高优先级):");
8202
- lines.push(COMPLIANCE_REDLINES.join("\n"));
8203
- if (assetsBlock !== "") lines.push(assetsBlock);
8204
- return lines.join("\n");
8205
- }
8206
8767
  /** Stream a chapter polish (de-AI-ify). Draft-mode: the polished body lands
8207
8768
  * in `chapter.pendingDraft` and is only applied on draft/apply. */
8208
- async function* polishChapterStream(ctx, config, project, outputDir, chapterNo) {
8769
+ async function* polishChapterStream(ctx, config, project, outputDir, chapterNo, signal) {
8209
8770
  const chapter = project.chapters.find((c) => c.no === chapterNo);
8210
8771
  if (chapter === void 0) throw new NovelActionError("contract", `章节 ${chapterNo} 不在计划中`);
8211
8772
  const body = readChapterFile(outputDir, chapter);
@@ -8234,6 +8795,7 @@ async function* polishChapterStream(ctx, config, project, outputDir, chapterNo)
8234
8795
  ctx,
8235
8796
  config,
8236
8797
  request,
8798
+ signal,
8237
8799
  liveLabel: `去 AI 味润色 · 第${chapterNo}章`,
8238
8800
  liveUser: body.slice(0, 2e3),
8239
8801
  maxTokensError: "润色输出达到 maxTokens 上限,请增大配置后重试",
@@ -8254,7 +8816,7 @@ function countHanzi(text) {
8254
8816
  return m === null ? 0 : m.length;
8255
8817
  }
8256
8818
  /** Generate one chapter (streaming). Yields progress frames; persists when done. */
8257
- async function* generateChapterStream(ctx, config, project, outputDir, chapterNo) {
8819
+ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo, signal) {
8258
8820
  const chapter = project.chapters.find((c) => c.no === chapterNo);
8259
8821
  if (chapter === void 0) throw new NovelActionError("contract", `章节 ${chapterNo} 不在计划中`);
8260
8822
  let continuity = "";
@@ -8341,6 +8903,7 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
8341
8903
  ctx,
8342
8904
  config,
8343
8905
  request,
8906
+ signal,
8344
8907
  liveLabel: `正文生成 · 第${chapter.no}章`,
8345
8908
  liveUser: user,
8346
8909
  maxTokensError: "达到 maxTokens 上限,正文可能不完整,请增大 maxTokens 后重试",
@@ -8357,7 +8920,7 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
8357
8920
  `请承接上文继续写约 ${need} 字,不要重复已有内容,不要重写开头,不要总结前文,直接续写新情节,结尾留一个章末钩子。只输出续写的正文,不要输出标题或任何说明。`
8358
8921
  ].join("\n");
8359
8922
  const add = (await complete(ctx, config, {
8360
- system: writeSystemPrompt(project, target, void 0, chapter.no) + "\n\n【续写任务】严格承接已有正文往下写,不要重复已有内容、不要重写开头、不要总结前文,直接续写新情节,并在结尾留一个章末钩子。",
8923
+ system: writeSystemPrompt(project, target, void 0, chapter.no) + bodyContinuationSuffix(),
8361
8924
  user: contUser,
8362
8925
  temperature: .85,
8363
8926
  maxTokens: Math.max(config.maxTokens, 12e3),
@@ -8393,11 +8956,7 @@ async function summarizeChapter(ctx, config, project, outputDir, chapterNo) {
8393
8956
  const body = readChapterFile(outputDir, chapter);
8394
8957
  if (body === void 0) throw new NovelActionError("contract", `章节 ${chapterNo} 的正文文件不存在`);
8395
8958
  chapter.summary = (await complete(ctx, config, {
8396
- system: [
8397
- "你是一位网文编辑。请为下面一章写一段 120-200 字的摘要,供后续章节写作时保持连贯性。",
8398
- "摘要必须包含:本章发生的关键事件、主角状态变化(境界/资源/伤势/心境)、新增的伏笔或线索、角色关系变化。",
8399
- "用客观陈述句,不要评价,不要剧透式感叹。只输出摘要正文。"
8400
- ].join("\n"),
8959
+ system: chapterSummarySystemPrompt(),
8401
8960
  user: stripChapterHeadings(body),
8402
8961
  temperature: .3,
8403
8962
  maxTokens: Math.max(config.maxTokens, 4e3)
@@ -8410,40 +8969,36 @@ async function summarizeChapter(ctx, config, project, outputDir, chapterNo) {
8410
8969
  * 反向推大纲:从已写章节正文反推出全书总纲(分卷 + 章节要点 + 主线/人物弧线/伏笔清单)。
8411
8970
  * 两阶段:分批提取章节事件摘要 → 汇总生成大纲。不修改章节/设定,只返回大纲文本。
8412
8971
  */
8413
- async function reverseOutlineFromChapters(ctx, config, project, outputDir, onProgress) {
8972
+ async function reverseOutlineFromChapters(ctx, config, project, outputDir, onProgress, signal) {
8414
8973
  const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating" && c.status !== "error").filter((c) => readChapterFile(outputDir, c) !== void 0).sort((a, b) => a.no - b.no);
8415
8974
  if (written.length === 0) throw new NovelActionError("contract", "本书还没有已写章节,无法反推大纲");
8416
8975
  const BATCH = 10;
8417
8976
  const notes = [];
8418
8977
  const total = written.length;
8419
8978
  for (let i = 0; i < written.length; i += BATCH) {
8979
+ const bodies = written.slice(i, i + BATCH).map((c) => {
8980
+ const body = readChapterFile(outputDir, c) ?? "";
8981
+ return "第" + c.no + "章《" + (c.title || "无题") + "》\n" + stripChapterHeadings(body).slice(0, 1e3);
8982
+ }).join("\n\n---\n\n");
8420
8983
  const note = await complete(ctx, config, {
8421
- system: "你是一位网文编辑。下面是一本书若干章正文的节选。请为每一章输出一行「事件摘要」,格式严格为:第N章《标题》:关键事件+主角状态变化+新增伏笔或线索。每章恰好一行,不要空行,不要评价,不要输出其他内容。",
8422
- user: written.slice(i, i + BATCH).map((c) => {
8423
- const body = readChapterFile(outputDir, c) ?? "";
8424
- return "第" + c.no + "章《" + (c.title || "无题") + "》\n" + stripChapterHeadings(body).slice(0, 1e3);
8425
- }).join("\n\n---\n\n"),
8984
+ system: reverseOutlineBatchSystemPrompt(),
8985
+ user: bodies,
8426
8986
  temperature: .2,
8427
8987
  maxTokens: Math.max(config.maxTokens, 3e3),
8428
- reasoning: config.analysisReasoning ?? "low"
8988
+ reasoning: config.analysisReasoning ?? "low",
8989
+ signal
8429
8990
  });
8430
8991
  notes.push(note.trim());
8431
8992
  onProgress?.(Math.min(i + BATCH, total), total, "章节摘要");
8432
8993
  }
8433
8994
  onProgress?.(total, total, "生成大纲");
8434
8995
  const outline = await complete(ctx, config, {
8435
- system: [
8436
- "你是一位经验丰富的小说主编。根据下面全书各章事件摘要,反推出这本书的总纲(大纲),可直接作为后续写作依据。要求:",
8437
- "1. 第一行写《书名》(从摘要中的书名或内容推断,若无则用《未命名》)。",
8438
- "2. 按故事弧线划分卷/部分:每卷给出卷名与主旨(标注覆盖章节范围)。",
8439
- "3. 每一章列出:章节号 + 标题 + 一句话核心情节(若原章无标题可自拟)。",
8440
- "4. 最后给出:全书主线、主要人物弧线、已埋设待回收的伏笔清单。",
8441
- "5. 输出为纯文本 Markdown 结构(# 一级标题、## 二级标题、- 列表),不要多余寒暄。"
8442
- ].join("\n"),
8996
+ system: reverseOutlineSystemPrompt(),
8443
8997
  user: notes.join("\n\n"),
8444
8998
  temperature: .4,
8445
8999
  maxTokens: Math.max(config.maxTokens, 6e3),
8446
- reasoning: config.analysisReasoning ?? "low"
9000
+ reasoning: config.analysisReasoning ?? "low",
9001
+ signal
8447
9002
  });
8448
9003
  onProgress?.(total, total, "完成");
8449
9004
  return outline.trim();
@@ -8469,16 +9024,8 @@ async function analyzeAdaptation(ctx, config, text) {
8469
9024
  }
8470
9025
  if (n > 1) pick(n - 1);
8471
9026
  const raw = parseJsonObject(await complete(ctx, config, {
8472
- system: "你是改编策划分析助手。",
8473
- user: [
8474
- "你是一位资深网文编辑兼改编策划。下面给你一部已完结/连载小说的若干章正文节选。请通读并输出该书的「原文设定卡片」与「可改范围矩阵」。",
8475
- "输出合法 JSON 对象:",
8476
- "{\"bookName\": \"书名\", \"outline\": \"一句话主线梗概(100字内)\", \"dimensions\": [{\"key\":\"realm\",\"title\":\"大世界\",\"mutability\":\"big|small|free|locked|visual\",\"current\":\"当前值\",\"evidence\":\"证据(出现章节/频次)\",\"candidates\":[{\"name\":\"候选体系名\",\"desc\":\"该体系一句话说明\"}],\"impact\":\"改了会影响什么\",\"risk\":\"high|medium|low\"}]}",
8477
- "dimensions 至少覆盖以下维度(key/title):realm 大世界、cultivation 修为体系、protagonist 主角、goldenFinger 金手指、supporting 配角与势力人物名、faction 势力/组织、style 文风与叙事、ending 结局走向、timeline 时间线/编年、foreshadow 伏笔/暗线。",
8478
- "mutability 取值:locked=建议保留、big=可改影响大、small=可改影响小、free=可自由改、visual=仅视觉包装。",
8479
- "每个 dimension.current 必须忠于文本,能引用原文就用原文(尤其角色名/境界名/势力名/金手指名)。",
8480
- "重要:所有字符串值内部不得包含换行符;JSON 必须在一段内完整结束;直接输出 JSON,不要 Markdown 代码块。"
8481
- ].join("\n") + "\n\n" + sampleBodies.join("\n\n---\n\n"),
9027
+ system: adaptationAnalyzeSystemPrompt(),
9028
+ user: adaptationAnalyzeUser(sampleBodies),
8482
9029
  temperature: .3,
8483
9030
  maxTokens: Math.max(config.maxTokens, 6e3),
8484
9031
  reasoning: config.analysisReasoning ?? "low"
@@ -8551,15 +9098,7 @@ async function reverseOutlineFromAdaptationText(ctx, config, chapters) {
8551
9098
  sample.push("第" + c.no + "章《" + (c.title || "无题") + "》\n" + c.body.slice(0, 600));
8552
9099
  }
8553
9100
  return (await complete(ctx, config, {
8554
- system: [
8555
- "你是一位经验丰富的小说主编。根据下面若干章节的正文节选,反推出这本书的总纲(可作为后续改编与章节续写的骨架)。",
8556
- "要求:",
8557
- "1. 第一行写《书名》(可从正文推断,否则《未命名》)。",
8558
- "2. 按故事弧线划分卷/部分:每卷给卷名与主旨(标注覆盖章节范围)。",
8559
- "3. 对每章列出:章节号 + 标题 + 一句话核心情节。",
8560
- "4. 最后给出:全书主线、主要人物弧线、已埋设待回收的伏笔清单。",
8561
- "5. 输出纯文本 Markdown 结构(# 一级标题、## 二级标题、- 列表),不要寒暄,不要其他输出。"
8562
- ].join("\n"),
9101
+ system: reverseOutlineFromAdaptationSystemPrompt(),
8563
9102
  user: sample.join("\n\n---\n\n"),
8564
9103
  temperature: .4,
8565
9104
  maxTokens: Math.max(config.maxTokens, 6e3),
@@ -8568,23 +9107,11 @@ async function reverseOutlineFromAdaptationText(ctx, config, chapters) {
8568
9107
  }
8569
9108
  /** 改编方案:由用户勾选的维度与新值,生成 LLM 映射表/规则/影响清单。 */
8570
9109
  async function proposeAdaptation(ctx, config, _text, selections, dimensions) {
9110
+ const selLines = selections.map((s) => "- " + s.title + "(" + s.key + "):" + s.current + " → " + s.target).join("\n");
9111
+ const dimLines = (dimensions ?? []).map((d) => "- " + d.title + ":" + d.current + "(可改度:" + d.mutability + ",风险:" + d.risk + ")").join("\n");
8571
9112
  const raw = parseJsonObject(await complete(ctx, config, {
8572
- system: "你是改编策划。",
8573
- user: [
8574
- "下面给出改编决策:用户想改哪些维度、改成什么值。请生成一份可执行的「改编方案」。",
8575
- "要求输出合法 JSON 对象:",
8576
- "{\"mappings\": [{\"source\":\"原值\",\"target\":\"新值\",\"scope\":\"name|realm|faction|term|other\",\"note\":\"说明\"}], \"rules\": {\"preserve\":[\"必须保留的要素\"],\"change\":[\"允许改变的要素\"],\"constraints\":[\"改编红线/一致性要求\"]}, \"impacts\": [{\"item\":\"受影响项\",\"detail\":\"说明\",\"risk\":\"high|medium|low\",\"chapters\":[章号]}]}",
8577
- "mappings 需把用户确认的新值展开为「原→新」条目(如主角名/境界名/势力名/术语),并补充用户未填但关联的必改项(如改了修为体系名,相关的境界名一并列映射)。",
8578
- "rules.preserve 至少包含:故事骨架、人物动机、伏笔逻辑、爽点结构。",
8579
- "impacts 列出每个改动会影响的内容(术语/角色/章节/伏笔),能定位章号尽量定位;无法定位则给章节区间提示。",
8580
- "重要:所有字符串值内部不得包含换行符;JSON 必须在一段内完整结束;直接输出 JSON,不要 Markdown 代码块。",
8581
- "",
8582
- "用户要改:",
8583
- selections.map((s) => "- " + s.title + "(" + s.key + "):" + s.current + " → " + s.target).join("\n"),
8584
- "",
8585
- "原文可改矩阵(已分析):",
8586
- (dimensions ?? []).map((d) => "- " + d.title + ":" + d.current + "(可改度:" + d.mutability + ",风险:" + d.risk + ")").join("\n")
8587
- ].join("\n"),
9113
+ system: adaptationProposeSystemPrompt(),
9114
+ user: adaptationProposeUser(selLines, dimLines),
8588
9115
  temperature: .3,
8589
9116
  maxTokens: Math.max(config.maxTokens, 6e3),
8590
9117
  reasoning: config.analysisReasoning ?? "low"
@@ -8684,16 +9211,7 @@ async function rewriteAdaptationBook(ctx, config, text, mappings, rules, options
8684
9211
  rules.change.length > 0 ? "允许改变:\n" + rules.change.map((x) => `- ${x}`).join("\n") : "",
8685
9212
  rules.constraints.length > 0 ? "改编红线/一致性要求:\n" + rules.constraints.map((x) => `- ${x}`).join("\n") : ""
8686
9213
  ].filter((s) => s !== "").join("\n") : "";
8687
- const system = [
8688
- "你是一位资深网文改编编剧。你会收到某一章的正文,以及本书的改编映射表与改编规则。",
8689
- "任务:把这一章按改编方案**重写**成新版本(结构性改编,不只是换词)。",
8690
- "要求:",
8691
- "1. 严格遵守映射表(原值→新值),正文中所有源值都要替换为新值;涉及改名/改体系/改势力时,相关表述一起调整,使上下文自洽。",
8692
- "2. 改编规则:必须保留的内容不得破坏(故事骨架/人物动机/伏笔逻辑/爽点结构);允许改变的内容可以放开调整;红线/一致性要求必须遵守。",
8693
- "3. 若某个改动牵动叙事(如结局走向/时间线/世界观),要把这章的叙述顺势改得通顺、可信。",
8694
- "4. 输出**只包含这一章重写后的正文**,不要重复标题,不要任何解释、开头或结尾。",
8695
- "5. 字数与原章基本相当(允许 ±20%)。"
8696
- ].join("\n");
9214
+ const system = adaptationRewriteSystemPrompt();
8697
9215
  const adaptedParts = [];
8698
9216
  const rewritten = [];
8699
9217
  const skipped = [];
@@ -8712,12 +9230,19 @@ async function rewriteAdaptationBook(ctx, config, text, mappings, rules, options
8712
9230
  ].filter((s) => s !== "").join("\n\n");
8713
9231
  let body = "";
8714
9232
  try {
9233
+ if (options.signal?.aborted === true) return {
9234
+ adaptedText: adaptedParts.join("\n"),
9235
+ rewritten,
9236
+ skipped,
9237
+ hits
9238
+ };
8715
9239
  body = stripRewriteHeading(await complete(ctx, config, {
8716
9240
  system,
8717
9241
  user,
8718
9242
  temperature: .7,
8719
9243
  maxTokens: Math.max(config.maxTokens, Math.min(16e3, c.body.length * 3)),
8720
- reasoning: config.analysisReasoning ?? "low"
9244
+ reasoning: config.analysisReasoning ?? "low",
9245
+ signal: options.signal
8721
9246
  }));
8722
9247
  if (body.length < 50) body = "";
8723
9248
  } catch {
@@ -8934,12 +9459,7 @@ async function summarizeAndExtractFacts(ctx, config, project, outputDir, chapter
8934
9459
  factCount: 0
8935
9460
  };
8936
9461
  const raw = parseJsonObject(await complete(ctx, config, {
8937
- system: [
8938
- "你是一位网文编辑。请为下面一章做两件事,输出合法 JSON 对象:",
8939
- "{\"summary\": \"120-200字摘要,含关键事件/主角状态变化(境界资源伤势心境)/新增伏笔线索/角色关系变化,客观陈述不评价\", \"facts\": [\"已确立事实1\", \"…3-6条\"]}",
8940
- "facts 指:本章明确写出的、对后续有约束力的事实——人物当前状态、重要关系变化、地点与时间线、已落地或新增的伏笔线索、关键道具去向。",
8941
- "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
8942
- ].join("\n"),
9462
+ system: summaryFactsSystemPrompt(),
8943
9463
  user: stripChapterHeadings(body),
8944
9464
  temperature: .2,
8945
9465
  maxTokens: Math.max(config.maxTokens, 5e3)
@@ -8989,20 +9509,7 @@ async function summarizeFactsAndTimeline(ctx, config, project, outputDir, chapte
8989
9509
  const wantTimeline = options.timeline !== false;
8990
9510
  const NL = String.fromCharCode(10);
8991
9511
  const raw = parseJsonObject(await complete(ctx, config, {
8992
- system: [
8993
- "你是一位网文编辑。请为下面一章做三件事,输出**一个**合法 JSON 对象:",
8994
- "{\"summary\": \"120-200字摘要,含关键事件/主角状态变化(境界资源伤势心境)/新增伏笔线索/角色关系变化,客观陈述不评价\",",
8995
- " \"facts\": [\"已确立事实1\", \"…3-6条\"],",
8996
- ...wantTimeline ? [" \"timeline\": [{\"time\":\"第三日黄昏\",\"order\":1,\"place\":\"青云宗外门\",\"characters\":[\"沈青\"],\"event\":\"不超过40字\"}]"] : [],
8997
- "",
8998
- "facts 指:本章明确写出的、对后续有约束力的事实——人物当前状态、重要关系变化、地点与时间线、已落地或新增的伏笔线索、关键道具去向。",
8999
- ...wantTimeline ? [
9000
- "timeline 指:只抽取正文里真实发生的场景节点(3-8 条),不要抽预告、回忆性泛泛陈述;纯粹的回忆/插叙请在 event 里注明「(回忆)」并给出其故事内时间。",
9001
- "time 用正文里的说法(如「第三日黄昏」「入宗三个月后」「同日夜」);正文没写时间就填相对说法(如「紧接上一场景」)。",
9002
- "order 是本章内的先后序号,从 1 递增;place 是一处一个词;characters 只列在场的角色名(用正文里的名字,不要写「主角」)。"
9003
- ] : [],
9004
- "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束,不要输出 Markdown 代码块标记。"
9005
- ].join(NL),
9512
+ system: summaryFactsTimelineSystemPrompt(wantTimeline),
9006
9513
  user: `第 ${chapterNo} 章《${chapter.title}》正文:${NL}${NL}${stripChapterHeadings(body).slice(0, 24e3)}`,
9007
9514
  temperature: .2,
9008
9515
  maxTokens: options.maxTokens ?? Math.max(config.maxTokens, wantTimeline ? 8e3 : 5e3),
@@ -9068,14 +9575,7 @@ async function extractFacts(ctx, config, project, outputDir, chapterNo) {
9068
9575
  const body = readChapterFile(outputDir, chapter);
9069
9576
  if (body === void 0) return 0;
9070
9577
  const lines = (await complete(ctx, config, {
9071
- system: [
9072
- "你是一位网文编辑。请从本章正文中抽取「已确立事实」,供后续章节保持一致。",
9073
- "事实指:人物当前状态(境界/修为/伤势/资源/心境)、重要关系变化、地点与时间线、已落地或新增的伏笔线索、关键道具去向。",
9074
- "要求:",
9075
- "1. 只抽取本章明确写出的、对后续有约束力的内容;纯心理活动与无关细节不要。",
9076
- "2. 每行一条事实,用客观陈述句,不含主观评价。",
9077
- "3. 输出 3-6 条,每行一条,不要编号、不要前缀、不要解释。"
9078
- ].join("\n"),
9578
+ system: extractFactsSystemPrompt(),
9079
9579
  user: stripChapterHeadings(body),
9080
9580
  temperature: .2,
9081
9581
  maxTokens: Math.max(config.maxTokens, 4e3)
@@ -9094,19 +9594,7 @@ async function extractFacts(ctx, config, project, outputDir, chapterNo) {
9094
9594
  const AUDIT_BATCH_SIZE = 10;
9095
9595
  /** 单批质检:设定 + 事实库 + 该批章节节选 → 矛盾清单。 */
9096
9596
  async function auditBatch(ctx, config, project, outputDir, batch) {
9097
- const system = [
9098
- "你是一位严谨的网文连续性审校编辑。你会收到一本小说的道藏、事实库和一批章节正文节选。",
9099
- "请找出这批章节中的一致性矛盾,例如:",
9100
- "- 人物状态冲突:境界/修为/伤势/资源在同一章内或跨章前后矛盾。",
9101
- "- 设定违背:正文与世界观规则、金手指规则、写作红线冲突。",
9102
- "- 时间线错乱:事件顺序、时间跨度、地点移动不合逻辑。",
9103
- "- 细节穿帮:人名/地名/物品/数字前后不一致。",
9104
- "要求:",
9105
- "1. 只报告有实质证据的矛盾,不要泛泛而谈写作质量问题。",
9106
- "2. 每条必须定位到具体章节号。",
9107
- "3. 输出必须是合法 JSON 数组,格式:[{\"chapterNo\": 章节号, \"severity\": \"high|medium|low\", \"item\": \"矛盾描述\", \"suggestion\": \"修改建议\"}]",
9108
- "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
9109
- ].join("\n");
9597
+ const system = auditBatchSystemPrompt();
9110
9598
  const factsBlock = (project.facts ?? []).slice(-60).map((f) => `[第${f.chapterNo}章] ${f.text}`).join("\n");
9111
9599
  const chapterBlocks = batch.map((c) => {
9112
9600
  const excerpt = stripChapterHeadings(readChapterFile(outputDir, c) ?? "");
@@ -9169,13 +9657,7 @@ async function auditBook(ctx, config, project, outputDir, onProgress) {
9169
9657
  }
9170
9658
  /** 小说简介:生成或按已写开头补全(面向读者的作品门面)。 */
9171
9659
  async function generateBlurb(ctx, config, project, partial = "") {
9172
- const system = [
9173
- "你是一位网文平台编辑,擅长写抓人的作品简介。",
9174
- "要求:",
9175
- "1. 120-250 字,突出核心卖点(金手指/题材/爽点/人设反差),用一两句抛出开局钩子。",
9176
- "2. 不剧透结局与关键反转;语气贴合题材(热血/悬疑/轻松/虐心)。",
9177
- "3. 中文,直接输出简介正文,不要 Markdown、不要引号包裹、不要「简介:」前缀。"
9178
- ].join("\n");
9660
+ const system = blurbSystemPrompt();
9179
9661
  const genreBlock = project.bible?.genre !== void 0 ? `题材:${project.bible.genre}` : "";
9180
9662
  const volumeBlock = (project.volumes ?? []).slice(0, 3).map((v) => v.title).join("、");
9181
9663
  return (await complete(ctx, config, {
@@ -9265,12 +9747,7 @@ function bookOverview(project, scope = "recent") {
9265
9747
  * 定位一次改动波及的所有位置。助手在修改后主动调用,做连锁维护。
9266
9748
  */
9267
9749
  async function analyzeImpact(ctx, config, project, outputDir, change) {
9268
- const system = [
9269
- "你是一位网文一致性审校。作者要做一处修改,请找出这次改动会波及的所有位置(设定、大纲、已写章节正文、事实库、简介中可能因此过时或矛盾的内容)。",
9270
- "输出必须是合法 JSON 数组,格式:[{\"location\": \"位置(第N章/大纲/道藏-世界规则/大世界-境界/事实库/简介)\", \"quote\": \"原文片段(20-60字)\", \"suggestion\": \"修改建议\", \"kind\": \"must|optional|note\"}]",
9271
- "kind 含义:must=必须同步改否则矛盾;optional=建议改(影响观感);note=备注(如旧称保留为古称、或无需改但需知晓)。",
9272
- "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
9273
- ].join("\n");
9750
+ const system = impactAnalysisSystemPrompt();
9274
9751
  const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
9275
9752
  const base = [
9276
9753
  `要做的修改:${change}`,
@@ -9309,38 +9786,9 @@ async function analyzeImpact(ctx, config, project, outputDir, change) {
9309
9786
  }
9310
9787
  return items.slice(0, 30);
9311
9788
  }
9312
- /** 把大世界结构化数据渲染成提示词块(境界体系按顺序强约束)。 */
9313
- function renderWorld(world) {
9314
- if (world === void 0) return "";
9315
- const sections = ["==================== 大世界(结构化设定,写作时严格遵守) ===================="];
9316
- if (world.realms.length > 0) {
9317
- sections.push("境界体系(由低到高,不得随意跳级或自创境界):");
9318
- world.realms.forEach((realm, i) => {
9319
- sections.push(`${i + 1}. ${realm.name}${realm.description !== "" ? ` — ${realm.description}` : ""}`);
9320
- });
9321
- }
9322
- if (world.regions.length > 0) {
9323
- sections.push("地理区域:");
9324
- for (const region of world.regions) sections.push(`- ${region.name}${region.description !== "" ? `:${region.description}` : ""}${region.faction !== void 0 && region.faction !== "" ? `(势力:${region.faction})` : ""}`);
9325
- }
9326
- if (world.factions.length > 0) {
9327
- sections.push("势力分布:");
9328
- for (const faction of world.factions) sections.push(`- ${faction.name}(${faction.kind})${faction.description !== "" ? `:${faction.description}` : ""}${faction.region !== void 0 && faction.region !== "" ? `(驻地:${faction.region})` : ""}`);
9329
- }
9330
- return sections.join("\n");
9331
- }
9332
9789
  /** 提炼大世界:从大纲 + 道藏生成结构化境界体系/区域/势力。 */
9333
9790
  async function extractWorld(ctx, config, project) {
9334
- const system = [
9335
- "你是一位网文世界观架构师。请根据小说大纲与道藏,提炼结构化「大世界」数据。",
9336
- "输出必须是合法 JSON 对象:",
9337
- "{\"realms\": [{\"name\": \"境界名\", \"description\": \"突破条件/寿命/标志等\"}], \"regions\": [{\"name\": \"区域名\", \"description\": \"描述\", \"faction\": \"关联势力名或空\"}], \"factions\": [{\"name\": \"势力名\", \"kind\": \"宗门/家族/王朝/组织等\", \"description\": \"描述\", \"region\": \"驻地区域或空\"}]}",
9338
- "要求:",
9339
- "1. realms 按由低到高顺序排列(修仙题材必须含完整境界链;无境界设定的题材可输出空数组)。",
9340
- "2. 数量贴合大纲:realms 3-12 个,regions 2-10 个,factions 2-10 个。",
9341
- "3. 内容严格来自大纲与道藏,不要凭空发明与大纲冲突的设定。",
9342
- "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
9343
- ].join("\n");
9791
+ const system = extractWorldSystemPrompt();
9344
9792
  const bibleBlock = project.bible !== void 0 ? [project.bible.genre !== "" ? `题材:${project.bible.genre}` : "", project.bible.worldRules.length > 0 ? `世界规则:\n${project.bible.worldRules.map((r) => `- ${r}`).join("\n")}` : ""].filter((s) => s !== "").join("\n") : "";
9345
9793
  const raw = parseJsonObject(await complete(ctx, config, {
9346
9794
  system,
@@ -9422,11 +9870,7 @@ async function refreshCharacters(ctx, config, project, outputDir) {
9422
9870
  }
9423
9871
  let statuses = /* @__PURE__ */ new Map();
9424
9872
  if (facts.length > 0) {
9425
- const system = [
9426
- "你是一位网文角色档案管理员。请根据「角色名单」与「已确立事实库」,为每个角色输出「当前状态」一句话(境界/修为/伤势/资源/心境)。",
9427
- "输出必须是合法 JSON 数组,格式:[{\"name\": \"角色名\", \"status\": \"当前状态一句话\"}]",
9428
- "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
9429
- ].join("\n");
9873
+ const system = roleStatusSystemPrompt();
9430
9874
  const rosterBlock = roster.map((ch) => `- ${ch.name}(${ch.traits.join("、")})`).join("\n");
9431
9875
  const factsBlock = facts.map((f) => `[第${f.chapterNo}章] ${f.text}`).join("\n");
9432
9876
  const user = [
@@ -9468,19 +9912,6 @@ async function refreshCharacters(ctx, config, project, outputDir) {
9468
9912
  });
9469
9913
  return cards;
9470
9914
  }
9471
- /** System prompt for foreshadow suggestions. */
9472
- function foreshadowSystemPrompt() {
9473
- return [
9474
- "你是一位网文伏笔设计师。你会收到大纲和已写的章节信息,请为小说建议 3-8 条值得埋设的伏笔。",
9475
- "要求:",
9476
- "1. 伏笔必须有明确的回收价值(推动主线、人物弧光、世界观揭秘)。",
9477
- "2. 描述要具体,指出埋设章节与预计回收章节(可空缺)。",
9478
- "3. 优先从大纲的暗线(如记忆代价、残片收集、身世谜团)中提炼。",
9479
- "输出必须是合法 JSON 数组:",
9480
- "[{\"description\": \"伏笔描述\", \"plantedChapter\": 章节号或null, \"targetChapter\": 章节号或null}]",
9481
- "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
9482
- ].join("\n");
9483
- }
9484
9915
  /** Suggest foreshadows from the outline + plan. */
9485
9916
  async function suggestForeshadows(ctx, config, project) {
9486
9917
  const user = [
@@ -9570,18 +10001,6 @@ async function extractStyleFormula(ctx, config, sampleText, depth) {
9570
10001
  applyGuidance: typeof raw.applyGuidance === "string" ? raw.applyGuidance.trim().slice(0, 300) : ""
9571
10002
  };
9572
10003
  }
9573
- /** 市场雷达系统提示(对齐上游 marketRadar 分析师角色)。 */
9574
- function marketRadarSystemPrompt() {
9575
- return [
9576
- "你是中文网络文学市场分析师。只分析输入中的公开榜单元数据或用户给出的市场线索,不补写作品正文,不假装知道未提供的信息。",
9577
- "只根据输入归纳热门题材和市场信号;对未提供的信息不要臆测。",
9578
- "重点分析:热门题材组合、主角身份、金手指机制、开篇危机、关系卖点、标题句式、拥挤套路和差异化机会。",
9579
- "kind 只能使用 genre、protagonist、advantage、opening、relationship、title_pattern、opportunity、crowding,不得创造近义枚举值。",
9580
- "榜单高频不等于适合照搬。机会建议必须说明读者满足点,同时避开直接复制具体作品。",
9581
- "productionFoundation 的题材/推进模式优先引用「资源库」中已有的 existingId(名称一字不差);只有确无合适资产时才给出新资产(existingId 为 null)。",
9582
- "creativeBrief 严禁复用榜单作品的人名、专有设定、简介句子和完整书名;只能提炼读者需求、爽点机制和结构机会,不得输出任何具体人名/作品名,一律使用身份或通用场景称谓。"
9583
- ].join("\n");
9584
- }
9585
10004
  /** 题材雷达:输入平台/题材/榜单文本 → 信号 + 生产底座 + 开书创意。 */
9586
10005
  async function runMarketRadar(ctx, config, req) {
9587
10006
  const genreCatalog = BUILTIN_GENRE_LIBRARY.map((g, i) => `- [${i}] ${g.name}:${g.description}${g.template !== void 0 ? `(写法:${g.template})` : ""}`).join("\n");
@@ -9694,7 +10113,7 @@ async function runMarketCreativeBrief(ctx, config, req) {
9694
10113
  /** 书分析/拆书:输入一本书/章节文本 → 卖点/结构/可借鉴点/风险。 */
9695
10114
  async function runBookAnalysis(ctx, config, req) {
9696
10115
  const raw = parseJsonObject(await complete(ctx, config, {
9697
- system: ["你是资深中文网文拆书编辑。分析输入的一本书或章节文本,提炼可复用的创作思路。", "严禁照搬具体作品的人名/专有设定/简介句子;只能提炼读者需求、结构手法和风险。"].join("\n"),
10116
+ system: bookAnalysisSystemPrompt(),
9698
10117
  user: [
9699
10118
  "请分析下面文本:\n" + (req.text ?? "").slice(0, 8e3),
9700
10119
  "输出必须是合法 JSON 对象。",
@@ -9716,7 +10135,7 @@ async function runBookAnalysis(ctx, config, req) {
9716
10135
  async function runIdeaInspiration(ctx, config, req) {
9717
10136
  const count = Math.max(1, Math.min(10, req.count ?? 5));
9718
10137
  const raw = parseJsonObject(await complete(ctx, config, {
9719
- system: ["你是中文网文开书灵感策划。根据用户一句话/题材方向,给出多个可开书的差异化创意。", "严禁输出具体人名/作品名;用身份/通用称谓。每个创意都要有明确钩子、题材、视角、长期兑现。"].join("\n"),
10138
+ system: ideaInspirationSystemPrompt(),
9720
10139
  user: [
9721
10140
  `我的方向:${req.idea ?? ""}`,
9722
10141
  `请给 ${count} 个开书创意。`,
@@ -9740,7 +10159,7 @@ async function runIdeaInspiration(ctx, config, req) {
9740
10159
  /** 雷达→灵感:基于市场信号/生产底座/创意简报,生成贴合市场的开书灵感。 */
9741
10160
  async function runMarketIdeaInspiration(ctx, config, req) {
9742
10161
  const count = Math.max(1, Math.min(10, req.count ?? 5));
9743
- const system = ["你是中文网文开书灵感策划,擅长从市场榜单分析中提炼可开书方向。", "严禁输出具体人名/作品名;用身份/通用称谓。每个创意都要有明确钩子、题材、视角、长期兑现,且要与市场分析呼应但做差异化。"].join("\n");
10162
+ const system = marketIdeaInspirationSystemPrompt();
9744
10163
  const signalBlock = (req.signals ?? []).map((s) => `- [${s.kind}] ${s.title}:${s.detail}`).join("\n") || "(无)";
9745
10164
  const f = req.foundation;
9746
10165
  const foundationBlock = f !== void 0 ? `题材:${f.genre.name}(${f.genre.description})\n主推进:${f.primaryStoryMode.name}(${f.primaryStoryMode.driver})${f.secondaryStoryMode !== void 0 ? `\n副推进:${f.secondaryStoryMode.name}` : ""}` : "(无)";
@@ -9779,22 +10198,23 @@ async function runDirectorAdvice(ctx, config, project, req) {
9779
10198
  const plotlinesBlock = (project.plotlines ?? []).filter((l) => l.status === "active" || l.status === "paused").map((l) => `[${l.kind}] ${l.name}:${l.goal}${l.progress ? `(${l.progress})` : ""}`).join("\n") || "无";
9780
10199
  const foreBlock = (project.foreshadows ?? []).filter((f) => f.status === "planted" || f.status === "progressing").map((f) => `- ${f.description}${f.targetChapter ? `(约${f.targetChapter}章回收)` : ""}`).join("\n") || "无";
9781
10200
  const recentFacts = (project.facts ?? []).slice(-8).map((f) => `[第${f.chapterNo}章] ${f.text}`).join("\n");
10201
+ const user = [
10202
+ `书名:《${project.bookName}》`,
10203
+ req.focus !== void 0 && req.focus !== "" ? `聚焦:${req.focus}` : "",
10204
+ `当前进度:已写 ${done.length} 章,最后一章《${last?.title ?? "无"}》摘要:${last?.summary ?? ""}`,
10205
+ "",
10206
+ `分卷:\n${volumesBlock}`,
10207
+ `剧情线:\n${plotlinesBlock}`,
10208
+ `活跃伏笔:\n${foreBlock}`,
10209
+ `最近事实:\n${recentFacts}`,
10210
+ "",
10211
+ "请作为本书的自动导演,给出下一卷/下一阶段的编排建议:续写方向、阶段弧光、节奏板、风险提示、需要修复/再平衡的点。",
10212
+ "输出必须是合法 JSON 对象。",
10213
+ "JSON 结构:{\"summary\":\"总体判断一句话\",\"nextArc\":[\"下一阶段关键剧情节点(每条约一句话)\"],\"pacing\":\"节奏板(起承转合/爽点密度/章节节奏)\",\"risks\":[\"风险/跑偏提示\"],\"fixes\":[\"需要修复/再平衡的点\"]}"
10214
+ ].filter((s) => s !== "").join("\n\n");
9782
10215
  const raw = parseJsonObject(await complete(ctx, config, {
9783
- system: "你是一位长篇网文自动导演,负责整本编排、节奏、风险与再平衡。只给出可执行建议,不输出正文。",
9784
- user: [
9785
- `书名:《${project.bookName}》`,
9786
- req.focus !== void 0 && req.focus !== "" ? `聚焦:${req.focus}` : "",
9787
- `当前进度:已写 ${done.length} 章,最后一章《${last?.title ?? "无"}》摘要:${last?.summary ?? ""}`,
9788
- "",
9789
- `分卷:\n${volumesBlock}`,
9790
- `剧情线:\n${plotlinesBlock}`,
9791
- `活跃伏笔:\n${foreBlock}`,
9792
- `最近事实:\n${recentFacts}`,
9793
- "",
9794
- "请作为本书的自动导演,给出下一卷/下一阶段的编排建议:续写方向、阶段弧光、节奏板、风险提示、需要修复/再平衡的点。",
9795
- "输出必须是合法 JSON 对象。",
9796
- "JSON 结构:{\"summary\":\"总体判断一句话\",\"nextArc\":[\"下一阶段关键剧情节点(每条约一句话)\"],\"pacing\":\"节奏板(起承转合/爽点密度/章节节奏)\",\"risks\":[\"风险/跑偏提示\"],\"fixes\":[\"需要修复/再平衡的点\"]}"
9797
- ].filter((s) => s !== "").join("\n\n"),
10216
+ system: directorSystemPrompt(),
10217
+ user,
9798
10218
  temperature: .4,
9799
10219
  maxTokens: Math.max(config.maxTokens, 4e3),
9800
10220
  liveLabel: "自动导演"
@@ -9828,18 +10248,7 @@ async function extractTimelineForChapter(ctx, config, project, outputDir, chapte
9828
10248
  if (body === void 0) throw new NovelActionError("contract", `章节 ${chapterNo} 的正文文件不存在`);
9829
10249
  const NL = String.fromCharCode(10);
9830
10250
  const text = await complete(ctx, config, {
9831
- system: [
9832
- "你是小说连续性编辑。任务:把一章正文压缩成「故事时间线」事件,供后续章节保持时间与地点的连续性。",
9833
- "要求:",
9834
- "1. 只抽取**正文里真实发生**的场景节点(3-8 条),不要抽取预告、回忆性的泛泛陈述;纯粹的回忆/插叙请在 event 里注明「(回忆)」并给出其故事内时间。",
9835
- "2. time 用正文里的说法(如「第三日黄昏」「入宗三个月后」「同日夜」);正文没写时间就填相对说法(如「紧接上一场景」)。",
9836
- "3. order 是本章内的先后序号,从 1 递增;跨章我会用章号排序,你只需要保证章内顺序正确。",
9837
- "4. place 是场景地点(一处一个词,如「青云宗外门」);characters 只列**在场**的角色名(用正文里的名字,不要写「主角」)。",
9838
- "5. event 一句话说清发生了什么(不超过 40 字)。",
9839
- "输出必须是合法 JSON 数组,不要输出任何其他文字或 Markdown 代码块标记:",
9840
- "[{\"time\":\"第三日黄昏\",\"order\":1,\"place\":\"青云宗外门\",\"characters\":[\"沈青\"],\"event\":\"沈青被外门弟子当众羞辱\"}]",
9841
- "重要:字符串值内部不得包含换行符;JSON 必须在一段内完整结束。"
9842
- ].join("@NL@").replace("@NL@", NL),
10251
+ system: timelineExtractSystemPrompt(),
9843
10252
  user: `第 ${chapterNo} 章《${chapter.title}》正文:@NL@@NL@${stripChapterHeadings(body).slice(0, 24e3)}`.replace("@NL@", NL),
9844
10253
  temperature: .2,
9845
10254
  maxTokens: Math.max(config.maxTokens, 4e3),
@@ -9882,15 +10291,7 @@ async function reviewTimeline(ctx, config, project) {
9882
10291
  return "- " + parts.join(" ");
9883
10292
  });
9884
10293
  const text = await complete(ctx, config, {
9885
- system: [
9886
- "你是小说连续性编辑,只做一件事:找**时间线**上的矛盾。",
9887
- "检查项:1) 时间倒流(后一章早于前一章且没有明确插叙标注);2) 地点瞬移(同一人物无移动交代就换场景);",
9888
- "3) 时间跨度与事件量明显不匹配(如「一日之内」发生跨越数月的剧情);4) 季节/年龄/节日前后矛盾;5) 回忆与现实混淆。",
9889
- "只报**能在给定事件表里指认**的问题,不要臆测;没有问题就返回空数组。",
9890
- "输出必须是合法 JSON 数组,不要任何其他文字:",
9891
- "[{\"severity\":\"high|medium|low\",\"chapters\":[3,7],\"item\":\"问题一句话\",\"suggestion\":\"怎么修\"}]",
9892
- "重要:字符串值内部不得包含换行符。"
9893
- ].join(NL),
10294
+ system: timelineReviewSystemPrompt(),
9894
10295
  user: `故事时间线(按章节顺序):@NL@${lines.join(NL)}`.replace("@NL@", NL),
9895
10296
  temperature: .2,
9896
10297
  maxTokens: Math.max(config.maxTokens, 4e3),
@@ -10532,15 +10933,19 @@ function revisionTodoText(item) {
10532
10933
  /**
10533
10934
  * 选出生产单下一章要处理的章。
10534
10935
  *
10535
- * 三种章:已 `approved` 快进;连败达上限的熔断跳过;其余为下一章。
10936
+ * 四种章:已 `approved` 快进;已列入 `pendingManual` 的快进(人工已接管);
10937
+ * 连败达上限的熔断跳过;其余为下一章。
10536
10938
  *
10537
10939
  * @param chapters - 全书章节计划。
10538
10940
  * @param currentNo - 当前章号(含)。
10539
10941
  * @param endNo - 区间终点(含)。
10540
10942
  * @param failedAttempts - 章号 → 已失败次数(来自 `RunState.failedAttempts`)。
10943
+ * @param pendingManual - 已列入待人工的章号(来自 `RunState.pendingManual`)。
10944
+ * 必须传入,否则重写 N 轮仍不过的章会被反复选中,每轮烧两次 rewrite + 两次 verify。
10541
10945
  */
10542
- function pickNextChapter(chapters, currentNo, endNo, failedAttempts) {
10946
+ function pickNextChapter(chapters, currentNo, endNo, failedAttempts, pendingManual = []) {
10543
10947
  const circuitBroken = [];
10948
+ const manual = new Set(pendingManual);
10544
10949
  let advanceTo = currentNo - 1;
10545
10950
  for (let no = currentNo; no <= endNo; no++) {
10546
10951
  const ch = chapters.find((c) => c.no === no);
@@ -10549,6 +10954,10 @@ function pickNextChapter(chapters, currentNo, endNo, failedAttempts) {
10549
10954
  advanceTo = no;
10550
10955
  continue;
10551
10956
  }
10957
+ if (manual.has(no)) {
10958
+ advanceTo = no;
10959
+ continue;
10960
+ }
10552
10961
  const attempts = failedAttempts[String(no)] ?? 0;
10553
10962
  if (attempts >= 3) {
10554
10963
  circuitBroken.push({
@@ -10768,7 +11177,7 @@ var ProductionRunner = class {
10768
11177
  this.persist();
10769
11178
  break;
10770
11179
  }
10771
- const picked = pickNextChapter(project.chapters, this.state.currentNo, this.state.endNo, this.state.failedAttempts ?? {});
11180
+ const picked = pickNextChapter(project.chapters, this.state.currentNo, this.state.endNo, this.state.failedAttempts ?? {}, this.state.pendingManual ?? []);
10772
11181
  if (picked.advanceTo >= this.state.currentNo) this.state.currentNo = picked.advanceTo;
10773
11182
  for (const skipped of picked.circuitBroken) this.skipFailedChapter(skipped.chapter, skipped.attempts);
10774
11183
  if (picked.next === void 0) {
@@ -10903,11 +11312,12 @@ var ProductionRunner = class {
10903
11312
  text
10904
11313
  });
10905
11314
  }
10906
- mergeVolatileFromDisk(outputDir, project);
11315
+ mergeVolatileFromDisk(outputDir, project, ["plotlines", "facts"]);
10907
11316
  saveProject(outputDir, project);
10908
11317
  }
10909
11318
  } catch (e) {
10910
11319
  console.warn("[dsh-novel-forge] run author review:", e.message);
11320
+ this.log(`第${no}章 作者复盘失败(正文已保留,可稍后补跑复盘):${e.message}`);
10911
11321
  }
10912
11322
  } catch (error) {
10913
11323
  chapter.status = "error";
@@ -10973,8 +11383,15 @@ var ProductionRunner = class {
10973
11383
  baseReport: verify
10974
11384
  });
10975
11385
  }
10976
- if (this.state !== null) this.state.pendingManual.push(no);
10977
- this.log(`第${no}章 2 轮修订仍不过 → 保留草稿待人工`);
11386
+ if (this.state !== null) {
11387
+ if (!this.state.pendingManual.includes(no)) {
11388
+ this.state.pendingManual.push(no);
11389
+ this.state.pendingManual.sort((a, b) => a - b);
11390
+ }
11391
+ this.state.currentNo = no + 1;
11392
+ this.persist();
11393
+ }
11394
+ this.log(`第${no}章 2 轮修订仍不过 → 保留草稿待人工(已跳过,可修正后单独重试该章)`);
10978
11395
  }
10979
11396
  applyDraft(project, chapter, draft, report) {
10980
11397
  const config = this.deps.getConfig();
@@ -11937,7 +12354,7 @@ function historyToMessages(history) {
11937
12354
  return messages;
11938
12355
  }
11939
12356
  /** One non-streaming LLM chat turn (used inside the tool loop). */
11940
- async function chatOnce(ctx, config, system, history) {
12357
+ async function chatOnce(ctx, config, system, history, signal) {
11941
12358
  const messages = historyToMessages(history);
11942
12359
  const last = messages[messages.length - 1];
11943
12360
  if (last?.role === "user" && Array.isArray(last.content)) {
@@ -11972,15 +12389,27 @@ async function chatOnce(ctx, config, system, history) {
11972
12389
  user: messages.map((m) => JSON.stringify(m.content)).join(" / ").slice(0, 8e3)
11973
12390
  });
11974
12391
  const assembler = new BlockAssembler();
11975
- for await (const chunk of ctx.llm.stream(request)) {
11976
- if (chunk.type === "text-delta") markFirstToken(live);
11977
- assembler.push(chunk);
12392
+ try {
12393
+ for await (const chunk of ctx.llm.stream(signal === void 0 ? request : {
12394
+ ...request,
12395
+ signal
12396
+ })) {
12397
+ if (chunk.type === "text-delta") markFirstToken(live);
12398
+ assembler.push(chunk);
12399
+ }
12400
+ } catch (error) {
12401
+ endLiveCall(live, assembler, {
12402
+ phase: isCancellation(error) || signal?.aborted === true ? "cancelled" : "failed",
12403
+ error: error instanceof Error ? error.message : String(error)
12404
+ });
12405
+ throw error;
11978
12406
  }
11979
12407
  const finish = assembler.finish;
11980
12408
  if (finish.kind === "error" || finish.kind === "aborted") {
11981
- const message = `助手调用失败(${finish.kind}): ${finish.failure.message}`;
12409
+ const cancelled = isCancellation(finish.kind);
12410
+ const message = `${cancelled ? "助手对话已取消" : "助手调用失败"}(${finish.kind}): ${finish.failure.message}`;
11982
12411
  endLiveCall(live, assembler, {
11983
- phase: "failed",
12412
+ phase: cancelled ? "cancelled" : "failed",
11984
12413
  error: message
11985
12414
  });
11986
12415
  throw new Error(message);
@@ -12004,7 +12433,7 @@ async function chatOnce(ctx, config, system, history) {
12004
12433
  }, runOnce);
12005
12434
  }
12006
12435
  /** Run one user turn. Yields stream frames; persists history. */
12007
- async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
12436
+ async function* runAssistantTurn(ctx, config, project, outputDir, userMessage, signal) {
12008
12437
  const history = loadAssistantHistory(outputDir);
12009
12438
  const stage = computeBookStage(project);
12010
12439
  const strictStage = isGenericContinue(userMessage);
@@ -12071,7 +12500,8 @@ async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
12071
12500
  };
12072
12501
  for (;;) {
12073
12502
  if (iterations++ > 20) break;
12074
- const reply = await chatOnce(ctx, config, system, history);
12503
+ if (signal?.aborted === true) throw new Error("客户端已断开连接");
12504
+ const reply = await chatOnce(ctx, config, system, history, signal);
12075
12505
  if (reply.length > 120 && /^[0-9a-fA-F\s]+$/.test(reply.slice(0, 2e3))) {
12076
12506
  garbleCount++;
12077
12507
  if (garbleCount >= 2) {
@@ -12805,6 +13235,12 @@ function addGlobalMode(m) {
12805
13235
  //#region src/routes.ts
12806
13236
  /** Cap on JSON request bodies (generous: cover images travel as base64). */
12807
13237
  const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024;
13238
+ /** 允许「服务器本地文件」模式导入的扩展名:纯文本小说。不含 docx/pdf 等二进制。 */
13239
+ const IMPORTABLE_EXTENSIONS = [
13240
+ ".txt",
13241
+ ".md",
13242
+ ".markdown"
13243
+ ];
12808
13244
  /** 包内置风格效果图目录(assets/styles,随 npm 包分发)。 */
12809
13245
  /** 解析请求级目标书目录:优先按请求携带的 bookId 查书架,否则回退全局 active 书目录。 */
12810
13246
  function resolveOutputDir(config, bookId) {
@@ -12814,6 +13250,42 @@ function resolveOutputDir(config, bookId) {
12814
13250
  }
12815
13251
  return config.outputDir;
12816
13252
  }
13253
+ /**
13254
+ * 校验一个「客户端自报的目录」是否确实是已登记的书目录。
13255
+ *
13256
+ * 背景:部分路由(封面读取等)历史上直接采信 `?dir=` 参数拼路径读文件。
13257
+ * 本服务只有 loopback 围栏、没有 token,而围栏允许 `Origin` 缺失的请求
13258
+ * (非浏览器客户端、curl、恶意扩展都能过),所以形如
13259
+ * `GET /cover?dir=C:/Users/x` 会让服务读出该目录下任意 `novel-project.json`
13260
+ * 指向的文件并 base64 回吐。
13261
+ *
13262
+ * 正确做法不是「校验路径合法」(攻击者可以传任意合法路径),而是
13263
+ * **只承认自己登记过的目录**:书架里的每本书 + 设置里的默认输出目录。
13264
+ * 攻击者能传的值因此被压缩到一个可枚举的集合内。
13265
+ *
13266
+ * @returns 归一化后的目录;不在白名单内返回 undefined。
13267
+ */
13268
+ function resolveRegisteredDir(config, requested) {
13269
+ if (requested === null || requested === "") return config.outputDir;
13270
+ let normalized;
13271
+ try {
13272
+ normalized = resolve(requested).replace(/[\\/]+$/, "");
13273
+ } catch {
13274
+ return;
13275
+ }
13276
+ const store = loadBookshelf();
13277
+ const allowed = [config.outputDir, ...store.books.map((b) => b.outputDir)];
13278
+ for (const dir of allowed) {
13279
+ if (dir === "") continue;
13280
+ let candidate;
13281
+ try {
13282
+ candidate = resolve(dir).replace(/[\\/]+$/, "");
13283
+ } catch {
13284
+ continue;
13285
+ }
13286
+ if (candidate.toLowerCase() === normalized.toLowerCase()) return candidate;
13287
+ }
13288
+ }
12817
13289
  /** Loopback-only fence (mirrors the family plugins' pairing routes). */
12818
13290
  function isLoopbackRequest(request) {
12819
13291
  const address = request.socket.remoteAddress;
@@ -12861,6 +13333,50 @@ async function readJsonBody(req) {
12861
13333
  return;
12862
13334
  }
12863
13335
  }
13336
+ /**
13337
+ * 把「客户端是否还在听」绑成一个 `AbortSignal`。
13338
+ *
13339
+ * ## 为什么必须有
13340
+ *
13341
+ * Node 的 `res.write()` 对已销毁的 socket **不抛异常也不返回 false** ——
13342
+ * 请求处理器会继续跑完。7 条 NDJSON 路由此前零 `close` 监听,于是:
13343
+ * 浏览器关标签页 → `for await (const step of generateChapterStream(...))`
13344
+ * 继续跑完整章生成(2-5 分钟,maxTokens 常 20000),审稿、复盘、落盘全部照跑。
13345
+ * **「关掉页面」不等于「停止烧 token」**,而且 `chapter.status` 已被写盘为
13346
+ * `generating`,只能靠 `/status` 的 10 分钟兜底复位。
13347
+ *
13348
+ * 宿主 SDK 明确约定「implementations must honor `options.signal`」
13349
+ * (`@deepseek-ai/dsh-llm` `LLMAdapter.stream` 文档),所以只要把 signal
13350
+ * 一路传到 `ctx.llm.stream` 就能真正掐断。
13351
+ *
13352
+ * ## 用法
13353
+ *
13354
+ * ```ts
13355
+ * const abort = abortOnClientClose(req, res)
13356
+ * try {
13357
+ * for await (const step of generateChapterStream(ctx, config, project, dir, no, abort.signal)) { ... }
13358
+ * } catch (err) {
13359
+ * if (isAbortError(err)) return // 客户端主动断开:静默收尾,不回错误帧
13360
+ * ...
13361
+ * }
13362
+ * ```
13363
+ *
13364
+ * @returns controller;`signal` 传给下游,`aborted` 可直接判断。
13365
+ */
13366
+ function abortOnClientClose(req, res) {
13367
+ const controller = new AbortController();
13368
+ const onClose = () => {
13369
+ if (!res.writableEnded) controller.abort(/* @__PURE__ */ new Error("客户端已断开连接"));
13370
+ };
13371
+ res.once("close", onClose);
13372
+ req.once("aborted", onClose);
13373
+ return controller;
13374
+ }
13375
+ /** 判断错误是否为「客户端断开」导致的中止(据此静默收尾而不是回错误帧)。 */
13376
+ function isAbortError(err) {
13377
+ if (err instanceof Error && (err.name === "AbortError" || err.message.includes("客户端已断开连接"))) return true;
13378
+ return false;
13379
+ }
12864
13380
  /** Default chapter count for planning when the request omits it. */
12865
13381
  const DEFAULT_PLAN_COUNT = 30;
12866
13382
  /**
@@ -12906,15 +13422,39 @@ function makeRoutes(deps) {
12906
13422
  return true;
12907
13423
  };
12908
13424
  /** Load (and sync) the project, or respond 400. */
12909
- const requireProject = (res) => {
12910
- const config = getConfig();
12911
- const project = loadProject(config.outputDir);
13425
+ /**
13426
+ * 取本次请求的目标项目。
13427
+ *
13428
+ * 目录解析与 {@link resolveOutputDir} 同源:按请求携带的 `bookId` 查书架,
13429
+ * 未携带才回退全局 active 书。**此前本函数只看 `getConfig().outputDir`**,
13430
+ * 而约一半路由走它、另一半走 resolveOutputDir,前端每个请求都带 bookId
13431
+ * 却被后端一半静默忽略 —— 多书场景下对非激活书调用会写到激活书目录。
13432
+ *
13433
+ * 顺带修掉写盘副作用:`syncProjectWithDisk` 无条件更新 `updatedAt`,
13434
+ * 使 `saveProject` 的 no-op 检测永不命中,于是每个「读」请求都在重写整个
13435
+ * novel-project.json(长篇项目可达数MB,每 4 秒一次)。这里改成
13436
+ * 同步后按内容比对,仅在真的变化时落盘。
13437
+ *
13438
+ * @param req - 用于解析 `bookId`(GET 查 query,POST 查 body 由调用方保证)。
13439
+ * @param res - 用于回写错误。
13440
+ * @param body - POST 请求体(可能含 bookId)。
13441
+ */
13442
+ const requireProjectAt = (req, res, body) => {
13443
+ const outputDir = resolveOutputDir(getConfig(), body?.bookId ?? (() => {
13444
+ try {
13445
+ return new URL(req.url ?? "/", "http://localhost").searchParams.get("bookId") ?? void 0;
13446
+ } catch {
13447
+ return;
13448
+ }
13449
+ })() ?? void 0);
13450
+ const project = loadProject(outputDir);
12912
13451
  if (project === void 0) {
12913
13452
  writeJson(res, 400, { error: "输出目录中没有项目,请先加载大纲" });
12914
13453
  return;
12915
13454
  }
12916
- syncProjectWithDisk(project, config.outputDir);
12917
- saveProject(config.outputDir, project);
13455
+ const before = JSON.stringify(project);
13456
+ syncProjectWithDisk(project, outputDir);
13457
+ if (JSON.stringify(project) !== before) saveProject(outputDir, project);
12918
13458
  return project;
12919
13459
  };
12920
13460
  const statusRoute = {
@@ -12939,6 +13479,11 @@ function makeRoutes(deps) {
12939
13479
  syncProjectWithDisk(project, outputDir);
12940
13480
  saveProject(outputDir, project);
12941
13481
  }
13482
+ const writtenAtByNo = /* @__PURE__ */ new Map();
13483
+ for (const c of project?.chapters ?? []) {
13484
+ const at = chapterWrittenAt(outputDir, c);
13485
+ if (at !== void 0) writtenAtByNo.set(c.no, at);
13486
+ }
12942
13487
  const slim = new URL(req.url ?? "/", "http://localhost").searchParams.get("slim") === "1";
12943
13488
  const truncations = [];
12944
13489
  let projectPayload;
@@ -12958,7 +13503,8 @@ function makeRoutes(deps) {
12958
13503
  review: c.review !== void 0 ? {
12959
13504
  score: c.review.score,
12960
13505
  passed: c.review.passed
12961
- } : void 0
13506
+ } : void 0,
13507
+ writtenAt: writtenAtByNo.get(c.no)
12962
13508
  })),
12963
13509
  createdAt: project.createdAt,
12964
13510
  updatedAt: project.updatedAt
@@ -12969,6 +13515,10 @@ function makeRoutes(deps) {
12969
13515
  if (allFacts.length > shownFacts.length) truncations.push(`编年录:共 ${allFacts.length} 条,仅返回最近 ${shownFacts.length} 条(较早 ${allFacts.length - shownFacts.length} 条未返回)`);
12970
13516
  projectPayload = {
12971
13517
  ...project,
13518
+ chapters: project.chapters.map((c) => ({
13519
+ ...c,
13520
+ writtenAt: writtenAtByNo.get(c.no)
13521
+ })),
12972
13522
  facts: shownFacts
12973
13523
  };
12974
13524
  }
@@ -13183,46 +13733,18 @@ function makeRoutes(deps) {
13183
13733
  const send = (frame) => {
13184
13734
  res.write(JSON.stringify(frame) + "\n");
13185
13735
  };
13186
- const liveSession = nextSessionId();
13187
- emitLive({
13188
- type: "session_started",
13189
- sessionId: liveSession,
13190
- label: "正文生成",
13191
- model: config.generateModel || config.model,
13192
- at: (/* @__PURE__ */ new Date()).toISOString(),
13193
- context: {
13194
- interactionId: liveSession,
13195
- taskId: `ch-${no}`
13196
- }
13197
- });
13198
- emitLive({
13199
- type: "phase_changed",
13200
- sessionId: liveSession,
13201
- phase: "streaming",
13202
- phaseMessage: "模型正在返回正文",
13203
- at: (/* @__PURE__ */ new Date()).toISOString()
13204
- });
13736
+ const abort = abortOnClientClose(req, res);
13205
13737
  try {
13206
13738
  send({
13207
13739
  type: "start",
13208
13740
  no,
13209
13741
  title: chapter.title
13210
13742
  });
13211
- let genChars = 0;
13212
- for await (const step of generateChapterStream(ctx, config, project, config.outputDir, no)) if (step.frame === "delta") {
13213
- send({
13214
- type: "delta",
13215
- text: step.text
13216
- });
13217
- genChars += step.text.length;
13218
- emitLive({
13219
- type: "output_delta",
13220
- sessionId: liveSession,
13221
- content: step.text,
13222
- totalChars: genChars,
13223
- at: (/* @__PURE__ */ new Date()).toISOString()
13224
- });
13225
- } else if (step.frame === "done") send({
13743
+ for await (const step of generateChapterStream(ctx, config, project, config.outputDir, no, abort.signal)) if (step.frame === "delta") send({
13744
+ type: "delta",
13745
+ text: step.text
13746
+ });
13747
+ else if (step.frame === "done") send({
13226
13748
  type: "done",
13227
13749
  no,
13228
13750
  file: step.file,
@@ -13230,14 +13752,6 @@ function makeRoutes(deps) {
13230
13752
  title: chapter.title,
13231
13753
  warn: step.warn
13232
13754
  });
13233
- emitLive({
13234
- type: "session_completed",
13235
- sessionId: liveSession,
13236
- totalChars: genChars,
13237
- preview: "",
13238
- at: (/* @__PURE__ */ new Date()).toISOString(),
13239
- phase: "completed"
13240
- });
13241
13755
  try {
13242
13756
  const wantTimeline = config.autoTimeline ?? true;
13243
13757
  await summarizeFactsAndTimeline(ctx, config, project, config.outputDir, no, {
@@ -13274,7 +13788,7 @@ function makeRoutes(deps) {
13274
13788
  const review = await authorReviewChapter(ctx, config, project, no, currentBody, prevTail);
13275
13789
  chapter.authorReview = review;
13276
13790
  if (review.advancedLines !== void 0) autoLinkPlotlines(project, no, review.advancedLines);
13277
- mergeVolatileFromDisk(config.outputDir, project);
13791
+ mergeVolatileFromDisk(config.outputDir, project, ["plotlines"]);
13278
13792
  saveProject(config.outputDir, project);
13279
13793
  send({
13280
13794
  type: "author-review",
@@ -13287,6 +13801,14 @@ function makeRoutes(deps) {
13287
13801
  }
13288
13802
  res.end();
13289
13803
  } catch (error) {
13804
+ if (isAbortError(error)) {
13805
+ chapter.status = "pending";
13806
+ chapter.generatingAt = void 0;
13807
+ chapter.error = void 0;
13808
+ syncProjectWithDisk(project, config.outputDir);
13809
+ saveProject(config.outputDir, project);
13810
+ return;
13811
+ }
13290
13812
  chapter.status = "error";
13291
13813
  chapter.error = error.message;
13292
13814
  mergeVolatileFromDisk(config.outputDir, project);
@@ -13362,8 +13884,9 @@ function makeRoutes(deps) {
13362
13884
  const send = (frame) => {
13363
13885
  res.write(JSON.stringify(frame) + "\n");
13364
13886
  };
13887
+ const abort = abortOnClientClose(req, res);
13365
13888
  try {
13366
- for await (const step of rewriteChapterStream(ctx, config, project, config.outputDir, no, body?.instructions ?? "", body?.target)) if (step.frame === "delta") send({
13889
+ for await (const step of rewriteChapterStream(ctx, config, project, config.outputDir, no, body?.instructions ?? "", body?.target, abort.signal)) if (step.frame === "delta") send({
13367
13890
  type: "delta",
13368
13891
  text: step.text
13369
13892
  });
@@ -13375,6 +13898,7 @@ function makeRoutes(deps) {
13375
13898
  });
13376
13899
  res.end();
13377
13900
  } catch (error) {
13901
+ if (isAbortError(error)) return;
13378
13902
  if (!res.writableEnded) {
13379
13903
  send({
13380
13904
  type: "error",
@@ -13417,8 +13941,9 @@ function makeRoutes(deps) {
13417
13941
  const send = (frame) => {
13418
13942
  res.write(JSON.stringify(frame) + "\n");
13419
13943
  };
13944
+ const abort = abortOnClientClose(req, res);
13420
13945
  try {
13421
- for await (const step of polishChapterStream(ctx, config, project, config.outputDir, no)) if (step.frame === "delta") send({
13946
+ for await (const step of polishChapterStream(ctx, config, project, config.outputDir, no, abort.signal)) if (step.frame === "delta") send({
13422
13947
  type: "delta",
13423
13948
  text: step.text
13424
13949
  });
@@ -13430,6 +13955,7 @@ function makeRoutes(deps) {
13430
13955
  });
13431
13956
  res.end();
13432
13957
  } catch (error) {
13958
+ if (isAbortError(error)) return;
13433
13959
  if (!res.writableEnded) {
13434
13960
  send({
13435
13961
  type: "error",
@@ -13448,7 +13974,7 @@ function makeRoutes(deps) {
13448
13974
  handler: async (req, res) => {
13449
13975
  if (!guard(req, res, "POST")) return;
13450
13976
  const config = getConfig();
13451
- const project = requireProject(res);
13977
+ const project = requireProjectAt(req, res);
13452
13978
  if (project === void 0) return;
13453
13979
  const body = await readJsonBody(req);
13454
13980
  if (!Number.isInteger(body?.chapterNo)) {
@@ -13499,7 +14025,7 @@ function makeRoutes(deps) {
13499
14025
  handler: async (req, res) => {
13500
14026
  if (!guard(req, res, "POST")) return;
13501
14027
  const config = getConfig();
13502
- const project = requireProject(res);
14028
+ const project = requireProjectAt(req, res);
13503
14029
  if (project === void 0) return;
13504
14030
  const body = await readJsonBody(req);
13505
14031
  if (!Number.isInteger(body?.chapterNo)) {
@@ -13525,7 +14051,7 @@ function makeRoutes(deps) {
13525
14051
  handler: async (req, res) => {
13526
14052
  if (!guard(req, res, "POST")) return;
13527
14053
  const config = getConfig();
13528
- const project = requireProject(res);
14054
+ const project = requireProjectAt(req, res);
13529
14055
  if (project === void 0) return;
13530
14056
  const body = await readJsonBody(req);
13531
14057
  if (!Number.isInteger(body?.chapterNo)) {
@@ -13669,7 +14195,7 @@ function makeRoutes(deps) {
13669
14195
  handler: async (req, res) => {
13670
14196
  if (!guard(req, res, "POST")) return;
13671
14197
  const config = getConfig();
13672
- const project = requireProject(res);
14198
+ const project = requireProjectAt(req, res);
13673
14199
  if (project === void 0) return;
13674
14200
  const body = await readJsonBody(req);
13675
14201
  const text = body?.text?.trim() ?? "";
@@ -13691,7 +14217,7 @@ function makeRoutes(deps) {
13691
14217
  handler: async (req, res) => {
13692
14218
  if (!guard(req, res, "POST")) return;
13693
14219
  const config = getConfig();
13694
- const project = requireProject(res);
14220
+ const project = requireProjectAt(req, res);
13695
14221
  if (project === void 0) return;
13696
14222
  const body = await readJsonBody(req);
13697
14223
  if (!Number.isInteger(body?.chapterNo)) {
@@ -13762,8 +14288,9 @@ function makeRoutes(deps) {
13762
14288
  const send = (frame) => {
13763
14289
  res.write(JSON.stringify(frame) + "\n");
13764
14290
  };
14291
+ const abort = abortOnClientClose(req, res);
13765
14292
  try {
13766
- for await (const step of runAssistantTurn(ctx, config, project, outputDir, message)) if (step.frame === "delta") send({
14293
+ for await (const step of runAssistantTurn(ctx, config, project, outputDir, message, abort.signal)) if (step.frame === "delta") send({
13767
14294
  type: "delta",
13768
14295
  text: step.text
13769
14296
  });
@@ -13786,6 +14313,7 @@ function makeRoutes(deps) {
13786
14313
  send({ type: "done" });
13787
14314
  res.end();
13788
14315
  } catch (error) {
14316
+ if (isAbortError(error)) return;
13789
14317
  if (!res.writableEnded) {
13790
14318
  send({
13791
14319
  type: "error",
@@ -14923,6 +15451,10 @@ function makeRoutes(deps) {
14923
15451
  writeJson(res, 400, { error: "filePath 不能为空(或请上传文件内容)" });
14924
15452
  return;
14925
15453
  }
15454
+ if (!IMPORTABLE_EXTENSIONS.includes(extname(filePath).toLowerCase())) {
15455
+ writeJson(res, 400, { error: `仅支持纯文本小说文件(${IMPORTABLE_EXTENSIONS.join(" / ")})` });
15456
+ return;
15457
+ }
14926
15458
  if (!existsSync(filePath)) {
14927
15459
  writeJson(res, 400, { error: `文件不存在:${filePath}` });
14928
15460
  return;
@@ -15026,7 +15558,7 @@ function makeRoutes(deps) {
15026
15558
  handler: async (req, res) => {
15027
15559
  if (!guard(req, res, "POST")) return;
15028
15560
  const config = getConfig();
15029
- const project = requireProject(res);
15561
+ const project = requireProjectAt(req, res);
15030
15562
  if (project === void 0) return;
15031
15563
  try {
15032
15564
  const cards = await refreshCharacters(ctx, config, project, config.outputDir);
@@ -15046,7 +15578,7 @@ function makeRoutes(deps) {
15046
15578
  handler: async (req, res) => {
15047
15579
  if (!guard(req, res, "POST")) return;
15048
15580
  const config = getConfig();
15049
- const project = requireProject(res);
15581
+ const project = requireProjectAt(req, res);
15050
15582
  if (project === void 0) return;
15051
15583
  try {
15052
15584
  writeJson(res, 200, {
@@ -15065,7 +15597,7 @@ function makeRoutes(deps) {
15065
15597
  handler: async (req, res) => {
15066
15598
  if (!guard(req, res, "POST")) return;
15067
15599
  const config = getConfig();
15068
- const project = requireProject(res);
15600
+ const project = requireProjectAt(req, res);
15069
15601
  if (project === void 0) return;
15070
15602
  if (project.bible === void 0) {
15071
15603
  writeJson(res, 400, { error: "尚未生成道藏,请先生成" });
@@ -15100,7 +15632,7 @@ function makeRoutes(deps) {
15100
15632
  handler: async (req, res) => {
15101
15633
  if (!guard(req, res, "POST")) return;
15102
15634
  const config = getConfig();
15103
- const project = requireProject(res);
15635
+ const project = requireProjectAt(req, res);
15104
15636
  if (project === void 0) return;
15105
15637
  const body = await readJsonBody(req);
15106
15638
  try {
@@ -15134,8 +15666,11 @@ function makeRoutes(deps) {
15134
15666
  writeJson(res, 403, { error: "forbidden: loopback-only" });
15135
15667
  return;
15136
15668
  }
15137
- const dirParam = new URL(req.url ?? "/", "http://localhost").searchParams.get("dir");
15138
- const targetDir = dirParam !== null && dirParam !== "" ? dirParam : config.outputDir;
15669
+ const targetDir = resolveRegisteredDir(config, new URL(req.url ?? "/", "http://localhost").searchParams.get("dir"));
15670
+ if (targetDir === void 0) {
15671
+ writeJson(res, 400, { error: "dir 不是已登记的书籍目录" });
15672
+ return;
15673
+ }
15139
15674
  const coverPath = loadProject(targetDir)?.coverPath;
15140
15675
  if (coverPath === void 0 || coverPath === "") {
15141
15676
  writeJson(res, 200, { dataUrl: null });
@@ -15151,7 +15686,7 @@ function makeRoutes(deps) {
15151
15686
  }
15152
15687
  if (req.method === "POST") {
15153
15688
  if (!guard(req, res, "POST")) return;
15154
- const project = requireProject(res);
15689
+ const project = requireProjectAt(req, res);
15155
15690
  if (project === void 0) return;
15156
15691
  const body = await readJsonBody(req);
15157
15692
  try {
@@ -15207,7 +15742,7 @@ function makeRoutes(deps) {
15207
15742
  handler: async (req, res) => {
15208
15743
  if (!guard(req, res, "POST")) return;
15209
15744
  const config = getConfig();
15210
- const project = requireProject(res);
15745
+ const project = requireProjectAt(req, res);
15211
15746
  if (project === void 0) return;
15212
15747
  const body = await readJsonBody(req);
15213
15748
  try {
@@ -15239,7 +15774,7 @@ function makeRoutes(deps) {
15239
15774
  handler: async (req, res) => {
15240
15775
  if (!guard(req, res, "POST")) return;
15241
15776
  const config = getConfig();
15242
- const project = requireProject(res);
15777
+ const project = requireProjectAt(req, res);
15243
15778
  if (project === void 0) return;
15244
15779
  const bookName = (await readJsonBody(req))?.bookName?.trim();
15245
15780
  if (bookName === void 0 || bookName === "") {
@@ -15261,7 +15796,7 @@ function makeRoutes(deps) {
15261
15796
  handler: async (req, res) => {
15262
15797
  if (!guard(req, res, "POST")) return;
15263
15798
  const config = getConfig();
15264
- const project = requireProject(res);
15799
+ const project = requireProjectAt(req, res);
15265
15800
  if (project === void 0) return;
15266
15801
  const body = await readJsonBody(req);
15267
15802
  if (project.plotlines === void 0) project.plotlines = [];
@@ -15390,7 +15925,7 @@ function makeRoutes(deps) {
15390
15925
  handler: async (req, res) => {
15391
15926
  if (!guard(req, res, "POST")) return;
15392
15927
  const config = getConfig();
15393
- const project = requireProject(res);
15928
+ const project = requireProjectAt(req, res);
15394
15929
  if (project === void 0) return;
15395
15930
  const body = await readJsonBody(req);
15396
15931
  const hits = [];
@@ -15444,7 +15979,7 @@ function makeRoutes(deps) {
15444
15979
  handler: async (req, res) => {
15445
15980
  if (!guard(req, res, "POST")) return;
15446
15981
  const config = getConfig();
15447
- const project = requireProject(res);
15982
+ const project = requireProjectAt(req, res);
15448
15983
  if (project === void 0) return;
15449
15984
  const body = await readJsonBody(req);
15450
15985
  if (project.roles === void 0) project.roles = [];
@@ -15482,7 +16017,7 @@ function makeRoutes(deps) {
15482
16017
  handler: async (req, res) => {
15483
16018
  if (!guard(req, res, "POST")) return;
15484
16019
  const config = getConfig();
15485
- const project = requireProject(res);
16020
+ const project = requireProjectAt(req, res);
15486
16021
  if (project === void 0) return;
15487
16022
  const no = (await readJsonBody(req))?.chapterNo;
15488
16023
  if (!Number.isInteger(no) || no === void 0 || no < 1) {
@@ -15512,7 +16047,7 @@ function makeRoutes(deps) {
15512
16047
  handler: async (req, res) => {
15513
16048
  if (!guard(req, res, "POST")) return;
15514
16049
  const config = getConfig();
15515
- const project = requireProject(res);
16050
+ const project = requireProjectAt(req, res);
15516
16051
  if (project === void 0) return;
15517
16052
  const no = (await readJsonBody(req))?.chapterNo;
15518
16053
  if (!Number.isInteger(no) || no === void 0 || no < 1) {
@@ -15533,6 +16068,73 @@ function makeRoutes(deps) {
15533
16068
  });
15534
16069
  }
15535
16070
  };
16071
+ /**
16072
+ * 补写本章计划(方案 §7「左侧章节计划」六项)。
16073
+ *
16074
+ * 这些字段原本只有规划步骤会写:AI 排偏了,作者却没有纠正入口,而它们正是生成
16075
+ * 与审稿的锚点(见 engine 的本章义务合约拼装)。上限与规划步骤保持一致 ——
16076
+ * 否则「手动能写 10 条、AI 一重排只剩 4 条」就是静默丢数据;超限必然回写 notices。
16077
+ */
16078
+ const chapterPlanRoute = {
16079
+ kind: "exact",
16080
+ path: NOVEL_API.chapterPlan,
16081
+ handler: async (req, res) => {
16082
+ if (!guard(req, res, "POST")) return;
16083
+ const config = getConfig();
16084
+ const project = requireProjectAt(req, res);
16085
+ if (project === void 0) return;
16086
+ const body = await readJsonBody(req);
16087
+ const no = body?.chapterNo;
16088
+ if (!Number.isInteger(no) || no === void 0 || no < 1) {
16089
+ writeJson(res, 400, { error: "chapterNo 须为正整数" });
16090
+ return;
16091
+ }
16092
+ const chapter = project.chapters.find((c) => c.no === no);
16093
+ if (chapter === void 0) {
16094
+ writeJson(res, 404, { error: `章节 ${no} 不在计划中` });
16095
+ return;
16096
+ }
16097
+ const patch = body?.patch;
16098
+ if (patch === null || typeof patch !== "object") {
16099
+ writeJson(res, 400, { error: "patch 须为对象" });
16100
+ return;
16101
+ }
16102
+ const notices = [];
16103
+ const oneLine = (label, raw, max) => {
16104
+ if (typeof raw !== "string") return void 0;
16105
+ const value = raw.trim();
16106
+ if (value.length <= max) return value;
16107
+ notices.push(`${label}:${value.length} 字已裁到 ${max} 字`);
16108
+ return value.slice(0, max);
16109
+ };
16110
+ const items = (label, raw, max) => {
16111
+ if (!Array.isArray(raw)) return void 0;
16112
+ const value = raw.filter((x) => typeof x === "string" && x.trim() !== "").map((x) => x.trim());
16113
+ if (value.length <= max) return value;
16114
+ notices.push(`${label}:${value.length} 条只保留前 ${max} 条`);
16115
+ return value.slice(0, max);
16116
+ };
16117
+ const obligation = oneLine("本章目标", patch.obligation, 200);
16118
+ if (obligation !== void 0) chapter.obligation = obligation === "" ? void 0 : obligation;
16119
+ const mustAdvance = items("本章必达项", patch.mustAdvance, 4);
16120
+ if (mustAdvance !== void 0) chapter.mustAdvance = mustAdvance;
16121
+ const mustPreserve = items("必须保持", patch.mustPreserve, 4);
16122
+ if (mustPreserve !== void 0) chapter.mustPreserve = mustPreserve;
16123
+ const hardFacts = items("人物硬事实", patch.characterHardFacts, 6);
16124
+ if (hardFacts !== void 0) chapter.characterHardFacts = hardFacts;
16125
+ const endingHook = oneLine("结尾钩子", patch.endingHook, 120);
16126
+ if (endingHook !== void 0) chapter.endingHook = endingHook === "" ? void 0 : endingHook;
16127
+ const beats = oneLine("剧情推进", patch.beats, 2e3);
16128
+ if (beats !== void 0) chapter.beats = beats;
16129
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
16130
+ saveProject(config.outputDir, project);
16131
+ writeJson(res, 200, {
16132
+ ok: true,
16133
+ chapter,
16134
+ notices
16135
+ });
16136
+ }
16137
+ };
15536
16138
  /** 作者复盘补跑:对已写章节补齐 authorReview(body.chapterNo=单章 JSON,缺省=全书 NDJSON 流)。 */
15537
16139
  const reviewBackfillRoute = {
15538
16140
  kind: "exact",
@@ -15540,9 +16142,10 @@ function makeRoutes(deps) {
15540
16142
  handler: async (req, res) => {
15541
16143
  if (!guard(req, res, "POST")) return;
15542
16144
  const config = getConfig();
15543
- const project = requireProject(res);
16145
+ const project = requireProjectAt(req, res);
15544
16146
  if (project === void 0) return;
15545
16147
  const body = await readJsonBody(req);
16148
+ const abort = abortOnClientClose(req, res);
15546
16149
  /** 对一章执行作者复盘(读取已落盘正文,不改变章节状态/正文)。 */
15547
16150
  const runOne = async (chapter) => {
15548
16151
  const currentBody = readChapterFile(config.outputDir, chapter);
@@ -15552,6 +16155,7 @@ function makeRoutes(deps) {
15552
16155
  const prev = project.chapters.find((c) => c.no === chapter.no - 1);
15553
16156
  if (prev !== void 0) prevTail = stripChapterHeadings(readChapterFile(config.outputDir, prev) ?? "").slice(-600);
15554
16157
  }
16158
+ if (abort.signal.aborted) throw new Error("客户端已断开连接");
15555
16159
  return authorReviewChapter(ctx, config, project, chapter.no, currentBody, prevTail);
15556
16160
  };
15557
16161
  if (typeof body?.chapterNo === "number" && body.chapterNo > 0) {
@@ -15726,7 +16330,7 @@ function makeRoutes(deps) {
15726
16330
  handler: async (req, res) => {
15727
16331
  if (!guard(req, res, "POST")) return;
15728
16332
  const config = getConfig();
15729
- const project = requireProject(res);
16333
+ const project = requireProjectAt(req, res);
15730
16334
  if (project === void 0) return;
15731
16335
  res.writeHead(200, {
15732
16336
  "content-type": "application/x-ndjson; charset=utf-8",
@@ -15737,6 +16341,7 @@ function makeRoutes(deps) {
15737
16341
  const send = (frame) => {
15738
16342
  res.write(JSON.stringify(frame) + "\n");
15739
16343
  };
16344
+ const abort = abortOnClientClose(req, res);
15740
16345
  try {
15741
16346
  const outline = await reverseOutlineFromChapters(ctx, config, project, config.outputDir, (done, total, phase) => {
15742
16347
  send({
@@ -15745,7 +16350,7 @@ function makeRoutes(deps) {
15745
16350
  total,
15746
16351
  phase
15747
16352
  });
15748
- });
16353
+ }, abort.signal);
15749
16354
  project.outline = outline;
15750
16355
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
15751
16356
  saveProject(config.outputDir, project);
@@ -15755,6 +16360,7 @@ function makeRoutes(deps) {
15755
16360
  chars: outline.length
15756
16361
  });
15757
16362
  } catch (error) {
16363
+ if (isAbortError(error)) return;
15758
16364
  send({
15759
16365
  type: "error",
15760
16366
  no: 0,
@@ -15865,7 +16471,7 @@ function makeRoutes(deps) {
15865
16471
  handler: async (req, res) => {
15866
16472
  if (!guard(req, res, "POST")) return;
15867
16473
  const config = getConfig();
15868
- const project = requireProject(res);
16474
+ const project = requireProjectAt(req, res);
15869
16475
  if (project === void 0) return;
15870
16476
  const body = await readJsonBody(req);
15871
16477
  try {
@@ -15875,8 +16481,16 @@ function makeRoutes(deps) {
15875
16481
  }
15876
16482
  }
15877
16483
  };
15878
- /** 生产单执行器(单例):计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。 */
15879
- const runner = new ProductionRunner({
16484
+ /**
16485
+ * 生产单执行器:计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。
16486
+ *
16487
+ * 必须由调用方(index.ts)持有并注入,**不要在这里 new**:
16488
+ * 本工厂每次 `sync()` 都会重建,藏在闭包里的 runner 会被丢掉——
16489
+ * 旧实例的 `loop()` 仍在跑并继续写盘,而新实例 `isWorking()` 恒 false,
16490
+ * 于是 `moveOutputDir` 的「正在生产则拒绝迁移」守卫直接失效。
16491
+ * 测试可省略本项,内部自建一个一次性实例。
16492
+ */
16493
+ const runner = deps.runner ?? new ProductionRunner({
15880
16494
  ctx,
15881
16495
  getConfig
15882
16496
  });
@@ -16232,11 +16846,13 @@ function makeRoutes(deps) {
16232
16846
  const send = (frame) => {
16233
16847
  res.write(JSON.stringify(frame) + "\n");
16234
16848
  };
16849
+ const abort = abortOnClientClose(req, res);
16235
16850
  try {
16236
16851
  const result = await rewriteAdaptationBook(ctx, getConfig(), text, mappings, body?.rules, {
16237
16852
  maxChapters: body?.maxChapters,
16238
16853
  startNo: body?.startNo,
16239
16854
  endNo: body?.endNo,
16855
+ signal: abort.signal,
16240
16856
  onProgress: (info) => send({
16241
16857
  type: "progress",
16242
16858
  completed: info.completed,
@@ -16245,6 +16861,7 @@ function makeRoutes(deps) {
16245
16861
  title: info.title
16246
16862
  })
16247
16863
  });
16864
+ if (abort.signal.aborted) return;
16248
16865
  send({
16249
16866
  type: "done",
16250
16867
  result: {
@@ -16257,6 +16874,7 @@ function makeRoutes(deps) {
16257
16874
  }
16258
16875
  });
16259
16876
  } catch (err) {
16877
+ if (isAbortError(err)) return;
16260
16878
  send({
16261
16879
  type: "error",
16262
16880
  message: err instanceof Error ? err.message : String(err)
@@ -16470,6 +17088,7 @@ function makeRoutes(deps) {
16470
17088
  reviewBackfillRoute,
16471
17089
  chapterResetRoute,
16472
17090
  chapterApproveRoute,
17091
+ chapterPlanRoute,
16473
17092
  configRoute,
16474
17093
  moveOutputDirRoute,
16475
17094
  bookSettingsRoute,
@@ -16845,6 +17464,21 @@ function apply(ctx, config) {
16845
17464
  };
16846
17465
  let disposeSection;
16847
17466
  let disposeRoutes;
17467
+ /**
17468
+ * 生产单 runner 单例:**必须在 makeRoutes 之外**持有。
17469
+ *
17470
+ * 此前 runner 在 makeRoutes 工厂体内 `new`,而 `sync()` 每次配置变更都会
17471
+ * 重新 makeRoutes + dispose 全部旧路由 —— 于是:
17472
+ * 1. 旧 runner 的 `loop()` 仍在跑并继续 saveProject(失去引用但不停止),同时
17473
+ * 2. 新 runner 的 `isWorking()` 恒为 false,`moveOutputDir` 的守卫失效 →
17474
+ * 可以边迁移目录边写盘。
17475
+ * 提到这里之后,sync() 只重建路由表,runner 及其内存状态(working / 当前章 /
17476
+ * 暂停标志)跨重注册保持连续。
17477
+ */
17478
+ const runner = new ProductionRunner({
17479
+ ctx,
17480
+ getConfig: resolve
17481
+ });
16848
17482
  const sync = () => {
16849
17483
  if (disposeSection !== void 0) {
16850
17484
  disposeSection();
@@ -16866,7 +17500,8 @@ function apply(ctx, config) {
16866
17500
  getConfig: resolve,
16867
17501
  patchConfig,
16868
17502
  rawConfig: () => current(),
16869
- settingsNs
17503
+ settingsNs,
17504
+ runner
16870
17505
  });
16871
17506
  disposeRoutes = ctx.effect(() => {
16872
17507
  const disposers = routes.map((route) => ctx.webServer.register(route));