dsh-novel-generator 0.1.32 → 0.1.34

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 (4) hide show
  1. package/README.md +11 -3
  2. package/client.js +117 -66
  3. package/index.js +93 -28
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -83,9 +83,17 @@ dsh plugin --profile web add dsh-novel-generator
83
83
 
84
84
  ## 章节故事页
85
85
 
86
- - 正文可以直接编辑、保存;工具条上还有「AI 修改」:填入修改意见后,模型按意见重写整章。
87
- - 正文为空时按钮不可点,接口也会拒绝(400);修改意见为空同样拒绝。
88
- - **重写结果只放进编辑区、不自动保存**,状态变成「未保存」,你确认后再点「保存」——避免 AI 的改动直接覆盖原稿。
86
+ - 正文可以直接编辑、保存;工具条上还有两个按钮:
87
+ - **「生成故事梗概」**:确认后把当前正文交给模型,生成一段新梗概并**直接保存、覆盖原有梗概**(正文不动)。
88
+ - **「AI 修改」**:填入修改意见后,模型按意见重写整章,结果只放进编辑区、不自动保存。
89
+ - 正文为空时两个按钮都不可点,接口也会拒绝(400);修改意见为空同样拒绝。
90
+
91
+ ## 章节大纲页
92
+
93
+ - **「AI 修改梗概」**:与章节故事页的「AI 修改」区分——它只改**故事梗概**,依据是「原梗概 + 你的修改意见」,不动正文和章节名。结果填回梗概框并标记未保存,确认后点「保存」。
94
+ - 梗概为空或意见为空时按钮不可点,接口也会 400。
95
+
96
+ > 刻意的差别:「生成故事梗概」会覆盖保存(读的是你已经写定的正文),「AI 修改梗概」只改草稿(它改的正是你用来生成正文的依据,值得先看一眼)。
89
97
 
90
98
  ## 「小说工坊 · 作者」预设(随主插件一起装)
91
99
 
package/client.js CHANGED
@@ -1299,9 +1299,9 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1299
1299
  disabled: saving || Boolean(summarizing) || String(text || '').trim() === '',
1300
1300
  title: String(text || '').trim() === ''
1301
1301
  ? t('这一章还没有正文,先写或先生成', 'No text yet — write or generate the chapter first')
1302
- : t('用本章正文重新生成故事梗概,会覆盖现有梗概', 'Recreate the synopsis from this chapter’s text, replacing the current one'),
1302
+ : t('用本章正文重写梗概,并同步时间长度、字数范围与梗概下限(覆盖旧大纲)', 'Rewrite the synopsis from this chapter and re-measure its duration, word bounds and synopsis floor, replacing the old outline'),
1303
1303
  onClick: onSummarize,
1304
- }, summarizing ? t('生成梗概中…', 'Summarizing…') : t('生成故事梗概', 'Synopsis from text')),
1304
+ }, summarizing ? t('同步大纲中…', 'Syncing…') : t('同步章节大纲', 'Sync outline')),
1305
1305
  h('button', {
1306
1306
  className: 'dsn-btn',
1307
1307
  type: 'button',
@@ -1357,7 +1357,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1357
1357
  }, busy ? t('改写中…', 'Revising…') : t('确认修改', 'Revise')))));
1358
1358
  }
1359
1359
 
