@waterwx/dsh-novel-forge 1.7.2 → 1.8.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.
Files changed (48) hide show
  1. package/README.md +1 -1
  2. package/lib/client.js +3788 -600
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +1001 -83
  5. package/lib/index.js.map +1 -1
  6. package/lib/types/author-assets.d.ts +25 -0
  7. package/lib/types/client/api.d.ts +18 -0
  8. package/lib/types/client/panel/AdaptModeView.d.ts +4 -0
  9. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  10. package/lib/types/client/panel/AuthorHome.d.ts +20 -0
  11. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  12. package/lib/types/client/panel/SettingsView.d.ts +14 -0
  13. package/lib/types/client/text.d.ts +9 -0
  14. package/lib/types/engine.d.ts +24 -1
  15. package/lib/types/index.d.ts +8 -0
  16. package/lib/types/protocol.d.ts +206 -10
  17. package/lib/types/shot-language.d.ts +46 -0
  18. package/lib/types/story-beat-language.d.ts +35 -0
  19. package/package.json +4 -2
  20. package/skills/chapter-batch/SKILL.md +81 -0
  21. package/src/author-assets.ts +166 -0
  22. package/src/client/api.ts +41 -0
  23. package/src/client/mount.tsx._new +97 -0
  24. package/src/client/mount.tsx._nf +97 -0
  25. package/src/client/mount.tsx._tmp +97 -0
  26. package/src/client/panel/AdaptModeView.tsx +204 -0
  27. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  28. package/src/client/panel/AuthorHome.tsx +93 -0
  29. package/src/client/panel/CreateBookView.tsx +4 -0
  30. package/src/client/panel/ImportModal.tsx +13 -3
  31. package/src/client/panel/NovelPanel.tsx +48 -16
  32. package/src/client/panel/ProgressHomeView.tsx +87 -0
  33. package/src/client/panel/RunPanel.tsx +1 -1
  34. package/src/client/panel/SceneLibrary.tsx +1 -1
  35. package/src/client/panel/SettingsView.tsx +285 -0
  36. package/src/client/panel/ShelfView.tsx +2 -2
  37. package/src/client/panel/StoryboardTab.tsx +42 -10
  38. package/src/client/panel/panel.module.css +325 -0
  39. package/src/client/sidebar-entry.ts +1 -1
  40. package/src/client/text.ts +28 -0
  41. package/src/engine.ts +256 -14
  42. package/src/index.ts +21 -0
  43. package/src/protocol.ts +218 -10
  44. package/src/routes.ts +204 -7
  45. package/src/run.ts +5 -4
  46. package/src/shot-language.ts +181 -0
  47. package/src/story-beat-language.ts +130 -0
  48. package/lib/types/client/panel/RoleVisualPanel.d.ts +0 -15
package/lib/index.js CHANGED
@@ -2,11 +2,11 @@ import { homedir } from "node:os";
2
2
  import { basename, extname, join } from "node:path";
3
3
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
4
4
  import z from "schemastery";
5
- import { exec } from "node:child_process";
5
+ import { spawn } from "node:child_process";
6
6
  import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
