@waterwx/dsh-novel-forge 1.3.3 → 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
@@ -1,5 +1,5 @@
1
1
  import { homedir } from "node:os";
2
- import { join } from "node:path";
2
+ import { basename, extname, join } from "node:path";
3
3
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
4
4
  import z from "schemastery";
5
5
  import { exec } from "node:child_process";
@@ -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,6 +1995,175 @@ async function extractScenes(ctx, config, project) {
1948
1995
  }
1949
1996
  return scenes;
1950
1997
  }
1998
+ /** 从 txt/md 全本文本拆章(纯逻辑,不落盘):识别章节头、剥离重复标题、去重、排序并统一重新编号。 */
1999
+ function splitBookText(raw) {
2000
+ const lines = raw.split(/\r?\n/);
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;
2005
+ const cnNum = {
2006
+ 一: 1,
2007
+ 二: 2,
2008
+ 三: 3,
2009
+ 四: 4,
2010
+ 五: 5,
2011
+ 六: 6,
2012
+ 七: 7,
2013
+ 八: 8,
2014
+ 九: 9,
2015
+ 十: 10,
2016
+ 百: 100,
2017
+ 千: 1e3
2018
+ };
2019
+ const parseCn = (s) => {
2020
+ if (/^\d+$/.test(s)) return Number(s);
2021
+ let total = 0;
2022
+ let section = 0;
2023
+ for (const ch of s) {
2024
+ const v = cnNum[ch];
2025
+ if (v === void 0) return 0;
2026
+ if (v >= 10) {
2027
+ total += (section > 0 ? section : 1) * v;
2028
+ section = 0;
2029
+ } else section = v;
2030
+ }
2031
+ return total + section;
2032
+ };
2033
+ const chunks = [];
2034
+ let current = null;
2035
+ let specialSeq = 0;
2036
+ for (const line of lines) {
2037
+ const m = chapterHead.exec(line);
2038
+ if (m !== null) {
2039
+ const no = parseCn(m[1]);
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);
2077
+ current = {
2078
+ sortKey: Number(me[1]),
2079
+ title: (me[2] ?? "").trim(),
2080
+ body: []
2081
+ };
2082
+ continue;
2083
+ }
2084
+ if (current !== null) current.body.push(line);
2085
+ }
2086
+ if (current !== null) chunks.push(current);
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) {
2131
+ const project = createProject(bookName);
2132
+ mkdirSync(outputDir, { recursive: true });
2133
+ const skipped = [];
2134
+ for (const c of splitBookText(raw)) {
2135
+ const body = c.body;
2136
+ if (body.length < 50) {
2137
+ skipped.push("第" + c.no + "章" + (c.title !== "" ? "「" + c.title + "」" : "") + "(内容过短,已跳过)");
2138
+ continue;
2139
+ }
2140
+ const chapter = {
2141
+ no: c.no,
2142
+ volume: 0,
2143
+ title: c.title !== "" ? c.title : "第" + c.no + "章",
2144
+ beats: "",
2145
+ targetChars: 0,
2146
+ status: "written",
2147
+ file: "",
2148
+ chars: 0
2149
+ };
2150
+ chapter.file = chapterFileName(chapter);
2151
+ writeFileSync(join(outputDir, chapter.file), "# 第" + c.no + "章 " + chapter.title + "\n\n" + body + "\n", "utf8");
2152
+ chapter.chars = body.length;
2153
+ project.chapters.push(chapter);
2154
+ }
2155
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2156
+ saveProject(outputDir, project);
2157
+ return {
2158
+ bookName,
2159
+ chapters: project.chapters.length,
2160
+ skipped
2161
+ };
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
+ }
1951
2167
  /**
1952
2168
  * 为单个角色提炼「动漫形象描述词」:扫描该角色出场的已写章节正文,
1953
2169
  * 截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
@@ -2078,7 +2294,8 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
2078
2294
  "只输出 JSON 对象。"
2079
2295
  ].join("\n\n"),
2080
2296
  temperature: .4,
2081
- maxTokens: Math.max(config.maxTokens, 12e3)
2297
+ maxTokens: Math.max(config.maxTokens, 12e3),
2298
+ reasoning: config.analysisReasoning ?? "low"
2082
2299
  }));
2083
2300
  const zh = typeof raw.zh === "string" ? raw.zh.trim().slice(0, 500) : "";
2084
2301
  const en = typeof raw.en === "string" ? raw.en.trim().slice(0, 1500) : "";
@@ -2151,7 +2368,8 @@ async function generateRolePromptKit(ctx, config, project, roleName) {
2151
2368
  "只输出 JSON 对象。"
2152
2369
  ].join("\n\n"),
2153
2370
  temperature: .4,
2154
- maxTokens: Math.max(config.maxTokens, 12e3)
2371
+ maxTokens: Math.max(config.maxTokens, 12e3),
2372
+ reasoning: config.analysisReasoning ?? "low"
2155
2373
  }));
2156
2374
  const str = (v) => typeof v === "string" ? v.trim() : "";
2157
2375
  const pair = (v) => {
@@ -2196,7 +2414,8 @@ async function extractVisualRules(ctx, config, project) {
2196
2414
  "只输出 JSON 数组。"
2197
2415
  ].filter((s) => s !== "").join("\n\n"),
2198
2416
  temperature: .3,
2199
- maxTokens: Math.max(config.maxTokens, 4e3)
2417
+ maxTokens: Math.max(config.maxTokens, 4e3),
2418
+ reasoning: config.analysisReasoning ?? "low"
2200
2419
  })).map((r) => typeof r === "string" ? r.trim().slice(0, 80) : "").filter((r) => r !== "").slice(0, 8);
2201
2420
  }
2202
2421
  /**
@@ -2232,7 +2451,8 @@ async function suggestOutlines(ctx, config, idea, count = 3, exclude = []) {
2232
2451
  "只输出 JSON 数组。"
2233
2452
  ].join("\n\n"),
2234
2453
  temperature: .85,
2235
- maxTokens: Math.max(config.maxTokens, 12e3)
2454
+ maxTokens: Math.max(config.maxTokens, 12e3),
2455
+ reasoning: config.analysisReasoning ?? "low"
2236
2456
  }));
2237
2457
  const candidates = [];
2238
2458
  for (const entry of parsed) {
@@ -2302,7 +2522,8 @@ async function breakdownBook(ctx, config, project, outputDir, scope = "recent",
2302
2522
  system: noteSystem,
2303
2523
  user: noteUser,
2304
2524
  temperature: .2,
2305
- maxTokens: Math.max(config.maxTokens, 3e3)
2525
+ maxTokens: Math.max(config.maxTokens, 3e3),
2526
+ reasoning: config.analysisReasoning ?? "low"
2306
2527
  }));
2307
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) : [];
2308
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(";") || "(无明显短板信号)"}`);
@@ -2374,7 +2595,8 @@ async function breakdownBook(ctx, config, project, outputDir, scope = "recent",
2374
2595
  system: sec.system,
2375
2596
  user: `分析范围:${selected.length} 章(${scope === "all" ? "全书" : scope === "recent" ? "最近 20 章" : "指定卷"})。\n\n章节笔记:\n${notesText}`,
2376
2597
  temperature: .3,
2377
- maxTokens: Math.max(config.maxTokens, 6e3)
2598
+ maxTokens: Math.max(config.maxTokens, 6e3),
2599
+ reasoning: config.analysisReasoning ?? "low"
2378
2600
  }));
2379
2601
  sections.push({
2380
2602
  key: sec.key,
@@ -2828,6 +3050,48 @@ async function summarizeChapter(ctx, config, project, outputDir, chapterNo) {
2828
3050
  return chapter.summary;
2829
3051
  }
2830
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
+ /**
2831
3095
  * 摘要 + 事实抽取合并为一次 LLM 调用(省一次调用与一次正文输入,
2832
3096
  * 批量生成时整体开销约省 25%)。
2833
3097
  * @returns 摘要与新增事实条数(失败返回空,调用方 best-effort)。
@@ -3032,7 +3296,8 @@ async function generateBlurb(ctx, config, project, partial = "") {
3032
3296
  partial.trim() !== "" ? `已有开头草稿(请保留其内容与语气,续写补全为完整简介):\n${partial.trim()}` : "请全量生成一份完整简介。"
3033
3297
  ].filter((s) => s !== "").join("\n\n"),
3034
3298
  temperature: .7,
3035
- maxTokens: Math.max(config.maxTokens, 4e3)
3299
+ maxTokens: Math.max(config.maxTokens, 4e3),
3300
+ reasoning: config.analysisReasoning ?? "low"
3036
3301
  })).replace(/^["'「『]|["'」』]$/g, "").replace(/^简介[::]\s*/, "").trim().slice(0, 600);
