dsh-novel-generator 0.1.10 → 0.1.11

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 (3) hide show
  1. package/README.md +1 -1
  2. package/index.js +110 -35
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -36,7 +36,7 @@ dsh plugin --profile web add dsh-novel-generator
36
36
 
37
37
  - **重置**:时间线右上角的「重置快照」会清空全部快照,再按当前间隔从第 1 格逐格重建到故事末端(确认框里写明要生成几份)。
38
38
  - **物品 / 事件**:快照里除了世界观、角色、势力、关系,还有物品(名称 + 下落/用途)和事件(故事天数 + 标题 + 一句话),事件按时间从新到旧排列。
39
- - **章节正文的依据**:生成正文时会带上「当前快照 + 该快照之后到本章之前的各章梗概」,不再只看上一章。
39
+ - **生成依据(统一规则)**:先找本章之前最近的那份世界快照。若「本章起始日 − 快照落点 > 当前间隔」,判定快照过旧,直接拒绝并提示先去时间长河更新快照;否则自动推演用「最近快照 + 快照落点之后、本章之前各章梗概」,写正文在此基础上再加一段「待生成的本章梗概」,并在提示里区分已发生与待生成两部分。
40
40
 
41
41
  ## 数据与接口
42
42
 
package/index.js CHANGED
@@ -939,66 +939,105 @@ function snapshotAt(project, day) {
939
939
  return snapshots[snapshots.length - 1];
940
940
  }
941
941
 
