@waterwx/dsh-novel-forge 1.4.0 → 1.4.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
@@ -690,6 +690,25 @@ const COMPLIANCE_REDLINES = [
690
690
  ];
691
691
  /** Project state file name inside the output dir. */
692
692
  const PROJECT_FILE = "novel-project.json";
693
+ /** 智能解码文本文件:UTF-8 BOM / UTF-16 BOM / UTF-8(严格校验)/ GB18030 回退。 */
694
+ function decodeTextSmart(buf) {
695
+ if (buf.length >= 3 && buf[0] === 239 && buf[1] === 187 && buf[2] === 191) return buf.subarray(3).toString("utf8");
696
+ if (buf.length >= 2 && buf[0] === 255 && buf[1] === 254) return buf.subarray(2).toString("utf16le");
697
+ const utf8 = buf.toString("utf8");
698
+ const bad = countReplacementChars(utf8);
699
+ if (bad === 0) return utf8;
700
+ try {
701
+ const gbk = new TextDecoder("gb18030").decode(buf);
702
+ if (countReplacementChars(gbk) < bad) return gbk;
703
+ } catch {}
704
+ return utf8;
705
+ }
706
+ /** 统计替换字符 U+FFFD 数量(UTF-8 乱码检测)。 */
707
+ function countReplacementChars(s) {
708
+ let n = 0;
709
+ for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 65533) n++;
710
+ return n;
711
+ }
693
712
  /** Sanitize a file name: keep CJK/alphanumerics/space/dash/underscore. */
694
713
  function safeFileName(name) {
695
714
  return name.replace(/[\\/:*?"<>|]/g, "").replace(/\s+/g, " ").trim().slice(0, 60);
@@ -1167,7 +1186,7 @@ async function complete(ctx, config, options) {
1167
1186
  system: options.system,
1168
1187
  maxTokens: options.maxTokens ?? config.maxTokens,
1169
1188
  temperature: options.temperature ?? .7,
1170
- reasoningEffort: ReasoningEffortId(config.reasoningEffort ?? "off")
1189
+ reasoningEffort: ReasoningEffortId(options.reasoning ?? config.reasoningEffort ?? "off")
1171
1190
  };
1172
1191
  const assembler = new BlockAssembler();
1173
1192
  for await (const chunk of ctx.llm.stream(request)) assembler.push(chunk);
@@ -1293,7 +1312,8 @@ async function extractBible(ctx, config, outline, project) {
1293
1312
  system: bibleSystemPrompt(),
1294
1313
  user,
1295
1314
  temperature: .4,
1296
- maxTokens: Math.max(config.maxTokens, 16e3)
1315
+ maxTokens: Math.max(config.maxTokens, 16e3),
1316
+ reasoning: config.analysisReasoning ?? "low"
1297
1317
  }));
1298
1318
  const strArray = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string" && v.trim() !== "") : [];
1299
1319
  const characters = Array.isArray(raw.characters) ? raw.characters.filter((v) => typeof v === "object" && v !== null).map((entry) => ({
@@ -1756,7 +1776,8 @@ async function suggestPlotlines(ctx, config, project) {
1756
1776
  "只输出 JSON 数组。"
1757
1777
  ].join("\n\n"),
1758
1778
  temperature: .6,
1759
- maxTokens: Math.max(config.maxTokens, 4e3)
1779
+ maxTokens: Math.max(config.maxTokens, 4e3),
1780
+ reasoning: config.analysisReasoning ?? "low"
1760
1781
  }));
1761
1782
  const lines = [];
1762
1783
  const kinds = /* @__PURE__ */ new Set([
@@ -1803,7 +1824,8 @@ async function refreshPlotlineProgress(ctx, config, project, line) {
1803
1824
  "只输出 JSON 对象。"
1804
1825
  ].join("\n\n"),
1805
1826
  temperature: .3,
1806
- maxTokens: Math.max(config.maxTokens, 2e3)
1827
+ maxTokens: Math.max(config.maxTokens, 2e3),
1828
+ reasoning: config.analysisReasoning ?? "low"
1807
1829
  }));
1808
1830
  return typeof raw.progress === "string" ? raw.progress.trim().slice(0, 300) : "";
1809
1831
  }
