dsh-novel-generator 0.1.8 → 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 (4) hide show
  1. package/README.md +1 -1
  2. package/client.js +59 -20
  3. package/index.js +151 -50
  4. 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/client.js CHANGED
@@ -197,6 +197,11 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
197
197
 
198
198
  /* workspace track */
199
199
  .dsn-stage{flex:1;position:relative;overflow:hidden;min-width:0}
200
+ .dsn-lock{position:absolute;inset:0;z-index:40;display:flex;align-items:center;justify-content:center;background:color-mix(in srgb, var(--dsw-alias-bg-overlay) 88%, transparent);backdrop-filter:blur(2px);cursor:progress}
201
+ .dsn-lock-box{max-width:420px;padding:20px 24px;border-radius:14px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-3);box-shadow:0 18px 48px rgba(0,0,0,.28);text-align:center}
202
+ .dsn-lock-title{font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary)}
203
+ .dsn-lock-text{margin-top:8px;font-size:13px;line-height:1.6;color:var(--dsw-alias-label-secondary);word-break:break-word}
204
+ .dsn-lock-hint{margin-top:10px;font-size:12px;color:var(--dsw-alias-label-tertiary)}
200
205
  .dsn-track{display:flex;height:100%;transition:transform .28s ease}
201
206
  .dsn-page{width:100%;height:100%;flex:0 0 100%;display:flex;flex-direction:column;min-width:0}
202
207
  .dsn-pagehead{display:flex;align-items:center;gap:10px;padding:10px 16px;border-bottom:1px solid var(--dsw-alias-border-l1)}
@@ -1465,7 +1470,10 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1465
1470
  function ProjectWorkspace({ detail, onReload, notify, onRegisterLeaveGuard }) {
1466
1471
  const [view, setView] = useState('world');
1467
1472
  const [chapterId, setChapterId] = useState(null);
1468
- const [snapshotBusy, setSnapshotBusy] = useState(false);
1473
+ // A running snapshot job: { label, message }. Non-null locks the studio,
1474
+ // because the job keeps rewriting this work's snapshots.
1475
+ const [snapshotJob, setSnapshotJob] = useState(null);
1476
+ const snapshotBusy = snapshotJob !== null;
1469
1477
  const [dialogOpen, setDialogOpen] = useState(false);
1470
1478
  const [text, setText] = useState('');
1471
1479
  const [dirty, setDirty] = useState(false);
@@ -1583,36 +1591,57 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1583
1591
  }
1584
1592
  }, [detail.projectId, onReload, notify]);
1585
1593
 
