@waterwx/dsh-novel-forge 1.1.3 → 1.1.5
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 +6 -1
- package/lib/client.js +4676 -3125
- package/lib/client.js.map +1 -1
- package/lib/index.js +1124 -500
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +14 -0
- package/lib/types/client/locales.d.ts +4 -2
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/engine.d.ts +15 -1
- package/lib/types/index.d.ts +2 -0
- package/lib/types/protocol.d.ts +110 -3
- package/package.json +1 -1
- package/src/assistant.ts +83 -37
- package/src/client/api.ts +20 -0
- package/src/client/locales.ts +8 -4
- package/src/client/panel/NovelPanel.tsx +997 -209
- package/src/engine.ts +367 -0
- package/src/index.ts +6 -0
- package/src/protocol.ts +111 -3
- package/src/routes.ts +257 -0
package/lib/index.js
CHANGED
|
@@ -65,6 +65,12 @@ const NOVEL_API = {
|
|
|
65
65
|
cover: "/api/dsh-novel-forge/blurb/cover",
|
|
66
66
|
/** 剧情线管理:增删改 + 关联章节。 */
|
|
67
67
|
plotlines: "/api/dsh-novel-forge/plotlines",
|
|
68
|
+
/** 角色库:AI 提炼 / 采纳 / 更新 / 删除。 */
|
|
69
|
+
roles: "/api/dsh-novel-forge/roles",
|
|
70
|
+
/** 作者复盘补跑:对已写章节补齐 authorReview(全书流式 / 单章 JSON)。 */
|
|
71
|
+
reviewBackfill: "/api/dsh-novel-forge/review/backfill",
|
|
72
|
+
/** 章节复位:generating 卡死 → pending(可重新生成)。 */
|
|
73
|
+
chapterReset: "/api/dsh-novel-forge/chapter/reset",
|
|
68
74
|
/** 敏感词检查:全书已写章节或指定文本。 */
|
|
69
75
|
sensitiveCheck: "/api/dsh-novel-forge/sensitive-check",
|
|
70
76
|
config: "/api/dsh-novel-forge/config",
|
|
@@ -1424,6 +1430,19 @@ function writeSystemPrompt(project) {
|
|
|
1424
1430
|
if (Array.isArray(card.knowledge) && card.knowledge.length > 0) sections.push(` 已知信息(该角色知道的:${card.knowledge.join(";")};未列出的信息该角色一律不知道,不得写其知晓或提及)`);
|
|
1425
1431
|
}
|
|
1426
1432
|
}
|
|
1433
|
+
const roleLib = project.roles ?? [];
|
|
1434
|
+
if (roleLib.length > 0) {
|
|
1435
|
+
const labelName = {
|
|
1436
|
+
protagonist: "主角",
|
|
1437
|
+
female_lead: "女主",
|
|
1438
|
+
female_support: "女配",
|
|
1439
|
+
support: "配角",
|
|
1440
|
+
antagonist: "反派",
|
|
1441
|
+
extra: "路人"
|
|
1442
|
+
};
|
|
1443
|
+
sections.push("角色库(出场角色按定位规格刻画互动):");
|
|
1444
|
+
for (const r of roleLib) sections.push(`- ${r.name}(${labelName[r.roleLabel]}):${r.identity}${r.relations.length > 0 ? `;关系:${r.relations.join("、")}` : ""}`);
|
|
1445
|
+
}
|
|
1427
1446
|
if (bible.redLines.length > 0) sections.push("写作红线(违反即失败):\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
|
|
1428
1447
|
if (bible.style.length > 0) sections.push("风格要求:\n" + bible.style.map((r) => `- ${r}`).join("\n"));
|
|
1429
1448
|
}
|
|
@@ -1637,6 +1656,301 @@ async function reviewChapterText(ctx, config, project, text) {
|
|
|
1637
1656
|
reviewedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1638
1657
|
};
|
|
1639
1658
|
}
|
|
1659
|
+
/** Build the author-review system prompt (narrative structure, not prose). */
|
|
1660
|
+
function authorReviewSystemPrompt() {
|
|
1661
|
+
return [
|
|
1662
|
+
"你是一位网文作者复盘助手。你会收到:本章正文、上一章结尾(钩子)、上一章作者复盘(如有)、活跃剧情线与编年录近期事实。",
|
|
1663
|
+
"请从叙事结构层面复盘本章(不评文笔,那是审稿的事):",
|
|
1664
|
+
"1. hookHonored:上一章结尾的钩子/悬念是否在本章兑现或推进(true/false)。",
|
|
1665
|
+
"2. hookNote:钩子兑现情况一句话;未兑现时说明并给出\"建议在第几章补\"的建议。",
|
|
1666
|
+
"3. endingHook:本章结尾钩子强度,0-10 的整数(低于 6 说明结尾平淡,读者可能不想看下一章)。",
|
|
1667
|
+
"4. plotlineProgress:本章推进了哪条剧情线(主线/支线名),或\"无实质推进\"(连续无推进要提醒)。",
|
|
1668
|
+
"5. advancedLines:本章实际推进的剧情线名称数组——从「活跃剧情线」清单中选出推进了的线(名称必须与清单中的线名一字不差;没推进任何线则输出空数组)。",
|
|
1669
|
+
"6. continuity:与上一章结尾的衔接检查(人物位置/时间/伤势/资源/对话状态),发现问题要指出。",
|
|
1670
|
+
"7. trend:结合上一章复盘看近期节奏趋势(是否连续拖沓、爽点密度是否下降、是否需要调整)。",
|
|
1671
|
+
"输出必须是合法 JSON 对象,不要输出任何其他文字:",
|
|
1672
|
+
"{\"hookHonored\": true或false, \"hookNote\": \"一句话\", \"endingHook\": 0-10整数, \"plotlineProgress\": \"一句话\", \"advancedLines\": [\"线名\"], \"continuity\": \"一句话\", \"trend\": \"一句话\"}",
|
|
1673
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
1674
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
1675
|
+
].join("\n");
|
|
1676
|
+
}
|
|
1677
|
+
/** 作者复盘:对一章做叙事结构复盘(钩子兑现/结尾钩子/推进/连续性/趋势)。 */
|
|
1678
|
+
async function authorReviewChapter(ctx, config, project, chapterNo, body, prevTail) {
|
|
1679
|
+
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
1680
|
+
const prevChapter = chapterNo > 1 ? project.chapters.find((c) => c.no === chapterNo - 1) : void 0;
|
|
1681
|
+
const lines = (project.plotlines ?? []).filter((l) => l.status === "active" || l.status === "paused");
|
|
1682
|
+
const facts = (project.facts ?? []).slice(-10);
|
|
1683
|
+
const user = [
|
|
1684
|
+
`书名:《${project.bookName}》`,
|
|
1685
|
+
chapter !== void 0 ? `本章:第 ${chapter.no} 章《${chapter.title}》` : `本章:第 ${chapterNo} 章`,
|
|
1686
|
+
prevTail !== "" ? `==================== 上一章(第 ${chapterNo - 1} 章)结尾(钩子) ====================\n${prevTail}` : "(本书第一章,无上一章钩子;hookHonored 视为 true,hookNote 写\"开篇无前置钩子\")",
|
|
1687
|
+
prevChapter?.authorReview !== void 0 ? `==================== 上一章作者复盘 ====================\n${JSON.stringify(prevChapter.authorReview)}` : "",
|
|
1688
|
+
lines.length > 0 ? `==================== 活跃剧情线 ====================\n${lines.map((l) => `- [${l.kind}] ${l.name}:${l.goal}${l.progress !== "" ? `(${l.progress})` : ""}`).join("\n")}` : "",
|
|
1689
|
+
facts.length > 0 ? `==================== 编年录近期事实 ====================\n${facts.map((f) => `[第${f.chapterNo}章] ${f.text}`).join("\n")}` : "",
|
|
1690
|
+
"==================== 本章正文 ====================",
|
|
1691
|
+
body.slice(0, 16e3),
|
|
1692
|
+
"",
|
|
1693
|
+
"只输出 JSON 对象。"
|
|
1694
|
+
].join("\n");
|
|
1695
|
+
const raw = parseJsonObject(await complete(ctx, config, {
|
|
1696
|
+
system: authorReviewSystemPrompt(),
|
|
1697
|
+
user,
|
|
1698
|
+
temperature: .3,
|
|
1699
|
+
maxTokens: Math.max(config.maxTokens, 4e3)
|
|
1700
|
+
}));
|
|
1701
|
+
const knownLineNames = new Set((project.plotlines ?? []).map((l) => l.name));
|
|
1702
|
+
const advancedLines = Array.isArray(raw.advancedLines) ? raw.advancedLines.filter((n) => typeof n === "string" && n.trim() !== "" && knownLineNames.has(n.trim())).map((n) => n.trim()) : [];
|
|
1703
|
+
return {
|
|
1704
|
+
hookHonored: raw.hookHonored === true,
|
|
1705
|
+
hookNote: typeof raw.hookNote === "string" ? raw.hookNote.slice(0, 200) : "",
|
|
1706
|
+
endingHook: typeof raw.endingHook === "number" ? Math.max(0, Math.min(10, Math.round(raw.endingHook))) : 5,
|
|
1707
|
+
plotlineProgress: typeof raw.plotlineProgress === "string" ? raw.plotlineProgress.slice(0, 200) : "",
|
|
1708
|
+
advancedLines,
|
|
1709
|
+
continuity: typeof raw.continuity === "string" ? raw.continuity.slice(0, 200) : "",
|
|
1710
|
+
trend: typeof raw.trend === "string" ? raw.trend.slice(0, 200) : "",
|
|
1711
|
+
reviewedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
/** 复盘后自动关联:把本章号写入复盘标记推进的剧情线(按名称匹配,去重)。 */
|
|
1715
|
+
function autoLinkPlotlines(project, chapterNo, advancedLines) {
|
|
1716
|
+
if (!Array.isArray(project.plotlines) || advancedLines.length === 0) return;
|
|
1717
|
+
for (const line of project.plotlines) if (advancedLines.includes(line.name) && !line.chapters.includes(chapterNo)) line.chapters.push(chapterNo);
|
|
1718
|
+
}
|
|
1719
|
+
/** AI 建议剧情线:基于大纲/卷计划/已写章节/编年录,提炼候选线。 */
|
|
1720
|
+
async function suggestPlotlines(ctx, config, project) {
|
|
1721
|
+
const system = [
|
|
1722
|
+
"你是一位网文剧情架构师。根据本书的大纲、卷计划、已写章节标题与编年录,为作者提炼建议的剧情线(主线/支线/人物线/悬念线)。",
|
|
1723
|
+
"每条线要:名称简洁有力;目标写清楚这条线最终要完成什么;progress 写当前推进到哪(没有就空字符串)。",
|
|
1724
|
+
"建议 4-8 条,覆盖:1 条主线、1-2 条人物线、1-2 条悬念线、1-3 条支线。避免与大纲明显重复的废话线。",
|
|
1725
|
+
"输出必须是合法 JSON 数组,格式:[{\"name\": \"线名\", \"kind\": \"main|branch|character|mystery\", \"goal\": \"目标\", \"progress\": \"\"}]",
|
|
1726
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
1727
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
1728
|
+
].join("\n");
|
|
1729
|
+
const written = project.chapters.filter((c) => c.status !== "pending");
|
|
1730
|
+
const raw = parseJsonArray(await complete(ctx, config, {
|
|
1731
|
+
system,
|
|
1732
|
+
user: [
|
|
1733
|
+
`书名:《${project.bookName}》`,
|
|
1734
|
+
`大纲(节选前 4000 字):\n${project.outline.slice(0, 4e3)}`,
|
|
1735
|
+
project.volumes !== void 0 && project.volumes.length > 0 ? `卷计划:\n${project.volumes.map((v) => `第${v.no}卷《${v.title}》:${v.summary}`).join("\n")}` : "",
|
|
1736
|
+
written.length > 0 ? `已写章节:\n${written.map((c) => `第${c.no}章《${c.title}》${c.summary !== void 0 && c.summary !== "" ? `:${c.summary.slice(0, 80)}` : ""}`).join("\n")}` : "",
|
|
1737
|
+
(project.facts ?? []).length > 0 ? `编年录近期事实(最近 15 条):\n${(project.facts ?? []).slice(-15).map((f) => `[第${f.chapterNo}章] ${f.text.slice(0, 100)}`).join("\n")}` : "",
|
|
1738
|
+
"只输出 JSON 数组。"
|
|
1739
|
+
].join("\n\n"),
|
|
1740
|
+
temperature: .6,
|
|
1741
|
+
maxTokens: Math.max(config.maxTokens, 4e3)
|
|
1742
|
+
}));
|
|
1743
|
+
const lines = [];
|
|
1744
|
+
const kinds = /* @__PURE__ */ new Set([
|
|
1745
|
+
"main",
|
|
1746
|
+
"branch",
|
|
1747
|
+
"character",
|
|
1748
|
+
"mystery"
|
|
1749
|
+
]);
|
|
1750
|
+
for (const entry of raw) {
|
|
1751
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
1752
|
+
const name = typeof entry.name === "string" ? entry.name.trim().slice(0, 40) : "";
|
|
1753
|
+
if (name === "") continue;
|
|
1754
|
+
lines.push({
|
|
1755
|
+
id: "",
|
|
1756
|
+
name,
|
|
1757
|
+
kind: kinds.has(entry.kind) ? entry.kind : "branch",
|
|
1758
|
+
goal: typeof entry.goal === "string" ? entry.goal.trim().slice(0, 300) : "",
|
|
1759
|
+
progress: typeof entry.progress === "string" ? entry.progress.trim().slice(0, 300) : "",
|
|
1760
|
+
status: "active",
|
|
1761
|
+
chapters: [],
|
|
1762
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1763
|
+
});
|
|
1764
|
+
}
|
|
1765
|
+
return lines;
|
|
1766
|
+
}
|
|
1767
|
+
/** AI 刷新单条剧情线的进度:结合编年录与各章摘要分析该线推进到哪。 */
|
|
1768
|
+
async function refreshPlotlineProgress(ctx, config, project, line) {
|
|
1769
|
+
const system = [
|
|
1770
|
+
"你是一位网文剧情线管理员。请根据「剧情线信息」与「本书已写章节摘要/编年录」,判断这条线目前推进到了哪一步。",
|
|
1771
|
+
"输出一句话(30-60 字):这条线当前的状态、最近一次推进发生在第几章、下一步可能的方向。如果这条线还没开始推进,明确说\"尚未推进\"。",
|
|
1772
|
+
"输出必须是合法 JSON 对象:{\"progress\": \"一句话\"}",
|
|
1773
|
+
"重要:不要输出任何其他文字。"
|
|
1774
|
+
].join("\n");
|
|
1775
|
+
const written = project.chapters.filter((c) => c.status !== "pending" && c.summary !== void 0 && c.summary !== "");
|
|
1776
|
+
const raw = parseJsonObject(await complete(ctx, config, {
|
|
1777
|
+
system,
|
|
1778
|
+
user: [
|
|
1779
|
+
`剧情线:${line.name}(${line.kind})`,
|
|
1780
|
+
`目标:${line.goal}`,
|
|
1781
|
+
`已知进度:${line.progress !== "" ? line.progress : "(无)"}`,
|
|
1782
|
+
`已关联章节:${line.chapters.length > 0 ? line.chapters.map((n) => `第${n}章`).join("、") : "(无)"}`,
|
|
1783
|
+
`章节摘要(最近 8 章):\n${written.slice(-8).map((c) => `第${c.no}章《${c.title}》:${c.summary.slice(0, 120)}`).join("\n")}`,
|
|
1784
|
+
(project.facts ?? []).length > 0 ? `编年录近期事实(最近 15 条):\n${(project.facts ?? []).slice(-15).map((f) => `[第${f.chapterNo}章] ${f.text.slice(0, 100)}`).join("\n")}` : "",
|
|
1785
|
+
"只输出 JSON 对象。"
|
|
1786
|
+
].join("\n\n"),
|
|
1787
|
+
temperature: .3,
|
|
1788
|
+
maxTokens: Math.max(config.maxTokens, 2e3)
|
|
1789
|
+
}));
|
|
1790
|
+
return typeof raw.progress === "string" ? raw.progress.trim().slice(0, 300) : "";
|
|
1791
|
+
}
|
|
1792
|
+
/** ✨ AI 从全书提炼角色库:大纲 + 道藏 + 编年录 + 章节摘要 → 结构化角色清单。 */
|
|
1793
|
+
async function extractRoles(ctx, config, project) {
|
|
1794
|
+
const system = [
|
|
1795
|
+
"你是一位网文角色库管理员。请根据本书的大纲、设定、编年录与章节摘要,提炼完整的角色库。",
|
|
1796
|
+
"覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如\"矮胖姑娘\")用其身份简称收录并标注。",
|
|
1797
|
+
"数量控制:最多输出 10 个角色,宁缺毋滥;路人级一次带过的不要收录。",
|
|
1798
|
+
"每个角色输出:",
|
|
1799
|
+
"1. name:角色名(或身份简称)。",
|
|
1800
|
+
"2. roleLabel:定位——protagonist=主角;female_lead=女主(唯一知己/感情线核心,无后宫前提下只此一位);female_support=重要女配;support=普通配角;antagonist=反派;extra=路人/背景。",
|
|
1801
|
+
"3. identity:身份一句话(宗门/势力/血脉/职业)。",
|
|
1802
|
+
"4. traits:3-6 个性格标签。",
|
|
1803
|
+
"5. goals:目标与动机一句话。",
|
|
1804
|
+
"6. relations:关系网数组,格式[\"角色名(关系)\", ...]。",
|
|
1805
|
+
"7. arc:成长线数组,格式[\"阶段:说明\", ...](如\"出场:祭品身份\"/\"转折:祭祀被中断脱身\")。",
|
|
1806
|
+
"8. knowledge:该角色已经知道的关键信息(3-8 条),不知道的信息不要写进去。",
|
|
1807
|
+
"精简要求:identity 控制在 30 字内;traits 3-6 个短标签;goals 60 字内;relations 2-5 条;arc 2-4 条;knowledge 每条 40 字内。整体输出量要紧凑,避免冗长。",
|
|
1808
|
+
"重要:用户消息里列出的「已收录角色」绝不要再次输出——这些角色已经在角色库里,跳过它们,只提炼未收录的。",
|
|
1809
|
+
"输出必须是合法 JSON 数组,不要输出其他文字:[{\"name\":\"...\", \"roleLabel\":\"...\", \"identity\":\"...\", \"traits\":[...], \"goals\":\"...\", \"relations\":[...], \"arc\":[...], \"knowledge\":[...]}]",
|
|
1810
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
1811
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
1812
|
+
].join("\n");
|
|
1813
|
+
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
|
|
1814
|
+
const existingRoles = project.roles ?? [];
|
|
1815
|
+
const raw = parseJsonArray(await complete(ctx, config, {
|
|
1816
|
+
system,
|
|
1817
|
+
user: [
|
|
1818
|
+
`书名:《${project.bookName}》`,
|
|
1819
|
+
existingRoles.length > 0 ? `已收录角色(跳过,不要输出):${existingRoles.map((r) => r.name).join("、")}` : "",
|
|
1820
|
+
`大纲(节选前 3000 字):\n${project.outline.slice(0, 3e3)}`,
|
|
1821
|
+
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")}` : "",
|
|
1822
|
+
(project.facts ?? []).length > 0 ? `编年录(最近 60 条):\n${(project.facts ?? []).slice(-60).map((f) => `[第${f.chapterNo}章] ${f.text.slice(0, 80)}`).join("\n")}` : "",
|
|
1823
|
+
written.length > 0 ? `已写章节标题(${written.length} 章):\n${written.map((c) => `第${c.no}章《${c.title}》`).join("、")}` : "",
|
|
1824
|
+
"只输出 JSON 数组。"
|
|
1825
|
+
].join("\n\n"),
|
|
1826
|
+
temperature: .3,
|
|
1827
|
+
maxTokens: Math.max(config.maxTokens, 24e3)
|
|
1828
|
+
}));
|
|
1829
|
+
const labels = /* @__PURE__ */ new Set([
|
|
1830
|
+
"protagonist",
|
|
1831
|
+
"female_lead",
|
|
1832
|
+
"female_support",
|
|
1833
|
+
"support",
|
|
1834
|
+
"antagonist",
|
|
1835
|
+
"extra"
|
|
1836
|
+
]);
|
|
1837
|
+
const strArr = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.trim() !== "") : [];
|
|
1838
|
+
const roles = [];
|
|
1839
|
+
for (const entry of raw) {
|
|
1840
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
1841
|
+
const name = typeof entry.name === "string" ? entry.name.trim().slice(0, 30) : "";
|
|
1842
|
+
if (name === "") continue;
|
|
1843
|
+
roles.push({
|
|
1844
|
+
name,
|
|
1845
|
+
roleLabel: labels.has(entry.roleLabel) ? entry.roleLabel : "support",
|
|
1846
|
+
identity: typeof entry.identity === "string" ? entry.identity.slice(0, 100) : "",
|
|
1847
|
+
traits: strArr(entry.traits).map((t) => t.slice(0, 20)).slice(0, 8),
|
|
1848
|
+
goals: typeof entry.goals === "string" ? entry.goals.slice(0, 200) : "",
|
|
1849
|
+
relations: strArr(entry.relations).map((r) => r.slice(0, 60)).slice(0, 10),
|
|
1850
|
+
arc: strArr(entry.arc).map((a) => a.slice(0, 120)).slice(0, 10),
|
|
1851
|
+
knowledge: strArr(entry.knowledge).map((k) => k.slice(0, 120)).slice(0, 12)
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
return roles;
|
|
1855
|
+
}
|
|
1856
|
+
/** 🩺 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
|
|
1857
|
+
async function analyzePlotlineHealth(ctx, config, project) {
|
|
1858
|
+
const system = [
|
|
1859
|
+
"你是一位网文剧情架构师。请对本书的「剧情线体系」做健康检查,判断当前是否需要新增剧情线、应在多少章后添加。",
|
|
1860
|
+
"评估维度:各线最近推进到第几章(已写章节与关联章节的差值越大越危险)、各线状态、已写章节总数、卷计划当前进度、编年录近期事实。",
|
|
1861
|
+
"输出规则:",
|
|
1862
|
+
"1. verdict:一句话结论——\"需要新增线\" / \"暂不需要\" / \"再写 N 章后需要\"(N 给出具体章数)。",
|
|
1863
|
+
"2. timing:说明建议添加的时机(如:第 25 章前引入新支线,因为主线预计第 22 章告一段落)。",
|
|
1864
|
+
"3. reasons:3-5 条依据(引用具体数据:哪条线多少章没推进、已写章节数、卷进度等)。",
|
|
1865
|
+
"4. lines:对每条线给健康度——ok(近期推进过)/ warning(超过 5 章未推进)/ stale(超过 10 章未推进或悬置过久)。",
|
|
1866
|
+
"输出必须是合法 JSON 对象:{\"verdict\": \"...\", \"timing\": \"...\", \"reasons\": [\"...\"], \"lines\": [{\"name\": \"线名\", \"health\": \"ok|warning|stale\", \"note\": \"一句说明\"}]}",
|
|
1867
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
1868
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
1869
|
+
].join("\n");
|
|
1870
|
+
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
|
|
1871
|
+
const lines = (project.plotlines ?? []).filter((l) => l.status === "active" || l.status === "paused");
|
|
1872
|
+
const raw = parseJsonObject(await complete(ctx, config, {
|
|
1873
|
+
system,
|
|
1874
|
+
user: [
|
|
1875
|
+
`书名:《${project.bookName}》`,
|
|
1876
|
+
`已写章节数:${written.length}(最新章号 ${written.length > 0 ? written[written.length - 1].no : 0})`,
|
|
1877
|
+
project.volumes !== void 0 && project.volumes.length > 0 ? `卷计划:\n${project.volumes.map((v) => `第${v.no}卷《${v.title}》(${v.chapterStart}-${v.chapterEnd}):${v.summary.slice(0, 60)}`).join("\n")}` : "",
|
|
1878
|
+
`剧情线(${lines.length} 条):\n${lines.length > 0 ? lines.map((l) => `- [${l.kind}] ${l.name}|目标:${l.goal}|进度:${l.progress !== "" ? l.progress : "未推进"}|最近关联章节:${l.chapters.length > 0 ? "第" + Math.max(...l.chapters) + "章" : "无"}`).join("\n") : "(暂无剧情线)"}`,
|
|
1879
|
+
(project.facts ?? []).length > 0 ? `编年录近期事实(最近 10 条):\n${(project.facts ?? []).slice(-10).map((f) => `[第${f.chapterNo}章] ${f.text.slice(0, 80)}`).join("\n")}` : "",
|
|
1880
|
+
"只输出 JSON 对象。"
|
|
1881
|
+
].join("\n\n"),
|
|
1882
|
+
temperature: .3,
|
|
1883
|
+
maxTokens: Math.max(config.maxTokens, 3e3)
|
|
1884
|
+
}));
|
|
1885
|
+
const strArr = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.trim() !== "") : [];
|
|
1886
|
+
const lineArr = Array.isArray(raw.lines) ? raw.lines.filter((v) => typeof v === "object" && v !== null).map((entry) => ({
|
|
1887
|
+
name: typeof entry.name === "string" ? entry.name.slice(0, 40) : "",
|
|
1888
|
+
health: [
|
|
1889
|
+
"ok",
|
|
1890
|
+
"warning",
|
|
1891
|
+
"stale"
|
|
1892
|
+
].includes(entry.health) ? entry.health : "ok",
|
|
1893
|
+
note: typeof entry.note === "string" ? entry.note.slice(0, 150) : ""
|
|
1894
|
+
})).filter((x) => x.name !== "") : [];
|
|
1895
|
+
return {
|
|
1896
|
+
verdict: typeof raw.verdict === "string" ? raw.verdict.slice(0, 100) : "",
|
|
1897
|
+
timing: typeof raw.timing === "string" ? raw.timing.slice(0, 200) : "",
|
|
1898
|
+
reasons: strArr(raw.reasons).map((r) => r.slice(0, 200)),
|
|
1899
|
+
lines: lineArr
|
|
1900
|
+
};
|
|
1901
|
+
}
|
|
1902
|
+
/** ✨ AI 剧情方案:基于健康检查结果设计下一阶段方向与建议新线。 */
|
|
1903
|
+
async function designPlotlinePlan(ctx, config, project, health) {
|
|
1904
|
+
const system = [
|
|
1905
|
+
"你是一位网文剧情架构师。请为本书设计「下一阶段的剧情方案」:给出未来 5-10 章的剧情方向,并建议 2-3 条值得新增的剧情线。",
|
|
1906
|
+
"要求:方向必须结合本书大纲/卷计划/现有线/编年录;新线要能落地(和当前主角处境、已有伏笔、下一阶段舞台相关),不得重复已有线。",
|
|
1907
|
+
"输出必须是合法 JSON 对象:{\"direction\": \"下一阶段方向 60-120 字\", \"suggestions\": [{\"name\": \"线名\", \"kind\": \"main|branch|character|mystery\", \"goal\": \"目标\", \"progress\": \"初始进度(可空)\"}]}",
|
|
1908
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
1909
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
1910
|
+
].join("\n");
|
|
1911
|
+
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
|
|
1912
|
+
const raw = parseJsonObject(await complete(ctx, config, {
|
|
1913
|
+
system,
|
|
1914
|
+
user: [
|
|
1915
|
+
`书名:《${project.bookName}》`,
|
|
1916
|
+
health !== void 0 ? `健康检查结论:\n判定:${health.verdict}\n时机:${health.timing}\n依据:${health.reasons.join(";")}` : "",
|
|
1917
|
+
`大纲(节选前 3000 字):\n${project.outline.slice(0, 3e3)}`,
|
|
1918
|
+
project.volumes !== void 0 && project.volumes.length > 0 ? `卷计划:\n${project.volumes.map((v) => `第${v.no}卷《${v.title}》:${v.summary.slice(0, 60)}`).join("\n")}` : "",
|
|
1919
|
+
`现有剧情线:\n${(project.plotlines ?? []).map((l) => `- [${l.kind}${l.status === "resolved" ? "·已完结" : ""}] ${l.name}:${l.goal}`).join("\n") || "(无)"}`,
|
|
1920
|
+
written.length > 0 ? `最近写的章节:\n${written.slice(-5).map((c) => `第${c.no}章《${c.title}》`).join("、")}` : "",
|
|
1921
|
+
"只输出 JSON 对象。"
|
|
1922
|
+
].join("\n\n"),
|
|
1923
|
+
temperature: .6,
|
|
1924
|
+
maxTokens: Math.max(config.maxTokens, 3e3)
|
|
1925
|
+
}));
|
|
1926
|
+
const suggestions = [];
|
|
1927
|
+
const kinds = /* @__PURE__ */ new Set([
|
|
1928
|
+
"main",
|
|
1929
|
+
"branch",
|
|
1930
|
+
"character",
|
|
1931
|
+
"mystery"
|
|
1932
|
+
]);
|
|
1933
|
+
if (Array.isArray(raw.suggestions)) for (const entry of raw.suggestions) {
|
|
1934
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
1935
|
+
const e = entry;
|
|
1936
|
+
const name = typeof e.name === "string" ? e.name.trim().slice(0, 40) : "";
|
|
1937
|
+
if (name === "") continue;
|
|
1938
|
+
suggestions.push({
|
|
1939
|
+
id: "",
|
|
1940
|
+
name,
|
|
1941
|
+
kind: kinds.has(e.kind) ? e.kind : "branch",
|
|
1942
|
+
goal: typeof e.goal === "string" ? e.goal.trim().slice(0, 300) : "",
|
|
1943
|
+
progress: typeof e.progress === "string" ? e.progress.trim().slice(0, 300) : "",
|
|
1944
|
+
status: "active",
|
|
1945
|
+
chapters: [],
|
|
1946
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
return {
|
|
1950
|
+
direction: typeof raw.direction === "string" ? raw.direction.slice(0, 300) : "",
|
|
1951
|
+
suggestions
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1640
1954
|
/** Build the rewrite system prompt (fix review issues / instructions). */
|
|
1641
1955
|
function rewriteSystemPrompt(project) {
|
|
1642
1956
|
return writeSystemPrompt(project) + "\n\n额外要求:你正在【修订】一章已写好的正文。保留原文中好的部分,只修改需要修改的地方,输出完整的新正文(不要只输出修改片段),字数与原文相当。";
|
|
@@ -2488,7 +2802,7 @@ function clearAssistantHistory(outputDir) {
|
|
|
2488
2802
|
function renderProjectSnapshot(project) {
|
|
2489
2803
|
const sections = [];
|
|
2490
2804
|
sections.push(`书名:${project.bookName}`);
|
|
2491
|
-
sections.push(
|
|
2805
|
+
sections.push(`总纲节选(如需全文用 outline_text 工具):\n${project.outline.slice(0, 2500)}`);
|
|
2492
2806
|
const assetNames = [];
|
|
2493
2807
|
if (project.assets?.genre !== void 0) assetNames.push(`题材:${project.assets.genre.name}`);
|
|
2494
2808
|
if (project.assets?.primaryProgression !== void 0) assetNames.push(`主推进:${project.assets.primaryProgression.name}`);
|
|
@@ -2497,7 +2811,7 @@ function renderProjectSnapshot(project) {
|
|
|
2497
2811
|
if (assetNames.length > 0) sections.push(`【写作资产】${assetNames.join(" · ")}`);
|
|
2498
2812
|
if (project.bible !== void 0) {
|
|
2499
2813
|
const bible = project.bible;
|
|
2500
|
-
sections.push("
|
|
2814
|
+
sections.push("【道藏】");
|
|
2501
2815
|
if (bible.genre !== "") sections.push(`题材基调:${bible.genre}`);
|
|
2502
2816
|
if (bible.worldRules.length > 0) sections.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
|
|
2503
2817
|
if (bible.characters.length > 0) {
|
|
@@ -2548,14 +2862,14 @@ function renderProjectSnapshot(project) {
|
|
|
2548
2862
|
}
|
|
2549
2863
|
}
|
|
2550
2864
|
if (project.foreshadows.length > 0) {
|
|
2551
|
-
sections.push("
|
|
2865
|
+
sections.push("【暗线】");
|
|
2552
2866
|
for (const f of project.foreshadows) sections.push(`- [${f.status}] ${f.description}${f.targetChapter !== void 0 ? `(预计 ${f.targetChapter} 章回收)` : ""}`);
|
|
2553
2867
|
}
|
|
2554
2868
|
if ((project.facts ?? []).length > 0) {
|
|
2555
|
-
sections.push("
|
|
2869
|
+
sections.push("【已确立编年录(最近 40 条,回答设定问题必须遵守)】");
|
|
2556
2870
|
for (const f of (project.facts ?? []).slice(-40)) sections.push(`- [第${f.chapterNo}章] ${f.text}`);
|
|
2557
2871
|
}
|
|
2558
|
-
if (project.blurb !== void 0 && project.blurb !== "") sections.push(
|
|
2872
|
+
if (project.blurb !== void 0 && project.blurb !== "") sections.push(`【卷首语】${project.blurb}`);
|
|
2559
2873
|
return sections.join("\n");
|
|
2560
2874
|
}
|
|
2561
2875
|
/** The assistant system prompt. */
|
|
@@ -2565,33 +2879,35 @@ function assistantSystemPrompt(project) {
|
|
|
2565
2879
|
"人设:二十年网文老编辑,懂套路、懂市场、懂节奏,说话直接但句句有用。",
|
|
2566
2880
|
"座右铭:「书是你的,但坑我替你盯着。」",
|
|
2567
2881
|
"职责边界:陪作者讨论剧情/人设/世界观/爽点节奏并落地修改、维护全书一致性;不闲聊、不彩虹屁、不无意义长篇大论。",
|
|
2882
|
+
"==================== 模块正式名称(回复作者时一律使用,禁止使用括号里的旧称) ====================",
|
|
2883
|
+
"总纲 = 总纲;道藏 = 道藏;暗线 = 暗线;卷首语 = 卷首语;编年录 = 编年录。",
|
|
2568
2884
|
"==================== 当前项目快照 ====================",
|
|
2569
2885
|
renderProjectSnapshot(project),
|
|
2570
2886
|
"==================== 快照结束 ====================",
|
|
2571
2887
|
"",
|
|
2572
2888
|
"工作规则(严格遵守):",
|
|
2573
|
-
"1. 全量知情:回答和修改必须基于项目真实数据,禁止编造书中不存在的设定。需要完整信息时,先调用 book_overview
|
|
2889
|
+
"1. 全量知情:回答和修改必须基于项目真实数据,禁止编造书中不存在的设定。需要完整信息时,先调用 book_overview 获取全书上下文(总纲全文/道藏/大世界/编年录/全部章节要点/暗线/卷首语);需要某章正文用 chapter_text。",
|
|
2574
2890
|
"2. 修改流程:改前用一句话说明意图 → 执行工具 → 改后简要汇报。",
|
|
2575
|
-
"3.
|
|
2891
|
+
"3. 连锁维护:改动可能波及其它位置(其它章节、设定、编年录、卷首语)时,执行后主动调用 impact_analysis 分析影响面,并把「必须同步」的项一并处理或明确提示作者逐项确认。",
|
|
2576
2892
|
"4. 删除红线:删除章节、清空设定等破坏性操作必须等作者明确同意。",
|
|
2577
2893
|
"5. 品质门槛:建议必须具体——指出问题在哪一章、哪一段、哪一句,并给出可落地的改法;禁止\"建议增强冲突\"这类空话。",
|
|
2578
|
-
"6.
|
|
2894
|
+
"6. 设定忠诚:忠于总纲、道藏、大世界、编年录;发现书中已有内容与设定冲突时,主动指出并给修正方案。",
|
|
2579
2895
|
"7. 中文回复,简洁有干货。",
|
|
2580
2896
|
"",
|
|
2581
2897
|
"可用工具:",
|
|
2582
|
-
"- book_overview:{\"scope\": \"recent|full|volume:2\"(可选,默认 recent)}
|
|
2583
|
-
"- facts_query:{\"keyword\": \"关键词\"}
|
|
2584
|
-
"- impact_analysis:{\"change\": \"要做的修改描述\"}
|
|
2585
|
-
"- outline_text
|
|
2586
|
-
"- outline_replace:{\"old\": \"要替换的原文片段\", \"new\": \"新文本\"}
|
|
2587
|
-
"- bible_set_rule:{\"index\": 序号(0起), \"text\": \"新规则文本\"} 或 {\"append\": \"追加的规则\"}
|
|
2898
|
+
"- book_overview:{\"scope\": \"recent|full|volume:2\"(可选,默认 recent)}。返回全书上下文包(总纲/道藏/大世界/章节要点/编年录/暗线/卷首语)。recent=最近30章;full=全部章节(书很长时慎用);volume:N=只看第N卷。",
|
|
2899
|
+
"- facts_query:{\"keyword\": \"关键词\"}。从编年录按关键词检索相关事实(如灵石、境界名、人物名)。",
|
|
2900
|
+
"- impact_analysis:{\"change\": \"要做的修改描述\"}。分析这次改动会波及哪些位置,返回影响清单(定位到章节/设定/编年录)。",
|
|
2901
|
+
"- outline_text:无参数。返回当前总纲全文。",
|
|
2902
|
+
"- outline_replace:{\"old\": \"要替换的原文片段\", \"new\": \"新文本\"}。在总纲中替换一段文字(old 必须能在总纲中找到)。",
|
|
2903
|
+
"- bible_set_rule:{\"index\": 序号(0起), \"text\": \"新规则文本\"} 或 {\"append\": \"追加的规则\"}。修改道藏的世界规则。",
|
|
2588
2904
|
"- bible_set_redline:同上,修改写作红线。",
|
|
2589
2905
|
"- chapter_text:{\"no\": 章节号}。返回该章正文。",
|
|
2590
2906
|
"- chapter_rewrite:{\"no\": 章节号, \"instructions\": \"修改要求\", \"target\": \"原文片段(可选,留空整章)\"}。按讨论结果修订章节;给了 target 只改该自然段。",
|
|
2591
2907
|
"- chapter_generate:{\"no\": 章节号}。重新生成该章。",
|
|
2592
2908
|
"- chapter_review:{\"no\": 章节号}。对该章执行 AI 审稿。",
|
|
2593
|
-
"- foreshadow_add:{\"description\": \"
|
|
2594
|
-
"- foreshadow_update:{\"id\": \"
|
|
2909
|
+
"- foreshadow_add:{\"description\": \"暗线描述\", \"targetChapter\": 预计回收章(可选)}。新增暗线。",
|
|
2910
|
+
"- foreshadow_update:{\"id\": \"暗线id\", \"status\": \"planned|planted|progressing|resolved|abandoned\"}。更新暗线状态。",
|
|
2595
2911
|
"- export_txt:无参数。导出全本 TXT。",
|
|
2596
2912
|
"- assets_status:无参数。查看本书当前写作资产(题材/推进模式/反AI规则/写法)。",
|
|
2597
2913
|
"- assets_set_genre:{\"name\": \"题材名\", \"description\": \"题材说明(可选)\"}。设置本书题材基底。",
|
|
@@ -2599,25 +2915,29 @@ function assistantSystemPrompt(project) {
|
|
|
2599
2915
|
"- assets_add_rule:{\"name\": \"规则名(可选)\", \"avoid\": \"要避免的表达问题\", \"fix\": \"修正方向(可选)}。新增反 AI 规则。",
|
|
2600
2916
|
"",
|
|
2601
2917
|
"回答质量要求(非常重要):",
|
|
2602
|
-
"-
|
|
2918
|
+
"- 具体:回答必须引用项目里的真实内容(人名、境界、章节、暗线、设定),禁止空泛套话。快照里没有的信息,先调用工具获取(chapter_text / outline_text)再回答。",
|
|
2603
2919
|
"- 专业:给建议时说明理由,指出问题所在章节/段落,给出可直接落地的修改方案(改什么、怎么改)。",
|
|
2604
2920
|
"- 主动:作者说\"改一下\",主动调用对应工具执行,不要只给建议不动手;执行前用一句话说明意图,执行后简短汇报结果。",
|
|
2605
|
-
"-
|
|
2921
|
+
"- 忠于设定:以总纲、道藏、编年录为准,不得自相矛盾;发现问题(如剧情与设定冲突)主动指出。",
|
|
2606
2922
|
"- 中文回复;文字量适中,别啰嗦。",
|
|
2607
2923
|
"",
|
|
2608
2924
|
"使用规则(非常重要):",
|
|
2609
2925
|
"- 当你想执行任何工具时,你的【整个回复】必须只包含动作指令标签,格式如下(不要有任何解释文字、不要用自然语言说\"我要去改\",直接输出标签):",
|
|
2610
2926
|
" 正确示例:<dsh-action name=\"outline_replace\">{\"old\":\"要替换的原文\",\"new\":\"新文本\"}</dsh-action>",
|
|
2611
2927
|
" 正确示例:<dsh-action name=\"chapter_text\">{\"no\":1}</dsh-action>",
|
|
2612
|
-
" 错误示例(绝对不要这样回复):\"
|
|
2928
|
+
" 错误示例(绝对不要这样回复):\"好的,我先看一下总纲,马上改。\" ← 这只是文字,不会执行任何操作",
|
|
2929
|
+
" 错误示例(绝对不要这样回复):\"先拉完整上下文确认改动落地情况,避免继续空转。\" ← 没有动作标签,不会执行任何操作",
|
|
2930
|
+
" 错误示例(绝对不要这样回复):\"现在处理暗线库,我先看当前列表定位 id。\" ← 没有动作标签,不会执行任何操作",
|
|
2931
|
+
"- 铁律:只要你想执行任何操作,你的【整个回复】必须只包含一个动作标签,禁止先说话、禁止解释\"我要做什么\"、禁止铺垫——直接输出标签。",
|
|
2932
|
+
"- 如果你收到「格式提示」(宿主说你没有输出动作标签):你的下一条回复必须只输出动作标签,禁止再解释、禁止再道歉、禁止再描述计划。",
|
|
2613
2933
|
"- 工具调用是自动的:你输出标签后,宿主会执行并把结果反馈给你,你再基于结果继续。",
|
|
2614
2934
|
"- 每次回复最多调用 1 个动作;执行结果会反馈给你,你可以继续讨论或再调用。",
|
|
2615
|
-
"-
|
|
2935
|
+
"- 需要先看总纲/章节再决定怎么改?那就先输出一个 outline_text / chapter_text 的标签,等结果回来。",
|
|
2616
2936
|
"- chapter_rewrite 的 target 参数:从章节正文中复制一小段(一句话或几句话即可),不要带换行、不要带引号,取连续文本片段。",
|
|
2617
2937
|
"- 如果工具执行失败(例如片段未找到),根据错误信息修正参数后自动重试一次,不要直接放弃或让作者手动操作。",
|
|
2618
2938
|
"- 修改前先向作者说明你要改什么、为什么;动作执行后简要汇报结果。",
|
|
2619
2939
|
"- 涉及删除类操作(删除章节、清空设定)必须等作者明确同意。",
|
|
2620
|
-
"-
|
|
2940
|
+
"- 严格忠于道藏与总纲;不得自行发明与既有设定冲突的内容。",
|
|
2621
2941
|
"- 用中文回复。"
|
|
2622
2942
|
].join("\n");
|
|
2623
2943
|
}
|
|
@@ -2658,11 +2978,11 @@ async function* executeAction(ctx, config, project, outputDir, name, args) {
|
|
|
2658
2978
|
case "outline_replace": {
|
|
2659
2979
|
const old = str(args.old);
|
|
2660
2980
|
const next = str(args.new);
|
|
2661
|
-
if (old === "" || !project.outline.includes(old)) throw new Error(
|
|
2981
|
+
if (old === "" || !project.outline.includes(old)) throw new Error(`总纲中未找到片段「${old.slice(0, 40)}…」`);
|
|
2662
2982
|
project.outline = project.outline.replace(old, next);
|
|
2663
2983
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2664
2984
|
saveProject(outputDir, project);
|
|
2665
|
-
return
|
|
2985
|
+
return `总纲已修改:替换了 ${old.length} 字符的片段。`;
|
|
2666
2986
|
}
|
|
2667
2987
|
case "bible_set_rule": {
|
|
2668
2988
|
if (project.bible === void 0) throw new Error("尚无道藏,请先提炼");
|
|
@@ -2752,13 +3072,13 @@ async function* executeAction(ctx, config, project, outputDir, name, args) {
|
|
|
2752
3072
|
});
|
|
2753
3073
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2754
3074
|
saveProject(outputDir, project);
|
|
2755
|
-
return
|
|
3075
|
+
return `已新增暗线:「${description.slice(0, 50)}」`;
|
|
2756
3076
|
}
|
|
2757
3077
|
case "foreshadow_update": {
|
|
2758
3078
|
const id = str(args.id);
|
|
2759
3079
|
const status = str(args.status);
|
|
2760
3080
|
const target = project.foreshadows.find((f) => f.id === id);
|
|
2761
|
-
if (target === void 0) throw new Error(
|
|
3081
|
+
if (target === void 0) throw new Error(`暗线 ${id} 不存在`);
|
|
2762
3082
|
if (![
|
|
2763
3083
|
"planned",
|
|
2764
3084
|
"planted",
|
|
@@ -2769,7 +3089,7 @@ async function* executeAction(ctx, config, project, outputDir, name, args) {
|
|
|
2769
3089
|
target.status = status;
|
|
2770
3090
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2771
3091
|
saveProject(outputDir, project);
|
|
2772
|
-
return
|
|
3092
|
+
return `暗线已更新为 ${status}:「${target.description.slice(0, 50)}」`;
|
|
2773
3093
|
}
|
|
2774
3094
|
case "export_txt": {
|
|
2775
3095
|
const result = exportBook(outputDir, project, "txt");
|
|
@@ -2863,7 +3183,7 @@ function extractAction(reply) {
|
|
|
2863
3183
|
}
|
|
2864
3184
|
/** Render the recent history as LLM messages (skipping tool chatter in early rounds). */
|
|
2865
3185
|
function historyToMessages(history) {
|
|
2866
|
-
const recent = history.slice(-
|
|
3186
|
+
const recent = history.slice(-18);
|
|
2867
3187
|
const messages = [];
|
|
2868
3188
|
for (const entry of recent) if (entry.role === "user") messages.push(createUserMessage({
|
|
2869
3189
|
content: [{
|
|
@@ -2903,6 +3223,22 @@ function historyToMessages(history) {
|
|
|
2903
3223
|
/** One non-streaming LLM chat turn (used inside the tool loop). */
|
|
2904
3224
|
async function chatOnce(ctx, config, system, history) {
|
|
2905
3225
|
const messages = historyToMessages(history);
|
|
3226
|
+
const last = messages[messages.length - 1];
|
|
3227
|
+
if (last?.role === "user" && Array.isArray(last.content)) {
|
|
3228
|
+
const blocks = last.content;
|
|
3229
|
+
const idx = blocks.findIndex((b) => b.type === "text");
|
|
3230
|
+
if (idx !== -1) {
|
|
3231
|
+
const textBlock = blocks[idx];
|
|
3232
|
+
const newBlocks = blocks.map((b, i) => i === idx ? {
|
|
3233
|
+
...textBlock,
|
|
3234
|
+
text: textBlock.text + "\n\n(回复格式提醒:如果你需要执行任何操作,你的回复必须【只包含】一个 <dsh-action name=\"工具名\">{\"参数\":值}</dsh-action> 标签,禁止先说话、禁止解释、禁止铺垫;如果你只是在回答或讨论,正常回复即可,不要输出标签。)"
|
|
3235
|
+
} : b);
|
|
3236
|
+
messages[messages.length - 1] = {
|
|
3237
|
+
...last,
|
|
3238
|
+
content: newBlocks
|
|
3239
|
+
};
|
|
3240
|
+
}
|
|
3241
|
+
}
|
|
2906
3242
|
const request = {
|
|
2907
3243
|
provider: config.provider,
|
|
2908
3244
|
model: config.model,
|
|
@@ -2935,17 +3271,40 @@ async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
|
|
|
2935
3271
|
history.push(userEntry);
|
|
2936
3272
|
appendHistory(outputDir, userEntry);
|
|
2937
3273
|
let round = 0;
|
|
2938
|
-
/**
|
|
2939
|
-
let nudged =
|
|
3274
|
+
/** 已提示模型输出动作标签的次数(0 = 尚未提示;超过上限则按纯文字回复结束,防死循环)。 */
|
|
3275
|
+
let nudged = 0;
|
|
3276
|
+
const MAX_NUDGES = 6;
|
|
3277
|
+
/** 连续收到 hex 乱码回复的次数(≥2 次判定 LLM 侧异常,放弃本轮避免死循环)。 */
|
|
3278
|
+
let garbleCount = 0;
|
|
2940
3279
|
for (;;) {
|
|
3280
|
+
if (round++ > 20) break;
|
|
2941
3281
|
const reply = await chatOnce(ctx, config, system, history);
|
|
3282
|
+
if (reply.length > 120 && /^[0-9a-fA-F\s]+$/.test(reply.slice(0, 2e3))) {
|
|
3283
|
+
garbleCount++;
|
|
3284
|
+
if (garbleCount >= 2) {
|
|
3285
|
+
const garbleEntry = {
|
|
3286
|
+
role: "assistant",
|
|
3287
|
+
content: "(模型本次返回了异常编码内容,已忽略;请重新描述你的问题。)",
|
|
3288
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
3289
|
+
};
|
|
3290
|
+
history.push(garbleEntry);
|
|
3291
|
+
appendHistory(outputDir, garbleEntry);
|
|
3292
|
+
yield {
|
|
3293
|
+
frame: "delta",
|
|
3294
|
+
text: garbleEntry.content
|
|
3295
|
+
};
|
|
3296
|
+
return;
|
|
3297
|
+
}
|
|
3298
|
+
continue;
|
|
3299
|
+
}
|
|
2942
3300
|
const action = extractAction(reply);
|
|
2943
3301
|
if (action === void 0) {
|
|
2944
|
-
const intendsAction = /(
|
|
3302
|
+
const intendsAction = /(改|修改|修订|重写|替换|调整|生成|新增|删除|导出|看看|查看|调出|读一下|加上|加一个|去掉|删掉|把.+改成|定位|处理|转轨|检查|确认|搜索|找一下|列一下|查一下|查一遍|查一查|再查|查查|核实|核对|清点|盘点|看一下|看下|继续)/.test(reply);
|
|
3303
|
+
const mentionsTarget = /(编年录|道藏|暗线|总纲|卷首语|章节|正文|规则|红线|伏笔|简介|大纲|事实|设定|世界|角色|人物|第\s*\d+\s*章)/.test(reply);
|
|
2945
3304
|
const strayTag = /<[a-z_-]*action[^>]*>/.test(reply);
|
|
2946
|
-
if ((intendsAction || strayTag) &&
|
|
2947
|
-
nudged =
|
|
2948
|
-
|
|
3305
|
+
if ((intendsAction || mentionsTarget || strayTag) && nudged < MAX_NUDGES) {
|
|
3306
|
+
const nudge = nudged === 0 ? "你的上一条回复表达了想操作项目的意图(或动作标签格式有误),因此没有执行任何操作。请直接输出动作标签来执行,格式必须为 <dsh-action name=\"工具名\">{\"参数\":值}</dsh-action>(注意拼写是 dsh-action,不是 dash-action;标签成对出现,参数为合法 JSON)。如果需要先看内容,先输出 outline_text 或 chapter_text 标签。" : `你第 ${nudged + 1} 次表达了操作意图但没有输出动作标签,因此仍未执行任何操作。铁律:你的【整个回复】现在必须只包含一个 <dsh-action> 标签(例如 <dsh-action name="chapter_text">{"no":1}</dsh-action>),禁止任何解释、铺垫或"我这就去"之类的文字。若你其实不打算执行任何操作,请明确回复「不执行」。`;
|
|
3307
|
+
nudged++;
|
|
2949
3308
|
history.push({
|
|
2950
3309
|
role: "tool",
|
|
2951
3310
|
content: nudge,
|
|
@@ -3283,6 +3642,15 @@ function makeRoutes(deps) {
|
|
|
3283
3642
|
seedBookshelfFromOutputDir(config.outputDir);
|
|
3284
3643
|
const project = loadProject(config.outputDir);
|
|
3285
3644
|
if (project !== void 0) {
|
|
3645
|
+
const staleMs = 600 * 1e3;
|
|
3646
|
+
for (const c of project.chapters) if (c.status === "generating" && c.generatingAt !== void 0) {
|
|
3647
|
+
const started = new Date(c.generatingAt).getTime();
|
|
3648
|
+
if (Number.isFinite(started) && Date.now() - started > staleMs) {
|
|
3649
|
+
c.status = "pending";
|
|
3650
|
+
c.error = void 0;
|
|
3651
|
+
c.generatingAt = void 0;
|
|
3652
|
+
}
|
|
3653
|
+
}
|
|
3286
3654
|
syncProjectWithDisk(project, config.outputDir);
|
|
3287
3655
|
saveProject(config.outputDir, project);
|
|
3288
3656
|
}
|
|
@@ -3471,6 +3839,7 @@ function makeRoutes(deps) {
|
|
|
3471
3839
|
});
|
|
3472
3840
|
chapter.status = "generating";
|
|
3473
3841
|
chapter.error = void 0;
|
|
3842
|
+
chapter.generatingAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3474
3843
|
saveProject(config.outputDir, project);
|
|
3475
3844
|
const send = (frame) => {
|
|
3476
3845
|
res.write(JSON.stringify(frame) + "\n");
|
|
@@ -3506,6 +3875,27 @@ function makeRoutes(deps) {
|
|
|
3506
3875
|
chapter.status = "approved";
|
|
3507
3876
|
saveProject(config.outputDir, project);
|
|
3508
3877
|
}
|
|
3878
|
+
if (config.autoAuthorReview ?? true) try {
|
|
3879
|
+
const currentBody = readChapterFile(config.outputDir, chapter);
|
|
3880
|
+
let prevTail = "";
|
|
3881
|
+
if (no > 1) {
|
|
3882
|
+
const prev = project.chapters.find((c) => c.no === no - 1);
|
|
3883
|
+
if (prev !== void 0) prevTail = (readChapterFile(config.outputDir, prev) ?? "").replace(/^#.*$/m, "").trim().slice(-600);
|
|
3884
|
+
}
|
|
3885
|
+
if (currentBody !== void 0) {
|
|
3886
|
+
const review = await authorReviewChapter(ctx, config, project, no, currentBody, prevTail);
|
|
3887
|
+
chapter.authorReview = review;
|
|
3888
|
+
if (review.advancedLines !== void 0) autoLinkPlotlines(project, no, review.advancedLines);
|
|
3889
|
+
saveProject(config.outputDir, project);
|
|
3890
|
+
send({
|
|
3891
|
+
type: "author-review",
|
|
3892
|
+
no,
|
|
3893
|
+
review
|
|
3894
|
+
});
|
|
3895
|
+
}
|
|
3896
|
+
} catch (error) {
|
|
3897
|
+
console.warn("[dsh-novel-forge] author review failed:", error.message);
|
|
3898
|
+
}
|
|
3509
3899
|
res.end();
|
|
3510
3900
|
} catch (error) {
|
|
3511
3901
|
chapter.status = "error";
|
|
@@ -3965,554 +4355,784 @@ function makeRoutes(deps) {
|
|
|
3965
4355
|
writeJson(res, 200, { ok: true });
|
|
3966
4356
|
}
|
|
3967
4357
|
};
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
{
|
|
3988
|
-
kind: "exact",
|
|
3989
|
-
path: NOVEL_API.assets,
|
|
3990
|
-
handler: async (req, res) => {
|
|
3991
|
-
if (req.method !== "GET" && req.method !== "POST") {
|
|
3992
|
-
writeJson(res, 405, { error: "method not allowed (expected GET or POST)" });
|
|
4358
|
+
const assetsRoute = {
|
|
4359
|
+
kind: "exact",
|
|
4360
|
+
path: NOVEL_API.assets,
|
|
4361
|
+
handler: async (req, res) => {
|
|
4362
|
+
if (req.method !== "GET" && req.method !== "POST") {
|
|
4363
|
+
writeJson(res, 405, { error: "method not allowed (expected GET or POST)" });
|
|
4364
|
+
return;
|
|
4365
|
+
}
|
|
4366
|
+
if (!isLoopbackRequest(req)) {
|
|
4367
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
4368
|
+
return;
|
|
4369
|
+
}
|
|
4370
|
+
const config = getConfig();
|
|
4371
|
+
const project = loadProject(config.outputDir);
|
|
4372
|
+
const projectAssets = project?.assets ?? emptyProjectAssets();
|
|
4373
|
+
if (req.method === "POST") {
|
|
4374
|
+
const body = await readJsonBody(req);
|
|
4375
|
+
if (body === void 0) {
|
|
4376
|
+
writeJson(res, 400, { error: "无效的 JSON" });
|
|
3993
4377
|
return;
|
|
3994
4378
|
}
|
|
3995
|
-
if (
|
|
3996
|
-
writeJson(res,
|
|
4379
|
+
if (project === void 0) {
|
|
4380
|
+
writeJson(res, 400, { error: "请先加载大纲创建项目" });
|
|
3997
4381
|
return;
|
|
3998
4382
|
}
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
if (
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4383
|
+
if (body.genre !== void 0) projectAssets.genre = body.genre;
|
|
4384
|
+
if (body.primaryProgression !== void 0) projectAssets.primaryProgression = body.primaryProgression;
|
|
4385
|
+
if (body.auxiliaryProgressions !== void 0) projectAssets.auxiliaryProgressions = body.auxiliaryProgressions;
|
|
4386
|
+
if (body.antiAiRules !== void 0) projectAssets.antiAiRules = body.antiAiRules;
|
|
4387
|
+
if (body.styleAssets !== void 0) projectAssets.styleAssets = body.styleAssets;
|
|
4388
|
+
projectAssets.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4389
|
+
project.assets = projectAssets;
|
|
4390
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4391
|
+
saveProject(config.outputDir, project);
|
|
4392
|
+
}
|
|
4393
|
+
writeJson(res, 200, {
|
|
4394
|
+
projectAssets,
|
|
4395
|
+
genreLibrary: BUILTIN_GENRE_LIBRARY,
|
|
4396
|
+
antiAiLibrary: BUILTIN_ANTI_AI_RULES,
|
|
4397
|
+
styleTemplates: BUILTIN_STYLE_TEMPLATES,
|
|
4398
|
+
progressionLibrary: BUILTIN_PROGRESSION_MODES
|
|
4399
|
+
});
|
|
4400
|
+
}
|
|
4401
|
+
};
|
|
4402
|
+
const styleEngineRoute = {
|
|
4403
|
+
kind: "exact",
|
|
4404
|
+
path: NOVEL_API.styleEngine,
|
|
4405
|
+
handler: async (req, res) => {
|
|
4406
|
+
if (!guard(req, res, "POST")) return;
|
|
4407
|
+
const config = getConfig();
|
|
4408
|
+
const project = loadProject(config.outputDir);
|
|
4409
|
+
const body = await readJsonBody(req);
|
|
4410
|
+
const sample = body?.sampleText?.trim();
|
|
4411
|
+
if (sample === void 0 || sample.length < 50) {
|
|
4412
|
+
writeJson(res, 400, { error: "样本文本过短(<50 字符),请粘贴一段能代表目标风格的文字" });
|
|
4413
|
+
return;
|
|
4414
|
+
}
|
|
4415
|
+
try {
|
|
4416
|
+
const rules = await extractStyleAsset(ctx, config, sample);
|
|
4417
|
+
const styleAsset = {
|
|
4418
|
+
name: (body?.name?.trim() !== "" && body?.name !== void 0 ? body.name : `风格资产 ${Date.now().toString(36)}`).slice(0, 40),
|
|
4419
|
+
...rules,
|
|
4420
|
+
sourceText: sample.slice(0, 3e3),
|
|
4421
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4422
|
+
};
|
|
4423
|
+
if (project !== void 0) {
|
|
4424
|
+
project.assets ??= emptyProjectAssets();
|
|
4425
|
+
project.assets.styleAssets ??= [];
|
|
4426
|
+
project.assets.styleAssets.push(styleAsset);
|
|
4427
|
+
project.assets.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4019
4428
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4020
4429
|
saveProject(config.outputDir, project);
|
|
4021
4430
|
}
|
|
4022
|
-
writeJson(res, 200, {
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
antiAiLibrary: BUILTIN_ANTI_AI_RULES,
|
|
4026
|
-
styleTemplates: BUILTIN_STYLE_TEMPLATES,
|
|
4027
|
-
progressionLibrary: BUILTIN_PROGRESSION_MODES
|
|
4028
|
-
});
|
|
4431
|
+
writeJson(res, 200, { styleAsset });
|
|
4432
|
+
} catch (error) {
|
|
4433
|
+
writeJson(res, 500, { error: error.message });
|
|
4029
4434
|
}
|
|
4030
|
-
}
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
const sample = body?.sampleText?.trim();
|
|
4040
|
-
if (sample === void 0 || sample.length < 50) {
|
|
4041
|
-
writeJson(res, 400, { error: "样本文本过短(<50 字符),请粘贴一段能代表目标风格的文字" });
|
|
4435
|
+
}
|
|
4436
|
+
};
|
|
4437
|
+
const bookshelfRoute = {
|
|
4438
|
+
kind: "exact",
|
|
4439
|
+
path: NOVEL_API.bookshelf,
|
|
4440
|
+
handler: async (req, res) => {
|
|
4441
|
+
if (req.method === "GET") {
|
|
4442
|
+
if (!isLoopbackRequest(req)) {
|
|
4443
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
4042
4444
|
return;
|
|
4043
4445
|
}
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
name: (body?.name?.trim() !== "" && body?.name !== void 0 ? body.name : `风格资产 ${Date.now().toString(36)}`).slice(0, 40),
|
|
4048
|
-
...rules,
|
|
4049
|
-
sourceText: sample.slice(0, 3e3),
|
|
4050
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4051
|
-
};
|
|
4052
|
-
if (project !== void 0) {
|
|
4053
|
-
project.assets ??= emptyProjectAssets();
|
|
4054
|
-
project.assets.styleAssets ??= [];
|
|
4055
|
-
project.assets.styleAssets.push(styleAsset);
|
|
4056
|
-
project.assets.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4057
|
-
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4058
|
-
saveProject(config.outputDir, project);
|
|
4059
|
-
}
|
|
4060
|
-
writeJson(res, 200, { styleAsset });
|
|
4061
|
-
} catch (error) {
|
|
4062
|
-
writeJson(res, 500, { error: error.message });
|
|
4063
|
-
}
|
|
4446
|
+
seedBookshelfFromOutputDir(getConfig().outputDir);
|
|
4447
|
+
writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
|
|
4448
|
+
return;
|
|
4064
4449
|
}
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
assistantClearRoute,
|
|
4069
|
-
{
|
|
4070
|
-
kind: "exact",
|
|
4071
|
-
path: NOVEL_API.bookshelf,
|
|
4072
|
-
handler: async (req, res) => {
|
|
4073
|
-
if (req.method === "GET") {
|
|
4074
|
-
if (!isLoopbackRequest(req)) {
|
|
4075
|
-
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
4076
|
-
return;
|
|
4077
|
-
}
|
|
4078
|
-
seedBookshelfFromOutputDir(getConfig().outputDir);
|
|
4079
|
-
writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
|
|
4080
|
-
return;
|
|
4081
|
-
}
|
|
4082
|
-
if (req.method === "POST") {
|
|
4083
|
-
if (!isLoopbackRequest(req)) {
|
|
4084
|
-
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
4085
|
-
return;
|
|
4086
|
-
}
|
|
4087
|
-
const body = await readJsonBody(req);
|
|
4088
|
-
const bookName = body?.bookName?.trim();
|
|
4089
|
-
if (bookName === void 0 || bookName === "") {
|
|
4090
|
-
writeJson(res, 400, { error: "bookName 不能为空" });
|
|
4091
|
-
return;
|
|
4092
|
-
}
|
|
4093
|
-
const outputDir = body?.outputDir?.trim() !== "" && body?.outputDir !== void 0 ? body.outputDir : defaultOutputDirFor(bookName);
|
|
4094
|
-
const book = createBook(bookName, outputDir);
|
|
4095
|
-
const outline = body?.outline?.trim();
|
|
4096
|
-
if (outline !== void 0 && outline.length >= 50) {
|
|
4097
|
-
const project = createProject(outline);
|
|
4098
|
-
saveProject(outputDir, project);
|
|
4099
|
-
renameBook(book.id, project.bookName);
|
|
4100
|
-
}
|
|
4101
|
-
writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
|
|
4450
|
+
if (req.method === "POST") {
|
|
4451
|
+
if (!isLoopbackRequest(req)) {
|
|
4452
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
4102
4453
|
return;
|
|
4103
4454
|
}
|
|
4104
|
-
writeJson(res, 405, { error: "method not allowed (expected GET or POST)" });
|
|
4105
|
-
}
|
|
4106
|
-
},
|
|
4107
|
-
{
|
|
4108
|
-
kind: "exact",
|
|
4109
|
-
path: "/api/dsh-novel-forge/bookshelf/activate",
|
|
4110
|
-
handler: async (req, res) => {
|
|
4111
|
-
if (!guard(req, res, "POST")) return;
|
|
4112
4455
|
const body = await readJsonBody(req);
|
|
4113
|
-
|
|
4114
|
-
|
|
4456
|
+
const bookName = body?.bookName?.trim();
|
|
4457
|
+
if (bookName === void 0 || bookName === "") {
|
|
4458
|
+
writeJson(res, 400, { error: "bookName 不能为空" });
|
|
4115
4459
|
return;
|
|
4116
4460
|
}
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4461
|
+
const outputDir = body?.outputDir?.trim() !== "" && body?.outputDir !== void 0 ? body.outputDir : defaultOutputDirFor(bookName);
|
|
4462
|
+
const book = createBook(bookName, outputDir);
|
|
4463
|
+
const outline = body?.outline?.trim();
|
|
4464
|
+
if (outline !== void 0 && outline.length >= 50) {
|
|
4465
|
+
const project = createProject(outline);
|
|
4466
|
+
saveProject(outputDir, project);
|
|
4467
|
+
renameBook(book.id, project.bookName);
|
|
4120
4468
|
}
|
|
4121
4469
|
writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
|
|
4470
|
+
return;
|
|
4122
4471
|
}
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
}
|
|
4138
|
-
writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
|
|
4472
|
+
writeJson(res, 405, { error: "method not allowed (expected GET or POST)" });
|
|
4473
|
+
}
|
|
4474
|
+
};
|
|
4475
|
+
/** 重置项目:清空设定/卷/章节计划/正文/伏笔/资产/事实库(可携带新大纲)。 */
|
|
4476
|
+
const resetRoute = {
|
|
4477
|
+
kind: "exact",
|
|
4478
|
+
path: NOVEL_API.reset,
|
|
4479
|
+
handler: async (req, res) => {
|
|
4480
|
+
if (!guard(req, res, "POST")) return;
|
|
4481
|
+
const config = getConfig();
|
|
4482
|
+
const project = loadProject(config.outputDir);
|
|
4483
|
+
if (project === void 0) {
|
|
4484
|
+
writeJson(res, 400, { error: "输出目录中没有项目,无需重置" });
|
|
4485
|
+
return;
|
|
4139
4486
|
}
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4487
|
+
const outline = (await readJsonBody(req))?.outline?.trim();
|
|
4488
|
+
if (outline !== void 0 && outline.length >= 50) {
|
|
4489
|
+
project.outline = outline;
|
|
4490
|
+
project.bookName = createProject(outline).bookName;
|
|
4491
|
+
}
|
|
4492
|
+
project.bible = void 0;
|
|
4493
|
+
project.volumes = void 0;
|
|
4494
|
+
project.chapters = [];
|
|
4495
|
+
project.foreshadows = [];
|
|
4496
|
+
project.assets = emptyProjectAssets();
|
|
4497
|
+
project.facts = [];
|
|
4498
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4499
|
+
saveProject(config.outputDir, project);
|
|
4500
|
+
writeJson(res, 200, {
|
|
4501
|
+
ok: true,
|
|
4502
|
+
bookName: project.bookName
|
|
4503
|
+
});
|
|
4504
|
+
}
|
|
4505
|
+
};
|
|
4506
|
+
const bookshelfActivateRoute = {
|
|
4507
|
+
kind: "exact",
|
|
4508
|
+
path: "/api/dsh-novel-forge/bookshelf/activate",
|
|
4509
|
+
handler: async (req, res) => {
|
|
4510
|
+
if (!guard(req, res, "POST")) return;
|
|
4511
|
+
const body = await readJsonBody(req);
|
|
4512
|
+
if (body?.id === void 0 || body.id === "") {
|
|
4513
|
+
writeJson(res, 400, { error: "id 不能为空" });
|
|
4514
|
+
return;
|
|
4515
|
+
}
|
|
4516
|
+
if (activateBook(body.id) === void 0) {
|
|
4517
|
+
writeJson(res, 404, { error: `书 ${body.id} 不存在` });
|
|
4518
|
+
return;
|
|
4519
|
+
}
|
|
4520
|
+
writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
|
|
4521
|
+
}
|
|
4522
|
+
};
|
|
4523
|
+
const bookshelfRemoveRoute = {
|
|
4524
|
+
kind: "exact",
|
|
4525
|
+
path: "/api/dsh-novel-forge/bookshelf/remove",
|
|
4526
|
+
handler: async (req, res) => {
|
|
4527
|
+
if (!guard(req, res, "POST")) return;
|
|
4528
|
+
const body = await readJsonBody(req);
|
|
4529
|
+
if (body?.id === void 0 || body.id === "") {
|
|
4530
|
+
writeJson(res, 400, { error: "id 不能为空" });
|
|
4531
|
+
return;
|
|
4532
|
+
}
|
|
4533
|
+
if (!removeBook(body.id)) {
|
|
4534
|
+
writeJson(res, 404, { error: `书 ${body.id} 不存在` });
|
|
4535
|
+
return;
|
|
4536
|
+
}
|
|
4537
|
+
writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
|
|
4538
|
+
}
|
|
4539
|
+
};
|
|
4540
|
+
/** 全书一致性质检。 */
|
|
4541
|
+
const auditRoute = {
|
|
4542
|
+
kind: "exact",
|
|
4543
|
+
path: NOVEL_API.audit,
|
|
4544
|
+
handler: async (req, res) => {
|
|
4545
|
+
if (!guard(req, res, "POST")) return;
|
|
4546
|
+
const config = getConfig();
|
|
4547
|
+
const project = requireProject(res);
|
|
4548
|
+
if (project === void 0) return;
|
|
4549
|
+
try {
|
|
4550
|
+
writeJson(res, 200, {
|
|
4551
|
+
issues: await auditBook(ctx, config, project, config.outputDir),
|
|
4552
|
+
auditedChapters: project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating").length,
|
|
4553
|
+
auditedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4554
|
+
});
|
|
4555
|
+
} catch (error) {
|
|
4556
|
+
writeJson(res, 500, { error: error.message });
|
|
4557
|
+
}
|
|
4558
|
+
}
|
|
4559
|
+
};
|
|
4560
|
+
/** 角色卡刷新(出场统计精确化 + LLM 聚合状态)。 */
|
|
4561
|
+
const charactersRefreshRoute = {
|
|
4562
|
+
kind: "exact",
|
|
4563
|
+
path: NOVEL_API.charactersRefresh,
|
|
4564
|
+
handler: async (req, res) => {
|
|
4565
|
+
if (!guard(req, res, "POST")) return;
|
|
4566
|
+
const config = getConfig();
|
|
4567
|
+
const project = requireProject(res);
|
|
4568
|
+
if (project === void 0) return;
|
|
4569
|
+
try {
|
|
4570
|
+
const cards = await refreshCharacters(ctx, config, project, config.outputDir);
|
|
4571
|
+
project.roleStatus = cards;
|
|
4163
4572
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4164
4573
|
saveProject(config.outputDir, project);
|
|
4574
|
+
writeJson(res, 200, { cards });
|
|
4575
|
+
} catch (error) {
|
|
4576
|
+
writeJson(res, 500, { error: error.message });
|
|
4577
|
+
}
|
|
4578
|
+
}
|
|
4579
|
+
};
|
|
4580
|
+
/** 事实库回填:对历史已生成章节批量抽取事实。 */
|
|
4581
|
+
const factsBackfillRoute = {
|
|
4582
|
+
kind: "exact",
|
|
4583
|
+
path: NOVEL_API.factsBackfill,
|
|
4584
|
+
handler: async (req, res) => {
|
|
4585
|
+
if (!guard(req, res, "POST")) return;
|
|
4586
|
+
const config = getConfig();
|
|
4587
|
+
const project = requireProject(res);
|
|
4588
|
+
if (project === void 0) return;
|
|
4589
|
+
try {
|
|
4165
4590
|
writeJson(res, 200, {
|
|
4166
4591
|
ok: true,
|
|
4167
|
-
|
|
4592
|
+
filled: await backfillFacts(ctx, config, project, config.outputDir)
|
|
4168
4593
|
});
|
|
4594
|
+
} catch (error) {
|
|
4595
|
+
writeJson(res, 500, { error: error.message });
|
|
4169
4596
|
}
|
|
4170
|
-
}
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
});
|
|
4185
|
-
} catch (error) {
|
|
4186
|
-
writeJson(res, 500, { error: error.message });
|
|
4187
|
-
}
|
|
4597
|
+
}
|
|
4598
|
+
};
|
|
4599
|
+
/** 设定圣经局部修补(世界观规则/红线/风格)。 */
|
|
4600
|
+
const biblePatchRoute = {
|
|
4601
|
+
kind: "exact",
|
|
4602
|
+
path: NOVEL_API.biblePatch,
|
|
4603
|
+
handler: async (req, res) => {
|
|
4604
|
+
if (!guard(req, res, "POST")) return;
|
|
4605
|
+
const config = getConfig();
|
|
4606
|
+
const project = requireProject(res);
|
|
4607
|
+
if (project === void 0) return;
|
|
4608
|
+
if (project.bible === void 0) {
|
|
4609
|
+
writeJson(res, 400, { error: "尚未生成道藏,请先生成" });
|
|
4610
|
+
return;
|
|
4188
4611
|
}
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4612
|
+
const body = await readJsonBody(req);
|
|
4613
|
+
if (Array.isArray(body?.worldRules)) project.bible.worldRules = body.worldRules.filter((r) => r.trim() !== "");
|
|
4614
|
+
if (Array.isArray(body?.redLines)) project.bible.redLines = body.redLines.filter((r) => r.trim() !== "");
|
|
4615
|
+
if (Array.isArray(body?.style)) project.bible.style = body.style.filter((r) => r.trim() !== "");
|
|
4616
|
+
if (Array.isArray(body?.characters)) project.bible.characters = body.characters.filter((c) => c !== void 0 && c !== null && typeof c.name === "string" && c.name.trim() !== "").map((c) => ({
|
|
4617
|
+
name: c.name.trim(),
|
|
4618
|
+
role: [
|
|
4619
|
+
"protagonist",
|
|
4620
|
+
"supporting",
|
|
4621
|
+
"antagonist",
|
|
4622
|
+
"other"
|
|
4623
|
+
].includes(c.role) ? c.role : "other",
|
|
4624
|
+
traits: Array.isArray(c.traits) ? c.traits.filter((t) => typeof t === "string" && t.trim() !== "") : [],
|
|
4625
|
+
goals: typeof c.goals === "string" ? c.goals : "",
|
|
4626
|
+
relations: typeof c.relations === "string" ? c.relations : "",
|
|
4627
|
+
knowledge: Array.isArray(c.knowledge) ? c.knowledge.filter((k) => typeof k === "string" && k.trim() !== "") : void 0
|
|
4628
|
+
}));
|
|
4629
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4630
|
+
saveProject(config.outputDir, project);
|
|
4631
|
+
writeJson(res, 200, { bible: project.bible });
|
|
4632
|
+
}
|
|
4633
|
+
};
|
|
4634
|
+
/** 小说简介:AI 生成/补全,或手动保存。 */
|
|
4635
|
+
const blurbRoute = {
|
|
4636
|
+
kind: "exact",
|
|
4637
|
+
path: NOVEL_API.blurb,
|
|
4638
|
+
handler: async (req, res) => {
|
|
4639
|
+
if (!guard(req, res, "POST")) return;
|
|
4640
|
+
const config = getConfig();
|
|
4641
|
+
const project = requireProject(res);
|
|
4642
|
+
if (project === void 0) return;
|
|
4643
|
+
const body = await readJsonBody(req);
|
|
4644
|
+
try {
|
|
4645
|
+
if (body?.action === "save") {
|
|
4646
|
+
const text = body.text?.trim() ?? "";
|
|
4647
|
+
project.blurb = text;
|
|
4648
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4649
|
+
saveProject(config.outputDir, project);
|
|
4650
|
+
writeJson(res, 200, { blurb: text });
|
|
4651
|
+
return;
|
|
4202
4652
|
}
|
|
4653
|
+
const partial = body?.partial?.trim() ?? "";
|
|
4654
|
+
const blurb = await generateBlurb(ctx, config, project, partial);
|
|
4655
|
+
project.blurb = blurb;
|
|
4656
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4657
|
+
saveProject(config.outputDir, project);
|
|
4658
|
+
writeJson(res, 200, { blurb });
|
|
4659
|
+
} catch (error) {
|
|
4660
|
+
writeJson(res, 500, { error: error.message });
|
|
4203
4661
|
}
|
|
4204
|
-
}
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
writeJson(res,
|
|
4215
|
-
|
|
4216
|
-
filled: await backfillFacts(ctx, config, project, config.outputDir)
|
|
4217
|
-
});
|
|
4218
|
-
} catch (error) {
|
|
4219
|
-
writeJson(res, 500, { error: error.message });
|
|
4662
|
+
}
|
|
4663
|
+
};
|
|
4664
|
+
/** 封面:GET 读取(dataUrl);POST 上传(base64)或移除。 */
|
|
4665
|
+
const coverRoute = {
|
|
4666
|
+
kind: "exact",
|
|
4667
|
+
path: NOVEL_API.cover,
|
|
4668
|
+
handler: async (req, res) => {
|
|
4669
|
+
const config = getConfig();
|
|
4670
|
+
if (req.method === "GET") {
|
|
4671
|
+
if (!isLoopbackRequest(req)) {
|
|
4672
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
4673
|
+
return;
|
|
4220
4674
|
}
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
handler: async (req, res) => {
|
|
4227
|
-
if (!guard(req, res, "POST")) return;
|
|
4228
|
-
const config = getConfig();
|
|
4229
|
-
const project = requireProject(res);
|
|
4230
|
-
if (project === void 0) return;
|
|
4231
|
-
if (project.bible === void 0) {
|
|
4232
|
-
writeJson(res, 400, { error: "尚未生成道藏,请先生成" });
|
|
4675
|
+
const dirParam = new URL(req.url ?? "/", "http://localhost").searchParams.get("dir");
|
|
4676
|
+
const targetDir = dirParam !== null && dirParam !== "" ? dirParam : config.outputDir;
|
|
4677
|
+
const coverPath = loadProject(targetDir)?.coverPath;
|
|
4678
|
+
if (coverPath === void 0 || coverPath === "") {
|
|
4679
|
+
writeJson(res, 200, { dataUrl: null });
|
|
4233
4680
|
return;
|
|
4234
4681
|
}
|
|
4235
|
-
const
|
|
4236
|
-
if (
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
"protagonist",
|
|
4243
|
-
"supporting",
|
|
4244
|
-
"antagonist",
|
|
4245
|
-
"other"
|
|
4246
|
-
].includes(c.role) ? c.role : "other",
|
|
4247
|
-
traits: Array.isArray(c.traits) ? c.traits.filter((t) => typeof t === "string" && t.trim() !== "") : [],
|
|
4248
|
-
goals: typeof c.goals === "string" ? c.goals : "",
|
|
4249
|
-
relations: typeof c.relations === "string" ? c.relations : "",
|
|
4250
|
-
knowledge: Array.isArray(c.knowledge) ? c.knowledge.filter((k) => typeof k === "string" && k.trim() !== "") : void 0
|
|
4251
|
-
}));
|
|
4252
|
-
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4253
|
-
saveProject(config.outputDir, project);
|
|
4254
|
-
writeJson(res, 200, { bible: project.bible });
|
|
4682
|
+
const file = join(targetDir, coverPath);
|
|
4683
|
+
if (!existsSync(file)) {
|
|
4684
|
+
writeJson(res, 200, { dataUrl: null });
|
|
4685
|
+
return;
|
|
4686
|
+
}
|
|
4687
|
+
writeJson(res, 200, { dataUrl: `data:${coverPath.toLowerCase().endsWith(".png") ? "image/png" : coverPath.toLowerCase().endsWith(".jpg") || coverPath.toLowerCase().endsWith(".jpeg") ? "image/jpeg" : coverPath.toLowerCase().endsWith(".webp") ? "image/webp" : "image/png"};base64,${readFileSync(file).toString("base64")}` });
|
|
4688
|
+
return;
|
|
4255
4689
|
}
|
|
4256
|
-
|
|
4257
|
-
{
|
|
4258
|
-
kind: "exact",
|
|
4259
|
-
path: NOVEL_API.blurb,
|
|
4260
|
-
handler: async (req, res) => {
|
|
4690
|
+
if (req.method === "POST") {
|
|
4261
4691
|
if (!guard(req, res, "POST")) return;
|
|
4262
|
-
const config = getConfig();
|
|
4263
4692
|
const project = requireProject(res);
|
|
4264
4693
|
if (project === void 0) return;
|
|
4265
4694
|
const body = await readJsonBody(req);
|
|
4266
4695
|
try {
|
|
4267
|
-
if (body?.action === "
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4271
|
-
saveProject(config.outputDir, project);
|
|
4272
|
-
writeJson(res, 200, { blurb: text });
|
|
4273
|
-
return;
|
|
4274
|
-
}
|
|
4275
|
-
const partial = body?.partial?.trim() ?? "";
|
|
4276
|
-
const blurb = await generateBlurb(ctx, config, project, partial);
|
|
4277
|
-
project.blurb = blurb;
|
|
4278
|
-
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4279
|
-
saveProject(config.outputDir, project);
|
|
4280
|
-
writeJson(res, 200, { blurb });
|
|
4281
|
-
} catch (error) {
|
|
4282
|
-
writeJson(res, 500, { error: error.message });
|
|
4283
|
-
}
|
|
4284
|
-
}
|
|
4285
|
-
},
|
|
4286
|
-
{
|
|
4287
|
-
kind: "exact",
|
|
4288
|
-
path: NOVEL_API.cover,
|
|
4289
|
-
handler: async (req, res) => {
|
|
4290
|
-
const config = getConfig();
|
|
4291
|
-
if (req.method === "GET") {
|
|
4292
|
-
if (!isLoopbackRequest(req)) {
|
|
4293
|
-
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
4294
|
-
return;
|
|
4295
|
-
}
|
|
4296
|
-
const dirParam = new URL(req.url ?? "/", "http://localhost").searchParams.get("dir");
|
|
4297
|
-
const targetDir = dirParam !== null && dirParam !== "" ? dirParam : config.outputDir;
|
|
4298
|
-
const coverPath = loadProject(targetDir)?.coverPath;
|
|
4299
|
-
if (coverPath === void 0 || coverPath === "") {
|
|
4300
|
-
writeJson(res, 200, { dataUrl: null });
|
|
4301
|
-
return;
|
|
4302
|
-
}
|
|
4303
|
-
const file = join(targetDir, coverPath);
|
|
4304
|
-
if (!existsSync(file)) {
|
|
4305
|
-
writeJson(res, 200, { dataUrl: null });
|
|
4306
|
-
return;
|
|
4307
|
-
}
|
|
4308
|
-
writeJson(res, 200, { dataUrl: `data:${coverPath.toLowerCase().endsWith(".png") ? "image/png" : coverPath.toLowerCase().endsWith(".jpg") || coverPath.toLowerCase().endsWith(".jpeg") ? "image/jpeg" : coverPath.toLowerCase().endsWith(".webp") ? "image/webp" : "image/png"};base64,${readFileSync(file).toString("base64")}` });
|
|
4309
|
-
return;
|
|
4310
|
-
}
|
|
4311
|
-
if (req.method === "POST") {
|
|
4312
|
-
if (!guard(req, res, "POST")) return;
|
|
4313
|
-
const project = requireProject(res);
|
|
4314
|
-
if (project === void 0) return;
|
|
4315
|
-
const body = await readJsonBody(req);
|
|
4316
|
-
try {
|
|
4317
|
-
if (body?.action === "remove") {
|
|
4318
|
-
if (project.coverPath !== void 0 && project.coverPath !== "") {
|
|
4319
|
-
const oldFile = join(config.outputDir, project.coverPath);
|
|
4320
|
-
if (existsSync(oldFile)) rmSync(oldFile, { force: true });
|
|
4321
|
-
}
|
|
4322
|
-
project.coverPath = void 0;
|
|
4323
|
-
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4324
|
-
saveProject(config.outputDir, project);
|
|
4325
|
-
writeJson(res, 200, {
|
|
4326
|
-
ok: true,
|
|
4327
|
-
coverPath: null
|
|
4328
|
-
});
|
|
4329
|
-
return;
|
|
4330
|
-
}
|
|
4331
|
-
const dataUrl = body?.dataUrl ?? "";
|
|
4332
|
-
const match = /^data:(image\/(?:png|jpeg|jpg|webp));base64,(.+)$/s.exec(dataUrl);
|
|
4333
|
-
if (match === null) {
|
|
4334
|
-
writeJson(res, 400, { error: "封面须为 PNG/JPEG/WebP 的 base64 data URL" });
|
|
4335
|
-
return;
|
|
4336
|
-
}
|
|
4337
|
-
const mime = match[1];
|
|
4338
|
-
const fileName = `cover.${mime === "image/png" ? "png" : mime === "image/webp" ? "webp" : "jpg"}`;
|
|
4339
|
-
const targetPath = join(config.outputDir, fileName);
|
|
4340
|
-
const oldPath = project.coverPath;
|
|
4341
|
-
mkdirSync(config.outputDir, { recursive: true });
|
|
4342
|
-
writeFileSync(targetPath, Buffer.from(match[2], "base64"));
|
|
4343
|
-
if (oldPath !== void 0 && oldPath !== "" && oldPath !== fileName) {
|
|
4344
|
-
const oldFile = join(config.outputDir, oldPath);
|
|
4696
|
+
if (body?.action === "remove") {
|
|
4697
|
+
if (project.coverPath !== void 0 && project.coverPath !== "") {
|
|
4698
|
+
const oldFile = join(config.outputDir, project.coverPath);
|
|
4345
4699
|
if (existsSync(oldFile)) rmSync(oldFile, { force: true });
|
|
4346
4700
|
}
|
|
4347
|
-
project.coverPath =
|
|
4701
|
+
project.coverPath = void 0;
|
|
4348
4702
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4349
4703
|
saveProject(config.outputDir, project);
|
|
4350
4704
|
writeJson(res, 200, {
|
|
4351
4705
|
ok: true,
|
|
4352
|
-
coverPath:
|
|
4706
|
+
coverPath: null
|
|
4353
4707
|
});
|
|
4354
|
-
|
|
4355
|
-
writeJson(res, 500, { error: error.message });
|
|
4708
|
+
return;
|
|
4356
4709
|
}
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
},
|
|
4362
|
-
{
|
|
4363
|
-
kind: "exact",
|
|
4364
|
-
path: NOVEL_API.world,
|
|
4365
|
-
handler: async (req, res) => {
|
|
4366
|
-
if (!guard(req, res, "POST")) return;
|
|
4367
|
-
const config = getConfig();
|
|
4368
|
-
const project = requireProject(res);
|
|
4369
|
-
if (project === void 0) return;
|
|
4370
|
-
const body = await readJsonBody(req);
|
|
4371
|
-
try {
|
|
4372
|
-
if (body?.action === "save" && body.world !== void 0) {
|
|
4373
|
-
project.world = {
|
|
4374
|
-
realms: Array.isArray(body.world.realms) ? body.world.realms : [],
|
|
4375
|
-
regions: Array.isArray(body.world.regions) ? body.world.regions : [],
|
|
4376
|
-
factions: Array.isArray(body.world.factions) ? body.world.factions : []
|
|
4377
|
-
};
|
|
4378
|
-
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4379
|
-
saveProject(config.outputDir, project);
|
|
4380
|
-
writeJson(res, 200, { world: project.world });
|
|
4710
|
+
const dataUrl = body?.dataUrl ?? "";
|
|
4711
|
+
const match = /^data:(image\/(?:png|jpeg|jpg|webp));base64,(.+)$/s.exec(dataUrl);
|
|
4712
|
+
if (match === null) {
|
|
4713
|
+
writeJson(res, 400, { error: "封面须为 PNG/JPEG/WebP 的 base64 data URL" });
|
|
4381
4714
|
return;
|
|
4382
4715
|
}
|
|
4383
|
-
const
|
|
4384
|
-
|
|
4716
|
+
const mime = match[1];
|
|
4717
|
+
const fileName = `cover.${mime === "image/png" ? "png" : mime === "image/webp" ? "webp" : "jpg"}`;
|
|
4718
|
+
const targetPath = join(config.outputDir, fileName);
|
|
4719
|
+
const oldPath = project.coverPath;
|
|
4720
|
+
mkdirSync(config.outputDir, { recursive: true });
|
|
4721
|
+
writeFileSync(targetPath, Buffer.from(match[2], "base64"));
|
|
4722
|
+
if (oldPath !== void 0 && oldPath !== "" && oldPath !== fileName) {
|
|
4723
|
+
const oldFile = join(config.outputDir, oldPath);
|
|
4724
|
+
if (existsSync(oldFile)) rmSync(oldFile, { force: true });
|
|
4725
|
+
}
|
|
4726
|
+
project.coverPath = fileName;
|
|
4385
4727
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4386
4728
|
saveProject(config.outputDir, project);
|
|
4387
|
-
writeJson(res, 200, {
|
|
4729
|
+
writeJson(res, 200, {
|
|
4730
|
+
ok: true,
|
|
4731
|
+
coverPath: fileName
|
|
4732
|
+
});
|
|
4388
4733
|
} catch (error) {
|
|
4389
4734
|
writeJson(res, 500, { error: error.message });
|
|
4390
4735
|
}
|
|
4736
|
+
return;
|
|
4391
4737
|
}
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4738
|
+
writeJson(res, 405, { error: "method not allowed (expected GET or POST)" });
|
|
4739
|
+
}
|
|
4740
|
+
};
|
|
4741
|
+
/** 大世界:AI 提炼或手动保存(境界体系/区域/势力)。 */
|
|
4742
|
+
const worldRoute = {
|
|
4743
|
+
kind: "exact",
|
|
4744
|
+
path: NOVEL_API.world,
|
|
4745
|
+
handler: async (req, res) => {
|
|
4746
|
+
if (!guard(req, res, "POST")) return;
|
|
4747
|
+
const config = getConfig();
|
|
4748
|
+
const project = requireProject(res);
|
|
4749
|
+
if (project === void 0) return;
|
|
4750
|
+
const body = await readJsonBody(req);
|
|
4751
|
+
try {
|
|
4752
|
+
if (body?.action === "save" && body.world !== void 0) {
|
|
4753
|
+
project.world = {
|
|
4754
|
+
realms: Array.isArray(body.world.realms) ? body.world.realms : [],
|
|
4755
|
+
regions: Array.isArray(body.world.regions) ? body.world.regions : [],
|
|
4756
|
+
factions: Array.isArray(body.world.factions) ? body.world.factions : []
|
|
4757
|
+
};
|
|
4758
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4759
|
+
saveProject(config.outputDir, project);
|
|
4760
|
+
writeJson(res, 200, { world: project.world });
|
|
4404
4761
|
return;
|
|
4405
4762
|
}
|
|
4406
|
-
|
|
4763
|
+
const world = await extractWorld(ctx, config, project);
|
|
4764
|
+
project.world = world;
|
|
4407
4765
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4408
4766
|
saveProject(config.outputDir, project);
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
writeJson(res,
|
|
4767
|
+
writeJson(res, 200, { world });
|
|
4768
|
+
} catch (error) {
|
|
4769
|
+
writeJson(res, 500, { error: error.message });
|
|
4412
4770
|
}
|
|
4413
|
-
}
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4771
|
+
}
|
|
4772
|
+
};
|
|
4773
|
+
/** 重命名当前书:同步项目 bookName 与书架条目。 */
|
|
4774
|
+
const renameRoute = {
|
|
4775
|
+
kind: "exact",
|
|
4776
|
+
path: NOVEL_API.rename,
|
|
4777
|
+
handler: async (req, res) => {
|
|
4778
|
+
if (!guard(req, res, "POST")) return;
|
|
4779
|
+
const config = getConfig();
|
|
4780
|
+
const project = requireProject(res);
|
|
4781
|
+
if (project === void 0) return;
|
|
4782
|
+
const bookName = (await readJsonBody(req))?.bookName?.trim();
|
|
4783
|
+
if (bookName === void 0 || bookName === "") {
|
|
4784
|
+
writeJson(res, 400, { error: "书名不能为空" });
|
|
4785
|
+
return;
|
|
4786
|
+
}
|
|
4787
|
+
project.bookName = bookName.slice(0, 60);
|
|
4788
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4789
|
+
saveProject(config.outputDir, project);
|
|
4790
|
+
const store = loadBookshelf();
|
|
4791
|
+
if (store.activeBookId !== null) renameBook(store.activeBookId, project.bookName);
|
|
4792
|
+
writeJson(res, 200, { bookName: project.bookName });
|
|
4793
|
+
}
|
|
4794
|
+
};
|
|
4795
|
+
/** 剧情线管理:增删改 + 关联章节(主线/支线/人物线/悬念线)。 */
|
|
4796
|
+
const plotlinesRoute = {
|
|
4797
|
+
kind: "exact",
|
|
4798
|
+
path: NOVEL_API.plotlines,
|
|
4799
|
+
handler: async (req, res) => {
|
|
4800
|
+
if (!guard(req, res, "POST")) return;
|
|
4801
|
+
const config = getConfig();
|
|
4802
|
+
const project = requireProject(res);
|
|
4803
|
+
if (project === void 0) return;
|
|
4804
|
+
const body = await readJsonBody(req);
|
|
4805
|
+
if (project.plotlines === void 0) project.plotlines = [];
|
|
4806
|
+
const op = body?.op;
|
|
4807
|
+
if (op === "add" && body?.line !== void 0) {
|
|
4808
|
+
const line = body.line;
|
|
4809
|
+
project.plotlines.push({
|
|
4810
|
+
id: line.id !== "" ? line.id : `pl-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
4811
|
+
name: line.name.slice(0, 40),
|
|
4812
|
+
kind: line.kind,
|
|
4813
|
+
goal: line.goal.slice(0, 300),
|
|
4814
|
+
progress: line.progress.slice(0, 300),
|
|
4815
|
+
status: line.status,
|
|
4816
|
+
chapters: Array.isArray(line.chapters) ? line.chapters.filter((n) => typeof n === "number") : [],
|
|
4817
|
+
createdAt: line.createdAt !== "" ? line.createdAt : (/* @__PURE__ */ new Date()).toISOString()
|
|
4818
|
+
});
|
|
4819
|
+
} else if (op === "update" && body?.line !== void 0 && body.line.id !== "") {
|
|
4820
|
+
const idx = project.plotlines.findIndex((l) => l.id === body.line.id);
|
|
4821
|
+
if (idx !== -1) {
|
|
4426
4822
|
const line = body.line;
|
|
4427
|
-
project.plotlines
|
|
4428
|
-
|
|
4823
|
+
project.plotlines[idx] = {
|
|
4824
|
+
...project.plotlines[idx],
|
|
4429
4825
|
name: line.name.slice(0, 40),
|
|
4430
4826
|
kind: line.kind,
|
|
4431
4827
|
goal: line.goal.slice(0, 300),
|
|
4432
4828
|
progress: line.progress.slice(0, 300),
|
|
4433
|
-
status: line.status
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4829
|
+
status: line.status
|
|
4830
|
+
};
|
|
4831
|
+
}
|
|
4832
|
+
} else if (op === "remove" && body?.id !== void 0) project.plotlines = project.plotlines.filter((l) => l.id !== body.id);
|
|
4833
|
+
else if (op === "link" && body?.id !== void 0 && typeof body.chapterNo === "number" && body.chapterNo > 0) {
|
|
4834
|
+
const line = project.plotlines.find((l) => l.id === body.id);
|
|
4835
|
+
if (line !== void 0 && !line.chapters.includes(body.chapterNo)) {
|
|
4836
|
+
line.chapters.push(body.chapterNo);
|
|
4837
|
+
if (line.status === "active" && line.progress === "") line.progress = `推进至第 ${body.chapterNo} 章`;
|
|
4838
|
+
}
|
|
4839
|
+
} else if (op === "suggest") try {
|
|
4840
|
+
const suggestions = await suggestPlotlines(ctx, config, project);
|
|
4841
|
+
writeJson(res, 200, {
|
|
4842
|
+
plotlines: project.plotlines,
|
|
4843
|
+
suggestions
|
|
4844
|
+
});
|
|
4845
|
+
return;
|
|
4846
|
+
} catch (error) {
|
|
4847
|
+
writeJson(res, 500, { error: `AI 建议失败:${error.message}` });
|
|
4848
|
+
return;
|
|
4849
|
+
}
|
|
4850
|
+
else if (op === "refresh" && body?.id !== void 0) {
|
|
4851
|
+
const line = project.plotlines.find((l) => l.id === body.id);
|
|
4852
|
+
if (line === void 0) {
|
|
4853
|
+
writeJson(res, 404, { error: "剧情线不存在" });
|
|
4854
|
+
return;
|
|
4855
|
+
}
|
|
4856
|
+
try {
|
|
4857
|
+
line.progress = await refreshPlotlineProgress(ctx, config, project, line);
|
|
4858
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4859
|
+
saveProject(config.outputDir, project);
|
|
4860
|
+
writeJson(res, 200, { plotlines: project.plotlines });
|
|
4861
|
+
return;
|
|
4862
|
+
} catch (error) {
|
|
4863
|
+
writeJson(res, 500, { error: `刷新进度失败:${error.message}` });
|
|
4864
|
+
return;
|
|
4865
|
+
}
|
|
4866
|
+
} else if (op === "health") try {
|
|
4867
|
+
const health = await analyzePlotlineHealth(ctx, config, project);
|
|
4868
|
+
writeJson(res, 200, {
|
|
4869
|
+
plotlines: project.plotlines,
|
|
4870
|
+
health
|
|
4871
|
+
});
|
|
4872
|
+
return;
|
|
4873
|
+
} catch (error) {
|
|
4874
|
+
writeJson(res, 500, { error: `健康检查失败:${error.message}` });
|
|
4875
|
+
return;
|
|
4876
|
+
}
|
|
4877
|
+
else if (op === "plan") try {
|
|
4878
|
+
const health = await analyzePlotlineHealth(ctx, config, project);
|
|
4879
|
+
const plan = await designPlotlinePlan(ctx, config, project, health);
|
|
4880
|
+
writeJson(res, 200, {
|
|
4881
|
+
plotlines: project.plotlines,
|
|
4882
|
+
health,
|
|
4883
|
+
plan
|
|
4884
|
+
});
|
|
4885
|
+
return;
|
|
4886
|
+
} catch (error) {
|
|
4887
|
+
writeJson(res, 500, { error: `剧情方案生成失败:${error.message}` });
|
|
4888
|
+
return;
|
|
4889
|
+
}
|
|
4890
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4891
|
+
saveProject(config.outputDir, project);
|
|
4892
|
+
writeJson(res, 200, { plotlines: project.plotlines });
|
|
4893
|
+
}
|
|
4894
|
+
};
|
|
4895
|
+
/** 敏感词检查:指定章节 / 任意文本 / 全书已写章节。 */
|
|
4896
|
+
const sensitiveRoute = {
|
|
4897
|
+
kind: "exact",
|
|
4898
|
+
path: NOVEL_API.sensitiveCheck,
|
|
4899
|
+
handler: async (req, res) => {
|
|
4900
|
+
if (!guard(req, res, "POST")) return;
|
|
4901
|
+
const config = getConfig();
|
|
4902
|
+
const project = requireProject(res);
|
|
4903
|
+
if (project === void 0) return;
|
|
4904
|
+
const body = await readJsonBody(req);
|
|
4905
|
+
const hits = [];
|
|
4906
|
+
let scanned = 0;
|
|
4907
|
+
if (body?.text !== void 0) for (const hit of checkSensitiveText(body.text)) hits.push({
|
|
4908
|
+
chapterNo: 0,
|
|
4909
|
+
word: hit.word,
|
|
4910
|
+
category: hit.category,
|
|
4911
|
+
count: hit.count
|
|
4912
|
+
});
|
|
4913
|
+
else if (typeof body?.chapterNo === "number") {
|
|
4914
|
+
const chapter = project.chapters.find((c) => c.no === body.chapterNo);
|
|
4915
|
+
if (chapter !== void 0) {
|
|
4916
|
+
const text = readChapterFile(config.outputDir, chapter);
|
|
4917
|
+
if (text !== void 0) {
|
|
4918
|
+
scanned = 1;
|
|
4919
|
+
for (const hit of checkSensitiveText(text)) hits.push({
|
|
4920
|
+
chapterNo: chapter.no,
|
|
4921
|
+
word: hit.word,
|
|
4922
|
+
category: hit.category,
|
|
4923
|
+
count: hit.count
|
|
4924
|
+
});
|
|
4456
4925
|
}
|
|
4457
4926
|
}
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4927
|
+
} else if (body?.all === true) for (const chapter of project.chapters) {
|
|
4928
|
+
if (chapter.status === "pending" || chapter.status === "generating") continue;
|
|
4929
|
+
const text = readChapterFile(config.outputDir, chapter);
|
|
4930
|
+
if (text === void 0) continue;
|
|
4931
|
+
scanned++;
|
|
4932
|
+
for (const hit of checkSensitiveText(text)) hits.push({
|
|
4933
|
+
chapterNo: chapter.no,
|
|
4934
|
+
word: hit.word,
|
|
4935
|
+
category: hit.category,
|
|
4936
|
+
count: hit.count
|
|
4937
|
+
});
|
|
4461
4938
|
}
|
|
4462
|
-
|
|
4939
|
+
else {
|
|
4940
|
+
writeJson(res, 400, { error: "请提供 chapterNo / text / all 之一" });
|
|
4941
|
+
return;
|
|
4942
|
+
}
|
|
4943
|
+
writeJson(res, 200, {
|
|
4944
|
+
hits,
|
|
4945
|
+
scannedChapters: scanned
|
|
4946
|
+
});
|
|
4947
|
+
}
|
|
4948
|
+
};
|
|
4949
|
+
/** 角色库:AI 提炼 / 采纳 / 更新 / 删除。 */
|
|
4950
|
+
const rolesRoute = {
|
|
4951
|
+
kind: "exact",
|
|
4952
|
+
path: NOVEL_API.roles,
|
|
4953
|
+
handler: async (req, res) => {
|
|
4954
|
+
if (!guard(req, res, "POST")) return;
|
|
4955
|
+
const config = getConfig();
|
|
4956
|
+
const project = requireProject(res);
|
|
4957
|
+
if (project === void 0) return;
|
|
4958
|
+
const body = await readJsonBody(req);
|
|
4959
|
+
if (project.roles === void 0) project.roles = [];
|
|
4960
|
+
const op = body?.op;
|
|
4961
|
+
if (op === "extract") try {
|
|
4962
|
+
const candidates = await extractRoles(ctx, config, project);
|
|
4963
|
+
writeJson(res, 200, {
|
|
4964
|
+
roles: project.roles,
|
|
4965
|
+
candidates
|
|
4966
|
+
});
|
|
4967
|
+
return;
|
|
4968
|
+
} catch (error) {
|
|
4969
|
+
writeJson(res, 500, { error: `角色提炼失败:${error.message}` });
|
|
4970
|
+
return;
|
|
4971
|
+
}
|
|
4972
|
+
else if ((op === "adopt" || op === "update") && body?.role !== void 0) {
|
|
4973
|
+
const r = body.role;
|
|
4974
|
+
const idx = project.roles.findIndex((x) => x.name === r.name);
|
|
4975
|
+
if (idx === -1) project.roles.push(r);
|
|
4976
|
+
else project.roles[idx] = r;
|
|
4977
|
+
} else if (op === "remove" && body?.name !== void 0) project.roles = project.roles.filter((x) => x.name !== body.name);
|
|
4978
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4979
|
+
saveProject(config.outputDir, project);
|
|
4980
|
+
writeJson(res, 200, { roles: project.roles });
|
|
4981
|
+
}
|
|
4982
|
+
};
|
|
4983
|
+
/** 章节复位:generating 卡死 → pending(可重新生成)。 */
|
|
4984
|
+
const chapterResetRoute = {
|
|
4985
|
+
kind: "exact",
|
|
4986
|
+
path: NOVEL_API.chapterReset,
|
|
4987
|
+
handler: async (req, res) => {
|
|
4988
|
+
if (!guard(req, res, "POST")) return;
|
|
4989
|
+
const config = getConfig();
|
|
4990
|
+
const project = requireProject(res);
|
|
4991
|
+
if (project === void 0) return;
|
|
4992
|
+
const no = (await readJsonBody(req))?.chapterNo;
|
|
4993
|
+
if (!Number.isInteger(no) || no === void 0 || no < 1) {
|
|
4994
|
+
writeJson(res, 400, { error: "chapterNo 须为正整数" });
|
|
4995
|
+
return;
|
|
4996
|
+
}
|
|
4997
|
+
const chapter = project.chapters.find((c) => c.no === no);
|
|
4998
|
+
if (chapter === void 0) {
|
|
4999
|
+
writeJson(res, 404, { error: `章节 ${no} 不在计划中` });
|
|
5000
|
+
return;
|
|
5001
|
+
}
|
|
5002
|
+
chapter.status = "pending";
|
|
5003
|
+
chapter.error = void 0;
|
|
5004
|
+
chapter.generatingAt = void 0;
|
|
5005
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5006
|
+
saveProject(config.outputDir, project);
|
|
5007
|
+
writeJson(res, 200, {
|
|
5008
|
+
ok: true,
|
|
5009
|
+
no
|
|
5010
|
+
});
|
|
5011
|
+
}
|
|
5012
|
+
};
|
|
5013
|
+
return [
|
|
5014
|
+
statusRoute,
|
|
5015
|
+
loadOutlineRoute,
|
|
5016
|
+
saveOutlineRoute,
|
|
5017
|
+
bibleRoute,
|
|
5018
|
+
volumesRoute,
|
|
5019
|
+
planRoute,
|
|
5020
|
+
generateRoute,
|
|
5021
|
+
reviewRoute,
|
|
5022
|
+
rewriteRoute,
|
|
5023
|
+
polishRoute,
|
|
5024
|
+
draftApplyRoute,
|
|
5025
|
+
draftDiscardRoute,
|
|
5026
|
+
summaryRoute,
|
|
5027
|
+
foreshadowRoute,
|
|
5028
|
+
exportRoute,
|
|
5029
|
+
chapterRoute,
|
|
5030
|
+
chapterCheckRoute,
|
|
5031
|
+
chapterSaveRoute,
|
|
5032
|
+
assetsRoute,
|
|
5033
|
+
styleEngineRoute,
|
|
5034
|
+
assistantRoute,
|
|
5035
|
+
assistantHistoryRoute,
|
|
5036
|
+
assistantClearRoute,
|
|
5037
|
+
bookshelfRoute,
|
|
5038
|
+
bookshelfActivateRoute,
|
|
5039
|
+
bookshelfRemoveRoute,
|
|
5040
|
+
resetRoute,
|
|
5041
|
+
auditRoute,
|
|
5042
|
+
charactersRefreshRoute,
|
|
5043
|
+
factsBackfillRoute,
|
|
5044
|
+
biblePatchRoute,
|
|
5045
|
+
blurbRoute,
|
|
5046
|
+
coverRoute,
|
|
5047
|
+
worldRoute,
|
|
5048
|
+
renameRoute,
|
|
5049
|
+
plotlinesRoute,
|
|
5050
|
+
rolesRoute,
|
|
5051
|
+
sensitiveRoute,
|
|
4463
5052
|
{
|
|
4464
5053
|
kind: "exact",
|
|
4465
|
-
path: NOVEL_API.
|
|
5054
|
+
path: NOVEL_API.reviewBackfill,
|
|
4466
5055
|
handler: async (req, res) => {
|
|
4467
5056
|
if (!guard(req, res, "POST")) return;
|
|
4468
5057
|
const config = getConfig();
|
|
4469
5058
|
const project = requireProject(res);
|
|
4470
5059
|
if (project === void 0) return;
|
|
4471
5060
|
const body = await readJsonBody(req);
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
5061
|
+
/** 对一章执行作者复盘(读取已落盘正文,不改变章节状态/正文)。 */
|
|
5062
|
+
const runOne = async (chapter) => {
|
|
5063
|
+
const currentBody = readChapterFile(config.outputDir, chapter);
|
|
5064
|
+
if (currentBody === void 0) throw new Error(`章节 ${chapter.no} 的正文文件不存在`);
|
|
5065
|
+
let prevTail = "";
|
|
5066
|
+
if (chapter.no > 1) {
|
|
5067
|
+
const prev = project.chapters.find((c) => c.no === chapter.no - 1);
|
|
5068
|
+
if (prev !== void 0) prevTail = (readChapterFile(config.outputDir, prev) ?? "").replace(/^#.*$/m, "").trim().slice(-600);
|
|
5069
|
+
}
|
|
5070
|
+
return authorReviewChapter(ctx, config, project, chapter.no, currentBody, prevTail);
|
|
5071
|
+
};
|
|
5072
|
+
if (typeof body?.chapterNo === "number" && body.chapterNo > 0) {
|
|
4481
5073
|
const chapter = project.chapters.find((c) => c.no === body.chapterNo);
|
|
4482
|
-
if (chapter
|
|
4483
|
-
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
5074
|
+
if (chapter === void 0) {
|
|
5075
|
+
writeJson(res, 404, { error: `章节 ${body.chapterNo} 不在计划中` });
|
|
5076
|
+
return;
|
|
5077
|
+
}
|
|
5078
|
+
if (chapter.status === "pending") {
|
|
5079
|
+
writeJson(res, 400, { error: "该章尚未生成正文,无法复盘" });
|
|
5080
|
+
return;
|
|
5081
|
+
}
|
|
5082
|
+
try {
|
|
5083
|
+
const review = await runOne(chapter);
|
|
5084
|
+
chapter.authorReview = review;
|
|
5085
|
+
if (review.advancedLines !== void 0) autoLinkPlotlines(project, chapter.no, review.advancedLines);
|
|
5086
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5087
|
+
saveProject(config.outputDir, project);
|
|
5088
|
+
writeJson(res, 200, {
|
|
5089
|
+
no: chapter.no,
|
|
5090
|
+
review
|
|
5091
|
+
});
|
|
5092
|
+
return;
|
|
5093
|
+
} catch (error) {
|
|
5094
|
+
writeJson(res, 500, { error: error.message });
|
|
5095
|
+
return;
|
|
4493
5096
|
}
|
|
4494
|
-
} else if (body?.all === true) for (const chapter of project.chapters) {
|
|
4495
|
-
if (chapter.status === "pending" || chapter.status === "generating") continue;
|
|
4496
|
-
const text = readChapterFile(config.outputDir, chapter);
|
|
4497
|
-
if (text === void 0) continue;
|
|
4498
|
-
scanned++;
|
|
4499
|
-
for (const hit of checkSensitiveText(text)) hits.push({
|
|
4500
|
-
chapterNo: chapter.no,
|
|
4501
|
-
word: hit.word,
|
|
4502
|
-
category: hit.category,
|
|
4503
|
-
count: hit.count
|
|
4504
|
-
});
|
|
4505
5097
|
}
|
|
4506
|
-
|
|
4507
|
-
|
|
5098
|
+
const missing = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating" && c.authorReview === void 0);
|
|
5099
|
+
if (missing.length === 0) {
|
|
5100
|
+
writeJson(res, 200, { count: 0 });
|
|
4508
5101
|
return;
|
|
4509
5102
|
}
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
5103
|
+
res.writeHead(200, {
|
|
5104
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
5105
|
+
"cache-control": "no-cache",
|
|
5106
|
+
"referrer-policy": "no-referrer"
|
|
4513
5107
|
});
|
|
5108
|
+
const send = (frame) => {
|
|
5109
|
+
res.write(JSON.stringify(frame) + "\n");
|
|
5110
|
+
};
|
|
5111
|
+
let done = 0;
|
|
5112
|
+
for (const chapter of missing) try {
|
|
5113
|
+
const review = await runOne(chapter);
|
|
5114
|
+
chapter.authorReview = review;
|
|
5115
|
+
if (review.advancedLines !== void 0) autoLinkPlotlines(project, chapter.no, review.advancedLines);
|
|
5116
|
+
done++;
|
|
5117
|
+
saveProject(config.outputDir, project);
|
|
5118
|
+
send({
|
|
5119
|
+
type: "author-review",
|
|
5120
|
+
no: chapter.no,
|
|
5121
|
+
review
|
|
5122
|
+
});
|
|
5123
|
+
} catch (error) {
|
|
5124
|
+
console.warn(`[dsh-novel-forge] author backfill ch.${chapter.no} failed:`, error.message);
|
|
5125
|
+
}
|
|
5126
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5127
|
+
saveProject(config.outputDir, project);
|
|
5128
|
+
send({
|
|
5129
|
+
type: "author-backfill-done",
|
|
5130
|
+
count: done
|
|
5131
|
+
});
|
|
5132
|
+
res.end();
|
|
4514
5133
|
}
|
|
4515
5134
|
},
|
|
5135
|
+
chapterResetRoute,
|
|
4516
5136
|
{
|
|
4517
5137
|
kind: "exact",
|
|
4518
5138
|
path: NOVEL_API.config,
|
|
@@ -4573,7 +5193,8 @@ const Config = z.object({
|
|
|
4573
5193
|
chapterChars: z.number().default(3500),
|
|
4574
5194
|
maxTokens: z.number().default(12e3),
|
|
4575
5195
|
reviewPassScore: z.number().default(70),
|
|
4576
|
-
autoReview: z.boolean().default(true)
|
|
5196
|
+
autoReview: z.boolean().default(true),
|
|
5197
|
+
autoAuthorReview: z.boolean().default(true)
|
|
4577
5198
|
});
|
|
4578
5199
|
/** Schema defaults, re-read for hand-built test contexts. */
|
|
4579
5200
|
const DEFAULT_ANNOUNCE = true;
|
|
@@ -4585,6 +5206,7 @@ const DEFAULT_CHAPTER_CHARS = 3500;
|
|
|
4585
5206
|
const DEFAULT_MAX_TOKENS = 12e3;
|
|
4586
5207
|
const DEFAULT_REVIEW_PASS_SCORE = 70;
|
|
4587
5208
|
const DEFAULT_AUTO_REVIEW = true;
|
|
5209
|
+
const DEFAULT_AUTO_AUTHOR_REVIEW = true;
|
|
4588
5210
|
/** Order of the announcement section within the tool-guidance band. */
|
|
4589
5211
|
const SECTION_ORDER = 160;
|
|
4590
5212
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
@@ -4599,7 +5221,8 @@ function resolveConfig(value) {
|
|
|
4599
5221
|
chapterChars: value?.chapterChars ?? DEFAULT_CHAPTER_CHARS,
|
|
4600
5222
|
maxTokens: value?.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
4601
5223
|
reviewPassScore: value?.reviewPassScore ?? DEFAULT_REVIEW_PASS_SCORE,
|
|
4602
|
-
autoReview: value?.autoReview ?? DEFAULT_AUTO_REVIEW
|
|
5224
|
+
autoReview: value?.autoReview ?? DEFAULT_AUTO_REVIEW,
|
|
5225
|
+
autoAuthorReview: value?.autoAuthorReview ?? DEFAULT_AUTO_AUTHOR_REVIEW
|
|
4603
5226
|
};
|
|
4604
5227
|
}
|
|
4605
5228
|
/**
|
|
@@ -4628,6 +5251,7 @@ function apply(ctx, config) {
|
|
|
4628
5251
|
if (patch.maxTokens !== void 0) next.maxTokens = patch.maxTokens;
|
|
4629
5252
|
if (patch.reviewPassScore !== void 0) next.reviewPassScore = patch.reviewPassScore;
|
|
4630
5253
|
if (patch.autoReview !== void 0) next.autoReview = patch.autoReview;
|
|
5254
|
+
if (patch.autoAuthorReview !== void 0) next.autoAuthorReview = patch.autoAuthorReview;
|
|
4631
5255
|
const settings = ctx.get("settings");
|
|
4632
5256
|
if (settings !== void 0) await settings.update(NOVEL_SETTINGS_NAMESPACE, next);
|
|
4633
5257
|
else current = () => ({
|