@waterwx/dsh-novel-forge 2.1.4 → 2.2.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.
package/lib/index.js CHANGED
@@ -3975,6 +3975,24 @@ function isRetryableLlmError(error) {
3975
3975
  return RETRYABLE_PATTERNS.some((re) => re.test(message));
3976
3976
  }
3977
3977
  /**
3978
+ * 判断一次失败是「取消」(作者关页面 / 主动中断)还是模型故障。
3979
+ *
3980
+ * 取消有两种现场:适配器给出 `aborted` 收尾块,或抛出 `AbortError` / 本插件自己的
3981
+ * 断开错误。它必须和真实失败分开记账——否则面板「本次运行失败 N 次」会把作者的正常
3982
+ * 中断统计成模型不稳定,进而误导备用模型档位判断(与规则 1「用户取消不重试」同一口径)。
3983
+ *
3984
+ * @param input 收尾块 kind(字符串)或捕获到的异常。
3985
+ * @returns 属于取消时为 true。
3986
+ */
3987
+ function isCancellation(input) {
3988
+ if (typeof input === "string") return input === "aborted";
3989
+ if (input instanceof Error) {
3990
+ if (input.name === "AbortError") return true;
3991
+ return /已取消|用户取消|客户端已断开|\baborted\b/i.test(input.message);
3992
+ }
3993
+ return false;
3994
+ }
3995
+ /**
3978
3996
  * 判断是否应当切换到备用模型。
3979
3997
  *
3980
3998
  * @param fallbackModel 配置的备用模型(空串 = 未配置)。
@@ -4205,6 +4223,16 @@ function applyModelCapability(base, model, options = {}) {
4205
4223
  //#endregion
4206
4224
  //#region src/stream-fallback.ts
4207
4225
  /**
4226
+ * 统一的取消错误:与 `DOMException('AbortError')` 同名,便于上层用 `name` 判定。
4227
+ * @param reason 取消原因(signal.reason,或适配器抛出的原始异常)。
4228
+ */
4229
+ function abortError(reason) {
4230
+ if (reason instanceof Error) return reason;
4231
+ const err = new Error(typeof reason === "string" ? reason : "客户端已断开连接");
4232
+ err.name = "AbortError";
4233
+ return err;
4234
+ }
4235
+ /**
4208
4236
  * 执行流式生成,主模型失败且「一个字都没产出」时换备用模型重试一次。
4209
4237
  *
4210
4238
  * 第三泛型(`TNext`)取 `unknown` 而非 `void`:三处调用方的外层生成器都声明为
@@ -4215,11 +4243,13 @@ function applyModelCapability(base, model, options = {}) {
4215
4243
  * @throws 两次都失败时抛最后一次的错误;成功但文本过短时抛「结果过短」。
4216
4244
  */
