@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/client.js +1126 -584
- package/lib/client.js.map +1 -1
- package/lib/index.js +468 -31
- package/lib/index.js.map +1 -1
- package/lib/types/bookshelf.d.ts +5 -0
- package/lib/types/client/api.d.ts +10 -0
- package/lib/types/client/locales.d.ts +4 -2
- package/lib/types/client/panel/ImportModal.d.ts +7 -0
- package/lib/types/client/panel/ShelfView.d.ts +3 -1
- package/lib/types/engine.d.ts +32 -0
- package/lib/types/index.d.ts +2 -0
- package/lib/types/protocol.d.ts +74 -0
- package/package.json +97 -97
- package/src/bookshelf.ts +16 -0
- package/src/client/api.ts +435 -410
- package/src/client/locales.ts +7 -3
- package/src/client/panel/ImportModal.tsx +293 -0
- package/src/client/panel/NovelPanel.tsx +5132 -5046
- package/src/client/panel/ShelfView.tsx +249 -236
- package/src/client/panel/panel.module.css +3114 -2941
- package/src/engine.ts +3172 -2943
- package/src/index.ts +6 -0
- package/src/protocol.ts +1277 -1207
- package/src/routes.ts +2409 -2272
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
|
|
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,
|
|
1870
|
+
if (text.length > 0) excerptParts.push(`第${chapter.no}章《${chapter.title}》\n${text.slice(0, 1500)}`);
|
|
1839
1871
|
}
|
|
1840
|
-
const
|
|
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 =
|
|
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;
|