7
+ import { randomBytes } from "node:crypto";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import { BlockAssembler, ReasoningEffortId, createAssistantMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
9
- import { randomBytes } from "node:crypto";
10
10
  import { strFromU8, unzipSync } from "fflate";
11
11
  //#region src/assets.ts
12
12
  /** 预置写法模板(来自 AI-Novel-Writing-Assistant 内置 DEFAULT_STYLE_TEMPLATES)。 */
@@ -854,6 +854,338 @@ function styleKeywords(styleId, filterId) {
854
854
  return parts.join(",") || "3D动漫,超精细建模,电影光影";
855
855
  }
856
856
  //#endregion
857
+ //#region src/shot-language.ts
858
+ const SHOT_SIZES = [
859
+ {
860
+ id: "extreme_wide",
861
+ zh: "大远景",
862
+ en: "extreme wide shot",
863
+ hint: "环境为主,人物很小"
864
+ },
865
+ {
866
+ id: "wide",
867
+ zh: "远景",
868
+ en: "wide shot",
869
+ hint: "人在景中,交代环境"
870
+ },
871
+ {
872
+ id: "full",
873
+ zh: "全景",
874
+ en: "full shot",
875
+ hint: "全身』人物整体"
876
+ },
877
+ {
878
+ id: "medium",
879
+ zh: "中景",
880
+ en: "medium shot",
881
+ hint: "膝部以上,叙事主力"
882
+ },
883
+ {
884
+ id: "medium_close",
885
+ zh: "中近景",
886
+ en: "medium close-up",
887
+ hint: "胸以上,表情+动作"
888
+ },
889
+ {
890
+ id: "close",
891
+ zh: "近景",
892
+ en: "close-up",
893
+ hint: "肩以上,突出表情"
894
+ },
895
+ {
896
+ id: "extreme_close",
897
+ zh: "特写",
898
+ en: "extreme close-up",
899
+ hint: "脸/物细节"
900
+ },
901
+ {
902
+ id: "big_extreme_close",
903
+ zh: "大特写",
904
+ en: "big close-up",
905
+ hint: "局部(眼/手/标志物)"
906
+ }
907
+ ];
908
+ const CAMERA_MOVES = [
909
+ {
910
+ id: "static",
911
+ zh: "固定机位",
912
+ en: "static camera",
913
+ hint: "机位不动"
914
+ },
915
+ {
916
+ id: "dolly_in",
917
+ zh: "推近",
918
+ en: "dolly in",
919
+ hint: "镜头向主体逼近"
920
+ },
921
+ {
922
+ id: "dolly_out",
923
+ zh: "拉远",
924
+ en: "dolly out",
925
+ hint: "镜头远离主体"
926
+ },
927
+ {
928
+ id: "pan_left",
929
+ zh: "左摇",
930
+ en: "pan left",
931
+ hint: "机位不动,镜头左转"
932
+ },
933
+ {
934
+ id: "pan_right",
935
+ zh: "右摇",
936
+ en: "pan right",
937
+ hint: "机位不动,镜头右转"
938
+ },
939
+ {
940
+ id: "track_left",
941
+ zh: "左横移",
942
+ en: "track left",
943
+ hint: "机位随主体左移"
944
+ },
945
+ {
946
+ id: "track_right",
947
+ zh: "右横移",
948
+ en: "track right",
949
+ hint: "机位随主体右移"
950
+ },
951
+ {
952
+ id: "follow",
953
+ zh: "跟随",
954
+ en: "follow shot",
955
+ hint: "镜头跟着主体运动"
956
+ },
957
+ {
958
+ id: "pedestal_up",
959
+ zh: "升镜",
960
+ en: "pedestal up",
961
+ hint: "机位抬高"
962
+ },
963
+ {
964
+ id: "pedestal_down",
965
+ zh: "降镜",
966
+ en: "pedestal down",
967
+ hint: "机位降低"
968
+ },
969
+ {
970
+ id: "orbit",
971
+ zh: "环绕",
972
+ en: "orbit shot",
973
+ hint: "镜头绕主体转"
974
+ },
975
+ {
976
+ id: "handheld",
977
+ zh: "手持晃动",
978
+ en: "handheld",
979
+ hint: "真实感/紧张感"
980
+ },
981
+ {
982
+ id: "low_angle",
983
+ zh: "低机位仰拍",
984
+ en: "low angle",
985
+ hint: "突出威严/压迫"
986
+ },
987
+ {
988
+ id: "high_angle",
989
+ zh: "高机位俯拍",
990
+ en: "high angle",
991
+ hint: "突出渺小/全知"
992
+ },
993
+ {
994
+ id: "over_shoulder",
995
+ zh: "过肩镜头",
996
+ en: "over-the-shoulder",
997
+ hint: "对话常用"
998
+ }
999
+ ];
1000
+ const LIGHTINGS = [
1001
+ {
1002
+ id: "front",
1003
+ zh: "顺光",
1004
+ en: "front light",
1005
+ hint: "正面均匀照明"
1006
+ },
1007
+ {
1008
+ id: "side",
1009
+ zh: "侧光",
1010
+ en: "side light",
1011
+ hint: "明暗对比强"
1012
+ },
1013
+ {
1014
+ id: "back",
1015
+ zh: "逆光",
1016
+ en: "back light",
1017
+ hint: "轮廓光/剪影"
1018
+ },
1019
+ {
1020
+ id: "top",
1021
+ zh: "顶光",
1022
+ en: "top light",
1023
+ hint: "俯照硬朗"
1024
+ },
1025
+ {
1026
+ id: "rembrandt",
1027
+ zh: "伦勃朗光",
1028
+ en: "rembrandt lighting",
1029
+ hint: "侧逆光,经典人像"
1030
+ },
1031
+ {
1032
+ id: "neon",
1033
+ zh: "霓虹光",
1034
+ en: "neon lighting",
1035
+ hint: "赛博/夜景"
1036
+ },
1037
+ {
1038
+ id: "hard",
1039
+ zh: "硬光",
1040
+ en: "hard light",
1041
+ hint: "犀利阴影"
1042
+ },
1043
+ {
1044
+ id: "soft",
1045
+ zh: "柔光",
1046
+ en: "soft light",
1047
+ hint: "柔和过渡"
1048
+ },
1049
+ {
1050
+ id: "mood",
1051
+ zh: "氛围光",
1052
+ en: "mood lighting",
1053
+ hint: "情绪化色温"
1054
+ },
1055
+ {
1056
+ id: "contrast",
1057
+ zh: "高反差",
1058
+ en: "high contrast",
1059
+ hint: "黑白/强明暗"
1060
+ }
1061
+ ];
1062
+ /** 从中文文本归一化到景别(处理旧数据/LLM 口语)。未知回退 medium。 */
1063
+ function normalizeShotSize(text) {
1064
+ if (text === void 0 || text === "") return "medium";
1065
+ const t = text.trim();
1066
+ if (t.includes("大远景")) return "extreme_wide";
1067
+ if (t.includes("远景")) return "wide";
1068
+ if (t.includes("全景")) return "full";
1069
+ if (t.includes("中近景") || t.includes("中景")) return "medium";
1070
+ if (t.includes("近景") || t.includes("胸")) return "close";
1071
+ if (t.includes("大特写")) return "big_extreme_close";
1072
+ if (t.includes("特写")) return "extreme_close";
1073
+ return "medium";
1074
+ }
1075
+ /** 从中文文本归一化到运镜列表。 */
1076
+ function normalizeCameras(text) {
1077
+ if (text === void 0 || text === "") return ["static"];
1078
+ const t = text.trim();
1079
+ const out = [];
1080
+ const push = (id) => {
1081
+ if (!out.includes(id)) out.push(id);
1082
+ };
1083
+ if (t.includes("推近") || t.includes("推进")) push("dolly_in");
1084
+ if (t.includes("拉远") || t.includes("拉出")) push("dolly_out");
1085
+ if (t.includes("左摇")) push("pan_left");
1086
+ if (t.includes("右摇")) push("pan_right");
1087
+ if (t.includes("横移")) push(t.includes("左") ? "track_left" : "track_right");
1088
+ if (t.includes("跟随") || t.includes("跟拍")) push("follow");
1089
+ if (t.includes("升降") || t.includes("升") || t.includes("降")) push("pedestal_up");
1090
+ if (t.includes("环绕")) push("orbit");
1091
+ if (t.includes("手持") || t.includes("晃动")) push("handheld");
1092
+ if (t.includes("低机位") || t.includes("仰拍") || t.includes("仰")) push("low_angle");
1093
+ if (t.includes("高机位") || t.includes("俯拍") || t.includes("俯")) push("high_angle");
1094
+ if (t.includes("过肩")) push("over_shoulder");
1095
+ if (out.length === 0) push("static");
1096
+ return out;
1097
+ }
1098
+ /** 从中文文本归一化到构图(可选)。 */
1099
+ function normalizeComposition(text) {
1100
+ if (text === void 0 || text === "") return void 0;
1101
+ const t = text.trim();
1102
+ if (t.includes("三分")) return "rule_of_thirds";
1103
+ if (t.includes("中心") || t.includes("居中")) return "center";
1104
+ if (t.includes("引导")) return "leading_line";
1105
+ if (t.includes("前景")) return "foreground";
1106
+ if (t.includes("低机位")) return "low";
1107
+ if (t.includes("俯拍") || t.includes("俯视")) return "overhead";
1108
+ if (t.includes("对称")) return "symmetry";
1109
+ }
1110
+ /** 从中文文本归一化到光效列表。 */
1111
+ function normalizeLightings(text) {
1112
+ if (text === void 0 || text === "") return ["soft"];
1113
+ const t = text.trim();
1114
+ const out = [];
1115
+ const push = (id) => {
1116
+ if (!out.includes(id)) out.push(id);
1117
+ };
1118
+ if (t.includes("顺光")) push("front");
1119
+ if (t.includes("侧光") || t.includes("伦勃朗")) push(t.includes("伦勃朗") ? "rembrandt" : "side");
1120
+ if (t.includes("逆光")) push("back");
1121
+ if (t.includes("顶光")) push("top");
1122
+ if (t.includes("霓虹")) push("neon");
1123
+ if (t.includes("硬光")) push("hard");
1124
+ if (t.includes("柔光")) push("soft");
1125
+ if (t.includes("氛围") || t.includes("情绪")) push("mood");
1126
+ if (t.includes("高反差")) push("contrast");
1127
+ if (out.length === 0) push("soft");
1128
+ return out;
1129
+ }
1130
+ /** 取词条中文(ZHs 合并句子)。 */
1131
+ function sizeZh(id) {
1132
+ return SHOT_SIZES.find((e) => e.id === id)?.zh ?? "中景";
1133
+ }
1134
+ function cameraZh(ids) {
1135
+ if (ids === void 0 || ids.length === 0) return "固定机位";
1136
+ return CAMERA_MOVES.filter((e) => ids.includes(e.id)).map((e) => e.zh).join(" + ");
1137
+ }
1138
+ function lightZh(ids) {
1139
+ if (ids === void 0 || ids.length === 0) return "柔光";
1140
+ return LIGHTINGS.filter((e) => ids.includes(e.id)).map((e) => e.zh).join(" + ");
1141
+ }
1142
+ //#endregion
1143
+ //#region src/story-beat-language.ts
1144
+ /** 从中文归一化叙事功能(未知回退 exposition)。 */
1145
+ function normalizeStoryFunction(text) {
1146
+ if (text === void 0 || text === "") return "exposition";
1147
+ const t = text.trim();
1148
+ if (t.includes("铺垫")) return "exposition";
1149
+ if (t.includes("冲突")) return "conflict";
1150
+ if (t.includes("转折")) return "turn";
1151
+ if (t.includes("高潮")) return "climax";
1152
+ if (t.includes("收束")) return "resolve";
1153
+ if (t.includes("伏笔")) return "foreshadow";
1154
+ if (t.includes("人物塑造")) return "character";
1155
+ return "exposition";
1156
+ }
1157
+ /** 从中文文本归一化情绪词列表(可含→箭头链)。 */
1158
+ function normalizeEmotions(text) {
1159
+ if (text === void 0 || text === "") return ["calm"];
1160
+ const t = text.trim();
1161
+ const out = [];
1162
+ const push = (id) => {
1163
+ if (!out.includes(id)) out.push(id);
1164
+ };
1165
+ if (t.includes("平静")) push("calm");
1166
+ if (t.includes("淡然")) push("indifferent");
1167
+ if (t.includes("期待")) push("expectant");
1168
+ if (t.includes("好奇")) push("curious");
1169
+ if (t.includes("警觉")) push("alert");
1170
+ if (t.includes("压抑")) push("suppressed");
1171
+ if (t.includes("隐忍")) push("enduring");
1172
+ if (t.includes("担忧")) push("worried");
1173
+ if (t.includes("焦躁")) push("irritable");
1174
+ if (t.includes("不安")) push("uneasy");
1175
+ if (t.includes("惊惧")) push("terrified");
1176
+ if (t.includes("愤怒")) push("angry");
1177
+ if (t.includes("崩溃")) push("collapsing");
1178
+ if (t.includes("决绝")) push("resolute");
1179
+ if (t.includes("痛心")) push("grieved");
1180
+ if (t.includes("释然")) push("relieved");
1181
+ if (t.includes("悲凉")) push("bittersweet");
1182
+ if (t.includes("得意")) push("triumphant");
1183
+ if (t.includes("重生")) push("reborn");
1184
+ if (t.includes("麻木")) push("numb");
1185
+ if (out.length === 0) push("calm");
1186
+ return out;
1187
+ }
1188
+ //#endregion
857
1189
  //#region src/engine.ts
858
1190
  /**
859
1191
  * Novel engine — the host half's core: LLM-driven story-bible extraction,
@@ -3835,8 +4167,11 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
3835
4167
  system: [
3836
4168
  "你是一位从业 10 年的电影导演兼分镜师,专长网文改编影视化。",
3837
4169
  "任务:把「剧情骨架」的每一个节拍展开为 1-3 个电影镜头,输出分镜表——只做画面层,禁止新增或改变剧情(骨架是只读输入)。",
3838
- "输出合法 JSON 对象:{\"shots\": [{\"beatId\": \"骨架节拍id\", \"shot\": \"景别\", \"camera\": \"机位与运镜\", \"duration\": 秒数, \"visual\": \"画面内容\", \"line\": \"台词/旁白\", \"sound\": \"音效\", \"light\": \"光效\", \"prevState\": \"承接上一镜头结尾状态\", \"nextState\": \"本镜头结束状态\", \"characters\": [\"出镜角色名\"]}]}",
3839
- "景别取值:远景/全景/中景/近景/特写。",
4170
+ "输出合法 JSON 对象:{\"shots\": [{\"beatId\": \"骨架节拍id\", \"shot\": \"景别\", \"camera\": \"机位与运镜\", \"composition\": \"构图\", \"duration\": 秒数, \"visual\": \"画面内容\", \"line\": \"台词/旁白\", \"sound\": \"音效\", \"light\": \"光效\", \"prevState\": \"承接上一镜头结尾状态\", \"nextState\": \"本镜头结束状态\", \"characters\": [\"出镜角色名\"]}]}",
4171
+ "景别取值(只能从这些词中选一):大远景/远景/全景/中景/中近景/近景/特写/大特写。",
4172
+ "运镜取值(可组合,用加号连接):固定机位/推近/拉远/左摇/右摇/左横移/右横移/跟随/升镜/降镜/环绕/手持晃动/低机位仰拍/高机位俯拍/过肩镜头。",
4173
+ "光效取值(可组合,用加号连接):顺光/侧光/逆光/顶光/伦勃朗光/霓虹光/硬光/柔光/氛围光/高反差。",
4174
+ "构图取值(可选,只能从这些词中选一):三分法/中心对称/引导线/前景遮挡/低机位/俯拍/对称构图。",
3840
4175
  "硬性要求:",
3841
4176
  "【视觉风格】(必须内嵌进 visual/light 的画面措辞与光效描述):",
3842
4177
  ...styleLines,
@@ -3862,27 +4197,21 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
3862
4197
  body.replace(/^#\s+.*$/m, "").trim().slice(0, 3e3)
3863
4198
  ].filter((s) => s !== "").join("\n\n"),
3864
4199
  temperature: .3,
3865
- maxTokens: Math.max(config.maxTokens, 6e3),
4200
+ maxTokens: Math.max(config.maxTokens, 16e3),
3866
4201
  reasoning: config.analysisReasoning ?? "low"
3867
4202
  }));
3868
4203
  const beatIds = new Set(skeleton.beats.map((b) => b.id));
3869
- const shotKinds = /* @__PURE__ */ new Set([
3870
- "远景",
3871
- "全景",
3872
- "中景",
3873
- "近景",
3874
- "特写"
3875
- ]);
3876
4204
  const shots = Array.isArray(raw.shots) ? raw.shots.filter((v) => typeof v === "object" && v !== null).map((entry, i) => ({
3877
4205
  id: "s" + (i + 1),
3878
4206
  beatId: beatIds.has(entry.beatId) ? entry.beatId : skeleton.beats[0].id,
3879
- shot: shotKinds.has(entry.shot) ? entry.shot : "中景",
3880
- camera: typeof entry.camera === "string" ? entry.camera.trim().slice(0, 80) : "",
4207
+ shot: normalizeShotSize(entry.shot),
4208
+ camera: normalizeCameras(entry.camera),
4209
+ composition: normalizeComposition(entry.composition),
3881
4210
  duration: typeof entry.duration === "number" && entry.duration >= 1 && entry.duration <= 12 ? Math.round(entry.duration) : 4,
3882
4211
  visual: typeof entry.visual === "string" ? entry.visual.trim().slice(0, 300) : "",
3883
4212
  line: typeof entry.line === "string" ? entry.line.trim().slice(0, 120) : "",
3884
4213
  sound: typeof entry.sound === "string" ? entry.sound.trim().slice(0, 80) : "",
3885
- light: typeof entry.light === "string" ? entry.light.trim().slice(0, 80) : "",
4214
+ light: normalizeLightings(entry.light),
3886
4215
  prevState: typeof entry.prevState === "string" ? entry.prevState.trim().slice(0, 150) : "",
3887
4216
  nextState: typeof entry.nextState === "string" ? entry.nextState.trim().slice(0, 150) : "",
3888
4217
  characters: sanitizeCharacters(entry.characters, 4)
@@ -3940,12 +4269,12 @@ async function generateStoryboardPrompts(ctx, config, project, outputDir, chapte
3940
4269
  const ref = c.imageUrl !== void 0 ? "(参考图)" : "";
3941
4270
  return c.name + ":" + anchor + ref;
3942
4271
  }).join(";");
3943
- return `[${s.id}] 节拍${s.beatId} · ${s.shot} · ${s.camera} · ${s.duration}s
4272
+ return `[${s.id}] 节拍${s.beatId} · ${sizeZh(s.shot)} · ${cameraZh(s.camera)} · ${s.duration}s
3944
4273
  出场:${s.characters !== void 0 && s.characters.length > 0 ? s.characters.join("、") : "(未标注)"}
3945
4274
  定妆:${makeUp !== "" ? makeUp : "(未绑定漫剧卡)"}
3946
4275
  画面:${s.visual}
3947
4276
  台词:${s.line !== "" ? s.line : "(无)"}
3948
- 光效:${s.light !== "" ? s.light : "(无)"}
4277
+ 光效:${lightZh(s.light) !== "" ? lightZh(s.light) : "(无)"}
3949
4278
  承接:${s.prevState} → ${s.nextState}`;
3950
4279
  }).join("\n\n");
3951
4280
  const raw = parseJsonObject(await complete(ctx, config, {
@@ -3970,7 +4299,7 @@ async function generateStoryboardPrompts(ctx, config, project, outputDir, chapte
3970
4299
  "只输出 JSON 对象。"
3971
4300
  ].filter((x) => x !== "").join("\n\n"),
3972
4301
  temperature: .3,
3973
- maxTokens: Math.max(config.maxTokens, 6e3),
4302
+ maxTokens: Math.max(config.maxTokens, 16e3),
3974
4303
  reasoning: config.analysisReasoning ?? "low"
3975
4304
  }));
3976
4305
  const shotIds = new Set(table.shots.map((s) => s.id));
@@ -4005,7 +4334,9 @@ async function generateStoryboardSkeleton(ctx, config, project, outputDir, chapt
4005
4334
  system: [
4006
4335
  "你是一位从业 15 年的电影编剧,专长网文改编影视化,深谙三幕结构与节拍(beat)写作。",
4007
4336
  "任务:把这一章改编成影视化「剧情骨架」——只做故事层,不做画面。",
4008
- "输出合法 JSON 对象:{\"arc\": \"本章弧线一句话(起承转合,20-60字)\", \"beats\": [{\"event\": \"事件一句话(发生了什么)\", \"emotion\": \"人物情绪走向(如 压抑→隐忍→惊惧)\", \"function\": \"铺垫|冲突|转折|高潮|收束|伏笔|人物塑造\", \"cause\": \"承接上一节拍的原因(可省略)\"}], \"characters\": [\"出镜角色名1\", \"出镜角色名2\"]}",
4337
+ "输出合法 JSON 对象:{\"arc\": \"本章弧线一句话(起承转合,20-60字)\", \"beats\": [{\"event\": \"事件一句话(发生了什么)\", \"emotion\": \"人物情绪走向(情绪词数组)\", \"function\": \"铺垫|冲突|转折|高潮|收束|伏笔|人物塑造\", \"cause\": \"承接上一节拍的原因(可省略)\"}], \"characters\": [\"出镜角色名1\", \"出镜角色名2\"]}",
4338
+ "情绪词取值(从这些词中选 1-3 个,用数组输出,按情绪发展顺序):平静/淡然/期待/好奇/警觉/压抑/隐忍/担忧/焦躁/不安/惊惧/愤怒/崩溃/决绝/痛心/释然/悲凉/得意/重生/麻木。",
4339
+ "功能取值(只能从这些词中选一):铺垫/冲突/转折/高潮/收束/伏笔/人物塑造。",
4009
4340
  "硬性要求:",
4010
4341
  "1. beats 数量 4-9 个,严格按时间顺序,因果链完整:前一个 beat 的结果是后一个 beat 的原因。",
4011
4342
  "2. 必须覆盖本章全部剧情要点与正文关键事件,遗漏关键事件视为失败。",
@@ -4025,20 +4356,11 @@ async function generateStoryboardSkeleton(ctx, config, project, outputDir, chapt
4025
4356
  reasoning: config.analysisReasoning ?? "low"
4026
4357
  }));
4027
4358
  const arc = typeof raw.arc === "string" ? raw.arc.trim().slice(0, 200) : "";
4028
- const functions = /* @__PURE__ */ new Set([
4029
- "铺垫",
4030
- "冲突",
4031
- "转折",
4032
- "高潮",
4033
- "收束",
4034
- "伏笔",
4035
- "人物塑造"
4036
- ]);
4037
4359
  const beats = Array.isArray(raw.beats) ? raw.beats.filter((v) => typeof v === "object" && v !== null).map((entry, i) => ({
4038
4360
  id: "b" + (i + 1),
4039
4361
  event: typeof entry.event === "string" ? entry.event.trim().slice(0, 200) : "",
4040
- emotion: typeof entry.emotion === "string" ? entry.emotion.trim().slice(0, 100) : "",
4041
- function: functions.has(entry.function) ? entry.function : "铺垫",
4362
+ emotion: Array.isArray(entry.emotion) ? normalizeEmotions(entry.emotion.filter((x) => typeof x === "string").join("→")) : normalizeEmotions(typeof entry.emotion === "string" ? entry.emotion : void 0),
4363
+ function: normalizeStoryFunction(typeof entry.function === "string" ? entry.function : void 0),
4042
4364
  cause: typeof entry.cause === "string" && entry.cause.trim() !== "" ? entry.cause.trim().slice(0, 150) : void 0
4043
4365
  })).filter((b) => b.event !== "") : [];
4044
4366
  if (beats.length === 0) throw new Error("模型未输出有效节拍链,请重试");
@@ -4100,6 +4422,209 @@ async function reverseOutlineFromChapters(ctx, config, project, outputDir, onPro
4100
4422
  return outline.trim();
4101
4423
  }
4102
4424
  /**
4425
+ * 改编模式 P0:全文分析 → 原文设定卡片 / 可改范围矩阵。
4426
+ * 拆章统计 + 取样正文,让 LLM 一次输出结构化 JSON:
4427
+ * { bookName, outline, dimensions: [{key,title,mutability,current,evidence,candidates,impact,risk}] }。
4428
+ */
4429
+ async function analyzeAdaptation(ctx, config, text) {
4430
+ const chapters = splitBookText(text).filter((c) => c.body.length >= 50);
4431
+ if (chapters.length === 0) throw new Error("未能从全文拆出章节(内容过短或无章节结构)");
4432
+ const sampleBodies = [];
4433
+ const n = chapters.length;
4434
+ const pick = (i) => {
4435
+ const c = chapters[i];
4436
+ if (c !== void 0) sampleBodies.push("第" + c.no + "章《" + (c.title || "无题") + "》\n" + c.body.slice(0, 1200));
4437
+ };
4438
+ pick(0);
4439
+ if (n > 3) {
4440
+ pick(Math.floor(n / 3));
4441
+ pick(Math.floor(2 * n / 3));
4442
+ }
4443
+ if (n > 1) pick(n - 1);
4444
+ const raw = parseJsonObject(await complete(ctx, config, {
4445
+ system: "你是改编策划分析助手。",
4446
+ user: [
4447
+ "你是一位资深网文编辑兼改编策划。下面给你一部已完结/连载小说的若干章正文节选。请通读并输出该书的「原文设定卡片」与「可改范围矩阵」。",
4448
+ "输出合法 JSON 对象:",
4449
+ "{\"bookName\": \"书名\", \"outline\": \"一句话主线梗概(100字内)\", \"dimensions\": [{\"key\":\"realm\",\"title\":\"大世界\",\"mutability\":\"big|small|free|locked|visual\",\"current\":\"当前值\",\"evidence\":\"证据(出现章节/频次)\",\"candidates\":[\"候选1\",\"候选2\"],\"impact\":\"改了会影响什么\",\"risk\":\"high|medium|low\"}]}",
4450
+ "dimensions 至少覆盖以下维度(key/title):realm 大世界、cultivation 修为体系、protagonist 主角、goldenFinger 金手指、supporting 配角与势力人物名、faction 势力/组织、style 文风与叙事、ending 结局走向、timeline 时间线/编年、foreshadow 伏笔/暗线。",
4451
+ "mutability 取值:locked=建议保留、big=可改影响大、small=可改影响小、free=可自由改、visual=仅视觉包装。",
4452
+ "每个 dimension.current 必须忠于文本,能引用原文就用原文(尤其角色名/境界名/势力名/金手指名)。",
4453
+ "重要:所有字符串值内部不得包含换行符;JSON 必须在一段内完整结束;直接输出 JSON,不要 Markdown 代码块。"
4454
+ ].join("\n") + "\n\n" + sampleBodies.join("\n\n---\n\n"),
4455
+ temperature: .3,
4456
+ maxTokens: Math.max(config.maxTokens, 6e3),
4457
+ reasoning: config.analysisReasoning ?? "low"
4458
+ }));
4459
+ const bookName = typeof raw.bookName === "string" ? raw.bookName.trim() : "未命名";
4460
+ const dims = Array.isArray(raw.dimensions) ? raw.dimensions.filter((d) => typeof d === "object" && d !== null).map((d) => normalizeAdaptationDimension(d)).filter((d) => d !== null) : [];
4461
+ let outline;
4462
+ try {
4463
+ outline = await reverseOutlineFromAdaptationText(ctx, config, chapters);
4464
+ } catch {
4465
+ outline = typeof raw.outline === "string" && raw.outline.trim() !== "" ? raw.outline.trim() : void 0;
4466
+ }
4467
+ return {
4468
+ bookName,
4469
+ chapters: chapters.length,
4470
+ outline,
4471
+ dimensions: dims,
4472
+ note: "基于节选分析(首/中/末取样)+ 反推大纲。如需逐章全文级深度分析请后续启用全文流式入口。"
4473
+ };
4474
+ }
4475
+ /** 校验并归一化一行的改编维度数据(来自 LLM)。 */
4476
+ function normalizeAdaptationDimension(d) {
4477
+ const key = typeof d.key === "string" ? d.key : "";
4478
+ const title = typeof d.title === "string" ? d.title : "";
4479
+ if (key === "" || title === "") return null;
4480
+ const mutability = [
4481
+ "locked",
4482
+ "big",
4483
+ "small",
4484
+ "free",
4485
+ "visual"
4486
+ ].includes(d.mutability) ? d.mutability : "small";
4487
+ const risk = [
4488
+ "high",
4489
+ "medium",
4490
+ "low"
4491
+ ].includes(d.risk) ? d.risk : "medium";
4492
+ return {
4493
+ key,
4494
+ title,
4495
+ mutability,
4496
+ current: typeof d.current === "string" ? d.current : "",
4497
+ evidence: typeof d.evidence === "string" ? d.evidence : void 0,
4498
+ candidates: Array.isArray(d.candidates) ? d.candidates.filter((x) => typeof x === "string") : [],
4499
+ impact: typeof d.impact === "string" ? d.impact : "",
4500
+ risk
4501
+ };
4502
+ }
4503
+ /** 从全文拆出的章节节选反推全书总纲(Markdown),用于改编 P0 的「反推大纲」。 */
4504
+ async function reverseOutlineFromAdaptationText(ctx, config, chapters) {
4505
+ const n = chapters.length;
4506
+ const sample = [];
4507
+ const count = Math.min(n, 20);
4508
+ for (let i = 0; i < count; i++) {
4509
+ const c = chapters[Math.floor(i * n / count)];
4510
+ if (c === void 0) continue;
4511
+ sample.push("第" + c.no + "章《" + (c.title || "无题") + "》\n" + c.body.slice(0, 600));
4512
+ }
4513
+ return (await complete(ctx, config, {
4514
+ system: [
4515
+ "你是一位经验丰富的小说主编。根据下面若干章节的正文节选,反推出这本书的总纲(可作为后续改编与章节续写的骨架)。",
4516
+ "要求:",
4517
+ "1. 第一行写《书名》(可从正文推断,否则《未命名》)。",
4518
+ "2. 按故事弧线划分卷/部分:每卷给卷名与主旨(标注覆盖章节范围)。",
4519
+ "3. 对每章列出:章节号 + 标题 + 一句话核心情节。",
4520
+ "4. 最后给出:全书主线、主要人物弧线、已埋设待回收的伏笔清单。",
4521
+ "5. 输出纯文本 Markdown 结构(# 一级标题、## 二级标题、- 列表),不要寒暄,不要其他输出。"
4522
+ ].join("\n"),
4523
+ user: sample.join("\n\n---\n\n"),
4524
+ temperature: .4,
4525
+ maxTokens: Math.max(config.maxTokens, 6e3),
4526
+ reasoning: config.analysisReasoning ?? "low"
4527
+ })).trim();
4528
+ }
4529
+ /** 改编方案:由用户勾选的维度与新值,生成 LLM 映射表/规则/影响清单。 */
4530
+ async function proposeAdaptation(ctx, config, text, selections, dimensions) {
4531
+ const raw = parseJsonObject(await complete(ctx, config, {
4532
+ system: "你是改编策划。",
4533
+ user: [
4534
+ "下面给出改编决策:用户想改哪些维度、改成什么值。请生成一份可执行的「改编方案」。",
4535
+ "要求输出合法 JSON 对象:",
4536
+ "{\"mappings\": [{\"source\":\"原值\",\"target\":\"新值\",\"scope\":\"name|realm|faction|term|other\",\"note\":\"说明\"}], \"rules\": {\"preserve\":[\"必须保留的要素\"],\"change\":[\"允许改变的要素\"],\"constraints\":[\"改编红线/一致性要求\"]}, \"impacts\": [{\"item\":\"受影响项\",\"detail\":\"说明\",\"risk\":\"high|medium|low\",\"chapters\":[章号]}]}",
4537
+ "mappings 需把用户确认的新值展开为「原→新」条目(如主角名/境界名/势力名/术语),并补充用户未填但关联的必改项(如改了修为体系名,相关的境界名一并列映射)。",
4538
+ "rules.preserve 至少包含:故事骨架、人物动机、伏笔逻辑、爽点结构。",
4539
+ "impacts 列出每个改动会影响的内容(术语/角色/章节/伏笔),能定位章号尽量定位;无法定位则给章节区间提示。",
4540
+ "重要:所有字符串值内部不得包含换行符;JSON 必须在一段内完整结束;直接输出 JSON,不要 Markdown 代码块。",
4541
+ "",
4542
+ "用户要改:",
4543
+ selections.map((s) => "- " + s.title + "(" + s.key + "):" + s.current + " → " + s.target).join("\n"),
4544
+ "",
4545
+ "原文可改矩阵(已分析):",
4546
+ (dimensions ?? []).map((d) => "- " + d.title + ":" + d.current + "(可改度:" + d.mutability + ",风险:" + d.risk + ")").join("\n")
4547
+ ].join("\n"),
4548
+ temperature: .3,
4549
+ maxTokens: Math.max(config.maxTokens, 6e3),
4550
+ reasoning: config.analysisReasoning ?? "low"
4551
+ }));
4552
+ return { proposal: {
4553
+ mappings: Array.isArray(raw.mappings) ? raw.mappings.filter((m) => typeof m === "object" && m !== null).map((m) => normalizeAdaptationMapping(m)).filter((m) => m !== null) : [],
4554
+ rules: normalizeAdaptationRules(raw.rules),
4555
+ impacts: Array.isArray(raw.impacts) ? raw.impacts.filter((i) => typeof i === "object" && i !== null).map((i) => normalizeAdaptationImpact(i)) : []
4556
+ } };
4557
+ }
4558
+ /** 剧本术语替换执行:按映射表做精确替换并统计命中。 */
4559
+ function applyAdaptationReplacements(text, mappings) {
4560
+ const seen = /* @__PURE__ */ new Set();
4561
+ const unique = mappings.filter((m) => {
4562
+ const s = m.source.trim();
4563
+ if (s === "" || s === m.target.trim()) return false;
4564
+ if (seen.has(s)) return false;
4565
+ seen.add(s);
4566
+ return true;
4567
+ }).sort((a, b) => b.source.length - a.source.length);
4568
+ let adapted = text;
4569
+ const hits = [];
4570
+ for (const m of unique) {
4571
+ const count = adapted.split(m.source).length - 1;
4572
+ if (count > 0) adapted = adapted.split(m.source).join(m.target);
4573
+ hits.push({
4574
+ source: m.source,
4575
+ target: m.target,
4576
+ count
4577
+ });
4578
+ }
4579
+ return {
4580
+ adaptedText: adapted,
4581
+ hits
4582
+ };
4583
+ }
4584
+ /** 校验归一化一条映射(来自 LLM)。 */
4585
+ function normalizeAdaptationMapping(m) {
4586
+ const source = typeof m.source === "string" ? m.source.trim() : "";
4587
+ const target = typeof m.target === "string" ? m.target.trim() : "";
4588
+ if (source === "" || target === "") return null;
4589
+ return {
4590
+ source,
4591
+ target,
4592
+ scope: [
4593
+ "name",
4594
+ "realm",
4595
+ "faction",
4596
+ "term",
4597
+ "other"
4598
+ ].includes(m.scope) ? m.scope : "other",
4599
+ note: typeof m.note === "string" ? m.note : void 0
4600
+ };
4601
+ }
4602
+ /** 校验归一化改编规则(来自 LLM)。 */
4603
+ function normalizeAdaptationRules(r) {
4604
+ const obj = typeof r === "object" && r !== null ? r : {};
4605
+ const arr = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
4606
+ return {
4607
+ preserve: arr(obj.preserve),
4608
+ change: arr(obj.change),
4609
+ constraints: arr(obj.constraints)
4610
+ };
4611
+ }
4612
+ /** 校验归一化一条影响项(来自 LLM)。 */
4613
+ function normalizeAdaptationImpact(i) {
4614
+ const risk = [
4615
+ "high",
4616
+ "medium",
4617
+ "low"
4618
+ ].includes(i.risk) ? i.risk : "medium";
4619
+ const chapters = Array.isArray(i.chapters) ? i.chapters.filter((x) => typeof x === "number") : void 0;
4620
+ return {
4621
+ item: typeof i.item === "string" ? i.item : "",
4622
+ detail: typeof i.detail === "string" ? i.detail : "",
4623
+ risk,
4624
+ chapters: chapters !== void 0 && chapters.length > 0 ? chapters : void 0
4625
+ };
4626
+ }
4627
+ /**
4103
4628
  * 摘要 + 事实抽取合并为一次 LLM 调用(省一次调用与一次正文输入,
4104
4629
  * 批量生成时整体开销约省 25%)。
4105
4630
  * @returns 摘要与新增事实条数(失败返回空,调用方 best-effort)。
@@ -5113,10 +5638,11 @@ var ProductionRunner = class {
5113
5638
  }
5114
5639
  applyDraft(project, chapter, draft, report) {
5115
5640
  const config = this.deps.getConfig();
5641
+ const outputDir = this.bookDir ?? config.outputDir;
5116
5642
  const fileName = chapterFileName(chapter);
5117
- mkdirSync(config.outputDir, { recursive: true });
5118
- const targetPath = join(config.outputDir, fileName);
5119
- if (existsSync(targetPath)) copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, "")}.bak.md`));
5643
+ mkdirSync(outputDir, { recursive: true });
5644
+ const targetPath = join(outputDir, fileName);
5645
+ if (existsSync(targetPath)) copyFileSync(targetPath, join(outputDir, `${fileName.replace(/\.md$/, "")}.bak.md`));
5120
5646
  writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, "utf8");
