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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/index.js +211 -74
  3. 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) throw httpError(409, '这个作品还没有绑定会话');
843
- let result;
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 (error) {
847
- throw httpError(502, `无法恢复作品会话:${error instanceof Error ? error.message : String(error)}`);
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
- /** Send one prompt into the project's own Session and return the reply text. */
872
- async function askAgent(ctx, project, prompt) {
873
- const { controller, agent } = await resolveAgent(ctx, project);
874
- const admission = new AbortController();
875
- await withTimeout(controller.prompt({
876
- sessionId: project.sessionId,
877
- requestId: `novel-${randomUUID()}`,
878
- mode: 'queue',
879
- content: [{ type: 'text', text: prompt }],
880
- clientTimeZone: 'Asia/Shanghai',
881
- }, admission.signal), 60 * 1000, () => admission.abort());
882
- await withTimeout(agent.whenIdle(), AGENT_TIMEOUT_MS, () => {
883
- try {
884
- agent.cancel({ kind: 'hook', reason: 'novel-studio generation timeout' });
885
- } catch {
886
- // Cancellation is best effort on the failure path.
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 httpError(502, '模型没有返回任何正文');
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 = 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);
1050
+ const { snapshot } = snapshotDistance(project, chapter);
954
1051
  const lines = [];
955
1052
  lines.push('你现在是这部小说的执笔作者。只输出本章正文,不要解释、不要标题、不要分点提纲。');
956
1053
  lines.push('');
957
- lines.push(`【章节】第 ${chapter.index + 1} 章 ${chapter.title || ''}(${dateForDay(project, window.start)} 起,跨度 ${chapter.durationDays} 天)`);
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
- 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('');
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 day = chapter ? chapterWindow(project, chapter).start : (Number(project.timeline.cursor) || 0);
986
- const snapshot = snapshotAt(project, day);
987
- const chapters = project.chapters || [];
988
- const previous = chapters.length ? chapters[chapters.length - 1] : null;
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
- if (previous) {
997
- lines.push('【最近一章】');
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.28',
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.10",
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",