3037
3302
  }
3038
3303
  /**
@@ -3194,7 +3459,8 @@ async function extractWorld(ctx, config, project) {
3194
3459
  "只输出 JSON 对象。"
3195
3460
  ].filter((s) => s !== "").join("\n\n"),
3196
3461
  temperature: .3,
3197
- maxTokens: Math.max(config.maxTokens, 12e3)
3462
+ maxTokens: Math.max(config.maxTokens, 12e3),
3463
+ reasoning: config.analysisReasoning ?? "low"
3198
3464
  }));
3199
3465
  const str = (value) => typeof value === "string" ? value.trim() : "";
3200
3466
  const objArray = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "object" && v !== null) : [];
@@ -3333,7 +3599,8 @@ async function suggestForeshadows(ctx, config, project) {
3333
3599
  system: foreshadowSystemPrompt(),
3334
3600
  user,
3335
3601
  temperature: .5,
3336
- maxTokens: Math.max(config.maxTokens, 12e3)
3602
+ maxTokens: Math.max(config.maxTokens, 12e3),
3603
+ reasoning: config.analysisReasoning ?? "low"
3337
3604
  }));
3338
3605
  const existing = new Set(project.foreshadows.map((f) => f.description));
3339
3606
  const created = [];
@@ -3501,6 +3768,25 @@ function seedBookshelfFromOutputDir(outputDir) {
3501
3768
  createBook(loadProject(outputDir)?.bookName ?? outputDir.split(/[\\/]/).pop() ?? "未命名小说", outputDir);
3502
3769
  return true;
3503
3770
  }
3771
+ /** 导入已有项目目录到书架:校验 novel-project.json,已存在则直接激活。 */
3772
+ function importDir(outputDir) {
3773
+ const project = loadProject(outputDir);
3774
+ if (project === void 0) throw new Error(`目录中未找到有效的 novel-project.json:${outputDir}`);
3775
+ const store = loadBookshelf();
3776
+ const existed = store.books.find((b) => b.outputDir === outputDir);
3777
+ if (existed !== void 0) {
3778
+ store.activeBookId = existed.id;
3779
+ saveBookshelf(store);
3780
+ return {
3781
+ book: existed,
3782
+ existed: true
3783
+ };
3784
+ }
3785
+ return {
3786
+ book: createBook(project.bookName !== "" ? project.bookName : outputDir.split(/[\\/]/).pop() ?? "未命名小说", outputDir),
3787
+ existed: false
3788
+ };
3789
+ }
3504
3790
  /** 激活一本书。 */
