dsh-novel-generator 0.1.10 → 0.1.12
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 +211 -74
- 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
|
@@ -835,22 +835,59 @@ function normalizeSnapshot(raw) {
|
|
|
835
835
|
|
|
836
836
|
/* ------------------------------------------------------------ agent work */
|
|
837
837
|
|
|
838
|
+
/**
|
|
839
|
+
* Bind a fresh session to a work whose old one disappeared (deleted in the
|
|
840
|
+
* sidebar, or gone from the session store). The work's memory — snapshots,
|
|
841
|
+
* chapters, outlines — lives in files, so a new session loses only chat
|
|
842
|
+
* history, not the novel itself.
|
|
843
|
+
*/
|
|
844
|
+
async function rebindSession(ctx, root, project) {
|
|
845
|
+
const controller = ctx.get('sessionController');
|
|
846
|
+
if (controller === undefined) throw httpError(503, '当前部署缺少 sessionController 服务,无法为作品重建会话');
|
|
847
|
+
const created = await controller.create(project.agentPreset ? { cwd: project.cwd, agentPreset: project.agentPreset } : { cwd: project.cwd });
|
|
848
|
+
const sessionId = created && created.sessionId ? created.sessionId : null;
|
|
849
|
+
if (!sessionId) throw httpError(502, '没能为作品重建会话');
|
|
850
|
+
if (project.model) {
|
|
851
|
+
try {
|
|
852
|
+
await controller.selectModel({ sessionId, ...project.model });
|
|
853
|
+
} catch {
|
|
854
|
+
// The rebuilt session still works with the profile's default model.
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
project.sessionId = sessionId;
|
|
858
|
+
await mutateProject(root, project.projectId, (draft) => {
|
|
859
|
+
draft.sessionId = sessionId;
|
|
860
|
+
});
|
|
861
|
+
try {
|
|
862
|
+
await ensureWorkspace(ctx, project);
|
|
863
|
+
} catch {
|
|
864
|
+
// Grouping is cosmetic; the session works either way.
|
|
865
|
+
}
|
|
866
|
+
const result = await controller.resolveAgent(sessionId);
|
|
867
|
+
if (!result || !result.agent) throw httpError(502, '重建的会话无法启动');
|
|
868
|
+
return { controller, agent: result.agent };
|
|
869
|
+
}
|
|
870
|
+
|
|
838
871
|
/** Resolve (or resume) the ordinary Agent that owns one project's Session. */
|
|
839
|
-
async function resolveAgent(ctx, project) {
|
|
872
|
+
async function resolveAgent(ctx, root, project, onNote) {
|
|
840
873
|
const controller = ctx.get('sessionController');
|
|
841
874
|
if (controller === undefined) throw httpError(503, '当前部署缺少 sessionController 服务,无法驱动创作会话');
|
|
842
|
-
if (!project.sessionId)
|
|
843
|
-
|
|
875
|
+
if (!project.sessionId) {
|
|
876
|
+
const rebuilt = await rebindSession(ctx, root, project);
|
|
877
|
+
if (onNote) onNote('作品没有绑定会话,已新建一个会话');
|
|
878
|
+
return rebuilt;
|
|
879
|
+
}
|
|
880
|
+
let result = null;
|
|
844
881
|
try {
|
|
845
882
|
result = await controller.resolveAgent(project.sessionId);
|
|
846
|
-
} catch
|
|
847
|
-
|
|
848
|
-
}
|
|
849
|
-
if (!result || !result.agent) {
|
|
850
|
-
const code = result && result.error && result.error.code ? result.error.code : 'unknown';
|
|
851
|
-
throw httpError(502, `无法恢复作品会话(${code})`);
|
|
883
|
+
} catch {
|
|
884
|
+
result = null;
|
|
852
885
|
}
|
|
853
|
-
return { controller, agent: result.agent };
|
|
886
|
+
if (result && result.agent) return { controller, agent: result.agent };
|
|
887
|
+
// The old session is gone: rebuild instead of failing every later call.
|
|
888
|
+
const rebuilt = await rebindSession(ctx, root, project);
|
|
889
|
+
if (onNote) onNote('原作品会话已不存在,已自动新建一个会话继续');
|
|
890
|
+
return rebuilt;
|
|
854
891
|
}
|
|
855
892
|
|
|
856
893
|
function withTimeout(promise, ms, onTimeout) {
|
|
@@ -868,32 +905,47 @@ function withTimeout(promise, ms, onTimeout) {
|
|
|
868
905
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
869
906
|
}
|
|
870
907
|
|
|
871
|
-
/**
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
908
|
+
/**
|
|
909
|
+
* Send one prompt into the project's own Session and return the reply text.
|
|
910
|
+
* A freshly rebuilt session occasionally answers nothing on its first turn, so
|
|
911
|
+
* one silent reply earns a single retry before the job fails.
|
|
912
|
+
*/
|
|
913
|
+
async function askAgent(ctx, root, project, prompt, onNote) {
|
|
914
|
+
let rebuilt = false;
|
|
915
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
916
|
+
const before = project.sessionId;
|
|
917
|
+
const { controller, agent } = await resolveAgent(ctx, root, project, attempt === 0 ? onNote : undefined);
|
|
918
|
+
if (project.sessionId !== before) rebuilt = true;
|
|
919
|
+
const admission = new AbortController();
|
|
920
|
+
await withTimeout(controller.prompt({
|
|
921
|
+
sessionId: project.sessionId,
|
|
922
|
+
requestId: `novel-${randomUUID()}`,
|
|
923
|
+
mode: 'queue',
|
|
924
|
+
content: [{ type: 'text', text: prompt }],
|
|
925
|
+
clientTimeZone: 'Asia/Shanghai',
|
|
926
|
+
}, admission.signal), 60 * 1000, () => admission.abort());
|
|
927
|
+
await withTimeout(agent.whenIdle(), AGENT_TIMEOUT_MS, () => {
|
|
928
|
+
try {
|
|
929
|
+
agent.cancel({ kind: 'hook', reason: 'novel-studio generation timeout' });
|
|
930
|
+
} catch {
|
|
931
|
+
// Cancellation is best effort on the failure path.
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
const messages = agent.session.deriveMessages();
|
|
935
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
936
|
+
const message = messages[index];
|
|
937
|
+
if (!message || message.role !== 'assistant') continue;
|
|
938
|
+
const text = textOfMessage(message);
|
|
939
|
+
if (text !== '') return text;
|
|
940
|
+
}
|
|
941
|
+
if (attempt === 0) {
|
|
942
|
+
if (onNote) onNote('模型这一轮没有回复,重试一次');
|
|
943
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
887
944
|
}
|
|
888
|
-
});
|
|
889
|
-
const messages = agent.session.deriveMessages();
|
|
890
|
-
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
891
|
-
const message = messages[index];
|
|
892
|
-
if (!message || message.role !== 'assistant') continue;
|
|
893
|
-
const text = textOfMessage(message);
|
|
894
|
-
if (text !== '') return text;
|
|
895
945
|
}
|
|
896
|
-
throw
|
|
946
|
+
throw rebuilt
|
|
947
|
+
? httpError(502, '作品的旧会话已不存在,已为它新建一个会话,但模型没有回复;请在侧栏打开这部作品的会话(「小说工坊」分组里)后再试一次')
|
|
948
|
+
: httpError(502, '模型没有返回任何正文');
|
|
897
949
|
}
|
|
898
950
|
|
|
899
951
|
function snapshotBlock(snapshot) {
|
|
@@ -939,66 +991,105 @@ function snapshotAt(project, day) {
|
|
|
939
991
|
return snapshots[snapshots.length - 1];
|
|
940
992
|
}
|
|
941
993
|
|
|
994
|
+
/**
|
|
995
|
+
* How far the nearest snapshot sits behind a chapter's start.
|
|
996
|
+
*
|
|
997
|
+
* The rule both prompts share: the nearest snapshot node at or before the
|
|
998
|
+
* chapter wins. If the chapter starts more than one interval after that node,
|
|
999
|
+
* the snapshot is too stale to write from and the Client is told to update it
|
|
1000
|
+
* first — the same rule for 自动推演 and for writing the chapter body.
|
|
1001
|
+
*/
|
|
1002
|
+
function snapshotDistance(project, chapter) {
|
|
1003
|
+
const span = chapterWindow(project, chapter);
|
|
1004
|
+
const unit = Math.max(1, project.timeline.unitDays || 30);
|
|
1005
|
+
const snapshot = snapshotAt(project, span.start) || null;
|
|
1006
|
+
const base = snapshot ? snapshot.day : 0;
|
|
1007
|
+
const gap = Math.max(0, span.start - base);
|
|
1008
|
+
return { snapshot, unit, base, gap, tooFar: gap > unit, startDay: span.start };
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/** Chapters from the snapshot node up to (not including) the target chapter. */
|
|
1012
|
+
function stretchBefore(project, chapter, snapshot) {
|
|
1013
|
+
const base = snapshot ? snapshot.day : 0;
|
|
1014
|
+
return (project.chapters || [])
|
|
1015
|
+
.filter((entry) => entry.chapterId !== chapter.chapterId
|
|
1016
|
+
&& entry.startDay < chapter.startDay
|
|
1017
|
+
&& chapterWindow(project, entry).end > base
|
|
1018
|
+
&& ((entry.outline && entry.outline.synopsis) || '').trim() !== '')
|
|
1019
|
+
.sort((a, b) => a.startDay - b.startDay);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Refuse to write from a snapshot that has drifted more than one interval
|
|
1024
|
+
* behind the chapter, and say what the user has to do about it.
|
|
1025
|
+
*/
|
|
1026
|
+
function assertSnapshotFresh(project, chapter) {
|
|
1027
|
+
const distance = snapshotDistance(project, chapter);
|
|
1028
|
+
if (!distance.tooFar) return distance;
|
|
1029
|
+
const from = distance.base > 0 ? `第 ${distance.base} 天` : '故事开篇(还没有任何快照)';
|
|
1030
|
+
throw httpError(409, `最近的世界快照停在${from},本章从第 ${distance.startDay} 天开始,间隔 ${distance.gap} 天已超过当前快照间隔(${distance.unit} 天);请先在时间长河点「更新世界快照」再来生成`);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function stretchLines(project, chapter, snapshot) {
|
|
1034
|
+
const lines = [];
|
|
1035
|
+
const stretch = stretchBefore(project, chapter, snapshot);
|
|
1036
|
+
if (stretch.length === 0) return lines;
|
|
1037
|
+
lines.push(snapshot
|
|
1038
|
+
? `【已发生的章节梗概 · 第 ${snapshot.day} 天之后到上一章】`
|
|
1039
|
+
: '【已发生的章节梗概 · 开篇到上一章】');
|
|
1040
|
+
for (const entry of stretch) {
|
|
1041
|
+
lines.push(`- 第 ${entry.index + 1} 章 ${entry.title || ''}(第 ${entry.startDay}→${entry.startDay + entry.durationDays} 天):${entry.outline.synopsis}`);
|
|
1042
|
+
}
|
|
1043
|
+
lines.push('');
|
|
1044
|
+
return lines;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
942
1047
|
function chapterPrompt(project, chapter, previous) {
|
|
943
1048
|
const window = chapterWindow(project, chapter);
|
|
944
1049
|
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);
|
|
1050
|
+
const { snapshot } = snapshotDistance(project, chapter);
|
|
954
1051
|
const lines = [];
|
|
955
1052
|
lines.push('你现在是这部小说的执笔作者。只输出本章正文,不要解释、不要标题、不要分点提纲。');
|
|
956
1053
|
lines.push('');
|
|
957
|
-
lines.push(
|
|
1054
|
+
lines.push(`【本章】第 ${chapter.index + 1} 章 ${chapter.title || ''}(第 ${window.start}→${window.end} 天,跨度 ${chapter.durationDays} 天)`);
|
|
958
1055
|
lines.push('');
|
|
959
|
-
lines.push('
|
|
1056
|
+
lines.push('【最近的世界快照】');
|
|
960
1057
|
lines.push(snapshotBlock(snapshot));
|
|
961
1058
|
lines.push('');
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
lines.push(`- 第 ${entry.index + 1} 章 ${entry.title || ''}:${entry.outline.synopsis}`);
|
|
966
|
-
}
|
|
967
|
-
lines.push('');
|
|
1059
|
+
const stretch = stretchLines(project, chapter, snapshot);
|
|
1060
|
+
if (stretch.length) {
|
|
1061
|
+
lines.push(...stretch);
|
|
968
1062
|
} else if (previous) {
|
|
969
1063
|
lines.push('【上一章梗概】');
|
|
970
1064
|
lines.push(`${previous.title || ''}:${(previous.outline && previous.outline.synopsis) || '(无)'}`);
|
|
971
1065
|
lines.push('');
|
|
972
1066
|
}
|
|
973
|
-
lines.push('
|
|
1067
|
+
lines.push('【本章梗概 · 待生成的部分】');
|
|
974
1068
|
lines.push(outline.synopsis || '(无)');
|
|
975
1069
|
lines.push('');
|
|
976
1070
|
lines.push('【要求】');
|
|
1071
|
+
lines.push('- 以上"已发生的章节梗概"是既定事实,不得改写;本章正文要接着它往下写。');
|
|
1072
|
+
lines.push('- 按"本章梗概"把这一章的内容写出来,情节推进、场景与对话要完整。');
|
|
977
1073
|
lines.push('- 不要调用任何工具,不要反问,不要输出计划或步骤说明,直接给出最终正文。');
|
|
978
1074
|
lines.push(`- 字数控制在 ${outline.minWords || 800} 到 ${outline.maxWords || 3000} 字之间。`);
|
|
979
1075
|
lines.push('- 与世界观、角色、势力、物品、事件和人物关系保持一致,不要引入与快照冲突的设定。');
|
|
980
|
-
lines.push('- 有完整的情节推进、场景描写和对话,直接成文。');
|
|
981
1076
|
return lines.join('\n');
|
|
982
1077
|
}
|
|
983
1078
|
|
|
984
1079
|
function deducePrompt(project, chapter) {
|
|
985
|
-
const
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
const
|
|
1080
|
+
const target = chapter
|
|
1081
|
+
? chapter
|
|
1082
|
+
: { chapterId: '', startDay: Number(project.timeline.cursor) || 0, durationDays: 0, index: (project.chapters || []).length };
|
|
1083
|
+
const { snapshot } = snapshotDistance(project, target);
|
|
989
1084
|
const lines = [];
|
|
990
|
-
lines.push('
|
|
1085
|
+
lines.push('你是这部小说的剧情推演器。请依据最近的世界快照,以及从该快照之后已经写过的各章梗概,推演下一章的大纲。');
|
|
991
1086
|
lines.push('只输出一个 JSON 对象,不要任何额外文字。');
|
|
992
1087
|
lines.push('');
|
|
993
|
-
lines.push('
|
|
1088
|
+
lines.push('【最近的世界快照】');
|
|
994
1089
|
lines.push(snapshotBlock(snapshot));
|
|
995
1090
|
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
|
-
}
|
|
1091
|
+
const stretch = stretchLines(project, target, snapshot);
|
|
1092
|
+
if (stretch.length) lines.push(...stretch);
|
|
1002
1093
|
lines.push('【输出 JSON 结构】');
|
|
1003
1094
|
lines.push('{"synopsis": "本章梗概,300 字以内,包含起因、经过、转折", "durationDays": 30, "minWords": 1200, "maxWords": 3500, "title": "本章标题"}');
|
|
1004
1095
|
lines.push('');
|
|
@@ -1148,7 +1239,7 @@ async function bootstrap(ctx, root) {
|
|
|
1148
1239
|
}
|
|
1149
1240
|
}
|
|
1150
1241
|
return {
|
|
1151
|
-
revision: '2026-10-20.
|
|
1242
|
+
revision: '2026-10-20.35',
|
|
1152
1243
|
projects,
|
|
1153
1244
|
models: {
|
|
1154
1245
|
default: modelCatalog.default || null,
|
|
@@ -1526,7 +1617,9 @@ function registerSnapshot(ctx, root, registry, projectId, targetDay) {
|
|
|
1526
1617
|
const base = (project.snapshots || []).filter((snapshot) => snapshot.day < point).pop() || null;
|
|
1527
1618
|
const chapters = chaptersForPoint(project, base ? base.day : 0, point);
|
|
1528
1619
|
job.message = `正在生成第 ${point} 天的世界快照(第 ${made.length + 1}/${points.length} 份,目标第 ${target} 天)`;
|
|
1529
|
-
const reply = await askAgent(ctx, project, snapshotPrompt(project, base, chapters, point))
|
|
1620
|
+
const reply = await askAgent(ctx, root, project, snapshotPrompt(project, base, chapters, point), (note) => {
|
|
1621
|
+
job.message = note;
|
|
1622
|
+
});
|
|
1530
1623
|
const parsed = parseJsonReply(reply);
|
|
1531
1624
|
if (!parsed) throw httpError(502, '模型没有返回可解析的快照 JSON');
|
|
1532
1625
|
const normalized = normalizeSnapshot(parsed);
|
|
@@ -1606,19 +1699,29 @@ function startSnapshotReset(ctx, root, registry, body) {
|
|
|
1606
1699
|
});
|
|
1607
1700
|
}
|
|
1608
1701
|
|
|
1609
|
-
function startGenerate(ctx, root, registry, body) {
|
|
1702
|
+
async function startGenerate(ctx, root, registry, body) {
|
|
1610
1703
|
const projectId = asText(body.projectId);
|
|
1611
1704
|
const chapterId = asText(body.chapterId);
|
|
1705
|
+
// Refuse before the job is queued, so the Client sees a plain 409 instead of
|
|
1706
|
+
// a job that fails a second later.
|
|
1707
|
+
const preview = await readProject(root, projectId);
|
|
1708
|
+
const claimed = preview.chapters.find((entry) => entry.chapterId === chapterId);
|
|
1709
|
+
if (!claimed) throw httpError(404, `章节不存在: ${chapterId}`);
|
|
1710
|
+
if (!claimed.outline) throw httpError(409, '请先在章节大纲里保存设定,再生成正文');
|
|
1711
|
+
assertSnapshotFresh(preview, claimed);
|
|
1612
1712
|
return startJob(registry, { kind: 'generate', projectId, chapterId }, async (job) => {
|
|
1613
1713
|
const project = await readProject(root, projectId);
|
|
1614
1714
|
const chapter = project.chapters.find((entry) => entry.chapterId === chapterId);
|
|
1615
1715
|
if (!chapter) throw httpError(404, `章节不存在: ${chapterId}`);
|
|
1616
1716
|
if (!chapter.outline) throw httpError(409, '请先在章节大纲里保存设定,再生成正文');
|
|
1717
|
+
assertSnapshotFresh(project, chapter);
|
|
1617
1718
|
const previous = project.chapters
|
|
1618
1719
|
.filter((entry) => entry.chapterId !== chapter.chapterId && entry.startDay <= chapter.startDay)
|
|
1619
1720
|
.sort((a, b) => b.startDay - a.startDay)[0] || null;
|
|
1620
1721
|
job.message = '正在生成章节正文';
|
|
1621
|
-
const content = await askAgent(ctx, project, chapterPrompt(project, chapter, previous))
|
|
1722
|
+
const content = await askAgent(ctx, root, project, chapterPrompt(project, chapter, previous), (note) => {
|
|
1723
|
+
job.message = note;
|
|
1724
|
+
});
|
|
1622
1725
|
const saved = await mutateProject(root, projectId, (draft) => {
|
|
1623
1726
|
const target = draft.chapters.find((entry) => entry.chapterId === chapterId);
|
|
1624
1727
|
if (!target) throw httpError(404, `章节不存在: ${chapterId}`);
|
|
@@ -1646,8 +1749,25 @@ function startGenerate(ctx, root, registry, body) {
|
|
|
1646
1749
|
* chapter in place (the outline page's 自动推演, which overwrites everything);
|
|
1647
1750
|
* without it the job appends a new one (the new-chapter dialog).
|
|
1648
1751
|
*/
|
|
1649
|
-
function startCompose(ctx, root, registry, body) {
|
|
1752
|
+
async function startCompose(ctx, root, registry, body) {
|
|
1650
1753
|
const projectId = asText(body.projectId);
|
|
1754
|
+
// Same early refusal as writing: a stale snapshot stops the job from being
|
|
1755
|
+
// queued at all.
|
|
1756
|
+
const preview = await readProject(root, projectId);
|
|
1757
|
+
const claimedId = asText(body.chapterId);
|
|
1758
|
+
const claimed = claimedId === ''
|
|
1759
|
+
? null
|
|
1760
|
+
: (preview.chapters || []).find((entry) => entry.chapterId === claimedId) || null;
|
|
1761
|
+
if (claimedId !== '' && !claimed) throw httpError(404, `章节不存在: ${claimedId}`);
|
|
1762
|
+
const claimedStart = claimed
|
|
1763
|
+
? claimed.startDay
|
|
1764
|
+
: (Number.isFinite(Number(body.startDay)) ? Math.max(0, Math.round(Number(body.startDay))) : lastChapterEnd(preview));
|
|
1765
|
+
assertSnapshotFresh(preview, claimed || {
|
|
1766
|
+
chapterId: '',
|
|
1767
|
+
startDay: claimedStart,
|
|
1768
|
+
durationDays: Math.round(Number(body.durationDays) || 0) || 1,
|
|
1769
|
+
index: (preview.chapters || []).length,
|
|
1770
|
+
});
|
|
1651
1771
|
return startJob(registry, { kind: 'compose', projectId }, async (job) => {
|
|
1652
1772
|
const minWords = Math.round(Number(body.minWords) || 0);
|
|
1653
1773
|
const maxWords = Math.round(Number(body.maxWords) || 0);
|
|
@@ -1686,9 +1806,12 @@ function startCompose(ctx, root, registry, body) {
|
|
|
1686
1806
|
outline: null,
|
|
1687
1807
|
};
|
|
1688
1808
|
const previous = previousChapter(project, draft);
|
|
1809
|
+
assertSnapshotFresh(project, draft);
|
|
1689
1810
|
|
|
1690
1811
|
job.message = '正在推演本章梗概与名称';
|
|
1691
|
-
const deduceReply = await askAgent(ctx, project, deducePrompt(project, draft))
|
|
1812
|
+
const deduceReply = await askAgent(ctx, root, project, deducePrompt(project, draft), (note) => {
|
|
1813
|
+
job.message = note;
|
|
1814
|
+
});
|
|
1692
1815
|
const deduced = parseJsonReply(deduceReply) || {};
|
|
1693
1816
|
const synopsis = asText(firstOf(deduced, ['synopsis', '梗概', 'summary']));
|
|
1694
1817
|
if (synopsis === '') throw httpError(502, '模型没有给出章节梗概');
|
|
@@ -1732,7 +1855,9 @@ function startCompose(ctx, root, registry, body) {
|
|
|
1732
1855
|
const fresh = await readProject(root, projectId);
|
|
1733
1856
|
const target = fresh.chapters.find((entry) => entry.chapterId === chapterId);
|
|
1734
1857
|
if (!target) throw httpError(500, '章节写入失败');
|
|
1735
|
-
const content = await askAgent(ctx, fresh, chapterPrompt(fresh, target, previous))
|
|
1858
|
+
const content = await askAgent(ctx, root, fresh, chapterPrompt(fresh, target, previous), (note) => {
|
|
1859
|
+
job.message = note;
|
|
1860
|
+
});
|
|
1736
1861
|
|
|
1737
1862
|
await mutateProject(root, projectId, (current) => {
|
|
1738
1863
|
const entry = current.chapters.find((item) => item.chapterId === chapterId);
|
|
@@ -1763,14 +1888,26 @@ function startCompose(ctx, root, registry, body) {
|
|
|
1763
1888
|
});
|
|
1764
1889
|
}
|
|
1765
1890
|
|
|
1766
|
-
function startDeduce(ctx, root, registry, body) {
|
|
1891
|
+
async function startDeduce(ctx, root, registry, body) {
|
|
1767
1892
|
const projectId = asText(body.projectId);
|
|
1768
1893
|
const chapterId = asText(body.chapterId);
|
|
1894
|
+
// The outline page's 自动推演 follows the same freshness rule as writing, and
|
|
1895
|
+
// refuses before queuing as well.
|
|
1896
|
+
const preview = await readProject(root, projectId);
|
|
1897
|
+
const claimed = chapterId === '' ? null : preview.chapters.find((entry) => entry.chapterId === chapterId) || null;
|
|
1898
|
+
assertSnapshotFresh(preview, claimed || {
|
|
1899
|
+
chapterId: '',
|
|
1900
|
+
startDay: Number(preview.timeline.cursor) || lastChapterEnd(preview),
|
|
1901
|
+
durationDays: 0,
|
|
1902
|
+
index: (preview.chapters || []).length,
|
|
1903
|
+
});
|
|
1769
1904
|
return startJob(registry, { kind: 'deduce', projectId, chapterId }, async (job) => {
|
|
1770
1905
|
const project = await readProject(root, projectId);
|
|
1771
1906
|
const chapter = chapterId === '' ? null : project.chapters.find((entry) => entry.chapterId === chapterId) || null;
|
|
1772
1907
|
job.message = '正在推演下一章大纲';
|
|
1773
|
-
const reply = await askAgent(ctx, project, deducePrompt(project, chapter))
|
|
1908
|
+
const reply = await askAgent(ctx, root, project, deducePrompt(project, chapter), (note) => {
|
|
1909
|
+
job.message = note;
|
|
1910
|
+
});
|
|
1774
1911
|
const parsed = parseJsonReply(reply);
|
|
1775
1912
|
if (!parsed) throw httpError(502, '模型没有返回可解析的大纲 JSON');
|
|
1776
1913
|
const synopsis = asText(firstOf(parsed, ['synopsis', '梗概', 'summary']));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-novel-generator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
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",
|