@waterwx/dsh-novel-forge 2.5.0 → 2.5.1

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
@@ -278,10 +278,26 @@ function scanAiFlavor(text, rules) {
278
278
  const totalChars = text.length;
279
279
  const clicheHits = [];
280
280
  let weightedTotal = 0;
281
- for (const word of Object.keys(PATTERN_COVERAGE)) {
282
- const count = countOccurrences$1(text, word);
283
- if (count === 0) continue;
281
+ const occupied = new Uint8Array(text.length);
282
+ const patterns = Object.keys(PATTERN_COVERAGE).sort((a, b) => b.length - a.length);
283
+ for (const word of patterns) {
284
+ if (word.length === 0) continue;
284
285
  const weight = patternWeight(PATTERN_COVERAGE[word] ?? 0);
286
+ let count = 0;
287
+ let at = text.indexOf(word);
288
+ while (at !== -1) {
289
+ let free = true;
290
+ for (let i = at; i < at + word.length; i++) if (occupied[i] === 1) {
291
+ free = false;
292
+ break;
293
+ }
294
+ if (free) {
295
+ count += 1;
296
+ for (let i = at; i < at + word.length; i++) occupied[i] = 1;
297
+ }
298
+ at = text.indexOf(word, at + word.length);
299
+ }
300
+ if (count === 0) continue;
285
301
  clicheHits.push({
286
302
  word,
287
303
  count,
@@ -5718,7 +5734,8 @@ function buildCurveData(project) {
5718
5734
  const a = actualTension(c);
5719
5735
  if (t !== void 0) point.target = t;
5720
5736
  if (a !== void 0) point.actual = a;
5721
- if (reference[i] !== void 0) point.reference = reference[i];
5737
+ const refValue = preset === "custom" ? custom.length > 0 ? custom[c.no - 1] : t : reference[i];
5738
+ if (refValue !== void 0) point.reference = refValue;
5722
5739
  return point;
5723
5740
  })
5724
5741
  };
@@ -8084,7 +8101,9 @@ async function authorReviewChapter(ctx, config, project, chapterNo, body, prevTa
8084
8101
  system: authorReviewSystemPrompt(),
8085
8102
  user,
8086
8103
  temperature: .3,
8087
- maxTokens: Math.max(config.maxTokens, 4e3)
8104
+ maxTokens: Math.max(config.maxTokens, 4e3),
8105
+ model: config.reviewModel,
8106
+ liveLabel: "作者复盘"
8088
8107
  }));
8089
8108
  const knownLineNames = new Set((project.plotlines ?? []).map((l) => l.name));
8090
8109
  const advancedLines = Array.isArray(raw.advancedLines) ? raw.advancedLines.filter((n) => typeof n === "string" && n.trim() !== "" && knownLineNames.has(n.trim())).map((n) => n.trim()) : [];
@@ -8401,11 +8420,13 @@ async function extractRoles(ctx, config, project) {
8401
8420
  /** AI 从全书提炼场景库:正文/编年录 → 高频重要场景的结构化视觉锚点。 */
8402
8421
  function splitBookText(raw) {
8403
8422
  const lines = raw.split(/\r?\n/);
8404
- const chapterHead = /^\s*(?:#\s*)?第\s*(\d+|[一二三四五六七八九十百千]+)\s*[章回节卷]\s*(.*?)\s*$/;
8423
+ const chapterHead = /^\s*(?:#\s*)?第\s*(\d+|[一二三四五六七八九十百千万零〇]+)\s*[章回节]\s*(.*?)\s*$/;
8405
8424
  const cnOnlyHead = /^\s*(?:#\s*)?([一二三四五六七八九十百千万零〇]{1,6})(?:[、..::]\s*(.*?)\s*)?$/;
8406
8425
  const specialHead = /^\s*(?:#\s*)?(序章|序言|楔子|引子|前言|开篇|尾声|终章|大结局|番外(?:篇|章)?|后记|完结感言|上架感言|作者的话)\s*[::、.\s]*(.*?)\s*$/;
8407
8426
  const enHead = /^\s*(?:#\s*)?chapter\s+(\d+)\s*[.::、\-\s]*(.*?)\s*$/i;
8408
8427
  const cnNum = {
8428
+ 零: 0,
8429
+ 〇: 0,
8409
8430
  一: 1,
8410
8431
  二: 2,
8411
8432
  三: 3,
@@ -8417,7 +8438,8 @@ function splitBookText(raw) {
8417
8438
  九: 9,
8418
8439
  十: 10,
8419
8440
  百: 100,
8420
- 千: 1e3
8441
+ 千: 1e3,
8442
+ 万: 1e4
8421
8443
  };
8422
8444
  const parseCn = (s) => {
8423
8445
  if (/^\d+$/.test(s)) return Number(s);
@@ -8426,7 +8448,11 @@ function splitBookText(raw) {
8426
8448
  for (const ch of s) {
8427
8449
  const v = cnNum[ch];
8428
8450
  if (v === void 0) return 0;
8429
- if (v >= 10) {
8451
+ if (v === 0) continue;
8452
+ if (v >= 1e4) {
8453
+ section = (section + total) * v;
8454
+ total = 0;
8455
+ } else if (v >= 10) {
8430
8456
  total += (section > 0 ? section : 1) * v;
8431
8457
  section = 0;
8432
8458
  } else section = v;
@@ -9795,12 +9821,12 @@ async function summarizeAndExtractFacts(ctx, config, project, outputDir, chapter
9795
9821
  const summary = typeof raw.summary === "string" ? raw.summary.trim().slice(0, 500) : "";
9796
9822
  const factLines = parseFactLines(raw.facts);
9797
9823
  if (summary !== "") chapter.summary = summary;
9798
- dedupAndAddFacts(project, chapterNo, factLines);
9824
+ const added = dedupAndAddFacts(project, chapterNo, factLines);
9799
9825
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
9800
9826
  saveProject(outputDir, project);
9801
9827
  return {
9802
9828
  summary,
9803
- factCount: factLines.length
9829
+ factCount: added
9804
9830
  };
9805
9831
  }
9806
9832
  /**
@@ -9849,13 +9875,13 @@ async function summarizeFactsAndTimeline(ctx, config, project, outputDir, chapte
9849
9875
  const now = (/* @__PURE__ */ new Date()).toISOString();
9850
9876
  const extracted = parseTimelineItems(raw.timeline).map((item, index) => normalizeTimelineEvent(mapTimelineItem(item, index), chapterNo, index, now)).filter((e) => e.event !== "");
9851
9877
  if (summary !== "") chapter.summary = summary;
9852
- dedupAndAddFacts(project, chapterNo, factLines);
9878
+ const added = dedupAndAddFacts(project, chapterNo, factLines);
9853
9879
  if (extracted.length > 0) project.timeline = [...(project.timeline ?? []).filter((e) => e.chapterNo !== chapterNo), ...extracted];
9854
9880
  project.updatedAt = now;
9855
9881
  saveProject(outputDir, project);
9856
9882
  return {
9857
9883
  summary,
9858
- factCount: factLines.length,
9884
+ factCount: added,
9859
9885
  timelineCount: extracted.length
9860
9886
  };
9861
9887
  }
@@ -10629,58 +10655,98 @@ async function reviewTimeline(ctx, config, project) {
10629
10655
  const events = sortTimeline(project.timeline ?? []);
10630
10656
  if (events.length === 0) return [];
10631
10657
  const NL = String.fromCharCode(10);
10632
- const lines = events.map((e) => {
10633
- const parts = [`第${e.chapterNo}章`];
10634
- if (e.order !== void 0) parts.push(`#${e.order}`);
10635
- if (e.time !== "") parts.push(e.time);
10636
- if (e.place !== "") parts.push(`@${e.place}`);
10637
- if (e.characters.length > 0) parts.push(`(${e.characters.join("、")})`);
10638
- parts.push(e.event);
10639
- return "- " + parts.join(" ");
10640
- });
10641
- const text = await complete(ctx, config, {
10642
- system: timelineReviewSystemPrompt(),
10643
- user: `故事时间线(按章节顺序):@NL@${lines.join(NL)}`.replace("@NL@", NL),
10644
- temperature: .2,
10645
- maxTokens: Math.max(config.maxTokens, 4e3),
10646
- model: config.auditModel || config.model,
10647
- liveLabel: "时间线复核"
10648
- });
10649
- let parsed = [];
10650
- try {
10651
- parsed = parseJson(text, true);
10652
- } catch {
10653
- parsed = [];
10658
+ const system = timelineReviewSystemPrompt();
10659
+ const issues = [];
10660
+ const BATCH = 150;
10661
+ const totalBatches = Math.ceil(events.length / BATCH);
10662
+ for (let start = 0; start < events.length; start += BATCH) {
10663
+ const batch = events.slice(start, start + BATCH);
10664
+ const batchNo = Math.floor(start / BATCH) + 1;
10665
+ const text = await complete(ctx, config, {
10666
+ system,
10667
+ user: `故事时间线(按章节顺序;第 ${batchNo}/${totalBatches} 批):@NL@${batch.map((e) => {
10668
+ const parts = [`第${e.chapterNo}章`];
10669
+ if (e.order !== void 0) parts.push(`#${e.order}`);
10670
+ if (e.time !== "") parts.push(e.time);
10671
+ if (e.place !== "") parts.push(`@${e.place}`);
10672
+ if (e.characters.length > 0) parts.push(`(${e.characters.join("、")})`);
10673
+ parts.push(e.event);
10674
+ return "- " + parts.join(" ");
10675
+ }).join(NL)}`.replace("@NL@", NL),
10676
+ temperature: .2,
10677
+ maxTokens: Math.max(config.maxTokens, 4e3),
10678
+ model: config.auditModel || config.model,
10679
+ liveLabel: `时间线复核 · 第${batchNo}/${totalBatches}批`
10680
+ });
10681
+ let parsed = [];
10682
+ try {
10683
+ parsed = parseJson(text, true);
10684
+ } catch {
10685
+ parsed = [];
10686
+ }
10687
+ if (!Array.isArray(parsed)) continue;
10688
+ issues.push(...parsed.filter((item) => typeof item === "object" && item !== null).map((item) => {
10689
+ return {
10690
+ severity: item.severity === "high" || item.severity === "low" ? item.severity : "medium",
10691
+ chapters: Array.isArray(item.chapters) ? item.chapters.filter((c) => typeof c === "number" && Number.isFinite(c)) : [],
10692
+ item: typeof item.item === "string" ? item.item.slice(0, 300) : "",
10693
+ suggestion: typeof item.suggestion === "string" ? item.suggestion.slice(0, 300) : ""
10694
+ };
10695
+ }).filter((issue) => issue.item !== ""));
10654
10696
  }
10655
- if (!Array.isArray(parsed)) return [];
10656
- return parsed.filter((item) => typeof item === "object" && item !== null).map((item) => {
10657
- return {
10658
- severity: item.severity === "high" || item.severity === "low" ? item.severity : "medium",
10659
- chapters: Array.isArray(item.chapters) ? item.chapters.filter((c) => typeof c === "number" && Number.isFinite(c)) : [],
10660
- item: typeof item.item === "string" ? item.item.slice(0, 300) : "",
10661
- suggestion: typeof item.suggestion === "string" ? item.suggestion.slice(0, 300) : ""
10662
- };
10663
- }).filter((issue) => issue.item !== "");
10697
+ return issues;
10664
10698
  }
10665
- /** Export the whole book as one txt/md file. */
10699
+ /**
10700
+ * 选一个不覆盖已有文件的导出名:同名已存在时依次加 ` (2)`、` (3)`…
10701
+ *
10702
+ * 为什么不再固定文件名覆盖:作者往往「导出 → 看 → 改 → 再导出」,
10703
+ * 固定名会把上一版直接擦掉;导出件是成品,不该只有一份。
10704
+ * @param outputDir 书目录。
10705
+ * @param base 不含扩展名的文件名主体。
10706
+ * @param ext 扩展名(不带点)。
10707
+ */
10708
+ function uniqueExportFile(outputDir, base, ext) {
10709
+ let file = `${base}.${ext}`;
10710
+ let n = 2;
10711
+ while (existsSync(join(outputDir, file))) {
10712
+ file = `${base} (${n}).${ext}`;
10713
+ n++;
10714
+ }
10715
+ return file;
10716
+ }
10717
+ /**
10718
+ * Export the whole book as one txt/md file.
10719
+ *
10720
+ * 只收真实存在且非空的章节文件:`chapter.file` 有值不等于文件没被手删/清空,
10721
+ * 旧实现会把它们当空串拼进去,导出件看起来完整、实际缺章。跳过的章号随
10722
+ * `skipped` 一起返回,由面板如实提示。
10723
+ */
10666
10724
  function exportBook(outputDir, project, format) {
10667
10725
  const parts = [];
10668
10726
  if (format === "md") parts.push(`# ${project.bookName}\n`);
10669
10727
  else parts.push(project.bookName, "");
10670
- const done = project.chapters.filter((c) => c.file !== void 0);
10671
- for (const chapter of done) {
10672
- const body = readChapterFile(outputDir, chapter) ?? "";
10728
+ const written = [];
10729
+ const skipped = [];
10730
+ for (const chapter of project.chapters) {
10731
+ if (chapter.file === void 0 || chapter.file === "") continue;
10732
+ const body = readChapterFile(outputDir, chapter);
10733
+ if (body === void 0 || body.trim() === "") {
10734
+ skipped.push(chapter.no);
10735
+ continue;
10736
+ }
10737
+ written.push(chapter);
10673
10738
  if (format === "md") parts.push(`\n## 第${chapter.no}章 ${chapter.title}\n`, body.trim(), "");
10674
10739
  else parts.push("", `第${chapter.no}章 ${chapter.title}`, "", body.trim(), "");
10675
10740
  }
10676
10741
  const content = parts.join("\n");
10677
10742
  const ext = format === "md" ? "md" : "txt";
10678
- const file = `《${safeFileName(project.bookName)}》全本.${ext}`;
10743
+ const file = uniqueExportFile(outputDir, `《${safeFileName(project.bookName)}》全本`, ext);
10679
10744
  writeFileSync(join(outputDir, file), content, "utf8");
10680
10745
  return {
10681
10746
  file,
10682
10747
  chars: content.length,
10683
- chapters: done.length
10748
+ chapters: written.length,
10749
+ ...skipped.length > 0 ? { skipped } : {}
10684
10750
  };
10685
10751
  }
10686
10752
  /** 逐行渲染「- 名称:值」清单,跳过空值。 */
@@ -10857,7 +10923,7 @@ function exportScope(outputDir, project, scope, format) {
10857
10923
  project
10858
10924
  };
10859
10925
  const content = JSON.stringify(payload, null, 2);
10860
- const file = `《${safeFileName(project.bookName)}》项目备份.json`;
10926
+ const file = uniqueExportFile(outputDir, `《${safeFileName(project.bookName)}》项目备份`, "json");
10861
10927
  writeFileSync(join(outputDir, file), content, "utf8");
10862
10928
  return {
10863
10929
  file,
@@ -10868,7 +10934,7 @@ function exportScope(outputDir, project, scope, format) {
10868
10934
  const body = scope === "settings" ? renderSettings(project) : scope === "plan" ? renderPlan(project) : scope === "characters" ? renderCharacters(project) : renderReview(project);
10869
10935
  const ext = format === "json" ? "md" : format;
10870
10936
  const label = scope === "settings" ? "项目设定" : scope === "plan" ? "规划" : scope === "characters" ? "角色" : "质检记录";
10871
- const file = `《${safeFileName(project.bookName)}》${label}.${ext}`;
10937
+ const file = uniqueExportFile(outputDir, `《${safeFileName(project.bookName)}》${label}`, ext);
10872
10938
  writeFileSync(join(outputDir, file), body, "utf8");
10873
10939
  return {
10874
10940
  file,
@@ -11908,7 +11974,7 @@ function computeBookStage(project) {
11908
11974
  allow: ["chapter_rewrite", "chapter_review"],
11909
11975
  reason: `第 ${rejected.map((c) => c.no).join("、")} 章审稿未过,需要按意见修订`
11910
11976
  };
11911
- const awaitingReview = chapters.filter((c) => c.status === "written" || c.status === "reviewing");
11977
+ const awaitingReview = chapters.filter((c) => c.status === "written");
11912
11978
  if (awaitingReview.length > 0) return {
11913
11979
  id: "review",
11914
11980
  label: "审稿",
@@ -13198,11 +13264,15 @@ function upsertAuthorAsset(input) {
13198
13264
  createdAt: prev.createdAt,
13199
13265
  updatedAt: now
13200
13266
  };
13201
- } else library.items.push({
13202
- ...input,
13203
- createdAt: input.createdAt !== "" ? input.createdAt : now,
13204
- updatedAt: now
13205
- });
13267
+ } else {
13268
+ const id = input.id !== "" ? input.id : "aa-" + Date.now().toString(36) + "-" + randomBytes(4).toString("hex");
13269
+ library.items.push({
13270
+ ...input,
13271
+ id,
13272
+ createdAt: input.createdAt !== "" ? input.createdAt : now,
13273
+ updatedAt: now
13274
+ });
13275
+ }
13206
13276
  saveAuthorAssets(library);
13207
13277
  return library;
13208
13278
  }
@@ -13528,8 +13598,14 @@ async function scanMarketRanking(platforms) {
13528
13598
  */
13529
13599
  const FILE = dshHomePath("novel-forge-global-assets.json");
13530
13600
  let cache = null;
13601
+ /** 缓存对应的文件 mtime(ms);-1 = 文件不存在。 */
13602
+ let cacheMtime = -1;
13531
13603
  function load() {
13532
- if (cache !== null) return cache;
13604
+ let mtime = -1;
13605
+ try {
13606
+ mtime = statSync(FILE).mtimeMs;
13607
+ } catch {}
13608
+ if (cache !== null && cacheMtime === mtime) return cache;
13533
13609
  try {
13534
13610
  const raw = readFileSync(FILE, "utf8");
13535
13611
  const parsed = JSON.parse(raw);
@@ -13543,12 +13619,18 @@ function load() {
13543
13619
  modes: []
13544
13620
  };
13545
13621
  }
13622
+ cacheMtime = mtime;
13546
13623
  return cache;
13547
13624
  }
13548
13625
  function persist() {
13549
13626
  if (cache === null) return;
13550
13627
  mkdirSync(dshHome(), { recursive: true });
13551
13628
  writeFileSync(FILE, JSON.stringify(cache, null, 2), "utf8");
13629
+ try {
13630
+ cacheMtime = statSync(FILE).mtimeMs;
13631
+ } catch {
13632
+ cacheMtime = -1;
13633
+ }
13552
13634
  }
13553
13635
  function globalGenreLibrary() {
13554
13636
  return load().genres;
@@ -13728,6 +13810,26 @@ function isAbortError(err) {
13728
13810
  /** Default chapter count for planning when the request omits it. */
13729
13811
  const DEFAULT_PLAN_COUNT = 30;
13730
13812
  /**
13813
+ * 复盘结果回灌编年录(与生产单 run.ts 同口径:stateChanges + clues)。
13814
+ *
13815
+ * 为什么必须回灌:作者复盘是**当前状态**的事实来源(谁伤了、谁拿到了什么、哪条线落地);
13816
+ * 只写 chapter.authorReview、不回灌 facts,会让面板单独补跑复盘的章节在后续生成里
13817
+ * 「失忆」——编年录缺了这段,下一章就可能写飞。
13818
+ *
13819
+ * @param project 项目状态(就地修改)。
13820
+ * @param chapterNo 章节号。
13821
+ * @param review 复盘结果。
13822
+ */
13823
+ function backfillReviewFacts(project, chapterNo, review) {
13824
+ const facts = [...review.stateChanges ?? [], ...review.clues ?? []];
13825
+ if (facts.length === 0) return;
13826
+ project.facts ??= [];
13827
+ for (const text of facts) project.facts.push({
13828
+ chapterNo,
13829
+ text
13830
+ });
13831
+ }
13832
+ /**
13731
13833
  * The profile this host booted (`--profile <name>` on the dsh CLI invocation).
13732
13834
  * Profile-scoped paths must not assume a name: the shipped template is `web`,
13733
13835
  * but a custom profile (`web-alpha`) is the one actually running.
@@ -14136,7 +14238,8 @@ function makeRoutes(deps) {
14136
14238
  const review = await authorReviewChapter(ctx, config, project, no, currentBody, prevTail);
14137
14239
  chapter.authorReview = review;
14138
14240
  if (review.advancedLines !== void 0) autoLinkPlotlines(project, no, review.advancedLines);
14139
- mergeVolatileFromDisk(config.outputDir, project, ["plotlines"]);
14241
+ backfillReviewFacts(project, no, review);
14242
+ mergeVolatileFromDisk(config.outputDir, project, ["plotlines", "facts"]);
14140
14243
  saveProject(config.outputDir, project);
14141
14244
  send({
14142
14245
  type: "author-review",
@@ -14322,9 +14425,9 @@ function makeRoutes(deps) {
14322
14425
  handler: async (req, res) => {
14323
14426
  if (!guard(req, res, "POST")) return;
14324
14427
  const config = getConfig();
14325
- const project = requireProjectAt(req, res);
14326
- if (project === void 0) return;
14327
14428
  const body = await readJsonBody(req);
14429
+ const project = requireProjectAt(req, res, body);
14430
+ if (project === void 0) return;
14328
14431
  if (!Number.isInteger(body?.chapterNo)) {
14329
14432
  writeJson(res, 400, { error: "chapterNo 须为正整数" });
14330
14433
  return;
@@ -14373,9 +14476,9 @@ function makeRoutes(deps) {
14373
14476
  handler: async (req, res) => {
14374
14477
  if (!guard(req, res, "POST")) return;
14375
14478
  const config = getConfig();
14376
- const project = requireProjectAt(req, res);
14377
- if (project === void 0) return;
14378
14479
  const body = await readJsonBody(req);
14480
+ const project = requireProjectAt(req, res, body);
14481
+ if (project === void 0) return;
14379
14482
  if (!Number.isInteger(body?.chapterNo)) {
14380
14483
  writeJson(res, 400, { error: "chapterNo 须为正整数" });
14381
14484
  return;
@@ -14399,9 +14502,9 @@ function makeRoutes(deps) {
14399
14502
  handler: async (req, res) => {
14400
14503
  if (!guard(req, res, "POST")) return;
14401
14504
  const config = getConfig();
14402
- const project = requireProjectAt(req, res);
14403
- if (project === void 0) return;
14404
14505
  const body = await readJsonBody(req);
14506
+ const project = requireProjectAt(req, res, body);
14507
+ if (project === void 0) return;
14405
14508
  if (!Number.isInteger(body?.chapterNo)) {
14406
14509
  writeJson(res, 400, { error: "chapterNo 须为正整数" });
14407
14510
  return;
@@ -14543,9 +14646,9 @@ function makeRoutes(deps) {
14543
14646
  handler: async (req, res) => {
14544
14647
  if (!guard(req, res, "POST")) return;
14545
14648
  const config = getConfig();
14546
- const project = requireProjectAt(req, res);
14547
- if (project === void 0) return;
14548
14649
  const body = await readJsonBody(req);
14650
+ const project = requireProjectAt(req, res, body);
14651
+ if (project === void 0) return;
14549
14652
  const text = body?.text?.trim() ?? "";
14550
14653
  if (text.length < 50) {
14551
14654
  writeJson(res, 400, { error: "正文过短(<50 字),请先编辑内容" });
@@ -14565,9 +14668,9 @@ function makeRoutes(deps) {
14565
14668
  handler: async (req, res) => {
14566
14669
  if (!guard(req, res, "POST")) return;
14567
14670
  const config = getConfig();
14568
- const project = requireProjectAt(req, res);
14569
- if (project === void 0) return;
14570
14671
  const body = await readJsonBody(req);
14672
+ const project = requireProjectAt(req, res, body);
14673
+ if (project === void 0) return;
14571
14674
  if (!Number.isInteger(body?.chapterNo)) {
14572
14675
  writeJson(res, 400, { error: "chapterNo 须为正整数" });
14573
14676
  return;
@@ -16026,7 +16129,8 @@ function makeRoutes(deps) {
16026
16129
  handler: async (req, res) => {
16027
16130
  if (!guard(req, res, "POST")) return;
16028
16131
  const config = getConfig();
16029
- const project = requireProjectAt(req, res);
16132
+ const body = await readJsonBody(req);
16133
+ const project = requireProjectAt(req, res, body);
16030
16134
  if (project === void 0) return;
16031
16135
  try {
16032
16136
  const cards = await refreshCharacters(ctx, config, project, config.outputDir);
@@ -16046,7 +16150,8 @@ function makeRoutes(deps) {
16046
16150
  handler: async (req, res) => {
16047
16151
  if (!guard(req, res, "POST")) return;
16048
16152
  const config = getConfig();
16049
- const project = requireProjectAt(req, res);
16153
+ const body = await readJsonBody(req);
16154
+ const project = requireProjectAt(req, res, body);
16050
16155
  if (project === void 0) return;
16051
16156
  try {
16052
16157
  writeJson(res, 200, {
@@ -16065,13 +16170,13 @@ function makeRoutes(deps) {
16065
16170
  handler: async (req, res) => {
16066
16171
  if (!guard(req, res, "POST")) return;
16067
16172
  const config = getConfig();
16068
- const project = requireProjectAt(req, res);
16173
+ const body = await readJsonBody(req);
16174
+ const project = requireProjectAt(req, res, body);
16069
16175
  if (project === void 0) return;
16070
16176
  if (project.bible === void 0) {
16071
16177
  writeJson(res, 400, { error: "尚未生成道藏,请先生成" });
16072
16178
  return;
16073
16179
  }
16074
- const body = await readJsonBody(req);
16075
16180
  if (Array.isArray(body?.worldRules)) project.bible.worldRules = body.worldRules.filter((r) => r.trim() !== "");
16076
16181
  if (Array.isArray(body?.redLines)) project.bible.redLines = body.redLines.filter((r) => r.trim() !== "");
16077
16182
  if (Array.isArray(body?.style)) project.bible.style = body.style.filter((r) => r.trim() !== "");
@@ -16100,9 +16205,9 @@ function makeRoutes(deps) {
16100
16205
  handler: async (req, res) => {
16101
16206
  if (!guard(req, res, "POST")) return;
16102
16207
  const config = getConfig();
16103
- const project = requireProjectAt(req, res);
16104
- if (project === void 0) return;
16105
16208
  const body = await readJsonBody(req);
16209
+ const project = requireProjectAt(req, res, body);
16210
+ if (project === void 0) return;
16106
16211
  try {
16107
16212
  if (body?.action === "save") {
16108
16213
  const text = body.text?.trim() ?? "";
@@ -16154,9 +16259,9 @@ function makeRoutes(deps) {
16154
16259
  }
16155
16260
  if (req.method === "POST") {
16156
16261
  if (!guard(req, res, "POST")) return;
16157
- const project = requireProjectAt(req, res);
16158
- if (project === void 0) return;
16159
16262
  const body = await readJsonBody(req);
16263
+ const project = requireProjectAt(req, res, body);
16264
+ if (project === void 0) return;
16160
16265
  try {
16161
16266
  if (body?.action === "remove") {
16162
16267
  if (project.coverPath !== void 0 && project.coverPath !== "") {
@@ -16210,9 +16315,9 @@ function makeRoutes(deps) {
16210
16315
  handler: async (req, res) => {
16211
16316
  if (!guard(req, res, "POST")) return;
16212
16317
  const config = getConfig();
16213
- const project = requireProjectAt(req, res);
16214
- if (project === void 0) return;
16215
16318
  const body = await readJsonBody(req);
16319
+ const project = requireProjectAt(req, res, body);
16320
+ if (project === void 0) return;
16216
16321
  try {
16217
16322
  if (body?.action === "save" && body.world !== void 0) {
16218
16323
  project.world = {
@@ -16242,9 +16347,10 @@ function makeRoutes(deps) {
16242
16347
  handler: async (req, res) => {
16243
16348
  if (!guard(req, res, "POST")) return;
16244
16349
  const config = getConfig();
16245
- const project = requireProjectAt(req, res);
16350
+ const body = await readJsonBody(req);
16351
+ const project = requireProjectAt(req, res, body);
16246
16352
  if (project === void 0) return;
16247
- const bookName = (await readJsonBody(req))?.bookName?.trim();
16353
+ const bookName = body?.bookName?.trim();
16248
16354
  if (bookName === void 0 || bookName === "") {
16249
16355
  writeJson(res, 400, { error: "书名不能为空" });
16250
16356
  return;
@@ -16264,9 +16370,9 @@ function makeRoutes(deps) {
16264
16370
  handler: async (req, res) => {
16265
16371
  if (!guard(req, res, "POST")) return;
16266
16372
  const config = getConfig();
16267
- const project = requireProjectAt(req, res);
16268
- if (project === void 0) return;
16269
16373
  const body = await readJsonBody(req);
16374
+ const project = requireProjectAt(req, res, body);
16375
+ if (project === void 0) return;
16270
16376
  if (project.plotlines === void 0) project.plotlines = [];
16271
16377
  const op = body?.op;
16272
16378
  if (op === "add" && body?.line !== void 0) {
@@ -16393,9 +16499,9 @@ function makeRoutes(deps) {
16393
16499
  handler: async (req, res) => {
16394
16500
  if (!guard(req, res, "POST")) return;
16395
16501
  const config = getConfig();
16396
- const project = requireProjectAt(req, res);
16397
- if (project === void 0) return;
16398
16502
  const body = await readJsonBody(req);
16503
+ const project = requireProjectAt(req, res, body);
16504
+ if (project === void 0) return;
16399
16505
  const hits = [];
16400
16506
  let scanned = 0;
16401
16507
  if (body?.text !== void 0) for (const hit of checkSensitiveText(body.text)) hits.push({
@@ -16447,9 +16553,9 @@ function makeRoutes(deps) {
16447
16553
  handler: async (req, res) => {
16448
16554
  if (!guard(req, res, "POST")) return;
16449
16555
  const config = getConfig();
16450
- const project = requireProjectAt(req, res);
16451
- if (project === void 0) return;
16452
16556
  const body = await readJsonBody(req);
16557
+ const project = requireProjectAt(req, res, body);
16558
+ if (project === void 0) return;
16453
16559
  if (project.roles === void 0) project.roles = [];
16454
16560
  const op = body?.op;
16455
16561
  if (op === "extract") try {
@@ -16485,9 +16591,10 @@ function makeRoutes(deps) {
16485
16591
  handler: async (req, res) => {
16486
16592
  if (!guard(req, res, "POST")) return;
16487
16593
  const config = getConfig();
16488
- const project = requireProjectAt(req, res);
16594
+ const body = await readJsonBody(req);
16595
+ const project = requireProjectAt(req, res, body);
16489
16596
  if (project === void 0) return;
16490
- const no = (await readJsonBody(req))?.chapterNo;
16597
+ const no = body?.chapterNo;
16491
16598
  if (!Number.isInteger(no) || no === void 0 || no < 1) {
16492
16599
  writeJson(res, 400, { error: "chapterNo 须为正整数" });
16493
16600
  return;
@@ -16515,9 +16622,10 @@ function makeRoutes(deps) {
16515
16622
  handler: async (req, res) => {
16516
16623
  if (!guard(req, res, "POST")) return;
16517
16624
  const config = getConfig();
16518
- const project = requireProjectAt(req, res);
16625
+ const body = await readJsonBody(req);
16626
+ const project = requireProjectAt(req, res, body);
16519
16627
  if (project === void 0) return;
16520
- const no = (await readJsonBody(req))?.chapterNo;
16628
+ const no = body?.chapterNo;
16521
16629
  if (!Number.isInteger(no) || no === void 0 || no < 1) {
16522
16630
  writeJson(res, 400, { error: "chapterNo 须为正整数" });
16523
16631
  return;
@@ -16549,9 +16657,9 @@ function makeRoutes(deps) {
16549
16657
  handler: async (req, res) => {
16550
16658
  if (!guard(req, res, "POST")) return;
16551
16659
  const config = getConfig();
16552
- const project = requireProjectAt(req, res);
16553
- if (project === void 0) return;
16554
16660
  const body = await readJsonBody(req);
16661
+ const project = requireProjectAt(req, res, body);
16662
+ if (project === void 0) return;
16555
16663
  const no = body?.chapterNo;
16556
16664
  if (!Number.isInteger(no) || no === void 0 || no < 1) {
16557
16665
  writeJson(res, 400, { error: "chapterNo 须为正整数" });
@@ -16610,9 +16718,9 @@ function makeRoutes(deps) {
16610
16718
  handler: async (req, res) => {
16611
16719
  if (!guard(req, res, "POST")) return;
16612
16720
  const config = getConfig();
16613
- const project = requireProjectAt(req, res);
16614
- if (project === void 0) return;
16615
16721
  const body = await readJsonBody(req);
16722
+ const project = requireProjectAt(req, res, body);
16723
+ if (project === void 0) return;
16616
16724
  const abort = abortOnClientClose(req, res);
16617
16725
  /** 对一章执行作者复盘(读取已落盘正文,不改变章节状态/正文)。 */
16618
16726
  const runOne = async (chapter) => {
@@ -16640,6 +16748,7 @@ function makeRoutes(deps) {
16640
16748
  const review = await runOne(chapter);
16641
16749
  chapter.authorReview = review;
16642
16750
  if (review.advancedLines !== void 0) autoLinkPlotlines(project, chapter.no, review.advancedLines);
16751
+ backfillReviewFacts(project, chapter.no, review);
16643
16752
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
16644
16753
  saveProject(config.outputDir, project);
16645
16754
  writeJson(res, 200, {
@@ -16670,6 +16779,7 @@ function makeRoutes(deps) {
16670
16779
  const review = await runOne(chapter);
16671
16780
  chapter.authorReview = review;
16672
16781
  if (review.advancedLines !== void 0) autoLinkPlotlines(project, chapter.no, review.advancedLines);
16782
+ backfillReviewFacts(project, chapter.no, review);
16673
16783
  done++;
16674
16784
  saveProject(config.outputDir, project);
16675
16785
  send({
@@ -16798,7 +16908,8 @@ function makeRoutes(deps) {
16798
16908
  handler: async (req, res) => {
16799
16909
  if (!guard(req, res, "POST")) return;
16800
16910
  const config = getConfig();
16801
- const project = requireProjectAt(req, res);
16911
+ const body = await readJsonBody(req);
16912
+ const project = requireProjectAt(req, res, body);
16802
16913
  if (project === void 0) return;
16803
16914
  res.writeHead(200, {
16804
16915
  "content-type": "application/x-ndjson; charset=utf-8",
@@ -16939,9 +17050,9 @@ function makeRoutes(deps) {
16939
17050
  handler: async (req, res) => {
16940
17051
  if (!guard(req, res, "POST")) return;
16941
17052
  const config = getConfig();
16942
- const project = requireProjectAt(req, res);
16943
- if (project === void 0) return;
16944
17053
  const body = await readJsonBody(req);
17054
+ const project = requireProjectAt(req, res, body);
17055
+ if (project === void 0) return;
16945
17056
  try {
16946
17057
  writeJson(res, 200, await breakdownBook(ctx, config, project, config.outputDir, body?.scope ?? "recent", body?.preset ?? "quick", body?.budgetTokens ?? 5e4));
16947
17058
  } catch (error) {
@@ -17202,11 +17313,18 @@ function makeRoutes(deps) {
17202
17313
  const body = await readJsonBody(req);
17203
17314
  const config = getConfig();
17204
17315
  let text = body?.text?.trim() ?? "";
17205
- if (text === "" && body?.filePath !== void 0 && body.filePath !== "") try {
17206
- text = readFileSync(body.filePath, "utf8");
17207
- } catch (err) {
17208
- writeJson(res, 400, { error: "读取文件失败:" + err.message });
17209
- return;
17316
+ if (text === "" && body?.filePath !== void 0 && body.filePath !== "") {
17317
+ const sourcePath = body.filePath.trim();
17318
+ if (!IMPORTABLE_EXTENSIONS.includes(extname(sourcePath).toLowerCase())) {
17319
+ writeJson(res, 400, { error: "只支持 txt / md / markdown 文件" });
17320
+ return;
17321
+ }
17322
+ try {
17323
+ text = readFileSync(sourcePath, "utf8");
17324
+ } catch (err) {
17325
+ writeJson(res, 400, { error: "读取文件失败:" + err.message });
17326
+ return;
17327
+ }
17210
17328
  }
17211
17329
  if (text.length < 200) {
17212
17330
  writeJson(res, 400, { error: "全文内容过短(<200 字符),请上传完整小说文本" });
@@ -17780,6 +17898,7 @@ const Config = z.object({
17780
17898
  reviewModel: live(z.string().default("")),
17781
17899
  auditModel: live(z.string().default("")),
17782
17900
  fallbackModel: live(z.string().default("")),
17901
+ fallbackMaxTokens: live(z.number().default(0)),
17783
17902
  autoTimeline: live(z.boolean().default(true)),
17784
17903
  reasoningEffort: live(z.union([
17785
17904
  "off",
@@ -17802,7 +17921,13 @@ const Config = z.object({
17802
17921
  themeBackground: live(z.string().default("")),
17803
17922
  themeBackgroundBlur: live(z.number().default(0)),
17804
17923
  themeOpacity: live(z.number().default(100)),
17805
- enableAdaptMode: live(z.boolean().default(true))
17924
+ enableAdaptMode: live(z.boolean().default(true)),
17925
+ savedModels: live(z.array(z.object({
17926
+ id: z.string(),
17927
+ name: z.string(),
17928
+ provider: z.string(),
17929
+ model: z.string()
17930
+ })).default([]))
17806
17931
  });
17807
17932
  /** Schema defaults, re-read for hand-built test contexts. */
17808
17933
  const DEFAULT_ANNOUNCE = true;
@@ -17835,6 +17960,7 @@ function resolveConfig(value) {
17835
17960
  reviewModel: value?.reviewModel,
17836
17961
  auditModel: value?.auditModel,
17837
17962
  fallbackModel: value?.fallbackModel ?? DEFAULT_FALLBACK_MODEL,
17963
+ fallbackMaxTokens: value?.fallbackMaxTokens,
17838
17964
  autoTimeline: value?.autoTimeline ?? DEFAULT_AUTO_TIMELINE,
17839
17965
  reasoningEffort: value?.reasoningEffort ?? DEFAULT_REASONING_EFFORT,
17840
17966
  analysisReasoning: value?.analysisReasoning ?? DEFAULT_ANALYSIS_REASONING,
@@ -17847,7 +17973,8 @@ function resolveConfig(value) {
17847
17973
  themeBackground: value?.themeBackground ?? "",
17848
17974
  themeBackgroundBlur: value?.themeBackgroundBlur ?? 0,
17849
17975
  themeOpacity: value?.themeOpacity ?? 100,
17850
- enableAdaptMode: value?.enableAdaptMode ?? true
17976
+ enableAdaptMode: value?.enableAdaptMode ?? true,
17977
+ savedModels: value?.savedModels ?? []
17851
17978
  };
17852
17979
  }
17853
17980
  /**
@@ -17910,6 +18037,7 @@ function apply(ctx, config) {
17910
18037
  if (patch.reviewModel !== void 0) next.reviewModel = patch.reviewModel;
17911
18038
  if (patch.auditModel !== void 0) next.auditModel = patch.auditModel;
17912
18039
  if (patch.fallbackModel !== void 0) next.fallbackModel = patch.fallbackModel;
18040
+ if (patch.fallbackMaxTokens !== void 0) next.fallbackMaxTokens = patch.fallbackMaxTokens;
17913
18041
  if (patch.autoTimeline !== void 0) next.autoTimeline = patch.autoTimeline;
17914
18042
  if (patch.reasoningEffort !== void 0) next.reasoningEffort = patch.reasoningEffort;
17915
18043
  if (patch.analysisReasoning !== void 0) next.analysisReasoning = patch.analysisReasoning;
@@ -17923,6 +18051,7 @@ function apply(ctx, config) {
17923
18051
  if (patch.themeBackgroundBlur !== void 0) next.themeBackgroundBlur = patch.themeBackgroundBlur;
17924
18052
  if (patch.themeOpacity !== void 0) next.themeOpacity = patch.themeOpacity;
17925
18053
  if (patch.enableAdaptMode !== void 0) next.enableAdaptMode = patch.enableAdaptMode;
18054
+ if (patch.savedModels !== void 0) next.savedModels = patch.savedModels;
17926
18055
  const settings = ctx.get("settings");
17927
18056
  const persisted = await persistConfigPatch(current(), next, settings, settingsNs, NOVEL_SETTINGS_NAMESPACE);
17928
18057
  setCurrent(persisted.config);
@@ -17988,7 +18117,6 @@ function apply(ctx, config) {
17988
18117
  },
17989
18118
  onChange: sync
17990
18119
  });
17991
- ctx.get("skills");
17992
18120
  sync();
17993
18121
  }
17994
18122
  //#endregion