@waterwx/dsh-novel-forge 1.1.6 → 1.1.8
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/README.md +101 -5
- package/lib/client.js +2488 -658
- package/lib/client.js.map +1 -1
- package/lib/index.js +641 -12
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +14 -4
- package/lib/types/client/locales.d.ts +2 -0
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/client/panel/ReaderView.d.ts +8 -0
- package/lib/types/client/panel/ShelfView.d.ts +3 -1
- package/lib/types/engine.d.ts +46 -2
- package/lib/types/index.d.ts +2 -0
- package/lib/types/protocol.d.ts +179 -1
- package/package.json +3 -1
- package/src/client/api.ts +27 -6
- package/src/client/locales.ts +4 -0
- package/src/client/panel/CreateBookView.tsx +159 -0
- package/src/client/panel/NovelPanel.tsx +607 -98
- package/src/client/panel/ReaderView.tsx +306 -0
- package/src/client/panel/ShelfView.tsx +146 -23
- package/src/client/panel/panel.module.css +374 -4
- package/src/engine.ts +575 -3
- package/src/index.ts +56 -0
- package/src/protocol.ts +193 -1
- package/src/routes.ts +179 -4
package/lib/index.js
CHANGED
|
@@ -75,6 +75,14 @@ const NOVEL_API = {
|
|
|
75
75
|
chapterApprove: "/api/dsh-novel-forge/chapter/approve",
|
|
76
76
|
/** 敏感词检查:全书已写章节或指定文本。 */
|
|
77
77
|
sensitiveCheck: "/api/dsh-novel-forge/sensitive-check",
|
|
78
|
+
/** 开书想法 → AI 补全大纲:输入一句话想法,生成 2-3 个可选大纲方案。 */
|
|
79
|
+
outlineSuggest: "/api/dsh-novel-forge/outline/suggest",
|
|
80
|
+
/** 拆书分析:对已写章节做结构/人物/文风/卖点四维体检(两阶段:源笔记→分节分析)。 */
|
|
81
|
+
breakdown: "/api/dsh-novel-forge/breakdown",
|
|
82
|
+
/** 漫剧分镜生成:章节 → 角色锚点 + 分镜表(可适配豆包/Seedance/SD)。 */
|
|
83
|
+
storyboard: "/api/dsh-novel-forge/storyboard",
|
|
84
|
+
/** 漫剧分集计划:读一卷 → 按故事弧线分集(高潮拆集/过渡并章)。 */
|
|
85
|
+
storyboardPlan: "/api/dsh-novel-forge/storyboard/plan",
|
|
78
86
|
config: "/api/dsh-novel-forge/config",
|
|
79
87
|
openFolder: "/api/dsh-novel-forge/open-folder"
|
|
80
88
|
};
|
|
@@ -1650,17 +1658,18 @@ async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
|
|
|
1650
1658
|
* 审查「任意正文文本」(作者手动编辑后的草稿,不落盘)。
|
|
1651
1659
|
* 复用审稿提示词与红线/道藏/反AI规则;仅返回报告,不改文件不改状态。
|
|
1652
1660
|
*/
|
|
1653
|
-
async function reviewChapterText(ctx, config, project, text) {
|
|
1661
|
+
async function reviewChapterText(ctx, config, project, text, previousReport) {
|
|
1654
1662
|
const user = [
|
|
1655
1663
|
`书名:《${project.bookName}》`,
|
|
1656
|
-
"====================
|
|
1664
|
+
previousReport !== void 0 ? "==================== 上一轮审稿意见(逐条核对是否已解决) ====================\n" + previousReport.issues.map((it, i) => `${i + 1}. [${it.severity}] ${it.item}${it.suggestion !== "" ? ` → ${it.suggestion}` : ""}`).join("\n") : "",
|
|
1665
|
+
previousReport !== void 0 ? "==================== 修订稿(上一轮审稿后按意见修订的正文) ====================" : "==================== 待审查正文 ====================",
|
|
1657
1666
|
text.slice(0, 2e4)
|
|
1658
1667
|
].join("\n");
|
|
1659
1668
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
1660
|
-
system: reviewSystemPrompt(project),
|
|
1669
|
+
system: previousReport !== void 0 ? verifySystemPrompt(project) : reviewSystemPrompt(project),
|
|
1661
1670
|
user,
|
|
1662
1671
|
temperature: .3,
|
|
1663
|
-
maxTokens: Math.max(config.maxTokens,
|
|
1672
|
+
maxTokens: Math.max(config.maxTokens, 16e3)
|
|
1664
1673
|
}));
|
|
1665
1674
|
const issues = Array.isArray(raw.issues) ? raw.issues.filter((v) => typeof v === "object" && v !== null).map((entry) => ({
|
|
1666
1675
|
severity: [
|
|
@@ -1672,14 +1681,37 @@ async function reviewChapterText(ctx, config, project, text) {
|
|
|
1672
1681
|
suggestion: typeof entry.suggestion === "string" ? entry.suggestion : ""
|
|
1673
1682
|
})).filter((issue) => issue.item !== "") : [];
|
|
1674
1683
|
const score = typeof raw.score === "number" ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60;
|
|
1684
|
+
let passed = score >= config.reviewPassScore;
|
|
1685
|
+
if (previousReport !== void 0) {
|
|
1686
|
+
const hasHigh = issues.some((i) => i.severity === "high");
|
|
1687
|
+
const prevHighResolved = previousReport.issues.filter((i) => i.severity === "high").every((p) => !issues.some((i) => i.item.includes(p.item.slice(0, 12))));
|
|
1688
|
+
passed = !hasHigh && prevHighResolved;
|
|
1689
|
+
}
|
|
1675
1690
|
return {
|
|
1676
1691
|
score,
|
|
1677
|
-
passed
|
|
1692
|
+
passed,
|
|
1678
1693
|
verdict: typeof raw.verdict === "string" ? raw.verdict.slice(0, 200) : "",
|
|
1679
1694
|
issues,
|
|
1680
1695
|
reviewedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1681
1696
|
};
|
|
1682
1697
|
}
|
|
1698
|
+
/** 验证模式系统提示:修订后逐条核对原意见是否解决,只挑新增 high,不重复挑剔主观项。 */
|
|
1699
|
+
function verifySystemPrompt(project) {
|
|
1700
|
+
return [
|
|
1701
|
+
"你是一位网文审稿验证员。作者已按上一轮审稿意见修订了本章,你需要验证修订效果。",
|
|
1702
|
+
"你的任务(严格按此执行):",
|
|
1703
|
+
"1. 逐条核对「上一轮意见」中的每一条是否已在修订稿中解决——已解决的不再列出;未解决或部分解决的,按原严重度列出(item 需注明\"未解决:原意见 xxx\")。",
|
|
1704
|
+
"2. 只挑修订【新引入】的 high 级问题(设定矛盾/逻辑硬伤/事实错误)——新引入的 medium/low 主观项(文笔/套话/节奏)不要列。",
|
|
1705
|
+
"3. 禁止重复挑剔上一轮已指出且本次已解决的主观项(如\"缓缓/微微\"等套话、错别字)——即使换个说法再提也不行。",
|
|
1706
|
+
"4. 严禁为了显得专业而新增\"换一批毛病\"式的意见;如果修订稿已解决全部 high 且无新增 high,输出 issues 为空数组。",
|
|
1707
|
+
"score 评分:按修订稿整体质量给 50-90 分(解决全部 high 且无新增 high 时给 70 以上)。",
|
|
1708
|
+
"verdict:一句话结论(如\"原 high 已解决,无新增高风险问题\"或\"仍有未解决的 high\")。",
|
|
1709
|
+
"输出必须是合法 JSON 对象:{\"score\": 数字, \"verdict\": \"一句话\", \"issues\": [{\"severity\": \"high|medium|low\", \"item\": \"问题\", \"suggestion\": \"建议\"}]}",
|
|
1710
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
1711
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。",
|
|
1712
|
+
`本书设定圣经(核对设定冲突用):\n${project.bible !== void 0 ? JSON.stringify(project.bible).slice(0, 3e3) : "(无)"}`
|
|
1713
|
+
].join("\n");
|
|
1714
|
+
}
|
|
1683
1715
|
/** Build the author-review system prompt (narrative structure, not prose). */
|
|
1684
1716
|
function authorReviewSystemPrompt() {
|
|
1685
1717
|
return [
|
|
@@ -1877,6 +1909,401 @@ async function extractRoles(ctx, config, project) {
|
|
|
1877
1909
|
}
|
|
1878
1910
|
return roles;
|
|
1879
1911
|
}
|
|
1912
|
+
/**
|
|
1913
|
+
* 为单个角色提炼「动漫形象描述词」:扫描该角色出场的已写章节正文,
|
|
1914
|
+
* 截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
|
|
1915
|
+
*/
|
|
1916
|
+
async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
|
|
1917
|
+
const role = (project.roles ?? []).find((r) => r.name === roleName);
|
|
1918
|
+
if (role === void 0) throw new Error(`角色「${roleName}」不在角色库中`);
|
|
1919
|
+
const appearanceHints = /(发|眉|眼|眸|脸|肤|唇|身材|身高|衣|袍|裙|衫|靴|腰带|气质|模样|长相|容貌|披|束|扎|戴|佩|挂|绣|青|白|黑|红|蓝|紫|灰|银|金|少年|青年|少女|汉子|老者|中年|纤细|挺拔|瘦削|壮实|清秀|俊朗|英气|阴鸷|慈眉)/;
|
|
1920
|
+
const excerpts = [];
|
|
1921
|
+
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating" && c.file !== void 0).slice(-60);
|
|
1922
|
+
for (const chapter of written) {
|
|
1923
|
+
const body = readChapterFile(outputDir, chapter);
|
|
1924
|
+
if (body === void 0) continue;
|
|
1925
|
+
const paras = body.split(/\n{2,}/).map((p) => p.trim()).filter((p) => p.length > 0);
|
|
1926
|
+
let perChapter = 0;
|
|
1927
|
+
for (const para of paras) {
|
|
1928
|
+
if (perChapter >= 2 || excerpts.length >= 12) break;
|
|
1929
|
+
if (!para.includes(roleName)) continue;
|
|
1930
|
+
if (appearanceHints.test(para) || excerpts.length < 4) {
|
|
1931
|
+
excerpts.push({
|
|
1932
|
+
no: chapter.no,
|
|
1933
|
+
text: para.slice(0, 220)
|
|
1934
|
+
});
|
|
1935
|
+
perChapter++;
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
if (excerpts.length >= 12) break;
|
|
1939
|
+
}
|
|
1940
|
+
if (excerpts.length === 0) throw new Error(`正文中未找到「${roleName}」的出场描写(仅搜索最近 60 章),请确认角色名与正文一致`);
|
|
1941
|
+
const raw = parseJsonObject(await complete(ctx, config, {
|
|
1942
|
+
system: [
|
|
1943
|
+
"你是一位动漫角色设定师。根据网文正文中该角色的实际外貌描写,提炼「动漫形象描述词」,用于 AI 绘图(NovelAI / Stable Diffusion / Midjourney / 豆包等)生成一致的角色立绘。",
|
|
1944
|
+
"硬性要求(依据优先):",
|
|
1945
|
+
"1. 发色/发型/瞳色/服装/气质/标志物必须来自提供的正文段落,不得凭空发明。",
|
|
1946
|
+
"2. 正文未明确写到的项目(如瞳色没写),用「未定」标注,不要编造。",
|
|
1947
|
+
"3. 服装优先取正文明确出现的(颜色+款式),多次出现取最常穿的组合。",
|
|
1948
|
+
"输出三个部分:",
|
|
1949
|
+
"- zh:中文外貌描述,一段连贯文字(60-150 字):发色发型、瞳色、脸型气质、服装(颜色款式)、身材、标志性物件。",
|
|
1950
|
+
"- en:英文绘图标签,booru 风格、逗号分隔、小写,30-50 个标签:含性别(1boy/1girl)、发色、发型、瞳色、服装(如 chinese hanfu / daoist robe)、气质、背景无关项。不要输出负面提示词。",
|
|
1951
|
+
"- tags:中文关键标签数组,5-10 个(如 [\"黑发\",\"束发\",\"青色道袍\",\"清秀\",\"腰悬古玉\"])。",
|
|
1952
|
+
"- source:说明依据(如\"第1章/第8章外貌描写;瞳色未明确\")。",
|
|
1953
|
+
"输出必须是合法 JSON 对象:{\"zh\": \"...\", \"en\": \"...\", \"tags\": [...], \"source\": \"...\"}",
|
|
1954
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
1955
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
1956
|
+
].join("\n"),
|
|
1957
|
+
user: [
|
|
1958
|
+
`书名:《${project.bookName}》`,
|
|
1959
|
+
`目标角色:${role.name}(${role.identity})`,
|
|
1960
|
+
role.traits.length > 0 ? `性格标签:${role.traits.join("、")}` : "",
|
|
1961
|
+
`正文出场描写(含外貌线索的段落):`,
|
|
1962
|
+
...excerpts.map((e) => `[第${e.no}章] ${e.text}`),
|
|
1963
|
+
"只输出 JSON 对象。"
|
|
1964
|
+
].join("\n\n"),
|
|
1965
|
+
temperature: .4,
|
|
1966
|
+
maxTokens: Math.max(config.maxTokens, 4e3)
|
|
1967
|
+
}));
|
|
1968
|
+
const zh = typeof raw.zh === "string" ? raw.zh.trim().slice(0, 500) : "";
|
|
1969
|
+
const en = typeof raw.en === "string" ? raw.en.trim().slice(0, 1500) : "";
|
|
1970
|
+
const tags = Array.isArray(raw.tags) ? raw.tags.filter((t) => typeof t === "string" && t.trim() !== "").map((t) => t.trim().slice(0, 20)).slice(0, 12) : [];
|
|
1971
|
+
const source = typeof raw.source === "string" ? raw.source.trim().slice(0, 300) : "";
|
|
1972
|
+
if (zh === "" || en === "") throw new Error("形象描述提炼失败:LLM 未返回有效 JSON");
|
|
1973
|
+
return {
|
|
1974
|
+
zh,
|
|
1975
|
+
en,
|
|
1976
|
+
tags,
|
|
1977
|
+
source
|
|
1978
|
+
};
|
|
1979
|
+
}
|
|
1980
|
+
/**
|
|
1981
|
+
* 开书想法 → AI 大纲:输入一句话想法,生成 2-3 个方向不同、可直接开书的完整大纲方案。
|
|
1982
|
+
* @param count 本次生成几个(默认 3,最多 3)
|
|
1983
|
+
* @param exclude 已暂留方案的剧情方向/卖点摘要(换批时避开,防止重复)
|
|
1984
|
+
*/
|
|
1985
|
+
async function suggestOutlines(ctx, config, idea, count = 3, exclude = []) {
|
|
1986
|
+
const n = Math.max(1, Math.min(3, Math.floor(count)));
|
|
1987
|
+
const parsed = parseJsonArray(await complete(ctx, config, {
|
|
1988
|
+
system: [
|
|
1989
|
+
"你是一位资深网文策划。作者只给了一句「想法」,你需要把它扩展成 2-3 个【方向差异明显】的完整小说大纲方案,供作者挑选。",
|
|
1990
|
+
"每个方案必须满足:",
|
|
1991
|
+
"1. bookName:书名(6 字以内,抓眼球、点题)。",
|
|
1992
|
+
"2. genre:题材(如 仙侠修真 / 都市异能 / 玄幻 / 悬疑)。",
|
|
1993
|
+
"3. sellingPoint:核心卖点一句话(金手指/爽点/差异化,40 字内)。",
|
|
1994
|
+
"4. outline:完整大纲文本(至少 800 字,可直接作为开书大纲),结构包含:书名与题材、金手指/核心设定、主角人设与动机、主线剧情走向(至少 5 个阶段)、关键配角与势力、卖点与爽点设计、预计分卷(3-5 卷)。",
|
|
1995
|
+
"方向差异要求:",
|
|
1996
|
+
"- 方案之间的金手指/剧情走向必须明显不同(如:苟道发育流 vs 随身老爷爷流 vs 群像争霸流),不能只是换书名。",
|
|
1997
|
+
"- 忠实于作者想法的核心要素,但允许在不同方向上进行合理演绎。",
|
|
1998
|
+
"- 不输出任何与已列「需避开的方向」雷同的方案。",
|
|
1999
|
+
"输出必须是合法 JSON 数组,只输出数组本身:",
|
|
2000
|
+
"[{\"id\": \"唯一id\", \"bookName\": \"...\", \"genre\": \"...\", \"sellingPoint\": \"...\", \"outline\": \"...\"}]",
|
|
2001
|
+
`本次只输出 ${n} 个方案。`,
|
|
2002
|
+
"重要:所有字符串值内部不得包含换行符(大纲内部分段请用「。\n」或「;」自然断句),JSON 必须在一段内完整结束。",
|
|
2003
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
2004
|
+
].join("\n"),
|
|
2005
|
+
user: [
|
|
2006
|
+
`作者的想法:${idea}`,
|
|
2007
|
+
exclude.length > 0 ? `需避开的已暂留方案方向(新方案不得与之雷同):\n${exclude.map((e, i) => `${i + 1}. ${e}`).join("\n")}` : "",
|
|
2008
|
+
`请生成 ${n} 个大纲方案。`,
|
|
2009
|
+
"只输出 JSON 数组。"
|
|
2010
|
+
].join("\n\n"),
|
|
2011
|
+
temperature: .85,
|
|
2012
|
+
maxTokens: Math.max(config.maxTokens, 12e3)
|
|
2013
|
+
}));
|
|
2014
|
+
const candidates = [];
|
|
2015
|
+
for (const entry of parsed) {
|
|
2016
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
2017
|
+
const bookName = typeof entry.bookName === "string" ? entry.bookName.trim().slice(0, 30) : "";
|
|
2018
|
+
const outline = typeof entry.outline === "string" ? entry.outline.trim() : "";
|
|
2019
|
+
if (bookName === "" || outline.length < 300) continue;
|
|
2020
|
+
candidates.push({
|
|
2021
|
+
id: typeof entry.id === "string" && entry.id !== "" ? entry.id : `oc-${Date.now().toString(36)}-${candidates.length}`,
|
|
2022
|
+
bookName,
|
|
2023
|
+
genre: typeof entry.genre === "string" ? entry.genre.trim().slice(0, 20) : "",
|
|
2024
|
+
sellingPoint: typeof entry.sellingPoint === "string" ? entry.sellingPoint.trim().slice(0, 120) : "",
|
|
2025
|
+
outline
|
|
2026
|
+
});
|
|
2027
|
+
}
|
|
2028
|
+
if (candidates.length === 0) throw new Error("大纲方案生成失败:LLM 未返回有效 JSON(可重试)");
|
|
2029
|
+
return candidates.slice(0, n);
|
|
2030
|
+
}
|
|
2031
|
+
/** 拆书分析:对已写章节做结构/人物/文风/卖点四维体检。
|
|
2032
|
+
* 两阶段管道(借鉴 AI-Novel-Writing-Assistant):
|
|
2033
|
+
* ① 源片段笔记:每章抽取结构化笔记(剧情/人物/设定/写法/卖点/短板信号)
|
|
2034
|
+
* ② 分节分析:按维度各跑一次 LLM,输出可读分析稿 + 结构化数据 + 证据链。
|
|
2035
|
+
* @param scope 'recent'(默认最近20章) | 'volume:N' | 'all'
|
|
2036
|
+
* @param preset 'quick'(总览/剧情/人物/文风) | 'standard'(+卖点)
|
|
2037
|
+
* @param budgetTokens token 预算上限(超过即截断章节取样)。
|
|
2038
|
+
*/
|
|
2039
|
+
async function breakdownBook(ctx, config, project, outputDir, scope = "recent", preset = "quick", budgetTokens = 5e4) {
|
|
2040
|
+
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating" && c.summary !== void 0 && c.summary !== "");
|
|
2041
|
+
let selected = written;
|
|
2042
|
+
if (scope === "recent") selected = written.slice(-20);
|
|
2043
|
+
else if (/^volume:\d+$/.test(scope)) {
|
|
2044
|
+
const v = Number(scope.slice(7));
|
|
2045
|
+
selected = written.filter((c) => c.volume === v);
|
|
2046
|
+
}
|
|
2047
|
+
if (selected.length === 0) throw new Error("没有可分析的已写章节(需要已生成并带摘要)");
|
|
2048
|
+
let budget = budgetTokens;
|
|
2049
|
+
const chunks = [];
|
|
2050
|
+
for (const c of selected.slice().reverse()) {
|
|
2051
|
+
const bodySlice = (readChapterFile(outputDir, c) ?? "").replace(/^#\s+.*$/m, "").trim().slice(0, 4e3);
|
|
2052
|
+
const est = Math.ceil((bodySlice.length + (c.summary?.length ?? 0)) / 4) + 400;
|
|
2053
|
+
if (est > budget && chunks.length > 0) break;
|
|
2054
|
+
chunks.unshift({
|
|
2055
|
+
no: c.no,
|
|
2056
|
+
title: c.title,
|
|
2057
|
+
summary: c.summary ?? "",
|
|
2058
|
+
body: bodySlice
|
|
2059
|
+
});
|
|
2060
|
+
budget -= est;
|
|
2061
|
+
}
|
|
2062
|
+
const notes = [];
|
|
2063
|
+
let usedTokens = 0;
|
|
2064
|
+
const noteSystem = [
|
|
2065
|
+
"你是中文网文拆书助手。把单章正文整理成结构化笔记,供后续章节级分析复用。",
|
|
2066
|
+
"只输出 JSON 对象:",
|
|
2067
|
+
"{\"summary\": \"1-2句\", \"plotPoints\": [\"...\"], \"characters\": [\"...\"], \"worldbuilding\": [\"...\"], \"styleTechniques\": [\"...\"], \"marketHighlights\": [\"...\"], \"weaknessSignals\": [\"...\"]}",
|
|
2068
|
+
"硬规则:只提取正文明确出现的信息;每数组最多 4 项;不要补写原文外的动机/意图;evidence 不在此阶段输出。",
|
|
2069
|
+
"重要:直接输出 JSON,不要输出其他文字;字符串内不含换行。"
|
|
2070
|
+
].join("\n");
|
|
2071
|
+
for (const ch of chunks) {
|
|
2072
|
+
const noteUser = [
|
|
2073
|
+
`第${ch.no}章《${ch.title}》`,
|
|
2074
|
+
"正文:",
|
|
2075
|
+
ch.body.slice(0, 3e3)
|
|
2076
|
+
].join("\n");
|
|
2077
|
+
try {
|
|
2078
|
+
const raw = parseJsonObject(await complete(ctx, config, {
|
|
2079
|
+
system: noteSystem,
|
|
2080
|
+
user: noteUser,
|
|
2081
|
+
temperature: .2,
|
|
2082
|
+
maxTokens: Math.max(config.maxTokens, 3e3)
|
|
2083
|
+
}));
|
|
2084
|
+
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) : [];
|
|
2085
|
+
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(";") || "(无明显短板信号)"}`);
|
|
2086
|
+
usedTokens += 800;
|
|
2087
|
+
} catch {}
|
|
2088
|
+
}
|
|
2089
|
+
const sectionsConfig = [
|
|
2090
|
+
{
|
|
2091
|
+
key: "overview",
|
|
2092
|
+
title: "拆书总览",
|
|
2093
|
+
focus: "一句话定位、题材标签、整体优势与短板",
|
|
2094
|
+
system: [
|
|
2095
|
+
"你是资深中文网文拆书分析师,负责《拆书总览》小节。",
|
|
2096
|
+
"基于给定章节笔记做低风险综合判断,输出 JSON:{\"markdown\": \"可直接展示的分析稿(简体中文,先给结论再说明体现在哪、为何成立)\", \"structured\": {\"oneLinePositioning\": \"一句话定位\", \"genreTags\": [\"题材标签\"], \"sellingPointTags\": [\"卖点标签\"], \"strengths\": [\"整体优势\"], \"weaknesses\": [\"整体短板\"]}}",
|
|
2097
|
+
"硬规则:只基于笔记归纳;推断用「更偏向/可能」等谨慎措辞;证据不足写「材料不足」;不虚构原文细节。",
|
|
2098
|
+
"重要:直接输出 JSON,字符串内不含换行。"
|
|
2099
|
+
].join("\n")
|
|
2100
|
+
},
|
|
2101
|
+
{
|
|
2102
|
+
key: "plot",
|
|
2103
|
+
title: "剧情结构",
|
|
2104
|
+
focus: "主线梗概、阶段推进、冲突升级、节奏风险",
|
|
2105
|
+
system: [
|
|
2106
|
+
"你是资深中文网文拆书分析师,负责《剧情结构》小节。",
|
|
2107
|
+
"基于给定章节笔记分析,输出 JSON:{\"markdown\": \"分析稿(简体中文,先结论后依据)\", \"structured\": {\"mainlineSummary\": \"主线梗概\", \"phaseProgressions\": [\"阶段推进\"], \"escalationDesigns\": [\"冲突升级\"], \"paceRisks\": [\"节奏风险\"], \"reusablePatterns\": [\"可复用套路\"]}}",
|
|
2108
|
+
"硬规则:只基于笔记归纳;推断谨慎措辞;不虚构。",
|
|
2109
|
+
"重要:直接输出 JSON,字符串内不含换行。"
|
|
2110
|
+
].join("\n")
|
|
2111
|
+
},
|
|
2112
|
+
{
|
|
2113
|
+
key: "character",
|
|
2114
|
+
title: "人物系统",
|
|
2115
|
+
focus: "主角定位、配角功能、关系网络、成长弧线、辨识度风险",
|
|
2116
|
+
system: [
|
|
2117
|
+
"你是资深中文网文拆书分析师,负责《人物系统》小节。",
|
|
2118
|
+
"基于给定章节笔记分析,输出 JSON:{\"markdown\": \"分析稿(简体中文,先结论后依据)\", \"structured\": {\"protagonistPositioning\": \"主角定位\", \"supportingFunctions\": [\"配角功能\"], \"relationshipNetwork\": [\"关系网络\"], \"growthArcs\": [\"成长弧线\"], \"clarityRisks\": [\"辨识度风险\"]}}",
|
|
2119
|
+
"硬规则:只基于笔记归纳;推断谨慎措辞;不虚构。",
|
|
2120
|
+
"重要:直接输出 JSON,字符串内不含换行。"
|
|
2121
|
+
].join("\n")
|
|
2122
|
+
},
|
|
2123
|
+
{
|
|
2124
|
+
key: "style",
|
|
2125
|
+
title: "文风与技法",
|
|
2126
|
+
focus: "叙事视角、语言风格、描写方式、节奏控制、钩子设计、可复用写法",
|
|
2127
|
+
system: [
|
|
2128
|
+
"你是资深中文网文拆书分析师,负责《文风与技法》小节。",
|
|
2129
|
+
"基于给定章节笔记分析,输出 JSON:{\"markdown\": \"分析稿(简体中文,先结论后依据)\", \"structured\": {\"narrativePov\": \"叙事视角\", \"languageStyle\": \"语言风格\", \"dialoguePatterns\": [\"对话特征\"], \"rhythmControl\": [\"节奏控制\"], \"hookDesigns\": [\"钩子设计\"], \"reusableTechniques\": [\"可复用写法\"]}}",
|
|
2130
|
+
"硬规则:只基于笔记归纳;推断谨慎措辞;不虚构。",
|
|
2131
|
+
"重要:直接输出 JSON,字符串内不含换行。"
|
|
2132
|
+
].join("\n")
|
|
2133
|
+
}
|
|
2134
|
+
];
|
|
2135
|
+
if (preset === "standard") sectionsConfig.push({
|
|
2136
|
+
key: "market",
|
|
2137
|
+
title: "商业化卖点",
|
|
2138
|
+
focus: "读者爽点、点击驱动、人物/题材卖点、商业化风险",
|
|
2139
|
+
system: [
|
|
2140
|
+
"你是资深中文网文拆书分析师,负责《商业化卖点》小节。",
|
|
2141
|
+
"基于给定章节笔记分析,输出 JSON:{\"markdown\": \"分析稿(简体中文,先结论后依据)\", \"structured\": {\"hookPoints\": [\"读者爽点\"], \"clickDrivers\": [\"点击驱动\"], \"characterSellingPoints\": [\"人物卖点\"], \"genreSellingPoints\": [\"题材卖点\"], \"commercialRisks\": [\"商业化风险\"]}}",
|
|
2142
|
+
"硬规则:只基于笔记归纳;推断谨慎措辞;不虚构。",
|
|
2143
|
+
"重要:直接输出 JSON,字符串内不含换行。"
|
|
2144
|
+
].join("\n")
|
|
2145
|
+
});
|
|
2146
|
+
const notesText = notes.join("\n\n");
|
|
2147
|
+
const sections = [];
|
|
2148
|
+
const evidence = [];
|
|
2149
|
+
for (const sec of sectionsConfig) try {
|
|
2150
|
+
const raw = parseJsonObject(await complete(ctx, config, {
|
|
2151
|
+
system: sec.system,
|
|
2152
|
+
user: `分析范围:${selected.length} 章(${scope === "all" ? "全书" : scope === "recent" ? "最近 20 章" : "指定卷"})。\n\n章节笔记:\n${notesText}`,
|
|
2153
|
+
temperature: .3,
|
|
2154
|
+
maxTokens: Math.max(config.maxTokens, 6e3)
|
|
2155
|
+
}));
|
|
2156
|
+
sections.push({
|
|
2157
|
+
key: sec.key,
|
|
2158
|
+
title: sec.title,
|
|
2159
|
+
markdown: typeof raw.markdown === "string" ? raw.markdown.trim() : "(生成失败)",
|
|
2160
|
+
structured: typeof raw.structured === "object" && raw.structured !== null ? raw.structured : {}
|
|
2161
|
+
});
|
|
2162
|
+
usedTokens += 2e3;
|
|
2163
|
+
} catch {
|
|
2164
|
+
sections.push({
|
|
2165
|
+
key: sec.key,
|
|
2166
|
+
title: sec.title,
|
|
2167
|
+
markdown: "(本节生成失败,可重试)",
|
|
2168
|
+
structured: {}
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2171
|
+
return {
|
|
2172
|
+
sections,
|
|
2173
|
+
evidence,
|
|
2174
|
+
chaptersScanned: chunks.length,
|
|
2175
|
+
usedTokens
|
|
2176
|
+
};
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* 漫剧分镜生成:把一章正文改编为短视频漫剧分镜(吸收 manga-script-master 方法论)。
|
|
2180
|
+
* 产出:本集标题 + 赛道节奏说明 + 角色视觉锚点卡 + 分镜表(8-12 格)+ 结尾钩子。
|
|
2181
|
+
* 角色锚点优先复用角色库 imagePrompt(无则从正文提炼)。
|
|
2182
|
+
*/
|
|
2183
|
+
async function generateStoryboard(ctx, config, project, outputDir, chapterNo, genre, platform, tool) {
|
|
2184
|
+
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
2185
|
+
if (chapter === void 0) throw new Error(`章节 ${chapterNo} 不在计划中`);
|
|
2186
|
+
const body = readChapterFile(outputDir, chapter);
|
|
2187
|
+
if (body === void 0) throw new Error(`章节 ${chapterNo} 尚未生成正文`);
|
|
2188
|
+
const roleCards = (project.roles ?? []).filter((r) => r.imagePrompt !== void 0 && (body.includes(r.name) || chapter.summary?.includes(r.name) === true)).map((r) => ({
|
|
2189
|
+
name: r.name,
|
|
2190
|
+
anchor: r.imagePrompt.zh.slice(0, 120),
|
|
2191
|
+
tags: r.imagePrompt.en.slice(0, 300)
|
|
2192
|
+
})).slice(0, 3);
|
|
2193
|
+
const raw = parseJsonObject(await complete(ctx, config, {
|
|
2194
|
+
system: [
|
|
2195
|
+
"你是一位精通短视频算法与 AIGC 的顶级漫剧导演。把给定的一章网文改编为高完播率的漫剧分镜。",
|
|
2196
|
+
"硬性要求:",
|
|
2197
|
+
"1. 前 3 秒必须有强冲突或视觉冲击(黄金开局);每 10 秒至少一个小高潮;结尾必须有悬念钩子。",
|
|
2198
|
+
"2. 所有情感必须具象化为动作(不能写\"他很愤怒\",要写\"他拳头砸碎桌角,碎片飞溅\")。",
|
|
2199
|
+
"3. 台词每句 ≤15 字,用画面推进叙事。",
|
|
2200
|
+
"4. 总时长 60-120 秒,分镜 8-12 格,每格标注时间码、景别、转场/动效。",
|
|
2201
|
+
"5. 输出 JSON:",
|
|
2202
|
+
"{\"title\": \"本集标题(核心设定+冲突+悬念句式)\", \"pacingNote\": \"赛道判断与节奏说明(按爽文/甜宠/悬疑/搞笑模型)\", \"hook\": \"本集钩子一句话\", \"panels\": [{\"timecode\": \"0:00-0:03\", \"shot\": \"特写\", \"visual\": \"具象画面描述\", \"dialogue\": \"(情绪)台词\", \"transition\": \"硬切/闪白等\", \"prompt\": \"英文AI提示词:角色标签+表情+动作+场景+景别+光影+风格+9:16\"}], \"endingHook\": \"结尾钩子台词\"}",
|
|
2203
|
+
"6. 英文 prompt 里的角色标签必须复用提供的角色锚点标签;多角色同框用 left/right/foreground 分隔。",
|
|
2204
|
+
"7. 严禁在提示词里出现文字/水印(画面内字幕后期加)。",
|
|
2205
|
+
"重要:直接输出 JSON,字符串内不含换行。"
|
|
2206
|
+
].join("\n"),
|
|
2207
|
+
user: [
|
|
2208
|
+
`书名:《${project.bookName}》`,
|
|
2209
|
+
`章节:第 ${chapter.no} 章《${chapter.title}》`,
|
|
2210
|
+
chapter.summary !== void 0 ? `章节摘要:${chapter.summary}` : "",
|
|
2211
|
+
`目标平台:${platform}(默认抖音竖屏 9:16)`,
|
|
2212
|
+
`赛道:${genre !== "" ? genre : "(由你按内容判断:爽文/甜宠/悬疑/搞笑)"}`,
|
|
2213
|
+
`AI 工具:${tool}(影响提示词格式)`,
|
|
2214
|
+
roleCards.length > 0 ? `角色视觉锚点(英文标签全分镜强制复用):\n${roleCards.map((r) => `- ${r.name}:${r.tags}`).join("\n")}` : "(无角色锚点卡,从正文提炼 1-2 个核心角色并给出标签)",
|
|
2215
|
+
"==================== 正文 ====================",
|
|
2216
|
+
body.replace(/^#\s+.*$/m, "").trim().slice(0, 6e3),
|
|
2217
|
+
"只输出 JSON 对象。"
|
|
2218
|
+
].join("\n\n"),
|
|
2219
|
+
temperature: .5,
|
|
2220
|
+
maxTokens: Math.max(config.maxTokens, 12e3)
|
|
2221
|
+
}));
|
|
2222
|
+
const str = (v, fallback = "") => typeof v === "string" ? v.trim() : fallback;
|
|
2223
|
+
const panels = (Array.isArray(raw.panels) ? raw.panels.filter((p) => typeof p === "object" && p !== null) : []).map((p, i) => ({
|
|
2224
|
+
index: i + 1,
|
|
2225
|
+
timecode: str(p.timecode, `0:${String(i * 10).padStart(2, "0")}`),
|
|
2226
|
+
shot: str(p.shot, "中景"),
|
|
2227
|
+
visual: str(p.visual),
|
|
2228
|
+
dialogue: str(p.dialogue),
|
|
2229
|
+
transition: str(p.transition, "硬切"),
|
|
2230
|
+
prompt: str(p.prompt)
|
|
2231
|
+
})).filter((p) => p.visual !== "").slice(0, 12);
|
|
2232
|
+
return {
|
|
2233
|
+
title: str(raw.title, `第 ${chapter.no} 集`),
|
|
2234
|
+
pacingNote: str(raw.pacingNote, "(按内容判断赛道节奏)"),
|
|
2235
|
+
hook: str(raw.hook),
|
|
2236
|
+
characters: roleCards.map((r) => ({
|
|
2237
|
+
name: r.name,
|
|
2238
|
+
visualAnchor: r.anchor,
|
|
2239
|
+
tags: r.tags,
|
|
2240
|
+
expressions: []
|
|
2241
|
+
})),
|
|
2242
|
+
panels,
|
|
2243
|
+
endingHook: str(raw.endingHook)
|
|
2244
|
+
};
|
|
2245
|
+
}
|
|
2246
|
+
/**
|
|
2247
|
+
* 漫剧分集计划:AI 通读一卷的章节标题+摘要+beats,按故事弧线把章节分组为漫剧集。
|
|
2248
|
+
* 原则(吸收 manga-script 节奏模型):高潮章单独成集、过渡章合并、断点选在钩子最强处。
|
|
2249
|
+
*/
|
|
2250
|
+
async function planStoryboardEpisodes(ctx, config, project, volumeNo, platform, maxEpisodes) {
|
|
2251
|
+
const volume = project.volumes?.find((v) => v.no === volumeNo);
|
|
2252
|
+
if (volume === void 0) throw new Error(`第 ${volumeNo} 卷不存在(卷计划未生成或卷号错误)`);
|
|
2253
|
+
const chapters = project.chapters.filter((c) => c.volume === volumeNo && c.status !== "pending" && c.status !== "generating").sort((a, b) => a.no - b.no);
|
|
2254
|
+
if (chapters.length === 0) throw new Error(`第 ${volumeNo} 卷没有已写章节`);
|
|
2255
|
+
const chapterBriefs = chapters.map((c) => {
|
|
2256
|
+
const beatsHead = (c.beats ?? "").split("\n")[0] ?? "";
|
|
2257
|
+
return `第${c.no}章《${c.title}》|${beatsHead.slice(0, 60)}|${(c.summary ?? "").slice(0, 100)}`;
|
|
2258
|
+
});
|
|
2259
|
+
const raw = parseJsonObject(await complete(ctx, config, {
|
|
2260
|
+
system: [
|
|
2261
|
+
"你是一位精通短视频算法与漫剧节奏的总导演。把给定的一卷网文改编为「漫剧分集计划」——不是按章分,而是按故事弧线分集。",
|
|
2262
|
+
"分集原则:",
|
|
2263
|
+
"1. 高潮章(大冲突/身份揭晓/大收获)单独成集或两章一集;过渡章(赶路/准备/日常)2-3 章合并为一集;支线穿插章并入相邻主线圈。",
|
|
2264
|
+
"2. 每集 60-120 秒 ≈ 1-2 章正文,但以\"叙事任务完整\"为准,不被章节号束缚。",
|
|
2265
|
+
"3. 断点选在钩子最强处:每集结尾必须留下让观众点下集的悬念。",
|
|
2266
|
+
"4. 全集数控制在 8 到上限之间(上限由输入给出),宁少勿碎。",
|
|
2267
|
+
"5. 卷的开局集要强(3 秒钩子),卷的结尾集要收束本卷弧线并埋下卷间钩子。",
|
|
2268
|
+
"输出 JSON:",
|
|
2269
|
+
"{\"strategy\": \"本卷漫剧化策略一句话(赛道判断+整体节奏安排)\", \"episodes\": [{\"title\": \"集标题(冲突+悬念句式)\", \"chapters\": [章号数组], \"narrativeJob\": \"这集讲什么、为什么这么分(1-2句)\", \"openingHook\": \"开头3秒钩子\", \"endingHook\": \"结尾钩子\"}]}",
|
|
2270
|
+
"硬性要求:episodes 按章号顺序排列且覆盖全部给定章节(不得遗漏);chapters 数组必须连续(如 [81,82,83]);每集 chapters 至少 1 章、最多 4 章;所有文字用简体中文。",
|
|
2271
|
+
"重要:直接输出 JSON,字符串内不含换行。"
|
|
2272
|
+
].join("\n"),
|
|
2273
|
+
user: [
|
|
2274
|
+
`书名:《${project.bookName}》`,
|
|
2275
|
+
`第 ${volumeNo} 卷《${volume.title}》(${volume.chapterStart}-${volume.chapterEnd} 章)`,
|
|
2276
|
+
`卷定位:${volume.summary}`,
|
|
2277
|
+
`目标平台:${platform}(竖屏 9:16)`,
|
|
2278
|
+
`本卷已写章节(${chapters.length} 章,按序):\n${chapterBriefs.join("\n")}`,
|
|
2279
|
+
`全集数上限:${maxEpisodes}`,
|
|
2280
|
+
"只输出 JSON 对象。"
|
|
2281
|
+
].join("\n\n"),
|
|
2282
|
+
temperature: .4,
|
|
2283
|
+
maxTokens: Math.max(config.maxTokens, 12e3)
|
|
2284
|
+
}));
|
|
2285
|
+
const episodesRaw = Array.isArray(raw.episodes) ? raw.episodes.filter((e) => typeof e === "object" && e !== null) : [];
|
|
2286
|
+
const str = (v, fallback = "") => typeof v === "string" ? v.trim() : fallback;
|
|
2287
|
+
const episodes = [];
|
|
2288
|
+
for (const e of episodesRaw) {
|
|
2289
|
+
const chaptersArr = Array.isArray(e.chapters) ? e.chapters.filter((n) => typeof n === "number" && Number.isInteger(n) && n > 0) : [];
|
|
2290
|
+
if (chaptersArr.length === 0) continue;
|
|
2291
|
+
episodes.push({
|
|
2292
|
+
index: episodes.length + 1,
|
|
2293
|
+
title: str(e.title, `第 ${episodes.length + 1} 集`),
|
|
2294
|
+
chapters: chaptersArr,
|
|
2295
|
+
narrativeJob: str(e.narrativeJob),
|
|
2296
|
+
openingHook: str(e.openingHook),
|
|
2297
|
+
endingHook: str(e.endingHook)
|
|
2298
|
+
});
|
|
2299
|
+
}
|
|
2300
|
+
if (episodes.length === 0) throw new Error("分集计划生成失败:LLM 未返回有效集数");
|
|
2301
|
+
return {
|
|
2302
|
+
strategy: str(raw.strategy, "(按内容判断赛道节奏)"),
|
|
2303
|
+
episodes,
|
|
2304
|
+
chaptersScanned: chapters.length
|
|
2305
|
+
};
|
|
2306
|
+
}
|
|
1880
2307
|
/** 🩺 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
|
|
1881
2308
|
async function analyzePlotlineHealth(ctx, config, project) {
|
|
1882
2309
|
const system = [
|
|
@@ -2181,6 +2608,7 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
|
|
|
2181
2608
|
score
|
|
2182
2609
|
};
|
|
2183
2610
|
}).filter((x) => x.score >= 3).sort((a, b) => b.score - a.score).slice(0, 15).map((x) => `[第${x.f.chapterNo}章] ${x.f.text}`).filter((t) => !recentSet.has(t.slice(t.indexOf("]") + 2)));
|
|
2611
|
+
const foreshadowHints = (project.foreshadows ?? []).filter((f) => f.status === "planned" && f.targetChapter !== void 0 && f.targetChapter > 0).filter((f) => Math.abs(f.targetChapter - chapterNo) <= 12).map((f) => `- ${f.description.slice(0, 120)}${f.targetChapter !== void 0 ? `(计划回收于第 ${f.targetChapter} 章)` : ""}`);
|
|
2184
2612
|
const messages = [createUserMessage({
|
|
2185
2613
|
content: [{
|
|
2186
2614
|
type: "text",
|
|
@@ -2188,6 +2616,7 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
|
|
|
2188
2616
|
`现在写第 ${chapter.no} 章,标题《${chapter.title}》。`,
|
|
2189
2617
|
`本章剧情要点:${chapter.beats}`,
|
|
2190
2618
|
"",
|
|
2619
|
+
foreshadowHints.length > 0 ? `本章附近需顺势埋下以下暗线(自然带过,不喧宾夺主,1-2 句即可,但细节要可辨识、与描述吻合):\n${foreshadowHints.join("\n")}` : "",
|
|
2191
2620
|
recentFacts.length > 0 ? `本书已确立的事实(新写内容不得与之矛盾):\n${recentFacts.join("\n")}` : "",
|
|
2192
2621
|
relatedFacts.length > 0 ? `本章相关的既往事实(同样不得违背):\n${relatedFacts.join("\n")}` : "",
|
|
2193
2622
|
prevSummary !== void 0 && prevSummary !== "" ? `上一章摘要:${prevSummary}` : "",
|
|
@@ -2304,6 +2733,39 @@ async function summarizeAndExtractFacts(ctx, config, project, outputDir, chapter
|
|
|
2304
2733
|
};
|
|
2305
2734
|
}
|
|
2306
2735
|
/**
|
|
2736
|
+
* 伏笔落地标记:检查刚生成的章节正文是否埋下了 planned 伏笔(关键词匹配),
|
|
2737
|
+
* 命中则将该伏笔标记为 planted 并记录 plantedChapter——保证暗线管理页与正文同步。
|
|
2738
|
+
* 纯关键词粗匹配,宁缺毋滥:仅处理「描述含可辨识关键词」的伏笔,无把握则不标。
|
|
2739
|
+
*/
|
|
2740
|
+
function markForeshadowPlanted(project, outputDir, chapterNo) {
|
|
2741
|
+
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
2742
|
+
if (chapter === void 0) return 0;
|
|
2743
|
+
const body = readChapterFile(outputDir, chapter);
|
|
2744
|
+
if (body === void 0) return 0;
|
|
2745
|
+
let marked = 0;
|
|
2746
|
+
for (const f of project.foreshadows ?? []) {
|
|
2747
|
+
if (f.status !== "planned") continue;
|
|
2748
|
+
if (f.plantedChapter !== void 0) continue;
|
|
2749
|
+
const quoted = f.description.match(/[「“『《]([^」”』》]{2,12})[」”』》]/g);
|
|
2750
|
+
const keywords = (quoted !== null ? quoted : []).map((q) => q.slice(1, -1)).filter((k) => k.length >= 2);
|
|
2751
|
+
const nearTarget = f.targetChapter !== void 0 && Math.abs(f.targetChapter - chapterNo) <= 12;
|
|
2752
|
+
if (keywords.length === 0 && !nearTarget) continue;
|
|
2753
|
+
const hit = keywords.length === 0 ? false : keywords.some((k) => body.includes(k));
|
|
2754
|
+
if (hit || keywords.length === 0 && nearTarget) {
|
|
2755
|
+
if (hit) {
|
|
2756
|
+
f.status = "planted";
|
|
2757
|
+
f.plantedChapter = chapterNo;
|
|
2758
|
+
marked++;
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
if (marked > 0) {
|
|
2763
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2764
|
+
saveProject(outputDir, project);
|
|
2765
|
+
}
|
|
2766
|
+
return marked;
|
|
2767
|
+
}
|
|
2768
|
+
/**
|
|
2307
2769
|
* 抽取本章「已确立事实」追加到事实库/时间线(最多 300 条,最新优先)。
|
|
2308
2770
|
* 事实注入后续章节生成提示词,保证人物状态/境界/资源/关系长期一致。
|
|
2309
2771
|
* @returns 新增事实条数(失败返回 0,调用方 best-effort)。
|
|
@@ -3975,6 +4437,12 @@ function makeRoutes(deps) {
|
|
|
3975
4437
|
} catch (error) {
|
|
3976
4438
|
console.warn("[dsh-novel-forge] summary/facts failed:", error.message);
|
|
3977
4439
|
}
|
|
4440
|
+
try {
|
|
4441
|
+
const marked = markForeshadowPlanted(project, config.outputDir, no);
|
|
4442
|
+
if (marked > 0) console.log(`[dsh-novel-forge] 第${no}章已埋伏笔 ${marked} 条`);
|
|
4443
|
+
} catch (error) {
|
|
4444
|
+
console.warn("[dsh-novel-forge] markForeshadowPlanted failed:", error.message);
|
|
4445
|
+
}
|
|
3978
4446
|
if (!(body?.skipReview === true) && (config.autoReview ?? true)) send({
|
|
3979
4447
|
type: "review",
|
|
3980
4448
|
no,
|
|
@@ -4169,17 +4637,24 @@ function makeRoutes(deps) {
|
|
|
4169
4637
|
if (existsSync(targetPath)) copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, "")}.bak.md`));
|
|
4170
4638
|
writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, "utf8");
|
|
4171
4639
|
chapter.pendingDraft = void 0;
|
|
4172
|
-
chapter.status = "written";
|
|
4173
4640
|
chapter.chars = draft.length;
|
|
4174
4641
|
chapter.file = fileName;
|
|
4175
|
-
|
|
4642
|
+
const carried = body?.report;
|
|
4643
|
+
if (carried !== void 0 && typeof carried.score === "number") {
|
|
4644
|
+
chapter.review = carried;
|
|
4645
|
+
chapter.status = carried.passed ? "approved" : "rejected";
|
|
4646
|
+
} else {
|
|
4647
|
+
chapter.status = "written";
|
|
4648
|
+
chapter.review = void 0;
|
|
4649
|
+
}
|
|
4176
4650
|
chapter.error = void 0;
|
|
4177
4651
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4178
4652
|
saveProject(config.outputDir, project);
|
|
4179
4653
|
writeJson(res, 200, {
|
|
4180
4654
|
ok: true,
|
|
4181
4655
|
chars: draft.length,
|
|
4182
|
-
file: fileName
|
|
4656
|
+
file: fileName,
|
|
4657
|
+
markdown: draft
|
|
4183
4658
|
});
|
|
4184
4659
|
}
|
|
4185
4660
|
};
|
|
@@ -4336,13 +4811,14 @@ function makeRoutes(deps) {
|
|
|
4336
4811
|
const config = getConfig();
|
|
4337
4812
|
const project = requireProject(res);
|
|
4338
4813
|
if (project === void 0) return;
|
|
4339
|
-
const
|
|
4814
|
+
const body = await readJsonBody(req);
|
|
4815
|
+
const text = body?.text?.trim() ?? "";
|
|
4340
4816
|
if (text.length < 50) {
|
|
4341
4817
|
writeJson(res, 400, { error: "正文过短(<50 字),请先编辑内容" });
|
|
4342
4818
|
return;
|
|
4343
4819
|
}
|
|
4344
4820
|
try {
|
|
4345
|
-
writeJson(res, 200, { report: await reviewChapterText(ctx, config, project, text) });
|
|
4821
|
+
writeJson(res, 200, { report: await reviewChapterText(ctx, config, project, text, body?.previousReport) });
|
|
4346
4822
|
} catch (error) {
|
|
4347
4823
|
writeJson(res, 500, { error: error.message });
|
|
4348
4824
|
}
|
|
@@ -5087,6 +5563,31 @@ function makeRoutes(deps) {
|
|
|
5087
5563
|
if (idx === -1) project.roles.push(r);
|
|
5088
5564
|
else project.roles[idx] = r;
|
|
5089
5565
|
} else if (op === "remove" && body?.name !== void 0) project.roles = project.roles.filter((x) => x.name !== body.name);
|
|
5566
|
+
else if (op === "visual") {
|
|
5567
|
+
const name = body?.name?.trim();
|
|
5568
|
+
if (name === void 0 || name === "") {
|
|
5569
|
+
writeJson(res, 400, { error: "name(角色名)必填" });
|
|
5570
|
+
return;
|
|
5571
|
+
}
|
|
5572
|
+
try {
|
|
5573
|
+
const visual = await extractRoleVisual(ctx, config, project, config.outputDir, name);
|
|
5574
|
+
const role = project.roles.find((r) => r.name === name);
|
|
5575
|
+
if (role !== void 0) role.imagePrompt = visual;
|
|
5576
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5577
|
+
saveProject(config.outputDir, project);
|
|
5578
|
+
writeJson(res, 200, {
|
|
5579
|
+
roles: project.roles,
|
|
5580
|
+
visual
|
|
5581
|
+
});
|
|
5582
|
+
return;
|
|
5583
|
+
} catch (error) {
|
|
5584
|
+
writeJson(res, 500, { error: `形象提炼失败:${error.message}` });
|
|
5585
|
+
return;
|
|
5586
|
+
}
|
|
5587
|
+
} else {
|
|
5588
|
+
writeJson(res, 400, { error: "未知的 roles op" });
|
|
5589
|
+
return;
|
|
5590
|
+
}
|
|
5090
5591
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5091
5592
|
saveProject(config.outputDir, project);
|
|
5092
5593
|
writeJson(res, 200, { roles: project.roles });
|
|
@@ -5305,6 +5806,83 @@ function makeRoutes(deps) {
|
|
|
5305
5806
|
else writeJson(res, 200, { ok: true });
|
|
5306
5807
|
});
|
|
5307
5808
|
}
|
|
5809
|
+
},
|
|
5810
|
+
{
|
|
5811
|
+
kind: "exact",
|
|
5812
|
+
path: NOVEL_API.outlineSuggest,
|
|
5813
|
+
handler: async (req, res) => {
|
|
5814
|
+
if (!guard(req, res, "POST")) return;
|
|
5815
|
+
const config = getConfig();
|
|
5816
|
+
const body = await readJsonBody(req);
|
|
5817
|
+
const idea = body?.idea?.trim() ?? "";
|
|
5818
|
+
if (idea.length < 50) {
|
|
5819
|
+
writeJson(res, 400, { error: "想法太短(<50 字),请多写一两句:主角是谁、什么世界、想要什么爽点" });
|
|
5820
|
+
return;
|
|
5821
|
+
}
|
|
5822
|
+
const count = body?.count !== void 0 ? Math.max(1, Math.min(3, Math.floor(body.count))) : 3;
|
|
5823
|
+
const exclude = Array.isArray(body?.exclude) ? body.exclude.filter((e) => typeof e === "string" && e.trim() !== "").map((e) => e.trim().slice(0, 200)) : [];
|
|
5824
|
+
try {
|
|
5825
|
+
writeJson(res, 200, { candidates: await suggestOutlines(ctx, config, idea, count, exclude) });
|
|
5826
|
+
} catch (error) {
|
|
5827
|
+
writeJson(res, 500, { error: `大纲方案生成失败:${error.message}` });
|
|
5828
|
+
}
|
|
5829
|
+
}
|
|
5830
|
+
},
|
|
5831
|
+
{
|
|
5832
|
+
kind: "exact",
|
|
5833
|
+
path: NOVEL_API.breakdown,
|
|
5834
|
+
handler: async (req, res) => {
|
|
5835
|
+
if (!guard(req, res, "POST")) return;
|
|
5836
|
+
const config = getConfig();
|
|
5837
|
+
const project = requireProject(res);
|
|
5838
|
+
if (project === void 0) return;
|
|
5839
|
+
const body = await readJsonBody(req);
|
|
5840
|
+
try {
|
|
5841
|
+
writeJson(res, 200, await breakdownBook(ctx, config, project, config.outputDir, body?.scope ?? "recent", body?.preset ?? "quick", body?.budgetTokens ?? 5e4));
|
|
5842
|
+
} catch (error) {
|
|
5843
|
+
writeJson(res, 500, { error: `拆书分析失败:${error.message}` });
|
|
5844
|
+
}
|
|
5845
|
+
}
|
|
5846
|
+
},
|
|
5847
|
+
{
|
|
5848
|
+
kind: "exact",
|
|
5849
|
+
path: NOVEL_API.storyboard,
|
|
5850
|
+
handler: async (req, res) => {
|
|
5851
|
+
if (!guard(req, res, "POST")) return;
|
|
5852
|
+
const config = getConfig();
|
|
5853
|
+
const project = requireProject(res);
|
|
5854
|
+
if (project === void 0) return;
|
|
5855
|
+
const body = await readJsonBody(req);
|
|
5856
|
+
if (!Number.isInteger(body?.chapterNo)) {
|
|
5857
|
+
writeJson(res, 400, { error: "chapterNo 须为正整数" });
|
|
5858
|
+
return;
|
|
5859
|
+
}
|
|
5860
|
+
try {
|
|
5861
|
+
writeJson(res, 200, await generateStoryboard(ctx, config, project, config.outputDir, body.chapterNo, body?.genre ?? "", body?.platform ?? "抖音", body?.tool ?? "doubao"));
|
|
5862
|
+
} catch (error) {
|
|
5863
|
+
writeJson(res, 500, { error: `分镜生成失败:${error.message}` });
|
|
5864
|
+
}
|
|
5865
|
+
}
|
|
5866
|
+
},
|
|
5867
|
+
{
|
|
5868
|
+
kind: "exact",
|
|
5869
|
+
path: NOVEL_API.storyboardPlan,
|
|
5870
|
+
handler: async (req, res) => {
|
|
5871
|
+
if (!guard(req, res, "POST")) return;
|
|
5872
|
+
const config = getConfig();
|
|
5873
|
+
const project = requireProject(res);
|
|
5874
|
+
if (project === void 0) return;
|
|
5875
|
+
const body = await readJsonBody(req);
|
|
5876
|
+
if (!Number.isInteger(body?.volumeNo) || (body?.volumeNo ?? 0) < 1) {
|
|
5877
|
+
writeJson(res, 400, { error: "volumeNo 须为正整数" });
|
|
5878
|
+
return;
|
|
5879
|
+
}
|
|
5880
|
+
try {
|
|
5881
|
+
writeJson(res, 200, await planStoryboardEpisodes(ctx, config, project, body.volumeNo, body?.platform ?? "抖音", body?.maxEpisodes ?? 25));
|
|
5882
|
+
} catch (error) {
|
|
5883
|
+
writeJson(res, 500, { error: `分集计划生成失败:${error.message}` });
|
|
5884
|
+
}
|
|
5885
|
+
}
|
|
5308
5886
|
}
|
|
5309
5887
|
];
|
|
5310
5888
|
}
|
|
@@ -5335,7 +5913,8 @@ const Config = z.object({
|
|
|
5335
5913
|
maxTokens: z.number().default(12e3),
|
|
5336
5914
|
reviewPassScore: z.number().default(70),
|
|
5337
5915
|
autoReview: z.boolean().default(true),
|
|
5338
|
-
autoAuthorReview: z.boolean().default(true)
|
|
5916
|
+
autoAuthorReview: z.boolean().default(true),
|
|
5917
|
+
autoReviewAfterRevise: z.boolean().default(true)
|
|
5339
5918
|
});
|
|
5340
5919
|
/** Schema defaults, re-read for hand-built test contexts. */
|
|
5341
5920
|
const DEFAULT_ANNOUNCE = true;
|
|
@@ -5348,6 +5927,7 @@ const DEFAULT_MAX_TOKENS = 12e3;
|
|
|
5348
5927
|
const DEFAULT_REVIEW_PASS_SCORE = 70;
|
|
5349
5928
|
const DEFAULT_AUTO_REVIEW = true;
|
|
5350
5929
|
const DEFAULT_AUTO_AUTHOR_REVIEW = true;
|
|
5930
|
+
const DEFAULT_AUTO_REVIEW_AFTER_REVISE = true;
|
|
5351
5931
|
/** Order of the announcement section within the tool-guidance band. */
|
|
5352
5932
|
const SECTION_ORDER = 160;
|
|
5353
5933
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
@@ -5363,7 +5943,8 @@ function resolveConfig(value) {
|
|
|
5363
5943
|
maxTokens: value?.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
5364
5944
|
reviewPassScore: value?.reviewPassScore ?? DEFAULT_REVIEW_PASS_SCORE,
|
|
5365
5945
|
autoReview: value?.autoReview ?? DEFAULT_AUTO_REVIEW,
|
|
5366
|
-
autoAuthorReview: value?.autoAuthorReview ?? DEFAULT_AUTO_AUTHOR_REVIEW
|
|
5946
|
+
autoAuthorReview: value?.autoAuthorReview ?? DEFAULT_AUTO_AUTHOR_REVIEW,
|
|
5947
|
+
autoReviewAfterRevise: value?.autoReviewAfterRevise ?? DEFAULT_AUTO_REVIEW_AFTER_REVISE
|
|
5367
5948
|
};
|
|
5368
5949
|
}
|
|
5369
5950
|
/**
|
|
@@ -5393,6 +5974,7 @@ function apply(ctx, config) {
|
|
|
5393
5974
|
if (patch.reviewPassScore !== void 0) next.reviewPassScore = patch.reviewPassScore;
|
|
5394
5975
|
if (patch.autoReview !== void 0) next.autoReview = patch.autoReview;
|
|
5395
5976
|
if (patch.autoAuthorReview !== void 0) next.autoAuthorReview = patch.autoAuthorReview;
|
|
5977
|
+
if (patch.autoReviewAfterRevise !== void 0) next.autoReviewAfterRevise = patch.autoReviewAfterRevise;
|
|
5396
5978
|
const settings = ctx.get("settings");
|
|
5397
5979
|
if (settings !== void 0) await settings.update(NOVEL_SETTINGS_NAMESPACE, next);
|
|
5398
5980
|
else current = () => ({
|
|
@@ -5438,6 +6020,53 @@ function apply(ctx, config) {
|
|
|
5438
6020
|
},
|
|
5439
6021
|
onChange: sync
|
|
5440
6022
|
});
|
|
6023
|
+
const skillsService = ctx.get("skills");
|
|
6024
|
+
if (skillsService?.register !== void 0) {
|
|
6025
|
+
const disposeSkill = skillsService.register({
|
|
6026
|
+
name: "novel-forge-chapter-batch",
|
|
6027
|
+
description: "批量生成小说章节并值守处理审稿未过的章节(豁免/按意见修订/验证模式/重新生成),依赖 dsh-novel-forge 的 /api/dsh-novel-forge/* 路由。",
|
|
6028
|
+
content: [
|
|
6029
|
+
"# 小说章节批量生产与值守处理",
|
|
6030
|
+
"",
|
|
6031
|
+
"## 0. 值守纪律(最高优先级,违反即空转)",
|
|
6032
|
+
"1. 启动后台任务后立即停下,不轮询、不连续 wait、不做无意义动作——后台任务完成时运行时自动通知。",
|
|
6033
|
+
"2. 只有收到「任务完成」通知或用户新消息时,才继续下一步(读结果→处理未过→汇报)。",
|
|
6034
|
+
"3. 等待期间可做与当前任务无关的其他有用工作,但不得为等任务而空转。",
|
|
6035
|
+
"4. 任务因 web 重启中断时:待 3080 恢复后,复位卡死章节(generating/error → reset)再续跑,并向用户说明中断原因。",
|
|
6036
|
+
"",
|
|
6037
|
+
"## 1. 前置检查",
|
|
6038
|
+
"1. `GET /api/dsh-novel-forge/status` 确认服务在跑,读项目章节数。",
|
|
6039
|
+
"2. 列出目标区间 pending 章节号,避免重复生成已处理章节。",
|
|
6040
|
+
"3. **串行执行,禁止并行写 project.json**(并发会互相覆盖状态,实测教训)。",
|
|
6041
|
+
"",
|
|
6042
|
+
"## 2. 批量生成",
|
|
6043
|
+
"逐章串行调用 `POST /generate` `{ chapterNo, skipReview: false }`(走完整质量门:生成→摘要+编年录→审稿→复盘)。",
|
|
6044
|
+
"响应为 NDJSON,找 review 帧取 score/passed。每章 2-5 分钟;失败重试 1 次;后台跑并告知预计时长。",
|
|
6045
|
+
"用 Node 脚本(fetch 逐章循环),避免 PowerShell 转义坑,用完删除。",
|
|
6046
|
+
"",
|
|
6047
|
+
"## 3. 未过章节分级处理",
|
|
6048
|
+
"### A. 无 high → 豁免通过:`POST /chapter/approve`(主观项不磨)",
|
|
6049
|
+
"### B. 有 high 且明确 → 按意见修订 + 验证模式:",
|
|
6050
|
+
"1. 拼指令 `按审稿意见修订(优先处理):\n[severity] item → suggestion`(high 优先,无 high 取前 3 medium)",
|
|
6051
|
+
"2. `POST /rewrite` 产草稿;3. `POST /chapter/check` 带 `previousReport` 走验证模式(只核对原意见解决+只挑新增 high)",
|
|
6052
|
+
"4. 判定 `passed || 无 high` 可接受;5. `POST /draft/apply` 带 passed 报告落盘 approved",
|
|
6053
|
+
"6. 不可接受 → 第二轮修订(指令更精确)→ 再验证;每章最多修 2 轮,仍不过保留草稿待人工",
|
|
6054
|
+
"### C. 结构性 high(修订改不好)→ `POST /draft/discard` 后 `POST /generate` 重写,剩主观项再走 A",
|
|
6055
|
+
"### D. error 状态 → `POST /chapter/reset` 后 `POST /generate`",
|
|
6056
|
+
"注意:approved 但 review 有 high 的保留不动;修订指令越精确越有效(具体数字/行为一次就过)。",
|
|
6057
|
+
"",
|
|
6058
|
+
"## 4. 收尾",
|
|
6059
|
+
"`GET /status` 验证目标区间全 approved;可选刷新剧情线(`POST /plotlines` op=refresh,注意只读最近 8 章摘要);汇报通过/未过/失败与遗留项。",
|
|
6060
|
+
"",
|
|
6061
|
+
"## 5. 已知陷阱",
|
|
6062
|
+
"- 并发写 project.json 互相覆盖 → 必须串行",
|
|
6063
|
+
"- rewrite 草稿可能偶发不落盘 → 应用前检查 pendingDraft,不存在则重跑",
|
|
6064
|
+
"- PowerShell 调 API 引号/中文转义是坑 → 用 Node 脚本",
|
|
6065
|
+
"- 验证模式 500 偶发 → 重试一次"
|
|
6066
|
+
].join("\n")
|
|
6067
|
+
});
|
|
6068
|
+
ctx.effect(() => disposeSkill, "dsh-novel-forge: skill");
|
|
6069
|
+
}
|
|
5441
6070
|
sync();
|
|
5442
6071
|
}
|
|
5443
6072
|
//#endregion
|