dsh-novel-generator 0.1.6 → 0.1.10

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 +4 -0
  2. package/client.js +118 -22
  3. package/index.js +215 -59
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -34,6 +34,10 @@ dsh plugin --profile web add dsh-novel-generator
34
34
  - **自动**:每次生成章节后检查两个条件,缺一不可:① 本章结束时间**正好落在网格点上**;② **上一格**(该点 − 一个间隔)已经有快照(第 0 天视为已覆盖)。满足才自动生成这一格的快照。
35
35
  - 这样改间隔不会把旧快照重排:网格随当前间隔移动,自动生成只沿着「上一格存在」的链条向前推进,断档的地方留给你手动补。
36
36
 
37
+ - **重置**:时间线右上角的「重置快照」会清空全部快照,再按当前间隔从第 1 格逐格重建到故事末端(确认框里写明要生成几份)。
38
+ - **物品 / 事件**:快照里除了世界观、角色、势力、关系,还有物品(名称 + 下落/用途)和事件(故事天数 + 标题 + 一句话),事件按时间从新到旧排列。
39
+ - **章节正文的依据**:生成正文时会带上「当前快照 + 该快照之后到本章之前的各章梗概」,不再只看上一章。
40
+
37
41
  ## 数据与接口
38
42
 
39
43
 
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)}
@@ -716,7 +721,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
716
721
 
717
722
  /* ------------------------------------------------------------ world page */
718
723
 
719
- function WorldPage({ detail, onTimeline, onEnterStory, onRefreshSnapshot, snapshotBusy, requestConfirm }) {
724
+ function WorldPage({ detail, onTimeline, onEnterStory, onRefreshSnapshot, onResetSnapshots, snapshotBusy, requestConfirm }) {
720
725
  const snapshots = detail.snapshots || [];
721
726
  const facts = detail.timelineFacts || {};
722
727
  const latest = facts.latestSnapshotDay !== undefined
@@ -800,6 +805,13 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
800
805
  'aria-pressed': showChart,
801
806
  onClick: () => setShowChart((value) => !value),
802
807
  }, showChart ? t('隐藏统计图', "Hide chart") : t('显示统计图', "Show chart")),
808
+ h('button', {
809
+ className: 'dsn-btn ghost',
810
+ type: 'button',
811
+ disabled: snapshotBusy || chapters.length === 0,
812
+ title: t('清除全部世界快照,按当前间隔逐格重建', 'Clear every snapshot and rebuild them point by point at the current interval'),
813
+ onClick: onResetSnapshots,
814
+ }, t('重置快照', "Reset snapshots")),
803
815
  canSnapshot
804
816
  ? h('button', {
805
817
  className: 'dsn-btn',
@@ -840,6 +852,27 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
840
852
  key: index,
841
853
  className: 'dsn-entry',
842
854
  }, h('span', null, relation))))
855
+ : h('div', { className: 'dsn-muted' }, t('暂无', "None yet"))),
856
+ h('div', { className: 'dsn-card' },
857
+ h('h4', null, t('物品', "Items")),
858
+ snapshot && snapshot.items && snapshot.items.length
859
+ ? h('div', { className: 'dsn-entries' }, snapshot.items.map((item, index) => h('div', {
860
+ key: `${item.name}-${index}`,
861
+ className: 'dsn-entry',
862
+ }, h('b', null, item.name), h('span', null, item.desc))))
863
+ : h('div', { className: 'dsn-muted' }, t('暂无', "None yet"))),
864
+ h('div', { className: 'dsn-card', style: { gridColumn: 'span 2' } },
865
+ h('h4', null, t('事件', "Events")),
866
+ snapshot && snapshot.events && snapshot.events.length
867
+ ? h('div', { className: 'dsn-entries' }, snapshot.events
868
+ .slice()
869
+ .sort((a, b) => (b.day === null || b.day === undefined ? -1 : b.day) - (a.day === null || a.day === undefined ? -1 : a.day))
870
+ .map((event, index) => h('div', {
871
+ key: `${event.title}-${index}`,
872
+ className: 'dsn-entry',
873
+ },
874
+ h('b', null, event.day === null || event.day === undefined ? t('时间不详', 'Day unknown') : t('第 {day} 天', 'Day {day}', { day: event.day })),
875
+ h('span', null, `${event.title}${event.desc ? `:${event.desc}` : ''}`))))
843
876
  : h('div', { className: 'dsn-muted' }, t('暂无', "None yet")))),
