dsh-novel-generator 0.1.11 → 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 (2) hide show
  1. package/index.js +102 -40
  2. package/package.json +1 -1
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) {
@@ -1187,7 +1239,7 @@ async function bootstrap(ctx, root) {
1187
1239
  }
1188
1240
  }
1189
1241
  return {
1190
- revision: '2026-10-20.32',
1242
+ revision: '2026-10-20.35',
1191
1243
  projects,
1192
1244
  models: {
1193
1245
  default: modelCatalog.default || null,
@@ -1565,7 +1617,9 @@ function registerSnapshot(ctx, root, registry, projectId, targetDay) {
1565
1617
  const base = (project.snapshots || []).filter((snapshot) => snapshot.day < point).pop() || null;
1566
1618
  const chapters = chaptersForPoint(project, base ? base.day : 0, point);
1567
1619
  job.message = `正在生成第 ${point} 天的世界快照(第 ${made.length + 1}/${points.length} 份,目标第 ${target} 天)`;
1568
- 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
+ });
1569
1623
  const parsed = parseJsonReply(reply);
1570
1624
  if (!parsed) throw httpError(502, '模型没有返回可解析的快照 JSON');
1571
1625
  const normalized = normalizeSnapshot(parsed);
@@ -1665,7 +1719,9 @@ async function startGenerate(ctx, root, registry, body) {
1665
1719
  .filter((entry) => entry.chapterId !== chapter.chapterId && entry.startDay <= chapter.startDay)
1666
1720
  .sort((a, b) => b.startDay - a.startDay)[0] || null;
1667
1721
  job.message = '正在生成章节正文';
1668
- 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
+ });
1669
1725
  const saved = await mutateProject(root, projectId, (draft) => {
1670
1726
  const target = draft.chapters.find((entry) => entry.chapterId === chapterId);
1671
1727
  if (!target) throw httpError(404, `章节不存在: ${chapterId}`);
@@ -1753,7 +1809,9 @@ async function startCompose(ctx, root, registry, body) {
1753
1809
  assertSnapshotFresh(project, draft);
1754
1810
 
1755
1811
  job.message = '正在推演本章梗概与名称';
1756
- 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
+ });
1757
1815
  const deduced = parseJsonReply(deduceReply) || {};
1758
1816
  const synopsis = asText(firstOf(deduced, ['synopsis', '梗概', 'summary']));
1759
1817
  if (synopsis === '') throw httpError(502, '模型没有给出章节梗概');
@@ -1797,7 +1855,9 @@ async function startCompose(ctx, root, registry, body) {
1797
1855
  const fresh = await readProject(root, projectId);
1798
1856
  const target = fresh.chapters.find((entry) => entry.chapterId === chapterId);
1799
1857
  if (!target) throw httpError(500, '章节写入失败');
1800
- 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
+ });
1801
1861
 
1802
1862
  await mutateProject(root, projectId, (current) => {
1803
1863
  const entry = current.chapters.find((item) => item.chapterId === chapterId);
@@ -1845,7 +1905,9 @@ async function startDeduce(ctx, root, registry, body) {
1845
1905
  const project = await readProject(root, projectId);
1846
1906
  const chapter = chapterId === '' ? null : project.chapters.find((entry) => entry.chapterId === chapterId) || null;
1847
1907
  job.message = '正在推演下一章大纲';
1848
- 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
+ });
1849
1911
  const parsed = parseJsonReply(reply);
1850
1912
  if (!parsed) throw httpError(502, '模型没有返回可解析的大纲 JSON');
1851
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.11",
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",