4217
4245
  async function* streamWithFallback(options) {
4218
- const { ctx, config, request, liveLabel, maxTokensError, minChars } = options;
4246
+ const { ctx, config, request, liveLabel, maxTokensError, minChars, signal } = options;
4219
4247
  const primaryModel = request.model ?? config.model;
4220
4248
  let text = "";
4221
4249
  let lastError;
4250
+ const abortedByCaller = () => signal?.aborted === true;
4222
4251
  for (let attempt = 0; attempt < 2; attempt++) {
4252
+ if (abortedByCaller()) throw abortError(signal?.reason);
4223
4253
  const model = attempt === 0 ? primaryModel : (config.fallbackModel ?? "").trim();
4224
4254
  if (model === "") break;
4225
4255
  const live = beginLiveCall({
@@ -4231,29 +4261,44 @@ async function* streamWithFallback(options) {
4231
4261
  const assembler = new BlockAssembler();
4232
4262
  let produced = 0;
4233
4263
  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
- };
4264
+ try {
4265
+ for await (const chunk of ctx.llm.stream(signal === void 0 ? converged : {
4266
+ ...converged,
4267
+ signal
4268
+ })) {
4269
+ assembler.push(chunk);
4270
+ if (chunk.type === "text-delta") {
4271
+ produced += chunk.text.length;
4272
+ markFirstToken(live);
4273
+ yield {
4274
+ frame: "delta",
4275
+ text: chunk.text
4276
+ };
4277
+ }
4243
4278
  }
4279
+ } catch (error) {
4280
+ const aborted = isCancellation(error) || abortedByCaller();
4281
+ endLiveCall(live, assembler, {
4282
+ chars: produced,
4283
+ phase: aborted ? "cancelled" : "failed",
4284
+ error: error instanceof Error ? error.message : String(error)
4285
+ });
4286
+ if (aborted) throw abortError(signal?.reason ?? error);
4287
+ throw error;
4244
4288
  }
4245
4289
  const finish = assembler.finish;
4246
4290
  lastError = void 0;
4247
- if (finish.kind === "error" || finish.kind === "aborted") lastError = /* @__PURE__ */ new Error(`${liveLabel}失败(${finish.kind}): ${finish.failure.message}`);
4291
+ if (finish.kind === "error" || finish.kind === "aborted") lastError = /* @__PURE__ */ new Error(`${isCancellation(finish.kind) ? "已取消" : `${liveLabel}失败`}(${finish.kind}): ${finish.failure.message}`);
4248
4292
  else if (finish.kind === "max-tokens") lastError = new Error(maxTokensError);
4249
4293
  text = assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
4294
+ const cancelled = lastError !== void 0 && isCancellation(lastError);
4250
4295
  endLiveCall(live, assembler, lastError === void 0 ? {
4251
4296
  chars: text.length,
4252
4297
  preview: text.slice(0, 320),
4253
4298
  phase: text === "" ? "failed" : "completed"
4254
4299
  } : {
4255
4300
  chars: text.length,
4256
- phase: "failed",
4301
+ phase: cancelled ? "cancelled" : "failed",
4257
4302
  error: lastError.message
4258
4303
  });
4259
4304
  if (lastError === void 0) break;
@@ -5368,6 +5413,8 @@ const NOVEL_API = {
5368
5413
  chapterCheck: "/api/dsh-novel-forge/chapter/check",
5369
5414
  /** 保存手动编辑的正文(自动备份 .bak)。 */
5370
5415
  chapterSave: "/api/dsh-novel-forge/chapter/save",
5416
+ /** 补写本章计划的固定六项(目标 / 必达 / 保持 / 硬事实 / 结尾钩子 / 剧情要点)。 */
5417
+ chapterPlan: "/api/dsh-novel-forge/chapter/plan",
5371
5418
  assistant: "/api/dsh-novel-forge/assistant",
5372
5419
  assistantHistory: "/api/dsh-novel-forge/assistant-history",
5373
5420
  /** 清空助手对话记录。 */
@@ -5647,26 +5694,44 @@ function saveProject(outputDir, project) {
5647
5694
  writeFileSync(tmp, data, "utf8");
5648
5695
  renameSync(tmp, target);
5649
5696
  }
5697
+ /** `mergeVolatileFromDisk` 会覆盖的易变字段的全集(也是 `keep` 的合法取值域)。 */
5698
+ const VOLATILE_FIELDS = [
5699
+ "bible",
5700
+ "roles",
5701
+ "plotlines",
5702
+ "roleStatus",
5703
+ "blurb",
5704
+ "coverPath",
5705
+ "facts",
5706
+ "assets",
5707
+ "world",
5708
+ "volumes"
5709
+ ];
5650
5710
  /**
5651
5711
  * 并发保护:长任务(章节计划生成/正文生成)在内存中持有旧快照,
5652
5712
  * 期间其他请求可能修改了「易变字段」(道藏/角色库/剧情线/人物志存档/简介/封面)。
5653
5713
  * 保存前用磁盘最新版本合并这些字段,避免旧快照覆盖新修改(曾导致角色卡丢失)。
5654
- * 注意:调用方若自己修改了这些字段,不要使用本函数。
5714
+ *
5715
+ * ⚠️ `keep` 不是可选的礼貌参数,而是本函数唯一的正确用法保证:
5716
+ * 调用方只要自己写过这些字段中的任何一个(哪怕只是 `project.plotlines[0].chapters.push(no)`),
5717
+ * **必须**把它列进 `keep`,否则本函数会用磁盘旧值把本轮成果整块覆盖掉。
5718
+ * 历史上正是漏了 `facts` / `plotlines`,导致作者复盘回灌的事实与剧情线关联被静默丢弃。
5719
+ *
5720
+ * @param outputDir - 项目目录。
5721
+ * @param project - 待保存的内存项目(原地修改)。
5722
+ * @param keep - 本次调用方已自行修改过的易变字段,这些字段不从磁盘覆盖。
5655
5723
  */
5656
- function mergeVolatileFromDisk(outputDir, project) {
5724
+ function mergeVolatileFromDisk(outputDir, project, keep = []) {
5657
5725
  try {
5658
5726
  const disk = loadProject(outputDir);
5659
5727
  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;
5728
+ const skip = new Set(keep);
5729
+ const target = project;
5730
+ const source = disk;
5731
+ for (const field of VOLATILE_FIELDS) {
5732
+ if (skip.has(field)) continue;
5733
+ target[field] = source[field];
5734
+ }
5670
5735
  } catch {}
5671
5736
  }
5672
5737
  /**
@@ -6051,6 +6116,22 @@ function readChapterFile(outputDir, chapter) {
6051
6116
  if (!existsSync(path)) return void 0;
6052
6117
  return readFileSync(path, "utf8");
6053
6118
  }
6119
+ /**
6120
+ * 章节正文的**写盘时间**(文件 mtime,ISO)—— 面板「今日产出 / 日环比」的唯一本地事实来源。
6121
+ *
6122
+ * 为什么不把它写进 novel-project.json:它是**派生数据**。落盘后一旦文件被外部替换、
6123
+ * 从备份回拷、或手工改名,存档里的时间戳就开始撒谎;mtime 永远跟着真实文件走。
6124
+ * 代价是每次 /status 要对每个有正文的章 stat 一次 —— 本地磁盘上是微秒级,
6125
+ * 相对它换来的"今天的产出是真的",这个代价可以接受。
6126
+ */
6127
+ function chapterWrittenAt(outputDir, chapter) {
6128
+ if (chapter.file === void 0) return void 0;
6129
+ try {
6130
+ return statSync(join(outputDir, chapter.file)).mtime.toISOString();
6131
+ } catch {
6132
+ return;
6133
+ }
6134
+ }
6054
6135
  /** Create a fresh project from an outline. */
6055
6136
  function createProject(outline, outlinePath) {
6056
6137
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -6105,15 +6186,26 @@ async function complete(ctx, config, options) {
6105
6186
  ...converged.reasoningEffort === void 0 ? {} : { reasoningEffort: ReasoningEffortId(converged.reasoningEffort) }
6106
6187
  };
6107
6188
  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);
6189
+ try {
6190
+ for await (const chunk of ctx.llm.stream(options.signal === void 0 ? request : {
6191
+ ...request,
6192
+ signal: options.signal
6193
+ })) {
6194
+ if (chunk.type === "text-delta") markFirstToken(live);
6195
+ assembler.push(chunk);
6196
+ }
6197
+ } catch (error) {
6198
+ endLiveCall(live, assembler, {
6199
+ phase: isCancellation(error) ? "cancelled" : "failed",
6200
+ error: error.message
6201
+ });
6202
+ throw error;
6111
6203
  }
6112
6204
  const finish = assembler.finish;
6113
6205
  if (finish.kind === "error" || finish.kind === "aborted") {
6114
- const message = `LLM 调用失败(${finish.kind}): ${finish.failure.message}`;
6206
+ const message = `${isCancellation(finish.kind) ? "调用已取消" : "LLM 调用失败"}(${finish.kind}): ${finish.failure.message}`;
6115
6207
  endLiveCall(live, assembler, {
6116
- phase: "failed",
6208
+ phase: isCancellation(finish.kind) ? "cancelled" : "failed",
6117
6209
  error: message
6118
6210
  });
6119
6211
  throw new Error(message);
@@ -8075,7 +8167,7 @@ function rewriteSystemPrompt(project, targetChars, beforeChapter) {
8075
8167
  * untouched (local revision). Without `target`, the whole chapter is
8076
8168
  * rewritten. Yields delta text; persists when done.
8077
8169
  */
8078
- async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo, instructions, target) {
8170
+ async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo, instructions, target, signal) {
8079
8171
  const chapter = project.chapters.find((c) => c.no === chapterNo);
8080
8172
  if (chapter === void 0) throw new NovelActionError("contract", `章节 ${chapterNo} 不在计划中`);
8081
8173
  const body = readChapterFile(outputDir, chapter);
@@ -8160,6 +8252,7 @@ async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo,
8160
8252
  ctx,
8161
8253
  config,
8162
8254
  request,
8255
+ signal,
8163
8256
  liveLabel: `修订 · 第${chapterNo}章`,
8164
8257
  liveUser: messages[0]?.content !== void 0 ? JSON.stringify(messages[0].content).slice(0, 2e3) : void 0,
8165
8258
  maxTokensError: "修订输出达到 maxTokens 上限,请增大配置后重试",
@@ -8205,7 +8298,7 @@ function polishSystemPrompt(project) {
8205
8298
  }
8206
8299
  /** Stream a chapter polish (de-AI-ify). Draft-mode: the polished body lands
8207
8300
  * in `chapter.pendingDraft` and is only applied on draft/apply. */
8208
- async function* polishChapterStream(ctx, config, project, outputDir, chapterNo) {
8301
+ async function* polishChapterStream(ctx, config, project, outputDir, chapterNo, signal) {
8209
8302
  const chapter = project.chapters.find((c) => c.no === chapterNo);
8210
8303
  if (chapter === void 0) throw new NovelActionError("contract", `章节 ${chapterNo} 不在计划中`);
8211
8304
  const body = readChapterFile(outputDir, chapter);
@@ -8234,6 +8327,7 @@ async function* polishChapterStream(ctx, config, project, outputDir, chapterNo)
8234
8327
  ctx,
8235
8328
  config,
8236
8329
  request,
8330
+ signal,
8237
8331
  liveLabel: `去 AI 味润色 · 第${chapterNo}章`,
8238
8332
  liveUser: body.slice(0, 2e3),
8239
8333
  maxTokensError: "润色输出达到 maxTokens 上限,请增大配置后重试",
@@ -8254,7 +8348,7 @@ function countHanzi(text) {
8254
8348
  return m === null ? 0 : m.length;
8255
8349
  }
8256
8350
  /** Generate one chapter (streaming). Yields progress frames; persists when done. */
8257
- async function* generateChapterStream(ctx, config, project, outputDir, chapterNo) {
8351
+ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo, signal) {
8258
8352
  const chapter = project.chapters.find((c) => c.no === chapterNo);
8259
8353
  if (chapter === void 0) throw new NovelActionError("contract", `章节 ${chapterNo} 不在计划中`);
8260
8354
  let continuity = "";
@@ -8341,6 +8435,7 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
8341
8435
  ctx,
8342
8436
  config,
8343
8437
  request,
8438
+ signal,
8344
8439
  liveLabel: `正文生成 · 第${chapter.no}章`,
8345
8440
  liveUser: user,
8346
8441
  maxTokensError: "达到 maxTokens 上限,正文可能不完整,请增大 maxTokens 后重试",
@@ -8410,7 +8505,7 @@ async function summarizeChapter(ctx, config, project, outputDir, chapterNo) {
8410
8505
  * 反向推大纲:从已写章节正文反推出全书总纲(分卷 + 章节要点 + 主线/人物弧线/伏笔清单)。
8411
8506
  * 两阶段:分批提取章节事件摘要 → 汇总生成大纲。不修改章节/设定,只返回大纲文本。
8412
8507
  */
8413
- async function reverseOutlineFromChapters(ctx, config, project, outputDir, onProgress) {
8508
+ async function reverseOutlineFromChapters(ctx, config, project, outputDir, onProgress, signal) {
8414
8509
  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
8510
  if (written.length === 0) throw new NovelActionError("contract", "本书还没有已写章节,无法反推大纲");
8416
8511
  const BATCH = 10;
@@ -8425,7 +8520,8 @@ async function reverseOutlineFromChapters(ctx, config, project, outputDir, onPro
8425
8520
  }).join("\n\n---\n\n"),
8426
8521
  temperature: .2,
8427
8522
  maxTokens: Math.max(config.maxTokens, 3e3),
8428
- reasoning: config.analysisReasoning ?? "low"
8523
+ reasoning: config.analysisReasoning ?? "low",
8524
+ signal
8429
8525
  });
8430
8526
  notes.push(note.trim());
8431
8527
  onProgress?.(Math.min(i + BATCH, total), total, "章节摘要");
@@ -8443,7 +8539,8 @@ async function reverseOutlineFromChapters(ctx, config, project, outputDir, onPro
8443
8539
  user: notes.join("\n\n"),
8444
8540
  temperature: .4,
8445
8541
  maxTokens: Math.max(config.maxTokens, 6e3),
8446
- reasoning: config.analysisReasoning ?? "low"
8542
+ reasoning: config.analysisReasoning ?? "low",
8543
+ signal
8447
8544
  });
8448
8545
  onProgress?.(total, total, "完成");
8449
8546
  return outline.trim();
@@ -8712,12 +8809,19 @@ async function rewriteAdaptationBook(ctx, config, text, mappings, rules, options
8712
8809
  ].filter((s) => s !== "").join("\n\n");
8713
8810
  let body = "";
8714
8811
  try {
8812
+ if (options.signal?.aborted === true) return {
8813
+ adaptedText: adaptedParts.join("\n"),
8814
+ rewritten,
8815
+ skipped,
8816
+ hits
8817
+ };
8715
8818
  body = stripRewriteHeading(await complete(ctx, config, {
8716
8819
  system,
8717
8820
  user,
8718
8821
  temperature: .7,
8719
8822
  maxTokens: Math.max(config.maxTokens, Math.min(16e3, c.body.length * 3)),
8720
- reasoning: config.analysisReasoning ?? "low"
8823
+ reasoning: config.analysisReasoning ?? "low",
8824
+ signal: options.signal
8721
8825
  }));
8722
8826
  if (body.length < 50) body = "";
8723
8827
  } catch {
@@ -10532,15 +10636,19 @@ function revisionTodoText(item) {
10532
10636
  /**
10533
10637
  * 选出生产单下一章要处理的章。
10534
10638
  *
10535
- * 三种章:已 `approved` 快进;连败达上限的熔断跳过;其余为下一章。
10639
+ * 四种章:已 `approved` 快进;已列入 `pendingManual` 的快进(人工已接管);
10640
+ * 连败达上限的熔断跳过;其余为下一章。
10536
10641
  *
10537
10642
  * @param chapters - 全书章节计划。
10538
10643
  * @param currentNo - 当前章号(含)。
10539
10644
  * @param endNo - 区间终点(含)。
10540
10645
  * @param failedAttempts - 章号 → 已失败次数(来自 `RunState.failedAttempts`)。
10646
+ * @param pendingManual - 已列入待人工的章号(来自 `RunState.pendingManual`)。
10647
+ * 必须传入,否则重写 N 轮仍不过的章会被反复选中,每轮烧两次 rewrite + 两次 verify。
10541
10648
  */
10542
- function pickNextChapter(chapters, currentNo, endNo, failedAttempts) {
10649
+ function pickNextChapter(chapters, currentNo, endNo, failedAttempts, pendingManual = []) {
10543
10650
  const circuitBroken = [];
10651
+ const manual = new Set(pendingManual);
10544
10652
  let advanceTo = currentNo - 1;
10545
10653
  for (let no = currentNo; no <= endNo; no++) {
10546
10654
  const ch = chapters.find((c) => c.no === no);
@@ -10549,6 +10657,10 @@ function pickNextChapter(chapters, currentNo, endNo, failedAttempts) {
10549
10657
  advanceTo = no;
10550
10658
  continue;
10551
10659
  }
10660
+ if (manual.has(no)) {
10661
+ advanceTo = no;
10662
+ continue;
10663
+ }
10552
10664
  const attempts = failedAttempts[String(no)] ?? 0;
10553
10665
  if (attempts >= 3) {
10554
10666
  circuitBroken.push({
@@ -10768,7 +10880,7 @@ var ProductionRunner = class {
10768
10880
  this.persist();
10769
10881
  break;
10770
10882
  }
10771
- const picked = pickNextChapter(project.chapters, this.state.currentNo, this.state.endNo, this.state.failedAttempts ?? {});
10883
+ const picked = pickNextChapter(project.chapters, this.state.currentNo, this.state.endNo, this.state.failedAttempts ?? {}, this.state.pendingManual ?? []);
10772
10884
  if (picked.advanceTo >= this.state.currentNo) this.state.currentNo = picked.advanceTo;
10773
10885
  for (const skipped of picked.circuitBroken) this.skipFailedChapter(skipped.chapter, skipped.attempts);
10774
10886
  if (picked.next === void 0) {
@@ -10903,11 +11015,12 @@ var ProductionRunner = class {
10903
11015
  text
10904
11016
  });
10905
11017
  }
10906
- mergeVolatileFromDisk(outputDir, project);
11018
+ mergeVolatileFromDisk(outputDir, project, ["plotlines", "facts"]);
10907
11019
  saveProject(outputDir, project);
10908
11020
  }
10909
11021
  } catch (e) {
10910
11022
  console.warn("[dsh-novel-forge] run author review:", e.message);
11023
+ this.log(`第${no}章 作者复盘失败(正文已保留,可稍后补跑复盘):${e.message}`);
10911
11024
  }
10912
11025
  } catch (error) {
10913
11026
  chapter.status = "error";
@@ -10973,8 +11086,15 @@ var ProductionRunner = class {
10973
11086
  baseReport: verify
10974
11087
  });
10975
11088
  }
10976
- if (this.state !== null) this.state.pendingManual.push(no);
10977
- this.log(`第${no}章 2 轮修订仍不过 → 保留草稿待人工`);
11089
+ if (this.state !== null) {
11090
+ if (!this.state.pendingManual.includes(no)) {
11091
+ this.state.pendingManual.push(no);
11092
+ this.state.pendingManual.sort((a, b) => a - b);
11093
+ }
11094
+ this.state.currentNo = no + 1;
11095
+ this.persist();
11096
+ }
11097
+ this.log(`第${no}章 2 轮修订仍不过 → 保留草稿待人工(已跳过,可修正后单独重试该章)`);
10978
11098
  }
10979
11099
  applyDraft(project, chapter, draft, report) {
10980
11100
  const config = this.deps.getConfig();
@@ -11937,7 +12057,7 @@ function historyToMessages(history) {
11937
12057
  return messages;
11938
12058
  }
11939
12059
  /** One non-streaming LLM chat turn (used inside the tool loop). */
11940
- async function chatOnce(ctx, config, system, history) {
12060
+ async function chatOnce(ctx, config, system, history, signal) {
11941
12061
  const messages = historyToMessages(history);
11942
12062
  const last = messages[messages.length - 1];
11943
12063
  if (last?.role === "user" && Array.isArray(last.content)) {
@@ -11972,15 +12092,27 @@ async function chatOnce(ctx, config, system, history) {
11972
12092
  user: messages.map((m) => JSON.stringify(m.content)).join(" / ").slice(0, 8e3)
11973
12093
  });
11974
12094
  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);
12095
+ try {
12096
+ for await (const chunk of ctx.llm.stream(signal === void 0 ? request : {
12097
+ ...request,
12098
+ signal
12099
+ })) {
12100
+ if (chunk.type === "text-delta") markFirstToken(live);
12101
+ assembler.push(chunk);
12102
+ }
12103
+ } catch (error) {
12104
+ endLiveCall(live, assembler, {
12105
+ phase: isCancellation(error) || signal?.aborted === true ? "cancelled" : "failed",
12106
+ error: error instanceof Error ? error.message : String(error)
12107
+ });
12108
+ throw error;
11978
12109
  }
11979
12110
  const finish = assembler.finish;
11980
12111
  if (finish.kind === "error" || finish.kind === "aborted") {
11981
- const message = `助手调用失败(${finish.kind}): ${finish.failure.message}`;
12112
+ const cancelled = isCancellation(finish.kind);
12113
+ const message = `${cancelled ? "助手对话已取消" : "助手调用失败"}(${finish.kind}): ${finish.failure.message}`;
11982
12114
  endLiveCall(live, assembler, {
11983
- phase: "failed",
12115
+ phase: cancelled ? "cancelled" : "failed",
11984
12116
  error: message
11985
12117
  });
11986
12118
  throw new Error(message);
@@ -12004,7 +12136,7 @@ async function chatOnce(ctx, config, system, history) {
12004
12136
  }, runOnce);
12005
12137
  }
12006
12138
  /** Run one user turn. Yields stream frames; persists history. */
12007
- async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
12139
+ async function* runAssistantTurn(ctx, config, project, outputDir, userMessage, signal) {
12008
12140
  const history = loadAssistantHistory(outputDir);
12009
12141
  const stage = computeBookStage(project);
12010
12142
  const strictStage = isGenericContinue(userMessage);
@@ -12071,7 +12203,8 @@ async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
12071
12203
  };
12072
12204
  for (;;) {
12073
12205
  if (iterations++ > 20) break;
12074
- const reply = await chatOnce(ctx, config, system, history);
12206
+ if (signal?.aborted === true) throw new Error("客户端已断开连接");
12207
+ const reply = await chatOnce(ctx, config, system, history, signal);
12075
12208
  if (reply.length > 120 && /^[0-9a-fA-F\s]+$/.test(reply.slice(0, 2e3))) {
12076
12209
  garbleCount++;
12077
12210
  if (garbleCount >= 2) {
@@ -12805,6 +12938,12 @@ function addGlobalMode(m) {
12805
12938
  //#region src/routes.ts
12806
12939
  /** Cap on JSON request bodies (generous: cover images travel as base64). */
12807
12940
  const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024;
12941
+ /** 允许「服务器本地文件」模式导入的扩展名:纯文本小说。不含 docx/pdf 等二进制。 */
12942
+ const IMPORTABLE_EXTENSIONS = [
12943
+ ".txt",
12944
+ ".md",
12945
+ ".markdown"
12946
+ ];
12808
12947
  /** 包内置风格效果图目录(assets/styles,随 npm 包分发)。 */
12809
12948
  /** 解析请求级目标书目录:优先按请求携带的 bookId 查书架,否则回退全局 active 书目录。 */
12810
12949
  function resolveOutputDir(config, bookId) {
@@ -12814,6 +12953,42 @@ function resolveOutputDir(config, bookId) {
12814
12953
  }
12815
12954
  return config.outputDir;
12816
12955
  }
12956
+ /**
12957
+ * 校验一个「客户端自报的目录」是否确实是已登记的书目录。
12958
+ *
12959
+ * 背景:部分路由(封面读取等)历史上直接采信 `?dir=` 参数拼路径读文件。
12960
+ * 本服务只有 loopback 围栏、没有 token,而围栏允许 `Origin` 缺失的请求
12961
+ * (非浏览器客户端、curl、恶意扩展都能过),所以形如
12962
+ * `GET /cover?dir=C:/Users/x` 会让服务读出该目录下任意 `novel-project.json`
12963
+ * 指向的文件并 base64 回吐。
12964
+ *
12965
+ * 正确做法不是「校验路径合法」(攻击者可以传任意合法路径),而是
12966
+ * **只承认自己登记过的目录**:书架里的每本书 + 设置里的默认输出目录。
12967
+ * 攻击者能传的值因此被压缩到一个可枚举的集合内。
12968
+ *
12969
+ * @returns 归一化后的目录;不在白名单内返回 undefined。
12970
+ */
12971
+ function resolveRegisteredDir(config, requested) {
12972
+ if (requested === null || requested === "") return config.outputDir;
12973
+ let normalized;
12974
+ try {
12975
+ normalized = resolve(requested).replace(/[\\/]+$/, "");
12976
+ } catch {
12977
+ return;
12978
+ }
12979
+ const store = loadBookshelf();
12980
+ const allowed = [config.outputDir, ...store.books.map((b) => b.outputDir)];
12981
+ for (const dir of allowed) {
12982
+ if (dir === "") continue;
12983
+ let candidate;
12984
+ try {
12985
+ candidate = resolve(dir).replace(/[\\/]+$/, "");
12986
+ } catch {
12987
+ continue;
12988
+ }
12989
+ if (candidate.toLowerCase() === normalized.toLowerCase()) return candidate;
12990
+ }
12991
+ }
12817
12992
  /** Loopback-only fence (mirrors the family plugins' pairing routes). */
12818
12993
  function isLoopbackRequest(request) {
12819
12994
  const address = request.socket.remoteAddress;
@@ -12861,6 +13036,50 @@ async function readJsonBody(req) {
12861
13036
  return;
12862
13037
  }
12863
13038
  }
13039
+ /**
13040
+ * 把「客户端是否还在听」绑成一个 `AbortSignal`。
13041
+ *
13042
+ * ## 为什么必须有
13043
+ *
13044
+ * Node 的 `res.write()` 对已销毁的 socket **不抛异常也不返回 false** ——
13045
+ * 请求处理器会继续跑完。7 条 NDJSON 路由此前零 `close` 监听,于是:
13046
+ * 浏览器关标签页 → `for await (const step of generateChapterStream(...))`
13047
+ * 继续跑完整章生成(2-5 分钟,maxTokens 常 20000),审稿、复盘、落盘全部照跑。
13048
+ * **「关掉页面」不等于「停止烧 token」**,而且 `chapter.status` 已被写盘为
13049
+ * `generating`,只能靠 `/status` 的 10 分钟兜底复位。
13050
+ *
13051
+ * 宿主 SDK 明确约定「implementations must honor `options.signal`」
13052
+ * (`@deepseek-ai/dsh-llm` `LLMAdapter.stream` 文档),所以只要把 signal
13053
+ * 一路传到 `ctx.llm.stream` 就能真正掐断。
13054
+ *
13055
+ * ## 用法
13056
+ *
13057
+ * ```ts
13058
+ * const abort = abortOnClientClose(req, res)
13059
+ * try {
13060
+ * for await (const step of generateChapterStream(ctx, config, project, dir, no, abort.signal)) { ... }
13061
+ * } catch (err) {
13062
+ * if (isAbortError(err)) return // 客户端主动断开:静默收尾,不回错误帧
13063
+ * ...
13064
+ * }
13065
+ * ```
13066
+ *
13067
+ * @returns controller;`signal` 传给下游,`aborted` 可直接判断。
13068
+ */
13069
+ function abortOnClientClose(req, res) {
13070
+ const controller = new AbortController();
13071
+ const onClose = () => {
13072
+ if (!res.writableEnded) controller.abort(/* @__PURE__ */ new Error("客户端已断开连接"));
13073
+ };
13074
+ res.once("close", onClose);
13075
+ req.once("aborted", onClose);
13076
+ return controller;
13077
+ }
13078
+ /** 判断错误是否为「客户端断开」导致的中止(据此静默收尾而不是回错误帧)。 */
13079
+ function isAbortError(err) {
13080
+ if (err instanceof Error && (err.name === "AbortError" || err.message.includes("客户端已断开连接"))) return true;
13081
+ return false;
13082
+ }
12864
13083
  /** Default chapter count for planning when the request omits it. */
12865
13084
  const DEFAULT_PLAN_COUNT = 30;
12866
13085
  /**
@@ -12906,15 +13125,39 @@ function makeRoutes(deps) {
12906
13125
  return true;
12907
13126
  };
12908
13127
  /** Load (and sync) the project, or respond 400. */
12909
- const requireProject = (res) => {
12910
- const config = getConfig();
12911
- const project = loadProject(config.outputDir);
13128
+ /**
13129
+ * 取本次请求的目标项目。
13130
+ *
13131
+ * 目录解析与 {@link resolveOutputDir} 同源:按请求携带的 `bookId` 查书架,
13132
+ * 未携带才回退全局 active 书。**此前本函数只看 `getConfig().outputDir`**,
13133
+ * 而约一半路由走它、另一半走 resolveOutputDir,前端每个请求都带 bookId
13134
+ * 却被后端一半静默忽略 —— 多书场景下对非激活书调用会写到激活书目录。
13135
+ *
13136
+ * 顺带修掉写盘副作用:`syncProjectWithDisk` 无条件更新 `updatedAt`,
13137
+ * 使 `saveProject` 的 no-op 检测永不命中,于是每个「读」请求都在重写整个
13138
+ * novel-project.json(长篇项目可达数MB,每 4 秒一次)。这里改成
13139
+ * 同步后按内容比对,仅在真的变化时落盘。
13140
+ *
13141
+ * @param req - 用于解析 `bookId`(GET 查 query,POST 查 body 由调用方保证)。
13142
+ * @param res - 用于回写错误。
13143
+ * @param body - POST 请求体(可能含 bookId)。
13144
+ */
13145
+ const requireProjectAt = (req, res, body) => {
13146
+ const outputDir = resolveOutputDir(getConfig(), body?.bookId ?? (() => {
13147
+ try {
13148
+ return new URL(req.url ?? "/", "http://localhost").searchParams.get("bookId") ?? void 0;
13149
+ } catch {
13150
+ return;
13151
+ }
13152
+ })() ?? void 0);
13153
+ const project = loadProject(outputDir);
12912
13154
  if (project === void 0) {
12913
13155
  writeJson(res, 400, { error: "输出目录中没有项目,请先加载大纲" });
12914
13156
  return;
12915
13157
  }
12916
- syncProjectWithDisk(project, config.outputDir);
12917
- saveProject(config.outputDir, project);
13158
+ const before = JSON.stringify(project);
13159
+ syncProjectWithDisk(project, outputDir);
13160
+ if (JSON.stringify(project) !== before) saveProject(outputDir, project);
12918
13161
  return project;
12919
13162
  };
12920
13163
  const statusRoute = {
@@ -12939,6 +13182,11 @@ function makeRoutes(deps) {
12939
13182
  syncProjectWithDisk(project, outputDir);
12940
13183
  saveProject(outputDir, project);
12941
13184
  }
13185
+ const writtenAtByNo = /* @__PURE__ */ new Map();
13186
+ for (const c of project?.chapters ?? []) {
13187
+ const at = chapterWrittenAt(outputDir, c);
13188
+ if (at !== void 0) writtenAtByNo.set(c.no, at);
13189
+ }
12942
13190
  const slim = new URL(req.url ?? "/", "http://localhost").searchParams.get("slim") === "1";
12943
13191
  const truncations = [];
12944
13192
  let projectPayload;
@@ -12958,7 +13206,8 @@ function makeRoutes(deps) {
12958
13206
  review: c.review !== void 0 ? {
12959
13207
  score: c.review.score,
12960
13208
  passed: c.review.passed
12961
- } : void 0
13209
+ } : void 0,
13210
+ writtenAt: writtenAtByNo.get(c.no)
12962
13211
  })),
12963
13212
  createdAt: project.createdAt,
12964
13213
  updatedAt: project.updatedAt
@@ -12969,6 +13218,10 @@ function makeRoutes(deps) {
12969
13218
  if (allFacts.length > shownFacts.length) truncations.push(`编年录:共 ${allFacts.length} 条,仅返回最近 ${shownFacts.length} 条(较早 ${allFacts.length - shownFacts.length} 条未返回)`);
12970
13219
  projectPayload = {
12971
13220
  ...project,
13221
+ chapters: project.chapters.map((c) => ({
13222
+ ...c,
13223
+ writtenAt: writtenAtByNo.get(c.no)
13224
+ })),
12972
13225
  facts: shownFacts
12973
13226
  };
12974
13227
  }
@@ -13183,46 +13436,18 @@ function makeRoutes(deps) {
13183
13436
  const send = (frame) => {
13184
13437
  res.write(JSON.stringify(frame) + "\n");
13185
13438
  };
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
- });
13439
+ const abort = abortOnClientClose(req, res);
13205
13440
  try {
13206
13441
  send({
13207
13442
  type: "start",
13208
13443
  no,
13209
13444
  title: chapter.title
13210
13445
  });
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({
13446
+ for await (const step of generateChapterStream(ctx, config, project, config.outputDir, no, abort.signal)) if (step.frame === "delta") send({
13447
+ type: "delta",
13448
+ text: step.text
13449
+ });
13450
+ else if (step.frame === "done") send({
13226
13451
  type: "done",
13227
13452
  no,
13228
13453
  file: step.file,
@@ -13230,14 +13455,6 @@ function makeRoutes(deps) {
13230
13455
  title: chapter.title,
13231
13456
  warn: step.warn
13232
13457
  });
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
13458
  try {
13242
13459
  const wantTimeline = config.autoTimeline ?? true;
13243
13460
  await summarizeFactsAndTimeline(ctx, config, project, config.outputDir, no, {
@@ -13274,7 +13491,7 @@ function makeRoutes(deps) {
13274
13491
  const review = await authorReviewChapter(ctx, config, project, no, currentBody, prevTail);
13275
13492
  chapter.authorReview = review;
13276
13493
  if (review.advancedLines !== void 0) autoLinkPlotlines(project, no, review.advancedLines);
13277
- mergeVolatileFromDisk(config.outputDir, project);
13494
+ mergeVolatileFromDisk(config.outputDir, project, ["plotlines"]);
13278
13495
  saveProject(config.outputDir, project);
13279
13496
  send({
13280
13497
  type: "author-review",
@@ -13287,6 +13504,14 @@ function makeRoutes(deps) {
13287
13504
  }
13288
13505
  res.end();
13289
13506
  } catch (error) {
13507
+ if (isAbortError(error)) {
13508
+ chapter.status = "pending";
13509
+ chapter.generatingAt = void 0;
13510
+ chapter.error = void 0;
13511
+ syncProjectWithDisk(project, config.outputDir);
13512
+ saveProject(config.outputDir, project);
13513
+ return;
13514
+ }
13290
13515
  chapter.status = "error";
13291
13516
  chapter.error = error.message;
13292
13517
  mergeVolatileFromDisk(config.outputDir, project);
@@ -13362,8 +13587,9 @@ function makeRoutes(deps) {
13362
13587
  const send = (frame) => {
13363
13588
  res.write(JSON.stringify(frame) + "\n");
13364
13589
  };
13590
+ const abort = abortOnClientClose(req, res);
13365
13591
  try {
13366
- for await (const step of rewriteChapterStream(ctx, config, project, config.outputDir, no, body?.instructions ?? "", body?.target)) if (step.frame === "delta") send({
13592
+ for await (const step of rewriteChapterStream(ctx, config, project, config.outputDir, no, body?.instructions ?? "", body?.target, abort.signal)) if (step.frame === "delta") send({
13367
13593
  type: "delta",
13368
13594
  text: step.text
13369
13595
  });
@@ -13375,6 +13601,7 @@ function makeRoutes(deps) {
13375
13601
  });
13376
13602
  res.end();
13377
13603
  } catch (error) {
13604
+ if (isAbortError(error)) return;
13378
13605
  if (!res.writableEnded) {
13379
13606
  send({
13380
13607
  type: "error",
@@ -13417,8 +13644,9 @@ function makeRoutes(deps) {
13417
13644
  const send = (frame) => {
13418
13645
  res.write(JSON.stringify(frame) + "\n");
13419
13646
  };
13647
+ const abort = abortOnClientClose(req, res);
13420
13648
  try {
13421
- for await (const step of polishChapterStream(ctx, config, project, config.outputDir, no)) if (step.frame === "delta") send({
13649
+ for await (const step of polishChapterStream(ctx, config, project, config.outputDir, no, abort.signal)) if (step.frame === "delta") send({
13422
13650
  type: "delta",
13423
13651
  text: step.text
13424
13652
  });
@@ -13430,6 +13658,7 @@ function makeRoutes(deps) {
13430
13658
  });
13431
13659
  res.end();
13432
13660
  } catch (error) {
13661
+ if (isAbortError(error)) return;
13433
13662
  if (!res.writableEnded) {
13434
13663
  send({
13435
13664
  type: "error",
@@ -13448,7 +13677,7 @@ function makeRoutes(deps) {
13448
13677
  handler: async (req, res) => {
13449
13678
  if (!guard(req, res, "POST")) return;
13450
13679
  const config = getConfig();
13451
- const project = requireProject(res);
13680
+ const project = requireProjectAt(req, res);
13452
13681
  if (project === void 0) return;
13453
13682
  const body = await readJsonBody(req);
13454
13683
  if (!Number.isInteger(body?.chapterNo)) {
@@ -13499,7 +13728,7 @@ function makeRoutes(deps) {
13499
13728
  handler: async (req, res) => {
13500
13729
  if (!guard(req, res, "POST")) return;
13501
13730
  const config = getConfig();
13502
- const project = requireProject(res);
13731
+ const project = requireProjectAt(req, res);
13503
13732
  if (project === void 0) return;
13504
13733
  const body = await readJsonBody(req);
13505
13734
  if (!Number.isInteger(body?.chapterNo)) {
@@ -13525,7 +13754,7 @@ function makeRoutes(deps) {
13525
13754
  handler: async (req, res) => {
13526
13755
  if (!guard(req, res, "POST")) return;
13527
13756
  const config = getConfig();
13528
- const project = requireProject(res);
13757
+ const project = requireProjectAt(req, res);
13529
13758
  if (project === void 0) return;
13530
13759
  const body = await readJsonBody(req);
13531
13760
  if (!Number.isInteger(body?.chapterNo)) {
@@ -13669,7 +13898,7 @@ function makeRoutes(deps) {
13669
13898
  handler: async (req, res) => {
13670
13899
  if (!guard(req, res, "POST")) return;
13671
13900
  const config = getConfig();
13672
- const project = requireProject(res);
13901
+ const project = requireProjectAt(req, res);
13673
13902
  if (project === void 0) return;
13674
13903
  const body = await readJsonBody(req);
13675
13904
  const text = body?.text?.trim() ?? "";
@@ -13691,7 +13920,7 @@ function makeRoutes(deps) {
13691
13920
  handler: async (req, res) => {
13692
13921
  if (!guard(req, res, "POST")) return;
13693
13922
  const config = getConfig();
13694
- const project = requireProject(res);
13923
+ const project = requireProjectAt(req, res);
13695
13924
  if (project === void 0) return;
13696
13925
  const body = await readJsonBody(req);
13697
13926
  if (!Number.isInteger(body?.chapterNo)) {
@@ -13762,8 +13991,9 @@ function makeRoutes(deps) {
13762
13991
  const send = (frame) => {
13763
13992
  res.write(JSON.stringify(frame) + "\n");
13764
13993
  };
13994
+ const abort = abortOnClientClose(req, res);
13765
13995
  try {
13766
- for await (const step of runAssistantTurn(ctx, config, project, outputDir, message)) if (step.frame === "delta") send({
13996
+ for await (const step of runAssistantTurn(ctx, config, project, outputDir, message, abort.signal)) if (step.frame === "delta") send({
13767
13997
  type: "delta",
13768
13998
  text: step.text
13769
13999
  });
@@ -13786,6 +14016,7 @@ function makeRoutes(deps) {
13786
14016
  send({ type: "done" });
13787
14017
  res.end();
13788
14018
  } catch (error) {
14019
+ if (isAbortError(error)) return;
13789
14020
  if (!res.writableEnded) {
13790
14021
  send({
13791
14022
  type: "error",
@@ -14923,6 +15154,10 @@ function makeRoutes(deps) {
14923
15154
  writeJson(res, 400, { error: "filePath 不能为空(或请上传文件内容)" });
14924
15155
  return;
14925
15156
  }
15157
+ if (!IMPORTABLE_EXTENSIONS.includes(extname(filePath).toLowerCase())) {
15158
+ writeJson(res, 400, { error: `仅支持纯文本小说文件(${IMPORTABLE_EXTENSIONS.join(" / ")})` });
15159
+ return;
15160
+ }
14926
15161
  if (!existsSync(filePath)) {
14927
15162
  writeJson(res, 400, { error: `文件不存在:${filePath}` });
14928
15163
  return;
@@ -15026,7 +15261,7 @@ function makeRoutes(deps) {
15026
15261
  handler: async (req, res) => {
15027
15262
  if (!guard(req, res, "POST")) return;
15028
15263
  const config = getConfig();
15029
- const project = requireProject(res);
15264
+ const project = requireProjectAt(req, res);
15030
15265
  if (project === void 0) return;
15031
15266
  try {
15032
15267
  const cards = await refreshCharacters(ctx, config, project, config.outputDir);
@@ -15046,7 +15281,7 @@ function makeRoutes(deps) {
15046
15281
  handler: async (req, res) => {
15047
15282
  if (!guard(req, res, "POST")) return;
15048
15283
  const config = getConfig();
15049
- const project = requireProject(res);
15284
+ const project = requireProjectAt(req, res);
15050
15285
  if (project === void 0) return;
15051
15286
  try {
15052
15287
  writeJson(res, 200, {
@@ -15065,7 +15300,7 @@ function makeRoutes(deps) {
15065
15300
  handler: async (req, res) => {
15066
15301
  if (!guard(req, res, "POST")) return;
15067
15302
  const config = getConfig();
15068
- const project = requireProject(res);
15303
+ const project = requireProjectAt(req, res);
15069
15304
  if (project === void 0) return;
15070
15305
  if (project.bible === void 0) {
15071
15306
  writeJson(res, 400, { error: "尚未生成道藏,请先生成" });
@@ -15100,7 +15335,7 @@ function makeRoutes(deps) {
15100
15335
  handler: async (req, res) => {
15101
15336
  if (!guard(req, res, "POST")) return;
15102
15337
  const config = getConfig();
15103
- const project = requireProject(res);
15338
+ const project = requireProjectAt(req, res);
15104
15339
  if (project === void 0) return;
15105
15340
  const body = await readJsonBody(req);
15106
15341
  try {
@@ -15134,8 +15369,11 @@ function makeRoutes(deps) {
15134
15369
  writeJson(res, 403, { error: "forbidden: loopback-only" });
15135
15370
  return;
15136
15371
  }
15137
- const dirParam = new URL(req.url ?? "/", "http://localhost").searchParams.get("dir");
15138
- const targetDir = dirParam !== null && dirParam !== "" ? dirParam : config.outputDir;
15372
+ const targetDir = resolveRegisteredDir(config, new URL(req.url ?? "/", "http://localhost").searchParams.get("dir"));
15373
+ if (targetDir === void 0) {
15374
+ writeJson(res, 400, { error: "dir 不是已登记的书籍目录" });
15375
+ return;
15376
+ }
15139
15377
  const coverPath = loadProject(targetDir)?.coverPath;
15140
15378
  if (coverPath === void 0 || coverPath === "") {
15141
15379
  writeJson(res, 200, { dataUrl: null });
@@ -15151,7 +15389,7 @@ function makeRoutes(deps) {
15151
15389
  }
15152
15390
  if (req.method === "POST") {
15153
15391
  if (!guard(req, res, "POST")) return;
15154
- const project = requireProject(res);
15392
+ const project = requireProjectAt(req, res);
15155
15393
  if (project === void 0) return;
15156
15394
  const body = await readJsonBody(req);
15157
15395
  try {
@@ -15207,7 +15445,7 @@ function makeRoutes(deps) {
15207
15445
  handler: async (req, res) => {
15208
15446
  if (!guard(req, res, "POST")) return;
15209
15447
  const config = getConfig();
15210
- const project = requireProject(res);
15448
+ const project = requireProjectAt(req, res);
15211
15449
  if (project === void 0) return;
15212
15450
  const body = await readJsonBody(req);
15213
15451
  try {
@@ -15239,7 +15477,7 @@ function makeRoutes(deps) {
15239
15477
  handler: async (req, res) => {
15240
15478
  if (!guard(req, res, "POST")) return;
15241
15479
  const config = getConfig();
15242
- const project = requireProject(res);
15480
+ const project = requireProjectAt(req, res);
15243
15481
  if (project === void 0) return;
15244
15482
  const bookName = (await readJsonBody(req))?.bookName?.trim();
15245
15483
  if (bookName === void 0 || bookName === "") {
@@ -15261,7 +15499,7 @@ function makeRoutes(deps) {
15261
15499
  handler: async (req, res) => {
15262
15500
  if (!guard(req, res, "POST")) return;
15263
15501
  const config = getConfig();
15264
- const project = requireProject(res);
15502
+ const project = requireProjectAt(req, res);
15265
15503
  if (project === void 0) return;
15266
15504
  const body = await readJsonBody(req);
15267
15505
  if (project.plotlines === void 0) project.plotlines = [];
@@ -15390,7 +15628,7 @@ function makeRoutes(deps) {
15390
15628
  handler: async (req, res) => {
15391
15629
  if (!guard(req, res, "POST")) return;
15392
15630
  const config = getConfig();
15393
- const project = requireProject(res);
15631
+ const project = requireProjectAt(req, res);
15394
15632
  if (project === void 0) return;
15395
15633
  const body = await readJsonBody(req);
15396
15634
  const hits = [];
@@ -15444,7 +15682,7 @@ function makeRoutes(deps) {
15444
15682
  handler: async (req, res) => {
15445
15683
  if (!guard(req, res, "POST")) return;
15446
15684
  const config = getConfig();
15447
- const project = requireProject(res);
15685
+ const project = requireProjectAt(req, res);
15448
15686
  if (project === void 0) return;
15449
15687
  const body = await readJsonBody(req);
15450
15688
  if (project.roles === void 0) project.roles = [];
@@ -15482,7 +15720,7 @@ function makeRoutes(deps) {
15482
15720
  handler: async (req, res) => {
15483
15721
  if (!guard(req, res, "POST")) return;
15484
15722
  const config = getConfig();
15485
- const project = requireProject(res);
15723
+ const project = requireProjectAt(req, res);
15486
15724
  if (project === void 0) return;
15487
15725
  const no = (await readJsonBody(req))?.chapterNo;
15488
15726
  if (!Number.isInteger(no) || no === void 0 || no < 1) {
@@ -15512,7 +15750,7 @@ function makeRoutes(deps) {
15512
15750
  handler: async (req, res) => {
15513
15751
  if (!guard(req, res, "POST")) return;
15514
15752
  const config = getConfig();
15515
- const project = requireProject(res);
15753
+ const project = requireProjectAt(req, res);
15516
15754
  if (project === void 0) return;
15517
15755
  const no = (await readJsonBody(req))?.chapterNo;
15518
15756
  if (!Number.isInteger(no) || no === void 0 || no < 1) {
@@ -15533,6 +15771,73 @@ function makeRoutes(deps) {
15533
15771
  });
15534
15772
  }
15535
15773
  };
15774
+ /**
15775
+ * 补写本章计划(方案 §7「左侧章节计划」六项)。
15776
+ *
15777
+ * 这些字段原本只有规划步骤会写:AI 排偏了,作者却没有纠正入口,而它们正是生成
15778
+ * 与审稿的锚点(见 engine 的本章义务合约拼装)。上限与规划步骤保持一致 ——
15779
+ * 否则「手动能写 10 条、AI 一重排只剩 4 条」就是静默丢数据;超限必然回写 notices。
15780
+ */
15781
+ const chapterPlanRoute = {
15782
+ kind: "exact",
15783
+ path: NOVEL_API.chapterPlan,
15784
+ handler: async (req, res) => {
15785
+ if (!guard(req, res, "POST")) return;
15786
+ const config = getConfig();
15787
+ const project = requireProjectAt(req, res);
15788
+ if (project === void 0) return;
15789
+ const body = await readJsonBody(req);
15790
+ const no = body?.chapterNo;
15791
+ if (!Number.isInteger(no) || no === void 0 || no < 1) {
15792
+ writeJson(res, 400, { error: "chapterNo 须为正整数" });
15793
+ return;
15794
+ }
15795
+ const chapter = project.chapters.find((c) => c.no === no);
15796
+ if (chapter === void 0) {
15797
+ writeJson(res, 404, { error: `章节 ${no} 不在计划中` });
15798
+ return;
15799
+ }
15800
+ const patch = body?.patch;
15801
+ if (patch === null || typeof patch !== "object") {
15802
+ writeJson(res, 400, { error: "patch 须为对象" });
15803
+ return;
15804
+ }
15805
+ const notices = [];
15806
+ const oneLine = (label, raw, max) => {
15807
+ if (typeof raw !== "string") return void 0;
15808
+ const value = raw.trim();
15809
+ if (value.length <= max) return value;
15810
+ notices.push(`${label}:${value.length} 字已裁到 ${max} 字`);
15811
+ return value.slice(0, max);
15812
+ };
15813
+ const items = (label, raw, max) => {
15814
+ if (!Array.isArray(raw)) return void 0;
15815
+ const value = raw.filter((x) => typeof x === "string" && x.trim() !== "").map((x) => x.trim());
15816
+ if (value.length <= max) return value;
15817
+ notices.push(`${label}:${value.length} 条只保留前 ${max} 条`);
15818
+ return value.slice(0, max);
15819
+ };
15820
+ const obligation = oneLine("本章目标", patch.obligation, 200);
15821
+ if (obligation !== void 0) chapter.obligation = obligation === "" ? void 0 : obligation;
15822
+ const mustAdvance = items("本章必达项", patch.mustAdvance, 4);
15823
+ if (mustAdvance !== void 0) chapter.mustAdvance = mustAdvance;
15824
+ const mustPreserve = items("必须保持", patch.mustPreserve, 4);
15825
+ if (mustPreserve !== void 0) chapter.mustPreserve = mustPreserve;
15826
+ const hardFacts = items("人物硬事实", patch.characterHardFacts, 6);
15827
+ if (hardFacts !== void 0) chapter.characterHardFacts = hardFacts;
15828
+ const endingHook = oneLine("结尾钩子", patch.endingHook, 120);
15829
+ if (endingHook !== void 0) chapter.endingHook = endingHook === "" ? void 0 : endingHook;
15830
+ const beats = oneLine("剧情推进", patch.beats, 2e3);
15831
+ if (beats !== void 0) chapter.beats = beats;
15832
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
15833
+ saveProject(config.outputDir, project);
15834
+ writeJson(res, 200, {
15835
+ ok: true,
15836
+ chapter,
15837
+ notices
15838
+ });
15839
+ }
15840
+ };
15536
15841
  /** 作者复盘补跑:对已写章节补齐 authorReview(body.chapterNo=单章 JSON,缺省=全书 NDJSON 流)。 */
15537
15842
  const reviewBackfillRoute = {
15538
15843
  kind: "exact",
@@ -15540,9 +15845,10 @@ function makeRoutes(deps) {
15540
15845
  handler: async (req, res) => {
15541
15846
  if (!guard(req, res, "POST")) return;
15542
15847
  const config = getConfig();
15543
- const project = requireProject(res);
15848
+ const project = requireProjectAt(req, res);
15544
15849
  if (project === void 0) return;
15545
15850
  const body = await readJsonBody(req);
15851
+ const abort = abortOnClientClose(req, res);
15546
15852
  /** 对一章执行作者复盘(读取已落盘正文,不改变章节状态/正文)。 */
15547
15853
  const runOne = async (chapter) => {
15548
15854
  const currentBody = readChapterFile(config.outputDir, chapter);
@@ -15552,6 +15858,7 @@ function makeRoutes(deps) {
15552
15858
  const prev = project.chapters.find((c) => c.no === chapter.no - 1);
15553
15859
  if (prev !== void 0) prevTail = stripChapterHeadings(readChapterFile(config.outputDir, prev) ?? "").slice(-600);
15554
15860
  }
15861
+ if (abort.signal.aborted) throw new Error("客户端已断开连接");
15555
15862
  return authorReviewChapter(ctx, config, project, chapter.no, currentBody, prevTail);
15556
15863
  };
15557
15864
  if (typeof body?.chapterNo === "number" && body.chapterNo > 0) {
@@ -15726,7 +16033,7 @@ function makeRoutes(deps) {
15726
16033
  handler: async (req, res) => {
15727
16034
  if (!guard(req, res, "POST")) return;
15728
16035
  const config = getConfig();
15729
- const project = requireProject(res);
16036
+ const project = requireProjectAt(req, res);
15730
16037
  if (project === void 0) return;
15731
16038
  res.writeHead(200, {
15732
16039
  "content-type": "application/x-ndjson; charset=utf-8",
@@ -15737,6 +16044,7 @@ function makeRoutes(deps) {
15737
16044
  const send = (frame) => {
15738
16045
  res.write(JSON.stringify(frame) + "\n");
15739
16046
  };
16047
+ const abort = abortOnClientClose(req, res);
15740
16048
  try {
15741
16049
  const outline = await reverseOutlineFromChapters(ctx, config, project, config.outputDir, (done, total, phase) => {
15742
16050
  send({
@@ -15745,7 +16053,7 @@ function makeRoutes(deps) {
15745
16053
  total,
15746
16054
  phase
15747
16055
  });
15748
- });
16056
+ }, abort.signal);
15749
16057
  project.outline = outline;
15750
16058
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
15751
16059
  saveProject(config.outputDir, project);
@@ -15755,6 +16063,7 @@ function makeRoutes(deps) {
15755
16063
  chars: outline.length
15756
16064
  });
15757
16065
  } catch (error) {
16066
+ if (isAbortError(error)) return;
15758
16067
  send({
15759
16068
  type: "error",
15760
16069
  no: 0,
@@ -15865,7 +16174,7 @@ function makeRoutes(deps) {
15865
16174
  handler: async (req, res) => {
15866
16175
  if (!guard(req, res, "POST")) return;
15867
16176
  const config = getConfig();
15868
- const project = requireProject(res);
16177
+ const project = requireProjectAt(req, res);
15869
16178
  if (project === void 0) return;
15870
16179
  const body = await readJsonBody(req);
15871
16180
  try {
@@ -15875,8 +16184,16 @@ function makeRoutes(deps) {
15875
16184
  }
15876
16185
  }
15877
16186
  };
15878
- /** 生产单执行器(单例):计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。 */
15879
- const runner = new ProductionRunner({
16187
+ /**
16188
+ * 生产单执行器:计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。
16189
+ *
16190
+ * 必须由调用方(index.ts)持有并注入,**不要在这里 new**:
16191
+ * 本工厂每次 `sync()` 都会重建,藏在闭包里的 runner 会被丢掉——
16192
+ * 旧实例的 `loop()` 仍在跑并继续写盘,而新实例 `isWorking()` 恒 false,
16193
+ * 于是 `moveOutputDir` 的「正在生产则拒绝迁移」守卫直接失效。
16194
+ * 测试可省略本项,内部自建一个一次性实例。
16195
+ */
16196
+ const runner = deps.runner ?? new ProductionRunner({
15880
16197
  ctx,
15881
16198
  getConfig
15882
16199
  });
@@ -16232,11 +16549,13 @@ function makeRoutes(deps) {
16232
16549
  const send = (frame) => {
16233
16550
  res.write(JSON.stringify(frame) + "\n");
16234
16551
  };
16552
+ const abort = abortOnClientClose(req, res);
16235
16553
  try {
16236
16554
  const result = await rewriteAdaptationBook(ctx, getConfig(), text, mappings, body?.rules, {
16237
16555
  maxChapters: body?.maxChapters,
16238
16556
  startNo: body?.startNo,
16239
16557
  endNo: body?.endNo,
16558
+ signal: abort.signal,
16240
16559
  onProgress: (info) => send({
16241
16560
  type: "progress",
16242
16561
  completed: info.completed,
@@ -16245,6 +16564,7 @@ function makeRoutes(deps) {
16245
16564
  title: info.title
16246
16565
  })
16247
16566
  });
16567
+ if (abort.signal.aborted) return;
16248
16568
  send({
16249
16569
  type: "done",
16250
16570
  result: {
@@ -16257,6 +16577,7 @@ function makeRoutes(deps) {
16257
16577
  }
16258
16578
  });
16259
16579
  } catch (err) {
16580
+ if (isAbortError(err)) return;
16260
16581
  send({
16261
16582
  type: "error",
16262
16583
  message: err instanceof Error ? err.message : String(err)
@@ -16470,6 +16791,7 @@ function makeRoutes(deps) {
16470
16791
  reviewBackfillRoute,
16471
16792
  chapterResetRoute,
16472
16793
  chapterApproveRoute,
16794
+ chapterPlanRoute,
16473
16795
  configRoute,
16474
16796
  moveOutputDirRoute,
16475
16797
  bookSettingsRoute,
@@ -16845,6 +17167,21 @@ function apply(ctx, config) {
16845
17167
  };
16846
17168
  let disposeSection;
16847
17169
  let disposeRoutes;
17170
+ /**
17171
+ * 生产单 runner 单例:**必须在 makeRoutes 之外**持有。
17172
+ *
17173
+ * 此前 runner 在 makeRoutes 工厂体内 `new`,而 `sync()` 每次配置变更都会
17174
+ * 重新 makeRoutes + dispose 全部旧路由 —— 于是:
17175
+ * 1. 旧 runner 的 `loop()` 仍在跑并继续 saveProject(失去引用但不停止),同时
17176
+ * 2. 新 runner 的 `isWorking()` 恒为 false,`moveOutputDir` 的守卫失效 →
17177
+ * 可以边迁移目录边写盘。
17178
+ * 提到这里之后,sync() 只重建路由表,runner 及其内存状态(working / 当前章 /
17179
+ * 暂停标志)跨重注册保持连续。
17180
+ */
17181
+ const runner = new ProductionRunner({
17182
+ ctx,
17183
+ getConfig: resolve
17184
+ });
16848
17185
  const sync = () => {
16849
17186
  if (disposeSection !== void 0) {
16850
17187
  disposeSection();
@@ -16866,7 +17203,8 @@ function apply(ctx, config) {
16866
17203
  getConfig: resolve,
16867
17204
  patchConfig,
16868
17205
  rawConfig: () => current(),
16869
- settingsNs
17206
+ settingsNs,
17207
+ runner
16870
17208
  });
16871
17209
  disposeRoutes = ctx.effect(() => {
16872
17210
  const disposers = routes.map((route) => ctx.webServer.register(route));