1360
- function OutlinePage({ detail, chapter, isFirstChapter, onBack, onSaved, notify, onRegisterGuard, requestConfirm, onBusyChange }) {
1360
+ function OutlinePage({ detail, chapter, isFirstChapter, onBack, onSaved, notify, onRegisterGuard, requestConfirm, onBusyChange, onReviseSynopsis, synopsisDraft, onSynopsisDraftApplied, revisingSynopsis }) {
1361
1361
  const buildForm = useCallback(() => ({
1362
1362
  title: chapter ? chapter.title || '' : '',
1363
1363
  durationDays: chapter && chapter.outline ? chapter.outline.durationDays : (detail.timeline.unitDays || 30),
@@ -1460,34 +1460,18 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1460
1460
 
1461
1461
  const generating = job && job.state === 'running' && job.kind === 'generate';
1462
1462
  const composing = job && job.state === 'running' && job.kind === 'compose';
1463
- const revisingSynopsis = job && job.state === 'running' && job.kind === 'synopsis-revise';
1464
1463
 
1465
- /** AI 修改故事梗概: rewrite the synopsis from a note, keep it as a draft. */
1466
- const [synopsisReviseOpen, setSynopsisReviseOpen] = useState(false);
1467
- const [synopsisReviseError, setSynopsisReviseError] = useState('');
1468
- const startSynopsisRevise = useCallback(async (instruction) => {
1469
- if (!chapter) return;
1470
- if (String(form.synopsis || '').trim() === '') {
1471
- setSynopsisReviseError(t('还没有故事梗概可改,先写一段再来', 'There is no synopsis yet — write one first.'));
1472
- return;
1473
- }
1474
- if (String(instruction || '').trim() === '') {
1475
- setSynopsisReviseError(t('请先写清修改意见', 'Write what should change first.'));
1476
- return;
1477
- }
1478
- setSynopsisReviseError('');
1479
- try {
1480
- const started = await api('/outline/revise', {
1481
- projectId: detail.projectId,
1482
- chapterId: chapter.chapterId,
1483
- synopsis: form.synopsis,
1484
- instruction: String(instruction).trim(),
1485
- });
1486
- setJob(started);
1487
- } catch (error) {
1488
- setSynopsisReviseError(messageOf(error));
1489
- }
1490
- }, [detail.projectId, chapter, form.synopsis, setJob]);
1464
+ /**
1465
+ * A rewritten synopsis arrives from the workspace (which owns the dialog,
1466
+ * because the workspace stage sits outside this page's transformed track
1467
+ * and a dialog rendered in here would be drawn off-screen).
1468
+ */
1469
+ useEffect(() => {
1470
+ if (typeof synopsisDraft !== 'string' || synopsisDraft === '') return;
1471
+ setForm((current) => ({ ...current, synopsis: synopsisDraft }));
1472
+ setDirty(true);
1473
+ if (typeof onSynopsisDraftApplied === 'function') onSynopsisDraftApplied();
1474
+ }, [synopsisDraft]);
1491
1475
 
1492
1476
  /** 自动推演: deduce synopsis, title and the whole chapter, then save. */
1493
1477
  const deduce = useCallback(() => {
@@ -1560,17 +1544,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1560
1544
  notify(t('已重新推演并保存章节', "Re-deduced and saved the chapter"), 'success');
1561
1545
  onSaved(chapter ? chapter.chapterId : null);
1562
1546
  }
1563
- if (job.kind === 'synopsis-revise' && job.state === 'done') {
1564
- const result = job.result || {};
1565
- if (typeof result.synopsis === 'string' && result.synopsis.trim() !== '') {
1566
- setForm((current) => ({ ...current, synopsis: result.synopsis }));
1567
- setDirty(true);
1568
- }
1569
- setSynopsisReviseOpen(false);
1570
- notify(t('AI 已改写故事梗概,确认后点「保存」', 'AI rewrote the synopsis — review it, then press Save'), 'success');
1571
- }
1572
1547
  if (job.state === 'error') {
1573
- if (job.kind === 'synopsis-revise') setSynopsisReviseError(job.error || t('改写失败', 'Revision failed'));
1574
1548
  notify(job.error || t('任务失败', "Task failed"), 'error');
1575
1549
  }
1576
1550
  }, [job ? job.jobId : null, job ? job.state : null]);
@@ -1589,8 +1563,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1589
1563
  ? t('还没有故事梗概可改', 'There is no synopsis to revise yet')
1590
1564
  : t('让 AI 按修改意见改写「故事梗概」(不动正文)', 'Let AI rewrite the synopsis from your notes (the chapter text stays)'),
1591
1565
  onClick: () => {
1592
- setSynopsisReviseError('');
1593
- setSynopsisReviseOpen(true);
1566
+ if (typeof onReviseSynopsis === 'function') onReviseSynopsis(form.synopsis);
1594
1567
  },
1595
1568
  }, t('AI 修改梗概', 'AI revise synopsis')),
1596
1569
  h('button', {
@@ -1685,20 +1658,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1685
1658
  type: 'button',
1686
1659
  disabled: !savedAt || Boolean(generating || composing) || !chapter,
1687
1660
  onClick: generate,
1688
- }, generating ? t('生成中…', "Generating…") : t('生成章节故事', "Generate chapter"))),
1689
- synopsisReviseOpen
1690
- ? h(SynopsisReviseDialog, {
1691
- chapter,
1692
- synopsis: form.synopsis,
1693
- busy: Boolean(revisingSynopsis),
1694
- error: synopsisReviseError,
1695
- onCancel: () => {
1696
- if (revisingSynopsis) return;
1697
- setSynopsisReviseOpen(false);
1698
- },
1699
- onConfirm: startSynopsisRevise,
1700
- })
1701
- : null);
1661
+ }, generating ? t('生成中…', "Generating…") : t('生成章节故事', "Generate chapter"))));
1702
1662
  }
