dsh-novel-generator 0.1.10 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/index.js +110 -35
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ dsh plugin --profile web add dsh-novel-generator
|
|
|
36
36
|
|
|
37
37
|
- **重置**:时间线右上角的「重置快照」会清空全部快照,再按当前间隔从第 1 格逐格重建到故事末端(确认框里写明要生成几份)。
|
|
38
38
|
- **物品 / 事件**:快照里除了世界观、角色、势力、关系,还有物品(名称 + 下落/用途)和事件(故事天数 + 标题 + 一句话),事件按时间从新到旧排列。
|
|
39
|
-
-
|
|
39
|
+
- **生成依据(统一规则)**:先找本章之前最近的那份世界快照。若「本章起始日 − 快照落点 > 当前间隔」,判定快照过旧,直接拒绝并提示先去时间长河更新快照;否则自动推演用「最近快照 + 快照落点之后、本章之前各章梗概」,写正文在此基础上再加一段「待生成的本章梗概」,并在提示里区分已发生与待生成两部分。
|
|
40
40
|
|
|
41
41
|
## 数据与接口
|
|
42
42
|
|
package/index.js
CHANGED
|
@@ -939,66 +939,105 @@ function snapshotAt(project, day) {
|
|
|
939
939
|
return snapshots[snapshots.length - 1];
|
|
940
940
|
}
|
|
941
941
|
|
|
942
|
+
/**
|
|
943
|
+
* How far the nearest snapshot sits behind a chapter's start.
|
|
944
|
+
*
|
|
945
|
+
* The rule both prompts share: the nearest snapshot node at or before the
|
|
946
|
+
* chapter wins. If the chapter starts more than one interval after that node,
|
|
947
|
+
* the snapshot is too stale to write from and the Client is told to update it
|
|
948
|
+
* first — the same rule for 自动推演 and for writing the chapter body.
|
|
949
|
+
*/
|
|
950
|
+
function snapshotDistance(project, chapter) {
|
|
951
|
+
const span = chapterWindow(project, chapter);
|
|
952
|
+
const unit = Math.max(1, project.timeline.unitDays || 30);
|
|
953
|
+
const snapshot = snapshotAt(project, span.start) || null;
|
|
954
|
+
const base = snapshot ? snapshot.day : 0;
|
|
955
|
+
const gap = Math.max(0, span.start - base);
|
|
956
|
+
return { snapshot, unit, base, gap, tooFar: gap > unit, startDay: span.start };
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** Chapters from the snapshot node up to (not including) the target chapter. */
|
|
960
|
+
function stretchBefore(project, chapter, snapshot) {
|
|
961
|
+
const base = snapshot ? snapshot.day : 0;
|
|
962
|
+
return (project.chapters || [])
|
|
963
|
+
.filter((entry) => entry.chapterId !== chapter.chapterId
|
|
964
|
+
&& entry.startDay < chapter.startDay
|
|
965
|
+
&& chapterWindow(project, entry).end > base
|
|
966
|
+
&& ((entry.outline && entry.outline.synopsis) || '').trim() !== '')
|
|
967
|
+
.sort((a, b) => a.startDay - b.startDay);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Refuse to write from a snapshot that has drifted more than one interval
|
|
972
|
+
* behind the chapter, and say what the user has to do about it.
|
|
973
|
+
*/
|
|
974
|
+
function assertSnapshotFresh(project, chapter) {
|
|
975
|
+
const distance = snapshotDistance(project, chapter);
|
|
976
|
+
if (!distance.tooFar) return distance;
|
|
977
|
+
const from = distance.base > 0 ? `第 ${distance.base} 天` : '故事开篇(还没有任何快照)';
|
|
978
|
+
throw httpError(409, `最近的世界快照停在${from},本章从第 ${distance.startDay} 天开始,间隔 ${distance.gap} 天已超过当前快照间隔(${distance.unit} 天);请先在时间长河点「更新世界快照」再来生成`);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function stretchLines(project, chapter, snapshot) {
|
|
982
|
+
const lines = [];
|
|
983
|
+
const stretch = stretchBefore(project, chapter, snapshot);
|
|
984
|
+
if (stretch.length === 0) return lines;
|
|
985
|
+
lines.push(snapshot
|
|
986
|
+
? `【已发生的章节梗概 · 第 ${snapshot.day} 天之后到上一章】`
|
|
987
|
+
: '【已发生的章节梗概 · 开篇到上一章】');
|
|
988
|
+
for (const entry of stretch) {
|
|
989
|
+
lines.push(`- 第 ${entry.index + 1} 章 ${entry.title || ''}(第 ${entry.startDay}→${entry.startDay + entry.durationDays} 天):${entry.outline.synopsis}`);
|
|
990
|
+
}
|
|
991
|
+
lines.push('');
|
|
992
|
+
return lines;
|
|
993
|
+
}
|
|
994
|
+
|
|
942
995
|
function chapterPrompt(project, chapter, previous) {
|
|
943
996
|
const window = chapterWindow(project, chapter);
|
|
944
997
|
const outline = chapter.outline || {};
|
|
945
|
-
const snapshot =
|
|
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('');
|
|
@@ -1148,7 +1187,7 @@ async function bootstrap(ctx, root) {
|
|
|
1148
1187
|
}
|
|
1149
1188
|
}
|
|
1150
1189
|
return {
|
|
1151
|
-
revision: '2026-10-20.
|
|
1190
|
+
revision: '2026-10-20.32',
|
|
1152
1191
|
projects,
|
|
1153
1192
|
models: {
|
|
1154
1193
|
default: modelCatalog.default || null,
|
|
@@ -1606,14 +1645,22 @@ function startSnapshotReset(ctx, root, registry, body) {
|
|
|
1606
1645
|
});
|
|
1607
1646
|
}
|
|
1608
1647
|
|
|
1609
|
-
function startGenerate(ctx, root, registry, body) {
|
|
1648
|
+
async function startGenerate(ctx, root, registry, body) {
|
|
1610
1649
|
const projectId = asText(body.projectId);
|
|
1611
1650
|
const chapterId = asText(body.chapterId);
|
|
1651
|
+
// Refuse before the job is queued, so the Client sees a plain 409 instead of
|
|
1652
|
+
// a job that fails a second later.
|
|
1653
|
+
const preview = await readProject(root, projectId);
|
|
1654
|
+
const claimed = preview.chapters.find((entry) => entry.chapterId === chapterId);
|
|
1655
|
+
if (!claimed) throw httpError(404, `章节不存在: ${chapterId}`);
|
|
1656
|
+
if (!claimed.outline) throw httpError(409, '请先在章节大纲里保存设定,再生成正文');
|
|
1657
|
+
assertSnapshotFresh(preview, claimed);
|
|
1612
1658
|
return startJob(registry, { kind: 'generate', projectId, chapterId }, async (job) => {
|
|
1613
1659
|
const project = await readProject(root, projectId);
|
|
1614
1660
|
const chapter = project.chapters.find((entry) => entry.chapterId === chapterId);
|
|
1615
1661
|
if (!chapter) throw httpError(404, `章节不存在: ${chapterId}`);
|
|
1616
1662
|
if (!chapter.outline) throw httpError(409, '请先在章节大纲里保存设定,再生成正文');
|
|
1663
|
+
assertSnapshotFresh(project, chapter);
|
|
1617
1664
|
const previous = project.chapters
|
|
1618
1665
|
.filter((entry) => entry.chapterId !== chapter.chapterId && entry.startDay <= chapter.startDay)
|
|
1619
1666
|
.sort((a, b) => b.startDay - a.startDay)[0] || null;
|
|
@@ -1646,8 +1693,25 @@ function startGenerate(ctx, root, registry, body) {
|
|
|
1646
1693
|
* chapter in place (the outline page's 自动推演, which overwrites everything);
|
|
1647
1694
|
* without it the job appends a new one (the new-chapter dialog).
|
|
1648
1695
|
*/
|
|
1649
|
-
function startCompose(ctx, root, registry, body) {
|
|
1696
|
+
async function startCompose(ctx, root, registry, body) {
|
|
1650
1697
|
const projectId = asText(body.projectId);
|
|
1698
|
+
// Same early refusal as writing: a stale snapshot stops the job from being
|
|
1699
|
+
// queued at all.
|
|
1700
|
+
const preview = await readProject(root, projectId);
|
|
1701
|
+
const claimedId = asText(body.chapterId);
|
|
1702
|
+
const claimed = claimedId === ''
|
|
1703
|
+
? null
|
|
1704
|
+
: (preview.chapters || []).find((entry) => entry.chapterId === claimedId) || null;
|
|
1705
|
+
if (claimedId !== '' && !claimed) throw httpError(404, `章节不存在: ${claimedId}`);
|
|
1706
|
+
const claimedStart = claimed
|
|
1707
|
+
? claimed.startDay
|
|
1708
|
+
: (Number.isFinite(Number(body.startDay)) ? Math.max(0, Math.round(Number(body.startDay))) : lastChapterEnd(preview));
|
|
1709
|
+
assertSnapshotFresh(preview, claimed || {
|
|
1710
|
+
chapterId: '',
|
|
1711
|
+
startDay: claimedStart,
|
|
1712
|
+
durationDays: Math.round(Number(body.durationDays) || 0) || 1,
|
|
1713
|
+
index: (preview.chapters || []).length,
|
|
1714
|
+
});
|
|
1651
1715
|
return startJob(registry, { kind: 'compose', projectId }, async (job) => {
|
|
1652
1716
|
const minWords = Math.round(Number(body.minWords) || 0);
|
|
1653
1717
|
const maxWords = Math.round(Number(body.maxWords) || 0);
|
|
@@ -1686,6 +1750,7 @@ function startCompose(ctx, root, registry, body) {
|
|
|
1686
1750
|
outline: null,
|
|
1687
1751
|
};
|
|
1688
1752
|
const previous = previousChapter(project, draft);
|
|
1753
|
+
assertSnapshotFresh(project, draft);
|
|
1689
1754
|
|
|
1690
1755
|
job.message = '正在推演本章梗概与名称';
|
|
1691
1756
|
const deduceReply = await askAgent(ctx, project, deducePrompt(project, draft));
|
|
@@ -1763,9 +1828,19 @@ function startCompose(ctx, root, registry, body) {
|
|
|
1763
1828
|
});
|
|
1764
1829
|
}
|
|
1765
1830
|
|
|
1766
|
-
function startDeduce(ctx, root, registry, body) {
|
|
1831
|
+
async function startDeduce(ctx, root, registry, body) {
|
|
1767
1832
|
const projectId = asText(body.projectId);
|
|
1768
1833
|
const chapterId = asText(body.chapterId);
|
|
1834
|
+
// The outline page's 自动推演 follows the same freshness rule as writing, and
|
|
1835
|
+
// refuses before queuing as well.
|
|
1836
|
+
const preview = await readProject(root, projectId);
|
|
1837
|
+
const claimed = chapterId === '' ? null : preview.chapters.find((entry) => entry.chapterId === chapterId) || null;
|
|
1838
|
+
assertSnapshotFresh(preview, claimed || {
|
|
1839
|
+
chapterId: '',
|
|
1840
|
+
startDay: Number(preview.timeline.cursor) || lastChapterEnd(preview),
|
|
1841
|
+
durationDays: 0,
|
|
1842
|
+
index: (preview.chapters || []).length,
|
|
1843
|
+
});
|
|
1769
1844
|
return startJob(registry, { kind: 'deduce', projectId, chapterId }, async (job) => {
|
|
1770
1845
|
const project = await readProject(root, projectId);
|
|
1771
1846
|
const chapter = chapterId === '' ? null : project.chapters.find((entry) => entry.chapterId === chapterId) || null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-novel-generator",
|
|
3
|
-
"version": "0.1.
|
|
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",
|