@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/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 relatedFacts = allFacts.slice(-120).filter((f) => {
2136
- const head = f.text.slice(0, 24);
2137
- for (let i = 0; i + 3 <= head.length; i++) {
2138
- const tri = head.slice(i, i + 3);
2139
- if (tri.trim() !== "" && beatsText.includes(tri)) return true;
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 false;
2142
- }).slice(-15).map((f) => `[第${f.chapterNo}章] ${f.text}`);
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 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>),禁止任何解释、铺垫或"我这就去"之类的文字。若你其实不打算执行任何操作,请明确回复「不执行」。`;
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,