dsh-novel-generator 0.1.6 → 0.1.8
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 +4 -0
- package/client.js +76 -19
- package/index.js +189 -59
- 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
|
@@ -716,7 +716,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
716
716
|
|
|
717
717
|
/* ------------------------------------------------------------ world page */
|
|
718
718
|
|
|
719
|
-
function WorldPage({ detail, onTimeline, onEnterStory, onRefreshSnapshot, snapshotBusy, requestConfirm }) {
|
|
719
|
+
function WorldPage({ detail, onTimeline, onEnterStory, onRefreshSnapshot, onResetSnapshots, snapshotBusy, requestConfirm }) {
|
|
720
720
|
const snapshots = detail.snapshots || [];
|
|
721
721
|
const facts = detail.timelineFacts || {};
|
|
722
722
|
const latest = facts.latestSnapshotDay !== undefined
|
|
@@ -800,6 +800,13 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
800
800
|
'aria-pressed': showChart,
|
|
801
801
|
onClick: () => setShowChart((value) => !value),
|
|
802
802
|
}, showChart ? t('隐藏统计图', "Hide chart") : t('显示统计图', "Show chart")),
|
|
803
|
+
h('button', {
|
|
804
|
+
className: 'dsn-btn ghost',
|
|
805
|
+
type: 'button',
|
|
806
|
+
disabled: snapshotBusy || chapters.length === 0,
|
|
807
|
+
title: t('清除全部世界快照,按当前间隔逐格重建', 'Clear every snapshot and rebuild them point by point at the current interval'),
|
|
808
|
+
onClick: onResetSnapshots,
|
|
809
|
+
}, t('重置快照', "Reset snapshots")),
|
|
803
810
|
canSnapshot
|
|
804
811
|
? h('button', {
|
|
805
812
|
className: 'dsn-btn',
|
|
@@ -840,6 +847,27 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
840
847
|
key: index,
|
|
841
848
|
className: 'dsn-entry',
|
|
842
849
|
}, h('span', null, relation))))
|
|
850
|
+
: h('div', { className: 'dsn-muted' }, t('暂无', "None yet"))),
|
|
851
|
+
h('div', { className: 'dsn-card' },
|
|
852
|
+
h('h4', null, t('物品', "Items")),
|
|
853
|
+
snapshot && snapshot.items && snapshot.items.length
|
|
854
|
+
? h('div', { className: 'dsn-entries' }, snapshot.items.map((item, index) => h('div', {
|
|
855
|
+
key: `${item.name}-${index}`,
|
|
856
|
+
className: 'dsn-entry',
|
|
857
|
+
}, h('b', null, item.name), h('span', null, item.desc))))
|
|
858
|
+
: h('div', { className: 'dsn-muted' }, t('暂无', "None yet"))),
|
|
859
|
+
h('div', { className: 'dsn-card', style: { gridColumn: 'span 2' } },
|
|
860
|
+
h('h4', null, t('事件', "Events")),
|
|
861
|
+
snapshot && snapshot.events && snapshot.events.length
|
|
862
|
+
? h('div', { className: 'dsn-entries' }, snapshot.events
|
|
863
|
+
.slice()
|
|
864
|
+
.sort((a, b) => (b.day === null || b.day === undefined ? -1 : b.day) - (a.day === null || a.day === undefined ? -1 : a.day))
|
|
865
|
+
.map((event, index) => h('div', {
|
|
866
|
+
key: `${event.title}-${index}`,
|
|
867
|
+
className: 'dsn-entry',
|
|
868
|
+
},
|
|
869
|
+
h('b', null, event.day === null || event.day === undefined ? t('时间不详', 'Day unknown') : t('第 {day} 天', 'Day {day}', { day: event.day })),
|
|
870
|
+
h('span', null, `${event.title}${event.desc ? `:${event.desc}` : ''}`))))
|
|
843
871
|
: h('div', { className: 'dsn-muted' }, t('暂无', "None yet")))),
|
|
844
872
|
showChart ? h(ChapterChart, { detail }) : null);
|
|
845
873
|
}
|
|
@@ -1555,34 +1583,62 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1555
1583
|
}
|
|
1556
1584
|
}, [detail.projectId, onReload, notify]);
|
|
1557
1585
|
|
|
1586
|
+
/** Poll one snapshot job until it settles, then refresh the work. */
|
|
1587
|
+
const waitSnapshotJob = useCallback((jobId, doneText) => {
|
|
1588
|
+
const timer = setInterval(async () => {
|
|
1589
|
+
try {
|
|
1590
|
+
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');
|
|
1596
|
+
await onReload();
|
|
1597
|
+
} else {
|
|
1598
|
+
notify(job.error || t('世界快照更新失败', "World snapshot update failed"), 'error');
|
|
1599
|
+
}
|
|
1600
|
+
} catch (error) {
|
|
1601
|
+
clearInterval(timer);
|
|
1602
|
+
setSnapshotBusy(false);
|
|
1603
|
+
notify(messageOf(error), 'error');
|
|
1604
|
+
}
|
|
1605
|
+
}, 2500);
|
|
1606
|
+
}, [onReload, notify]);
|
|
1607
|
+
|
|
1558
1608
|
const refreshSnapshot = useCallback(async (day) => {
|
|
1559
1609
|
setSnapshotBusy(true);
|
|
1560
1610
|
try {
|
|
1561
1611
|
const started = await api('/snapshot/refresh', { projectId: detail.projectId, day });
|
|
1562
1612
|
notify(t('正在结算世界快照…', "Settling the world snapshot…"), 'success');
|
|
1563
|
-
|
|
1613
|
+
waitSnapshotJob(started.jobId, t('世界快照已更新,时间线向前延伸', "World snapshot updated; the timeline moved forward."));
|
|
1614
|
+
} catch (error) {
|
|
1615
|
+
setSnapshotBusy(false);
|
|
1616
|
+
notify(messageOf(error), 'error');
|
|
1617
|
+
}
|
|
1618
|
+
}, [detail.projectId, notify, waitSnapshotJob]);
|
|
1619
|
+
|
|
1620
|
+
/** 重置快照:清空后按当前间隔从第 1 格重建到故事末端。 */
|
|
1621
|
+
const resetSnapshots = useCallback(() => {
|
|
1622
|
+
const facts = detail.timelineFacts || {};
|
|
1623
|
+
const unit = Math.max(1, facts.unitDays || detail.timeline.unitDays || 30);
|
|
1624
|
+
const end = Math.floor((facts.storyEnd || 0) / unit) * unit;
|
|
1625
|
+
setConfirmRequest({
|
|
1626
|
+
title: t('重置全部世界快照?', 'Reset every world snapshot?'),
|
|
1627
|
+
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
|
+
confirmLabel: t('清除并重建', 'Clear and rebuild'),
|
|
1629
|
+
action: async () => {
|
|
1630
|
+
setSnapshotBusy(true);
|
|
1564
1631
|
try {
|
|
1565
|
-
const
|
|
1566
|
-
|
|
1567
|
-
|
|
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
|
-
}
|
|
1632
|
+
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'));
|
|
1575
1635
|
} catch (error) {
|
|
1576
|
-
clearInterval(timer);
|
|
1577
1636
|
setSnapshotBusy(false);
|
|
1578
1637
|
notify(messageOf(error), 'error');
|
|
1579
1638
|
}
|
|
1580
|
-
},
|
|
1581
|
-
}
|
|
1582
|
-
|
|
1583
|
-
notify(messageOf(error), 'error');
|
|
1584
|
-
}
|
|
1585
|
-
}, [detail.projectId, onReload, notify]);
|
|
1639
|
+
},
|
|
1640
|
+
});
|
|
1641
|
+
}, [detail, setConfirmRequest, notify, waitSnapshotJob]);
|
|
1586
1642
|
|
|
1587
1643
|
const trackIndex = view === 'world' ? 0 : view === 'story' ? 1 : 2;
|
|
1588
1644
|
|
|
@@ -1594,6 +1650,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1594
1650
|
onTimeline: saveTimeline,
|
|
1595
1651
|
onEnterStory: enterStory,
|
|
1596
1652
|
onRefreshSnapshot: refreshSnapshot,
|
|
1653
|
+
onResetSnapshots: resetSnapshots,
|
|
1597
1654
|
snapshotBusy,
|
|
1598
1655
|
requestConfirm: setConfirmRequest,
|
|
1599
1656
|
})),
|
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 (
|
|
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('
|
|
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,12 @@ 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 记录这段时间出现或变化的关键物品(下落、归属、状态);没有就给空数组。');
|
|
1044
|
+
lines.push(`- events 记录这段时间发生的具体事件,day 用故事天数(整数,不超过 ${pointDay}),title 一行、desc 一两句,最近的排最前;没有就给空数组。`);
|
|
972
1045
|
lines.push('不要调用任何工具,不要反问,只输出这个 JSON。');
|
|
973
1046
|
return lines.join('\n');
|
|
974
1047
|
}
|
|
@@ -1073,7 +1146,7 @@ async function bootstrap(ctx, root) {
|
|
|
1073
1146
|
}
|
|
1074
1147
|
}
|
|
1075
1148
|
return {
|
|
1076
|
-
revision: '2026-10-20.
|
|
1149
|
+
revision: '2026-10-20.20',
|
|
1077
1150
|
projects,
|
|
1078
1151
|
models: {
|
|
1079
1152
|
default: modelCatalog.default || null,
|
|
@@ -1350,21 +1423,17 @@ async function ensureWorkspace(ctx, project) {
|
|
|
1350
1423
|
}
|
|
1351
1424
|
|
|
1352
1425
|
/**
|
|
1353
|
-
*
|
|
1354
|
-
*
|
|
1355
|
-
*
|
|
1356
|
-
*
|
|
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.
|
|
1426
|
+
* The grid point a new chapter brings the story to, or null when nothing new is
|
|
1427
|
+
* due. A chapter spanning several points targets the last point it covers; the
|
|
1428
|
+
* cascade then fills each point in between from the previous snapshot, so the
|
|
1429
|
+
* unfinished chapter that straddles them never becomes its own material.
|
|
1361
1430
|
*/
|
|
1362
|
-
function
|
|
1431
|
+
function autoSnapshotTarget(project, chapterEndDay) {
|
|
1363
1432
|
const unit = Math.max(1, project.timeline.unitDays || 30);
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
return
|
|
1433
|
+
const point = Math.floor(Math.max(0, chapterEndDay) / unit) * unit;
|
|
1434
|
+
if (point < unit) return null;
|
|
1435
|
+
const latest = (project.snapshots || []).reduce((max, snapshot) => Math.max(max, snapshot.day), 0);
|
|
1436
|
+
return point > latest ? point : null;
|
|
1368
1437
|
}
|
|
1369
1438
|
|
|
1370
1439
|
/** Story day where the next chapter continues: the end of the last chapter. */
|
|
@@ -1412,44 +1481,102 @@ function previousChapter(project, chapter) {
|
|
|
1412
1481
|
.sort((a, b) => b.startDay - a.startDay)[0] || null;
|
|
1413
1482
|
}
|
|
1414
1483
|
|
|
1415
|
-
|
|
1484
|
+
/**
|
|
1485
|
+
* Chapters that belong to the stretch `(fromDay, point]`: fully written up to
|
|
1486
|
+
* the point, so a chapter that straddles it (the one still being written) is
|
|
1487
|
+
* left out. The chapter starting exactly at `fromDay` is included.
|
|
1488
|
+
*/
|
|
1489
|
+
function chaptersForPoint(project, fromDay, point) {
|
|
1490
|
+
return (project.chapters || [])
|
|
1491
|
+
.filter((chapter) => {
|
|
1492
|
+
const span = chapterWindow(project, chapter);
|
|
1493
|
+
if (span.end > point || span.end <= fromDay) return false;
|
|
1494
|
+
return ((chapter.outline && chapter.outline.synopsis) || '').trim() !== ''
|
|
1495
|
+
|| String(chapter.content || '').trim() !== '';
|
|
1496
|
+
})
|
|
1497
|
+
.sort((a, b) => a.startDay - b.startDay);
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/**
|
|
1501
|
+
* Fill every interval point from the newest existing snapshot up to `targetDay`,
|
|
1502
|
+
* one snapshot per point, each built from the snapshot before it plus the
|
|
1503
|
+
* chapters of that stretch. Long gaps (a jump of several points) therefore fill
|
|
1504
|
+
* in order instead of skipping to the end.
|
|
1505
|
+
*/
|
|
1506
|
+
function registerSnapshot(ctx, root, registry, projectId, targetDay) {
|
|
1416
1507
|
return startJob(registry, { kind: 'snapshot', projectId }, async (job) => {
|
|
1508
|
+
const made = [];
|
|
1509
|
+
let target = Math.max(1, Math.round(Number(targetDay) || 0));
|
|
1510
|
+
for (;;) {
|
|
1511
|
+
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
|
+
}
|
|
1522
|
+
const chapters = chaptersForPoint(project, base ? base.day : 0, point);
|
|
1523
|
+
job.message = `正在生成第 ${point} 天的世界快照(第 ${made.length + 1} 份,目标第 ${target} 天)`;
|
|
1524
|
+
const reply = await askAgent(ctx, project, snapshotPrompt(project, base, chapters, point));
|
|
1525
|
+
const parsed = parseJsonReply(reply);
|
|
1526
|
+
if (!parsed) throw httpError(502, '模型没有返回可解析的快照 JSON');
|
|
1527
|
+
const normalized = normalizeSnapshot(parsed);
|
|
1528
|
+
if (normalized.world === '' && normalized.characters.length === 0 && normalized.factions.length === 0) {
|
|
1529
|
+
throw httpError(502, '模型返回的快照为空');
|
|
1530
|
+
}
|
|
1531
|
+
const snapshot = {
|
|
1532
|
+
snapshotId: randomUUID(),
|
|
1533
|
+
day: point,
|
|
1534
|
+
createdAt: Date.now(),
|
|
1535
|
+
unitDays: unit,
|
|
1536
|
+
...normalized,
|
|
1537
|
+
};
|
|
1538
|
+
await mutateProject(root, projectId, (draft) => {
|
|
1539
|
+
// One snapshot per day: regenerating replaces the one already there.
|
|
1540
|
+
draft.snapshots = (draft.snapshots || []).filter((entry) => entry.day !== point);
|
|
1541
|
+
draft.snapshots.push(snapshot);
|
|
1542
|
+
draft.snapshots.sort((a, b) => a.day - b.day);
|
|
1543
|
+
return snapshot;
|
|
1544
|
+
});
|
|
1545
|
+
await exportSnapshot(project, snapshot);
|
|
1546
|
+
made.push(point);
|
|
1547
|
+
}
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
/** Wipe every snapshot (and its files) so the work can be rebuilt from day 1. */
|
|
1552
|
+
function startSnapshotReset(ctx, root, registry, body) {
|
|
1553
|
+
const projectId = asText(body.projectId);
|
|
1554
|
+
return startJob(registry, { kind: 'snapshot-reset', projectId }, async (job) => {
|
|
1417
1555
|
const project = await readProject(root, projectId);
|
|
1418
|
-
const
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
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, '模型返回的快照为空');
|
|
1556
|
+
const unit = Math.max(1, project.timeline.unitDays || 30);
|
|
1557
|
+
const storyEnd = lastChapterEnd(project);
|
|
1558
|
+
const target = Math.floor(storyEnd / unit) * unit;
|
|
1559
|
+
if (target < unit) throw httpError(409, '故事还不到一个快照间隔,没有可重建的快照');
|
|
1560
|
+
if (target / unit > 120) {
|
|
1561
|
+
throw httpError(409, `当前间隔只有 ${unit} 天,重建到第 ${target} 天需要 ${Math.floor(target / unit)} 次模型调用;请先把快照间隔调大一些再重置`);
|
|
1435
1562
|
}
|
|
1436
|
-
|
|
1437
|
-
snapshotId: randomUUID(),
|
|
1438
|
-
day,
|
|
1439
|
-
createdAt: Date.now(),
|
|
1440
|
-
unitDays: project.timeline.unitDays,
|
|
1441
|
-
...normalized,
|
|
1442
|
-
};
|
|
1563
|
+
job.message = '正在清除全部世界快照';
|
|
1443
1564
|
await mutateProject(root, projectId, (draft) => {
|
|
1444
|
-
|
|
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;
|
|
1565
|
+
draft.snapshots = [];
|
|
1449
1566
|
});
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1567
|
+
try {
|
|
1568
|
+
await rm(join(projectContentDir(project), '世界快照'), { recursive: true, force: true });
|
|
1569
|
+
} catch {
|
|
1570
|
+
// Nothing to remove.
|
|
1571
|
+
}
|
|
1572
|
+
// Run the rebuild inside this same job so the Client polls one thing.
|
|
1573
|
+
const cascade = registerSnapshot(ctx, root, registry, projectId, target);
|
|
1574
|
+
while (cascade.state === 'running') {
|
|
1575
|
+
job.message = cascade.message || '正在重建世界快照';
|
|
1576
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
1577
|
+
}
|
|
1578
|
+
if (cascade.state === 'error') throw httpError(502, cascade.error || '重建世界快照失败');
|
|
1579
|
+
return { target, made: (cascade.result && cascade.result.made) || [] };
|
|
1453
1580
|
});
|
|
1454
1581
|
}
|
|
1455
1582
|
|
|
@@ -1479,9 +1606,9 @@ function startGenerate(ctx, root, registry, body) {
|
|
|
1479
1606
|
await exportChapter(project, saved, content);
|
|
1480
1607
|
const window = chapterWindow(project, chapter);
|
|
1481
1608
|
let snapshotJobId = null;
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
snapshotJobId =
|
|
1609
|
+
const autoTarget = autoSnapshotTarget(project, window.end);
|
|
1610
|
+
if (autoTarget !== null) {
|
|
1611
|
+
snapshotJobId = registerSnapshot(ctx, root, registry, projectId, autoTarget).jobId;
|
|
1485
1612
|
}
|
|
1486
1613
|
return { chapter: saved, wordCount: wordCount(content), snapshotJobId };
|
|
1487
1614
|
});
|
|
@@ -1596,8 +1723,9 @@ function startCompose(ctx, root, registry, body) {
|
|
|
1596
1723
|
if (written) await exportChapter(after, written, content);
|
|
1597
1724
|
const window = chapterWindow(after, target);
|
|
1598
1725
|
let snapshotJobId = null;
|
|
1599
|
-
|
|
1600
|
-
|
|
1726
|
+
const autoTarget = autoSnapshotTarget(after, window.end);
|
|
1727
|
+
if (autoTarget !== null) {
|
|
1728
|
+
snapshotJobId = registerSnapshot(ctx, root, registry, projectId, autoTarget).jobId;
|
|
1601
1729
|
}
|
|
1602
1730
|
return {
|
|
1603
1731
|
chapterId,
|
|
@@ -1713,6 +1841,8 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
|
|
|
1713
1841
|
: lastChapterEnd(project);
|
|
1714
1842
|
return registerSnapshot(ctx, root, registry, projectId, day);
|
|
1715
1843
|
}
|
|
1844
|
+
case '/snapshot/reset':
|
|
1845
|
+
return startSnapshotReset(ctx, root, registry, body);
|
|
1716
1846
|
default:
|
|
1717
1847
|
throw httpError(404, `未知接口: ${path}`);
|
|
1718
1848
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-novel-generator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
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",
|