844
877
  showChart ? h(ChapterChart, { detail }) : null);
845
878
  }
@@ -1437,7 +1470,10 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1437
1470
  function ProjectWorkspace({ detail, onReload, notify, onRegisterLeaveGuard }) {
1438
1471
  const [view, setView] = useState('world');
1439
1472
  const [chapterId, setChapterId] = useState(null);
1440
- 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;
1441
1477
  const [dialogOpen, setDialogOpen] = useState(false);
1442
1478
  const [text, setText] = useState('');
1443
1479
  const [dirty, setDirty] = useState(false);
@@ -1555,38 +1591,97 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1555
1591
  }
1556
1592
  }, [detail.projectId, onReload, notify]);
1557
1593
 
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();
1603
+ const timer = setInterval(async () => {
1604
+ try {
1605
+ const job = await api(`/job?id=${encodeURIComponent(jobId)}`);
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 });
1615
+ await onReload();
1616
+ return;
1617
+ }
1618
+ clearInterval(timer);
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
+ }
1631
+ }
1632
+ }, 2500);
1633
+ }, [onReload, notify]);
1634
+
1558
1635
  const refreshSnapshot = useCallback(async (day) => {
1559
- setSnapshotBusy(true);
1560
1636
  try {
1561
1637
  const started = await api('/snapshot/refresh', { projectId: detail.projectId, day });
1562
1638
  notify(t('正在结算世界快照…', "Settling the world snapshot…"), 'success');
1563
- const timer = setInterval(async () => {
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
+ );
1644
+ } catch (error) {
1645
+ notify(messageOf(error), 'error');
1646
+ }
1647
+ }, [detail.projectId, notify, waitSnapshotJob]);
1648
+
1649
+ /** 重置快照:清空后按当前间隔从第 1 格重建到故事末端。 */
1650
+ const resetSnapshots = useCallback(() => {
1651
+ const facts = detail.timelineFacts || {};
1652
+ const unit = Math.max(1, facts.unitDays || detail.timeline.unitDays || 30);
1653
+ const end = Math.floor((facts.storyEnd || 0) / unit) * unit;
1654
+ setConfirmRequest({
1655
+ title: t('重置全部世界快照?', 'Reset every world snapshot?'),
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)) }),
1657
+ confirmLabel: t('清除并重建', 'Clear and rebuild'),
1658
+ action: async () => {
1564
1659
  try {
1565
- const job = await api(`/job?id=${encodeURIComponent(started.jobId)}`);
1566
- if (job.state === 'running') return;
1567
- clearInterval(timer);
1568
- setSnapshotBusy(false);
1569
- if (job.state === 'done') {
1570
- notify(t('世界快照已更新,时间线向前延伸', "World snapshot updated; the timeline moved forward."), 'success');
1571
- await onReload();
1572
- } else {
1573
- notify(job.error || t('世界快照更新失败', "World snapshot update failed"), 'error');
1574
- }
1660
+ const started = await api('/snapshot/reset', { projectId: detail.projectId });
1661
+ waitSnapshotJob(
1662
+ started.jobId,
1663
+ t('正在重建世界快照…', 'Rebuilding world snapshots…'),
1664
+ t('世界快照已全部重建', 'All world snapshots rebuilt'),
1665
+ );
1575
1666
  } catch (error) {
1576
- clearInterval(timer);
1577
- setSnapshotBusy(false);
1578
1667
  notify(messageOf(error), 'error');
1579
1668
  }
1580
- }, 2500);
1581
- } catch (error) {
1582
- setSnapshotBusy(false);
1583
- notify(messageOf(error), 'error');
1584
- }
1585
- }, [detail.projectId, onReload, notify]);
1669
+ },
1670
+ });
1671
+ }, [detail, setConfirmRequest, notify, waitSnapshotJob]);
1586
1672
 