5121
5647
  chapter.pendingDraft = void 0;
5122
5648
  chapter.chars = draft.length;
@@ -5130,17 +5656,11 @@ var ProductionRunner = class {
5130
5656
  }
5131
5657
  chapter.error = void 0;
5132
5658
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
5133
- saveProject(config.outputDir, project);
5659
+ saveProject(outputDir, project);
5134
5660
  }
5135
5661
  };
5136
5662
  //#endregion
5137
5663
  //#region src/protocol.ts
5138
- /**
5139
- * dsh-novel-forge — shared protocol between the host half (Node) and the
5140
- * browser half (web GUI). Route paths, request/response shapes, the project
5141
- * state file format, and the NDJSON generation stream frames all live here so
5142
- * both halves spell exactly one vocabulary.
5143
- */
5144
5664
  /** The /api/dsh-novel-forge route family (same-origin, loopback-fenced). */
5145
5665
  const NOVEL_API = {
5146
5666
  status: "/api/dsh-novel-forge/status",
@@ -5238,7 +5758,25 @@ const NOVEL_API = {
5238
5758
  /** 生产单状态(含进度统计与日志)。 */
5239
5759
  runStatus: "/api/dsh-novel-forge/run/status",
5240
5760
  config: "/api/dsh-novel-forge/config",
5241
- openFolder: "/api/dsh-novel-forge/open-folder"
5761
+ openFolder: "/api/dsh-novel-forge/open-folder",
5762
+ /** 作者资产库/总数据:读取个人跨书资产(笔法/红线/套路/角色模板/世界观模板)。 */
5763
+ authorAssets: "/api/dsh-novel-forge/author-assets",
5764
+ /** 作者资产库:新增/更新一条资产(upsert by id)。 */
5765
+ authorAssetsUpsert: "/api/dsh-novel-forge/author-assets/upsert",
5766
+ /** 作者资产库:删除一条资产。 */
5767
+ authorAssetsRemove: "/api/dsh-novel-forge/author-assets/remove",
5768
+ /** 作者资产库:导入默认(书架书的写作资产/角色 + 内置全局库)批量沉淀。 */
5769
+ authorAssetsImportDefault: "/api/dsh-novel-forge/author-assets/import-default",
5770
+ /** 改编模式:上传全文 → 分析 → 原文设定卡片/可改范围矩阵。 */
5771
+ adaptAnalyze: "/api/dsh-novel-forge/adapt/analyze",
5772
+ /** 改编模式:确认要改的维度 → 生成映射表/改编规则/联动影响清单。 */
5773
+ adaptPropose: "/api/dsh-novel-forge/adapt/propose",
5774
+ /** 改编模式:执行术语替换(全局替换 + 命中统计 + 改编文本预览)。 */
5775
+ adaptExecute: "/api/dsh-novel-forge/adapt/execute",
5776
+ /** 主题自定义背景:上传图片(POST,存盘并返回服务端 URL)。 */
5777
+ themeBackgroundUpload: "/api/dsh-novel-forge/theme/background",
5778
+ /** 主题自定义背景:读取已上传文件(GET prefix,/theme/background/<name>)。 */
5779
+ themeBackgroundGet: "/api/dsh-novel-forge/theme/background"
5242
5780
  };
5243
5781
  //#endregion
5244
5782
  //#region src/docx.ts
@@ -6030,6 +6568,169 @@ async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
6030
6568
  }
