@waterwx/dsh-novel-forge 1.1.5 → 1.1.7
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 +104 -5
- package/lib/client.js +1237 -1327
- package/lib/client.js.map +1 -1
- package/lib/index.js +150 -9
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +5 -0
- package/lib/types/client/panel/helpers.d.ts +10 -0
- package/lib/types/client/panel/views.d.ts +77 -0
- package/lib/types/engine.d.ts +7 -0
- package/lib/types/protocol.d.ts +2 -0
- package/package.json +1 -1
- package/src/assistant.ts +59 -2
- package/src/client/api.ts +5 -0
- package/src/client/panel/NovelPanel.tsx +132 -335
- package/src/client/panel/helpers.ts +50 -0
- package/src/client/panel/views.tsx +314 -0
- package/src/engine.ts +61 -12
- package/src/protocol.ts +2 -0
- package/src/routes.ts +38 -0
package/lib/index.js
CHANGED
|
@@ -71,6 +71,8 @@ const NOVEL_API = {
|
|
|
71
71
|
reviewBackfill: "/api/dsh-novel-forge/review/backfill",
|
|
72
72
|
/** 章节复位:generating 卡死 → pending(可重新生成)。 */
|
|
73
73
|
chapterReset: "/api/dsh-novel-forge/chapter/reset",
|
|
74
|
+
/** 章节直接通过:作者对 rejected/written 章节行使最终决定权。 */
|
|
75
|
+
chapterApprove: "/api/dsh-novel-forge/chapter/approve",
|
|
74
76
|
/** 敏感词检查:全书已写章节或指定文本。 */
|
|
75
77
|
sensitiveCheck: "/api/dsh-novel-forge/sensitive-check",
|
|
76
78
|
config: "/api/dsh-novel-forge/config",
|
|
@@ -791,6 +793,28 @@ function saveProject(outputDir, project) {
|
|
|
791
793
|
writeFileSync(join(outputDir, PROJECT_FILE), JSON.stringify(project, null, 2), "utf8");
|
|
792
794
|
}
|
|
793
795
|
/**
|
|
796
|
+
* 并发保护:长任务(章节计划生成/正文生成)在内存中持有旧快照,
|
|
797
|
+
* 期间其他请求可能修改了「易变字段」(道藏/角色库/剧情线/人物志存档/简介/封面)。
|
|
798
|
+
* 保存前用磁盘最新版本合并这些字段,避免旧快照覆盖新修改(曾导致角色卡丢失)。
|
|
799
|
+
* 注意:调用方若自己修改了这些字段,不要使用本函数。
|
|
800
|
+
*/
|
|
801
|
+
function mergeVolatileFromDisk(outputDir, project) {
|
|
802
|
+
try {
|
|
803
|
+
const disk = loadProject(outputDir);
|
|
804
|
+
if (disk === void 0) return;
|
|
805
|
+
project.bible = disk.bible;
|
|
806
|
+
project.roles = disk.roles;
|
|
807
|
+
project.plotlines = disk.plotlines;
|
|
808
|
+
project.roleStatus = disk.roleStatus;
|
|
809
|
+
project.blurb = disk.blurb;
|
|
810
|
+
project.coverPath = disk.coverPath;
|
|
811
|
+
project.facts = disk.facts;
|
|
812
|
+
project.assets = disk.assets;
|
|
813
|
+
project.world = disk.world;
|
|
814
|
+
project.volumes = disk.volumes;
|
|
815
|
+
} catch {}
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
794
818
|
* 内置违禁词库(网文平台常见审查类别)。只做硬匹配提示,不代替人工判断。
|
|
795
819
|
* 词语刻意保持常见写法;作者可自行判断是否修改。
|
|
796
820
|
*/
|
|
@@ -2131,15 +2155,32 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
|
|
|
2131
2155
|
const prevSummary = prev?.summary;
|
|
2132
2156
|
const allFacts = project.facts ?? [];
|
|
2133
2157
|
const recentFacts = allFacts.slice(-20).map((f) => f.text);
|
|
2158
|
+
const recentSet = new Set(recentFacts);
|
|
2134
2159
|
const beatsText = chapter.beats;
|
|
2135
|
-
const
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2160
|
+
const roleNames = (project.roles ?? []).map((r) => r.name).filter((n) => typeof n === "string" && n !== "");
|
|
2161
|
+
const trigrams = (s) => {
|
|
2162
|
+
const out = /* @__PURE__ */ new Set();
|
|
2163
|
+
for (let i = 0; i + 3 <= s.length; i++) {
|
|
2164
|
+
const tri = s.slice(i, i + 3);
|
|
2165
|
+
if (tri.trim() !== "") out.add(tri);
|
|
2140
2166
|
}
|
|
2141
|
-
return
|
|
2142
|
-
}
|
|
2167
|
+
return out;
|
|
2168
|
+
};
|
|
2169
|
+
const beatsTri = trigrams(beatsText);
|
|
2170
|
+
const beatRoles = roleNames.filter((n) => beatsText.includes(n));
|
|
2171
|
+
const relatedFacts = allFacts.map((f, idx) => {
|
|
2172
|
+
const head = f.text.slice(0, 80);
|
|
2173
|
+
let score = 0;
|
|
2174
|
+
for (const tri of trigrams(head)) if (beatsTri.has(tri)) score += 1;
|
|
2175
|
+
if (beatRoles.length > 0) {
|
|
2176
|
+
for (const n of beatRoles) if (head.includes(n)) score += 8;
|
|
2177
|
+
}
|
|
2178
|
+
score += Math.min(idx, 40) / 10;
|
|
2179
|
+
return {
|
|
2180
|
+
f,
|
|
2181
|
+
score
|
|
2182
|
+
};
|
|
2183
|
+
}).filter((x) => x.score >= 3).sort((a, b) => b.score - a.score).slice(0, 15).map((x) => `[第${x.f.chapterNo}章] ${x.f.text}`).filter((t) => !recentSet.has(t.slice(t.indexOf("]") + 2)));
|
|
2143
2184
|
const messages = [createUserMessage({
|
|
2144
2185
|
content: [{
|
|
2145
2186
|
type: "text",
|
|
@@ -2591,7 +2632,11 @@ async function backfillFacts(ctx, config, project, outputDir) {
|
|
|
2591
2632
|
* 最近出现章节),LLM 只负责聚合「当前状态」一句话。
|
|
2592
2633
|
*/
|
|
2593
2634
|
async function refreshCharacters(ctx, config, project, outputDir) {
|
|
2594
|
-
const roster = project.bible?.characters ?? []
|
|
2635
|
+
const roster = (((project.roles ?? []).length > 0 ? project.roles : project.bible?.characters) ?? []).map((r) => ({
|
|
2636
|
+
name: r.name,
|
|
2637
|
+
traits: r.traits ?? [],
|
|
2638
|
+
role: r.roleLabel !== void 0 ? r.roleLabel : r.role ?? "other"
|
|
2639
|
+
}));
|
|
2595
2640
|
const facts = project.facts ?? [];
|
|
2596
2641
|
if (roster.length === 0 && facts.length === 0) return [];
|
|
2597
2642
|
const stat = /* @__PURE__ */ new Map();
|
|
@@ -2922,6 +2967,7 @@ function assistantSystemPrompt(project) {
|
|
|
2922
2967
|
"- 中文回复;文字量适中,别啰嗦。",
|
|
2923
2968
|
"",
|
|
2924
2969
|
"使用规则(非常重要):",
|
|
2970
|
+
"- 写操作(chapter_generate / chapter_rewrite / chapter_review / outline_replace / bible_set_* / foreshadow_* / assets_set_* / export_txt)只有在作者明确要求时才能调用——例如作者说\"生成第 120 章\"\"把第 105 章结尾改一下\"\"帮我审一下第 88 章\"。作者只是提问、闲聊、查信息时,一律用文字回答,禁止调用任何写操作,也不要先斩后奏(如\"为了回答你,我先把第 X 章生成了\")。",
|
|
2925
2971
|
"- 当你想执行任何工具时,你的【整个回复】必须只包含动作指令标签,格式如下(不要有任何解释文字、不要用自然语言说\"我要去改\",直接输出标签):",
|
|
2926
2972
|
" 正确示例:<dsh-action name=\"outline_replace\">{\"old\":\"要替换的原文\",\"new\":\"新文本\"}</dsh-action>",
|
|
2927
2973
|
" 正确示例:<dsh-action name=\"chapter_text\">{\"no\":1}</dsh-action>",
|
|
@@ -3276,6 +3322,33 @@ async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
|
|
|
3276
3322
|
const MAX_NUDGES = 6;
|
|
3277
3323
|
/** 连续收到 hex 乱码回复的次数(≥2 次判定 LLM 侧异常,放弃本轮避免死循环)。 */
|
|
3278
3324
|
let garbleCount = 0;
|
|
3325
|
+
const WRITE_TOOL_KEYS = {
|
|
3326
|
+
chapter_generate: /(生成|写第\s*\d+\s*章|写一[章篇]|新写|续写|接着写|继续写|开始写|写正文|写书|创作)/,
|
|
3327
|
+
chapter_rewrite: /(重写|改写|修订|修改|改一下|调整|替换|润色|优化|修正|完善|回炉|换一种|从头)/,
|
|
3328
|
+
chapter_review: /(审|检查|校验|点评|评估|把关|质量|怎么样|如何)/,
|
|
3329
|
+
outline_replace: /(大纲|总纲|简介)/,
|
|
3330
|
+
bible_set_rule: /(道藏|设定|规则|红线|世界|金手指)/,
|
|
3331
|
+
bible_set_redline: /(道藏|设定|红线)/,
|
|
3332
|
+
foreshadow_add: /(暗线|伏笔|埋)/,
|
|
3333
|
+
foreshadow_update: /(暗线|伏笔)/,
|
|
3334
|
+
export_txt: /(导出|打包|下载|txt)/,
|
|
3335
|
+
assets_set_genre: /(题材)/,
|
|
3336
|
+
assets_set_progression: /(推进)/,
|
|
3337
|
+
assets_add_rule: /(规则|文戒|反AI)/
|
|
3338
|
+
};
|
|
3339
|
+
/** 本轮已放行过写操作:后续写操作(生成→审稿→修订闭环)不再逐个拦截。 */
|
|
3340
|
+
let writeUnlocked = false;
|
|
3341
|
+
const guardWrite = (name, userMessage) => {
|
|
3342
|
+
if (writeUnlocked) return true;
|
|
3343
|
+
const key = WRITE_TOOL_KEYS[name];
|
|
3344
|
+
if (key === void 0) return true;
|
|
3345
|
+
const recentUsers = history.filter((m) => m.role === "user").slice(-2).map((m) => m.content).join("\n");
|
|
3346
|
+
if (key.test(recentUsers)) {
|
|
3347
|
+
writeUnlocked = true;
|
|
3348
|
+
return true;
|
|
3349
|
+
}
|
|
3350
|
+
return false;
|
|
3351
|
+
};
|
|
3279
3352
|
for (;;) {
|
|
3280
3353
|
if (round++ > 20) break;
|
|
3281
3354
|
const reply = await chatOnce(ctx, config, system, history);
|
|
@@ -3303,7 +3376,8 @@ async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
|
|
|
3303
3376
|
const mentionsTarget = /(编年录|道藏|暗线|总纲|卷首语|章节|正文|规则|红线|伏笔|简介|大纲|事实|设定|世界|角色|人物|第\s*\d+\s*章)/.test(reply);
|
|
3304
3377
|
const strayTag = /<[a-z_-]*action[^>]*>/.test(reply);
|
|
3305
3378
|
if ((intendsAction || mentionsTarget || strayTag) && nudged < MAX_NUDGES) {
|
|
3306
|
-
const
|
|
3379
|
+
const userWriteIntent = Object.values(WRITE_TOOL_KEYS).some((re) => re.test(userMessage));
|
|
3380
|
+
const nudge = nudged === 0 ? userWriteIntent ? "你的上一条回复表达了想操作项目的意图(或动作标签格式有误),因此没有执行任何操作。请直接输出动作标签来执行,格式必须为 <dsh-action name=\"工具名\">{\"参数\":值}</dsh-action>(注意拼写是 dsh-action,不是 dash-action;标签成对出现,参数为合法 JSON)。如果需要先看内容,先输出 outline_text 或 chapter_text 标签。" : "你刚才的回复看起来在讨论项目内容,但没有必要执行任何操作。如果用户只是在提问或闲聊,请直接以文字回答即可,不要输出动作标签,也不要自行调用任何写操作(生成/修订/删除等只有用户明确要求时才允许)。若确实需要先查看数据,最多使用只读工具(outline_text / chapter_text / book_overview / facts_query)。" : userWriteIntent ? `你第 ${nudged + 1} 次表达了操作意图但没有输出动作标签,因此仍未执行任何操作。铁律:你的【整个回复】现在必须只包含一个 <dsh-action> 标签(例如 <dsh-action name="chapter_text">{"no":1}</dsh-action>),禁止任何解释、铺垫或"我这就去"之类的文字。若你其实不打算执行任何操作,请明确回复「不执行」。` : `你第 ${nudged + 1} 次回复仍不需要执行操作。再次强调:用户没有要求修改,请直接给出文字回答(可以简短引用项目数据),不要输出动作标签。写操作只会在用户明确要求时被允许。`;
|
|
3307
3381
|
nudged++;
|
|
3308
3382
|
history.push({
|
|
3309
3383
|
role: "tool",
|
|
@@ -3334,6 +3408,39 @@ async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
|
|
|
3334
3408
|
}
|
|
3335
3409
|
const { name, args, index } = action;
|
|
3336
3410
|
const prose = reply.slice(0, index).trim();
|
|
3411
|
+
if (!guardWrite(name, userMessage)) {
|
|
3412
|
+
const denied = `【操作被拒绝】${name} 是写操作(会修改正文/项目数据),但你当前的消息里没有明确要求执行该修改。如果需要,请明确说明(如「生成第 120 章」「把第 105 章结尾改一下」)。我不会擅自修改你的作品。`;
|
|
3413
|
+
history.push({
|
|
3414
|
+
role: "tool",
|
|
3415
|
+
content: denied,
|
|
3416
|
+
tool: name,
|
|
3417
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
3418
|
+
});
|
|
3419
|
+
appendHistory(outputDir, {
|
|
3420
|
+
role: "tool",
|
|
3421
|
+
content: denied,
|
|
3422
|
+
tool: name,
|
|
3423
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
3424
|
+
});
|
|
3425
|
+
yield {
|
|
3426
|
+
frame: "tool",
|
|
3427
|
+
name,
|
|
3428
|
+
status: "error",
|
|
3429
|
+
detail: denied
|
|
3430
|
+
};
|
|
3431
|
+
const assistantEntry = {
|
|
3432
|
+
role: "assistant",
|
|
3433
|
+
content: denied,
|
|
3434
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
3435
|
+
};
|
|
3436
|
+
history.push(assistantEntry);
|
|
3437
|
+
appendHistory(outputDir, assistantEntry);
|
|
3438
|
+
yield {
|
|
3439
|
+
frame: "delta",
|
|
3440
|
+
text: denied
|
|
3441
|
+
};
|
|
3442
|
+
return;
|
|
3443
|
+
}
|
|
3337
3444
|
yield {
|
|
3338
3445
|
frame: "tool",
|
|
3339
3446
|
name,
|
|
@@ -3795,6 +3902,7 @@ function makeRoutes(deps) {
|
|
|
3795
3902
|
try {
|
|
3796
3903
|
const next = project ?? createProject(outline);
|
|
3797
3904
|
const chapters = await planChapters(ctx, config, next, count, body?.volume, config.outputDir);
|
|
3905
|
+
mergeVolatileFromDisk(config.outputDir, next);
|
|
3798
3906
|
next.chapters.push(...chapters);
|
|
3799
3907
|
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3800
3908
|
saveProject(config.outputDir, next);
|
|
@@ -3840,6 +3948,7 @@ function makeRoutes(deps) {
|
|
|
3840
3948
|
chapter.status = "generating";
|
|
3841
3949
|
chapter.error = void 0;
|
|
3842
3950
|
chapter.generatingAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3951
|
+
mergeVolatileFromDisk(config.outputDir, project);
|
|
3843
3952
|
saveProject(config.outputDir, project);
|
|
3844
3953
|
const send = (frame) => {
|
|
3845
3954
|
res.write(JSON.stringify(frame) + "\n");
|
|
@@ -3873,6 +3982,7 @@ function makeRoutes(deps) {
|
|
|
3873
3982
|
});
|
|
3874
3983
|
else {
|
|
3875
3984
|
chapter.status = "approved";
|
|
3985
|
+
mergeVolatileFromDisk(config.outputDir, project);
|
|
3876
3986
|
saveProject(config.outputDir, project);
|
|
3877
3987
|
}
|
|
3878
3988
|
if (config.autoAuthorReview ?? true) try {
|
|
@@ -3886,6 +3996,7 @@ function makeRoutes(deps) {
|
|
|
3886
3996
|
const review = await authorReviewChapter(ctx, config, project, no, currentBody, prevTail);
|
|
3887
3997
|
chapter.authorReview = review;
|
|
3888
3998
|
if (review.advancedLines !== void 0) autoLinkPlotlines(project, no, review.advancedLines);
|
|
3999
|
+
mergeVolatileFromDisk(config.outputDir, project);
|
|
3889
4000
|
saveProject(config.outputDir, project);
|
|
3890
4001
|
send({
|
|
3891
4002
|
type: "author-review",
|
|
@@ -3900,6 +4011,7 @@ function makeRoutes(deps) {
|
|
|
3900
4011
|
} catch (error) {
|
|
3901
4012
|
chapter.status = "error";
|
|
3902
4013
|
chapter.error = error.message;
|
|
4014
|
+
mergeVolatileFromDisk(config.outputDir, project);
|
|
3903
4015
|
saveProject(config.outputDir, project);
|
|
3904
4016
|
if (!res.writableEnded) {
|
|
3905
4017
|
send({
|
|
@@ -5010,6 +5122,34 @@ function makeRoutes(deps) {
|
|
|
5010
5122
|
});
|
|
5011
5123
|
}
|
|
5012
5124
|
};
|
|
5125
|
+
/** 章节直接通过:作者行使最终决定权(不重审,保留审稿记录)。 */
|
|
5126
|
+
const chapterApproveRoute = {
|
|
5127
|
+
kind: "exact",
|
|
5128
|
+
path: NOVEL_API.chapterApprove,
|
|
5129
|
+
handler: async (req, res) => {
|
|
5130
|
+
if (!guard(req, res, "POST")) return;
|
|
5131
|
+
const config = getConfig();
|
|
5132
|
+
const project = requireProject(res);
|
|
5133
|
+
if (project === void 0) return;
|
|
5134
|
+
const no = (await readJsonBody(req))?.chapterNo;
|
|
5135
|
+
if (!Number.isInteger(no) || no === void 0 || no < 1) {
|
|
5136
|
+
writeJson(res, 400, { error: "chapterNo 须为正整数" });
|
|
5137
|
+
return;
|
|
5138
|
+
}
|
|
5139
|
+
const chapter = project.chapters.find((c) => c.no === no);
|
|
5140
|
+
if (chapter === void 0) {
|
|
5141
|
+
writeJson(res, 404, { error: `章节 ${no} 不在计划中` });
|
|
5142
|
+
return;
|
|
5143
|
+
}
|
|
5144
|
+
chapter.status = "approved";
|
|
5145
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5146
|
+
saveProject(config.outputDir, project);
|
|
5147
|
+
writeJson(res, 200, {
|
|
5148
|
+
ok: true,
|
|
5149
|
+
no
|
|
5150
|
+
});
|
|
5151
|
+
}
|
|
5152
|
+
};
|
|
5013
5153
|
return [
|
|
5014
5154
|
statusRoute,
|
|
5015
5155
|
loadOutlineRoute,
|
|
@@ -5133,6 +5273,7 @@ function makeRoutes(deps) {
|
|
|
5133
5273
|
}
|
|
5134
5274
|
},
|
|
5135
5275
|
chapterResetRoute,
|
|
5276
|
+
chapterApproveRoute,
|
|
5136
5277
|
{
|
|
5137
5278
|
kind: "exact",
|
|
5138
5279
|
path: NOVEL_API.config,
|