1587
1673
  const trackIndex = view === 'world' ? 0 : view === 'story' ? 1 : 2;
1588
1674
 
1589
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,
1590
1685
  h('div', { className: 'dsn-track', style: { transform: `translateX(-${trackIndex * 100}%)` } },
1591
1686
  h('section', { className: 'dsn-page' },
1592
1687
  h(WorldPage, {
@@ -1594,6 +1689,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1594
1689
  onTimeline: saveTimeline,
1595
1690
  onEnterStory: enterStory,
1596
1691
  onRefreshSnapshot: refreshSnapshot,
1692
+ onResetSnapshots: resetSnapshots,
1597
1693
  snapshotBusy,
1598
1694
  requestConfirm: setConfirmRequest,
1599
1695
  })),
package/index.js CHANGED
@@ -386,6 +386,18 @@ async function exportSnapshot(project, snapshot) {
386
386
  lines.push('');
387
387
  for (const relation of snapshot.relations || []) lines.push(`- ${relation}`);
388
388
  lines.push('');
389
+ lines.push('## 物品');
390
+ lines.push('');
391
+ for (const item of snapshot.items || []) {
392
+ lines.push(`- ${item.name}${item.desc ? `:${item.desc}` : ''}`);
393
+ }
394
+ lines.push('');
395
+ lines.push('## 事件');
396
+ lines.push('');
397
+ for (const event of snapshot.events || []) {
398
+ lines.push(`- ${event.day === null || event.day === undefined ? '时间不详' : `第 ${event.day} 天`} ${event.title}${event.desc ? `:${event.desc}` : ''}`);
399
+ }
400
+ lines.push('');
389
401
  if (snapshot.summary) {
390
402
  lines.push('## 这段时间的变化');
391
403
  lines.push('');
@@ -401,6 +413,8 @@ async function exportSnapshot(project, snapshot) {
401
413
  characters: snapshot.characters || [],
402
414
  factions: snapshot.factions || [],
403
415
  relations: snapshot.relations || [],
416
+ items: snapshot.items || [],
417
+ events: snapshot.events || [],
404
418
  summary: snapshot.summary || '',
405
419
  }, null, 2), 'utf8');
406
420
  // Same one-time move for the snapshot folder the earlier build created
@@ -784,11 +798,37 @@ function normalizeSnapshot(raw) {
784
798
  return [from, to, type].filter((part) => part !== '').join(' ');
785
799
  })
786
800
  .filter((entry) => entry !== '');
801
+ const items = list(firstOf(source, ['items', '物品', '道具', '物品列表']))
802
+ .map((entry) => {
803
+ if (typeof entry === 'string') return { name: entry.trim(), desc: '' };
804
+ return {
805
+ name: asText(firstOf(entry, ['name', '名称', '名字', '物品'])),
806
+ desc: asText(firstOf(entry, ['desc', 'description', '描述', '简介', '用途'])),
807
+ };
808
+ })
809
+ .filter((entry) => entry.name !== '');
810
+ const events = list(firstOf(source, ['events', '事件', '事件列表']))
811
+ .map((entry) => {
812
+ if (typeof entry === 'string') return { day: null, title: entry.trim(), desc: '' };
813
+ const rawDay = firstOf(entry, ['day', 'time', '时间', '天数', 'at'], null);
814
+ const parsed = Number.isFinite(Number(rawDay)) ? Math.round(Number(rawDay)) : null;
815
+ const fallback = typeof rawDay === 'string' ? (rawDay.match(/\d+/g) || []).map(Number).pop() : undefined;
816
+ return {
817
+ day: parsed === null ? (Number.isFinite(fallback) ? fallback : null) : parsed,
818
+ title: asText(firstOf(entry, ['title', 'name', '事件', '标题', '名称'])),
819
+ desc: asText(firstOf(entry, ['desc', 'description', '描述', '内容', '简介'])),
820
+ };
821
+ })
822
+ .filter((entry) => entry.title !== '')
823
+ // Newest first: the list reads like the story's own clock.
824
+ .sort((a, b) => (b.day === null ? -1 : b.day) - (a.day === null ? -1 : a.day));
787
825
  return {
788
826
  world,
789
827
  characters,
790
828
  factions,
791
829
  relations,
830
+ items,
831
+ events,
792
832
  summary: asText(firstOf(source, ['summary', '梗概', '概述'])),
793
833
  };