1703
1663
 
1704
1664
  /* --------------------------------------------------------- new chapter */
@@ -1933,6 +1893,13 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1933
1893
  const [reviseError, setReviseError] = useState('');
1934
1894
  const [reviseJob, setReviseJob] = useJob();
1935
1895
  const [summarizeJob, setSummarizeJob] = useJob();
1896
+ // The synopsis rewrite lives here, not in OutlinePage: the page renders
1897
+ // inside the translated track, where an absolutely positioned dialog would
1898
+ // be painted off-screen.
1899
+ const [synopsisTarget, setSynopsisTarget] = useState(null);
1900
+ const [synopsisError, setSynopsisError] = useState('');
1901
+ const [synopsisDraft, setSynopsisDraft] = useState(null);
1902
+ const [synopsisJob, setSynopsisJob] = useJob();
1936
1903
  const [text, setText] = useState('');
1937
1904
  const [dirty, setDirty] = useState(false);
1938
1905
  const [saving, setSaving] = useState(false);
@@ -2006,19 +1973,19 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2006
1973
  }, [chapter, detail.projectId, text, setReviseJob]);
2007
1974
 
2008
1975
  /** 生成故事梗概: read the prose and overwrite the stored synopsis. */
2009
- const summarizeChapter = useCallback(() => {
1976
+ const syncOutline = useCallback(() => {
2010
1977
  if (!chapter) return;
2011
1978
  if (String(text || '').trim() === '') {
2012
- notify(t('这一章还没有正文,先写或先生成再生成梗概', 'This chapter has no text yet — write or generate it first.'), 'error');
1979
+ notify(t('这一章还没有正文,先写或先生成再同步大纲', 'This chapter has no text yet — write or generate it first.'), 'error');
2013
1980
  return;
2014
1981
  }
2015
1982
  setConfirmRequest({
2016
- title: t('是否以当前章节故事生成故事梗概?', 'Create the synopsis from this chapter’s text?'),
2017
- text: t('会用当前正文生成一段新的故事梗概,并「直接保存、覆盖原有梗概」。正文不会被修改。', 'A new synopsis is generated from the text and saved immediately, replacing the existing one. The chapter text itself is untouched.'),
2018
- confirmLabel: t('生成并覆盖', 'Generate and replace'),
1983
+ title: t('是否用当前章节故事同步章节大纲?', 'Sync the outline from this chapter’s text?'),
1984
+ text: t('会用当前正文重写「故事梗概」,并同步「故事时间长度」「最低字数」「最多字数」「梗概最低字数」四项,然后直接保存覆盖旧大纲。正文不会被修改。', 'The synopsis is rewritten from the text, and the story duration, minimum words, maximum words and synopsis floor are re-measured from it. The outline is saved immediately, replacing the old one; the chapter text is untouched.'),
1985
+ confirmLabel: t('同步并覆盖', 'Sync and replace'),
2019
1986
  action: async () => {
2020
1987
  try {
2021
- const started = await api('/chapter/summarize', {
1988
+ const started = await api('/chapter/sync-outline', {
2022
1989
  projectId: detail.projectId,
2023
1990
  chapterId: chapter.chapterId,
2024
1991
  content: text,
@@ -2036,22 +2003,85 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2036
2003
  if (!summarizeJob) return undefined;
2037
2004
  if (summarizeJob.state === 'running') {
2038
2005
  setChildBusy({
2039
- label: t('正在用正文生成故事梗概…', 'Building the synopsis from the text…'),
2006
+ label: t('正在用正文同步章节大纲…', 'Syncing the outline from the text…'),
2040
2007
  message: summarizeJob.message || '',
2041
2008
  });
2042
2009
  return undefined;
2043
2010
  }
2044
2011
  setChildBusy(null);
2045
2012
  if (summarizeJob.state === 'done') {
2046
- notify(t('已用正文生成并保存故事梗概', 'Synopsis generated from the text and saved'), 'success');
2013
+ const result = summarizeJob.result || {};
2014
+ const notes = Array.isArray(result.notes) ? result.notes.filter(Boolean) : [];
2015
+ notify(
2016
+ t('已同步大纲:梗概 {chars} 字 · 时间 {days} 天 · 字数 {min}–{max} · 梗概下限 {floor}',
2017
+ 'Outline synced: {chars} characters of synopsis · {days} days · {min}–{max} words · synopsis floor {floor}',
2018
+ {
2019
+ chars: result.synopsis ? result.synopsis.length : 0,
2020
+ days: result.durationDays || 0,
2021
+ min: result.minWords || 0,
2022
+ max: result.maxWords || 0,
2023
+ floor: result.synopsisMinChars || 0,
2024
+ }),
2025
+ 'success',
2026
+ );
2027
+ for (const note of notes) notify(note, 'error');
2047
2028
  onReload();
2048
2029
  } else if (summarizeJob.state === 'error') {
2049
- notify(summarizeJob.error || t('生成梗概失败', 'Building the synopsis failed'), 'error');
2030
+ notify(summarizeJob.error || t('同步大纲失败', 'Syncing the outline failed'), 'error');
2050
2031
  }
2051
2032
  return undefined;
2052
2033
  }, [summarizeJob ? summarizeJob.jobId : null, summarizeJob ? summarizeJob.state : null, summarizeJob ? summarizeJob.message : null]);
2053
2034
 
2054
2035
  const revising = Boolean(reviseJob && reviseJob.state === 'running');
2036
+
2037
+ /** Run the synopsis rewrite for the chapter the outline page handed up. */
2038
+ const sendSynopsisRevise = useCallback(async (instruction) => {
2039
+ const target = synopsisTarget;
2040
+ if (!target || !target.chapter) return;
2041
+ if (String(target.synopsis || '').trim() === '') {
2042
+ setSynopsisError(t('还没有故事梗概可改,先写一段再来', 'There is no synopsis yet — write one first.'));
2043
+ return;
2044
+ }
2045
+ if (String(instruction || '').trim() === '') {
2046
+ setSynopsisError(t('请先写清修改意见', 'Write what should change first.'));
2047
+ return;
2048
+ }
2049
+ setSynopsisError('');
2050
+ try {
2051
+ const started = await api('/outline/revise', {
2052
+ projectId: detail.projectId,
2053
+ chapterId: target.chapter.chapterId,
2054
+ synopsis: target.synopsis,
2055
+ instruction: String(instruction).trim(),
2056
+ });
2057
+ setSynopsisJob(started);
2058
+ } catch (error) {
2059
+ setSynopsisError(messageOf(error));
2060
+ }
2061
+ }, [synopsisTarget, detail.projectId, notify, setSynopsisJob]);
2062
+
2063
+ const revisingSynopsis = Boolean(synopsisJob && synopsisJob.state === 'running');
2064
+ useEffect(() => {
2065
+ if (!synopsisJob) return undefined;
2066
+ if (synopsisJob.state === 'running') {
2067
+ setChildBusy({
2068
+ label: t('正在按意见改写故事梗概…', 'Rewriting the synopsis…'),
2069
+ message: synopsisJob.message || '',
2070
+ });
2071
+ return undefined;
2072
+ }
2073
+ setChildBusy(null);
2074
+ if (synopsisJob.state === 'done') {
2075
+ const text = (synopsisJob.result || {}).synopsis;
2076
+ if (typeof text === 'string' && text.trim() !== '') setSynopsisDraft(text);
2077
+ setSynopsisTarget(null);
2078
+ notify(t('AI 已改写故事梗概,确认后点「保存」', 'AI rewrote the synopsis — review it, then press Save'), 'success');
2079
+ } else if (synopsisJob.state === 'error') {
2080
+ setSynopsisError(synopsisJob.error || t('改写失败', 'Revision failed'));
2081
+ }
2082
+ return undefined;
2083
+ }, [synopsisJob ? synopsisJob.jobId : null, synopsisJob ? synopsisJob.state : null, synopsisJob ? synopsisJob.message : null]);
2084
+
2055
2085
  useEffect(() => {
2056
2086
  if (!reviseJob) return undefined;
2057
2087
  if (reviseJob.state === 'running') {
@@ -2267,7 +2297,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2267
2297
  setReviseError('');
2268
2298
  setReviseOpen(true);
2269
2299
  },
2270
- onSummarize: summarizeChapter,
2300
+ onSummarize: syncOutline,
2271
2301
  summarizing: Boolean(summarizeJob && summarizeJob.state === 'running'),
2272
2302
  })),
2273
2303
  h('section', { className: 'dsn-page' },
@@ -2283,6 +2313,13 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2283
2313
  onRegisterGuard: registerOutlineGuard,
2284
2314
  requestConfirm: setConfirmRequest,
2285
2315
  onBusyChange: setChildBusy,
2316
+ onReviseSynopsis: (synopsis) => {
2317
+ setSynopsisError('');
2318
+ setSynopsisTarget({ chapter, synopsis });
2319
+ },
2320
+ synopsisDraft,
2321
+ onSynopsisDraftApplied: () => setSynopsisDraft(null),
2322
+ revisingSynopsis,
2286
2323
  isFirstChapter: Boolean(chapter && chapters.length > 0 && chapter.chapterId === chapters[0].chapterId),
2287
2324
  }))),
2288
2325
  confirmRequest
@@ -2298,6 +2335,20 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2298
2335
  },
2299
2336
  })
2300
2337
  : null,
2338
+ synopsisTarget
2339
+ ? h(SynopsisReviseDialog, {
2340
+ chapter: synopsisTarget.chapter,
2341
+ synopsis: synopsisTarget.synopsis,
2342
+ busy: revisingSynopsis,
2343
+ error: synopsisError,
2344
+ onCancel: () => {
2345
+ if (revisingSynopsis) return;
2346
+ setSynopsisTarget(null);
2347
+ setSynopsisError('');
2348
+ },
2349
+ onConfirm: sendSynopsisRevise,
2350
+ })
2351
+ : null,
2301
2352
  reviseOpen
2302
2353
  ? h(ReviseDialog, {
2303
2354
  chapter,
package/index.js CHANGED
@@ -1499,7 +1499,7 @@ async function bootstrap(ctx, root) {
1499
1499
  }
1500
1500
  }
1501
1501
  return {
1502
- revision: '2026-10-28.2',
1502
+ revision: '2026-10-28.5',
1503
1503
  projects,
1504
1504
  models: {
1505
1505
  default: modelCatalog.default || null,
@@ -2012,19 +2012,28 @@ async function updatePreset(ctx, root, body) {
2012
2012
  return { project, appliesAfterRebuild: true };
2013
2013
  }
2014
2014
 
2015
- /** Prompt for turning a chapter's prose back into its synopsis. */
2016
- function summarizePrompt(project, chapter, prose) {
2015
+ /**
2016
+ * Prompt for syncing a written chapter back into its outline: the synopsis plus
2017
+ * the four numbers the outline records, all measured from this one chapter.
2018
+ */
2019
+ function syncOutlinePrompt(project, chapter, prose) {
2017
2020
  const span = chapterWindow(project, chapter);
2021
+ const chars = wordCount(prose);
2018
2022
  const lines = [];
2019
- lines.push('你是这部小说的编辑。请把下面这一章的正文压缩成一段章节梗概。');
2020
- lines.push('只输出梗概本身:不要标题、不要分点、不要解释、不要评价,300 字以内。');
2021
- lines.push('梗概要写清起因、经过、转折与结果,供后续章节与世界观使用。');
2023
+ lines.push('你是这部小说的编辑。作者已经写完了下面这一章,请据此同步它的章节大纲。');
2024
+ lines.push('读正文,输出一个 JSON 对象,不要输出别的文字、不要代码块:');
2025
+ lines.push('{"synopsis":"……","durationDays":N,"minWords":N,"maxWords":N,"synopsisMinChars":N}');
2026
+ lines.push('');
2027
+ lines.push('各项含义:');
2028
+ lines.push('- synopsis:这一章的故事梗概,写清起因、经过、转折、结果,供后续章节与世界观使用;300 字以内,纯文本。');
2029
+ lines.push(`- durationDays:这段剧情在故事里覆盖的天数,整数 1–365。当前记为第 ${span.start}→${span.end} 天(共 ${span.end - span.start} 天),按正文实际推进判断是否需要改。`);
2030
+ lines.push(`- minWords / maxWords:这一章应有的字数下限与上限,整数;本章正文实际 ${chars} 字,范围应当把 ${chars} 包在里面。`);
2031
+ lines.push('- synopsisMinChars:上面这段梗概算「合格」的最低字数,整数 20–400,取梗概长度的八成左右。');
2022
2032
  lines.push('');
2023
2033
  const prelude = preludeBlock(project);
2024
2034
  if (prelude !== '') lines.push(prelude);
2025
2035
  lines.push(`【本章】第 ${chapter.index + 1} 章 ${chapter.title || ''}(第 ${span.start}→${span.end} 天)`);
2026
- lines.push('');
2027
- lines.push('【正文】');
2036
+ lines.push(`【正文】(共 ${chars} 字,不含空白)`);
2028
2037
  lines.push(prose);
2029
2038
  return lines.join('\n');
2030
2039
  }
@@ -2062,50 +2071,106 @@ function cleanSynopsis(text) {
2062
2071
  }
2063
2072
 
2064
2073
  /**
2065
- * Turn a chapter's prose into its synopsis and save it.
2074
+ * Sync a written chapter back into its outline.
2066
2075
  *
2067
- * This is the one place a generated summary overwrites the stored one, so the
2068
- * Client asks before calling it: an author's own synopsis is the expensive part
2069
- * of an outline, and replacing it is never silent.
2076
+ * Everything the outline records is re-measured from the finished chapter: the
2077
+ * synopsis, the story duration, the word bounds and the synopsis floor. This is
2078
+ * the one place generated values overwrite what the author had, so the Client
2079
+ * asks first: an outline is the material every later generation leans on.
2070
2080
  */
2071
- async function startSummarize(ctx, root, registry, body) {
2081
+ async function startSyncOutline(ctx, root, registry, body) {
2072
2082
  const projectId = asText(body.projectId);
2073
2083
  const chapterId = asText(body.chapterId);
2074
2084
  const prose = typeof body.content === 'string' ? body.content : '';
2075
- if (prose.trim() === '') throw httpError(400, '这一章还没有正文,无法生成梗概');
2085
+ if (prose.trim() === '') throw httpError(400, '这一章还没有正文,无法同步章节大纲');
2076
2086
  const preview = await readProject(root, projectId);
2077
2087
  if (!preview.chapters.some((entry) => entry.chapterId === chapterId)) {
2078
2088
  throw httpError(404, `章节不存在: ${chapterId}`);
2079
2089
  }
2080
- return startJob(registry, { kind: 'summarize', projectId, chapterId }, async (job) => {
2090
+ return startJob(registry, { kind: 'sync-outline', projectId, chapterId }, async (job) => {
2081
2091
  const project = await readProject(root, projectId);
2082
2092
  const chapter = project.chapters.find((entry) => entry.chapterId === chapterId);
2083
2093
  if (!chapter) throw httpError(404, `章节不存在: ${chapterId}`);
2084
- job.message = '正在读正文生成梗概';
2085
- const raw = await askStory(ctx, root, project, summarizePrompt(project, chapter, prose), (note) => {
2094
+ job.message = '正在读正文同步章节大纲';
2095
+ const raw = await askAgent(ctx, root, project, syncOutlinePrompt(project, chapter, prose), (note) => {
2086
2096
  job.message = note;
2087
2097
  });
2088
- const synopsis = cleanSynopsis(raw);
2098
+ const parsed = parseJsonReply(raw) || {};
2099
+ const chars = wordCount(prose);
2100
+ // A reply without usable JSON still yields a synopsis: the model's prose is
2101
+ // the summary, and the numbers fall back to what the chapter actually is.
2102
+ const synopsis = cleanSynopsis(
2103
+ typeof parsed.synopsis === 'string' && parsed.synopsis.trim() !== '' ? parsed.synopsis : raw,
2104
+ );
2089
2105
  if (synopsis === '') throw httpError(502, '模型没有给出梗概');
2106
+
2107
+ const previous = chapter.outline || {};
2108
+ const span = chapterWindow(project, chapter);
2109
+ const notes = [];
2110
+
2111
+ // Word bounds must contain what was actually written, whatever the model said.
2112
+ const floorHundred = (value) => Math.max(1, Math.floor(value / 100) * 100);
2113
+ const ceilHundred = (value) => Math.max(200, Math.ceil(value / 100) * 100);
2114
+ let minWords = Number.isFinite(Number(parsed.minWords)) ? Math.max(1, Math.round(Number(parsed.minWords))) : 0;
2115
+ let maxWords = Number.isFinite(Number(parsed.maxWords)) ? Math.max(1, Math.round(Number(parsed.maxWords))) : 0;
2116
+ if (minWords <= 0 || minWords > chars) minWords = floorHundred(chars * 0.9);
2117
+ if (maxWords <= 0 || maxWords < chars) maxWords = ceilHundred(chars * 1.1);
2118
+ if (maxWords < minWords) maxWords = minWords + 100;
2119
+
2120
+ // The synopsis floor has to stay reachable by the synopsis it was measured on.
2121
+ let synopsisMinChars = Number.isFinite(Number(parsed.synopsisMinChars))
2122
+ ? Math.round(Number(parsed.synopsisMinChars))
2123
+ : Math.max(20, Math.floor(synopsis.length * 0.8));
2124
+ synopsisMinChars = Math.max(20, Math.min(400, synopsisMinChars));
2125
+ synopsisMinChars = Math.max(1, Math.min(synopsisMinChars, synopsis.length));
2126
+
2127
+ // A new duration only lands when it keeps the next chapter where it is.
2128
+ let durationDays = chapter.durationDays;
2129
+ const proposed = Number.isFinite(Number(parsed.durationDays))
2130
+ ? Math.max(1, Math.min(365, Math.round(Number(parsed.durationDays))))
2131
+ : null;
2132
+ if (proposed !== null && proposed !== chapter.durationDays) {
2133
+ const following = (project.chapters || [])
2134
+ .filter((entry) => entry.chapterId !== chapter.chapterId && entry.startDay >= span.start)
2135
+ .sort((a, b) => a.startDay - b.startDay)[0] || null;
2136
+ if (following === null) {
2137
+ durationDays = proposed;
2138
+ } else if (following.startDay === span.start + proposed) {
2139
+ durationDays = proposed;
2140
+ } else {
2141
+ notes.push(`下一章从第 ${following.startDay} 天开始,改时长会错位,时间长度保持 ${chapter.durationDays} 天(模型建议 ${proposed} 天)`);
2142
+ }
2143
+ }
2144
+
2090
2145
  const saved = await mutateProject(root, projectId, (draft) => {
2091
2146
  const target = draft.chapters.find((entry) => entry.chapterId === chapterId);
2092
2147
  if (!target) throw httpError(404, `章节不存在: ${chapterId}`);
2093
- const previous = target.outline || {};
2094
2148
  target.outline = {
2095
2149
  ...previous,
2096
- minWords: previous.minWords || 800,
2097
- maxWords: previous.maxWords || 3000,
2098
- durationDays: previous.durationDays || target.durationDays,
2099
- synopsisMinChars: previous.synopsisMinChars || 100,
2100
2150
  synopsis,
2151
+ minWords,
2152
+ maxWords,
2153
+ durationDays,
2154
+ synopsisMinChars,
2101
2155
  savedAt: Date.now(),
2102
2156
  };
2157
+ target.durationDays = durationDays;
2103
2158
  target.updatedAt = Date.now();
2104
2159
  return { chapter: { ...target, content: undefined }, project: summarize(draft) };
2105
2160
  });
2106
- await exportChapterOutline(await readProject(root, projectId), saved.chapter);
2107
- await exportProjectInfo(await readProject(root, projectId));
2108
- return { synopsis, chapter: saved.chapter };
2161
+ const fresh = await readProject(root, projectId);
2162
+ await exportChapterOutline(fresh, saved.chapter);
2163
+ await exportProjectInfo(fresh);
2164
+ return {
2165
+ synopsis,
2166
+ minWords,
2167
+ maxWords,
2168
+ durationDays,
2169
+ synopsisMinChars,
2170
+ chars,
2171
+ notes,
2172
+ chapter: saved.chapter,
2173
+ };
2109
2174
  });
2110
2175
  }
2111
2176
 
@@ -2927,8 +2992,8 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
2927
2992
  return startGenerate(ctx, root, registry, body);
2928
2993
  case '/chapter/revise':
2929
2994
  return startRevise(ctx, root, registry, body);
2930
- case '/chapter/summarize':
2931
- return startSummarize(ctx, root, registry, body);
2995
+ case '/chapter/sync-outline':
2996
+ return startSyncOutline(ctx, root, registry, body);
2932
2997
  case '/outline/revise':
2933
2998
  return startSynopsisRevise(ctx, root, registry, body);
2934
2999
  case '/compose':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-novel-generator",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
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",