@@ -1813,6 +1835,8 @@ async function extractRoles(ctx, config, project) {
1813
1835
  "你是一位网文角色库管理员。请根据本书的大纲、设定、编年录与章节摘要,提炼完整的角色库。",
1814
1836
  "覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如\"矮胖姑娘\")用其身份简称收录并标注。",
1815
1837
  "数量控制:最多输出 10 个角色,宁缺毋滥;路人级一次带过的不要收录。",
1838
+ "重要:正文中若有明确的主角与主要反派,必须分别以 protagonist / antagonist 收录,禁止遗漏;反派确实未出场时才可省略。",
1839
+ "输出优先级:主角(protagonist)与主要反派(antagonist)必须优先输出并完整刻画,其次女主/重要配角;判断不出名字时用正文中的身份称呼。",
1816
1840
  "每个角色输出:",
1817
1841
  "1. name:角色名(或身份简称)。主角/有名配角必须用正文中实际出现的人名(如「沈放」),禁止用「主角(38岁超市理货员)」这类把身份塞进名字的占位名;正文确实没点名时才可用身份简称(如「富商」「灰衣老人」)。",
1818
1842
  "2. roleLabel:定位——protagonist=主角;female_lead=女主(唯一知己/感情线核心,无后宫前提下只此一位);female_support=重要女配;support=普通配角;antagonist=反派;extra=路人/背景。",
@@ -1830,28 +1854,50 @@ async function extractRoles(ctx, config, project) {
1830
1854
  ].join("\n");
1831
1855
  const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
1832
1856
  const existingRoles = project.roles ?? [];
1857
+ const sampleChapters = written.length <= 6 ? written : (() => {
1858
+ const picked = /* @__PURE__ */ new Set();
1859
+ for (let i = 0; i < 3 && i < written.length; i++) picked.add(i);
1860
+ const step = Math.max(1, Math.floor(written.length / 4));
1861
+ for (let i = step; i < written.length - 2; i += step) picked.add(i);
1862
+ for (let i = Math.max(0, written.length - 2); i < written.length; i++) picked.add(i);
1863
+ return [...picked].sort((a, b) => a - b).map((i) => written[i]);
1864
+ })();
1833
1865
  const excerptParts = [];
1834
- for (const chapter of written.slice(0, 3)) {
1866
+ for (const chapter of sampleChapters) {
1835
1867
  const body = readChapterFile(config.outputDir, chapter);
1836
1868
  if (body === void 0) continue;
1837
1869
  const text = body.replace(/^#.*$/gm, "").trim();
1838
- if (text.length > 0) excerptParts.push(`第${chapter.no}章《${chapter.title}》\n${text.slice(0, 2400)}`);
1870
+ if (text.length > 0) excerptParts.push(`第${chapter.no}章《${chapter.title}》\n${text.slice(0, 1500)}`);
1839
1871
  }
1840
- const raw = parseJsonArray(await complete(ctx, config, {
1872
+ const user = [
1873
+ `书名:《${project.bookName}》`,
1874
+ existingRoles.length > 0 ? `已收录角色(跳过,不要输出):${existingRoles.map((r) => r.name).join("、")}` : "",
1875
+ `大纲(节选前 3000 字):\n${project.outline.slice(0, 3e3)}`,
1876
+ excerptParts.length > 0 ? `已写正文摘录(角色姓名/身份/关系以正文为准):\n${excerptParts.join("\n\n")}` : "",
1877
+ project.bible !== void 0 && project.bible.characters.length > 0 ? `已有角色卡(补充信息):\n${project.bible.characters.map((c) => `- ${c.name}(${c.role}):${c.traits.join("、")}${c.goals !== "" ? `;目标:${c.goals}` : ""}`).join("\n")}` : "",
1878
+ (project.facts ?? []).length > 0 ? `编年录(最近 30 条):\n${(project.facts ?? []).slice(-30).map((f) => `[第${f.chapterNo}章] ${f.text.slice(0, 60)}`).join("\n")}` : "",
1879
+ written.length > 0 ? `已写章节标题(${written.length} 章):\n${written.map((c) => `第${c.no}章《${c.title}》`).join("、")}` : "",
1880
+ "只输出 JSON 数组。"
1881
+ ].join("\n\n");
1882
+ let text = await complete(ctx, config, {
1841
1883
  system,
1842
- user: [
1843
- `书名:《${project.bookName}》`,
1844
- existingRoles.length > 0 ? `已收录角色(跳过,不要输出):${existingRoles.map((r) => r.name).join("、")}` : "",
1845
- `大纲(节选前 3000 字):\n${project.outline.slice(0, 3e3)}`,
1846
- excerptParts.length > 0 ? `已写正文摘录(角色姓名/身份/关系以正文为准):\n${excerptParts.join("\n\n")}` : "",
1847
- project.bible !== void 0 && project.bible.characters.length > 0 ? `已有角色卡(补充信息):\n${project.bible.characters.map((c) => `- ${c.name}(${c.role}):${c.traits.join("、")}${c.goals !== "" ? `;目标:${c.goals}` : ""}`).join("\n")}` : "",
1848
- (project.facts ?? []).length > 0 ? `编年录(最近 60 条):\n${(project.facts ?? []).slice(-60).map((f) => `[第${f.chapterNo}章] ${f.text.slice(0, 80)}`).join("\n")}` : "",
1849
- written.length > 0 ? `已写章节标题(${written.length} 章):\n${written.map((c) => `第${c.no}章《${c.title}》`).join("、")}` : "",
1850
- "只输出 JSON 数组。"
1851
- ].join("\n\n"),
1884
+ user,
1852
1885
  temperature: .3,
1853
- maxTokens: Math.max(config.maxTokens, 24e3)
1854
- }));
1886
+ maxTokens: Math.max(config.maxTokens, 24e3),
1887
+ reasoning: config.analysisReasoning ?? "low"
1888
+ });
1889
+ let raw = parseJsonArray(text);
1890
+ const hasProtagonist = raw.some((e) => typeof e === "object" && e !== null && e.roleLabel === "protagonist");
1891
+ if (raw.length === 0 || !hasProtagonist) {
1892
+ text = await complete(ctx, config, {
1893
+ system: system + (raw.length === 0 ? "\n上一次输出为空或格式不正确。请直接输出 JSON 数组(即使只有一个角色也要输出),不要输出其他文字。" : "\n上一次输出中缺少主角(roleLabel 为 protagonist 的角色)。请重新输出完整 JSON 数组,务必包含正文中的主角。"),
1894
+ user,
1895
+ temperature: .3,
1896
+ maxTokens: Math.max(config.maxTokens, 24e3),
1897
+ reasoning: config.analysisReasoning ?? "low"
1898
+ });
1899
+ raw = parseJsonArray(text);
1900
+ }
1855
1901
  const labels = /* @__PURE__ */ new Set([
1856
1902
  "protagonist",
1857
1903
  "female_lead",
@@ -1921,7 +1967,8 @@ async function extractScenes(ctx, config, project) {
1921
1967
  "只输出 JSON 数组。"
1922
1968
  ].filter((s) => s !== "").join("\n"),
1923
1969
  temperature: .4,
1924
- maxTokens: Math.max(config.maxTokens, 12e3)
1970
+ maxTokens: Math.max(config.maxTokens, 12e3),
1971
+ reasoning: config.analysisReasoning ?? "low"
1925
1972
  }));
1926
1973
  const strArr = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.trim() !== "") : [];
1927
1974
  const str = (v) => typeof v === "string" ? v.trim() : "";
@@ -1948,10 +1995,13 @@ async function extractScenes(ctx, config, project) {
1948
1995
  }
1949
1996
  return scenes;
1950
1997
  }