794
834
  }
@@ -877,6 +917,18 @@ function snapshotBlock(snapshot) {
877
917
  lines.push('【关系】');
878
918
  for (const relation of snapshot.relations) lines.push(`- ${relation}`);
879
919
  }
920
+ if (snapshot.items && snapshot.items.length) {
921
+ lines.push('【物品】');
922
+ for (const item of snapshot.items) {
923
+ lines.push(`- ${item.name}${item.desc ? `:${item.desc}` : ''}`);
924
+ }
925
+ }
926
+ if (snapshot.events && snapshot.events.length) {
927
+ lines.push('【事件】');
928
+ for (const event of snapshot.events) {
929
+ lines.push(`- ${event.day === null || event.day === undefined ? '时间不详' : `第 ${event.day} 天`} ${event.title}${event.desc ? `:${event.desc}` : ''}`);
930
+ }
931
+ }
880
932
  return lines.join('\n');
881
933
  }
882
934
 
@@ -891,6 +943,14 @@ function chapterPrompt(project, chapter, previous) {
891
943
  const window = chapterWindow(project, chapter);
892
944
  const outline = chapter.outline || {};
893
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);
894
954
  const lines = [];
895
955
  lines.push('你现在是这部小说的执笔作者。只输出本章正文,不要解释、不要标题、不要分点提纲。');
896
956
  lines.push('');
