@waterwx/dsh-novel-forge 1.1.2 → 1.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/lib/client.js +4413 -2428
- package/lib/client.js.map +1 -1
- package/lib/index.js +926 -37
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +11 -0
- package/lib/types/client/locales.d.ts +46 -2
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/engine.d.ts +19 -1
- package/lib/types/index.d.ts +2 -0
- package/lib/types/protocol.d.ts +131 -0
- package/package.json +1 -1
- package/src/assistant.ts +59 -36
- package/src/client/api.ts +20 -0
- package/src/client/locales.ts +92 -4
- package/src/client/panel/AssetsTab.tsx +7 -7
- package/src/client/panel/NovelPanel.tsx +1139 -152
- package/src/client/panel/panel.module.css +398 -57
- package/src/engine.ts +378 -0
- package/src/index.ts +6 -0
- package/src/protocol.ts +135 -0
- package/src/routes.ts +295 -0
package/lib/index.js
CHANGED
|
@@ -63,6 +63,12 @@ const NOVEL_API = {
|
|
|
63
63
|
world: "/api/dsh-novel-forge/world",
|
|
64
64
|
/** 封面:GET 读取(dataUrl)/ POST 上传或移除。 */
|
|
65
65
|
cover: "/api/dsh-novel-forge/blurb/cover",
|
|
66
|
+
/** 剧情线管理:增删改 + 关联章节。 */
|
|
67
|
+
plotlines: "/api/dsh-novel-forge/plotlines",
|
|
68
|
+
/** 作者复盘补跑:对已写章节补齐 authorReview(全书流式 / 单章 JSON)。 */
|
|
69
|
+
reviewBackfill: "/api/dsh-novel-forge/review/backfill",
|
|
70
|
+
/** 敏感词检查:全书已写章节或指定文本。 */
|
|
71
|
+
sensitiveCheck: "/api/dsh-novel-forge/sensitive-check",
|
|
66
72
|
config: "/api/dsh-novel-forge/config",
|
|
67
73
|
openFolder: "/api/dsh-novel-forge/open-folder"
|
|
68
74
|
};
|
|
@@ -769,6 +775,7 @@ function loadProject(outputDir) {
|
|
|
769
775
|
if (!Array.isArray(raw.assets.auxiliaryProgressions)) raw.assets.auxiliaryProgressions = [];
|
|
770
776
|
if (!Array.isArray(raw.assets.styleAssets)) raw.assets.styleAssets = [];
|
|
771
777
|
if (!Array.isArray(raw.facts)) raw.facts = [];
|
|
778
|
+
if (!Array.isArray(raw.plotlines)) raw.plotlines = [];
|
|
772
779
|
return raw;
|
|
773
780
|
} catch {
|
|
774
781
|
return;
|
|
@@ -779,6 +786,354 @@ function saveProject(outputDir, project) {
|
|
|
779
786
|
mkdirSync(outputDir, { recursive: true });
|
|
780
787
|
writeFileSync(join(outputDir, PROJECT_FILE), JSON.stringify(project, null, 2), "utf8");
|
|
781
788
|
}
|
|
789
|
+
/**
|
|
790
|
+
* 内置违禁词库(网文平台常见审查类别)。只做硬匹配提示,不代替人工判断。
|
|
791
|
+
* 词语刻意保持常见写法;作者可自行判断是否修改。
|
|
792
|
+
*/
|
|
793
|
+
const SENSITIVE_WORDS = [
|
|
794
|
+
{
|
|
795
|
+
word: "共匪",
|
|
796
|
+
category: "政治"
|
|
797
|
+
},
|
|
798
|
+
{
|
|
799
|
+
word: "独裁",
|
|
800
|
+
category: "政治"
|
|
801
|
+
},
|
|
802
|
+
{
|
|
803
|
+
word: "法轮",
|
|
804
|
+
category: "政治"
|
|
805
|
+
},
|
|
806
|
+
{
|
|
807
|
+
word: "六四",
|
|
808
|
+
category: "政治"
|
|
809
|
+
},
|
|
810
|
+
{
|
|
811
|
+
word: "天安门事件",
|
|
812
|
+
category: "政治"
|
|
813
|
+
},
|
|
814
|
+
{
|
|
815
|
+
word: "翻墙",
|
|
816
|
+
category: "政治"
|
|
817
|
+
},
|
|
818
|
+
{
|
|
819
|
+
word: "政治敏感",
|
|
820
|
+
category: "政治"
|
|
821
|
+
},
|
|
822
|
+
{
|
|
823
|
+
word: "乳沟",
|
|
824
|
+
category: "擦边"
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
word: "酥胸",
|
|
828
|
+
category: "擦边"
|
|
829
|
+
},
|
|
830
|
+
{
|
|
831
|
+
word: "淫荡",
|
|
832
|
+
category: "擦边"
|
|
833
|
+
},
|
|
834
|
+
{
|
|
835
|
+
word: "做爱",
|
|
836
|
+
category: "擦边"
|
|
837
|
+
},
|
|
838
|
+
{
|
|
839
|
+
word: "上床",
|
|
840
|
+
category: "擦边"
|
|
841
|
+
},
|
|
842
|
+
{
|
|
843
|
+
word: "裸体",
|
|
844
|
+
category: "擦边"
|
|
845
|
+
},
|
|
846
|
+
{
|
|
847
|
+
word: "一丝不挂",
|
|
848
|
+
category: "擦边"
|
|
849
|
+
},
|
|
850
|
+
{
|
|
851
|
+
word: "胴体",
|
|
852
|
+
category: "擦边"
|
|
853
|
+
},
|
|
854
|
+
{
|
|
855
|
+
word: "春药",
|
|
856
|
+
category: "擦边"
|
|
857
|
+
},
|
|
858
|
+
{
|
|
859
|
+
word: "催情",
|
|
860
|
+
category: "擦边"
|
|
861
|
+
},
|
|
862
|
+
{
|
|
863
|
+
word: "迷奸",
|
|
864
|
+
category: "擦边"
|
|
865
|
+
},
|
|
866
|
+
{
|
|
867
|
+
word: "强暴",
|
|
868
|
+
category: "擦边"
|
|
869
|
+
},
|
|
870
|
+
{
|
|
871
|
+
word: "轮奸",
|
|
872
|
+
category: "擦边"
|
|
873
|
+
},
|
|
874
|
+
{
|
|
875
|
+
word: "援交",
|
|
876
|
+
category: "擦边"
|
|
877
|
+
},
|
|
878
|
+
{
|
|
879
|
+
word: "嫖娼",
|
|
880
|
+
category: "擦边"
|
|
881
|
+
},
|
|
882
|
+
{
|
|
883
|
+
word: "卖淫",
|
|
884
|
+
category: "擦边"
|
|
885
|
+
},
|
|
886
|
+
{
|
|
887
|
+
word: "色情",
|
|
888
|
+
category: "擦边"
|
|
889
|
+
},
|
|
890
|
+
{
|
|
891
|
+
word: "情色",
|
|
892
|
+
category: "擦边"
|
|
893
|
+
},
|
|
894
|
+
{
|
|
895
|
+
word: "撸管",
|
|
896
|
+
category: "擦边"
|
|
897
|
+
},
|
|
898
|
+
{
|
|
899
|
+
word: "自慰",
|
|
900
|
+
category: "擦边"
|
|
901
|
+
},
|
|
902
|
+
{
|
|
903
|
+
word: "口交",
|
|
904
|
+
category: "擦边"
|
|
905
|
+
},
|
|
906
|
+
{
|
|
907
|
+
word: "打炮",
|
|
908
|
+
category: "擦边"
|
|
909
|
+
},
|
|
910
|
+
{
|
|
911
|
+
word: "约炮",
|
|
912
|
+
category: "擦边"
|
|
913
|
+
},
|
|
914
|
+
{
|
|
915
|
+
word: "一夜情",
|
|
916
|
+
category: "擦边"
|
|
917
|
+
},
|
|
918
|
+
{
|
|
919
|
+
word: "碎尸",
|
|
920
|
+
category: "暴力"
|
|
921
|
+
},
|
|
922
|
+
{
|
|
923
|
+
word: "分尸",
|
|
924
|
+
category: "暴力"
|
|
925
|
+
},
|
|
926
|
+
{
|
|
927
|
+
word: "凌迟",
|
|
928
|
+
category: "暴力"
|
|
929
|
+
},
|
|
930
|
+
{
|
|
931
|
+
word: "剥皮",
|
|
932
|
+
category: "暴力"
|
|
933
|
+
},
|
|
934
|
+
{
|
|
935
|
+
word: "开膛",
|
|
936
|
+
category: "暴力"
|
|
937
|
+
},
|
|
938
|
+
{
|
|
939
|
+
word: "剖腹",
|
|
940
|
+
category: "暴力"
|
|
941
|
+
},
|
|
942
|
+
{
|
|
943
|
+
word: "挖心",
|
|
944
|
+
category: "暴力"
|
|
945
|
+
},
|
|
946
|
+
{
|
|
947
|
+
word: "虐杀",
|
|
948
|
+
category: "暴力"
|
|
949
|
+
},
|
|
950
|
+
{
|
|
951
|
+
word: "凌辱",
|
|
952
|
+
category: "暴力"
|
|
953
|
+
},
|
|
954
|
+
{
|
|
955
|
+
word: "血腥",
|
|
956
|
+
category: "暴力"
|
|
957
|
+
},
|
|
958
|
+
{
|
|
959
|
+
word: "大屠杀",
|
|
960
|
+
category: "暴力"
|
|
961
|
+
},
|
|
962
|
+
{
|
|
963
|
+
word: "灭门",
|
|
964
|
+
category: "暴力"
|
|
965
|
+
},
|
|
966
|
+
{
|
|
967
|
+
word: "满门抄斩",
|
|
968
|
+
category: "暴力"
|
|
969
|
+
},
|
|
970
|
+
{
|
|
971
|
+
word: "腰斩",
|
|
972
|
+
category: "暴力"
|
|
973
|
+
},
|
|
974
|
+
{
|
|
975
|
+
word: "活埋",
|
|
976
|
+
category: "暴力"
|
|
977
|
+
},
|
|
978
|
+
{
|
|
979
|
+
word: "点天灯",
|
|
980
|
+
category: "暴力"
|
|
981
|
+
},
|
|
982
|
+
{
|
|
983
|
+
word: "傻逼",
|
|
984
|
+
category: "辱骂"
|
|
985
|
+
},
|
|
986
|
+
{
|
|
987
|
+
word: "傻B",
|
|
988
|
+
category: "辱骂"
|
|
989
|
+
},
|
|
990
|
+
{
|
|
991
|
+
word: "草泥马",
|
|
992
|
+
category: "辱骂"
|
|
993
|
+
},
|
|
994
|
+
{
|
|
995
|
+
word: "妈的",
|
|
996
|
+
category: "辱骂"
|
|
997
|
+
},
|
|
998
|
+
{
|
|
999
|
+
word: "尼玛",
|
|
1000
|
+
category: "辱骂"
|
|
1001
|
+
},
|
|
1002
|
+
{
|
|
1003
|
+
word: "去死",
|
|
1004
|
+
category: "辱骂"
|
|
1005
|
+
},
|
|
1006
|
+
{
|
|
1007
|
+
word: "废物",
|
|
1008
|
+
category: "辱骂"
|
|
1009
|
+
},
|
|
1010
|
+
{
|
|
1011
|
+
word: "垃圾",
|
|
1012
|
+
category: "辱骂"
|
|
1013
|
+
},
|
|
1014
|
+
{
|
|
1015
|
+
word: "人渣",
|
|
1016
|
+
category: "辱骂"
|
|
1017
|
+
},
|
|
1018
|
+
{
|
|
1019
|
+
word: "贱人",
|
|
1020
|
+
category: "辱骂"
|
|
1021
|
+
},
|
|
1022
|
+
{
|
|
1023
|
+
word: "婊子",
|
|
1024
|
+
category: "辱骂"
|
|
1025
|
+
},
|
|
1026
|
+
{
|
|
1027
|
+
word: "狗日的",
|
|
1028
|
+
category: "辱骂"
|
|
1029
|
+
},
|
|
1030
|
+
{
|
|
1031
|
+
word: "加微信",
|
|
1032
|
+
category: "广告"
|
|
1033
|
+
},
|
|
1034
|
+
{
|
|
1035
|
+
word: "加QQ",
|
|
1036
|
+
category: "广告"
|
|
1037
|
+
},
|
|
1038
|
+
{
|
|
1039
|
+
word: "微信公众号",
|
|
1040
|
+
category: "广告"
|
|
1041
|
+
},
|
|
1042
|
+
{
|
|
1043
|
+
word: "淘宝",
|
|
1044
|
+
category: "广告"
|
|
1045
|
+
},
|
|
1046
|
+
{
|
|
1047
|
+
word: "拼多多",
|
|
1048
|
+
category: "广告"
|
|
1049
|
+
},
|
|
1050
|
+
{
|
|
1051
|
+
word: "刷单",
|
|
1052
|
+
category: "广告"
|
|
1053
|
+
},
|
|
1054
|
+
{
|
|
1055
|
+
word: "充值返利",
|
|
1056
|
+
category: "广告"
|
|
1057
|
+
},
|
|
1058
|
+
{
|
|
1059
|
+
word: "扫码领",
|
|
1060
|
+
category: "广告"
|
|
1061
|
+
},
|
|
1062
|
+
{
|
|
1063
|
+
word: "加群领",
|
|
1064
|
+
category: "广告"
|
|
1065
|
+
},
|
|
1066
|
+
{
|
|
1067
|
+
word: "vx",
|
|
1068
|
+
category: "广告"
|
|
1069
|
+
},
|
|
1070
|
+
{
|
|
1071
|
+
word: "扣扣",
|
|
1072
|
+
category: "广告"
|
|
1073
|
+
},
|
|
1074
|
+
{
|
|
1075
|
+
word: "赌博",
|
|
1076
|
+
category: "其他"
|
|
1077
|
+
},
|
|
1078
|
+
{
|
|
1079
|
+
word: "赌场",
|
|
1080
|
+
category: "其他"
|
|
1081
|
+
},
|
|
1082
|
+
{
|
|
1083
|
+
word: "毒品",
|
|
1084
|
+
category: "其他"
|
|
1085
|
+
},
|
|
1086
|
+
{
|
|
1087
|
+
word: "冰毒",
|
|
1088
|
+
category: "其他"
|
|
1089
|
+
},
|
|
1090
|
+
{
|
|
1091
|
+
word: "摇头丸",
|
|
1092
|
+
category: "其他"
|
|
1093
|
+
},
|
|
1094
|
+
{
|
|
1095
|
+
word: "自杀方法",
|
|
1096
|
+
category: "其他"
|
|
1097
|
+
},
|
|
1098
|
+
{
|
|
1099
|
+
word: "邪教",
|
|
1100
|
+
category: "其他"
|
|
1101
|
+
},
|
|
1102
|
+
{
|
|
1103
|
+
word: "传销",
|
|
1104
|
+
category: "其他"
|
|
1105
|
+
},
|
|
1106
|
+
{
|
|
1107
|
+
word: "军火",
|
|
1108
|
+
category: "其他"
|
|
1109
|
+
},
|
|
1110
|
+
{
|
|
1111
|
+
word: "枪支",
|
|
1112
|
+
category: "其他"
|
|
1113
|
+
},
|
|
1114
|
+
{
|
|
1115
|
+
word: "管制刀具",
|
|
1116
|
+
category: "其他"
|
|
1117
|
+
}
|
|
1118
|
+
];
|
|
1119
|
+
/** 对一段文本做违禁词硬匹配,返回命中(词/类别/次数)。 */
|
|
1120
|
+
function checkSensitiveText(text) {
|
|
1121
|
+
const hits = [];
|
|
1122
|
+
for (const entry of SENSITIVE_WORDS) {
|
|
1123
|
+
let count = 0;
|
|
1124
|
+
let idx = text.indexOf(entry.word);
|
|
1125
|
+
while (idx !== -1) {
|
|
1126
|
+
count++;
|
|
1127
|
+
idx = text.indexOf(entry.word, idx + entry.word.length);
|
|
1128
|
+
}
|
|
1129
|
+
if (count > 0) hits.push({
|
|
1130
|
+
word: entry.word,
|
|
1131
|
+
category: entry.category,
|
|
1132
|
+
count
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
return hits;
|
|
1136
|
+
}
|
|
782
1137
|
/** List generated chapter files in the output dir (sorted). */
|
|
783
1138
|
function listChapterFiles(outputDir) {
|
|
784
1139
|
if (!existsSync(outputDir)) return [];
|
|
@@ -971,7 +1326,8 @@ async function extractBible(ctx, config, outline) {
|
|
|
971
1326
|
].includes(entry.role) ? entry.role : "other",
|
|
972
1327
|
traits: strArray(entry.traits),
|
|
973
1328
|
goals: typeof entry.goals === "string" ? entry.goals : "",
|
|
974
|
-
relations: typeof entry.relations === "string" ? entry.relations : ""
|
|
1329
|
+
relations: typeof entry.relations === "string" ? entry.relations : "",
|
|
1330
|
+
knowledge: strArray(entry.knowledge)
|
|
975
1331
|
})).filter((card) => card.name !== "") : [];
|
|
976
1332
|
const bible = {
|
|
977
1333
|
genre: typeof raw.genre === "string" ? raw.genre : "",
|
|
@@ -1067,6 +1423,7 @@ function writeSystemPrompt(project) {
|
|
|
1067
1423
|
other: "其他"
|
|
1068
1424
|
}[card.role];
|
|
1069
1425
|
sections.push(`- ${card.name}(${roleName}):${card.traits.join("、")}${card.goals !== "" ? `;目标:${card.goals}` : ""}${card.relations !== "" ? `;关系:${card.relations}` : ""}`);
|
|
1426
|
+
if (Array.isArray(card.knowledge) && card.knowledge.length > 0) sections.push(` 已知信息(该角色知道的:${card.knowledge.join(";")};未列出的信息该角色一律不知道,不得写其知晓或提及)`);
|
|
1070
1427
|
}
|
|
1071
1428
|
}
|
|
1072
1429
|
if (bible.redLines.length > 0) sections.push("写作红线(违反即失败):\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
|
|
@@ -1085,6 +1442,17 @@ function writeSystemPrompt(project) {
|
|
|
1085
1442
|
sections.push("==================== 活跃伏笔(近期需推进或回收的线索) ====================");
|
|
1086
1443
|
for (const f of active) sections.push(`- [${f.status === "planted" ? "已埋设" : "推进中"}] ${f.description}${f.targetChapter !== void 0 ? `(预计 ${f.targetChapter} 章回收)` : ""}`);
|
|
1087
1444
|
}
|
|
1445
|
+
const lines = (project.plotlines ?? []).filter((l) => l.status === "active" || l.status === "paused");
|
|
1446
|
+
if (lines.length > 0) {
|
|
1447
|
+
const kindName = {
|
|
1448
|
+
main: "主线",
|
|
1449
|
+
branch: "支线",
|
|
1450
|
+
character: "人物线",
|
|
1451
|
+
mystery: "悬念线"
|
|
1452
|
+
};
|
|
1453
|
+
sections.push("==================== 剧情线(本章应推进至少一条活跃线) ====================");
|
|
1454
|
+
for (const l of lines) sections.push(`- [${kindName[l.kind]}${l.status === "paused" ? "·暂停中" : ""}] ${l.name}:${l.goal}${l.progress !== "" ? `(当前进度:${l.progress})` : ""}`);
|
|
1455
|
+
}
|
|
1088
1456
|
sections.push("");
|
|
1089
1457
|
sections.push("写作硬性要求:");
|
|
1090
1458
|
sections.push("1. 每章 3000-4000 字(按中文字符计),只输出章节正文,不要输出标题、章回名、作者的话或任何 Markdown 标记。");
|
|
@@ -1187,7 +1555,10 @@ function reviewSystemPrompt(project) {
|
|
|
1187
1555
|
if (bible.worldRules.length > 0) sections.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
|
|
1188
1556
|
if (bible.characters.length > 0) {
|
|
1189
1557
|
sections.push("角色卡:");
|
|
1190
|
-
for (const card of bible.characters)
|
|
1558
|
+
for (const card of bible.characters) {
|
|
1559
|
+
sections.push(`- ${card.name}(${card.role}):${card.traits.join("、")}`);
|
|
1560
|
+
if (Array.isArray(card.knowledge) && card.knowledge.length > 0) sections.push(` 该角色知道:${card.knowledge.join(";")}(未列出的信息该角色不知道)`);
|
|
1561
|
+
}
|
|
1191
1562
|
}
|
|
1192
1563
|
if (bible.redLines.length > 0) sections.push("红线:\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
|
|
1193
1564
|
}
|
|
@@ -1268,6 +1639,237 @@ async function reviewChapterText(ctx, config, project, text) {
|
|
|
1268
1639
|
reviewedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1269
1640
|
};
|
|
1270
1641
|
}
|
|
1642
|
+
/** Build the author-review system prompt (narrative structure, not prose). */
|
|
1643
|
+
function authorReviewSystemPrompt() {
|
|
1644
|
+
return [
|
|
1645
|
+
"你是一位网文作者复盘助手。你会收到:本章正文、上一章结尾(钩子)、上一章作者复盘(如有)、活跃剧情线与编年录近期事实。",
|
|
1646
|
+
"请从叙事结构层面复盘本章(不评文笔,那是审稿的事):",
|
|
1647
|
+
"1. hookHonored:上一章结尾的钩子/悬念是否在本章兑现或推进(true/false)。",
|
|
1648
|
+
"2. hookNote:钩子兑现情况一句话;未兑现时说明并给出\"建议在第几章补\"的建议。",
|
|
1649
|
+
"3. endingHook:本章结尾钩子强度,0-10 的整数(低于 6 说明结尾平淡,读者可能不想看下一章)。",
|
|
1650
|
+
"4. plotlineProgress:本章推进了哪条剧情线(主线/支线名),或\"无实质推进\"(连续无推进要提醒)。",
|
|
1651
|
+
"5. advancedLines:本章实际推进的剧情线名称数组——从「活跃剧情线」清单中选出推进了的线(名称必须与清单中的线名一字不差;没推进任何线则输出空数组)。",
|
|
1652
|
+
"6. continuity:与上一章结尾的衔接检查(人物位置/时间/伤势/资源/对话状态),发现问题要指出。",
|
|
1653
|
+
"7. trend:结合上一章复盘看近期节奏趋势(是否连续拖沓、爽点密度是否下降、是否需要调整)。",
|
|
1654
|
+
"输出必须是合法 JSON 对象,不要输出任何其他文字:",
|
|
1655
|
+
"{\"hookHonored\": true或false, \"hookNote\": \"一句话\", \"endingHook\": 0-10整数, \"plotlineProgress\": \"一句话\", \"advancedLines\": [\"线名\"], \"continuity\": \"一句话\", \"trend\": \"一句话\"}",
|
|
1656
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
1657
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
1658
|
+
].join("\n");
|
|
1659
|
+
}
|
|
1660
|
+
/** 作者复盘:对一章做叙事结构复盘(钩子兑现/结尾钩子/推进/连续性/趋势)。 */
|
|
1661
|
+
async function authorReviewChapter(ctx, config, project, chapterNo, body, prevTail) {
|
|
1662
|
+
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
1663
|
+
const prevChapter = chapterNo > 1 ? project.chapters.find((c) => c.no === chapterNo - 1) : void 0;
|
|
1664
|
+
const lines = (project.plotlines ?? []).filter((l) => l.status === "active" || l.status === "paused");
|
|
1665
|
+
const facts = (project.facts ?? []).slice(-10);
|
|
1666
|
+
const user = [
|
|
1667
|
+
`书名:《${project.bookName}》`,
|
|
1668
|
+
chapter !== void 0 ? `本章:第 ${chapter.no} 章《${chapter.title}》` : `本章:第 ${chapterNo} 章`,
|
|
1669
|
+
prevTail !== "" ? `==================== 上一章(第 ${chapterNo - 1} 章)结尾(钩子) ====================\n${prevTail}` : "(本书第一章,无上一章钩子;hookHonored 视为 true,hookNote 写\"开篇无前置钩子\")",
|
|
1670
|
+
prevChapter?.authorReview !== void 0 ? `==================== 上一章作者复盘 ====================\n${JSON.stringify(prevChapter.authorReview)}` : "",
|
|
1671
|
+
lines.length > 0 ? `==================== 活跃剧情线 ====================\n${lines.map((l) => `- [${l.kind}] ${l.name}:${l.goal}${l.progress !== "" ? `(${l.progress})` : ""}`).join("\n")}` : "",
|
|
1672
|
+
facts.length > 0 ? `==================== 编年录近期事实 ====================\n${facts.map((f) => `[第${f.chapterNo}章] ${f.text}`).join("\n")}` : "",
|
|
1673
|
+
"==================== 本章正文 ====================",
|
|
1674
|
+
body.slice(0, 16e3),
|
|
1675
|
+
"",
|
|
1676
|
+
"只输出 JSON 对象。"
|
|
1677
|
+
].join("\n");
|
|
1678
|
+
const raw = parseJsonObject(await complete(ctx, config, {
|
|
1679
|
+
system: authorReviewSystemPrompt(),
|
|
1680
|
+
user,
|
|
1681
|
+
temperature: .3,
|
|
1682
|
+
maxTokens: Math.max(config.maxTokens, 4e3)
|
|
1683
|
+
}));
|
|
1684
|
+
const knownLineNames = new Set((project.plotlines ?? []).map((l) => l.name));
|
|
1685
|
+
const advancedLines = Array.isArray(raw.advancedLines) ? raw.advancedLines.filter((n) => typeof n === "string" && n.trim() !== "" && knownLineNames.has(n.trim())).map((n) => n.trim()) : [];
|
|
1686
|
+
return {
|
|
1687
|
+
hookHonored: raw.hookHonored === true,
|
|
1688
|
+
hookNote: typeof raw.hookNote === "string" ? raw.hookNote.slice(0, 200) : "",
|
|
1689
|
+
endingHook: typeof raw.endingHook === "number" ? Math.max(0, Math.min(10, Math.round(raw.endingHook))) : 5,
|
|
1690
|
+
plotlineProgress: typeof raw.plotlineProgress === "string" ? raw.plotlineProgress.slice(0, 200) : "",
|
|
1691
|
+
advancedLines,
|
|
1692
|
+
continuity: typeof raw.continuity === "string" ? raw.continuity.slice(0, 200) : "",
|
|
1693
|
+
trend: typeof raw.trend === "string" ? raw.trend.slice(0, 200) : "",
|
|
1694
|
+
reviewedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1695
|
+
};
|
|
1696
|
+
}
|
|
1697
|
+
/** 复盘后自动关联:把本章号写入复盘标记推进的剧情线(按名称匹配,去重)。 */
|
|
1698
|
+
function autoLinkPlotlines(project, chapterNo, advancedLines) {
|
|
1699
|
+
if (!Array.isArray(project.plotlines) || advancedLines.length === 0) return;
|
|
1700
|
+
for (const line of project.plotlines) if (advancedLines.includes(line.name) && !line.chapters.includes(chapterNo)) line.chapters.push(chapterNo);
|
|
1701
|
+
}
|
|
1702
|
+
/** AI 建议剧情线:基于大纲/卷计划/已写章节/编年录,提炼候选线。 */
|
|
1703
|
+
async function suggestPlotlines(ctx, config, project) {
|
|
1704
|
+
const system = [
|
|
1705
|
+
"你是一位网文剧情架构师。根据本书的大纲、卷计划、已写章节标题与编年录,为作者提炼建议的剧情线(主线/支线/人物线/悬念线)。",
|
|
1706
|
+
"每条线要:名称简洁有力;目标写清楚这条线最终要完成什么;progress 写当前推进到哪(没有就空字符串)。",
|
|
1707
|
+
"建议 4-8 条,覆盖:1 条主线、1-2 条人物线、1-2 条悬念线、1-3 条支线。避免与大纲明显重复的废话线。",
|
|
1708
|
+
"输出必须是合法 JSON 数组,格式:[{\"name\": \"线名\", \"kind\": \"main|branch|character|mystery\", \"goal\": \"目标\", \"progress\": \"\"}]",
|
|
1709
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
1710
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
1711
|
+
].join("\n");
|
|
1712
|
+
const written = project.chapters.filter((c) => c.status !== "pending");
|
|
1713
|
+
const raw = parseJsonArray(await complete(ctx, config, {
|
|
1714
|
+
system,
|
|
1715
|
+
user: [
|
|
1716
|
+
`书名:《${project.bookName}》`,
|
|
1717
|
+
`大纲(节选前 4000 字):\n${project.outline.slice(0, 4e3)}`,
|
|
1718
|
+
project.volumes !== void 0 && project.volumes.length > 0 ? `卷计划:\n${project.volumes.map((v) => `第${v.no}卷《${v.title}》:${v.summary}`).join("\n")}` : "",
|
|
1719
|
+
written.length > 0 ? `已写章节:\n${written.map((c) => `第${c.no}章《${c.title}》${c.summary !== void 0 && c.summary !== "" ? `:${c.summary.slice(0, 80)}` : ""}`).join("\n")}` : "",
|
|
1720
|
+
(project.facts ?? []).length > 0 ? `编年录近期事实(最近 15 条):\n${(project.facts ?? []).slice(-15).map((f) => `[第${f.chapterNo}章] ${f.text.slice(0, 100)}`).join("\n")}` : "",
|
|
1721
|
+
"只输出 JSON 数组。"
|
|
1722
|
+
].join("\n\n"),
|
|
1723
|
+
temperature: .6,
|
|
1724
|
+
maxTokens: Math.max(config.maxTokens, 4e3)
|
|
1725
|
+
}));
|
|
1726
|
+
const lines = [];
|
|
1727
|
+
const kinds = /* @__PURE__ */ new Set([
|
|
1728
|
+
"main",
|
|
1729
|
+
"branch",
|
|
1730
|
+
"character",
|
|
1731
|
+
"mystery"
|
|
1732
|
+
]);
|
|
1733
|
+
for (const entry of raw) {
|
|
1734
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
1735
|
+
const name = typeof entry.name === "string" ? entry.name.trim().slice(0, 40) : "";
|
|
1736
|
+
if (name === "") continue;
|
|
1737
|
+
lines.push({
|
|
1738
|
+
id: "",
|
|
1739
|
+
name,
|
|
1740
|
+
kind: kinds.has(entry.kind) ? entry.kind : "branch",
|
|
1741
|
+
goal: typeof entry.goal === "string" ? entry.goal.trim().slice(0, 300) : "",
|
|
1742
|
+
progress: typeof entry.progress === "string" ? entry.progress.trim().slice(0, 300) : "",
|
|
1743
|
+
status: "active",
|
|
1744
|
+
chapters: [],
|
|
1745
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
return lines;
|
|
1749
|
+
}
|
|
1750
|
+
/** AI 刷新单条剧情线的进度:结合编年录与各章摘要分析该线推进到哪。 */
|
|
1751
|
+
async function refreshPlotlineProgress(ctx, config, project, line) {
|
|
1752
|
+
const system = [
|
|
1753
|
+
"你是一位网文剧情线管理员。请根据「剧情线信息」与「本书已写章节摘要/编年录」,判断这条线目前推进到了哪一步。",
|
|
1754
|
+
"输出一句话(30-60 字):这条线当前的状态、最近一次推进发生在第几章、下一步可能的方向。如果这条线还没开始推进,明确说\"尚未推进\"。",
|
|
1755
|
+
"输出必须是合法 JSON 对象:{\"progress\": \"一句话\"}",
|
|
1756
|
+
"重要:不要输出任何其他文字。"
|
|
1757
|
+
].join("\n");
|
|
1758
|
+
const written = project.chapters.filter((c) => c.status !== "pending" && c.summary !== void 0 && c.summary !== "");
|
|
1759
|
+
const raw = parseJsonObject(await complete(ctx, config, {
|
|
1760
|
+
system,
|
|
1761
|
+
user: [
|
|
1762
|
+
`剧情线:${line.name}(${line.kind})`,
|
|
1763
|
+
`目标:${line.goal}`,
|
|
1764
|
+
`已知进度:${line.progress !== "" ? line.progress : "(无)"}`,
|
|
1765
|
+
`已关联章节:${line.chapters.length > 0 ? line.chapters.map((n) => `第${n}章`).join("、") : "(无)"}`,
|
|
1766
|
+
`章节摘要(最近 8 章):\n${written.slice(-8).map((c) => `第${c.no}章《${c.title}》:${c.summary.slice(0, 120)}`).join("\n")}`,
|
|
1767
|
+
(project.facts ?? []).length > 0 ? `编年录近期事实(最近 15 条):\n${(project.facts ?? []).slice(-15).map((f) => `[第${f.chapterNo}章] ${f.text.slice(0, 100)}`).join("\n")}` : "",
|
|
1768
|
+
"只输出 JSON 对象。"
|
|
1769
|
+
].join("\n\n"),
|
|
1770
|
+
temperature: .3,
|
|
1771
|
+
maxTokens: Math.max(config.maxTokens, 2e3)
|
|
1772
|
+
}));
|
|
1773
|
+
return typeof raw.progress === "string" ? raw.progress.trim().slice(0, 300) : "";
|
|
1774
|
+
}
|
|
1775
|
+
/** 🩺 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
|
|
1776
|
+
async function analyzePlotlineHealth(ctx, config, project) {
|
|
1777
|
+
const system = [
|
|
1778
|
+
"你是一位网文剧情架构师。请对本书的「剧情线体系」做健康检查,判断当前是否需要新增剧情线、应在多少章后添加。",
|
|
1779
|
+
"评估维度:各线最近推进到第几章(已写章节与关联章节的差值越大越危险)、各线状态、已写章节总数、卷计划当前进度、编年录近期事实。",
|
|
1780
|
+
"输出规则:",
|
|
1781
|
+
"1. verdict:一句话结论——\"需要新增线\" / \"暂不需要\" / \"再写 N 章后需要\"(N 给出具体章数)。",
|
|
1782
|
+
"2. timing:说明建议添加的时机(如:第 25 章前引入新支线,因为主线预计第 22 章告一段落)。",
|
|
1783
|
+
"3. reasons:3-5 条依据(引用具体数据:哪条线多少章没推进、已写章节数、卷进度等)。",
|
|
1784
|
+
"4. lines:对每条线给健康度——ok(近期推进过)/ warning(超过 5 章未推进)/ stale(超过 10 章未推进或悬置过久)。",
|
|
1785
|
+
"输出必须是合法 JSON 对象:{\"verdict\": \"...\", \"timing\": \"...\", \"reasons\": [\"...\"], \"lines\": [{\"name\": \"线名\", \"health\": \"ok|warning|stale\", \"note\": \"一句说明\"}]}",
|
|
1786
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
1787
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
1788
|
+
].join("\n");
|
|
1789
|
+
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
|
|
1790
|
+
const lines = (project.plotlines ?? []).filter((l) => l.status === "active" || l.status === "paused");
|
|
1791
|
+
const raw = parseJsonObject(await complete(ctx, config, {
|
|
1792
|
+
system,
|
|
1793
|
+
user: [
|
|
1794
|
+
`书名:《${project.bookName}》`,
|
|
1795
|
+
`已写章节数:${written.length}(最新章号 ${written.length > 0 ? written[written.length - 1].no : 0})`,
|
|
1796
|
+
project.volumes !== void 0 && project.volumes.length > 0 ? `卷计划:\n${project.volumes.map((v) => `第${v.no}卷《${v.title}》(${v.chapterStart}-${v.chapterEnd}):${v.summary.slice(0, 60)}`).join("\n")}` : "",
|
|
1797
|
+
`剧情线(${lines.length} 条):\n${lines.length > 0 ? lines.map((l) => `- [${l.kind}] ${l.name}|目标:${l.goal}|进度:${l.progress !== "" ? l.progress : "未推进"}|最近关联章节:${l.chapters.length > 0 ? "第" + Math.max(...l.chapters) + "章" : "无"}`).join("\n") : "(暂无剧情线)"}`,
|
|
1798
|
+
(project.facts ?? []).length > 0 ? `编年录近期事实(最近 10 条):\n${(project.facts ?? []).slice(-10).map((f) => `[第${f.chapterNo}章] ${f.text.slice(0, 80)}`).join("\n")}` : "",
|
|
1799
|
+
"只输出 JSON 对象。"
|
|
1800
|
+
].join("\n\n"),
|
|
1801
|
+
temperature: .3,
|
|
1802
|
+
maxTokens: Math.max(config.maxTokens, 3e3)
|
|
1803
|
+
}));
|
|
1804
|
+
const strArr = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.trim() !== "") : [];
|
|
1805
|
+
const lineArr = Array.isArray(raw.lines) ? raw.lines.filter((v) => typeof v === "object" && v !== null).map((entry) => ({
|
|
1806
|
+
name: typeof entry.name === "string" ? entry.name.slice(0, 40) : "",
|
|
1807
|
+
health: [
|
|
1808
|
+
"ok",
|
|
1809
|
+
"warning",
|
|
1810
|
+
"stale"
|
|
1811
|
+
].includes(entry.health) ? entry.health : "ok",
|
|
1812
|
+
note: typeof entry.note === "string" ? entry.note.slice(0, 150) : ""
|
|
1813
|
+
})).filter((x) => x.name !== "") : [];
|
|
1814
|
+
return {
|
|
1815
|
+
verdict: typeof raw.verdict === "string" ? raw.verdict.slice(0, 100) : "",
|
|
1816
|
+
timing: typeof raw.timing === "string" ? raw.timing.slice(0, 200) : "",
|
|
1817
|
+
reasons: strArr(raw.reasons).map((r) => r.slice(0, 200)),
|
|
1818
|
+
lines: lineArr
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
/** ✨ AI 剧情方案:基于健康检查结果设计下一阶段方向与建议新线。 */
|
|
1822
|
+
async function designPlotlinePlan(ctx, config, project, health) {
|
|
1823
|
+
const system = [
|
|
1824
|
+
"你是一位网文剧情架构师。请为本书设计「下一阶段的剧情方案」:给出未来 5-10 章的剧情方向,并建议 2-3 条值得新增的剧情线。",
|
|
1825
|
+
"要求:方向必须结合本书大纲/卷计划/现有线/编年录;新线要能落地(和当前主角处境、已有伏笔、下一阶段舞台相关),不得重复已有线。",
|
|
1826
|
+
"输出必须是合法 JSON 对象:{\"direction\": \"下一阶段方向 60-120 字\", \"suggestions\": [{\"name\": \"线名\", \"kind\": \"main|branch|character|mystery\", \"goal\": \"目标\", \"progress\": \"初始进度(可空)\"}]}",
|
|
1827
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
1828
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
1829
|
+
].join("\n");
|
|
1830
|
+
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
|
|
1831
|
+
const raw = parseJsonObject(await complete(ctx, config, {
|
|
1832
|
+
system,
|
|
1833
|
+
user: [
|
|
1834
|
+
`书名:《${project.bookName}》`,
|
|
1835
|
+
health !== void 0 ? `健康检查结论:\n判定:${health.verdict}\n时机:${health.timing}\n依据:${health.reasons.join(";")}` : "",
|
|
1836
|
+
`大纲(节选前 3000 字):\n${project.outline.slice(0, 3e3)}`,
|
|
1837
|
+
project.volumes !== void 0 && project.volumes.length > 0 ? `卷计划:\n${project.volumes.map((v) => `第${v.no}卷《${v.title}》:${v.summary.slice(0, 60)}`).join("\n")}` : "",
|
|
1838
|
+
`现有剧情线:\n${(project.plotlines ?? []).map((l) => `- [${l.kind}${l.status === "resolved" ? "·已完结" : ""}] ${l.name}:${l.goal}`).join("\n") || "(无)"}`,
|
|
1839
|
+
written.length > 0 ? `最近写的章节:\n${written.slice(-5).map((c) => `第${c.no}章《${c.title}》`).join("、")}` : "",
|
|
1840
|
+
"只输出 JSON 对象。"
|
|
1841
|
+
].join("\n\n"),
|
|
1842
|
+
temperature: .6,
|
|
1843
|
+
maxTokens: Math.max(config.maxTokens, 3e3)
|
|
1844
|
+
}));
|
|
1845
|
+
const suggestions = [];
|
|
1846
|
+
const kinds = /* @__PURE__ */ new Set([
|
|
1847
|
+
"main",
|
|
1848
|
+
"branch",
|
|
1849
|
+
"character",
|
|
1850
|
+
"mystery"
|
|
1851
|
+
]);
|
|
1852
|
+
if (Array.isArray(raw.suggestions)) for (const entry of raw.suggestions) {
|
|
1853
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
1854
|
+
const e = entry;
|
|
1855
|
+
const name = typeof e.name === "string" ? e.name.trim().slice(0, 40) : "";
|
|
1856
|
+
if (name === "") continue;
|
|
1857
|
+
suggestions.push({
|
|
1858
|
+
id: "",
|
|
1859
|
+
name,
|
|
1860
|
+
kind: kinds.has(e.kind) ? e.kind : "branch",
|
|
1861
|
+
goal: typeof e.goal === "string" ? e.goal.trim().slice(0, 300) : "",
|
|
1862
|
+
progress: typeof e.progress === "string" ? e.progress.trim().slice(0, 300) : "",
|
|
1863
|
+
status: "active",
|
|
1864
|
+
chapters: [],
|
|
1865
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1866
|
+
});
|
|
1867
|
+
}
|
|
1868
|
+
return {
|
|
1869
|
+
direction: typeof raw.direction === "string" ? raw.direction.slice(0, 300) : "",
|
|
1870
|
+
suggestions
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1271
1873
|
/** Build the rewrite system prompt (fix review issues / instructions). */
|
|
1272
1874
|
function rewriteSystemPrompt(project) {
|
|
1273
1875
|
return writeSystemPrompt(project) + "\n\n额外要求:你正在【修订】一章已写好的正文。保留原文中好的部分,只修改需要修改的地方,输出完整的新正文(不要只输出修改片段),字数与原文相当。";
|
|
@@ -2119,7 +2721,7 @@ function clearAssistantHistory(outputDir) {
|
|
|
2119
2721
|
function renderProjectSnapshot(project) {
|
|
2120
2722
|
const sections = [];
|
|
2121
2723
|
sections.push(`书名:${project.bookName}`);
|
|
2122
|
-
sections.push(
|
|
2724
|
+
sections.push(`总纲节选(如需全文用 outline_text 工具):\n${project.outline.slice(0, 2500)}`);
|
|
2123
2725
|
const assetNames = [];
|
|
2124
2726
|
if (project.assets?.genre !== void 0) assetNames.push(`题材:${project.assets.genre.name}`);
|
|
2125
2727
|
if (project.assets?.primaryProgression !== void 0) assetNames.push(`主推进:${project.assets.primaryProgression.name}`);
|
|
@@ -2128,7 +2730,7 @@ function renderProjectSnapshot(project) {
|
|
|
2128
2730
|
if (assetNames.length > 0) sections.push(`【写作资产】${assetNames.join(" · ")}`);
|
|
2129
2731
|
if (project.bible !== void 0) {
|
|
2130
2732
|
const bible = project.bible;
|
|
2131
|
-
sections.push("
|
|
2733
|
+
sections.push("【道藏】");
|
|
2132
2734
|
if (bible.genre !== "") sections.push(`题材基调:${bible.genre}`);
|
|
2133
2735
|
if (bible.worldRules.length > 0) sections.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
|
|
2134
2736
|
if (bible.characters.length > 0) {
|
|
@@ -2179,14 +2781,14 @@ function renderProjectSnapshot(project) {
|
|
|
2179
2781
|
}
|
|
2180
2782
|
}
|
|
2181
2783
|
if (project.foreshadows.length > 0) {
|
|
2182
|
-
sections.push("
|
|
2784
|
+
sections.push("【暗线】");
|
|
2183
2785
|
for (const f of project.foreshadows) sections.push(`- [${f.status}] ${f.description}${f.targetChapter !== void 0 ? `(预计 ${f.targetChapter} 章回收)` : ""}`);
|
|
2184
2786
|
}
|
|
2185
2787
|
if ((project.facts ?? []).length > 0) {
|
|
2186
|
-
sections.push("
|
|
2788
|
+
sections.push("【已确立编年录(最近 40 条,回答设定问题必须遵守)】");
|
|
2187
2789
|
for (const f of (project.facts ?? []).slice(-40)) sections.push(`- [第${f.chapterNo}章] ${f.text}`);
|
|
2188
2790
|
}
|
|
2189
|
-
if (project.blurb !== void 0 && project.blurb !== "") sections.push(
|
|
2791
|
+
if (project.blurb !== void 0 && project.blurb !== "") sections.push(`【卷首语】${project.blurb}`);
|
|
2190
2792
|
return sections.join("\n");
|
|
2191
2793
|
}
|
|
2192
2794
|
/** The assistant system prompt. */
|
|
@@ -2196,33 +2798,35 @@ function assistantSystemPrompt(project) {
|
|
|
2196
2798
|
"人设:二十年网文老编辑,懂套路、懂市场、懂节奏,说话直接但句句有用。",
|
|
2197
2799
|
"座右铭:「书是你的,但坑我替你盯着。」",
|
|
2198
2800
|
"职责边界:陪作者讨论剧情/人设/世界观/爽点节奏并落地修改、维护全书一致性;不闲聊、不彩虹屁、不无意义长篇大论。",
|
|
2801
|
+
"==================== 模块正式名称(回复作者时一律使用,禁止使用括号里的旧称) ====================",
|
|
2802
|
+
"总纲 = 总纲;道藏 = 道藏;暗线 = 暗线;卷首语 = 卷首语;编年录 = 编年录。",
|
|
2199
2803
|
"==================== 当前项目快照 ====================",
|
|
2200
2804
|
renderProjectSnapshot(project),
|
|
2201
2805
|
"==================== 快照结束 ====================",
|
|
2202
2806
|
"",
|
|
2203
2807
|
"工作规则(严格遵守):",
|
|
2204
|
-
"1. 全量知情:回答和修改必须基于项目真实数据,禁止编造书中不存在的设定。需要完整信息时,先调用 book_overview
|
|
2808
|
+
"1. 全量知情:回答和修改必须基于项目真实数据,禁止编造书中不存在的设定。需要完整信息时,先调用 book_overview 获取全书上下文(总纲全文/道藏/大世界/编年录/全部章节要点/暗线/卷首语);需要某章正文用 chapter_text。",
|
|
2205
2809
|
"2. 修改流程:改前用一句话说明意图 → 执行工具 → 改后简要汇报。",
|
|
2206
|
-
"3.
|
|
2810
|
+
"3. 连锁维护:改动可能波及其它位置(其它章节、设定、编年录、卷首语)时,执行后主动调用 impact_analysis 分析影响面,并把「必须同步」的项一并处理或明确提示作者逐项确认。",
|
|
2207
2811
|
"4. 删除红线:删除章节、清空设定等破坏性操作必须等作者明确同意。",
|
|
2208
2812
|
"5. 品质门槛:建议必须具体——指出问题在哪一章、哪一段、哪一句,并给出可落地的改法;禁止\"建议增强冲突\"这类空话。",
|
|
2209
|
-
"6.
|
|
2813
|
+
"6. 设定忠诚:忠于总纲、道藏、大世界、编年录;发现书中已有内容与设定冲突时,主动指出并给修正方案。",
|
|
2210
2814
|
"7. 中文回复,简洁有干货。",
|
|
2211
2815
|
"",
|
|
2212
2816
|
"可用工具:",
|
|
2213
|
-
"- book_overview:{\"scope\": \"recent|full|volume:2\"(可选,默认 recent)}
|
|
2214
|
-
"- facts_query:{\"keyword\": \"关键词\"}
|
|
2215
|
-
"- impact_analysis:{\"change\": \"要做的修改描述\"}
|
|
2216
|
-
"- outline_text
|
|
2217
|
-
"- outline_replace:{\"old\": \"要替换的原文片段\", \"new\": \"新文本\"}
|
|
2218
|
-
"- bible_set_rule:{\"index\": 序号(0起), \"text\": \"新规则文本\"} 或 {\"append\": \"追加的规则\"}
|
|
2817
|
+
"- book_overview:{\"scope\": \"recent|full|volume:2\"(可选,默认 recent)}。返回全书上下文包(总纲/道藏/大世界/章节要点/编年录/暗线/卷首语)。recent=最近30章;full=全部章节(书很长时慎用);volume:N=只看第N卷。",
|
|
2818
|
+
"- facts_query:{\"keyword\": \"关键词\"}。从编年录按关键词检索相关事实(如灵石、境界名、人物名)。",
|
|
2819
|
+
"- impact_analysis:{\"change\": \"要做的修改描述\"}。分析这次改动会波及哪些位置,返回影响清单(定位到章节/设定/编年录)。",
|
|
2820
|
+
"- outline_text:无参数。返回当前总纲全文。",
|
|
2821
|
+
"- outline_replace:{\"old\": \"要替换的原文片段\", \"new\": \"新文本\"}。在总纲中替换一段文字(old 必须能在总纲中找到)。",
|
|
2822
|
+
"- bible_set_rule:{\"index\": 序号(0起), \"text\": \"新规则文本\"} 或 {\"append\": \"追加的规则\"}。修改道藏的世界规则。",
|
|
2219
2823
|
"- bible_set_redline:同上,修改写作红线。",
|
|
2220
2824
|
"- chapter_text:{\"no\": 章节号}。返回该章正文。",
|
|
2221
2825
|
"- chapter_rewrite:{\"no\": 章节号, \"instructions\": \"修改要求\", \"target\": \"原文片段(可选,留空整章)\"}。按讨论结果修订章节;给了 target 只改该自然段。",
|
|
2222
2826
|
"- chapter_generate:{\"no\": 章节号}。重新生成该章。",
|
|
2223
2827
|
"- chapter_review:{\"no\": 章节号}。对该章执行 AI 审稿。",
|
|
2224
|
-
"- foreshadow_add:{\"description\": \"
|
|
2225
|
-
"- foreshadow_update:{\"id\": \"
|
|
2828
|
+
"- foreshadow_add:{\"description\": \"暗线描述\", \"targetChapter\": 预计回收章(可选)}。新增暗线。",
|
|
2829
|
+
"- foreshadow_update:{\"id\": \"暗线id\", \"status\": \"planned|planted|progressing|resolved|abandoned\"}。更新暗线状态。",
|
|
2226
2830
|
"- export_txt:无参数。导出全本 TXT。",
|
|
2227
2831
|
"- assets_status:无参数。查看本书当前写作资产(题材/推进模式/反AI规则/写法)。",
|
|
2228
2832
|
"- assets_set_genre:{\"name\": \"题材名\", \"description\": \"题材说明(可选)\"}。设置本书题材基底。",
|
|
@@ -2230,25 +2834,29 @@ function assistantSystemPrompt(project) {
|
|
|
2230
2834
|
"- assets_add_rule:{\"name\": \"规则名(可选)\", \"avoid\": \"要避免的表达问题\", \"fix\": \"修正方向(可选)}。新增反 AI 规则。",
|
|
2231
2835
|
"",
|
|
2232
2836
|
"回答质量要求(非常重要):",
|
|
2233
|
-
"-
|
|
2837
|
+
"- 具体:回答必须引用项目里的真实内容(人名、境界、章节、暗线、设定),禁止空泛套话。快照里没有的信息,先调用工具获取(chapter_text / outline_text)再回答。",
|
|
2234
2838
|
"- 专业:给建议时说明理由,指出问题所在章节/段落,给出可直接落地的修改方案(改什么、怎么改)。",
|
|
2235
2839
|
"- 主动:作者说\"改一下\",主动调用对应工具执行,不要只给建议不动手;执行前用一句话说明意图,执行后简短汇报结果。",
|
|
2236
|
-
"-
|
|
2840
|
+
"- 忠于设定:以总纲、道藏、编年录为准,不得自相矛盾;发现问题(如剧情与设定冲突)主动指出。",
|
|
2237
2841
|
"- 中文回复;文字量适中,别啰嗦。",
|
|
2238
2842
|
"",
|
|
2239
2843
|
"使用规则(非常重要):",
|
|
2240
2844
|
"- 当你想执行任何工具时,你的【整个回复】必须只包含动作指令标签,格式如下(不要有任何解释文字、不要用自然语言说\"我要去改\",直接输出标签):",
|
|
2241
2845
|
" 正确示例:<dsh-action name=\"outline_replace\">{\"old\":\"要替换的原文\",\"new\":\"新文本\"}</dsh-action>",
|
|
2242
2846
|
" 正确示例:<dsh-action name=\"chapter_text\">{\"no\":1}</dsh-action>",
|
|
2243
|
-
" 错误示例(绝对不要这样回复):\"
|
|
2847
|
+
" 错误示例(绝对不要这样回复):\"好的,我先看一下总纲,马上改。\" ← 这只是文字,不会执行任何操作",
|
|
2848
|
+
" 错误示例(绝对不要这样回复):\"先拉完整上下文确认改动落地情况,避免继续空转。\" ← 没有动作标签,不会执行任何操作",
|
|
2849
|
+
" 错误示例(绝对不要这样回复):\"现在处理暗线库,我先看当前列表定位 id。\" ← 没有动作标签,不会执行任何操作",
|
|
2850
|
+
"- 铁律:只要你想执行任何操作,你的【整个回复】必须只包含一个动作标签,禁止先说话、禁止解释\"我要做什么\"、禁止铺垫——直接输出标签。",
|
|
2851
|
+
"- 如果你收到「格式提示」(宿主说你没有输出动作标签):你的下一条回复必须只输出动作标签,禁止再解释、禁止再道歉、禁止再描述计划。",
|
|
2244
2852
|
"- 工具调用是自动的:你输出标签后,宿主会执行并把结果反馈给你,你再基于结果继续。",
|
|
2245
2853
|
"- 每次回复最多调用 1 个动作;执行结果会反馈给你,你可以继续讨论或再调用。",
|
|
2246
|
-
"-
|
|
2854
|
+
"- 需要先看总纲/章节再决定怎么改?那就先输出一个 outline_text / chapter_text 的标签,等结果回来。",
|
|
2247
2855
|
"- chapter_rewrite 的 target 参数:从章节正文中复制一小段(一句话或几句话即可),不要带换行、不要带引号,取连续文本片段。",
|
|
2248
2856
|
"- 如果工具执行失败(例如片段未找到),根据错误信息修正参数后自动重试一次,不要直接放弃或让作者手动操作。",
|
|
2249
2857
|
"- 修改前先向作者说明你要改什么、为什么;动作执行后简要汇报结果。",
|
|
2250
2858
|
"- 涉及删除类操作(删除章节、清空设定)必须等作者明确同意。",
|
|
2251
|
-
"-
|
|
2859
|
+
"- 严格忠于道藏与总纲;不得自行发明与既有设定冲突的内容。",
|
|
2252
2860
|
"- 用中文回复。"
|
|
2253
2861
|
].join("\n");
|
|
2254
2862
|
}
|
|
@@ -2289,11 +2897,11 @@ async function* executeAction(ctx, config, project, outputDir, name, args) {
|
|
|
2289
2897
|
case "outline_replace": {
|
|
2290
2898
|
const old = str(args.old);
|
|
2291
2899
|
const next = str(args.new);
|
|
2292
|
-
if (old === "" || !project.outline.includes(old)) throw new Error(
|
|
2900
|
+
if (old === "" || !project.outline.includes(old)) throw new Error(`总纲中未找到片段「${old.slice(0, 40)}…」`);
|
|
2293
2901
|
project.outline = project.outline.replace(old, next);
|
|
2294
2902
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2295
2903
|
saveProject(outputDir, project);
|
|
2296
|
-
return
|
|
2904
|
+
return `总纲已修改:替换了 ${old.length} 字符的片段。`;
|
|
2297
2905
|
}
|
|
2298
2906
|
case "bible_set_rule": {
|
|
2299
2907
|
if (project.bible === void 0) throw new Error("尚无道藏,请先提炼");
|
|
@@ -2383,13 +2991,13 @@ async function* executeAction(ctx, config, project, outputDir, name, args) {
|
|
|
2383
2991
|
});
|
|
2384
2992
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2385
2993
|
saveProject(outputDir, project);
|
|
2386
|
-
return
|
|
2994
|
+
return `已新增暗线:「${description.slice(0, 50)}」`;
|
|
2387
2995
|
}
|
|
2388
2996
|
case "foreshadow_update": {
|
|
2389
2997
|
const id = str(args.id);
|
|
2390
2998
|
const status = str(args.status);
|
|
2391
2999
|
const target = project.foreshadows.find((f) => f.id === id);
|
|
2392
|
-
if (target === void 0) throw new Error(
|
|
3000
|
+
if (target === void 0) throw new Error(`暗线 ${id} 不存在`);
|
|
2393
3001
|
if (![
|
|
2394
3002
|
"planned",
|
|
2395
3003
|
"planted",
|
|
@@ -2400,7 +3008,7 @@ async function* executeAction(ctx, config, project, outputDir, name, args) {
|
|
|
2400
3008
|
target.status = status;
|
|
2401
3009
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2402
3010
|
saveProject(outputDir, project);
|
|
2403
|
-
return
|
|
3011
|
+
return `暗线已更新为 ${status}:「${target.description.slice(0, 50)}」`;
|
|
2404
3012
|
}
|
|
2405
3013
|
case "export_txt": {
|
|
2406
3014
|
const result = exportBook(outputDir, project, "txt");
|
|
@@ -2494,7 +3102,7 @@ function extractAction(reply) {
|
|
|
2494
3102
|
}
|
|
2495
3103
|
/** Render the recent history as LLM messages (skipping tool chatter in early rounds). */
|
|
2496
3104
|
function historyToMessages(history) {
|
|
2497
|
-
const recent = history.slice(-
|
|
3105
|
+
const recent = history.slice(-18);
|
|
2498
3106
|
const messages = [];
|
|
2499
3107
|
for (const entry of recent) if (entry.role === "user") messages.push(createUserMessage({
|
|
2500
3108
|
content: [{
|
|
@@ -2534,6 +3142,11 @@ function historyToMessages(history) {
|
|
|
2534
3142
|
/** One non-streaming LLM chat turn (used inside the tool loop). */
|
|
2535
3143
|
async function chatOnce(ctx, config, system, history) {
|
|
2536
3144
|
const messages = historyToMessages(history);
|
|
3145
|
+
const last = messages[messages.length - 1];
|
|
3146
|
+
if (last?.role === "user" && Array.isArray(last.content)) {
|
|
3147
|
+
const text = last.content.find((b) => b.type === "text");
|
|
3148
|
+
if (text !== void 0 && typeof text.text === "string") text.text += "\n\n(回复格式提醒:如果你需要执行任何操作,你的回复必须【只包含】一个 <dsh-action name=\"工具名\">{\"参数\":值}</dsh-action> 标签,禁止先说话、禁止解释、禁止铺垫;如果你只是在回答或讨论,正常回复即可,不要输出标签。)";
|
|
3149
|
+
}
|
|
2537
3150
|
const request = {
|
|
2538
3151
|
provider: config.provider,
|
|
2539
3152
|
model: config.model,
|
|
@@ -2566,17 +3179,20 @@ async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
|
|
|
2566
3179
|
history.push(userEntry);
|
|
2567
3180
|
appendHistory(outputDir, userEntry);
|
|
2568
3181
|
let round = 0;
|
|
2569
|
-
/**
|
|
2570
|
-
let nudged =
|
|
3182
|
+
/** 已提示模型输出动作标签的次数(0 = 尚未提示;超过上限则按纯文字回复结束,防死循环)。 */
|
|
3183
|
+
let nudged = 0;
|
|
3184
|
+
const MAX_NUDGES = 6;
|
|
2571
3185
|
for (;;) {
|
|
3186
|
+
if (round++ > 20) break;
|
|
2572
3187
|
const reply = await chatOnce(ctx, config, system, history);
|
|
2573
3188
|
const action = extractAction(reply);
|
|
2574
3189
|
if (action === void 0) {
|
|
2575
|
-
const intendsAction = /(
|
|
3190
|
+
const intendsAction = /(改|修改|修订|重写|替换|调整|生成|新增|删除|导出|看看|查看|调出|读一下|加上|加一个|去掉|删掉|把.+改成|定位|处理|转轨|检查|确认|搜索|找一下|列一下|查一下|查一遍|查一查|再查|查查|核实|核对|清点|盘点|看一下|看下|继续)/.test(reply);
|
|
3191
|
+
const mentionsTarget = /(编年录|道藏|暗线|总纲|卷首语|章节|正文|规则|红线|伏笔|简介|大纲|事实|设定|世界|角色|人物|第\s*\d+\s*章)/.test(reply);
|
|
2576
3192
|
const strayTag = /<[a-z_-]*action[^>]*>/.test(reply);
|
|
2577
|
-
if ((intendsAction || strayTag) &&
|
|
2578
|
-
nudged =
|
|
2579
|
-
|
|
3193
|
+
if ((intendsAction || mentionsTarget || strayTag) && nudged < MAX_NUDGES) {
|
|
3194
|
+
const nudge = nudged === 0 ? "你的上一条回复表达了想操作项目的意图(或动作标签格式有误),因此没有执行任何操作。请直接输出动作标签来执行,格式必须为 <dsh-action name=\"工具名\">{\"参数\":值}</dsh-action>(注意拼写是 dsh-action,不是 dash-action;标签成对出现,参数为合法 JSON)。如果需要先看内容,先输出 outline_text 或 chapter_text 标签。" : `你第 ${nudged + 1} 次表达了操作意图但没有输出动作标签,因此仍未执行任何操作。铁律:你的【整个回复】现在必须只包含一个 <dsh-action> 标签(例如 <dsh-action name="chapter_text">{"no":1}</dsh-action>),禁止任何解释、铺垫或"我这就去"之类的文字。若你其实不打算执行任何操作,请明确回复「不执行」。`;
|
|
3195
|
+
nudged++;
|
|
2580
3196
|
history.push({
|
|
2581
3197
|
role: "tool",
|
|
2582
3198
|
content: nudge,
|
|
@@ -3137,6 +3753,27 @@ function makeRoutes(deps) {
|
|
|
3137
3753
|
chapter.status = "approved";
|
|
3138
3754
|
saveProject(config.outputDir, project);
|
|
3139
3755
|
}
|
|
3756
|
+
if (config.autoAuthorReview ?? true) try {
|
|
3757
|
+
const currentBody = readChapterFile(config.outputDir, chapter);
|
|
3758
|
+
let prevTail = "";
|
|
3759
|
+
if (no > 1) {
|
|
3760
|
+
const prev = project.chapters.find((c) => c.no === no - 1);
|
|
3761
|
+
if (prev !== void 0) prevTail = (readChapterFile(config.outputDir, prev) ?? "").replace(/^#.*$/m, "").trim().slice(-600);
|
|
3762
|
+
}
|
|
3763
|
+
if (currentBody !== void 0) {
|
|
3764
|
+
const review = await authorReviewChapter(ctx, config, project, no, currentBody, prevTail);
|
|
3765
|
+
chapter.authorReview = review;
|
|
3766
|
+
if (review.advancedLines !== void 0) autoLinkPlotlines(project, no, review.advancedLines);
|
|
3767
|
+
saveProject(config.outputDir, project);
|
|
3768
|
+
send({
|
|
3769
|
+
type: "author-review",
|
|
3770
|
+
no,
|
|
3771
|
+
review
|
|
3772
|
+
});
|
|
3773
|
+
}
|
|
3774
|
+
} catch (error) {
|
|
3775
|
+
console.warn("[dsh-novel-forge] author review failed:", error.message);
|
|
3776
|
+
}
|
|
3140
3777
|
res.end();
|
|
3141
3778
|
} catch (error) {
|
|
3142
3779
|
chapter.status = "error";
|
|
@@ -3867,6 +4504,19 @@ function makeRoutes(deps) {
|
|
|
3867
4504
|
if (Array.isArray(body?.worldRules)) project.bible.worldRules = body.worldRules.filter((r) => r.trim() !== "");
|
|
3868
4505
|
if (Array.isArray(body?.redLines)) project.bible.redLines = body.redLines.filter((r) => r.trim() !== "");
|
|
3869
4506
|
if (Array.isArray(body?.style)) project.bible.style = body.style.filter((r) => r.trim() !== "");
|
|
4507
|
+
if (Array.isArray(body?.characters)) project.bible.characters = body.characters.filter((c) => c !== void 0 && c !== null && typeof c.name === "string" && c.name.trim() !== "").map((c) => ({
|
|
4508
|
+
name: c.name.trim(),
|
|
4509
|
+
role: [
|
|
4510
|
+
"protagonist",
|
|
4511
|
+
"supporting",
|
|
4512
|
+
"antagonist",
|
|
4513
|
+
"other"
|
|
4514
|
+
].includes(c.role) ? c.role : "other",
|
|
4515
|
+
traits: Array.isArray(c.traits) ? c.traits.filter((t) => typeof t === "string" && t.trim() !== "") : [],
|
|
4516
|
+
goals: typeof c.goals === "string" ? c.goals : "",
|
|
4517
|
+
relations: typeof c.relations === "string" ? c.relations : "",
|
|
4518
|
+
knowledge: Array.isArray(c.knowledge) ? c.knowledge.filter((k) => typeof k === "string" && k.trim() !== "") : void 0
|
|
4519
|
+
}));
|
|
3870
4520
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3871
4521
|
saveProject(config.outputDir, project);
|
|
3872
4522
|
writeJson(res, 200, { bible: project.bible });
|
|
@@ -4029,6 +4679,241 @@ function makeRoutes(deps) {
|
|
|
4029
4679
|
writeJson(res, 200, { bookName: project.bookName });
|
|
4030
4680
|
}
|
|
4031
4681
|
},
|
|
4682
|
+
{
|
|
4683
|
+
kind: "exact",
|
|
4684
|
+
path: NOVEL_API.plotlines,
|
|
4685
|
+
handler: async (req, res) => {
|
|
4686
|
+
if (!guard(req, res, "POST")) return;
|
|
4687
|
+
const config = getConfig();
|
|
4688
|
+
const project = requireProject(res);
|
|
4689
|
+
if (project === void 0) return;
|
|
4690
|
+
const body = await readJsonBody(req);
|
|
4691
|
+
if (project.plotlines === void 0) project.plotlines = [];
|
|
4692
|
+
const op = body?.op;
|
|
4693
|
+
if (op === "add" && body?.line !== void 0) {
|
|
4694
|
+
const line = body.line;
|
|
4695
|
+
project.plotlines.push({
|
|
4696
|
+
id: line.id !== "" ? line.id : `pl-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
4697
|
+
name: line.name.slice(0, 40),
|
|
4698
|
+
kind: line.kind,
|
|
4699
|
+
goal: line.goal.slice(0, 300),
|
|
4700
|
+
progress: line.progress.slice(0, 300),
|
|
4701
|
+
status: line.status,
|
|
4702
|
+
chapters: Array.isArray(line.chapters) ? line.chapters.filter((n) => typeof n === "number") : [],
|
|
4703
|
+
createdAt: line.createdAt !== "" ? line.createdAt : (/* @__PURE__ */ new Date()).toISOString()
|
|
4704
|
+
});
|
|
4705
|
+
} else if (op === "update" && body?.line !== void 0 && body.line.id !== "") {
|
|
4706
|
+
const idx = project.plotlines.findIndex((l) => l.id === body.line.id);
|
|
4707
|
+
if (idx !== -1) {
|
|
4708
|
+
const line = body.line;
|
|
4709
|
+
project.plotlines[idx] = {
|
|
4710
|
+
...project.plotlines[idx],
|
|
4711
|
+
name: line.name.slice(0, 40),
|
|
4712
|
+
kind: line.kind,
|
|
4713
|
+
goal: line.goal.slice(0, 300),
|
|
4714
|
+
progress: line.progress.slice(0, 300),
|
|
4715
|
+
status: line.status
|
|
4716
|
+
};
|
|
4717
|
+
}
|
|
4718
|
+
} else if (op === "remove" && body?.id !== void 0) project.plotlines = project.plotlines.filter((l) => l.id !== body.id);
|
|
4719
|
+
else if (op === "link" && body?.id !== void 0 && typeof body.chapterNo === "number" && body.chapterNo > 0) {
|
|
4720
|
+
const line = project.plotlines.find((l) => l.id === body.id);
|
|
4721
|
+
if (line !== void 0 && !line.chapters.includes(body.chapterNo)) {
|
|
4722
|
+
line.chapters.push(body.chapterNo);
|
|
4723
|
+
if (line.status === "active" && line.progress === "") line.progress = `推进至第 ${body.chapterNo} 章`;
|
|
4724
|
+
}
|
|
4725
|
+
} else if (op === "suggest") try {
|
|
4726
|
+
const suggestions = await suggestPlotlines(ctx, config, project);
|
|
4727
|
+
writeJson(res, 200, {
|
|
4728
|
+
plotlines: project.plotlines,
|
|
4729
|
+
suggestions
|
|
4730
|
+
});
|
|
4731
|
+
return;
|
|
4732
|
+
} catch (error) {
|
|
4733
|
+
writeJson(res, 500, { error: `AI 建议失败:${error.message}` });
|
|
4734
|
+
return;
|
|
4735
|
+
}
|
|
4736
|
+
else if (op === "refresh" && body?.id !== void 0) {
|
|
4737
|
+
const line = project.plotlines.find((l) => l.id === body.id);
|
|
4738
|
+
if (line === void 0) {
|
|
4739
|
+
writeJson(res, 404, { error: "剧情线不存在" });
|
|
4740
|
+
return;
|
|
4741
|
+
}
|
|
4742
|
+
try {
|
|
4743
|
+
line.progress = await refreshPlotlineProgress(ctx, config, project, line);
|
|
4744
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4745
|
+
saveProject(config.outputDir, project);
|
|
4746
|
+
writeJson(res, 200, { plotlines: project.plotlines });
|
|
4747
|
+
return;
|
|
4748
|
+
} catch (error) {
|
|
4749
|
+
writeJson(res, 500, { error: `刷新进度失败:${error.message}` });
|
|
4750
|
+
return;
|
|
4751
|
+
}
|
|
4752
|
+
} else if (op === "health") try {
|
|
4753
|
+
const health = await analyzePlotlineHealth(ctx, config, project);
|
|
4754
|
+
writeJson(res, 200, {
|
|
4755
|
+
plotlines: project.plotlines,
|
|
4756
|
+
health
|
|
4757
|
+
});
|
|
4758
|
+
return;
|
|
4759
|
+
} catch (error) {
|
|
4760
|
+
writeJson(res, 500, { error: `健康检查失败:${error.message}` });
|
|
4761
|
+
return;
|
|
4762
|
+
}
|
|
4763
|
+
else if (op === "plan") try {
|
|
4764
|
+
const health = await analyzePlotlineHealth(ctx, config, project);
|
|
4765
|
+
const plan = await designPlotlinePlan(ctx, config, project, health);
|
|
4766
|
+
writeJson(res, 200, {
|
|
4767
|
+
plotlines: project.plotlines,
|
|
4768
|
+
health,
|
|
4769
|
+
plan
|
|
4770
|
+
});
|
|
4771
|
+
return;
|
|
4772
|
+
} catch (error) {
|
|
4773
|
+
writeJson(res, 500, { error: `剧情方案生成失败:${error.message}` });
|
|
4774
|
+
return;
|
|
4775
|
+
}
|
|
4776
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4777
|
+
saveProject(config.outputDir, project);
|
|
4778
|
+
writeJson(res, 200, { plotlines: project.plotlines });
|
|
4779
|
+
}
|
|
4780
|
+
},
|
|
4781
|
+
{
|
|
4782
|
+
kind: "exact",
|
|
4783
|
+
path: NOVEL_API.sensitiveCheck,
|
|
4784
|
+
handler: async (req, res) => {
|
|
4785
|
+
if (!guard(req, res, "POST")) return;
|
|
4786
|
+
const config = getConfig();
|
|
4787
|
+
const project = requireProject(res);
|
|
4788
|
+
if (project === void 0) return;
|
|
4789
|
+
const body = await readJsonBody(req);
|
|
4790
|
+
const hits = [];
|
|
4791
|
+
let scanned = 0;
|
|
4792
|
+
if (body?.text !== void 0) for (const hit of checkSensitiveText(body.text)) hits.push({
|
|
4793
|
+
chapterNo: 0,
|
|
4794
|
+
word: hit.word,
|
|
4795
|
+
category: hit.category,
|
|
4796
|
+
count: hit.count
|
|
4797
|
+
});
|
|
4798
|
+
else if (typeof body?.chapterNo === "number") {
|
|
4799
|
+
const chapter = project.chapters.find((c) => c.no === body.chapterNo);
|
|
4800
|
+
if (chapter !== void 0) {
|
|
4801
|
+
const text = readChapterFile(config.outputDir, chapter);
|
|
4802
|
+
if (text !== void 0) {
|
|
4803
|
+
scanned = 1;
|
|
4804
|
+
for (const hit of checkSensitiveText(text)) hits.push({
|
|
4805
|
+
chapterNo: chapter.no,
|
|
4806
|
+
word: hit.word,
|
|
4807
|
+
category: hit.category,
|
|
4808
|
+
count: hit.count
|
|
4809
|
+
});
|
|
4810
|
+
}
|
|
4811
|
+
}
|
|
4812
|
+
} else if (body?.all === true) for (const chapter of project.chapters) {
|
|
4813
|
+
if (chapter.status === "pending" || chapter.status === "generating") continue;
|
|
4814
|
+
const text = readChapterFile(config.outputDir, chapter);
|
|
4815
|
+
if (text === void 0) continue;
|
|
4816
|
+
scanned++;
|
|
4817
|
+
for (const hit of checkSensitiveText(text)) hits.push({
|
|
4818
|
+
chapterNo: chapter.no,
|
|
4819
|
+
word: hit.word,
|
|
4820
|
+
category: hit.category,
|
|
4821
|
+
count: hit.count
|
|
4822
|
+
});
|
|
4823
|
+
}
|
|
4824
|
+
else {
|
|
4825
|
+
writeJson(res, 400, { error: "请提供 chapterNo / text / all 之一" });
|
|
4826
|
+
return;
|
|
4827
|
+
}
|
|
4828
|
+
writeJson(res, 200, {
|
|
4829
|
+
hits,
|
|
4830
|
+
scannedChapters: scanned
|
|
4831
|
+
});
|
|
4832
|
+
}
|
|
4833
|
+
},
|
|
4834
|
+
{
|
|
4835
|
+
kind: "exact",
|
|
4836
|
+
path: NOVEL_API.reviewBackfill,
|
|
4837
|
+
handler: async (req, res) => {
|
|
4838
|
+
if (!guard(req, res, "POST")) return;
|
|
4839
|
+
const config = getConfig();
|
|
4840
|
+
const project = requireProject(res);
|
|
4841
|
+
if (project === void 0) return;
|
|
4842
|
+
const body = await readJsonBody(req);
|
|
4843
|
+
/** 对一章执行作者复盘(读取已落盘正文,不改变章节状态/正文)。 */
|
|
4844
|
+
const runOne = async (chapter) => {
|
|
4845
|
+
const currentBody = readChapterFile(config.outputDir, chapter);
|
|
4846
|
+
if (currentBody === void 0) throw new Error(`章节 ${chapter.no} 的正文文件不存在`);
|
|
4847
|
+
let prevTail = "";
|
|
4848
|
+
if (chapter.no > 1) {
|
|
4849
|
+
const prev = project.chapters.find((c) => c.no === chapter.no - 1);
|
|
4850
|
+
if (prev !== void 0) prevTail = (readChapterFile(config.outputDir, prev) ?? "").replace(/^#.*$/m, "").trim().slice(-600);
|
|
4851
|
+
}
|
|
4852
|
+
return authorReviewChapter(ctx, config, project, chapter.no, currentBody, prevTail);
|
|
4853
|
+
};
|
|
4854
|
+
if (typeof body?.chapterNo === "number" && body.chapterNo > 0) {
|
|
4855
|
+
const chapter = project.chapters.find((c) => c.no === body.chapterNo);
|
|
4856
|
+
if (chapter === void 0) {
|
|
4857
|
+
writeJson(res, 404, { error: `章节 ${body.chapterNo} 不在计划中` });
|
|
4858
|
+
return;
|
|
4859
|
+
}
|
|
4860
|
+
if (chapter.status === "pending") {
|
|
4861
|
+
writeJson(res, 400, { error: "该章尚未生成正文,无法复盘" });
|
|
4862
|
+
return;
|
|
4863
|
+
}
|
|
4864
|
+
try {
|
|
4865
|
+
const review = await runOne(chapter);
|
|
4866
|
+
chapter.authorReview = review;
|
|
4867
|
+
if (review.advancedLines !== void 0) autoLinkPlotlines(project, chapter.no, review.advancedLines);
|
|
4868
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4869
|
+
saveProject(config.outputDir, project);
|
|
4870
|
+
writeJson(res, 200, {
|
|
4871
|
+
no: chapter.no,
|
|
4872
|
+
review
|
|
4873
|
+
});
|
|
4874
|
+
return;
|
|
4875
|
+
} catch (error) {
|
|
4876
|
+
writeJson(res, 500, { error: error.message });
|
|
4877
|
+
return;
|
|
4878
|
+
}
|
|
4879
|
+
}
|
|
4880
|
+
const missing = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating" && c.authorReview === void 0);
|
|
4881
|
+
if (missing.length === 0) {
|
|
4882
|
+
writeJson(res, 200, { count: 0 });
|
|
4883
|
+
return;
|
|
4884
|
+
}
|
|
4885
|
+
res.writeHead(200, {
|
|
4886
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
4887
|
+
"cache-control": "no-cache",
|
|
4888
|
+
"referrer-policy": "no-referrer"
|
|
4889
|
+
});
|
|
4890
|
+
const send = (frame) => {
|
|
4891
|
+
res.write(JSON.stringify(frame) + "\n");
|
|
4892
|
+
};
|
|
4893
|
+
let done = 0;
|
|
4894
|
+
for (const chapter of missing) try {
|
|
4895
|
+
const review = await runOne(chapter);
|
|
4896
|
+
chapter.authorReview = review;
|
|
4897
|
+
if (review.advancedLines !== void 0) autoLinkPlotlines(project, chapter.no, review.advancedLines);
|
|
4898
|
+
done++;
|
|
4899
|
+
saveProject(config.outputDir, project);
|
|
4900
|
+
send({
|
|
4901
|
+
type: "author-review",
|
|
4902
|
+
no: chapter.no,
|
|
4903
|
+
review
|
|
4904
|
+
});
|
|
4905
|
+
} catch (error) {
|
|
4906
|
+
console.warn(`[dsh-novel-forge] author backfill ch.${chapter.no} failed:`, error.message);
|
|
4907
|
+
}
|
|
4908
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4909
|
+
saveProject(config.outputDir, project);
|
|
4910
|
+
send({
|
|
4911
|
+
type: "author-backfill-done",
|
|
4912
|
+
count: done
|
|
4913
|
+
});
|
|
4914
|
+
res.end();
|
|
4915
|
+
}
|
|
4916
|
+
},
|
|
4032
4917
|
{
|
|
4033
4918
|
kind: "exact",
|
|
4034
4919
|
path: NOVEL_API.config,
|
|
@@ -4089,7 +4974,8 @@ const Config = z.object({
|
|
|
4089
4974
|
chapterChars: z.number().default(3500),
|
|
4090
4975
|
maxTokens: z.number().default(12e3),
|
|
4091
4976
|
reviewPassScore: z.number().default(70),
|
|
4092
|
-
autoReview: z.boolean().default(true)
|
|
4977
|
+
autoReview: z.boolean().default(true),
|
|
4978
|
+
autoAuthorReview: z.boolean().default(true)
|
|
4093
4979
|
});
|
|
4094
4980
|
/** Schema defaults, re-read for hand-built test contexts. */
|
|
4095
4981
|
const DEFAULT_ANNOUNCE = true;
|
|
@@ -4101,6 +4987,7 @@ const DEFAULT_CHAPTER_CHARS = 3500;
|
|
|
4101
4987
|
const DEFAULT_MAX_TOKENS = 12e3;
|
|
4102
4988
|
const DEFAULT_REVIEW_PASS_SCORE = 70;
|
|
4103
4989
|
const DEFAULT_AUTO_REVIEW = true;
|
|
4990
|
+
const DEFAULT_AUTO_AUTHOR_REVIEW = true;
|
|
4104
4991
|
/** Order of the announcement section within the tool-guidance band. */
|
|
4105
4992
|
const SECTION_ORDER = 160;
|
|
4106
4993
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
@@ -4115,7 +5002,8 @@ function resolveConfig(value) {
|
|
|
4115
5002
|
chapterChars: value?.chapterChars ?? DEFAULT_CHAPTER_CHARS,
|
|
4116
5003
|
maxTokens: value?.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
4117
5004
|
reviewPassScore: value?.reviewPassScore ?? DEFAULT_REVIEW_PASS_SCORE,
|
|
4118
|
-
autoReview: value?.autoReview ?? DEFAULT_AUTO_REVIEW
|
|
5005
|
+
autoReview: value?.autoReview ?? DEFAULT_AUTO_REVIEW,
|
|
5006
|
+
autoAuthorReview: value?.autoAuthorReview ?? DEFAULT_AUTO_AUTHOR_REVIEW
|
|
4119
5007
|
};
|
|
4120
5008
|
}
|
|
4121
5009
|
/**
|
|
@@ -4144,6 +5032,7 @@ function apply(ctx, config) {
|
|
|
4144
5032
|
if (patch.maxTokens !== void 0) next.maxTokens = patch.maxTokens;
|
|
4145
5033
|
if (patch.reviewPassScore !== void 0) next.reviewPassScore = patch.reviewPassScore;
|
|
4146
5034
|
if (patch.autoReview !== void 0) next.autoReview = patch.autoReview;
|
|
5035
|
+
if (patch.autoAuthorReview !== void 0) next.autoAuthorReview = patch.autoAuthorReview;
|
|
4147
5036
|
const settings = ctx.get("settings");
|
|
4148
5037
|
if (settings !== void 0) await settings.update(NOVEL_SETTINGS_NAMESPACE, next);
|
|
4149
5038
|
else current = () => ({
|