1951
- /** 从 txt/md 全本拆分章节并建立项目:正文落盘为章节文件,status=written(待审稿)。 */
1952
- function importBookText(filePath, outputDir) {
1953
- const lines = readFileSync(filePath, "utf8").split(/\r?\n/);
1998
+ /** 从 txt/md 全本文本拆章(纯逻辑,不落盘):识别章节头、剥离重复标题、去重、排序并统一重新编号。 */
1999
+ function splitBookText(raw) {
2000
+ const lines = raw.split(/\r?\n/);
1954
2001
  const chapterHead = /^\s*(?:#\s*)?第\s*(\d+|[一二三四五六七八九十百千]+)\s*[章回节卷]\s*(.*?)\s*$/;
2002
+ const cnOnlyHead = /^\s*(?:#\s*)?([一二三四五六七八九十百千万零〇]{1,6})(?:[、..::]\s*(.*?)\s*)?$/;
2003
+ const specialHead = /^\s*(?:#\s*)?(序章|序言|楔子|引子|前言|开篇|尾声|终章|大结局|番外(?:篇|章)?|后记|完结感言|上架感言|作者的话)\s*[::、.\s]*(.*?)\s*$/;
2004
+ const enHead = /^\s*(?:#\s*)?chapter\s+(\d+)\s*[.::、\-\s]*(.*?)\s*$/i;
1955
2005
  const cnNum = {
1956
2006
  一: 1,
1957
2007
  二: 2,
@@ -1982,33 +2032,107 @@ function importBookText(filePath, outputDir) {
1982
2032
  };
1983
2033
  const chunks = [];
1984
2034
  let current = null;
2035
+ let specialSeq = 0;
1985
2036
  for (const line of lines) {
1986
2037
  const m = chapterHead.exec(line);
1987
2038
  if (m !== null) {
1988
- if (current !== null) chunks.push(current);
1989
2039
  const no = parseCn(m[1]);
1990
- const title = (m[2] ?? "").trim() || "";
2040
+ if (current !== null) chunks.push(current);
2041
+ current = {
2042
+ sortKey: no > 0 ? no : chunks.length + 1,
2043
+ title: (m[2] ?? "").trim(),
2044
+ body: []
2045
+ };
2046
+ continue;
2047
+ }
2048
+ const mc = cnOnlyHead.exec(line);
2049
+ if (mc !== null) {
2050
+ const no = parseCn(mc[1]);
2051
+ if (current !== null) chunks.push(current);
2052
+ const t = (mc[2] ?? "").trim();
2053
+ current = {
2054
+ sortKey: no > 0 ? no : chunks.length + 1,
2055
+ title: t !== "" ? t : "第" + mc[1] + "章",
2056
+ body: []
2057
+ };
2058
+ continue;
2059
+ }
2060
+ const ms = specialHead.exec(line);
2061
+ if (ms !== null) {
2062
+ specialSeq++;
2063
+ const name = ms[1];
2064
+ const front = /序章|序言|楔子|引子|前言|开篇/.test(name);
2065
+ const title = (ms[2] ?? "").trim();
2066
+ if (current !== null) chunks.push(current);
2067
+ current = {
2068
+ sortKey: front ? specialSeq * .001 : 999999 + specialSeq * .001,
2069
+ title: title !== "" ? title : name,
2070
+ body: []
2071
+ };
2072
+ continue;
2073
+ }
2074
+ const me = enHead.exec(line);
2075
+ if (me !== null) {
2076
+ if (current !== null) chunks.push(current);
1991
2077
  current = {
1992
- no: no > 0 ? no : chunks.length + 1,
1993
- title,
2078
+ sortKey: Number(me[1]),
2079
+ title: (me[2] ?? "").trim(),
1994
2080
  body: []
1995
2081
  };
1996
- } else if (current !== null) current.body.push(line);
2082
+ continue;
2083
+ }
2084
+ if (current !== null) current.body.push(line);
1997
2085
  }
1998
2086
  if (current !== null) chunks.push(current);
1999
- if (chunks.length === 0) throw new Error("未识别到章节(需要\"第X章\"格式,或带 # 的章节标题)");
2000
- const seen = /* @__PURE__ */ new Set();
2001
- const ordered = chunks.filter((c) => {
2002
- if (seen.has(c.no)) return false;
2003
- seen.add(c.no);
2004
- return true;
2005
- }).sort((a, b) => a.no - b.no);
2006
- const bookName = basename(filePath, extname(filePath)).slice(0, 40) || "导入小说";
2087
+ if (chunks.length === 0) throw new Error("未识别到章节(需要\"第X章\"格式、中文数字章节(一、二、三…)、序章/楔子/尾声等章节标题、英文 Chapter N,或带 # 的章节标题)");
2088
+ const stripHead = (b) => {
2089
+ let i = 0;
2090
+ while (i < b.length) {
2091
+ const t = b[i].trim();
2092
+ if (t === "" || chapterHead.test(t) || cnOnlyHead.test(t) || specialHead.test(t) || enHead.test(t)) i++;
2093
+ else break;
2094
+ }
2095
+ return b.slice(i).join("\n").trim();
2096
+ };
2097
+ const byKey = /* @__PURE__ */ new Map();
2098
+ for (const c of chunks) {
2099
+ const len = stripHead(c.body).length;
2100
+ const ex = byKey.get(c.sortKey);
2101
+ if (ex === void 0 || len > stripHead(ex.body).length) byKey.set(c.sortKey, c);
2102
+ }
2103
+ return [...byKey.values()].sort((a, b) => a.sortKey - b.sortKey).map((c, i) => ({
2104
+ no: i + 1,
2105
+ title: c.title,
2106
+ body: stripHead(c.body)
2107
+ }));
2108
+ }
2109
+ /** 拆章预览(不落盘):章节编号/标题/字数 + 跳过清单。 */
2110
+ function previewBookText(raw) {
2111
+ const chapters = [];
2112
+ const skipped = [];
2113
+ for (const c of splitBookText(raw)) {
2114
+ if (c.body.length < 50) {
2115
+ skipped.push("第" + c.no + "章" + (c.title !== "" ? "「" + c.title + "」" : "") + "(内容过短,已跳过)");
2116
+ continue;
2117
+ }
2118
+ chapters.push({
2119
+ no: c.no,
2120
+ title: c.title !== "" ? c.title : "第" + c.no + "章",
2121
+ chars: c.body.length
2122
+ });
2123
+ }
2124
+ return {
2125
+ chapters,
2126
+ skipped
2127
+ };
2128
+ }
2129
+ /** 从全本文本导入(浏览器上传 / 服务器文件共用):建项目、写章节文件、保存。 */
2130
+ function importBookTextFromText(raw, outputDir, bookName) {
2007
2131
  const project = createProject(bookName);
2008
2132
  mkdirSync(outputDir, { recursive: true });
2009
2133
  const skipped = [];
2010
- for (const c of ordered) {
2011
- const body = c.body.join("\n").trim();
2134
+ for (const c of splitBookText(raw)) {
2135
+ const body = c.body;
2012
2136
  if (body.length < 50) {
2013
2137
  skipped.push("第" + c.no + "章" + (c.title !== "" ? "「" + c.title + "」" : "") + "(内容过短,已跳过)");
2014
2138
  continue;
@@ -2036,6 +2160,10 @@ function importBookText(filePath, outputDir) {
2036
2160
  skipped
2037
2161
  };
2038
2162
  }
2163
+ /** 从 txt/md 全本文件导入:编码自适应读取后拆章建项目,status=written(待审稿)。 */
2164
+ function importBookText(filePath, outputDir) {
2165
+ return importBookTextFromText(decodeTextSmart(readFileSync(filePath)), outputDir, basename(filePath, extname(filePath)).slice(0, 40) || "导入小说");
2166
+ }
2039
2167
  /**
2040
2168
  * 为单个角色提炼「动漫形象描述词」:扫描该角色出场的已写章节正文,
2041
2169
  * 截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
@@ -2166,7 +2294,8 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
2166
2294
  "只输出 JSON 对象。"
2167
2295
  ].join("\n\n"),
2168
2296
  temperature: .4,
2169
- maxTokens: Math.max(config.maxTokens, 12e3)
2297
+ maxTokens: Math.max(config.maxTokens, 12e3),
2298
+ reasoning: config.analysisReasoning ?? "low"
2170
2299
  }));
2171
2300
  const zh = typeof raw.zh === "string" ? raw.zh.trim().slice(0, 500) : "";
2172
2301
  const en = typeof raw.en === "string" ? raw.en.trim().slice(0, 1500) : "";
@@ -2239,7 +2368,8 @@ async function generateRolePromptKit(ctx, config, project, roleName) {
2239
2368
  "只输出 JSON 对象。"
2240
2369
  ].join("\n\n"),
2241
2370
  temperature: .4,
2242
- maxTokens: Math.max(config.maxTokens, 12e3)
2371
+ maxTokens: Math.max(config.maxTokens, 12e3),
2372
+ reasoning: config.analysisReasoning ?? "low"
2243
2373
  }));
2244
2374
  const str = (v) => typeof v === "string" ? v.trim() : "";
2245
2375
  const pair = (v) => {
@@ -2284,7 +2414,8 @@ async function extractVisualRules(ctx, config, project) {
2284
2414
  "只输出 JSON 数组。"
2285
2415
  ].filter((s) => s !== "").join("\n\n"),
2286
2416
  temperature: .3,
2287
- maxTokens: Math.max(config.maxTokens, 4e3)
2417
+ maxTokens: Math.max(config.maxTokens, 4e3),
2418
+ reasoning: config.analysisReasoning ?? "low"
2288
2419
  })).map((r) => typeof r === "string" ? r.trim().slice(0, 80) : "").filter((r) => r !== "").slice(0, 8);
2289
2420
  }
2290
2421
  /**
@@ -2320,7 +2451,8 @@ async function suggestOutlines(ctx, config, idea, count = 3, exclude = []) {
2320
2451
  "只输出 JSON 数组。"
2321
2452
  ].join("\n\n"),
2322
2453
  temperature: .85,
2323
- maxTokens: Math.max(config.maxTokens, 12e3)
2454
+ maxTokens: Math.max(config.maxTokens, 12e3),
2455
+ reasoning: config.analysisReasoning ?? "low"
2324
2456
  }));
2325
2457
  const candidates = [];
2326
2458
  for (const entry of parsed) {
@@ -2390,7 +2522,8 @@ async function breakdownBook(ctx, config, project, outputDir, scope = "recent",
2390
2522
  system: noteSystem,
2391
2523
  user: noteUser,
2392
2524
  temperature: .2,
2393
- maxTokens: Math.max(config.maxTokens, 3e3)
2525
+ maxTokens: Math.max(config.maxTokens, 3e3),
2526
+ reasoning: config.analysisReasoning ?? "low"
2394
2527
  }));
2395
2528
  const pick = (k) => Array.isArray(raw[k]) ? raw[k].filter((x) => typeof x === "string" && x.trim() !== "").map((x) => x.trim().slice(0, 120)).slice(0, 4) : [];
2396
2529
  notes.push(`【第${ch.no}章《${ch.title}》】\n摘要:${typeof raw.summary === "string" ? raw.summary.slice(0, 200) : ""}\n剧情:${pick("plotPoints").join(";")}\n人物:${pick("characters").join(";")}\n设定:${pick("worldbuilding").join(";")}\n写法:${pick("styleTechniques").join(";")}\n卖点:${pick("marketHighlights").join(";")}\n短板信号:${pick("weaknessSignals").join(";") || "(无明显短板信号)"}`);
@@ -2462,7 +2595,8 @@ async function breakdownBook(ctx, config, project, outputDir, scope = "recent",
2462
2595
  system: sec.system,
2463
2596
  user: `分析范围:${selected.length} 章(${scope === "all" ? "全书" : scope === "recent" ? "最近 20 章" : "指定卷"})。\n\n章节笔记:\n${notesText}`,
2464
2597
  temperature: .3,
2465
- maxTokens: Math.max(config.maxTokens, 6e3)
2598
+ maxTokens: Math.max(config.maxTokens, 6e3),
2599
+ reasoning: config.analysisReasoning ?? "low"
2466
2600
  }));
2467
2601
  sections.push({
2468
2602
  key: sec.key,
@@ -2916,6 +3050,48 @@ async function summarizeChapter(ctx, config, project, outputDir, chapterNo) {
2916
3050
  return chapter.summary;
2917
3051
  }
2918
3052
  /**
3053
+ * 反向推大纲:从已写章节正文反推出全书总纲(分卷 + 章节要点 + 主线/人物弧线/伏笔清单)。
3054
+ * 两阶段:分批提取章节事件摘要 → 汇总生成大纲。不修改章节/设定,只返回大纲文本。
3055
+ */
3056
+ async function reverseOutlineFromChapters(ctx, config, project, outputDir, onProgress) {
3057
+ 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);
3058
+ if (written.length === 0) throw new Error("本书还没有已写章节,无法反推大纲");
3059
+ const BATCH = 10;
3060
+ const notes = [];
3061
+ const total = written.length;
3062
+ for (let i = 0; i < written.length; i += BATCH) {
3063
+ const note = await complete(ctx, config, {
3064
+ system: "你是一位网文编辑。下面是一本书若干章正文的节选。请为每一章输出一行「事件摘要」,格式严格为:第N章《标题》:关键事件+主角状态变化+新增伏笔或线索。每章恰好一行,不要空行,不要评价,不要输出其他内容。",
3065
+ user: written.slice(i, i + BATCH).map((c) => {
3066
+ const body = readChapterFile(outputDir, c) ?? "";
3067
+ return "第" + c.no + "章《" + (c.title || "无题") + "》\n" + body.replace(/^#\s+.*$/m, "").trim().slice(0, 1e3);
3068
+ }).join("\n\n---\n\n"),
3069
+ temperature: .2,
3070
+ maxTokens: Math.max(config.maxTokens, 3e3),
3071
+ reasoning: config.analysisReasoning ?? "low"
3072
+ });
3073
+ notes.push(note.trim());
3074
+ onProgress?.(Math.min(i + BATCH, total), total, "章节摘要");
3075
+ }
3076
+ onProgress?.(total, total, "生成大纲");
3077
+ const outline = await complete(ctx, config, {
3078
+ system: [
3079
+ "你是一位经验丰富的小说主编。根据下面全书各章事件摘要,反推出这本书的总纲(大纲),可直接作为后续写作依据。要求:",
3080
+ "1. 第一行写《书名》(从摘要中的书名或内容推断,若无则用《未命名》)。",
3081
+ "2. 按故事弧线划分卷/部分:每卷给出卷名与主旨(标注覆盖章节范围)。",
3082
+ "3. 每一章列出:章节号 + 标题 + 一句话核心情节(若原章无标题可自拟)。",
3083
+ "4. 最后给出:全书主线、主要人物弧线、已埋设待回收的伏笔清单。",
3084
+ "5. 输出为纯文本 Markdown 结构(# 一级标题、## 二级标题、- 列表),不要多余寒暄。"
3085
+ ].join("\n"),
3086
+ user: notes.join("\n\n"),
3087
+ temperature: .4,
3088
+ maxTokens: Math.max(config.maxTokens, 6e3),
3089
+ reasoning: config.analysisReasoning ?? "low"
3090
+ });
3091
+ onProgress?.(total, total, "完成");
3092
+ return outline.trim();
3093
+ }
3094
+ /**
2919
3095
  * 摘要 + 事实抽取合并为一次 LLM 调用(省一次调用与一次正文输入,
2920
3096
  * 批量生成时整体开销约省 25%)。
2921
3097
  * @returns 摘要与新增事实条数(失败返回空,调用方 best-effort)。
@@ -3120,7 +3296,8 @@ async function generateBlurb(ctx, config, project, partial = "") {
3120
3296
  partial.trim() !== "" ? `已有开头草稿(请保留其内容与语气,续写补全为完整简介):\n${partial.trim()}` : "请全量生成一份完整简介。"
3121
3297
  ].filter((s) => s !== "").join("\n\n"),
3122
3298
  temperature: .7,
3123
- maxTokens: Math.max(config.maxTokens, 4e3)
3299
+ maxTokens: Math.max(config.maxTokens, 4e3),
3300
+ reasoning: config.analysisReasoning ?? "low"
3124
3301
  })).replace(/^["'「『]|["'」』]$/g, "").replace(/^简介[::]\s*/, "").trim().slice(0, 600);
3125
3302
  }
3126
3303
  /**
@@ -3282,7 +3459,8 @@ async function extractWorld(ctx, config, project) {
3282
3459
  "只输出 JSON 对象。"
3283
3460
  ].filter((s) => s !== "").join("\n\n"),
3284
3461
  temperature: .3,
3285
- maxTokens: Math.max(config.maxTokens, 12e3)
3462
+ maxTokens: Math.max(config.maxTokens, 12e3),
3463
+ reasoning: config.analysisReasoning ?? "low"
3286
3464
  }));
3287
3465
  const str = (value) => typeof value === "string" ? value.trim() : "";
3288
3466
  const objArray = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "object" && v !== null) : [];
@@ -3421,7 +3599,8 @@ async function suggestForeshadows(ctx, config, project) {
3421
3599
  system: foreshadowSystemPrompt(),
3422
3600
  user,
3423
3601
  temperature: .5,
3424
- maxTokens: Math.max(config.maxTokens, 12e3)
3602
+ maxTokens: Math.max(config.maxTokens, 12e3),
3603
+ reasoning: config.analysisReasoning ?? "low"
3425
3604
  }));
3426
3605
  const existing = new Set(project.foreshadows.map((f) => f.description));
3427
3606
  const created = [];
@@ -3989,6 +4168,8 @@ const NOVEL_API = {
3989
4168
  bookshelfImportDir: "/api/dsh-novel-forge/bookshelf/import-dir",
3990
4169
  /** 导入 txt/md 全本:拆章建项目并登记书架。 */
3991
4170
  bookshelfImportText: "/api/dsh-novel-forge/bookshelf/import-text",
4171
+ /** 导入 txt/md 全本:拆章预览(不落盘)。 */
4172
+ bookshelfImportTextPreview: "/api/dsh-novel-forge/bookshelf/import-text/preview",
3992
4173
  /** 重置项目(可选携带新大纲):清空设定/卷/章节/伏笔/资产/事实库。 */
3993
4174
  reset: "/api/dsh-novel-forge/reset",
3994
4175
  /** 全书一致性质检:LLM 扫描已生成章节,输出矛盾问题清单。 */
@@ -4023,6 +4204,8 @@ const NOVEL_API = {
4023
4204
  sensitiveCheck: "/api/dsh-novel-forge/sensitive-check",
4024
4205
  /** 开书想法 → AI 补全大纲:输入一句话想法,生成 2-3 个可选大纲方案。 */
4025
4206
  outlineSuggest: "/api/dsh-novel-forge/outline/suggest",
4207
+ /** 反推大纲:从已写章节正文反向生成全书总纲(NDJSON 流)。 */
4208
+ outlineReverse: "/api/dsh-novel-forge/outline/reverse",
4026
4209
  /** 拆书分析:对已写章节做结构/人物/文风/卖点四维体检(两阶段:源笔记→分节分析)。 */
4027
4210
  breakdown: "/api/dsh-novel-forge/breakdown",
4028
4211
  /** 漫剧分镜生成:章节 → 角色锚点 + 分镜表(可适配豆包/Seedance/SD)。 */
@@ -4829,7 +5012,7 @@ async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
4829
5012
  //#endregion
4830
5013
  //#region src/routes.ts
4831
5014
  /** Cap on JSON request bodies (generous: cover images travel as base64). */
4832
- const MAX_JSON_BODY_BYTES = 16 * 1024 * 1024;
5015
+ const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024;
4833
5016
  /** Loopback-only fence (mirrors the family plugins' pairing routes). */
4834
5017
  function isLoopbackRequest(request) {
4835
5018
  const address = request.socket.remoteAddress;
@@ -5862,23 +6045,34 @@ function makeRoutes(deps) {
5862
6045
  }
5863
6046
  }
5864
6047
  };
5865
- /** 导入 txt/md 全本(Mode B):拆章落盘建项目,登记书架。 */
6048
+ /** 导入 txt/md 全本(Mode B):拆章落盘建项目,登记书架。支持浏览器上传(text+fileName)与服务器本地文件(filePath)两种模式。 */
5866
6049
  const bookshelfImportTextRoute = {
5867
6050
  kind: "exact",
5868
6051
  path: NOVEL_API.bookshelfImportText,
5869
6052
  handler: async (req, res) => {
5870
6053
  if (!guard(req, res, "POST")) return;
5871
6054
  const body = await readJsonBody(req);
5872
- const filePath = body?.filePath?.trim();
5873
- if (filePath === void 0 || filePath === "") {
5874
- writeJson(res, 400, { error: "filePath 不能为空" });
5875
- return;
5876
- }
5877
- if (!existsSync(filePath)) {
5878
- writeJson(res, 400, { error: `文件不存在:${filePath}` });
5879
- return;
5880
- }
5881
6055
  try {
6056
+ if (body?.text !== void 0 && body.text.length > 0) {
6057
+ const bookName = (body.fileName ?? "").replace(/\.[^.]+$/, "").trim().slice(0, 40) || "导入小说";
6058
+ const outDir = body?.outputDir?.trim() !== void 0 && body.outputDir.trim() !== "" ? body.outputDir.trim() : defaultOutputDirFor(bookName);
6059
+ const result = importBookTextFromText(body.text, outDir, bookName);
6060
+ const { book } = importDir(outDir);
6061
+ writeJson(res, 200, {
6062
+ ...result,
6063
+ book
6064
+ });
6065
+ return;
6066
+ }
6067
+ const filePath = body?.filePath?.trim();
6068
+ if (filePath === void 0 || filePath === "") {
6069
+ writeJson(res, 400, { error: "filePath 不能为空(或请上传文件内容)" });
6070
+ return;
6071
+ }
6072
+ if (!existsSync(filePath)) {
6073
+ writeJson(res, 400, { error: `文件不存在:${filePath}` });
6074
+ return;
6075
+ }
5882
6076
  const bookName = basename(filePath, extname(filePath)).slice(0, 40) || "导入小说";
5883
6077
  const outDir = body?.outputDir?.trim() !== void 0 && body.outputDir.trim() !== "" ? body.outputDir.trim() : defaultOutputDirFor(bookName);
5884
6078
  const result = importBookText(filePath, outDir);
@@ -5892,6 +6086,27 @@ function makeRoutes(deps) {
5892
6086
  }
5893
6087
  }
5894
6088
  };
6089
+ /** 上传全文做拆章预览(不落盘、不登记书架)。 */
6090
+ const bookshelfImportTextPreviewRoute = {
6091
+ kind: "exact",
6092
+ path: NOVEL_API.bookshelfImportTextPreview,
6093
+ handler: async (req, res) => {
6094
+ if (!guard(req, res, "POST")) return;
6095
+ const body = await readJsonBody(req);
6096
+ if (body?.text === void 0 || body.text.length === 0) {
6097
+ writeJson(res, 400, { error: "text 不能为空" });
6098
+ return;
6099
+ }
6100
+ try {
6101
+ writeJson(res, 200, {
6102
+ bookName: (body.fileName ?? "").replace(/\.[^.]+$/, "").trim().slice(0, 40) || "导入小说",
6103
+ ...previewBookText(body.text)
6104
+ });
6105
+ } catch (err) {
6106
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
6107
+ }
6108
+ }
6109
+ };
5895
6110
  /** 全书一致性质检。 */
5896
6111
  const auditRoute = {
5897
6112
  kind: "exact",
@@ -6673,6 +6888,52 @@ function makeRoutes(deps) {
6673
6888
  }
6674
6889
  }
6675
6890
  };
6891
+ /** 反推大纲:从已写章节正文反向生成全书总纲(NDJSON 流式进度)。 */
6892
+ const outlineReverseRoute = {
6893
+ kind: "exact",
6894
+ path: NOVEL_API.outlineReverse,
6895
+ handler: async (req, res) => {
6896
+ if (!guard(req, res, "POST")) return;
6897
+ const config = getConfig();
6898
+ const project = requireProject(res);
6899
+ if (project === void 0) return;
6900
+ res.writeHead(200, {
6901
+ "content-type": "application/x-ndjson; charset=utf-8",
6902
+ "cache-control": "no-cache",
6903
+ "x-accel-buffering": "no",
6904
+ "referrer-policy": "no-referrer"
6905
+ });
6906
+ const send = (frame) => {
6907
+ res.write(JSON.stringify(frame) + "\n");
6908
+ };
6909
+ try {
6910
+ const outline = await reverseOutlineFromChapters(ctx, config, project, config.outputDir, (done, total, phase) => {
6911
+ send({
6912
+ type: "outline-progress",
6913
+ done,
6914
+ total,
6915
+ phase
6916
+ });
6917
+ });
6918
+ project.outline = outline;
6919
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
6920
+ saveProject(config.outputDir, project);
6921
+ send({
6922
+ type: "outline-done",
6923
+ outline,
6924
+ chars: outline.length
6925
+ });
6926
+ } catch (error) {
6927
+ send({
6928
+ type: "error",
6929
+ no: 0,
6930
+ message: error.message
6931
+ });
6932
+ } finally {
6933
+ res.end();
6934
+ }
6935
+ }
6936
+ };
6676
6937
  /** 拆书分析:对已写章节做结构/人物/文风/卖点体检(两阶段:源笔记→分节分析)。 */
6677
6938
  const breakdownRoute = {
6678
6939
  kind: "exact",
@@ -6817,6 +7078,7 @@ function makeRoutes(deps) {
6817
7078
  bookshelfRemoveRoute,
6818
7079
  bookshelfImportDirRoute,
6819
7080
  bookshelfImportTextRoute,
7081
+ bookshelfImportTextPreviewRoute,
6820
7082
  resetRoute,
6821
7083
  auditRoute,
6822
7084
  charactersRefreshRoute,
@@ -6837,6 +7099,7 @@ function makeRoutes(deps) {
6837
7099
  configRoute,
6838
7100
  openFolderRoute,
6839
7101
  outlineSuggestRoute,
7102
+ outlineReverseRoute,
6840
7103
  breakdownRoute,
6841
7104
  {
6842
7105
  kind: "exact",
@@ -6931,6 +7194,12 @@ const Config = z.object({
6931
7194
  "high",
6932
7195
  "max"
6933
7196
  ]).default("off"),
7197
+ analysisReasoning: z.union([
7198
+ "off",
7199
+ "low",
7200
+ "high",
7201
+ "max"
7202
+ ]).default("low"),
6934
7203
  chapterChars: z.number().default(3500),
6935
7204
  maxTokens: z.number().default(12e3),
6936
7205
  reviewPassScore: z.number().default(70),
@@ -6948,6 +7217,7 @@ const DEFAULT_OUTPUT_DIR = join(homedir(), ".dsh", "novels");
6948
7217
  const DEFAULT_PROVIDER = "deepseek-official";
6949
7218
  const DEFAULT_MODEL = "deepseek-v4-flash";
6950
7219
  const DEFAULT_REASONING_EFFORT = "off";
7220
+ const DEFAULT_ANALYSIS_REASONING = "low";
6951
7221
  const DEFAULT_CHAPTER_CHARS = 3500;
6952
7222
  const DEFAULT_MAX_TOKENS = 12e3;
6953
7223
  const DEFAULT_REVIEW_PASS_SCORE = 70;
@@ -6966,6 +7236,7 @@ function resolveConfig(value) {
6966
7236
  provider: value?.provider ?? DEFAULT_PROVIDER,
6967
7237
  model: value?.model ?? DEFAULT_MODEL,
6968
7238
  reasoningEffort: value?.reasoningEffort ?? DEFAULT_REASONING_EFFORT,
7239
+ analysisReasoning: value?.analysisReasoning ?? DEFAULT_ANALYSIS_REASONING,
6969
7240
  chapterChars: value?.chapterChars ?? DEFAULT_CHAPTER_CHARS,
6970
7241
  maxTokens: value?.maxTokens ?? DEFAULT_MAX_TOKENS,
6971
7242
  reviewPassScore: value?.reviewPassScore ?? DEFAULT_REVIEW_PASS_SCORE,
@@ -7000,6 +7271,7 @@ function apply(ctx, config) {
7000
7271
  if (patch.provider !== void 0) next.provider = patch.provider;
7001
7272
  if (patch.model !== void 0) next.model = patch.model;
7002
7273
  if (patch.reasoningEffort !== void 0) next.reasoningEffort = patch.reasoningEffort;
7274
+ if (patch.analysisReasoning !== void 0) next.analysisReasoning = patch.analysisReasoning;
7003
7275
  if (patch.chapterChars !== void 0) next.chapterChars = patch.chapterChars;
7004
7276
  if (patch.maxTokens !== void 0) next.maxTokens = patch.maxTokens;
7005
7277
  if (patch.reviewPassScore !== void 0) next.reviewPassScore = patch.reviewPassScore;