@@ -899,7 +959,13 @@ function chapterPrompt(project, chapter, previous) {
899
959
  lines.push('【当前世界快照】');
900
960
  lines.push(snapshotBlock(snapshot));
901
961
  lines.push('');
902
- if (previous) {
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('');
968
+ } else if (previous) {
903
969
  lines.push('【上一章梗概】');
904
970
  lines.push(`${previous.title || ''}:${(previous.outline && previous.outline.synopsis) || '(无)'}`);
905
971
  lines.push('');
@@ -910,7 +976,7 @@ function chapterPrompt(project, chapter, previous) {
910
976
  lines.push('【要求】');
911
977
  lines.push('- 不要调用任何工具,不要反问,不要输出计划或步骤说明,直接给出最终正文。');
912
978
  lines.push(`- 字数控制在 ${outline.minWords || 800} 到 ${outline.maxWords || 3000} 字之间。`);
913
- lines.push('- 与世界观、角色、势力和人物关系保持一致,不要引入与快照冲突的设定。');
979
+ lines.push('- 与世界观、角色、势力、物品、事件和人物关系保持一致,不要引入与快照冲突的设定。');
914
980
  lines.push('- 有完整的情节推进、场景描写和对话,直接成文。');
915
981
  return lines.join('\n');
916
982
  }
@@ -941,10 +1007,10 @@ function deducePrompt(project, chapter) {
941
1007
  return lines.join('\n');
942
1008
  }
943
1009
 
944
- function snapshotPrompt(project, previous, chapters) {
1010
+ function snapshotPrompt(project, previous, chapters, pointDay) {
945
1011
  const lines = [];
946
- lines.push('你是这部小说的世界观维护者。请阅读以下章节梗概,输出更新后的世界快照。');
947
- lines.push('只输出一个 JSON 对象,不要任何额外文字。');
1012
+ lines.push(`你是这部小说的世界观维护者。当前要产出第 ${pointDay} 天的世界快照。`);
1013
+ lines.push('请阅读以下章节梗概,输出更新后的世界快照。只输出一个 JSON 对象,不要任何额外文字。');
948
1014
  lines.push('');
949
1015
  if (previous) {
950
1016
  lines.push('【上一版世界快照】');
@@ -952,6 +1018,10 @@ function snapshotPrompt(project, previous, chapters) {
952
1018
  lines.push('');
953
1019
  }
954
1020
  lines.push('【这段时间的新章节】');
1021
+ if (chapters.length === 0) {
1022
+ lines.push('(这段时间没有已完成的章节:沿用上一版设定,只按时间推进做必要的细微更新。)');
1023
+ lines.push('');
1024
+ }
955
1025
  for (const chapter of chapters) {
956
1026
  const window = chapterWindow(project, chapter);
957
1027
  lines.push(`--- 第 ${chapter.index + 1} 章 ${chapter.title || ''}(第 ${window.start} → ${window.end} 天,${dateForDay(project, window.start)} 至 ${dateForDay(project, window.end)})---`);
@@ -966,9 +1036,14 @@ function snapshotPrompt(project, previous, chapters) {
966
1036
  lines.push('');
967
1037
  }
968
1038
  lines.push('【输出 JSON 结构】');
969
- lines.push('{"world":"世界观,按时间推进更新,500 字以内","characters":[{"name":"","desc":""}],"factions":[{"name":"","desc":""}],"relations":["A B 敌对"],"summary":"这段时期发生的关键变化"}');
1039
+ lines.push('{"world":"世界观,按时间推进更新,500 字以内","characters":[{"name":"","desc":""}],"factions":[{"name":"","desc":""}],"relations":["A B 敌对"],"items":[{"name":"","desc":""}],"events":[{"day":30,"title":"","desc":""}],"summary":"这段时期发生的关键变化"}');
970
1040
  lines.push('');
971
- lines.push('要求:保留上一版中尚未被推翻的设定;角色、势力要包含新登场者;关系用「甲 乙 关系」三段式。');
1041
+ lines.push('要求:');
1042
+ lines.push('- 保留上一版中尚未被推翻的设定;角色、势力要包含新登场者;关系用「甲 乙 关系」三段式。');
1043
+ lines.push('- items 必填:列出这段时间出现、易手或状态变化的关键物品(法宝、信物、丹药、线索等),每项 name + desc(下落/归属/用途)。');
1044
+ lines.push(`- events 必填:这段时间每发生一件值得记录的事就写一条,day 为故事天数(整数,0~${pointDay}),title 一行,desc 一两句,按 day 从大到小排列。只要这段时间有章节内容,就至少写 1 条。`);
1045
+ lines.push('- 只有这段时间确实没有任何可记录的物品或事件时,才给空数组。');
1046
+ lines.push('注意:输出的 JSON 必须同时包含 items 与 events 两个键,缺任何一个都算不合格(没有内容就写 [])。');
972
1047
  lines.push('不要调用任何工具,不要反问,只输出这个 JSON。');
973
1048
  return lines.join('\n');
974
1049
  }
@@ -1073,7 +1148,7 @@ async function bootstrap(ctx, root) {
1073
1148
  }
1074
1149
  }
1075
1150
  return {
1076
- revision: '2026-10-20.19',
1151
+ revision: '2026-10-20.28',
1077
1152
  projects,
1078
1153
  models: {
1079
1154
  default: modelCatalog.default || null,
@@ -1350,21 +1425,17 @@ async function ensureWorkspace(ctx, project) {
1350
1425
  }
1351
1426
 
1352
1427
  /**
1353
- * Whether a chapter ending at `day` earns an automatic snapshot.
1354
- *
1355
- * The rule ignores how many snapshots exist and which interval produced them:
1356
- * a point counts only when the chapter lands exactly on a multiple of the
1357
- * *current* interval, and the point one interval earlier is already covered
1358
- * (day 0 — the story's start — counts as covered). Changing the interval
1359
- * therefore moves the grid, and generation only continues along an unbroken
1360
- * chain rather than back-filling gaps by itself.
1428
+ * The grid point a new chapter brings the story to, or null when nothing new is
1429
+ * due. A chapter spanning several points targets the last point it covers; the
1430
+ * cascade then fills each point in between from the previous snapshot, so the
1431
+ * unfinished chapter that straddles them never becomes its own material.
1361
1432
  */
1362
- function snapshotPointDue(project, day) {
1433
+ function autoSnapshotTarget(project, chapterEndDay) {
1363
1434
  const unit = Math.max(1, project.timeline.unitDays || 30);
1364
- if (day <= 0 || day % unit !== 0) return false;
1365
- const previous = day - unit;
1366
- if (previous <= 0) return true;
1367
- return (project.snapshots || []).some((snapshot) => snapshot.day === previous);
1435
+ const point = Math.floor(Math.max(0, chapterEndDay) / unit) * unit;
1436
+ if (point < unit) return null;
1437
+ const latest = (project.snapshots || []).reduce((max, snapshot) => Math.max(max, snapshot.day), 0);
1438
+ return point > latest ? point : null;
1368
1439
  }
1369
1440
 
1370
1441
  /** Story day where the next chapter continues: the end of the last chapter. */
@@ -1412,44 +1483,126 @@ function previousChapter(project, chapter) {
1412
1483
  .sort((a, b) => b.startDay - a.startDay)[0] || null;
1413
1484
  }
1414
1485
 
1415
- function registerSnapshot(ctx, root, registry, projectId, day) {
1486
+ /**
1487
+ * Chapters that belong to the stretch `(fromDay, point]`: fully written up to
1488
+ * the point, so a chapter that straddles it (the one still being written) is
1489
+ * left out. The chapter starting exactly at `fromDay` is included.
1490
+ */
1491
+ function chaptersForPoint(project, fromDay, point) {
1492
+ return (project.chapters || [])
1493
+ .filter((chapter) => {
1494
+ const span = chapterWindow(project, chapter);
1495
+ if (span.end > point || span.end <= fromDay) return false;
1496
+ return ((chapter.outline && chapter.outline.synopsis) || '').trim() !== ''
1497
+ || String(chapter.content || '').trim() !== '';
1498
+ })
1499
+ .sort((a, b) => a.startDay - b.startDay);
1500
+ }
1501
+
1502
+ /**
1503
+ * Fill every interval point from the newest existing snapshot up to `targetDay`,
1504
+ * one snapshot per point, each built from the snapshot before it plus the
1505
+ * chapters of that stretch. Long gaps (a jump of several points) therefore fill
1506
+ * in order instead of skipping to the end.
1507
+ */
1508
+ function registerSnapshot(ctx, root, registry, projectId, targetDay) {
1416
1509
  return startJob(registry, { kind: 'snapshot', projectId }, async (job) => {
1510
+ // The sequence is fixed from the state at the start of the job: recomputing
1511
+ // it every round would regenerate the last point forever once it exists.
1512
+ const initial = await readProject(root, projectId);
1513
+ const unit = Math.max(1, initial.timeline.unitDays || 30);
1514
+ const target = Math.floor(Math.max(1, Math.round(Number(targetDay) || 0)) / unit) * unit;
1515
+ if (target < unit) throw httpError(409, '目标时间还不到一个快照间隔,无法生成世界快照');
1516
+ const startBase = (initial.snapshots || []).filter((snapshot) => snapshot.day < target).pop() || null;
1517
+ let first = (startBase ? startBase.day : 0) + unit;
1518
+ // Nothing new below the target means "redo the target point itself".
1519
+ if (first > target) first = target;
1520
+ const points = [];
1521
+ for (let point = first; point <= target; point += unit) points.push(point);
1522
+
1523
+ const made = [];
1524
+ for (const point of points) {
1525
+ const project = await readProject(root, projectId);
1526
+ const base = (project.snapshots || []).filter((snapshot) => snapshot.day < point).pop() || null;
1527
+ const chapters = chaptersForPoint(project, base ? base.day : 0, point);
1528
+ job.message = `正在生成第 ${point} 天的世界快照(第 ${made.length + 1}/${points.length} 份,目标第 ${target} 天)`;
1529
+ const reply = await askAgent(ctx, project, snapshotPrompt(project, base, chapters, point));
1530
+ const parsed = parseJsonReply(reply);
1531
+ if (!parsed) throw httpError(502, '模型没有返回可解析的快照 JSON');
1532
+ const normalized = normalizeSnapshot(parsed);
1533
+ if (normalized.world === '' && normalized.characters.length === 0 && normalized.factions.length === 0) {
1534
+ throw httpError(502, '模型返回的快照为空');
1535
+ }
1536
+ // A chapter boundary is a fact of the work itself, so it seeds the event
1537
+ // list even when the model leaves `events` empty; model-written events win
1538
+ // by title and anything else is added.
1539
+ const seeded = chapters.map((chapter) => {
1540
+ const span = chapterWindow(project, chapter);
1541
+ return {
1542
+ day: span.end,
1543
+ title: chapter.title || `第 ${chapter.index + 1} 章`,
1544
+ desc: ((chapter.outline && chapter.outline.synopsis) || '').slice(0, 120),
1545
+ };
1546
+ });
1547
+ const known = new Set(normalized.events.map((event) => event.title));
1548
+ for (const event of seeded) {
1549
+ if (!known.has(event.title)) {
1550
+ normalized.events.push(event);
1551
+ known.add(event.title);
1552
+ }
1553
+ }
1554
+ normalized.events.sort((a, b) => (b.day === null ? -1 : b.day) - (a.day === null ? -1 : a.day));
1555
+ const snapshot = {
1556
+ snapshotId: randomUUID(),
1557
+ day: point,
1558
+ createdAt: Date.now(),
1559
+ unitDays: unit,
1560
+ ...normalized,
1561
+ };
1562
+ await mutateProject(root, projectId, (draft) => {
1563
+ // One snapshot per day: regenerating replaces the one already there.
1564
+ draft.snapshots = (draft.snapshots || []).filter((entry) => entry.day !== point);
1565
+ draft.snapshots.push(snapshot);
1566
+ draft.snapshots.sort((a, b) => a.day - b.day);
1567
+ return snapshot;
1568
+ });
1569
+ await exportSnapshot(project, snapshot);
1570
+ made.push(point);
1571
+ }
1572
+ await exportProjectInfo(await readProject(root, projectId));
1573
+ return { made, target };
1574
+ });
1575
+ }
1576
+
1577
+ /** Wipe every snapshot (and its files) so the work can be rebuilt from day 1. */
1578
+ function startSnapshotReset(ctx, root, registry, body) {
1579
+ const projectId = asText(body.projectId);
1580
+ return startJob(registry, { kind: 'snapshot-reset', projectId }, async (job) => {
1417
1581
  const project = await readProject(root, projectId);
1418
- const snapshots = project.snapshots || [];
1419
- // Regenerating an existing day replaces it: the basis is the snapshot
1420
- // before that day, not the one being replaced.
1421
- const base = snapshots.filter((snapshot) => snapshot.day < day).pop() || null;
1422
- const since = base ? base.day : 0;
1423
- const chapters = (project.chapters || [])
1424
- .filter((chapter) => chapterWindow(project, chapter).end > since
1425
- && (((chapter.outline && chapter.outline.synopsis) || '').trim() !== '' || String(chapter.content || '').trim() !== ''))
1426
- .sort((a, b) => a.startDay - b.startDay);
1427
- if (chapters.length === 0) throw httpError(409, `第 ${day} 天之前还没有可依据的章节,无法生成世界快照`);
1428
- job.message = `正在阅读 ${chapters.length} 章的梗概并更新世界快照`;
1429
- const reply = await askAgent(ctx, project, snapshotPrompt(project, base, chapters));
1430
- const parsed = parseJsonReply(reply);
1431
- if (!parsed) throw httpError(502, '模型没有返回可解析的快照 JSON');
1432
- const normalized = normalizeSnapshot(parsed);
1433
- if (normalized.world === '' && normalized.characters.length === 0 && normalized.factions.length === 0) {
1434
- throw httpError(502, '模型返回的快照为空');
1582
+ const unit = Math.max(1, project.timeline.unitDays || 30);
1583
+ const storyEnd = lastChapterEnd(project);
1584
+ const target = Math.floor(storyEnd / unit) * unit;
1585
+ if (target < unit) throw httpError(409, '故事还不到一个快照间隔,没有可重建的快照');
1586
+ if (target / unit > 120) {
1587
+ throw httpError(409, `当前间隔只有 ${unit} 天,重建到第 ${target} 天需要 ${Math.floor(target / unit)} 次模型调用;请先把快照间隔调大一些再重置`);
1435
1588
  }
1436
- const snapshot = {
1437
- snapshotId: randomUUID(),
1438
- day,
1439
- createdAt: Date.now(),
1440
- unitDays: project.timeline.unitDays,
1441
- ...normalized,
1442
- };
1589
+ job.message = '正在清除全部世界快照';
1443
1590
  await mutateProject(root, projectId, (draft) => {
1444
- // One snapshot per day: regenerating replaces the one already there.
1445
- draft.snapshots = (draft.snapshots || []).filter((entry) => entry.day !== day);
1446
- draft.snapshots.push(snapshot);
1447
- draft.snapshots.sort((a, b) => a.day - b.day);
1448
- return snapshot;
1591
+ draft.snapshots = [];
1449
1592
  });
1450
- await exportSnapshot(project, snapshot);
1451
- await exportProjectInfo(await readProject(root, projectId));
1452
- return { snapshot };
1593
+ try {
1594
+ await rm(join(projectContentDir(project), '世界快照'), { recursive: true, force: true });
1595
+ } catch {
1596
+ // Nothing to remove.
1597
+ }
1598
+ // Run the rebuild inside this same job so the Client polls one thing.
1599
+ const cascade = registerSnapshot(ctx, root, registry, projectId, target);
1600
+ while (cascade.state === 'running') {
1601
+ job.message = cascade.message || '正在重建世界快照';
1602
+ await new Promise((resolve) => setTimeout(resolve, 1500));
1603
+ }
1604
+ if (cascade.state === 'error') throw httpError(502, cascade.error || '重建世界快照失败');
1605
+ return { target, made: (cascade.result && cascade.result.made) || [] };
1453
1606
  });
1454
1607
  }
1455
1608
 
@@ -1479,9 +1632,9 @@ function startGenerate(ctx, root, registry, body) {
1479
1632
  await exportChapter(project, saved, content);
1480
1633
  const window = chapterWindow(project, chapter);
1481
1634
  let snapshotJobId = null;
1482
- if (snapshotPointDue(project, window.end)) {
1483
- const snapshotJob = registerSnapshot(ctx, root, registry, projectId, window.end);
1484
- snapshotJobId = snapshotJob.jobId;
1635
+ const autoTarget = autoSnapshotTarget(project, window.end);
1636
+ if (autoTarget !== null) {
1637
+ snapshotJobId = registerSnapshot(ctx, root, registry, projectId, autoTarget).jobId;
1485
1638
  }
1486
1639
  return { chapter: saved, wordCount: wordCount(content), snapshotJobId };
1487
1640
  });
@@ -1596,8 +1749,9 @@ function startCompose(ctx, root, registry, body) {
1596
1749
  if (written) await exportChapter(after, written, content);
1597
1750
  const window = chapterWindow(after, target);
1598
1751
  let snapshotJobId = null;
1599
- if (snapshotPointDue(after, window.end)) {
1600
- snapshotJobId = registerSnapshot(ctx, root, registry, projectId, window.end).jobId;
1752
+ const autoTarget = autoSnapshotTarget(after, window.end);
1753
+ if (autoTarget !== null) {
1754
+ snapshotJobId = registerSnapshot(ctx, root, registry, projectId, autoTarget).jobId;
1601
1755
  }
1602
1756
  return {
1603
1757
  chapterId,
@@ -1713,6 +1867,8 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
1713
1867
  : lastChapterEnd(project);
1714
1868
  return registerSnapshot(ctx, root, registry, projectId, day);
1715
1869
  }
1870
+ case '/snapshot/reset':
1871
+ return startSnapshotReset(ctx, root, registry, body);
1716
1872
  default:
1717
1873
  throw httpError(404, `未知接口: ${path}`);
1718
1874
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-novel-generator",
3
- "version": "0.1.6",
3
+ "version": "0.1.10",
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",