3505
3791
  function activateBook(id) {
3506
3792
  const store = loadBookshelf();
@@ -3878,6 +4164,12 @@ const NOVEL_API = {
3878
4164
  /** 清空助手对话记录。 */
3879
4165
  assistantClear: "/api/dsh-novel-forge/assistant/clear",
3880
4166
  bookshelf: "/api/dsh-novel-forge/bookshelf",
4167
+ /** 导入已有项目目录(含 novel-project.json)到书架。 */
4168
+ bookshelfImportDir: "/api/dsh-novel-forge/bookshelf/import-dir",
4169
+ /** 导入 txt/md 全本:拆章建项目并登记书架。 */
4170
+ bookshelfImportText: "/api/dsh-novel-forge/bookshelf/import-text",
4171
+ /** 导入 txt/md 全本:拆章预览(不落盘)。 */
4172
+ bookshelfImportTextPreview: "/api/dsh-novel-forge/bookshelf/import-text/preview",
3881
4173
  /** 重置项目(可选携带新大纲):清空设定/卷/章节/伏笔/资产/事实库。 */
3882
4174
  reset: "/api/dsh-novel-forge/reset",
3883
4175
  /** 全书一致性质检:LLM 扫描已生成章节,输出矛盾问题清单。 */
@@ -3912,6 +4204,8 @@ const NOVEL_API = {
3912
4204
  sensitiveCheck: "/api/dsh-novel-forge/sensitive-check",
3913
4205
  /** 开书想法 → AI 补全大纲:输入一句话想法,生成 2-3 个可选大纲方案。 */
3914
4206
  outlineSuggest: "/api/dsh-novel-forge/outline/suggest",
4207
+ /** 反推大纲:从已写章节正文反向生成全书总纲(NDJSON 流)。 */
4208
+ outlineReverse: "/api/dsh-novel-forge/outline/reverse",
3915
4209
  /** 拆书分析:对已写章节做结构/人物/文风/卖点四维体检(两阶段:源笔记→分节分析)。 */
3916
4210
  breakdown: "/api/dsh-novel-forge/breakdown",
3917
4211
  /** 漫剧分镜生成:章节 → 角色锚点 + 分镜表(可适配豆包/Seedance/SD)。 */
@@ -4718,7 +5012,7 @@ async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
4718
5012
  //#endregion
4719
5013
  //#region src/routes.ts
4720
5014
  /** Cap on JSON request bodies (generous: cover images travel as base64). */
4721
- const MAX_JSON_BODY_BYTES = 16 * 1024 * 1024;
5015
+ const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024;
4722
5016
  /** Loopback-only fence (mirrors the family plugins' pairing routes). */
4723
5017
  function isLoopbackRequest(request) {
4724
5018
  const address = request.socket.remoteAddress;
@@ -5729,6 +6023,90 @@ function makeRoutes(deps) {
5729
6023
  writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
5730
6024
  }
5731
6025
  };
6026
+ /** 导入已有项目目录(Mode A):校验 novel-project.json,登记/激活书架。 */
6027
+ const bookshelfImportDirRoute = {
6028
+ kind: "exact",
6029
+ path: NOVEL_API.bookshelfImportDir,
6030
+ handler: async (req, res) => {
6031
+ if (!guard(req, res, "POST")) return;
6032
+ const outputDir = (await readJsonBody(req))?.outputDir?.trim();
6033
+ if (outputDir === void 0 || outputDir === "") {
6034
+ writeJson(res, 400, { error: "outputDir 不能为空" });
6035
+ return;
6036
+ }
6037
+ try {
6038
+ const { book, existed } = importDir(outputDir);
6039
+ writeJson(res, 200, {
6040
+ book,
6041
+ existed
6042
+ });
6043
+ } catch (err) {
6044
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
6045
+ }
6046
+ }
6047
+ };
6048
+ /** 导入 txt/md 全本(Mode B):拆章落盘建项目,登记书架。支持浏览器上传(text+fileName)与服务器本地文件(filePath)两种模式。 */
6049
+ const bookshelfImportTextRoute = {
6050
+ kind: "exact",
6051
+ path: NOVEL_API.bookshelfImportText,
6052
+ handler: async (req, res) => {
6053
+ if (!guard(req, res, "POST")) return;
6054
+ const body = await readJsonBody(req);
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
+ }
6076
+ const bookName = basename(filePath, extname(filePath)).slice(0, 40) || "导入小说";
6077
+ const outDir = body?.outputDir?.trim() !== void 0 && body.outputDir.trim() !== "" ? body.outputDir.trim() : defaultOutputDirFor(bookName);
6078
+ const result = importBookText(filePath, outDir);
6079
+ const { book } = importDir(outDir);
6080
+ writeJson(res, 200, {
6081
+ ...result,
6082
+ book
6083
+ });
6084
+ } catch (err) {
6085
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
6086
+ }
6087
+ }
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
+ };
5732
6110
  /** 全书一致性质检。 */
5733
6111
  const auditRoute = {
5734
6112
  kind: "exact",
@@ -6510,6 +6888,52 @@ function makeRoutes(deps) {
6510
6888
  }
6511
6889
  }
6512
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
+ };
6513
6937
  /** 拆书分析:对已写章节做结构/人物/文风/卖点体检(两阶段:源笔记→分节分析)。 */