942
+ /**
943
+ * How far the nearest snapshot sits behind a chapter's start.
944
+ *
945
+ * The rule both prompts share: the nearest snapshot node at or before the
946
+ * chapter wins. If the chapter starts more than one interval after that node,
947
+ * the snapshot is too stale to write from and the Client is told to update it
948
+ * first — the same rule for 自动推演 and for writing the chapter body.
949
+ */
950
+ function snapshotDistance(project, chapter) {
951
+ const span = chapterWindow(project, chapter);
952
+ const unit = Math.max(1, project.timeline.unitDays || 30);
953
+ const snapshot = snapshotAt(project, span.start) || null;
954
+ const base = snapshot ? snapshot.day : 0;
955
+ const gap = Math.max(0, span.start - base);
956
+ return { snapshot, unit, base, gap, tooFar: gap > unit, startDay: span.start };
957
+ }
958
+
959
+ /** Chapters from the snapshot node up to (not including) the target chapter. */
960
+ function stretchBefore(project, chapter, snapshot) {
961
+ const base = snapshot ? snapshot.day : 0;
962
+ return (project.chapters || [])
963
+ .filter((entry) => entry.chapterId !== chapter.chapterId
964
+ && entry.startDay < chapter.startDay
965
+ && chapterWindow(project, entry).end > base
966
+ && ((entry.outline && entry.outline.synopsis) || '').trim() !== '')
967
+ .sort((a, b) => a.startDay - b.startDay);
968
+ }
969
+
970
+ /**
971
+ * Refuse to write from a snapshot that has drifted more than one interval
972
+ * behind the chapter, and say what the user has to do about it.
973
+ */
974
+ function assertSnapshotFresh(project, chapter) {
975
+ const distance = snapshotDistance(project, chapter);
976
+ if (!distance.tooFar) return distance;
977
+ const from = distance.base > 0 ? `第 ${distance.base} 天` : '故事开篇(还没有任何快照)';
978
+ throw httpError(409, `最近的世界快照停在${from},本章从第 ${distance.startDay} 天开始,间隔 ${distance.gap} 天已超过当前快照间隔(${distance.unit} 天);请先在时间长河点「更新世界快照」再来生成`);
979
+ }
980
+
981
+ function stretchLines(project, chapter, snapshot) {
982
+ const lines = [];
983
+ const stretch = stretchBefore(project, chapter, snapshot);
984
+ if (stretch.length === 0) return lines;
985
+ lines.push(snapshot
986
+ ? `【已发生的章节梗概 · 第 ${snapshot.day} 天之后到上一章】`
987
+ : '【已发生的章节梗概 · 开篇到上一章】');
988
+ for (const entry of stretch) {
989
+ lines.push(`- 第 ${entry.index + 1} 章 ${entry.title || ''}(第 ${entry.startDay}→${entry.startDay + entry.durationDays} 天):${entry.outline.synopsis}`);
990
+ }
991
+ lines.push('');
992
+ return lines;
993
+ }
994
+
942
995
  function chapterPrompt(project, chapter, previous) {
943
996
  const window = chapterWindow(project, chapter);
944
997
  const outline = chapter.outline || {};
945
- const snapshot = snapshotAt(project, window.start);
946
- // Everything written since that snapshot: the chapter is the continuation of
947
- // this stretch, not just of the chapter right before it.
948
- const recent = (project.chapters || [])
949
- .filter((entry) => entry.chapterId !== chapter.chapterId
950
- && entry.startDay < window.start
951
- && (!snapshot || chapterWindow(project, entry).end > snapshot.day)
952
- && (((entry.outline && entry.outline.synopsis) || '').trim() !== ''))
953
- .sort((a, b) => a.startDay - b.startDay);
998
+ const { snapshot } = snapshotDistance(project, chapter);
954
999
  const lines = [];
955
1000
  lines.push('你现在是这部小说的执笔作者。只输出本章正文,不要解释、不要标题、不要分点提纲。');
956
1001
  lines.push('');
957
- lines.push(`【章节】第 ${chapter.index + 1} 章 ${chapter.title || ''}(${dateForDay(project, window.start)} 起,跨度 ${chapter.durationDays} 天)`);
1002
+ lines.push(`【本章】第 ${chapter.index + 1} 章 ${chapter.title || ''}(第 ${window.start}→${window.end} 天,跨度 ${chapter.durationDays} 天)`);
958
1003
  lines.push('');
959
- lines.push('【当前世界快照】');
1004
+ lines.push('【最近的世界快照】');
960
1005
  lines.push(snapshotBlock(snapshot));
961
1006
  lines.push('');
962
- if (recent.length) {
963
- lines.push(snapshot ? `【第 ${snapshot.day} 天之后各章梗概】` : '【此前各章梗概】');
964
- for (const entry of recent) {
965
- lines.push(`- 第 ${entry.index + 1} 章 ${entry.title || ''}:${entry.outline.synopsis}`);
966
- }
967
- lines.push('');
1007
+ const stretch = stretchLines(project, chapter, snapshot);
1008
+ if (stretch.length) {
1009
+ lines.push(...stretch);
968
1010
  } else if (previous) {
969
1011
  lines.push('【上一章梗概】');
970
1012
  lines.push(`${previous.title || ''}:${(previous.outline && previous.outline.synopsis) || '(无)'}`);
971
1013
  lines.push('');
972
1014
  }
973
- lines.push('【本章梗概】');
1015
+ lines.push('【本章梗概 · 待生成的部分】');
974
1016
  lines.push(outline.synopsis || '(无)');
975
1017
  lines.push('');
976
1018
  lines.push('【要求】');
1019
+ lines.push('- 以上"已发生的章节梗概"是既定事实,不得改写;本章正文要接着它往下写。');
1020
+ lines.push('- 按"本章梗概"把这一章的内容写出来,情节推进、场景与对话要完整。');
977
1021
  lines.push('- 不要调用任何工具,不要反问,不要输出计划或步骤说明,直接给出最终正文。');
978
1022
  lines.push(`- 字数控制在 ${outline.minWords || 800} 到 ${outline.maxWords || 3000} 字之间。`);
979
1023
  lines.push('- 与世界观、角色、势力、物品、事件和人物关系保持一致,不要引入与快照冲突的设定。');
980
- lines.push('- 有完整的情节推进、场景描写和对话,直接成文。');
981
1024
  return lines.join('\n');
982
1025
  }
983
1026
 
984
1027
  function deducePrompt(project, chapter) {
985
- const day = chapter ? chapterWindow(project, chapter).start : (Number(project.timeline.cursor) || 0);
986
- const snapshot = snapshotAt(project, day);
987
- const chapters = project.chapters || [];
988
- const previous = chapters.length ? chapters[chapters.length - 1] : null;
1028
+ const target = chapter
1029
+ ? chapter
1030
+ : { chapterId: '', startDay: Number(project.timeline.cursor) || 0, durationDays: 0, index: (project.chapters || []).length };
1031
+ const { snapshot } = snapshotDistance(project, target);
989
1032
  const lines = [];
990
- lines.push('你是这部小说的剧情推演器。请依据最新世界快照和最近一章,推演下一章的大纲。');
1033
+ lines.push('你是这部小说的剧情推演器。请依据最近的世界快照,以及从该快照之后已经写过的各章梗概,推演下一章的大纲。');
991
1034
  lines.push('只输出一个 JSON 对象,不要任何额外文字。');
992
1035
  lines.push('');
993
- lines.push('【最新世界快照】');
1036
+ lines.push('【最近的世界快照】');
994
1037
  lines.push(snapshotBlock(snapshot));
995
1038
  lines.push('');
996
- if (previous) {
997
- lines.push('【最近一章】');
998
- lines.push(`${previous.title || ''}(第 ${previous.startDay} 天起,跨度 ${previous.durationDays} 天)`);
999
- lines.push(`梗概:${(previous.outline && previous.outline.synopsis) || '(无)'}`);
1000
- lines.push('');
1001
- }
1039
+ const stretch = stretchLines(project, target, snapshot);
1040
+ if (stretch.length) lines.push(...stretch);
1002
1041
  lines.push('【输出 JSON 结构】');
1003
1042
  lines.push('{"synopsis": "本章梗概,300 字以内,包含起因、经过、转折", "durationDays": 30, "minWords": 1200, "maxWords": 3500, "title": "本章标题"}');
1004
1043
  lines.push('');
@@ -1148,7 +1187,7 @@ async function bootstrap(ctx, root) {
1148
1187
  }
1149
1188
  }
1150
1189
  return {
1151
- revision: '2026-10-20.28',
1190
+ revision: '2026-10-20.32',
1152
1191
  projects,
1153
1192
  models: {
1154
1193
  default: modelCatalog.default || null,
@@ -1606,14 +1645,22 @@ function startSnapshotReset(ctx, root, registry, body) {
1606
1645
  });
1607
1646
  }
1608
1647
 
1609
- function startGenerate(ctx, root, registry, body) {
1648
+ async function startGenerate(ctx, root, registry, body) {
1610
1649
  const projectId = asText(body.projectId);
1611
1650
  const chapterId = asText(body.chapterId);
1651
+ // Refuse before the job is queued, so the Client sees a plain 409 instead of
1652
+ // a job that fails a second later.
1653
+ const preview = await readProject(root, projectId);
1654
+ const claimed = preview.chapters.find((entry) => entry.chapterId === chapterId);
1655
+ if (!claimed) throw httpError(404, `章节不存在: ${chapterId}`);
1656
+ if (!claimed.outline) throw httpError(409, '请先在章节大纲里保存设定,再生成正文');
1657
+ assertSnapshotFresh(preview, claimed);
1612
1658
  return startJob(registry, { kind: 'generate', projectId, chapterId }, async (job) => {
1613
1659
  const project = await readProject(root, projectId);
1614
1660
  const chapter = project.chapters.find((entry) => entry.chapterId === chapterId);
1615
1661
  if (!chapter) throw httpError(404, `章节不存在: ${chapterId}`);
1616
1662
  if (!chapter.outline) throw httpError(409, '请先在章节大纲里保存设定,再生成正文');
1663
+ assertSnapshotFresh(project, chapter);
1617
1664
  const previous = project.chapters
1618
1665
  .filter((entry) => entry.chapterId !== chapter.chapterId && entry.startDay <= chapter.startDay)
1619
1666
  .sort((a, b) => b.startDay - a.startDay)[0] || null;
@@ -1646,8 +1693,25 @@ function startGenerate(ctx, root, registry, body) {
1646
1693
  * chapter in place (the outline page's 自动推演, which overwrites everything);
1647
1694
  * without it the job appends a new one (the new-chapter dialog).
1648
1695
  */
1649
- function startCompose(ctx, root, registry, body) {
1696
+ async function startCompose(ctx, root, registry, body) {
1650
1697
  const projectId = asText(body.projectId);
1698
+ // Same early refusal as writing: a stale snapshot stops the job from being
1699
+ // queued at all.
1700
+ const preview = await readProject(root, projectId);
1701
+ const claimedId = asText(body.chapterId);
1702
+ const claimed = claimedId === ''
1703
+ ? null
1704
+ : (preview.chapters || []).find((entry) => entry.chapterId === claimedId) || null;
1705
+ if (claimedId !== '' && !claimed) throw httpError(404, `章节不存在: ${claimedId}`);
1706
+ const claimedStart = claimed
1707
+ ? claimed.startDay
1708
+ : (Number.isFinite(Number(body.startDay)) ? Math.max(0, Math.round(Number(body.startDay))) : lastChapterEnd(preview));
1709
+ assertSnapshotFresh(preview, claimed || {
1710
+ chapterId: '',
1711
+ startDay: claimedStart,
1712
+ durationDays: Math.round(Number(body.durationDays) || 0) || 1,
1713
+ index: (preview.chapters || []).length,
1714
+ });
1651
1715
  return startJob(registry, { kind: 'compose', projectId }, async (job) => {
1652
1716
  const minWords = Math.round(Number(body.minWords) || 0);
1653
1717
  const maxWords = Math.round(Number(body.maxWords) || 0);
@@ -1686,6 +1750,7 @@ function startCompose(ctx, root, registry, body) {
1686
1750
  outline: null,
1687
1751
  };
1688
1752
  const previous = previousChapter(project, draft);
1753
+ assertSnapshotFresh(project, draft);
1689
1754
 
1690
1755
  job.message = '正在推演本章梗概与名称';
1691
1756
  const deduceReply = await askAgent(ctx, project, deducePrompt(project, draft));
@@ -1763,9 +1828,19 @@ function startCompose(ctx, root, registry, body) {
1763
1828
  });
1764
1829
  }
1765
1830
 
1766
- function startDeduce(ctx, root, registry, body) {
1831
+ async function startDeduce(ctx, root, registry, body) {
1767
1832
  const projectId = asText(body.projectId);
1768
1833
  const chapterId = asText(body.chapterId);
1834
+ // The outline page's 自动推演 follows the same freshness rule as writing, and
1835
+ // refuses before queuing as well.
1836
+ const preview = await readProject(root, projectId);
1837
+ const claimed = chapterId === '' ? null : preview.chapters.find((entry) => entry.chapterId === chapterId) || null;
1838
+ assertSnapshotFresh(preview, claimed || {
1839
+ chapterId: '',
1840
+ startDay: Number(preview.timeline.cursor) || lastChapterEnd(preview),
1841
+ durationDays: 0,
1842
+ index: (preview.chapters || []).length,
1843
+ });
1769
1844
  return startJob(registry, { kind: 'deduce', projectId, chapterId }, async (job) => {
1770
1845
  const project = await readProject(root, projectId);
1771
1846
  const chapter = chapterId === '' ? null : project.chapters.find((entry) => entry.chapterId === chapterId) || null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-novel-generator",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "type": "module",
5
5
  "description": "Novel studio for DeepSeek Harness: a draggable story timeline, world snapshots, chapter outlines, and chapters written by the work's own agent.",
6
6
  "license": "MIT",