1586
- /** Poll one snapshot job until it settles, then refresh the work. */
1587
- const waitSnapshotJob = useCallback((jobId, doneText) => {
1594
+ /**
1595
+ * Poll one snapshot job until it settles. While it runs the studio stays
1596
+ * locked and the work is reloaded on every tick, so newly written
1597
+ * snapshots appear on the timeline as they land.
1598
+ */
1599
+ const waitSnapshotJob = useCallback((jobId, label, doneText) => {
1600
+ setSnapshotJob({ label, message: label });
1601
+ let failures = 0;
1602
+ const startedAt = Date.now();
1588
1603
  const timer = setInterval(async () => {
1589
1604
  try {
1590
1605
  const job = await api(`/job?id=${encodeURIComponent(jobId)}`);
1591
- if (job.state === 'running') return;
1592
- clearInterval(timer);
1593
- setSnapshotBusy(false);
1594
- if (job.state === 'done') {
1595
- notify(doneText, 'success');
1606
+ failures = 0;
1607
+ if (job.state === 'running') {
1608
+ if (Date.now() - startedAt > 45 * 60 * 1000) {
1609
+ clearInterval(timer);
1610
+ setSnapshotJob(null);
1611
+ notify(t('快照任务超时,已解除锁定', 'The snapshot job timed out; the studio is unlocked.'), 'error');
1612
+ return;
1613
+ }
1614
+ setSnapshotJob({ label, message: job.message || label });
1596
1615
  await onReload();
1597
- } else {
1598
- notify(job.error || t('世界快照更新失败', "World snapshot update failed"), 'error');
1616
+ return;
1599
1617
  }
1600
- } catch (error) {
1601
1618
  clearInterval(timer);
1602
- setSnapshotBusy(false);
1603
- notify(messageOf(error), 'error');
1619
+ setSnapshotJob(null);
1620
+ await onReload();
1621
+ if (job.state === 'done') notify(doneText, 'success');
1622
+ else notify(job.error || t('世界快照更新失败', "World snapshot update failed"), 'error');
1623
+ } catch (error) {
1624
+ failures += 1;
1625
+ // Three failed polls in a row mean the studio cannot be held.
1626
+ if (failures >= 3) {
1627
+ clearInterval(timer);
1628
+ setSnapshotJob(null);
1629
+ notify(messageOf(error), 'error');
1630
+ }
1604
1631
  }
1605
1632
  }, 2500);
1606
1633
  }, [onReload, notify]);
1607
1634
 
1608
1635
  const refreshSnapshot = useCallback(async (day) => {
1609
- setSnapshotBusy(true);
1610
1636
  try {
1611
1637
  const started = await api('/snapshot/refresh', { projectId: detail.projectId, day });
1612
1638
  notify(t('正在结算世界快照…', "Settling the world snapshot…"), 'success');
1613
- waitSnapshotJob(started.jobId, t('世界快照已更新,时间线向前延伸', "World snapshot updated; the timeline moved forward."));
1639
+ waitSnapshotJob(
1640
+ started.jobId,
1641
+ t('正在生成第 {day} 天的世界快照…', 'Generating the world snapshot for day {day}…', { day }),
1642
+ t('世界快照已更新,时间线向前延伸', "World snapshot updated; the timeline moved forward."),
1643
+ );
1614
1644
  } catch (error) {
1615
- setSnapshotBusy(false);
1616
1645
  notify(messageOf(error), 'error');
1617
1646
  }
1618
1647
  }, [detail.projectId, notify, waitSnapshotJob]);
@@ -1627,13 +1656,14 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1627
1656
  text: t('将清除所有世界快照,并按当前间隔(每 {unit} 天)从第 {unit} 天起逐格重建到第 {end} 天,共 {count} 份。每份都要调用一次模型,耗时较长。', 'Every world snapshot is deleted and rebuilt point by point — {count} of them, from day {unit} to day {end} at the current {unit}-day interval. Each one calls the model, so this takes a while.', { unit, end, count: Math.max(0, Math.floor(end / unit)) }),
1628
1657
  confirmLabel: t('清除并重建', 'Clear and rebuild'),
1629
1658
  action: async () => {
1630
- setSnapshotBusy(true);
1631
1659
  try {
1632
1660
  const started = await api('/snapshot/reset', { projectId: detail.projectId });
1633
- notify(t('正在重建世界快照…', 'Rebuilding world snapshots…'), 'success');
1634
- waitSnapshotJob(started.jobId, t('世界快照已全部重建', 'All world snapshots rebuilt'));
1661
+ waitSnapshotJob(
1662
+ started.jobId,
1663
+ t('正在重建世界快照…', 'Rebuilding world snapshots…'),
1664
+ t('世界快照已全部重建', 'All world snapshots rebuilt'),
1665
+ );
1635
1666
  } catch (error) {
1636
- setSnapshotBusy(false);
1637
1667
  notify(messageOf(error), 'error');
1638
1668
  }
1639
1669
  },
@@ -1643,6 +1673,15 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1643
1673
  const trackIndex = view === 'world' ? 0 : view === 'story' ? 1 : 2;
1644
1674
 
1645
1675
  return h('div', { className: 'dsn-stage' },
1676
+ // A running snapshot job owns the work: nothing else may be edited
1677
+ // while it rewrites snapshots underneath.
1678
+ snapshotBusy
1679
+ ? h('div', { className: 'dsn-lock' },
1680
+ h('div', { className: 'dsn-lock-box' },
1681
+ h('div', { className: 'dsn-lock-title' }, snapshotJob.label),
1682
+ h('div', { className: 'dsn-lock-text' }, snapshotJob.message),
1683
+ h('div', { className: 'dsn-lock-hint' }, t('快照生成期间界面不可操作,完成后会自动刷新', 'The studio is locked while snapshots are generated; it refreshes when done.'))))
1684
+ : null,
1646
1685
  h('div', { className: 'dsn-track', style: { transform: `translateX(-${trackIndex * 100}%)` } },
1647
1686
  h('section', { className: 'dsn-page' },
1648
1687
  h(WorldPage, {
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('');
@@ -1040,8 +1079,10 @@ function snapshotPrompt(project, previous, chapters, pointDay) {
1040
1079
  lines.push('');
1041
1080
  lines.push('要求:');
1042
1081
  lines.push('- 保留上一版中尚未被推翻的设定;角色、势力要包含新登场者;关系用「甲 乙 关系」三段式。');
1043
- lines.push('- items 记录这段时间出现或变化的关键物品(下落、归属、状态);没有就给空数组。');
1044
- lines.push(`- events 记录这段时间发生的具体事件,day 用故事天数(整数,不超过 ${pointDay}),title 一行、desc 一两句,最近的排最前;没有就给空数组。`);
1082
+ lines.push('- items 必填:列出这段时间出现、易手或状态变化的关键物品(法宝、信物、丹药、线索等),每项 name + desc(下落/归属/用途)。');
1083
+ lines.push(`- events 必填:这段时间每发生一件值得记录的事就写一条,day 为故事天数(整数,0~${pointDay}),title 一行,desc 一两句,按 day 从大到小排列。只要这段时间有章节内容,就至少写 1 条。`);
1084
+ lines.push('- 只有这段时间确实没有任何可记录的物品或事件时,才给空数组。');
1085
+ lines.push('注意:输出的 JSON 必须同时包含 items 与 events 两个键,缺任何一个都算不合格(没有内容就写 [])。');
1045
1086
  lines.push('不要调用任何工具,不要反问,只输出这个 JSON。');
1046
1087
  return lines.join('\n');
1047
1088
  }
@@ -1146,7 +1187,7 @@ async function bootstrap(ctx, root) {
1146
1187
  }
1147
1188
  }
1148
1189
  return {
1149
- revision: '2026-10-20.20',
1190
+ revision: '2026-10-20.32',
1150
1191
  projects,
1151
1192
  models: {
1152
1193
  default: modelCatalog.default || null,
@@ -1505,22 +1546,25 @@ function chaptersForPoint(project, fromDay, point) {
1505
1546
  */
1506
1547
  function registerSnapshot(ctx, root, registry, projectId, targetDay) {
1507
1548
  return startJob(registry, { kind: 'snapshot', projectId }, async (job) => {
1549
+ // The sequence is fixed from the state at the start of the job: recomputing
1550
+ // it every round would regenerate the last point forever once it exists.
1551
+ const initial = await readProject(root, projectId);
1552
+ const unit = Math.max(1, initial.timeline.unitDays || 30);
1553
+ const target = Math.floor(Math.max(1, Math.round(Number(targetDay) || 0)) / unit) * unit;
1554
+ if (target < unit) throw httpError(409, '目标时间还不到一个快照间隔,无法生成世界快照');
1555
+ const startBase = (initial.snapshots || []).filter((snapshot) => snapshot.day < target).pop() || null;
1556
+ let first = (startBase ? startBase.day : 0) + unit;
1557
+ // Nothing new below the target means "redo the target point itself".
1558
+ if (first > target) first = target;
1559
+ const points = [];
1560
+ for (let point = first; point <= target; point += unit) points.push(point);
1561
+
1508
1562
  const made = [];
1509
- let target = Math.max(1, Math.round(Number(targetDay) || 0));
1510
- for (;;) {
1563
+ for (const point of points) {
1511
1564
  const project = await readProject(root, projectId);
1512
- const unit = Math.max(1, project.timeline.unitDays || 30);
1513
- target = Math.floor(target / unit) * unit;
1514
- if (target < unit) throw httpError(409, '目标时间还不到一个快照间隔,无法生成世界快照');
1515
- const snapshots = project.snapshots || [];
1516
- const base = snapshots.filter((snapshot) => snapshot.day < target).pop() || null;
1517
- const point = (base ? base.day : 0) + unit;
1518
- if (point > target) {
1519
- await exportProjectInfo(await readProject(root, projectId));
1520
- return { made, target };
1521
- }
1565
+ const base = (project.snapshots || []).filter((snapshot) => snapshot.day < point).pop() || null;
1522
1566
  const chapters = chaptersForPoint(project, base ? base.day : 0, point);
1523
- job.message = `正在生成第 ${point} 天的世界快照(第 ${made.length + 1} 份,目标第 ${target} 天)`;
1567
+ job.message = `正在生成第 ${point} 天的世界快照(第 ${made.length + 1}/${points.length} 份,目标第 ${target} 天)`;
1524
1568
  const reply = await askAgent(ctx, project, snapshotPrompt(project, base, chapters, point));
1525
1569
  const parsed = parseJsonReply(reply);
1526
1570
  if (!parsed) throw httpError(502, '模型没有返回可解析的快照 JSON');
@@ -1528,6 +1572,25 @@ function registerSnapshot(ctx, root, registry, projectId, targetDay) {
1528
1572
  if (normalized.world === '' && normalized.characters.length === 0 && normalized.factions.length === 0) {
1529
1573
  throw httpError(502, '模型返回的快照为空');
1530
1574
  }
1575
+ // A chapter boundary is a fact of the work itself, so it seeds the event
1576
+ // list even when the model leaves `events` empty; model-written events win
1577
+ // by title and anything else is added.
1578
+ const seeded = chapters.map((chapter) => {
1579
+ const span = chapterWindow(project, chapter);
1580
+ return {
1581
+ day: span.end,
1582
+ title: chapter.title || `第 ${chapter.index + 1} 章`,
1583
+ desc: ((chapter.outline && chapter.outline.synopsis) || '').slice(0, 120),
1584
+ };
1585
+ });
1586
+ const known = new Set(normalized.events.map((event) => event.title));
1587
+ for (const event of seeded) {
1588
+ if (!known.has(event.title)) {
1589
+ normalized.events.push(event);
1590
+ known.add(event.title);
1591
+ }
1592
+ }
1593
+ normalized.events.sort((a, b) => (b.day === null ? -1 : b.day) - (a.day === null ? -1 : a.day));
1531
1594
  const snapshot = {
1532
1595
  snapshotId: randomUUID(),
1533
1596
  day: point,
@@ -1545,6 +1608,8 @@ function registerSnapshot(ctx, root, registry, projectId, targetDay) {
1545
1608
  await exportSnapshot(project, snapshot);
1546
1609
  made.push(point);
1547
1610
  }
1611
+ await exportProjectInfo(await readProject(root, projectId));
1612
+ return { made, target };
1548
1613
  });
1549
1614
  }
1550
1615
 
@@ -1580,14 +1645,22 @@ function startSnapshotReset(ctx, root, registry, body) {
1580
1645
  });
1581
1646
  }
1582
1647
 
1583
- function startGenerate(ctx, root, registry, body) {
1648
+ async function startGenerate(ctx, root, registry, body) {
1584
1649
  const projectId = asText(body.projectId);
1585
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);
1586
1658
  return startJob(registry, { kind: 'generate', projectId, chapterId }, async (job) => {
1587
1659
  const project = await readProject(root, projectId);
1588
1660
  const chapter = project.chapters.find((entry) => entry.chapterId === chapterId);
1589
1661
  if (!chapter) throw httpError(404, `章节不存在: ${chapterId}`);
1590
1662
  if (!chapter.outline) throw httpError(409, '请先在章节大纲里保存设定,再生成正文');
1663
+ assertSnapshotFresh(project, chapter);
1591
1664
  const previous = project.chapters
1592
1665
  .filter((entry) => entry.chapterId !== chapter.chapterId && entry.startDay <= chapter.startDay)
1593
1666
  .sort((a, b) => b.startDay - a.startDay)[0] || null;
@@ -1620,8 +1693,25 @@ function startGenerate(ctx, root, registry, body) {
1620
1693
  * chapter in place (the outline page's 自动推演, which overwrites everything);
1621
1694
  * without it the job appends a new one (the new-chapter dialog).
1622
1695
  */
1623
- function startCompose(ctx, root, registry, body) {
1696
+ async function startCompose(ctx, root, registry, body) {
1624
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
+ });
1625
1715
  return startJob(registry, { kind: 'compose', projectId }, async (job) => {
1626
1716
  const minWords = Math.round(Number(body.minWords) || 0);
1627
1717
  const maxWords = Math.round(Number(body.maxWords) || 0);
@@ -1660,6 +1750,7 @@ function startCompose(ctx, root, registry, body) {
1660
1750
  outline: null,
1661
1751
  };
1662
1752
  const previous = previousChapter(project, draft);
1753
+ assertSnapshotFresh(project, draft);
1663
1754
 
1664
1755
  job.message = '正在推演本章梗概与名称';
1665
1756
  const deduceReply = await askAgent(ctx, project, deducePrompt(project, draft));
@@ -1737,9 +1828,19 @@ function startCompose(ctx, root, registry, body) {
1737
1828
  });
1738
1829
  }
1739
1830
 
1740
- function startDeduce(ctx, root, registry, body) {
1831
+ async function startDeduce(ctx, root, registry, body) {
1741
1832
  const projectId = asText(body.projectId);
1742
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
+ });
1743
1844
  return startJob(registry, { kind: 'deduce', projectId, chapterId }, async (job) => {
1744
1845
  const project = await readProject(root, projectId);
1745
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.8",
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",