6514
6938
  const breakdownRoute = {
6515
6939
  kind: "exact",
@@ -6652,6 +7076,9 @@ function makeRoutes(deps) {
6652
7076
  bookshelfRoute,
6653
7077
  bookshelfActivateRoute,
6654
7078
  bookshelfRemoveRoute,
7079
+ bookshelfImportDirRoute,
7080
+ bookshelfImportTextRoute,
7081
+ bookshelfImportTextPreviewRoute,
6655
7082
  resetRoute,
6656
7083
  auditRoute,
6657
7084
  charactersRefreshRoute,
@@ -6672,6 +7099,7 @@ function makeRoutes(deps) {
6672
7099
  configRoute,
6673
7100
  openFolderRoute,
6674
7101
  outlineSuggestRoute,
7102
+ outlineReverseRoute,
6675
7103
  breakdownRoute,
6676
7104
  {
6677
7105
  kind: "exact",
@@ -6766,6 +7194,12 @@ const Config = z.object({
6766
7194
  "high",
6767
7195
  "max"
6768
7196
  ]).default("off"),
7197
+ analysisReasoning: z.union([
7198
+ "off",
7199
+ "low",
7200
+ "high",
7201
+ "max"
7202
+ ]).default("low"),
6769
7203
  chapterChars: z.number().default(3500),
6770
7204
  maxTokens: z.number().default(12e3),
6771
7205
  reviewPassScore: z.number().default(70),
@@ -6783,6 +7217,7 @@ const DEFAULT_OUTPUT_DIR = join(homedir(), ".dsh", "novels");
6783
7217
  const DEFAULT_PROVIDER = "deepseek-official";
6784
7218
  const DEFAULT_MODEL = "deepseek-v4-flash";
6785
7219
  const DEFAULT_REASONING_EFFORT = "off";
7220
+ const DEFAULT_ANALYSIS_REASONING = "low";
6786
7221
  const DEFAULT_CHAPTER_CHARS = 3500;
6787
7222
  const DEFAULT_MAX_TOKENS = 12e3;
6788
7223
  const DEFAULT_REVIEW_PASS_SCORE = 70;
@@ -6801,6 +7236,7 @@ function resolveConfig(value) {
6801
7236
  provider: value?.provider ?? DEFAULT_PROVIDER,
6802
7237
  model: value?.model ?? DEFAULT_MODEL,
6803
7238
  reasoningEffort: value?.reasoningEffort ?? DEFAULT_REASONING_EFFORT,
7239
+ analysisReasoning: value?.analysisReasoning ?? DEFAULT_ANALYSIS_REASONING,
6804
7240
  chapterChars: value?.chapterChars ?? DEFAULT_CHAPTER_CHARS,
6805
7241
  maxTokens: value?.maxTokens ?? DEFAULT_MAX_TOKENS,
6806
7242
  reviewPassScore: value?.reviewPassScore ?? DEFAULT_REVIEW_PASS_SCORE,
@@ -6835,6 +7271,7 @@ function apply(ctx, config) {
6835
7271
  if (patch.provider !== void 0) next.provider = patch.provider;
6836
7272
  if (patch.model !== void 0) next.model = patch.model;
6837
7273
  if (patch.reasoningEffort !== void 0) next.reasoningEffort = patch.reasoningEffort;
7274
+ if (patch.analysisReasoning !== void 0) next.analysisReasoning = patch.analysisReasoning;
6838
7275
  if (patch.chapterChars !== void 0) next.chapterChars = patch.chapterChars;
6839
7276
  if (patch.maxTokens !== void 0) next.maxTokens = patch.maxTokens;
6840
7277
  if (patch.reviewPassScore !== void 0) next.reviewPassScore = patch.reviewPassScore;