6031
6569
  }
6032
6570
  //#endregion
6571
+ //#region src/author-assets.ts
6572
+ /**
6573
+ * 作者资产库/总数据(跨书)— 按作者维度聚合的可复用资产。
6574
+ * 与书架同惯例持久化到 ~/.dsh/dsh-novel-forge-author-assets.json。
6575
+ * 提供读取/持久化/新增或更新/删除,以及「导入默认」:把书架书的写作资产/角色 +
6576
+ * 内置全局库(题材/反AI规则/风格模板/推进模式)批量沉淀成资产条目(默认库)。
6577
+ */
6578
+ /** 作者资产库配置文件路径。 */
6579
+ function authorAssetsFile() {
6580
+ return join(homedir(), ".dsh", "dsh-novel-forge-author-assets.json");
6581
+ }
6582
+ /** 默认空资产库。 */
6583
+ function defaultLibrary() {
6584
+ return {
6585
+ version: 1,
6586
+ items: []
6587
+ };
6588
+ }
6589
+ /** 读取作者资产库(不存在/损坏则返回空库,不抛错)。 */
6590
+ function loadAuthorAssets() {
6591
+ const file = authorAssetsFile();
6592
+ if (!existsSync(file)) return defaultLibrary();
6593
+ try {
6594
+ let raw = readFileSync(file, "utf8");
6595
+ if (raw.charCodeAt(0) === 65279) raw = raw.slice(1);
6596
+ const parsed = JSON.parse(raw);
6597
+ if (!Array.isArray(parsed.items)) return defaultLibrary();
6598
+ return {
6599
+ version: 1,
6600
+ items: parsed.items
6601
+ };
6602
+ } catch {
6603
+ return defaultLibrary();
6604
+ }
6605
+ }
6606
+ /** 持久化作者资产库。 */
6607
+ function saveAuthorAssets(library) {
6608
+ const file = authorAssetsFile();
6609
+ mkdirSync(join(homedir(), ".dsh"), { recursive: true });
6610
+ writeFileSync(file, JSON.stringify(library, null, 2), "utf8");
6611
+ }
6612
+ /** 按 id 更新一条资产;不存在则追加。 */
6613
+ function upsertAuthorAsset(input) {
6614
+ const library = loadAuthorAssets();
6615
+ const idx = library.items.findIndex((a) => a.id === input.id);
6616
+ const now = (/* @__PURE__ */ new Date()).toISOString();
6617
+ if (idx >= 0) {
6618
+ const prev = library.items[idx];
6619
+ if (prev !== void 0) library.items[idx] = {
6620
+ ...prev,
6621
+ ...input,
6622
+ id: prev.id,
6623
+ createdAt: prev.createdAt,
6624
+ updatedAt: now
6625
+ };
6626
+ } else library.items.push({
6627
+ ...input,
6628
+ createdAt: input.createdAt !== "" ? input.createdAt : now,
6629
+ updatedAt: now
6630
+ });
6631
+ saveAuthorAssets(library);
6632
+ return library;
6633
+ }
6634
+ /** 删除一条资产;返回更新后的资产库。 */
6635
+ function removeAuthorAsset(id) {
6636
+ const library = loadAuthorAssets();
6637
+ library.items = library.items.filter((a) => a.id !== id);
6638
+ saveAuthorAssets(library);
6639
+ return library;
6640
+ }
6641
+ /**
6642
+ * 导入默认:把书架所有书的写作资产/角色 + 内置全局库批量沉淀到作者资产库。
6643
+ * 按 `kind:name` 去重(已存在则跳过),不覆盖用户已有条目。
6644
+ * @returns 更新后的资产库。
6645
+ */
6646
+ function importDefaultAuthorAssets() {
6647
+ const library = loadAuthorAssets();
6648
+ const seen = new Set(library.items.map((a) => a.kind + ":" + a.name));
6649
+ const now = (/* @__PURE__ */ new Date()).toISOString();
6650
+ const push = (kind, name, summary, content, tags, sourceBooks, structured) => {
6651
+ const key = kind + ":" + name.trim();
6652
+ if (name.trim() === "" || seen.has(key)) return;
6653
+ seen.add(key);
6654
+ library.items.push({
6655
+ id: "aa-" + Date.now().toString(36) + "-" + randomBytes(3).toString("hex"),
6656
+ kind,
6657
+ name: name.trim(),
6658
+ summary: summary.trim(),
6659
+ content: content.trim(),
6660
+ sourceBooks,
6661
+ tags,
6662
+ structured,
6663
+ createdAt: now,
6664
+ updatedAt: now
6665
+ });
6666
+ };
6667
+ for (const g of flattenGenres(BUILTIN_GENRE_LIBRARY)) push("genre", g.name, g.description, g.description, ["内置", "题材"], [], {
6668
+ description: g.description,
6669
+ children: (g.children ?? []).map((c) => c.name)
6670
+ });
6671
+ for (const r of BUILTIN_ANTI_AI_RULES) push("antiAi", r.name, r.avoid, "避免:" + r.avoid + "\n修正:" + r.fix, ["内置", "反AI"], [], {
6672
+ avoid: r.avoid,
6673
+ fix: r.fix,
6674
+ detectPatterns: r.detectPatterns
6675
+ });
6676
+ for (const t of BUILTIN_STYLE_TEMPLATES) push("style", t.name, t.description, "分类:" + t.category + "\n适用:" + t.applicableGenres.join("、") + "\n叙述:" + t.proseRules.join(";") + "\n台词:" + t.dialogueRules.join(";") + "\n语言:" + t.languageRules.join(";") + "\n节奏:" + t.rhythmRules.join(";"), ["内置", "风格模板"], [], {
6677
+ category: t.category,
6678
+ applicableGenres: t.applicableGenres
6679
+ });
6680
+ for (const p of BUILTIN_PROGRESSION_MODES) push("progression", p.name, p.driver, "驱动:" + p.driver + "\n期待:" + p.readerExpectation + "\n兑现:" + p.payoffs.join(";") + "\n风险:" + p.risks.join(";"), ["内置", "推进"], [], {
6681
+ driver: p.driver,
6682
+ primary: p.primary
6683
+ });
6684
+ const shelf = loadBookshelf();
6685
+ for (const book of shelf.books) {
6686
+ const project = loadProject(book.outputDir);
6687
+ if (project === void 0) continue;
6688
+ const src = [project.bookName];
6689
+ const assets = project.assets;
6690
+ if (assets !== void 0) {
6691
+ if (assets.genre !== void 0) push("genre", assets.genre.name, assets.genre.description, assets.genre.description, ["书", "题材"], src, { description: assets.genre.description });
6692
+ if (assets.primaryProgression !== void 0) push("progression", assets.primaryProgression.name, assets.primaryProgression.driver, "驱动:" + assets.primaryProgression.driver + "\n期待:" + assets.primaryProgression.readerExpectation, src, [], {
6693
+ driver: assets.primaryProgression.driver,
6694
+ primary: true
6695
+ });
6696
+ for (const p of assets.auxiliaryProgressions ?? []) push("progression", p.name, p.driver, "驱动:" + p.driver + "\n期待:" + p.readerExpectation, src, [], {
6697
+ driver: p.driver,
6698
+ primary: false
6699
+ });
6700
+ for (const r of assets.antiAiRules ?? []) push("antiAi", r.name, r.avoid, "避免:" + r.avoid + "\n修正:" + r.fix, src, [], {
6701
+ avoid: r.avoid,
6702
+ fix: r.fix,
6703
+ detectPatterns: r.detectPatterns
6704
+ });
6705
+ for (const s of assets.styleAssets ?? []) push("style", s.name, s.proseRules.join(";"), "叙述:" + s.proseRules.join(";") + "\n台词:" + s.dialogueRules.join(";") + "\n描写:" + s.descriptionRules.join(";") + "\n边界:" + s.boundaries.join(";"), src, [], { sourceText: s.sourceText });
6706
+ }
6707
+ for (const role of project.roles ?? []) {
6708
+ const persona = ("身份:" + role.identity + "\n性格:" + role.traits.join("、") + "\n目标:" + role.goals + "\n关系:" + role.relations.join("、") + "\n成长线:" + (role.arc.length > 0 ? role.arc.join(" > ") : "—") + "\n知情度:" + role.knowledge.join("、")).trim();
6709
+ push("roleTemplate", role.name, role.identity, persona, src, [], {
6710
+ roleLabel: role.roleLabel,
6711
+ traits: role.traits,
6712
+ relations: role.relations,
6713
+ arc: role.arc,
6714
+ knowledge: role.knowledge
6715
+ });
6716
+ }
6717
+ }
6718
+ saveAuthorAssets(library);
6719
+ return library;
6720
+ }
6721
+ /** 拍平题材库(含子题材,父级名称并入 tag)。 */
6722
+ function flattenGenres(nodes, parentName) {
6723
+ const out = [];
6724
+ for (const n of nodes) {
6725
+ out.push({
6726
+ ...n,
6727
+ name: parentName !== void 0 ? parentName + "/" + n.name : n.name
6728
+ });
6729
+ if (n.children.length > 0) out.push(...flattenGenres(n.children, parentName !== void 0 ? parentName + "/" + n.name : n.name));
6730
+ }
6731
+ return out;
6732
+ }
6733
+ //#endregion
6033
6734
  //#region src/routes.ts
