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.
- package/README.md +1 -1
- package/client.js +59 -20
- package/index.js +151 -50
- 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
|
-
|
|
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
|
-
/**
|
|
1587
|
-
|
|
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
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
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
|
-
|
|
1598
|
-
notify(job.error || t('世界快照更新失败', "World snapshot update failed"), 'error');
|
|
1616
|
+
return;
|
|
1599
1617
|
}
|
|
1600
|
-
} catch (error) {
|
|
1601
1618
|
clearInterval(timer);
|
|
1602
|
-
|
|
1603
|
-
|
|
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(
|
|
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
|
-
|
|
1634
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
const
|
|
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
|
-
|
|
997
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
1510
|
-
for (;;) {
|
|
1563
|
+
for (const point of points) {
|
|
1511
1564
|
const project = await readProject(root, projectId);
|
|
1512
|
-
const
|
|
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.
|
|
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",
|