@waterwx/dsh-novel-forge 1.4.0 → 1.5.0
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/assets/styles/arcane-thick-3d.webp +0 -0
- package/assets/styles/chinese-ink.webp +0 -0
- package/assets/styles/clay-stopmotion-3d.webp +0 -0
- package/assets/styles/dark-ghibli.webp +0 -0
- package/assets/styles/dark-gothic-3d.webp +0 -0
- package/assets/styles/film-photo.webp +0 -0
- package/assets/styles/guofeng-heavy.webp +0 -0
- package/assets/styles/horror-game-3d.webp +0 -0
- package/assets/styles/japan-cel-dark.webp +0 -0
- package/assets/styles/korean-webtoon.webp +0 -0
- package/assets/styles/low-cyberpunk-3d.webp +0 -0
- package/assets/styles/nextgen-cg.webp +0 -0
- package/assets/styles/noir-film.webp +0 -0
- package/assets/styles/openworld-survival.webp +0 -0
- package/assets/styles/paper-cut-3d.webp +0 -0
- package/assets/styles/pixar-adult-3d.webp +0 -0
- package/assets/styles/pop-marvel-2d.webp +0 -0
- package/assets/styles/shinkai-anime.webp +0 -0
- package/assets/styles/tactical-stealth.webp +0 -0
- package/assets/styles/vaporwave-3d.webp +0 -0
- package/assets/styles/vhs90.webp +0 -0
- package/lib/client.js +2073 -658
- package/lib/client.js.map +1 -1
- package/lib/index.js +919 -64
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +15 -1
- package/lib/types/client/locales.d.ts +4 -2
- package/lib/types/client/panel/MangaWorkspace.d.ts +9 -0
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/client/panel/StoryboardTab.d.ts +13 -0
- package/lib/types/client/panel/StyleGallery.d.ts +1 -0
- package/lib/types/engine.d.ts +46 -4
- package/lib/types/index.d.ts +2 -0
- package/lib/types/protocol.d.ts +194 -3
- package/lib/types/style-library.d.ts +33 -0
- package/package.json +2 -1
- package/src/client/api.ts +36 -1
- package/src/client/locales.ts +7 -3
- package/src/client/panel/ImportModal.tsx +123 -21
- package/src/client/panel/MangaWorkspace.tsx +185 -0
- package/src/client/panel/NovelPanel.tsx +239 -150
- package/src/client/panel/StoryboardTab.tsx +287 -0
- package/src/client/panel/StyleGallery.tsx +109 -0
- package/src/client/panel/panel.module.css +77 -0
- package/src/engine.ts +457 -39
- package/src/index.ts +6 -0
- package/src/protocol.ts +200 -3
- package/src/routes.ts +280 -13
- package/src/style-library.ts +78 -0
package/lib/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-sett
|
|
|
4
4
|
import z from "schemastery";
|
|
5
5
|
import { exec } from "node:child_process";
|
|
6
6
|
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
7
8
|
import { BlockAssembler, ReasoningEffortId, createAssistantMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
8
9
|
import { randomBytes } from "node:crypto";
|
|
9
10
|
import { strFromU8, unzipSync } from "fflate";
|
|
@@ -664,6 +665,195 @@ function getComicStylePrompt(styleId) {
|
|
|
664
665
|
return COMIC_STYLE_PRESETS.find((s) => s.id === styleId)?.prompt ?? "";
|
|
665
666
|
}
|
|
666
667
|
//#endregion
|
|
668
|
+
//#region src/style-library.ts
|
|
669
|
+
/** 内置风格模板(18 个)。 */
|
|
670
|
+
const STYLE_LIBRARY = [
|
|
671
|
+
{
|
|
672
|
+
id: "pixar-adult-3d",
|
|
673
|
+
name: "成人向皮克斯 3D",
|
|
674
|
+
category: "3d",
|
|
675
|
+
traits: "CG 三维渲染,材质细腻,微表情表现力强;需压制 Q 版可爱感",
|
|
676
|
+
keywords: "3D皮克斯动画,成人写实CGI,电影光影,拒绝Q版",
|
|
677
|
+
weight: 1
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
id: "arcane-thick-3d",
|
|
681
|
+
name: "双城之战美漫厚涂 3D",
|
|
682
|
+
category: "3d",
|
|
683
|
+
traits: "粗重轮廓线,高对比硬阴影,材质粗粝,悲剧、压抑、科幻题材适配高",
|
|
684
|
+
keywords: "双城之战美术,3D美漫厚涂,强轮廓光影,暗调粗粝渲染",
|
|
685
|
+
weight: .95
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
id: "nextgen-cg",
|
|
689
|
+
name: "次世代游戏 CG",
|
|
690
|
+
category: "3d",
|
|
691
|
+
traits: "接近真人的三维渲染,照片级布料、皮肤材质,冷峻写实",
|
|
692
|
+
keywords: "次世代游戏CG渲染,超写实3D,真实物理材质,电影布光",
|
|
693
|
+
weight: .9
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
id: "low-cyberpunk-3d",
|
|
697
|
+
name: "底层赛博朋克 3D",
|
|
698
|
+
category: "3d",
|
|
699
|
+
traits: "锈蚀工业、破碎霓虹、屏幕噪点;避开华丽高楼,偏向破败底层",
|
|
700
|
+
keywords: "底层赛博朋克3D,废土工业,破碎霓虹,画面噪点色散",
|
|
701
|
+
weight: .85
|
|
702
|
+
},
|
|
703
|
+
{
|
|
704
|
+
id: "vaporwave-3d",
|
|
705
|
+
name: "复古蒸汽波 3D",
|
|
706
|
+
category: "3d",
|
|
707
|
+
traits: "粉-蓝-紫霓虹轮廓、90 年代复古网格、胶片色散颗粒,颓废疏离",
|
|
708
|
+
keywords: "3D复古蒸汽波,90年代三维渲染,霓虹轮廓光,胶片颗粒",
|
|
709
|
+
weight: .8
|
|
710
|
+
},
|
|
711
|
+
{
|
|
712
|
+
id: "dark-gothic-3d",
|
|
713
|
+
name: "暗黑哥特 3D",
|
|
714
|
+
category: "3d",
|
|
715
|
+
traits: "大面积阴影、冷青色调、衰败破败质感,适合幽暗密闭空间",
|
|
716
|
+
keywords: "3D暗黑哥特,冷青暗光,衰败质感,大面积阴影",
|
|
717
|
+
weight: .8
|
|
718
|
+
},
|
|
719
|
+
{
|
|
720
|
+
id: "clay-stopmotion-3d",
|
|
721
|
+
name: "黏土 / 定格 3D",
|
|
722
|
+
category: "3d",
|
|
723
|
+
traits: "捏塑颗粒肌理,手工定格质感;可做压抑向,不局限可爱风",
|
|
724
|
+
keywords: "3D黏土定格渲染,泥塑肌理,电影打光",
|
|
725
|
+
weight: .7
|
|
726
|
+
},
|
|
727
|
+
{
|
|
728
|
+
id: "openworld-survival",
|
|
729
|
+
name: "开放世界生存游戏风",
|
|
730
|
+
category: "game",
|
|
731
|
+
traits: "场景破败感强,货架、仓库、管线、灰尘、锈蚀明显;人物带一点“生存者”气质;适合探索感、逃亡感、世界观展示",
|
|
732
|
+
keywords: "开放世界生存游戏风格,废土场景,破败仓库,锈蚀金属货架,灰尘粒子,冷色写实光影",
|
|
733
|
+
weight: .8
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
id: "horror-game-3d",
|
|
737
|
+
name: "恐怖游戏 3D",
|
|
738
|
+
category: "game",
|
|
739
|
+
traits: "大面积阴影,荧光灯频闪,空间压抑;适合悬疑、压迫、精神异常段落",
|
|
740
|
+
keywords: "恐怖游戏 3D 风格,昏暗仓库,老旧荧光灯,硬阴影,压迫空间,冷灰蓝调,低饱和",
|
|
741
|
+
weight: .75
|
|
742
|
+
},
|
|
743
|
+
{
|
|
744
|
+
id: "tactical-stealth",
|
|
745
|
+
name: "战术潜行游戏风",
|
|
746
|
+
category: "game",
|
|
747
|
+
traits: "低视角、第三人称镜头感;人物在货架之间移动,光影克制;适合紧张剧情",
|
|
748
|
+
keywords: "战术潜行游戏视角,第三人称镜头,货架通道,冷色暗光,真实3D渲染,紧张压迫氛围",
|
|
749
|
+
weight: .7
|
|
750
|
+
},
|
|
751
|
+
{
|
|
752
|
+
id: "korean-webtoon",
|
|
753
|
+
name: "韩漫条漫风",
|
|
754
|
+
category: "2d",
|
|
755
|
+
traits: "修长人物,低饱和高级灰,氛围感强,都市向漫剧爆款画风",
|
|
756
|
+
keywords: "韩漫2D绘画,精致人物,低饱和灰调,氛围感插画",
|
|
757
|
+
weight: 1
|
|
758
|
+
},
|
|
759
|
+
{
|
|
760
|
+
id: "japan-cel-dark",
|
|
761
|
+
name: "日系赛璐璐动画",
|
|
762
|
+
category: "2d",
|
|
763
|
+
traits: "干净勾线,硬阴影;分明亮版、暗调悲剧版(边缘行者质感)",
|
|
764
|
+
keywords: "日系赛璐璐动画,清晰轮廓线,硬阴影,暗调都市动画",
|
|
765
|
+
weight: .9
|
|
766
|
+
},
|
|
767
|
+
{
|
|
768
|
+
id: "shinkai-anime",
|
|
769
|
+
name: "新海诚动画风",
|
|
770
|
+
category: "2d",
|
|
771
|
+
traits: "极致环境光影、丁达尔、空气中尘埃粒子,擅长雨夜、夜景外景",
|
|
772
|
+
keywords: "新海诚动画渲染,细腻环境光,漂浮尘埃粒子",
|
|
773
|
+
weight: .85
|
|
774
|
+
},
|
|
775
|
+
{
|
|
776
|
+
id: "pop-marvel-2d",
|
|
777
|
+
name: "波普美漫",
|
|
778
|
+
category: "2d",
|
|
779
|
+
traits: "网点半调、强撞色,线条张扬,节奏冲击力强",
|
|
780
|
+
keywords: "平行宇宙波普美漫,网点半调,漫画粗线条",
|
|
781
|
+
weight: .8
|
|
782
|
+
},
|
|
783
|
+
{
|
|
784
|
+
id: "dark-ghibli",
|
|
785
|
+
name: "暗黑吉卜力手绘",
|
|
786
|
+
category: "2d",
|
|
787
|
+
traits: "手绘水彩肌理;强制关闭温暖治愈,做残酷冷调版本",
|
|
788
|
+
keywords: "吉卜力手绘动画,暗黑写实,水彩肌理,拒绝暖光治愈",
|
|
789
|
+
weight: .75
|
|
790
|
+
},
|
|
791
|
+
{
|
|
792
|
+
id: "noir-film",
|
|
793
|
+
name: "黑色电影 Noir",
|
|
794
|
+
category: "film",
|
|
795
|
+
traits: "高反差黑白,只保留少量色彩作为视觉锚点,张力极强",
|
|
796
|
+
keywords: "黑色电影,大部分黑白,局部保留色彩,硬侧光,胶片颗粒",
|
|
797
|
+
stackable: true,
|
|
798
|
+
weight: 1
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
id: "vhs90",
|
|
802
|
+
name: "VHS 90 录像带复古",
|
|
803
|
+
category: "film",
|
|
804
|
+
traits: "扫描线、色彩偏移、磁带噪点,模拟老旧档案录像",
|
|
805
|
+
keywords: "VHS录像带质感,扫描线,色彩偏移,磁带噪点",
|
|
806
|
+
stackable: true,
|
|
807
|
+
weight: .9
|
|
808
|
+
},
|
|
809
|
+
{
|
|
810
|
+
id: "film-photo",
|
|
811
|
+
name: "胶片电影写真",
|
|
812
|
+
category: "film",
|
|
813
|
+
traits: "电影机拍摄质感,轻微颗粒,景深虚化,无夸张特效",
|
|
814
|
+
keywords: "电影写真质感,35mm胶片,胶片颗粒,浅景深",
|
|
815
|
+
stackable: true,
|
|
816
|
+
weight: .85
|
|
817
|
+
},
|
|
818
|
+
{
|
|
819
|
+
id: "chinese-ink",
|
|
820
|
+
name: "新中式水墨动画",
|
|
821
|
+
category: "craft",
|
|
822
|
+
traits: "水墨晕染,留白写意",
|
|
823
|
+
keywords: "水墨动画渲染,写意晕染,国风2D",
|
|
824
|
+
weight: 1
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
id: "paper-cut-3d",
|
|
828
|
+
name: "纸雕定格",
|
|
829
|
+
category: "craft",
|
|
830
|
+
traits: "多层纸张镂空叠层,光影穿透纸面",
|
|
831
|
+
keywords: "立体纸雕定格,纸张肌理,层叠镂空光影",
|
|
832
|
+
weight: .9
|
|
833
|
+
},
|
|
834
|
+
{
|
|
835
|
+
id: "guofeng-heavy",
|
|
836
|
+
name: "工笔重彩国风",
|
|
837
|
+
category: "craft",
|
|
838
|
+
traits: "精细线条,矿物颜料质感,古典华美",
|
|
839
|
+
keywords: "工笔重彩,国风2D绘画,矿物颜料质感,精细勾线",
|
|
840
|
+
weight: .8
|
|
841
|
+
}
|
|
842
|
+
];
|
|
843
|
+
/** 取单个风格(未找到返回 undefined)。 */
|
|
844
|
+
function findStyle(id) {
|
|
845
|
+
return STYLE_LIBRARY.find((s) => s.id === id);
|
|
846
|
+
}
|
|
847
|
+
/** 按基底 + 滤镜拼接风格关键词(角色图/分镜/视频提示词共用;缺省回退 3D 动漫)。 */
|
|
848
|
+
function styleKeywords(styleId, filterId) {
|
|
849
|
+
const parts = [];
|
|
850
|
+
const base = styleId !== void 0 ? findStyle(styleId) : void 0;
|
|
851
|
+
if (base !== void 0) parts.push(base.keywords);
|
|
852
|
+
const filter = filterId !== void 0 ? findStyle(filterId) : void 0;
|
|
853
|
+
if (filter !== void 0) parts.push(filter.keywords);
|
|
854
|
+
return parts.join(",") || "3D动漫,超精细建模,电影光影";
|
|
855
|
+
}
|
|
856
|
+
//#endregion
|
|
667
857
|
//#region src/engine.ts
|
|
668
858
|
/**
|
|
669
859
|
* Novel engine — the host half's core: LLM-driven story-bible extraction,
|
|
@@ -690,6 +880,25 @@ const COMPLIANCE_REDLINES = [
|
|
|
690
880
|
];
|
|
691
881
|
/** Project state file name inside the output dir. */
|
|
692
882
|
const PROJECT_FILE = "novel-project.json";
|
|
883
|
+
/** 智能解码文本文件:UTF-8 BOM / UTF-16 BOM / UTF-8(严格校验)/ GB18030 回退。 */
|
|
884
|
+
function decodeTextSmart(buf) {
|
|
885
|
+
if (buf.length >= 3 && buf[0] === 239 && buf[1] === 187 && buf[2] === 191) return buf.subarray(3).toString("utf8");
|
|
886
|
+
if (buf.length >= 2 && buf[0] === 255 && buf[1] === 254) return buf.subarray(2).toString("utf16le");
|
|
887
|
+
const utf8 = buf.toString("utf8");
|
|
888
|
+
const bad = countReplacementChars(utf8);
|
|
889
|
+
if (bad === 0) return utf8;
|
|
890
|
+
try {
|
|
891
|
+
const gbk = new TextDecoder("gb18030").decode(buf);
|
|
892
|
+
if (countReplacementChars(gbk) < bad) return gbk;
|
|
893
|
+
} catch {}
|
|
894
|
+
return utf8;
|
|
895
|
+
}
|
|
896
|
+
/** 统计替换字符 U+FFFD 数量(UTF-8 乱码检测)。 */
|
|
897
|
+
function countReplacementChars(s) {
|
|
898
|
+
let n = 0;
|
|
899
|
+
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 65533) n++;
|
|
900
|
+
return n;
|
|
901
|
+
}
|
|
693
902
|
/** Sanitize a file name: keep CJK/alphanumerics/space/dash/underscore. */
|
|
694
903
|
function safeFileName(name) {
|
|
695
904
|
return name.replace(/[\\/:*?"<>|]/g, "").replace(/\s+/g, " ").trim().slice(0, 60);
|
|
@@ -1167,7 +1376,7 @@ async function complete(ctx, config, options) {
|
|
|
1167
1376
|
system: options.system,
|
|
1168
1377
|
maxTokens: options.maxTokens ?? config.maxTokens,
|
|
1169
1378
|
temperature: options.temperature ?? .7,
|
|
1170
|
-
reasoningEffort: ReasoningEffortId(config.reasoningEffort ?? "off")
|
|
1379
|
+
reasoningEffort: ReasoningEffortId(options.reasoning ?? config.reasoningEffort ?? "off")
|
|
1171
1380
|
};
|
|
1172
1381
|
const assembler = new BlockAssembler();
|
|
1173
1382
|
for await (const chunk of ctx.llm.stream(request)) assembler.push(chunk);
|
|
@@ -1293,7 +1502,8 @@ async function extractBible(ctx, config, outline, project) {
|
|
|
1293
1502
|
system: bibleSystemPrompt(),
|
|
1294
1503
|
user,
|
|
1295
1504
|
temperature: .4,
|
|
1296
|
-
maxTokens: Math.max(config.maxTokens, 16e3)
|
|
1505
|
+
maxTokens: Math.max(config.maxTokens, 16e3),
|
|
1506
|
+
reasoning: config.analysisReasoning ?? "low"
|
|
1297
1507
|
}));
|
|
1298
1508
|
const strArray = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string" && v.trim() !== "") : [];
|
|
1299
1509
|
const characters = Array.isArray(raw.characters) ? raw.characters.filter((v) => typeof v === "object" && v !== null).map((entry) => ({
|
|
@@ -1756,7 +1966,8 @@ async function suggestPlotlines(ctx, config, project) {
|
|
|
1756
1966
|
"只输出 JSON 数组。"
|
|
1757
1967
|
].join("\n\n"),
|
|
1758
1968
|
temperature: .6,
|
|
1759
|
-
maxTokens: Math.max(config.maxTokens, 4e3)
|
|
1969
|
+
maxTokens: Math.max(config.maxTokens, 4e3),
|
|
1970
|
+
reasoning: config.analysisReasoning ?? "low"
|
|
1760
1971
|
}));
|
|
1761
1972
|
const lines = [];
|
|
1762
1973
|
const kinds = /* @__PURE__ */ new Set([
|
|
@@ -1803,7 +2014,8 @@ async function refreshPlotlineProgress(ctx, config, project, line) {
|
|
|
1803
2014
|
"只输出 JSON 对象。"
|
|
1804
2015
|
].join("\n\n"),
|
|
1805
2016
|
temperature: .3,
|
|
1806
|
-
maxTokens: Math.max(config.maxTokens, 2e3)
|
|
2017
|
+
maxTokens: Math.max(config.maxTokens, 2e3),
|
|
2018
|
+
reasoning: config.analysisReasoning ?? "low"
|
|
1807
2019
|
}));
|
|
1808
2020
|
return typeof raw.progress === "string" ? raw.progress.trim().slice(0, 300) : "";
|
|
1809
2021
|
}
|
|
@@ -1813,6 +2025,8 @@ async function extractRoles(ctx, config, project) {
|
|
|
1813
2025
|
"你是一位网文角色库管理员。请根据本书的大纲、设定、编年录与章节摘要,提炼完整的角色库。",
|
|
1814
2026
|
"覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如\"矮胖姑娘\")用其身份简称收录并标注。",
|
|
1815
2027
|
"数量控制:最多输出 10 个角色,宁缺毋滥;路人级一次带过的不要收录。",
|
|
2028
|
+
"重要:正文中若有明确的主角与主要反派,必须分别以 protagonist / antagonist 收录,禁止遗漏;反派确实未出场时才可省略。",
|
|
2029
|
+
"输出优先级:主角(protagonist)与主要反派(antagonist)必须优先输出并完整刻画,其次女主/重要配角;判断不出名字时用正文中的身份称呼。",
|
|
1816
2030
|
"每个角色输出:",
|
|
1817
2031
|
"1. name:角色名(或身份简称)。主角/有名配角必须用正文中实际出现的人名(如「沈放」),禁止用「主角(38岁超市理货员)」这类把身份塞进名字的占位名;正文确实没点名时才可用身份简称(如「富商」「灰衣老人」)。",
|
|
1818
2032
|
"2. roleLabel:定位——protagonist=主角;female_lead=女主(唯一知己/感情线核心,无后宫前提下只此一位);female_support=重要女配;support=普通配角;antagonist=反派;extra=路人/背景。",
|
|
@@ -1830,28 +2044,50 @@ async function extractRoles(ctx, config, project) {
|
|
|
1830
2044
|
].join("\n");
|
|
1831
2045
|
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
|
|
1832
2046
|
const existingRoles = project.roles ?? [];
|
|
2047
|
+
const sampleChapters = written.length <= 6 ? written : (() => {
|
|
2048
|
+
const picked = /* @__PURE__ */ new Set();
|
|
2049
|
+
for (let i = 0; i < 3 && i < written.length; i++) picked.add(i);
|
|
2050
|
+
const step = Math.max(1, Math.floor(written.length / 4));
|
|
2051
|
+
for (let i = step; i < written.length - 2; i += step) picked.add(i);
|
|
2052
|
+
for (let i = Math.max(0, written.length - 2); i < written.length; i++) picked.add(i);
|
|
2053
|
+
return [...picked].sort((a, b) => a - b).map((i) => written[i]);
|
|
2054
|
+
})();
|
|
1833
2055
|
const excerptParts = [];
|
|
1834
|
-
for (const chapter of
|
|
2056
|
+
for (const chapter of sampleChapters) {
|
|
1835
2057
|
const body = readChapterFile(config.outputDir, chapter);
|
|
1836
2058
|
if (body === void 0) continue;
|
|
1837
2059
|
const text = body.replace(/^#.*$/gm, "").trim();
|
|
1838
|
-
if (text.length > 0) excerptParts.push(`第${chapter.no}章《${chapter.title}》\n${text.slice(0,
|
|
2060
|
+
if (text.length > 0) excerptParts.push(`第${chapter.no}章《${chapter.title}》\n${text.slice(0, 1500)}`);
|
|
1839
2061
|
}
|
|
1840
|
-
const
|
|
2062
|
+
const user = [
|
|
2063
|
+
`书名:《${project.bookName}》`,
|
|
2064
|
+
existingRoles.length > 0 ? `已收录角色(跳过,不要输出):${existingRoles.map((r) => r.name).join("、")}` : "",
|
|
2065
|
+
`大纲(节选前 3000 字):\n${project.outline.slice(0, 3e3)}`,
|
|
2066
|
+
excerptParts.length > 0 ? `已写正文摘录(角色姓名/身份/关系以正文为准):\n${excerptParts.join("\n\n")}` : "",
|
|
2067
|
+
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")}` : "",
|
|
2068
|
+
(project.facts ?? []).length > 0 ? `编年录(最近 30 条):\n${(project.facts ?? []).slice(-30).map((f) => `[第${f.chapterNo}章] ${f.text.slice(0, 60)}`).join("\n")}` : "",
|
|
2069
|
+
written.length > 0 ? `已写章节标题(${written.length} 章):\n${written.map((c) => `第${c.no}章《${c.title}》`).join("、")}` : "",
|
|
2070
|
+
"只输出 JSON 数组。"
|
|
2071
|
+
].join("\n\n");
|
|
2072
|
+
let text = await complete(ctx, config, {
|
|
1841
2073
|
system,
|
|
1842
|
-
user
|
|
1843
|
-
`书名:《${project.bookName}》`,
|
|
1844
|
-
existingRoles.length > 0 ? `已收录角色(跳过,不要输出):${existingRoles.map((r) => r.name).join("、")}` : "",
|
|
1845
|
-
`大纲(节选前 3000 字):\n${project.outline.slice(0, 3e3)}`,
|
|
1846
|
-
excerptParts.length > 0 ? `已写正文摘录(角色姓名/身份/关系以正文为准):\n${excerptParts.join("\n\n")}` : "",
|
|
1847
|
-
project.bible !== void 0 && project.bible.characters.length > 0 ? `已有角色卡(补充信息):\n${project.bible.characters.map((c) => `- ${c.name}(${c.role}):${c.traits.join("、")}${c.goals !== "" ? `;目标:${c.goals}` : ""}`).join("\n")}` : "",
|
|
1848
|
-
(project.facts ?? []).length > 0 ? `编年录(最近 60 条):\n${(project.facts ?? []).slice(-60).map((f) => `[第${f.chapterNo}章] ${f.text.slice(0, 80)}`).join("\n")}` : "",
|
|
1849
|
-
written.length > 0 ? `已写章节标题(${written.length} 章):\n${written.map((c) => `第${c.no}章《${c.title}》`).join("、")}` : "",
|
|
1850
|
-
"只输出 JSON 数组。"
|
|
1851
|
-
].join("\n\n"),
|
|
2074
|
+
user,
|
|
1852
2075
|
temperature: .3,
|
|
1853
|
-
maxTokens: Math.max(config.maxTokens, 24e3)
|
|
1854
|
-
|
|
2076
|
+
maxTokens: Math.max(config.maxTokens, 24e3),
|
|
2077
|
+
reasoning: config.analysisReasoning ?? "low"
|
|
2078
|
+
});
|
|
2079
|
+
let raw = parseJsonArray(text);
|
|
2080
|
+
const hasProtagonist = raw.some((e) => typeof e === "object" && e !== null && e.roleLabel === "protagonist");
|
|
2081
|
+
if (raw.length === 0 || !hasProtagonist) {
|
|
2082
|
+
text = await complete(ctx, config, {
|
|
2083
|
+
system: system + (raw.length === 0 ? "\n上一次输出为空或格式不正确。请直接输出 JSON 数组(即使只有一个角色也要输出),不要输出其他文字。" : "\n上一次输出中缺少主角(roleLabel 为 protagonist 的角色)。请重新输出完整 JSON 数组,务必包含正文中的主角。"),
|
|
2084
|
+
user,
|
|
2085
|
+
temperature: .3,
|
|
2086
|
+
maxTokens: Math.max(config.maxTokens, 24e3),
|
|
2087
|
+
reasoning: config.analysisReasoning ?? "low"
|
|
2088
|
+
});
|
|
2089
|
+
raw = parseJsonArray(text);
|
|
2090
|
+
}
|
|
1855
2091
|
const labels = /* @__PURE__ */ new Set([
|
|
1856
2092
|
"protagonist",
|
|
1857
2093
|
"female_lead",
|
|
@@ -1921,7 +2157,8 @@ async function extractScenes(ctx, config, project) {
|
|
|
1921
2157
|
"只输出 JSON 数组。"
|
|
1922
2158
|
].filter((s) => s !== "").join("\n"),
|
|
1923
2159
|
temperature: .4,
|
|
1924
|
-
maxTokens: Math.max(config.maxTokens, 12e3)
|
|
2160
|
+
maxTokens: Math.max(config.maxTokens, 12e3),
|
|
2161
|
+
reasoning: config.analysisReasoning ?? "low"
|
|
1925
2162
|
}));
|
|
1926
2163
|
const strArr = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.trim() !== "") : [];
|
|
1927
2164
|
const str = (v) => typeof v === "string" ? v.trim() : "";
|
|
@@ -1948,10 +2185,13 @@ async function extractScenes(ctx, config, project) {
|
|
|
1948
2185
|
}
|
|
1949
2186
|
return scenes;
|
|
1950
2187
|
}
|
|
1951
|
-
/** 从 txt/md
|
|
1952
|
-
function
|
|
1953
|
-
const lines =
|
|
2188
|
+
/** 从 txt/md 全本文本拆章(纯逻辑,不落盘):识别章节头、剥离重复标题、去重、排序并统一重新编号。 */
|
|
2189
|
+
function splitBookText(raw) {
|
|
2190
|
+
const lines = raw.split(/\r?\n/);
|
|
1954
2191
|
const chapterHead = /^\s*(?:#\s*)?第\s*(\d+|[一二三四五六七八九十百千]+)\s*[章回节卷]\s*(.*?)\s*$/;
|
|
2192
|
+
const cnOnlyHead = /^\s*(?:#\s*)?([一二三四五六七八九十百千万零〇]{1,6})(?:[、..::]\s*(.*?)\s*)?$/;
|
|
2193
|
+
const specialHead = /^\s*(?:#\s*)?(序章|序言|楔子|引子|前言|开篇|尾声|终章|大结局|番外(?:篇|章)?|后记|完结感言|上架感言|作者的话)\s*[::、.\s]*(.*?)\s*$/;
|
|
2194
|
+
const enHead = /^\s*(?:#\s*)?chapter\s+(\d+)\s*[.::、\-\s]*(.*?)\s*$/i;
|
|
1955
2195
|
const cnNum = {
|
|
1956
2196
|
一: 1,
|
|
1957
2197
|
二: 2,
|
|
@@ -1982,33 +2222,107 @@ function importBookText(filePath, outputDir) {
|
|
|
1982
2222
|
};
|
|
1983
2223
|
const chunks = [];
|
|
1984
2224
|
let current = null;
|
|
2225
|
+
let specialSeq = 0;
|
|
1985
2226
|
for (const line of lines) {
|
|
1986
2227
|
const m = chapterHead.exec(line);
|
|
1987
2228
|
if (m !== null) {
|
|
1988
|
-
if (current !== null) chunks.push(current);
|
|
1989
2229
|
const no = parseCn(m[1]);
|
|
1990
|
-
|
|
2230
|
+
if (current !== null) chunks.push(current);
|
|
2231
|
+
current = {
|
|
2232
|
+
sortKey: no > 0 ? no : chunks.length + 1,
|
|
2233
|
+
title: (m[2] ?? "").trim(),
|
|
2234
|
+
body: []
|
|
2235
|
+
};
|
|
2236
|
+
continue;
|
|
2237
|
+
}
|
|
2238
|
+
const mc = cnOnlyHead.exec(line);
|
|
2239
|
+
if (mc !== null) {
|
|
2240
|
+
const no = parseCn(mc[1]);
|
|
2241
|
+
if (current !== null) chunks.push(current);
|
|
2242
|
+
const t = (mc[2] ?? "").trim();
|
|
2243
|
+
current = {
|
|
2244
|
+
sortKey: no > 0 ? no : chunks.length + 1,
|
|
2245
|
+
title: t !== "" ? t : "第" + mc[1] + "章",
|
|
2246
|
+
body: []
|
|
2247
|
+
};
|
|
2248
|
+
continue;
|
|
2249
|
+
}
|
|
2250
|
+
const ms = specialHead.exec(line);
|
|
2251
|
+
if (ms !== null) {
|
|
2252
|
+
specialSeq++;
|
|
2253
|
+
const name = ms[1];
|
|
2254
|
+
const front = /序章|序言|楔子|引子|前言|开篇/.test(name);
|
|
2255
|
+
const title = (ms[2] ?? "").trim();
|
|
2256
|
+
if (current !== null) chunks.push(current);
|
|
1991
2257
|
current = {
|
|
1992
|
-
|
|
1993
|
-
title,
|
|
2258
|
+
sortKey: front ? specialSeq * .001 : 999999 + specialSeq * .001,
|
|
2259
|
+
title: title !== "" ? title : name,
|
|
1994
2260
|
body: []
|
|
1995
2261
|
};
|
|
1996
|
-
|
|
2262
|
+
continue;
|
|
2263
|
+
}
|
|
2264
|
+
const me = enHead.exec(line);
|
|
2265
|
+
if (me !== null) {
|
|
2266
|
+
if (current !== null) chunks.push(current);
|
|
2267
|
+
current = {
|
|
2268
|
+
sortKey: Number(me[1]),
|
|
2269
|
+
title: (me[2] ?? "").trim(),
|
|
2270
|
+
body: []
|
|
2271
|
+
};
|
|
2272
|
+
continue;
|
|
2273
|
+
}
|
|
2274
|
+
if (current !== null) current.body.push(line);
|
|
1997
2275
|
}
|
|
1998
2276
|
if (current !== null) chunks.push(current);
|
|
1999
|
-
if (chunks.length === 0) throw new Error("未识别到章节(需要\"第X章\"
|
|
2000
|
-
const
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2277
|
+
if (chunks.length === 0) throw new Error("未识别到章节(需要\"第X章\"格式、中文数字章节(一、二、三…)、序章/楔子/尾声等章节标题、英文 Chapter N,或带 # 的章节标题)");
|
|
2278
|
+
const stripHead = (b) => {
|
|
2279
|
+
let i = 0;
|
|
2280
|
+
while (i < b.length) {
|
|
2281
|
+
const t = b[i].trim();
|
|
2282
|
+
if (t === "" || chapterHead.test(t) || cnOnlyHead.test(t) || specialHead.test(t) || enHead.test(t)) i++;
|
|
2283
|
+
else break;
|
|
2284
|
+
}
|
|
2285
|
+
return b.slice(i).join("\n").trim();
|
|
2286
|
+
};
|
|
2287
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
2288
|
+
for (const c of chunks) {
|
|
2289
|
+
const len = stripHead(c.body).length;
|
|
2290
|
+
const ex = byKey.get(c.sortKey);
|
|
2291
|
+
if (ex === void 0 || len > stripHead(ex.body).length) byKey.set(c.sortKey, c);
|
|
2292
|
+
}
|
|
2293
|
+
return [...byKey.values()].sort((a, b) => a.sortKey - b.sortKey).map((c, i) => ({
|
|
2294
|
+
no: i + 1,
|
|
2295
|
+
title: c.title,
|
|
2296
|
+
body: stripHead(c.body)
|
|
2297
|
+
}));
|
|
2298
|
+
}
|
|
2299
|
+
/** 拆章预览(不落盘):章节编号/标题/字数 + 跳过清单。 */
|
|
2300
|
+
function previewBookText(raw) {
|
|
2301
|
+
const chapters = [];
|
|
2302
|
+
const skipped = [];
|
|
2303
|
+
for (const c of splitBookText(raw)) {
|
|
2304
|
+
if (c.body.length < 50) {
|
|
2305
|
+
skipped.push("第" + c.no + "章" + (c.title !== "" ? "「" + c.title + "」" : "") + "(内容过短,已跳过)");
|
|
2306
|
+
continue;
|
|
2307
|
+
}
|
|
2308
|
+
chapters.push({
|
|
2309
|
+
no: c.no,
|
|
2310
|
+
title: c.title !== "" ? c.title : "第" + c.no + "章",
|
|
2311
|
+
chars: c.body.length
|
|
2312
|
+
});
|
|
2313
|
+
}
|
|
2314
|
+
return {
|
|
2315
|
+
chapters,
|
|
2316
|
+
skipped
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
/** 从全本文本导入(浏览器上传 / 服务器文件共用):建项目、写章节文件、保存。 */
|
|
2320
|
+
function importBookTextFromText(raw, outputDir, bookName) {
|
|
2007
2321
|
const project = createProject(bookName);
|
|
2008
2322
|
mkdirSync(outputDir, { recursive: true });
|
|
2009
2323
|
const skipped = [];
|
|
2010
|
-
for (const c of
|
|
2011
|
-
const body = c.body
|
|
2324
|
+
for (const c of splitBookText(raw)) {
|
|
2325
|
+
const body = c.body;
|
|
2012
2326
|
if (body.length < 50) {
|
|
2013
2327
|
skipped.push("第" + c.no + "章" + (c.title !== "" ? "「" + c.title + "」" : "") + "(内容过短,已跳过)");
|
|
2014
2328
|
continue;
|
|
@@ -2036,11 +2350,15 @@ function importBookText(filePath, outputDir) {
|
|
|
2036
2350
|
skipped
|
|
2037
2351
|
};
|
|
2038
2352
|
}
|
|
2353
|
+
/** 从 txt/md 全本文件导入:编码自适应读取后拆章建项目,status=written(待审稿)。 */
|
|
2354
|
+
function importBookText(filePath, outputDir) {
|
|
2355
|
+
return importBookTextFromText(decodeTextSmart(readFileSync(filePath)), outputDir, basename(filePath, extname(filePath)).slice(0, 40) || "导入小说");
|
|
2356
|
+
}
|
|
2039
2357
|
/**
|
|
2040
2358
|
* 为单个角色提炼「动漫形象描述词」:扫描该角色出场的已写章节正文,
|
|
2041
2359
|
* 截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
|
|
2042
2360
|
*/
|
|
2043
|
-
async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
|
|
2361
|
+
async function extractRoleVisual(ctx, config, project, outputDir, roleName, styleId, filterId) {
|
|
2044
2362
|
const role = (project.roles ?? []).find((r) => r.name === roleName);
|
|
2045
2363
|
if (role === void 0) throw new Error(`角色「${roleName}」不在角色库中`);
|
|
2046
2364
|
const appearanceHints = /(发|眉|眼|眸|脸|肤|唇|身材|身高|衣|袍|裙|衫|靴|腰带|气质|模样|长相|容貌|披|束|扎|戴|佩|挂|绣|青|白|黑|红|蓝|紫|灰|银|金|少年|青年|少女|汉子|老者|中年|纤细|挺拔|瘦削|壮实|清秀|俊朗|英气|阴鸷|慈眉)/;
|
|
@@ -2131,16 +2449,18 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
|
|
|
2131
2449
|
if (excerpts.length >= 12) break;
|
|
2132
2450
|
}
|
|
2133
2451
|
if (excerpts.length === 0) throw new Error(`正文中未找到「${roleName}」的出场描写(仅搜索最近 60 章),请确认角色名与正文一致`);
|
|
2452
|
+
const rules = project.visualRules ?? [];
|
|
2134
2453
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
2135
2454
|
system: [
|
|
2136
2455
|
"你是一位动漫角色设定师与 AI 绘图提示词工程师。根据网文正文中该角色的实际外貌描写,输出「形象锚点」与「四类生图提示词」——一次完成,用于 AI 绘图(NovelAI / Stable Diffusion / Midjourney / 豆包等)生成一致的角色立绘。",
|
|
2456
|
+
"【当前视觉风格】(portrait 立绘的「风格」字段必须原样使用,sheet/expressions/details 同样内嵌):" + styleKeywords(styleId, filterId),
|
|
2137
2457
|
"硬性要求(依据优先):",
|
|
2138
2458
|
"1. 发色/发型/瞳色/服装/气质/标志物必须来自提供的正文段落,不得凭空发明。",
|
|
2139
2459
|
"2. 正文未明确写到的项目(如瞳色没写、身高没写),用「未定」标注或直接不写,不要编造数值。",
|
|
2140
2460
|
"3. 服装优先取正文明确出现的(颜色+款式),多次出现取最常穿的组合;服装按分件组织(上身/下身/鞋/配饰)。",
|
|
2141
2461
|
"4. 标志物(标签/印记/饰品)必须出现在每段提示词中——它们是一致性的命根子。",
|
|
2142
2462
|
"【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):",
|
|
2143
|
-
...
|
|
2463
|
+
...rules.map((r) => "- " + r),
|
|
2144
2464
|
"输出六部分:",
|
|
2145
2465
|
"- zh:中文形象锚点,一段连贯文字(60-150 字):发色发型、瞳色、脸型气质、服装(颜色款式)、身材、标志性物件。",
|
|
2146
2466
|
"- en:英文形象锚点,booru 风格、逗号分隔、小写,30-50 个标签:含性别(1boy/1girl)、发色、发型、瞳色、服装、气质、标志物。不要输出负面提示词。",
|
|
@@ -2166,7 +2486,8 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
|
|
|
2166
2486
|
"只输出 JSON 对象。"
|
|
2167
2487
|
].join("\n\n"),
|
|
2168
2488
|
temperature: .4,
|
|
2169
|
-
maxTokens: Math.max(config.maxTokens, 12e3)
|
|
2489
|
+
maxTokens: Math.max(config.maxTokens, 12e3),
|
|
2490
|
+
reasoning: config.analysisReasoning ?? "low"
|
|
2170
2491
|
}));
|
|
2171
2492
|
const zh = typeof raw.zh === "string" ? raw.zh.trim().slice(0, 500) : "";
|
|
2172
2493
|
const en = typeof raw.en === "string" ? raw.en.trim().slice(0, 1500) : "";
|
|
@@ -2212,20 +2533,22 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
|
|
|
2212
2533
|
* 角色四类生图提示词精修包(promptKit):以锚点 + 表情清单 + 视觉规则为输入,
|
|
2213
2534
|
* LLM 产出 立绘/四视图/表情×N/细节 四套 zh+en 提示词(比前端拼装更精炼)。
|
|
2214
2535
|
*/
|
|
2215
|
-
async function generateRolePromptKit(ctx, config, project, roleName) {
|
|
2536
|
+
async function generateRolePromptKit(ctx, config, project, roleName, styleId, filterId) {
|
|
2216
2537
|
const role = (project.roles ?? []).find((r) => r.name === roleName);
|
|
2217
2538
|
if (role === void 0) throw new Error(`角色「${roleName}」不在角色库中`);
|
|
2218
2539
|
if (role.imagePrompt === void 0) throw new Error(`角色「${roleName}」还没有形象锚点,请先生成锚点`);
|
|
2540
|
+
const rules = project.visualRules ?? [];
|
|
2219
2541
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
2220
2542
|
system: [
|
|
2221
2543
|
"你是一位 AI 绘图提示词工程师。基于给定角色的形象锚点、表情清单与本书视觉规则,输出四类生图提示词(每类 zh+en 各一段)。",
|
|
2544
|
+
"【当前视觉风格】(每类提示词必须内嵌):" + styleKeywords(styleId, filterId),
|
|
2222
2545
|
"四类:",
|
|
2223
2546
|
"1. portrait 立绘:正面站立全身人像,3D动漫超精细建模,纯白纯色背景;按字段流组织:身份(男子,角色名,外表年龄)→ 发型发色 → 胡茬 → 眼眸 → 面部 → 气质 → 上身服装分件(含磨损)→ 下身 → 鞋 → 标志物(含细节)→ 收尾(角色设计稿,细节完整展示,无多余杂物,全身完整无裁切)。",
|
|
2224
2547
|
"2. sheet 四视图:同一角色的 正面/左侧面/右侧面/背面 四视图设定表(character sheet),纯白背景,四个视角分别描述。",
|
|
2225
2548
|
"3. expressions 表情:每个表情一段,脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,只换情绪表达(眼神/嘴角/眉),皮肤纹理细节完整,无多余杂物。",
|
|
2226
2549
|
"4. details 细节:多组局部细节集合参考图(一张图多个局部框),纯白背景;把该角色全部标志物逐项列出,每项一句特写描述;细节清晰锐利,角色细节参考稿,无多余杂物。",
|
|
2227
2550
|
"【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):",
|
|
2228
|
-
...
|
|
2551
|
+
...rules.map((r) => "- " + r),
|
|
2229
2552
|
"zh 要求:连贯中文,写实电影感,60-150 字/段;en 要求:booru 风格逗号分隔标签,30-50 个/段。",
|
|
2230
2553
|
"输出必须是合法 JSON 对象:{\"portrait\": {\"zh\": \"...\", \"en\": \"...\"}, \"sheet\": {\"zh\": \"...\", \"en\": \"...\"}, \"expressions\": [{\"name\": \"疲惫\", \"zh\": \"...\", \"en\": \"...\"}], \"details\": {\"zh\": \"...\", \"en\": \"...\"}}",
|
|
2231
2554
|
"重要:所有字符串值内部不得包含换行符;直接输出 JSON 结果本身。"
|
|
@@ -2239,7 +2562,8 @@ async function generateRolePromptKit(ctx, config, project, roleName) {
|
|
|
2239
2562
|
"只输出 JSON 对象。"
|
|
2240
2563
|
].join("\n\n"),
|
|
2241
2564
|
temperature: .4,
|
|
2242
|
-
maxTokens: Math.max(config.maxTokens, 12e3)
|
|
2565
|
+
maxTokens: Math.max(config.maxTokens, 12e3),
|
|
2566
|
+
reasoning: config.analysisReasoning ?? "low"
|
|
2243
2567
|
}));
|
|
2244
2568
|
const str = (v) => typeof v === "string" ? v.trim() : "";
|
|
2245
2569
|
const pair = (v) => {
|
|
@@ -2284,7 +2608,8 @@ async function extractVisualRules(ctx, config, project) {
|
|
|
2284
2608
|
"只输出 JSON 数组。"
|
|
2285
2609
|
].filter((s) => s !== "").join("\n\n"),
|
|
2286
2610
|
temperature: .3,
|
|
2287
|
-
maxTokens: Math.max(config.maxTokens, 4e3)
|
|
2611
|
+
maxTokens: Math.max(config.maxTokens, 4e3),
|
|
2612
|
+
reasoning: config.analysisReasoning ?? "low"
|
|
2288
2613
|
})).map((r) => typeof r === "string" ? r.trim().slice(0, 80) : "").filter((r) => r !== "").slice(0, 8);
|
|
2289
2614
|
}
|
|
2290
2615
|
/**
|
|
@@ -2320,7 +2645,8 @@ async function suggestOutlines(ctx, config, idea, count = 3, exclude = []) {
|
|
|
2320
2645
|
"只输出 JSON 数组。"
|
|
2321
2646
|
].join("\n\n"),
|
|
2322
2647
|
temperature: .85,
|
|
2323
|
-
maxTokens: Math.max(config.maxTokens, 12e3)
|
|
2648
|
+
maxTokens: Math.max(config.maxTokens, 12e3),
|
|
2649
|
+
reasoning: config.analysisReasoning ?? "low"
|
|
2324
2650
|
}));
|
|
2325
2651
|
const candidates = [];
|
|
2326
2652
|
for (const entry of parsed) {
|
|
@@ -2390,7 +2716,8 @@ async function breakdownBook(ctx, config, project, outputDir, scope = "recent",
|
|
|
2390
2716
|
system: noteSystem,
|
|
2391
2717
|
user: noteUser,
|
|
2392
2718
|
temperature: .2,
|
|
2393
|
-
maxTokens: Math.max(config.maxTokens, 3e3)
|
|
2719
|
+
maxTokens: Math.max(config.maxTokens, 3e3),
|
|
2720
|
+
reasoning: config.analysisReasoning ?? "low"
|
|
2394
2721
|
}));
|
|
2395
2722
|
const pick = (k) => Array.isArray(raw[k]) ? raw[k].filter((x) => typeof x === "string" && x.trim() !== "").map((x) => x.trim().slice(0, 120)).slice(0, 4) : [];
|
|
2396
2723
|
notes.push(`【第${ch.no}章《${ch.title}》】\n摘要:${typeof raw.summary === "string" ? raw.summary.slice(0, 200) : ""}\n剧情:${pick("plotPoints").join(";")}\n人物:${pick("characters").join(";")}\n设定:${pick("worldbuilding").join(";")}\n写法:${pick("styleTechniques").join(";")}\n卖点:${pick("marketHighlights").join(";")}\n短板信号:${pick("weaknessSignals").join(";") || "(无明显短板信号)"}`);
|
|
@@ -2462,7 +2789,8 @@ async function breakdownBook(ctx, config, project, outputDir, scope = "recent",
|
|
|
2462
2789
|
system: sec.system,
|
|
2463
2790
|
user: `分析范围:${selected.length} 章(${scope === "all" ? "全书" : scope === "recent" ? "最近 20 章" : "指定卷"})。\n\n章节笔记:\n${notesText}`,
|
|
2464
2791
|
temperature: .3,
|
|
2465
|
-
maxTokens: Math.max(config.maxTokens, 6e3)
|
|
2792
|
+
maxTokens: Math.max(config.maxTokens, 6e3),
|
|
2793
|
+
reasoning: config.analysisReasoning ?? "low"
|
|
2466
2794
|
}));
|
|
2467
2795
|
sections.push({
|
|
2468
2796
|
key: sec.key,
|
|
@@ -2915,6 +3243,261 @@ async function summarizeChapter(ctx, config, project, outputDir, chapterNo) {
|
|
|
2915
3243
|
saveProject(outputDir, project);
|
|
2916
3244
|
return chapter.summary;
|
|
2917
3245
|
}
|
|
3246
|
+
/** 把分镜产出写入项目持久化(按章 upsert)。 */
|
|
3247
|
+
function saveChapterStoryboard(project, outputDir, entry) {
|
|
3248
|
+
if (project.storyboards === void 0) project.storyboards = [];
|
|
3249
|
+
const idx = project.storyboards.findIndex((e) => e.chapterNo === entry.chapterNo);
|
|
3250
|
+
if (idx === -1) project.storyboards.push(entry);
|
|
3251
|
+
else project.storyboards[idx] = {
|
|
3252
|
+
...project.storyboards[idx],
|
|
3253
|
+
...entry
|
|
3254
|
+
};
|
|
3255
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3256
|
+
saveProject(outputDir, project);
|
|
3257
|
+
}
|
|
3258
|
+
/**
|
|
3259
|
+
* 分镜·导演级:剧情骨架 → 分镜表(镜头级)。
|
|
3260
|
+
* 只做画面层:景别/机位运镜/时长/画面/台词/音效/光效 + 状态连续;禁止改剧情(骨架只读)。
|
|
3261
|
+
*/
|
|
3262
|
+
async function generateStoryboardTable(ctx, config, project, outputDir, chapterNo, skeleton, styleId, filterId) {
|
|
3263
|
+
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
3264
|
+
if (chapter === void 0) throw new Error(`章节 ${chapterNo} 不在计划中`);
|
|
3265
|
+
const body = readChapterFile(outputDir, chapter);
|
|
3266
|
+
if (body === void 0) throw new Error(`章节 ${chapterNo} 的正文文件不存在`);
|
|
3267
|
+
if ((skeleton.beats ?? []).length === 0) throw new Error("骨架为空,请先生成剧情骨架");
|
|
3268
|
+
const roles = (project.roles ?? []).filter((r) => body.includes(r.name)).slice(0, 8).map((r) => `${r.name}(${r.roleLabel === "protagonist" ? "主角" : r.roleLabel === "antagonist" ? "反派" : r.roleLabel === "female_lead" ? "女主" : "配角"}):${r.identity ?? ""}`);
|
|
3269
|
+
const scenes = (project.scenes ?? []).filter((s) => body.includes(s.name)).slice(0, 6).map((s) => `${s.name}:${s.summary ?? ""}`);
|
|
3270
|
+
const rules = (project.visualRules ?? []).map((r) => "- " + r);
|
|
3271
|
+
const baseStyle = styleId !== void 0 ? findStyle(styleId) : void 0;
|
|
3272
|
+
const filterStyle = filterId !== void 0 ? findStyle(filterId) : void 0;
|
|
3273
|
+
const styleLines = [];
|
|
3274
|
+
if (baseStyle !== void 0) styleLines.push("- 基底风格「" + baseStyle.name + "」:" + baseStyle.keywords);
|
|
3275
|
+
if (filterStyle !== void 0) styleLines.push("- 叠加滤镜「" + filterStyle.name + "」:" + filterStyle.keywords);
|
|
3276
|
+
if (styleLines.length === 0) styleLines.push("- (未选择风格,默认 3D 动漫超精细建模质感)");
|
|
3277
|
+
const raw = parseJsonObject(await complete(ctx, config, {
|
|
3278
|
+
system: [
|
|
3279
|
+
"你是一位从业 10 年的电影导演兼分镜师,专长网文改编影视化。",
|
|
3280
|
+
"任务:把「剧情骨架」的每一个节拍展开为 1-3 个电影镜头,输出分镜表——只做画面层,禁止新增或改变剧情(骨架是只读输入)。",
|
|
3281
|
+
"输出合法 JSON 对象:{\"shots\": [{\"beatId\": \"骨架节拍id\", \"shot\": \"景别\", \"camera\": \"机位与运镜\", \"duration\": 秒数, \"visual\": \"画面内容\", \"line\": \"台词/旁白\", \"sound\": \"音效\", \"light\": \"光效\", \"prevState\": \"承接上一镜头结尾状态\", \"nextState\": \"本镜头结束状态\"}]}",
|
|
3282
|
+
"景别取值:远景/全景/中景/近景/特写。",
|
|
3283
|
+
"硬性要求:",
|
|
3284
|
+
"【视觉风格】(必须内嵌进 visual/light 的画面措辞与光效描述):",
|
|
3285
|
+
...styleLines,
|
|
3286
|
+
"1. 按骨架节拍顺序输出镜头,每个节拍至少 1 个镜头,总镜头数 8-25。",
|
|
3287
|
+
"2. 镜头间连续:下一镜头的 prevState 必须与上一镜头的 nextState 一致(人物位置/动作/情绪/服装),禁止瞬移、服装消失、情绪跳变。",
|
|
3288
|
+
"3. visual 必须写明:角色动作 + 表情 + 服装/标志物(标志物来自下方视觉规则与角色锚点,逐镜头保持)。",
|
|
3289
|
+
"4. 台词/音效/光效无则空字符串,不要编造。",
|
|
3290
|
+
"5. duration 1-12 秒。",
|
|
3291
|
+
"6. 所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束,不要输出其他文字。"
|
|
3292
|
+
].join("\n"),
|
|
3293
|
+
user: [
|
|
3294
|
+
`章节《${chapter.title}》(第 ${chapter.no} 章)`,
|
|
3295
|
+
"==== 剧情骨架(只读,禁止改动) ====",
|
|
3296
|
+
`弧线:${skeleton.arc}`,
|
|
3297
|
+
skeleton.beats.map((b) => `[${b.id}] [${b.function}] ${b.event}(情绪:${b.emotion}${b.cause !== void 0 ? ";承接:" + b.cause : ""})`).join("\n"),
|
|
3298
|
+
roles.length > 0 ? "==== 出场角色锚点 ====\n" + roles.join("\n") : "",
|
|
3299
|
+
scenes.length > 0 ? "==== 相关场景 ====\n" + scenes.join("\n") : "",
|
|
3300
|
+
rules.length > 0 ? "==== 本书视觉规则(必须内嵌进 visual) ====\n" + rules.join("\n") : "",
|
|
3301
|
+
"==== 章节正文(画面细节以此为准) ====",
|
|
3302
|
+
body.replace(/^#\s+.*$/m, "").trim().slice(0, 3e3)
|
|
3303
|
+
].filter((s) => s !== "").join("\n\n"),
|
|
3304
|
+
temperature: .3,
|
|
3305
|
+
maxTokens: Math.max(config.maxTokens, 6e3),
|
|
3306
|
+
reasoning: config.analysisReasoning ?? "low"
|
|
3307
|
+
}));
|
|
3308
|
+
const beatIds = new Set(skeleton.beats.map((b) => b.id));
|
|
3309
|
+
const shotKinds = /* @__PURE__ */ new Set([
|
|
3310
|
+
"远景",
|
|
3311
|
+
"全景",
|
|
3312
|
+
"中景",
|
|
3313
|
+
"近景",
|
|
3314
|
+
"特写"
|
|
3315
|
+
]);
|
|
3316
|
+
const shots = Array.isArray(raw.shots) ? raw.shots.filter((v) => typeof v === "object" && v !== null).map((entry, i) => ({
|
|
3317
|
+
id: "s" + (i + 1),
|
|
3318
|
+
beatId: beatIds.has(entry.beatId) ? entry.beatId : skeleton.beats[0].id,
|
|
3319
|
+
shot: shotKinds.has(entry.shot) ? entry.shot : "中景",
|
|
3320
|
+
camera: typeof entry.camera === "string" ? entry.camera.trim().slice(0, 80) : "",
|
|
3321
|
+
duration: typeof entry.duration === "number" && entry.duration >= 1 && entry.duration <= 12 ? Math.round(entry.duration) : 4,
|
|
3322
|
+
visual: typeof entry.visual === "string" ? entry.visual.trim().slice(0, 300) : "",
|
|
3323
|
+
line: typeof entry.line === "string" ? entry.line.trim().slice(0, 120) : "",
|
|
3324
|
+
sound: typeof entry.sound === "string" ? entry.sound.trim().slice(0, 80) : "",
|
|
3325
|
+
light: typeof entry.light === "string" ? entry.light.trim().slice(0, 80) : "",
|
|
3326
|
+
prevState: typeof entry.prevState === "string" ? entry.prevState.trim().slice(0, 150) : "",
|
|
3327
|
+
nextState: typeof entry.nextState === "string" ? entry.nextState.trim().slice(0, 150) : ""
|
|
3328
|
+
})).filter((s) => s.visual !== "") : [];
|
|
3329
|
+
if (shots.length === 0) throw new Error("模型未输出有效镜头,请重试");
|
|
3330
|
+
const covered = new Set(shots.map((s) => s.beatId));
|
|
3331
|
+
const missing = skeleton.beats.filter((b) => !covered.has(b.id)).map((b) => b.id);
|
|
3332
|
+
if (missing.length > 0) console.warn(`[dsh-novel-forge] storyboard: beat ${missing.join(",")} 无镜头覆盖`);
|
|
3333
|
+
const table = {
|
|
3334
|
+
chapterNo,
|
|
3335
|
+
shots
|
|
3336
|
+
};
|
|
3337
|
+
saveChapterStoryboard(project, outputDir, {
|
|
3338
|
+
chapterNo,
|
|
3339
|
+
table,
|
|
3340
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3341
|
+
});
|
|
3342
|
+
return table;
|
|
3343
|
+
}
|
|
3344
|
+
/**
|
|
3345
|
+
* 分镜·提示词级:分镜表 → 即梦可粘贴视频提示词。
|
|
3346
|
+
* 每镜头一段:风格词块(基底+滤镜)+ 画面内容(角色动作/服装标志物)+ 机位运镜 + 光效。
|
|
3347
|
+
* 提示词聚焦画面与镜头(视频模型无音频,台词/音效不注入)。
|
|
3348
|
+
*/
|
|
3349
|
+
async function generateStoryboardPrompts(ctx, config, project, outputDir, chapterNo, table, styleId, filterId) {
|
|
3350
|
+
if ((table.shots ?? []).length === 0) throw new Error("分镜表为空,请先生成分镜表");
|
|
3351
|
+
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
3352
|
+
const baseStyle = styleId !== void 0 ? findStyle(styleId) : void 0;
|
|
3353
|
+
const filterStyle = filterId !== void 0 ? findStyle(filterId) : void 0;
|
|
3354
|
+
const stylePrefix = [baseStyle?.keywords, filterStyle?.keywords].filter((v) => v !== void 0 && v !== "").join(",") || "3D动漫,超精细建模,电影光影";
|
|
3355
|
+
const rules = (project.visualRules ?? []).map((r) => "- " + r);
|
|
3356
|
+
const shotLines = table.shots.map((s) => `[${s.id}] 节拍${s.beatId} · ${s.shot} · ${s.camera} · ${s.duration}s
|
|
3357
|
+
画面:${s.visual}
|
|
3358
|
+
台词:${s.line !== "" ? s.line : "(无)"}
|
|
3359
|
+
光效:${s.light !== "" ? s.light : "(无)"}
|
|
3360
|
+
承接:${s.prevState} → ${s.nextState}`).join("\n\n");
|
|
3361
|
+
const raw = parseJsonObject(await complete(ctx, config, {
|
|
3362
|
+
system: [
|
|
3363
|
+
"你是一位资深影视分镜提示词工程师,熟悉即梦 AI / Seedance 等视频生成模型的中文提示词写法。",
|
|
3364
|
+
"任务:把分镜表的每个镜头写成一段可直接粘贴进视频生成工具的提示词。",
|
|
3365
|
+
"提示词结构(按顺序):风格词块 → 画面主体(人物动作+表情+服装标志物)→ 机位运镜(含时长感)→ 光效氛围。单段中文 60-140 字,逗号分隔,流畅自然。",
|
|
3366
|
+
"风格词块(必须原样放在每段开头):" + stylePrefix,
|
|
3367
|
+
"硬性要求:",
|
|
3368
|
+
"1. 只写画面与镜头,禁止写音频/台词配音说明(视频模型无音频)。",
|
|
3369
|
+
"2. 服装/发色/标志物必须沿用镜头表与视觉规则,禁止自行更换。",
|
|
3370
|
+
"3. 运镜用通俗电影词(推近/拉远/跟随/低机位仰拍/过肩/手持晃动/固定机位),不要用分镜术语缩写。",
|
|
3371
|
+
"4. 输出合法 JSON 对象:{\"prompts\": [{\"shotId\": \"s1\", \"text\": \"...\"}]},所有镜头都要有,顺序一致。",
|
|
3372
|
+
"5. 字符串内不得包含换行符,JSON 必须在一段内完整结束。"
|
|
3373
|
+
].join("\n"),
|
|
3374
|
+
user: [
|
|
3375
|
+
`章节《${chapter?.title ?? ""}》(第 ${chapterNo} 章)`,
|
|
3376
|
+
rules.length > 0 ? "本书视觉规则(必须遵守):\n" + rules.join("\n") : "",
|
|
3377
|
+
"==== 分镜表 ====",
|
|
3378
|
+
shotLines,
|
|
3379
|
+
"只输出 JSON 对象。"
|
|
3380
|
+
].filter((x) => x !== "").join("\n\n"),
|
|
3381
|
+
temperature: .3,
|
|
3382
|
+
maxTokens: Math.max(config.maxTokens, 6e3),
|
|
3383
|
+
reasoning: config.analysisReasoning ?? "low"
|
|
3384
|
+
}));
|
|
3385
|
+
const shotIds = new Set(table.shots.map((s) => s.id));
|
|
3386
|
+
const prompts = Array.isArray(raw.prompts) ? raw.prompts.filter((v) => typeof v === "object" && v !== null).map((entry) => ({
|
|
3387
|
+
shotId: shotIds.has(entry.shotId) ? entry.shotId : "",
|
|
3388
|
+
text: typeof entry.text === "string" ? entry.text.trim().slice(0, 400) : ""
|
|
3389
|
+
})).filter((x) => x.shotId !== "" && x.text !== "") : [];
|
|
3390
|
+
if (prompts.length === 0) throw new Error("模型未输出有效提示词,请重试");
|
|
3391
|
+
saveChapterStoryboard(project, outputDir, {
|
|
3392
|
+
chapterNo,
|
|
3393
|
+
prompts,
|
|
3394
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3395
|
+
});
|
|
3396
|
+
return prompts;
|
|
3397
|
+
}
|
|
3398
|
+
/**
|
|
3399
|
+
* 分镜·编剧级:单章 → 剧情骨架(节拍链)。
|
|
3400
|
+
* 只做故事层(事件/情绪/功能/因果),不做画面;导演级分镜在其上展开。
|
|
3401
|
+
*/
|
|
3402
|
+
async function generateStoryboardSkeleton(ctx, config, project, outputDir, chapterNo) {
|
|
3403
|
+
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
3404
|
+
if (chapter === void 0) throw new Error(`章节 ${chapterNo} 不在计划中`);
|
|
3405
|
+
const body = readChapterFile(outputDir, chapter);
|
|
3406
|
+
if (body === void 0) throw new Error(`章节 ${chapterNo} 的正文文件不存在`);
|
|
3407
|
+
const raw = parseJsonObject(await complete(ctx, config, {
|
|
3408
|
+
system: [
|
|
3409
|
+
"你是一位从业 15 年的电影编剧,专长网文改编影视化,深谙三幕结构与节拍(beat)写作。",
|
|
3410
|
+
"任务:把这一章改编成影视化「剧情骨架」——只做故事层,不做画面。",
|
|
3411
|
+
"输出合法 JSON 对象:{\"arc\": \"本章弧线一句话(起承转合,20-60字)\", \"beats\": [{\"event\": \"事件一句话(发生了什么)\", \"emotion\": \"人物情绪走向(如 压抑→隐忍→惊惧)\", \"function\": \"铺垫|冲突|转折|高潮|收束|伏笔|人物塑造\", \"cause\": \"承接上一节拍的原因(可省略)\"}]}",
|
|
3412
|
+
"硬性要求:",
|
|
3413
|
+
"1. beats 数量 4-9 个,严格按时间顺序,因果链完整:前一个 beat 的结果是后一个 beat 的原因。",
|
|
3414
|
+
"2. 必须覆盖本章全部剧情要点与正文关键事件,遗漏关键事件视为失败。",
|
|
3415
|
+
"3. 只输出剧情骨架,禁止写画面描写、机位运镜、台词细节、音效(那是导演阶段的事)。",
|
|
3416
|
+
"4. 所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
3417
|
+
"5. 直接输出 JSON 本身,不要输出思考过程或其他文字。"
|
|
3418
|
+
].join("\n"),
|
|
3419
|
+
user: [
|
|
3420
|
+
`章节《${chapter.title}》(第 ${chapter.no} 章)`,
|
|
3421
|
+
`剧情要点:${chapter.beats !== void 0 && chapter.beats !== "" ? chapter.beats : "(未填写)"}`,
|
|
3422
|
+
"==================== 章节正文 ====================",
|
|
3423
|
+
body.replace(/^#\s+.*$/m, "").trim()
|
|
3424
|
+
].join("\n"),
|
|
3425
|
+
temperature: .3,
|
|
3426
|
+
maxTokens: Math.max(config.maxTokens, 4e3),
|
|
3427
|
+
reasoning: config.analysisReasoning ?? "low"
|
|
3428
|
+
}));
|
|
3429
|
+
const arc = typeof raw.arc === "string" ? raw.arc.trim().slice(0, 200) : "";
|
|
3430
|
+
const functions = /* @__PURE__ */ new Set([
|
|
3431
|
+
"铺垫",
|
|
3432
|
+
"冲突",
|
|
3433
|
+
"转折",
|
|
3434
|
+
"高潮",
|
|
3435
|
+
"收束",
|
|
3436
|
+
"伏笔",
|
|
3437
|
+
"人物塑造"
|
|
3438
|
+
]);
|
|
3439
|
+
const beats = Array.isArray(raw.beats) ? raw.beats.filter((v) => typeof v === "object" && v !== null).map((entry, i) => ({
|
|
3440
|
+
id: "b" + (i + 1),
|
|
3441
|
+
event: typeof entry.event === "string" ? entry.event.trim().slice(0, 200) : "",
|
|
3442
|
+
emotion: typeof entry.emotion === "string" ? entry.emotion.trim().slice(0, 100) : "",
|
|
3443
|
+
function: functions.has(entry.function) ? entry.function : "铺垫",
|
|
3444
|
+
cause: typeof entry.cause === "string" && entry.cause.trim() !== "" ? entry.cause.trim().slice(0, 150) : void 0
|
|
3445
|
+
})).filter((b) => b.event !== "") : [];
|
|
3446
|
+
if (beats.length === 0) throw new Error("模型未输出有效节拍链,请重试");
|
|
3447
|
+
const skeleton = {
|
|
3448
|
+
chapterNo,
|
|
3449
|
+
arc: arc !== "" ? arc : "(本章弧线未生成)",
|
|
3450
|
+
beats
|
|
3451
|
+
};
|
|
3452
|
+
saveChapterStoryboard(project, outputDir, {
|
|
3453
|
+
chapterNo,
|
|
3454
|
+
skeleton,
|
|
3455
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3456
|
+
});
|
|
3457
|
+
return skeleton;
|
|
3458
|
+
}
|
|
3459
|
+
/**
|
|
3460
|
+
* 反向推大纲:从已写章节正文反推出全书总纲(分卷 + 章节要点 + 主线/人物弧线/伏笔清单)。
|
|
3461
|
+
* 两阶段:分批提取章节事件摘要 → 汇总生成大纲。不修改章节/设定,只返回大纲文本。
|
|
3462
|
+
*/
|
|
3463
|
+
async function reverseOutlineFromChapters(ctx, config, project, outputDir, onProgress) {
|
|
3464
|
+
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating" && c.status !== "error").filter((c) => readChapterFile(outputDir, c) !== void 0).sort((a, b) => a.no - b.no);
|
|
3465
|
+
if (written.length === 0) throw new Error("本书还没有已写章节,无法反推大纲");
|
|
3466
|
+
const BATCH = 10;
|
|
3467
|
+
const notes = [];
|
|
3468
|
+
const total = written.length;
|
|
3469
|
+
for (let i = 0; i < written.length; i += BATCH) {
|
|
3470
|
+
const note = await complete(ctx, config, {
|
|
3471
|
+
system: "你是一位网文编辑。下面是一本书若干章正文的节选。请为每一章输出一行「事件摘要」,格式严格为:第N章《标题》:关键事件+主角状态变化+新增伏笔或线索。每章恰好一行,不要空行,不要评价,不要输出其他内容。",
|
|
3472
|
+
user: written.slice(i, i + BATCH).map((c) => {
|
|
3473
|
+
const body = readChapterFile(outputDir, c) ?? "";
|
|
3474
|
+
return "第" + c.no + "章《" + (c.title || "无题") + "》\n" + body.replace(/^#\s+.*$/m, "").trim().slice(0, 1e3);
|
|
3475
|
+
}).join("\n\n---\n\n"),
|
|
3476
|
+
temperature: .2,
|
|
3477
|
+
maxTokens: Math.max(config.maxTokens, 3e3),
|
|
3478
|
+
reasoning: config.analysisReasoning ?? "low"
|
|
3479
|
+
});
|
|
3480
|
+
notes.push(note.trim());
|
|
3481
|
+
onProgress?.(Math.min(i + BATCH, total), total, "章节摘要");
|
|
3482
|
+
}
|
|
3483
|
+
onProgress?.(total, total, "生成大纲");
|
|
3484
|
+
const outline = await complete(ctx, config, {
|
|
3485
|
+
system: [
|
|
3486
|
+
"你是一位经验丰富的小说主编。根据下面全书各章事件摘要,反推出这本书的总纲(大纲),可直接作为后续写作依据。要求:",
|
|
3487
|
+
"1. 第一行写《书名》(从摘要中的书名或内容推断,若无则用《未命名》)。",
|
|
3488
|
+
"2. 按故事弧线划分卷/部分:每卷给出卷名与主旨(标注覆盖章节范围)。",
|
|
3489
|
+
"3. 每一章列出:章节号 + 标题 + 一句话核心情节(若原章无标题可自拟)。",
|
|
3490
|
+
"4. 最后给出:全书主线、主要人物弧线、已埋设待回收的伏笔清单。",
|
|
3491
|
+
"5. 输出为纯文本 Markdown 结构(# 一级标题、## 二级标题、- 列表),不要多余寒暄。"
|
|
3492
|
+
].join("\n"),
|
|
3493
|
+
user: notes.join("\n\n"),
|
|
3494
|
+
temperature: .4,
|
|
3495
|
+
maxTokens: Math.max(config.maxTokens, 6e3),
|
|
3496
|
+
reasoning: config.analysisReasoning ?? "low"
|
|
3497
|
+
});
|
|
3498
|
+
onProgress?.(total, total, "完成");
|
|
3499
|
+
return outline.trim();
|
|
3500
|
+
}
|
|
2918
3501
|
/**
|
|
2919
3502
|
* 摘要 + 事实抽取合并为一次 LLM 调用(省一次调用与一次正文输入,
|
|
2920
3503
|
* 批量生成时整体开销约省 25%)。
|
|
@@ -3120,7 +3703,8 @@ async function generateBlurb(ctx, config, project, partial = "") {
|
|
|
3120
3703
|
partial.trim() !== "" ? `已有开头草稿(请保留其内容与语气,续写补全为完整简介):\n${partial.trim()}` : "请全量生成一份完整简介。"
|
|
3121
3704
|
].filter((s) => s !== "").join("\n\n"),
|
|
3122
3705
|
temperature: .7,
|
|
3123
|
-
maxTokens: Math.max(config.maxTokens, 4e3)
|
|
3706
|
+
maxTokens: Math.max(config.maxTokens, 4e3),
|
|
3707
|
+
reasoning: config.analysisReasoning ?? "low"
|
|
3124
3708
|
})).replace(/^["'「『]|["'」』]$/g, "").replace(/^简介[::]\s*/, "").trim().slice(0, 600);
|
|
3125
3709
|
}
|
|
3126
3710
|
/**
|
|
@@ -3282,7 +3866,8 @@ async function extractWorld(ctx, config, project) {
|
|
|
3282
3866
|
"只输出 JSON 对象。"
|
|
3283
3867
|
].filter((s) => s !== "").join("\n\n"),
|
|
3284
3868
|
temperature: .3,
|
|
3285
|
-
maxTokens: Math.max(config.maxTokens, 12e3)
|
|
3869
|
+
maxTokens: Math.max(config.maxTokens, 12e3),
|
|
3870
|
+
reasoning: config.analysisReasoning ?? "low"
|
|
3286
3871
|
}));
|
|
3287
3872
|
const str = (value) => typeof value === "string" ? value.trim() : "";
|
|
3288
3873
|
const objArray = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "object" && v !== null) : [];
|
|
@@ -3421,7 +4006,8 @@ async function suggestForeshadows(ctx, config, project) {
|
|
|
3421
4006
|
system: foreshadowSystemPrompt(),
|
|
3422
4007
|
user,
|
|
3423
4008
|
temperature: .5,
|
|
3424
|
-
maxTokens: Math.max(config.maxTokens, 12e3)
|
|
4009
|
+
maxTokens: Math.max(config.maxTokens, 12e3),
|
|
4010
|
+
reasoning: config.analysisReasoning ?? "low"
|
|
3425
4011
|
}));
|
|
3426
4012
|
const existing = new Set(project.foreshadows.map((f) => f.description));
|
|
3427
4013
|
const created = [];
|
|
@@ -3989,6 +4575,16 @@ const NOVEL_API = {
|
|
|
3989
4575
|
bookshelfImportDir: "/api/dsh-novel-forge/bookshelf/import-dir",
|
|
3990
4576
|
/** 导入 txt/md 全本:拆章建项目并登记书架。 */
|
|
3991
4577
|
bookshelfImportText: "/api/dsh-novel-forge/bookshelf/import-text",
|
|
4578
|
+
/** 导入 txt/md 全本:拆章预览(不落盘)。 */
|
|
4579
|
+
bookshelfImportTextPreview: "/api/dsh-novel-forge/bookshelf/import-text/preview",
|
|
4580
|
+
/** 漫剧方案管理:create/remove/activate。 */
|
|
4581
|
+
manhuaPlans: "/api/dsh-novel-forge/manhua/plans",
|
|
4582
|
+
/** 分镜·编剧级:单章 → 剧情骨架(节拍链)。 */
|
|
4583
|
+
storyboardSkeleton: "/api/dsh-novel-forge/storyboard/skeleton",
|
|
4584
|
+
/** 分镜·导演级:骨架 → 分镜表(镜头级)。 */
|
|
4585
|
+
storyboardTable: "/api/dsh-novel-forge/storyboard/table",
|
|
4586
|
+
/** 分镜·提示词级:分镜表 → 即梦可粘贴视频提示词。 */
|
|
4587
|
+
storyboardPrompts: "/api/dsh-novel-forge/storyboard/prompts",
|
|
3992
4588
|
/** 重置项目(可选携带新大纲):清空设定/卷/章节/伏笔/资产/事实库。 */
|
|
3993
4589
|
reset: "/api/dsh-novel-forge/reset",
|
|
3994
4590
|
/** 全书一致性质检:LLM 扫描已生成章节,输出矛盾问题清单。 */
|
|
@@ -4023,6 +4619,8 @@ const NOVEL_API = {
|
|
|
4023
4619
|
sensitiveCheck: "/api/dsh-novel-forge/sensitive-check",
|
|
4024
4620
|
/** 开书想法 → AI 补全大纲:输入一句话想法,生成 2-3 个可选大纲方案。 */
|
|
4025
4621
|
outlineSuggest: "/api/dsh-novel-forge/outline/suggest",
|
|
4622
|
+
/** 反推大纲:从已写章节正文反向生成全书总纲(NDJSON 流)。 */
|
|
4623
|
+
outlineReverse: "/api/dsh-novel-forge/outline/reverse",
|
|
4026
4624
|
/** 拆书分析:对已写章节做结构/人物/文风/卖点四维体检(两阶段:源笔记→分节分析)。 */
|
|
4027
4625
|
breakdown: "/api/dsh-novel-forge/breakdown",
|
|
4028
4626
|
/** 漫剧分镜生成:章节 → 角色锚点 + 分镜表(可适配豆包/Seedance/SD)。 */
|
|
@@ -4829,7 +5427,9 @@ async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
|
|
|
4829
5427
|
//#endregion
|
|
4830
5428
|
//#region src/routes.ts
|
|
4831
5429
|
/** Cap on JSON request bodies (generous: cover images travel as base64). */
|
|
4832
|
-
const MAX_JSON_BODY_BYTES =
|
|
5430
|
+
const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024;
|
|
5431
|
+
/** 包内置风格效果图目录(assets/styles,随 npm 包分发)。 */
|
|
5432
|
+
const builtinStyleDir = fileURLToPath(new URL("../assets/styles/", import.meta.url));
|
|
4833
5433
|
/** Loopback-only fence (mirrors the family plugins' pairing routes). */
|
|
4834
5434
|
function isLoopbackRequest(request) {
|
|
4835
5435
|
const address = request.socket.remoteAddress;
|
|
@@ -5862,23 +6462,34 @@ function makeRoutes(deps) {
|
|
|
5862
6462
|
}
|
|
5863
6463
|
}
|
|
5864
6464
|
};
|
|
5865
|
-
/** 导入 txt/md 全本(Mode B
|
|
6465
|
+
/** 导入 txt/md 全本(Mode B):拆章落盘建项目,登记书架。支持浏览器上传(text+fileName)与服务器本地文件(filePath)两种模式。 */
|
|
5866
6466
|
const bookshelfImportTextRoute = {
|
|
5867
6467
|
kind: "exact",
|
|
5868
6468
|
path: NOVEL_API.bookshelfImportText,
|
|
5869
6469
|
handler: async (req, res) => {
|
|
5870
6470
|
if (!guard(req, res, "POST")) return;
|
|
5871
6471
|
const body = await readJsonBody(req);
|
|
5872
|
-
const filePath = body?.filePath?.trim();
|
|
5873
|
-
if (filePath === void 0 || filePath === "") {
|
|
5874
|
-
writeJson(res, 400, { error: "filePath 不能为空" });
|
|
5875
|
-
return;
|
|
5876
|
-
}
|
|
5877
|
-
if (!existsSync(filePath)) {
|
|
5878
|
-
writeJson(res, 400, { error: `文件不存在:${filePath}` });
|
|
5879
|
-
return;
|
|
5880
|
-
}
|
|
5881
6472
|
try {
|
|
6473
|
+
if (body?.text !== void 0 && body.text.length > 0) {
|
|
6474
|
+
const bookName = (body.fileName ?? "").replace(/\.[^.]+$/, "").trim().slice(0, 40) || "导入小说";
|
|
6475
|
+
const outDir = body?.outputDir?.trim() !== void 0 && body.outputDir.trim() !== "" ? body.outputDir.trim() : defaultOutputDirFor(bookName);
|
|
6476
|
+
const result = importBookTextFromText(body.text, outDir, bookName);
|
|
6477
|
+
const { book } = importDir(outDir);
|
|
6478
|
+
writeJson(res, 200, {
|
|
6479
|
+
...result,
|
|
6480
|
+
book
|
|
6481
|
+
});
|
|
6482
|
+
return;
|
|
6483
|
+
}
|
|
6484
|
+
const filePath = body?.filePath?.trim();
|
|
6485
|
+
if (filePath === void 0 || filePath === "") {
|
|
6486
|
+
writeJson(res, 400, { error: "filePath 不能为空(或请上传文件内容)" });
|
|
6487
|
+
return;
|
|
6488
|
+
}
|
|
6489
|
+
if (!existsSync(filePath)) {
|
|
6490
|
+
writeJson(res, 400, { error: `文件不存在:${filePath}` });
|
|
6491
|
+
return;
|
|
6492
|
+
}
|
|
5882
6493
|
const bookName = basename(filePath, extname(filePath)).slice(0, 40) || "导入小说";
|
|
5883
6494
|
const outDir = body?.outputDir?.trim() !== void 0 && body.outputDir.trim() !== "" ? body.outputDir.trim() : defaultOutputDirFor(bookName);
|
|
5884
6495
|
const result = importBookText(filePath, outDir);
|
|
@@ -5892,6 +6503,27 @@ function makeRoutes(deps) {
|
|
|
5892
6503
|
}
|
|
5893
6504
|
}
|
|
5894
6505
|
};
|
|
6506
|
+
/** 上传全文做拆章预览(不落盘、不登记书架)。 */
|
|
6507
|
+
const bookshelfImportTextPreviewRoute = {
|
|
6508
|
+
kind: "exact",
|
|
6509
|
+
path: NOVEL_API.bookshelfImportTextPreview,
|
|
6510
|
+
handler: async (req, res) => {
|
|
6511
|
+
if (!guard(req, res, "POST")) return;
|
|
6512
|
+
const body = await readJsonBody(req);
|
|
6513
|
+
if (body?.text === void 0 || body.text.length === 0) {
|
|
6514
|
+
writeJson(res, 400, { error: "text 不能为空" });
|
|
6515
|
+
return;
|
|
6516
|
+
}
|
|
6517
|
+
try {
|
|
6518
|
+
writeJson(res, 200, {
|
|
6519
|
+
bookName: (body.fileName ?? "").replace(/\.[^.]+$/, "").trim().slice(0, 40) || "导入小说",
|
|
6520
|
+
...previewBookText(body.text)
|
|
6521
|
+
});
|
|
6522
|
+
} catch (err) {
|
|
6523
|
+
writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
|
|
6524
|
+
}
|
|
6525
|
+
}
|
|
6526
|
+
};
|
|
5895
6527
|
/** 全书一致性质检。 */
|
|
5896
6528
|
const auditRoute = {
|
|
5897
6529
|
kind: "exact",
|
|
@@ -6429,7 +7061,7 @@ function makeRoutes(deps) {
|
|
|
6429
7061
|
return;
|
|
6430
7062
|
}
|
|
6431
7063
|
try {
|
|
6432
|
-
const visual = await extractRoleVisual(ctx, config, project, config.outputDir, name);
|
|
7064
|
+
const visual = await extractRoleVisual(ctx, config, project, config.outputDir, name, body?.styleId, body?.filterId);
|
|
6433
7065
|
const role = project.roles.find((r) => r.name === name);
|
|
6434
7066
|
if (role !== void 0) {
|
|
6435
7067
|
role.imagePrompt = visual;
|
|
@@ -6454,7 +7086,7 @@ function makeRoutes(deps) {
|
|
|
6454
7086
|
return;
|
|
6455
7087
|
}
|
|
6456
7088
|
try {
|
|
6457
|
-
const kit = await generateRolePromptKit(ctx, config, project, name);
|
|
7089
|
+
const kit = await generateRolePromptKit(ctx, config, project, name, body?.styleId, body?.filterId);
|
|
6458
7090
|
const role = project.roles.find((r) => r.name === name);
|
|
6459
7091
|
if (role !== void 0) role.promptKit = kit;
|
|
6460
7092
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -6673,6 +7305,213 @@ function makeRoutes(deps) {
|
|
|
6673
7305
|
}
|
|
6674
7306
|
}
|
|
6675
7307
|
};
|
|
7308
|
+
/** 反推大纲:从已写章节正文反向生成全书总纲(NDJSON 流式进度)。 */
|
|
7309
|
+
const outlineReverseRoute = {
|
|
7310
|
+
kind: "exact",
|
|
7311
|
+
path: NOVEL_API.outlineReverse,
|
|
7312
|
+
handler: async (req, res) => {
|
|
7313
|
+
if (!guard(req, res, "POST")) return;
|
|
7314
|
+
const config = getConfig();
|
|
7315
|
+
const project = requireProject(res);
|
|
7316
|
+
if (project === void 0) return;
|
|
7317
|
+
res.writeHead(200, {
|
|
7318
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
7319
|
+
"cache-control": "no-cache",
|
|
7320
|
+
"x-accel-buffering": "no",
|
|
7321
|
+
"referrer-policy": "no-referrer"
|
|
7322
|
+
});
|
|
7323
|
+
const send = (frame) => {
|
|
7324
|
+
res.write(JSON.stringify(frame) + "\n");
|
|
7325
|
+
};
|
|
7326
|
+
try {
|
|
7327
|
+
const outline = await reverseOutlineFromChapters(ctx, config, project, config.outputDir, (done, total, phase) => {
|
|
7328
|
+
send({
|
|
7329
|
+
type: "outline-progress",
|
|
7330
|
+
done,
|
|
7331
|
+
total,
|
|
7332
|
+
phase
|
|
7333
|
+
});
|
|
7334
|
+
});
|
|
7335
|
+
project.outline = outline;
|
|
7336
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7337
|
+
saveProject(config.outputDir, project);
|
|
7338
|
+
send({
|
|
7339
|
+
type: "outline-done",
|
|
7340
|
+
outline,
|
|
7341
|
+
chars: outline.length
|
|
7342
|
+
});
|
|
7343
|
+
} catch (error) {
|
|
7344
|
+
send({
|
|
7345
|
+
type: "error",
|
|
7346
|
+
no: 0,
|
|
7347
|
+
message: error.message
|
|
7348
|
+
});
|
|
7349
|
+
} finally {
|
|
7350
|
+
res.end();
|
|
7351
|
+
}
|
|
7352
|
+
}
|
|
7353
|
+
};
|
|
7354
|
+
/** 分镜·提示词级:分镜表 → 即梦可粘贴视频提示词。 */
|
|
7355
|
+
const storyboardPromptsRoute = {
|
|
7356
|
+
kind: "exact",
|
|
7357
|
+
path: NOVEL_API.storyboardPrompts,
|
|
7358
|
+
handler: async (req, res) => {
|
|
7359
|
+
if (!guard(req, res, "POST")) return;
|
|
7360
|
+
const config = getConfig();
|
|
7361
|
+
const project = requireProject(res);
|
|
7362
|
+
if (project === void 0) return;
|
|
7363
|
+
const body = await readJsonBody(req);
|
|
7364
|
+
const no = body?.chapterNo;
|
|
7365
|
+
if (!Number.isInteger(no) || no === void 0 || no < 1) {
|
|
7366
|
+
writeJson(res, 400, { error: "chapterNo 须为正整数" });
|
|
7367
|
+
return;
|
|
7368
|
+
}
|
|
7369
|
+
if (body?.table === void 0 || (body.table.shots ?? []).length === 0) {
|
|
7370
|
+
writeJson(res, 400, { error: "table 不能为空,请先生成分镜表" });
|
|
7371
|
+
return;
|
|
7372
|
+
}
|
|
7373
|
+
try {
|
|
7374
|
+
writeJson(res, 200, { prompts: await generateStoryboardPrompts(ctx, config, project, config.outputDir, no, body.table, body?.styleId, body?.filterId) });
|
|
7375
|
+
} catch (error) {
|
|
7376
|
+
writeJson(res, 500, { error: `视频提示词生成失败:${error.message}` });
|
|
7377
|
+
}
|
|
7378
|
+
}
|
|
7379
|
+
};
|
|
7380
|
+
/** 分镜·导演级:骨架 → 分镜表(镜头级)。 */
|
|
7381
|
+
const storyboardTableRoute = {
|
|
7382
|
+
kind: "exact",
|
|
7383
|
+
path: NOVEL_API.storyboardTable,
|
|
7384
|
+
handler: async (req, res) => {
|
|
7385
|
+
if (!guard(req, res, "POST")) return;
|
|
7386
|
+
const config = getConfig();
|
|
7387
|
+
const project = requireProject(res);
|
|
7388
|
+
if (project === void 0) return;
|
|
7389
|
+
const body = await readJsonBody(req);
|
|
7390
|
+
const no = body?.chapterNo;
|
|
7391
|
+
if (!Number.isInteger(no) || no === void 0 || no < 1) {
|
|
7392
|
+
writeJson(res, 400, { error: "chapterNo 须为正整数" });
|
|
7393
|
+
return;
|
|
7394
|
+
}
|
|
7395
|
+
if (body?.skeleton === void 0 || (body.skeleton.beats ?? []).length === 0) {
|
|
7396
|
+
writeJson(res, 400, { error: "skeleton 不能为空,请先生成剧情骨架" });
|
|
7397
|
+
return;
|
|
7398
|
+
}
|
|
7399
|
+
try {
|
|
7400
|
+
writeJson(res, 200, { table: await generateStoryboardTable(ctx, config, project, config.outputDir, no, body.skeleton, body?.styleId, body?.filterId) });
|
|
7401
|
+
} catch (error) {
|
|
7402
|
+
writeJson(res, 500, { error: `分镜表生成失败:${error.message}` });
|
|
7403
|
+
}
|
|
7404
|
+
}
|
|
7405
|
+
};
|
|
7406
|
+
/** 漫剧方案管理:create / remove / activate。 */
|
|
7407
|
+
const manhuaPlansRoute = {
|
|
7408
|
+
kind: "exact",
|
|
7409
|
+
path: NOVEL_API.manhuaPlans,
|
|
7410
|
+
handler: async (req, res) => {
|
|
7411
|
+
if (!guard(req, res, "POST")) return;
|
|
7412
|
+
const config = getConfig();
|
|
7413
|
+
const project = requireProject(res);
|
|
7414
|
+
if (project === void 0) return;
|
|
7415
|
+
const body = await readJsonBody(req);
|
|
7416
|
+
if (project.mangaPlans === void 0) project.mangaPlans = [];
|
|
7417
|
+
if (body?.op === "create") {
|
|
7418
|
+
const name = body.name?.trim();
|
|
7419
|
+
const styleId = body.styleId?.trim();
|
|
7420
|
+
if (name === void 0 || name === "" || styleId === void 0 || styleId === "") {
|
|
7421
|
+
writeJson(res, 400, { error: "name 与 styleId 不能为空" });
|
|
7422
|
+
return;
|
|
7423
|
+
}
|
|
7424
|
+
if (project.mangaPlans.some((p) => p.name === name)) {
|
|
7425
|
+
writeJson(res, 400, { error: `方案名「${name}」已存在` });
|
|
7426
|
+
return;
|
|
7427
|
+
}
|
|
7428
|
+
const id = `manga-${Date.now().toString(36)}`;
|
|
7429
|
+
project.mangaPlans.push({
|
|
7430
|
+
id,
|
|
7431
|
+
name: name.slice(0, 40),
|
|
7432
|
+
styleId,
|
|
7433
|
+
filterId: body.filterId?.trim() !== "" ? body.filterId?.trim() : void 0,
|
|
7434
|
+
active: project.mangaPlans.length === 0,
|
|
7435
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7436
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
7437
|
+
});
|
|
7438
|
+
} else if (body?.op === "remove" && body.id !== void 0) {
|
|
7439
|
+
const removed = project.mangaPlans.find((p) => p.id === body.id);
|
|
7440
|
+
project.mangaPlans = project.mangaPlans.filter((p) => p.id !== body.id);
|
|
7441
|
+
if (removed?.active === true && project.mangaPlans.length > 0) project.mangaPlans[0].active = true;
|
|
7442
|
+
} else if (body?.op === "activate" && body.id !== void 0) project.mangaPlans.forEach((p) => {
|
|
7443
|
+
p.active = p.id === body.id;
|
|
7444
|
+
});
|
|
7445
|
+
else {
|
|
7446
|
+
writeJson(res, 400, { error: "op 须为 create/remove/activate" });
|
|
7447
|
+
return;
|
|
7448
|
+
}
|
|
7449
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7450
|
+
saveProject(config.outputDir, project);
|
|
7451
|
+
writeJson(res, 200, { plans: project.mangaPlans });
|
|
7452
|
+
}
|
|
7453
|
+
};
|
|
7454
|
+
/** 风格库效果图:GET /styles/image?id=<styleId>,从 ~/.dsh/dsh-novel-forge-styles/<id>.png 读取。 */
|
|
7455
|
+
const styleImageRoute = {
|
|
7456
|
+
kind: "exact",
|
|
7457
|
+
path: "/api/dsh-novel-forge/styles/image",
|
|
7458
|
+
handler: async (req, res) => {
|
|
7459
|
+
if (!isLoopbackRequest(req)) {
|
|
7460
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
7461
|
+
return;
|
|
7462
|
+
}
|
|
7463
|
+
const id = (new URL(req.url ?? "", "http://localhost").searchParams.get("id") ?? "").replace(/[^a-z0-9-]/gi, "");
|
|
7464
|
+
if (id === "") {
|
|
7465
|
+
writeJson(res, 404, { error: "style image not found" });
|
|
7466
|
+
return;
|
|
7467
|
+
}
|
|
7468
|
+
const base = join(homedir(), ".dsh", "dsh-novel-forge-styles");
|
|
7469
|
+
const candidates = [
|
|
7470
|
+
{
|
|
7471
|
+
file: join(base, "thumbs", id + ".webp"),
|
|
7472
|
+
type: "image/webp"
|
|
7473
|
+
},
|
|
7474
|
+
{
|
|
7475
|
+
file: join(base, id + ".png"),
|
|
7476
|
+
type: "image/png"
|
|
7477
|
+
},
|
|
7478
|
+
{
|
|
7479
|
+
file: join(builtinStyleDir, id + ".webp"),
|
|
7480
|
+
type: "image/webp"
|
|
7481
|
+
}
|
|
7482
|
+
];
|
|
7483
|
+
for (const c of candidates) if (existsSync(c.file)) {
|
|
7484
|
+
res.writeHead(200, {
|
|
7485
|
+
"content-type": c.type,
|
|
7486
|
+
"cache-control": "public, max-age=86400"
|
|
7487
|
+
});
|
|
7488
|
+
res.end(readFileSync(c.file));
|
|
7489
|
+
return;
|
|
7490
|
+
}
|
|
7491
|
+
writeJson(res, 404, { error: "style image not found" });
|
|
7492
|
+
}
|
|
7493
|
+
};
|
|
7494
|
+
/** 分镜·编剧级:单章 → 剧情骨架(节拍链)。 */
|
|
7495
|
+
const storyboardSkeletonRoute = {
|
|
7496
|
+
kind: "exact",
|
|
7497
|
+
path: NOVEL_API.storyboardSkeleton,
|
|
7498
|
+
handler: async (req, res) => {
|
|
7499
|
+
if (!guard(req, res, "POST")) return;
|
|
7500
|
+
const config = getConfig();
|
|
7501
|
+
const project = requireProject(res);
|
|
7502
|
+
if (project === void 0) return;
|
|
7503
|
+
const no = (await readJsonBody(req))?.chapterNo;
|
|
7504
|
+
if (!Number.isInteger(no) || no === void 0 || no < 1) {
|
|
7505
|
+
writeJson(res, 400, { error: "chapterNo 须为正整数" });
|
|
7506
|
+
return;
|
|
7507
|
+
}
|
|
7508
|
+
try {
|
|
7509
|
+
writeJson(res, 200, { skeleton: await generateStoryboardSkeleton(ctx, config, project, config.outputDir, no) });
|
|
7510
|
+
} catch (error) {
|
|
7511
|
+
writeJson(res, 500, { error: `剧情骨架生成失败:${error.message}` });
|
|
7512
|
+
}
|
|
7513
|
+
}
|
|
7514
|
+
};
|
|
6676
7515
|
/** 拆书分析:对已写章节做结构/人物/文风/卖点体检(两阶段:源笔记→分节分析)。 */
|
|
6677
7516
|
const breakdownRoute = {
|
|
6678
7517
|
kind: "exact",
|
|
@@ -6817,6 +7656,7 @@ function makeRoutes(deps) {
|
|
|
6817
7656
|
bookshelfRemoveRoute,
|
|
6818
7657
|
bookshelfImportDirRoute,
|
|
6819
7658
|
bookshelfImportTextRoute,
|
|
7659
|
+
bookshelfImportTextPreviewRoute,
|
|
6820
7660
|
resetRoute,
|
|
6821
7661
|
auditRoute,
|
|
6822
7662
|
charactersRefreshRoute,
|
|
@@ -6837,6 +7677,12 @@ function makeRoutes(deps) {
|
|
|
6837
7677
|
configRoute,
|
|
6838
7678
|
openFolderRoute,
|
|
6839
7679
|
outlineSuggestRoute,
|
|
7680
|
+
outlineReverseRoute,
|
|
7681
|
+
manhuaPlansRoute,
|
|
7682
|
+
styleImageRoute,
|
|
7683
|
+
storyboardSkeletonRoute,
|
|
7684
|
+
storyboardTableRoute,
|
|
7685
|
+
storyboardPromptsRoute,
|
|
6840
7686
|
breakdownRoute,
|
|
6841
7687
|
{
|
|
6842
7688
|
kind: "exact",
|
|
@@ -6931,6 +7777,12 @@ const Config = z.object({
|
|
|
6931
7777
|
"high",
|
|
6932
7778
|
"max"
|
|
6933
7779
|
]).default("off"),
|
|
7780
|
+
analysisReasoning: z.union([
|
|
7781
|
+
"off",
|
|
7782
|
+
"low",
|
|
7783
|
+
"high",
|
|
7784
|
+
"max"
|
|
7785
|
+
]).default("low"),
|
|
6934
7786
|
chapterChars: z.number().default(3500),
|
|
6935
7787
|
maxTokens: z.number().default(12e3),
|
|
6936
7788
|
reviewPassScore: z.number().default(70),
|
|
@@ -6948,6 +7800,7 @@ const DEFAULT_OUTPUT_DIR = join(homedir(), ".dsh", "novels");
|
|
|
6948
7800
|
const DEFAULT_PROVIDER = "deepseek-official";
|
|
6949
7801
|
const DEFAULT_MODEL = "deepseek-v4-flash";
|
|
6950
7802
|
const DEFAULT_REASONING_EFFORT = "off";
|
|
7803
|
+
const DEFAULT_ANALYSIS_REASONING = "low";
|
|
6951
7804
|
const DEFAULT_CHAPTER_CHARS = 3500;
|
|
6952
7805
|
const DEFAULT_MAX_TOKENS = 12e3;
|
|
6953
7806
|
const DEFAULT_REVIEW_PASS_SCORE = 70;
|
|
@@ -6966,6 +7819,7 @@ function resolveConfig(value) {
|
|
|
6966
7819
|
provider: value?.provider ?? DEFAULT_PROVIDER,
|
|
6967
7820
|
model: value?.model ?? DEFAULT_MODEL,
|
|
6968
7821
|
reasoningEffort: value?.reasoningEffort ?? DEFAULT_REASONING_EFFORT,
|
|
7822
|
+
analysisReasoning: value?.analysisReasoning ?? DEFAULT_ANALYSIS_REASONING,
|
|
6969
7823
|
chapterChars: value?.chapterChars ?? DEFAULT_CHAPTER_CHARS,
|
|
6970
7824
|
maxTokens: value?.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
6971
7825
|
reviewPassScore: value?.reviewPassScore ?? DEFAULT_REVIEW_PASS_SCORE,
|
|
@@ -7000,6 +7854,7 @@ function apply(ctx, config) {
|
|
|
7000
7854
|
if (patch.provider !== void 0) next.provider = patch.provider;
|
|
7001
7855
|
if (patch.model !== void 0) next.model = patch.model;
|
|
7002
7856
|
if (patch.reasoningEffort !== void 0) next.reasoningEffort = patch.reasoningEffort;
|
|
7857
|
+
if (patch.analysisReasoning !== void 0) next.analysisReasoning = patch.analysisReasoning;
|
|
7003
7858
|
if (patch.chapterChars !== void 0) next.chapterChars = patch.chapterChars;
|
|
7004
7859
|
if (patch.maxTokens !== void 0) next.maxTokens = patch.maxTokens;
|
|
7005
7860
|
if (patch.reviewPassScore !== void 0) next.reviewPassScore = patch.reviewPassScore;
|