6034
6735
  /** Cap on JSON request bodies (generous: cover images travel as base64). */
6035
6736
  const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024;
@@ -8083,12 +8784,24 @@ function makeRoutes(deps) {
8083
8784
  handler: async (req, res) => {
8084
8785
  if (!guard(req, res, "POST")) return;
8085
8786
  const dir = getConfig().outputDir;
8086
- exec(`explorer "${dir.replace(/"/g, "")}"`, (error) => {
8087
- if (error) writeJson(res, 500, {
8787
+ const child = spawn("explorer", [dir], { shell: false });
8788
+ let responded = false;
8789
+ child.on("error", (error) => {
8790
+ if (responded) return;
8791
+ responded = true;
8792
+ writeJson(res, 500, {
8088
8793
  ok: false,
8089
8794
  error: error.message
8090
8795
  });
8091
- else writeJson(res, 200, { ok: true });
8796
+ });
8797
+ child.on("exit", (code) => {
8798
+ if (responded) return;
8799
+ responded = true;
8800
+ if (code === 0) writeJson(res, 200, { ok: true });
8801
+ else writeJson(res, 500, {
8802
+ ok: false,
8803
+ error: `explorer 退出码 ${code}`
8804
+ });
8092
8805
  });
8093
8806
  }
8094
8807
  };
@@ -8454,6 +9167,191 @@ function makeRoutes(deps) {
8454
9167
  ctx,
8455
9168
  getConfig
8456
9169
  });
9170
+ const runStartRoute = {
9171
+ kind: "exact",
9172
+ path: NOVEL_API.runStart,
9173
+ handler: async (req, res) => {
9174
+ if (!guard(req, res, "POST")) return;
9175
+ const body = await readJsonBody(req);
9176
+ const project = loadProject(getConfig().outputDir);
9177
+ if (project === void 0) {
9178
+ writeJson(res, 400, { error: "输出目录中没有项目" });
9179
+ return;
9180
+ }
9181
+ const startNo = Number.isInteger(body?.startNo) && body.startNo >= 1 ? body.startNo : 1;
9182
+ let endNo;
9183
+ if (Number.isInteger(body?.endNo) && body.endNo >= startNo) endNo = body.endNo;
9184
+ else if (Number.isInteger(body?.count) && body.count >= 1 && body.count <= 200) endNo = Math.max(0, ...project.chapters.map((c) => c.no)) + body.count;
9185
+ else {
9186
+ writeJson(res, 400, { error: "请提供 endNo 或 count(1-200)" });
9187
+ return;
9188
+ }
9189
+ try {
9190
+ writeJson(res, 200, await runner.start(startNo, endNo));
9191
+ } catch (error) {
9192
+ writeJson(res, 409, { error: error.message });
9193
+ }
9194
+ }
9195
+ };
9196
+ const runControlRoute = {
9197
+ kind: "exact",
9198
+ path: NOVEL_API.runControl,
9199
+ handler: async (req, res) => {
9200
+ if (!guard(req, res, "POST")) return;
9201
+ const body = await readJsonBody(req);
9202
+ if (runner.status() === null) {
9203
+ writeJson(res, 400, { error: "没有生产单" });
9204
+ return;
9205
+ }
9206
+ if (body?.action === "pause") runner.pause();
9207
+ else if (body?.action === "resume") runner.resume();
9208
+ else if (body?.action === "stop") runner.stop();
9209
+ else {
9210
+ writeJson(res, 400, { error: "action 须为 pause / resume / stop" });
9211
+ return;
9212
+ }
9213
+ writeJson(res, 200, runner.status());
9214
+ }
9215
+ };
9216
+ const runStatusRoute = {
9217
+ kind: "exact",
9218
+ path: NOVEL_API.runStatus,
9219
+ handler: (req, res) => {
9220
+ if (!guard(req, res, "GET")) return;
9221
+ writeJson(res, 200, runner.status());
9222
+ }
9223
+ };
9224
+ const authorAssetsRoute = {
9225
+ kind: "exact",
9226
+ path: NOVEL_API.authorAssets,
9227
+ handler: (req, res) => {
9228
+ if (!guard(req, res, "GET")) return;
9229
+ writeJson(res, 200, { assets: loadAuthorAssets() });
9230
+ }
9231
+ };
9232
+ const authorAssetsUpsertRoute = {
9233
+ kind: "exact",
9234
+ path: NOVEL_API.authorAssetsUpsert,
9235
+ handler: async (req, res) => {
9236
+ if (!guard(req, res, "POST")) return;
9237
+ const asset = (await readJsonBody(req))?.asset;
9238
+ if (asset === void 0 || asset.name.trim() === "" || asset.content.trim() === "") {
9239
+ writeJson(res, 400, { error: "asset 须含 name / content" });
9240
+ return;
9241
+ }
9242
+ writeJson(res, 200, { assets: upsertAuthorAsset(asset) });
9243
+ }
9244
+ };
9245
+ const authorAssetsRemoveRoute = {
9246
+ kind: "exact",
9247
+ path: NOVEL_API.authorAssetsRemove,
9248
+ handler: async (req, res) => {
9249
+ if (!guard(req, res, "POST")) return;
9250
+ const id = (await readJsonBody(req))?.id ?? "";
9251
+ if (id === "") {
9252
+ writeJson(res, 400, { error: "id 必填" });
9253
+ return;
9254
+ }
9255
+ writeJson(res, 200, { assets: removeAuthorAsset(id) });
9256
+ }
9257
+ };
9258
+ const authorAssetsImportDefaultRoute = {
9259
+ kind: "exact",
9260
+ path: NOVEL_API.authorAssetsImportDefault,
9261
+ handler: (req, res) => {
9262
+ if (!guard(req, res, "POST")) return;
9263
+ writeJson(res, 200, { assets: importDefaultAuthorAssets() });
9264
+ }
9265
+ };
9266
+ const adaptAnalyzeRoute = {
9267
+ kind: "exact",
9268
+ path: NOVEL_API.adaptAnalyze,
9269
+ handler: async (req, res) => {
9270
+ if (!guard(req, res, "POST")) return;
9271
+ if (!getConfig().enableAdaptMode) {
9272
+ writeJson(res, 404, { error: "改编模式未开启" });
9273
+ return;
9274
+ }
9275
+ const body = await readJsonBody(req);
9276
+ const config = getConfig();
9277
+ let text = body?.text?.trim() ?? "";
9278
+ if (text === "" && body?.filePath !== void 0 && body.filePath !== "") try {
9279
+ text = readFileSync(body.filePath, "utf8");
9280
+ } catch (err) {
9281
+ writeJson(res, 400, { error: "读取文件失败:" + err.message });
9282
+ return;
9283
+ }
9284
+ if (text.length < 200) {
9285
+ writeJson(res, 400, { error: "全文内容过短(<200 字符),请上传完整小说文本" });
9286
+ return;
9287
+ }
9288
+ try {
9289
+ writeJson(res, 200, await analyzeAdaptation(ctx, config, text));
9290
+ } catch (err) {
9291
+ writeJson(res, 400, { error: err.message });
9292
+ }
9293
+ }
9294
+ };
9295
+ const adaptProposeRoute = {
9296
+ kind: "exact",
9297
+ path: NOVEL_API.adaptPropose,
9298
+ handler: async (req, res) => {
9299
+ if (!guard(req, res, "POST")) return;
9300
+ if (!getConfig().enableAdaptMode) {
9301
+ writeJson(res, 404, { error: "改编模式未开启" });
9302
+ return;
9303
+ }
9304
+ const body = await readJsonBody(req);
9305
+ const config = getConfig();
9306
+ const text = (body?.text ?? "").trim();
9307
+ const selections = body?.selections ?? [];
9308
+ if (text.length < 200 || selections.length === 0) {
9309
+ writeJson(res, 400, { error: "请提供全文与至少一条要改的维度" });
9310
+ return;
9311
+ }
9312
+ try {
9313
+ writeJson(res, 200, await proposeAdaptation(ctx, config, text, selections, body?.dimensions));
9314
+ } catch (err) {
9315
+ writeJson(res, 400, { error: err.message });
9316
+ }
9317
+ }
9318
+ };
9319
+ const adaptExecuteRoute = {
9320
+ kind: "exact",
9321
+ path: NOVEL_API.adaptExecute,
9322
+ handler: async (req, res) => {
9323
+ if (!guard(req, res, "POST")) return;
9324
+ if (!getConfig().enableAdaptMode) {
9325
+ writeJson(res, 404, { error: "改编模式未开启" });
9326
+ return;
9327
+ }
9328
+ const body = await readJsonBody(req);
9329
+ const text = body?.text ?? "";
9330
+ const mappings = body?.mappings ?? [];
9331
+ if (text.length < 200 || mappings.length === 0) {
9332
+ writeJson(res, 400, { error: "请提供全文与映射表" });
9333
+ return;
9334
+ }
9335
+ if (body?.mode === "rewrite") {
9336
+ writeJson(res, 400, { error: "rewrite 模式尚未实现,请使用 replace(术语替换)" });
9337
+ return;
9338
+ }
9339
+ const { adaptedText, hits } = applyAdaptationReplacements(text, mappings);
9340
+ writeJson(res, 200, {
9341
+ adaptedText,
9342
+ mappings: mappings.length,
9343
+ hits
9344
+ });
9345
+ }
9346
+ };
9347
+ const THEME_BG_DIR = join(homedir(), ".dsh", "dsh-novel-forge-assets");
9348
+ const MIME_BY_EXT = {
9349
+ png: "image/png",
9350
+ jpg: "image/jpeg",
9351
+ jpeg: "image/jpeg",
9352
+ webp: "image/webp",
9353
+ gif: "image/gif"
9354
+ };
8457
9355
  return [
8458
9356
  statusRoute,
8459
9357
  loadOutlineRoute,
@@ -8513,58 +9411,65 @@ function makeRoutes(deps) {
8513
9411
  storyboardTableRoute,
8514
9412
  storyboardPromptsRoute,
8515
9413
  breakdownRoute,
9414
+ runStartRoute,
9415
+ runControlRoute,
9416
+ runStatusRoute,
9417
+ authorAssetsRoute,
9418
+ authorAssetsUpsertRoute,
9419
+ authorAssetsRemoveRoute,
9420
+ authorAssetsImportDefaultRoute,
9421
+ adaptAnalyzeRoute,
9422
+ adaptProposeRoute,
9423
+ adaptExecuteRoute,
8516
9424
  {
8517
9425
  kind: "exact",
8518
- path: NOVEL_API.runStart,
9426
+ path: NOVEL_API.themeBackgroundUpload,
8519
9427
  handler: async (req, res) => {
8520
9428
  if (!guard(req, res, "POST")) return;
8521
- const body = await readJsonBody(req);
8522
- const project = loadProject(getConfig().outputDir);
8523
- if (project === void 0) {
8524
- writeJson(res, 400, { error: "输出目录中没有项目" });
9429
+ const dataUrl = (await readJsonBody(req))?.dataUrl ?? "";
9430
+ const m = /^data:image\/(png|jpeg|jpg|webp|gif);base64,(.+)$/i.exec(dataUrl);
9431
+ if (m === null) {
9432
+ writeJson(res, 400, { error: "仅支持 PNG/JPG/WebP/GIF 图片" });
8525
9433
  return;
8526
9434
  }
8527
- const startNo = Number.isInteger(body?.startNo) && body.startNo >= 1 ? body.startNo : 1;
8528
- let endNo;
8529
- if (Number.isInteger(body?.endNo) && body.endNo >= startNo) endNo = body.endNo;
8530
- else if (Number.isInteger(body?.count) && body.count >= 1 && body.count <= 200) endNo = Math.max(0, ...project.chapters.map((c) => c.no)) + body.count;
8531
- else {
8532
- writeJson(res, 400, { error: "请提供 endNo 或 count(1-200)" });
9435
+ const ext = m[1].toLowerCase() === "jpg" ? "jpeg" : m[1].toLowerCase();
9436
+ const buf = Buffer.from(m[2], "base64");
9437
+ if (buf.length === 0) {
9438
+ writeJson(res, 400, { error: "图片数据为空" });
8533
9439
  return;
8534
9440
  }
8535
- try {
8536
- writeJson(res, 200, await runner.start(startNo, endNo));
8537
- } catch (error) {
8538
- writeJson(res, 409, { error: error.message });
8539
- }
9441
+ mkdirSync(THEME_BG_DIR, { recursive: true });
9442
+ const name = "theme-bg-" + Date.now().toString(36) + "-" + randomBytes(3).toString("hex") + "." + ext;
9443
+ writeFileSync(join(THEME_BG_DIR, name), buf);
9444
+ writeJson(res, 200, { url: NOVEL_API.themeBackgroundGet + "/" + name });
8540
9445
  }
8541
9446
  },
8542
9447
  {
8543
- kind: "exact",
8544
- path: NOVEL_API.runControl,
8545
- handler: async (req, res) => {
8546
- if (!guard(req, res, "POST")) return;
8547
- const body = await readJsonBody(req);
8548
- if (runner.status() === null) {
8549
- writeJson(res, 400, { error: "没有生产单" });
9448
+ kind: "prefix",
9449
+ path: NOVEL_API.themeBackgroundGet,
9450
+ handler: (req, res) => {
9451
+ if (!guard(req, res, "GET")) return;
9452
+ let pathName = "";
9453
+ try {
9454
+ pathName = new URL(req.url ?? "", "http://localhost").pathname;
9455
+ } catch {}
9456
+ const name = basename(pathName);
9457
+ if (!name.startsWith("theme-bg-")) {
9458
+ writeJson(res, 404, { error: "not found" });
8550
9459
  return;
8551
9460
  }
8552
- if (body?.action === "pause") runner.pause();
8553
- else if (body?.action === "resume") runner.resume();
8554
- else if (body?.action === "stop") runner.stop();
8555
- else {
8556
- writeJson(res, 400, { error: "action 须为 pause / resume / stop" });
9461
+ const file = join(THEME_BG_DIR, name);
9462
+ if (!existsSync(file)) {
9463
+ writeJson(res, 404, { error: "not found" });
8557
9464
  return;
8558
9465
  }
8559
- writeJson(res, 200, runner.status());
8560
- }
8561
- },
8562
- {
8563
- kind: "exact",
8564
- path: NOVEL_API.runStatus,
8565
- handler: (req, res) => {
8566
- if (!guard(req, res, "GET")) return;
8567
- writeJson(res, 200, runner.status());
9466
+ const ext = extname(file).slice(1);
9467
+ const type = MIME_BY_EXT[ext] ?? "application/octet-stream";
9468
+ res.writeHead(200, {
9469
+ "content-type": type,
9470
+ "cache-control": "public, max-age=31536000"
9471
+ });
9472
+ res.end(readFileSync(file));
8568
9473
  }
8569
9474
  }
8570
9475
  ];
@@ -8628,7 +9533,11 @@ const Config = z.object({
8628
9533
  })).default([]),
8629
9534
  imageApiKey: z.string().default(""),
8630
9535
  imageApiModel: z.string().default(""),
8631
- imageApiEnabled: z.boolean().default(false)
9536
+ imageApiEnabled: z.boolean().default(false),
9537
+ themeBackground: z.string().default(""),
9538
+ themeBackgroundBlur: z.number().default(0),
9539
+ themeOpacity: z.number().default(100),
9540
+ enableAdaptMode: z.boolean().default(false)
8632
9541
  });
8633
9542
  /** Schema defaults, re-read for hand-built test contexts. */
8634
9543
  const DEFAULT_ANNOUNCE = true;
@@ -8681,7 +9590,11 @@ function resolveConfig(value) {
8681
9590
  imageApiModel: (() => {
8682
9591
  return ((value?.imageModels ?? []).find((m) => m.enabled) ?? (value?.imageModels ?? [])[0])?.model ?? value?.imageApiModel;
8683
9592
  })(),
8684
- imageApiEnabled: value?.imageApiEnabled ?? false
9593
+ imageApiEnabled: value?.imageApiEnabled ?? false,
9594
+ themeBackground: value?.themeBackground ?? "",
9595
+ themeBackgroundBlur: value?.themeBackgroundBlur ?? 0,
9596
+ themeOpacity: value?.themeOpacity ?? 100,
9597
+ enableAdaptMode: value?.enableAdaptMode ?? false
8685
9598
  };
8686
9599
  }
8687
9600
  /** 生图默认接口地址(豆包 ark,OpenAI 兼容)。 */
@@ -8719,6 +9632,11 @@ function apply(ctx, config) {
8719
9632
  if (patch.imageApiKey !== void 0) next.imageApiKey = patch.imageApiKey;
8720
9633
  if (patch.imageApiModel !== void 0) next.imageApiModel = patch.imageApiModel;
8721
9634
  if (patch.imageApiEnabled !== void 0) next.imageApiEnabled = patch.imageApiEnabled;
9635
+ if (patch.themeBackground !== void 0) next.themeBackground = patch.themeBackground;
9636
+ if (patch.themeBackgroundBlur !== void 0) next.themeBackgroundBlur = patch.themeBackgroundBlur;
9637
+ if (patch.themeOpacity !== void 0) next.themeOpacity = patch.themeOpacity;
9638
+ if (patch.enableAdaptMode !== void 0) next.enableAdaptMode = patch.enableAdaptMode;
9639
+ if (patch.imageModels !== void 0) next.imageModels = patch.imageModels;
8722
9640
  const settings = ctx.get("settings");
8723
9641
  if (settings !== void 0) await settings.update(NOVEL_SETTINGS_NAMESPACE, next);
8724
9642
  else current = () => ({