dsh-novel-generator 0.1.31 → 0.1.33

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 +200 -2
  3. package/index.js +126 -1
  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
@@ -1251,7 +1251,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1251
1251
  }, busy ? t('修改中…', 'Revising…') : t('确认修改', 'Revise')))));
1252
1252
  }
1253
1253
 
1254
- function StoryPage({ detail, chapter, previous, next, text, saving, onEdit, onSave, onBack, onOutline, onOpenChapter, onNewChapter, onRevise }) {
1254
+ function StoryPage({ detail, chapter, previous, next, text, saving, onEdit, onSave, onBack, onOutline, onOpenChapter, onNewChapter, onRevise, onSummarize, summarizing }) {
1255
1255
  const words = countWords(text);
1256
1256
  const window = chapter ? chapterWindow(detail, chapter) : null;
1257
1257
 
@@ -1293,6 +1293,15 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1293
1293
  h('div', { className: 'dsn-muted' },
1294
1294
  t('{words} 字{req}', '{words} words{req}', { words, req: chapter.outline ? t(' · 大纲要求 {min}–{max} 字', ' · outline asks for {min}–{max} words', { min: chapter.outline.minWords, max: chapter.outline.maxWords }) : '' })),
1295
1295
  h('div', { className: 'dsn-spacer' }),
1296
+ h('button', {
1297
+ className: 'dsn-btn',
1298
+ type: 'button',
1299
+ disabled: saving || Boolean(summarizing) || String(text || '').trim() === '',
1300
+ title: String(text || '').trim() === ''
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'),
1303
+ onClick: onSummarize,
1304
+ }, summarizing ? t('生成梗概中…', 'Summarizing…') : t('生成故事梗概', 'Synopsis from text')),
1296
1305
  h('button', {
1297
1306
  className: 'dsn-btn',
1298
1307
  type: 'button',
@@ -1308,7 +1317,47 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1308
1317
 
1309
1318
  /* ---------------------------------------------------------- outline page */
1310
1319
 
1311
- function OutlinePage({ detail, chapter, isFirstChapter, onBack, onSaved, notify, onRegisterGuard, requestConfirm, onBusyChange }) {
1320
+ /**
1321
+ * Ask the model to rewrite the chapter's synopsis from a note. Unlike the
1322
+ * story page's dialog this one works on the outline, and the result comes back
1323
+ * as an unsaved draft for the author to keep.
1324
+ */
1325
+ function SynopsisReviseDialog({ chapter, synopsis, busy, error, onCancel, onConfirm }) {
1326
+ const [instruction, setInstruction] = useState('');
1327
+ const ready = instruction.trim() !== '' && String(synopsis || '').trim() !== '';
1328
+
1329
+ return h('div', { className: 'dsn-dialog-backdrop' },
1330
+ h('div', { className: 'dsn-dialog', style: { width: 'min(560px, calc(100% - 48px))' } },
1331
+ h('div', { className: 'dsn-dialog-head' },
1332
+ h('div', { className: 'dsn-title' }, t('AI 修改故事梗概', 'Revise the synopsis with AI')),
1333
+ h('div', { className: 'dsn-sub' }, chapter ? t('第 {n} 章 · {title}', 'Chapter {n} · {title}', { n: chapter.index + 1, title: chapter.title || t('未命名', 'Untitled') }) : ''),
1334
+ h('div', { className: 'dsn-spacer' })),
1335
+ h('div', { className: 'dsn-dialog-body' },
1336
+ h('label', { className: 'dsn-field span4' },
1337
+ h('span', { className: 'dsn-label' }, t('修改意见(必填)', 'What to change (required)')),
1338
+ h('textarea', {
1339
+ className: 'dsn-textarea',
1340
+ style: { minHeight: '120px' },
1341
+ value: instruction,
1342
+ placeholder: t('针对「故事梗概」的意见,例如:把转折写得更明确,补上主角离开村庄的动机;删掉与主线无关的配角。', 'Notes about the synopsis itself, e.g. make the turn explicit, add the reason the hero leaves the village, drop the subplot that goes nowhere.'),
1343
+ onChange: (event) => setInstruction(event.target.value),
1344
+ })),
1345
+ String(synopsis || '').trim() === ''
1346
+ ? h('div', { className: 'dsn-hint warn span4' }, t('还没有故事梗概,先写一段再来让 AI 改。', 'There is no synopsis yet — write one before revising it.'))
1347
+ : h('div', { className: 'dsn-hint span4' }, t('只改「故事梗概」,不动正文与章节名。结果填回梗概框但不自动保存。', 'Only the synopsis is rewritten; the text and title stay. The result fills the box unsaved.')),
1348
+ error ? h('div', { className: 'dsn-hint warn span4' }, error) : null),
1349
+ h('div', { className: 'dsn-dialog-foot' },
1350
+ h('button', { className: 'dsn-btn ghost', type: 'button', disabled: busy, onClick: onCancel }, t('取消', 'Cancel')),
1351
+ h('div', { className: 'dsn-spacer' }),
1352
+ h('button', {
1353
+ className: 'dsn-btn primary',
1354
+ type: 'button',
1355
+ disabled: busy || !ready,
1356
+ onClick: () => onConfirm(instruction.trim()),
1357
+ }, busy ? t('改写中…', 'Revising…') : t('确认修改', 'Revise')))));
1358
+ }
1359
+
1360
+ function OutlinePage({ detail, chapter, isFirstChapter, onBack, onSaved, notify, onRegisterGuard, requestConfirm, onBusyChange, onReviseSynopsis, synopsisDraft, onSynopsisDraftApplied, revisingSynopsis }) {
1312
1361
  const buildForm = useCallback(() => ({
1313
1362
  title: chapter ? chapter.title || '' : '',
1314
1363
  durationDays: chapter && chapter.outline ? chapter.outline.durationDays : (detail.timeline.unitDays || 30),
@@ -1412,6 +1461,18 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1412
1461
  const generating = job && job.state === 'running' && job.kind === 'generate';
1413
1462
  const composing = job && job.state === 'running' && job.kind === 'compose';
1414
1463
 
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]);
1475
+
1415
1476
  /** 自动推演: deduce synopsis, title and the whole chapter, then save. */
1416
1477
  const deduce = useCallback(() => {
1417
1478
  if (isFirstChapter) {
@@ -1494,6 +1555,17 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1494
1555
  h('div', { className: 'dsn-title' }, chapter ? t('章节大纲 · 第 {n} 章', 'Outline · chapter {n}', { n: chapter.index + 1 }) : t('章节大纲 · 新章节', "Outline · new chapter")),
1495
1556
  h('div', { className: 'dsn-sub' }, t('起始 第 {day} 天', 'Starts day {day}', { day: chapter ? chapterWindow(detail, chapter).start : (detail.timeline.cursor || 0) })),
1496
1557
  h('div', { className: 'dsn-spacer' }),
1558
+ h('button', {
1559
+ className: 'dsn-btn ghost',
1560
+ type: 'button',
1561
+ disabled: !chapter || String(form.synopsis || '').trim() === '' || saving || Boolean(generating || composing || revisingSynopsis),
1562
+ title: String(form.synopsis || '').trim() === ''
1563
+ ? t('还没有故事梗概可改', 'There is no synopsis to revise yet')
1564
+ : t('让 AI 按修改意见改写「故事梗概」(不动正文)', 'Let AI rewrite the synopsis from your notes (the chapter text stays)'),
1565
+ onClick: () => {
1566
+ if (typeof onReviseSynopsis === 'function') onReviseSynopsis(form.synopsis);
1567
+ },
1568
+ }, t('AI 修改梗概', 'AI revise synopsis')),
1497
1569
  h('button', {
1498
1570
  className: 'dsn-btn ghost',
1499
1571
  type: 'button',
@@ -1820,6 +1892,14 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1820
1892
  const [reviseOpen, setReviseOpen] = useState(false);
1821
1893
  const [reviseError, setReviseError] = useState('');
1822
1894
  const [reviseJob, setReviseJob] = useJob();
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();
1823
1903
  const [text, setText] = useState('');
1824
1904
  const [dirty, setDirty] = useState(false);
1825
1905
  const [saving, setSaving] = useState(false);
@@ -1892,7 +1972,102 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1892
1972
  }
1893
1973
  }, [chapter, detail.projectId, text, setReviseJob]);
1894
1974
 
1975
+ /** 生成故事梗概: read the prose and overwrite the stored synopsis. */
1976
+ const summarizeChapter = useCallback(() => {
1977
+ if (!chapter) return;
1978
+ if (String(text || '').trim() === '') {
1979
+ notify(t('这一章还没有正文,先写或先生成再生成梗概', 'This chapter has no text yet — write or generate it first.'), 'error');
1980
+ return;
1981
+ }
1982
+ setConfirmRequest({
1983
+ title: t('是否以当前章节故事生成故事梗概?', 'Create the synopsis from this chapter’s text?'),
1984
+ text: t('会用当前正文生成一段新的故事梗概,并「直接保存、覆盖原有梗概」。正文不会被修改。', 'A new synopsis is generated from the text and saved immediately, replacing the existing one. The chapter text itself is untouched.'),
1985
+ confirmLabel: t('生成并覆盖', 'Generate and replace'),
1986
+ action: async () => {
1987
+ try {
1988
+ const started = await api('/chapter/summarize', {
1989
+ projectId: detail.projectId,
1990
+ chapterId: chapter.chapterId,
1991
+ content: text,
1992
+ });
1993
+ setSummarizeJob(started);
1994
+ } catch (error) {
1995
+ notify(messageOf(error), 'error');
1996
+ }
1997
+ },
1998
+ });
1999
+ }, [chapter, detail.projectId, text, notify, setConfirmRequest, setSummarizeJob]);
2000
+
2001
+ const summarizing = Boolean(summarizeJob && summarizeJob.state === 'running');
2002
+ useEffect(() => {
2003
+ if (!summarizeJob) return undefined;
2004
+ if (summarizeJob.state === 'running') {
2005
+ setChildBusy({
2006
+ label: t('正在用正文生成故事梗概…', 'Building the synopsis from the text…'),
2007
+ message: summarizeJob.message || '',
2008
+ });
2009
+ return undefined;
2010
+ }
2011
+ setChildBusy(null);
2012
+ if (summarizeJob.state === 'done') {
2013
+ notify(t('已用正文生成并保存故事梗概', 'Synopsis generated from the text and saved'), 'success');
2014
+ onReload();
2015
+ } else if (summarizeJob.state === 'error') {
2016
+ notify(summarizeJob.error || t('生成梗概失败', 'Building the synopsis failed'), 'error');
2017
+ }
2018
+ return undefined;
2019
+ }, [summarizeJob ? summarizeJob.jobId : null, summarizeJob ? summarizeJob.state : null, summarizeJob ? summarizeJob.message : null]);
2020
+
1895
2021
  const revising = Boolean(reviseJob && reviseJob.state === 'running');
2022
+
2023
+ /** Run the synopsis rewrite for the chapter the outline page handed up. */
2024
+ const sendSynopsisRevise = useCallback(async (instruction) => {
2025
+ const target = synopsisTarget;
2026
+ if (!target || !target.chapter) return;
2027
+ if (String(target.synopsis || '').trim() === '') {
2028
+ setSynopsisError(t('还没有故事梗概可改,先写一段再来', 'There is no synopsis yet — write one first.'));
2029
+ return;
2030
+ }
2031
+ if (String(instruction || '').trim() === '') {
2032
+ setSynopsisError(t('请先写清修改意见', 'Write what should change first.'));
2033
+ return;
2034
+ }
2035
+ setSynopsisError('');
2036
+ try {
2037
+ const started = await api('/outline/revise', {
2038
+ projectId: detail.projectId,
2039
+ chapterId: target.chapter.chapterId,
2040
+ synopsis: target.synopsis,
2041
+ instruction: String(instruction).trim(),
2042
+ });
2043
+ setSynopsisJob(started);
2044
+ } catch (error) {
2045
+ setSynopsisError(messageOf(error));
2046
+ }
2047
+ }, [synopsisTarget, detail.projectId, notify, setSynopsisJob]);
2048
+
2049
+ const revisingSynopsis = Boolean(synopsisJob && synopsisJob.state === 'running');
2050
+ useEffect(() => {
2051
+ if (!synopsisJob) return undefined;
2052
+ if (synopsisJob.state === 'running') {
2053
+ setChildBusy({
2054
+ label: t('正在按意见改写故事梗概…', 'Rewriting the synopsis…'),
2055
+ message: synopsisJob.message || '',
2056
+ });
2057
+ return undefined;
2058
+ }
2059
+ setChildBusy(null);
2060
+ if (synopsisJob.state === 'done') {
2061
+ const text = (synopsisJob.result || {}).synopsis;
2062
+ if (typeof text === 'string' && text.trim() !== '') setSynopsisDraft(text);
2063
+ setSynopsisTarget(null);
2064
+ notify(t('AI 已改写故事梗概,确认后点「保存」', 'AI rewrote the synopsis — review it, then press Save'), 'success');
2065
+ } else if (synopsisJob.state === 'error') {
2066
+ setSynopsisError(synopsisJob.error || t('改写失败', 'Revision failed'));
2067
+ }
2068
+ return undefined;
2069
+ }, [synopsisJob ? synopsisJob.jobId : null, synopsisJob ? synopsisJob.state : null, synopsisJob ? synopsisJob.message : null]);
2070
+
1896
2071
  useEffect(() => {
1897
2072
  if (!reviseJob) return undefined;
1898
2073
  if (reviseJob.state === 'running') {
@@ -2108,6 +2283,8 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2108
2283
  setReviseError('');
2109
2284
  setReviseOpen(true);
2110
2285
  },
2286
+ onSummarize: summarizeChapter,
2287
+ summarizing: Boolean(summarizeJob && summarizeJob.state === 'running'),
2111
2288
  })),
2112
2289
  h('section', { className: 'dsn-page' },
2113
2290
  h(OutlinePage, {
@@ -2122,6 +2299,13 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2122
2299
  onRegisterGuard: registerOutlineGuard,
2123
2300
  requestConfirm: setConfirmRequest,
2124
2301
  onBusyChange: setChildBusy,
2302
+ onReviseSynopsis: (synopsis) => {
2303
+ setSynopsisError('');
2304
+ setSynopsisTarget({ chapter, synopsis });
2305
+ },
2306
+ synopsisDraft,
2307
+ onSynopsisDraftApplied: () => setSynopsisDraft(null),
2308
+ revisingSynopsis,
2125
2309
  isFirstChapter: Boolean(chapter && chapters.length > 0 && chapter.chapterId === chapters[0].chapterId),
2126
2310
  }))),
2127
2311
  confirmRequest
@@ -2137,6 +2321,20 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2137
2321
  },
2138
2322
  })
2139
2323
  : null,
2324
+ synopsisTarget
2325
+ ? h(SynopsisReviseDialog, {
2326
+ chapter: synopsisTarget.chapter,
2327
+ synopsis: synopsisTarget.synopsis,
2328
+ busy: revisingSynopsis,
2329
+ error: synopsisError,
2330
+ onCancel: () => {
2331
+ if (revisingSynopsis) return;
2332
+ setSynopsisTarget(null);
2333
+ setSynopsisError('');
2334
+ },
2335
+ onConfirm: sendSynopsisRevise,
2336
+ })
2337
+ : null,
2140
2338
  reviseOpen
2141
2339
  ? h(ReviseDialog, {
2142
2340
  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-27.17',
1502
+ revision: '2026-10-28.4',
1503
1503
  projects,
1504
1504
  models: {
1505
1505
  default: modelCatalog.default || null,
@@ -2012,6 +2012,127 @@ 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) {
2017
+ const span = chapterWindow(project, chapter);
2018
+ const lines = [];
2019
+ lines.push('你是这部小说的编辑。请把下面这一章的正文压缩成一段章节梗概。');
2020
+ lines.push('只输出梗概本身:不要标题、不要分点、不要解释、不要评价,300 字以内。');
2021
+ lines.push('梗概要写清起因、经过、转折与结果,供后续章节与世界观使用。');
2022
+ lines.push('');
2023
+ const prelude = preludeBlock(project);
2024
+ if (prelude !== '') lines.push(prelude);
2025
+ lines.push(`【本章】第 ${chapter.index + 1} 章 ${chapter.title || ''}(第 ${span.start}→${span.end} 天)`);
2026
+ lines.push('');
2027
+ lines.push('【正文】');
2028
+ lines.push(prose);
2029
+ return lines.join('\n');
2030
+ }
2031
+
2032
+ /** Prompt for rewriting a synopsis from the author's note. */
2033
+ function synopsisRevisePrompt(project, chapter, synopsis, instruction) {
2034
+ const lines = [];
2035
+ lines.push('你是这部小说的编辑。下面是本章现有的故事梗概,以及作者给出的修改意见。');
2036
+ lines.push('请按修改意见改写这段梗概,输出改写后的**完整梗概**:不要解释、不要列改动点、不要标题,300 字以内。');
2037
+ lines.push('');
2038
+ const prelude = preludeBlock(project);
2039
+ if (prelude !== '') lines.push(prelude);
2040
+ lines.push(`【本章】第 ${chapter.index + 1} 章 ${chapter.title || ''}`);
2041
+ lines.push('');
2042
+ lines.push('【现有梗概】');
2043
+ lines.push(synopsis);
2044
+ lines.push('');
2045
+ lines.push('【修改意见】');
2046
+ lines.push(instruction);
2047
+ lines.push('');
2048
+ lines.push('【要求】');
2049
+ lines.push('- 没被意见推翻的情节保留;与正文一致的事实不要改。');
2050
+ lines.push('- 只输出梗概正文,不要加「梗概:」之类的前缀。');
2051
+ return lines.join('\n');
2052
+ }
2053
+
2054
+ /** Trim a model-written blurb down to plain synopsis text. */
2055
+ function cleanSynopsis(text) {
2056
+ return String(text || '')
2057
+ .replace(/^\s*#{1,6}\s*[^\n]*\n+/u, '')
2058
+ .replace(/^\s*梗概[::]\s*/u, '')
2059
+ .replace(/^```[a-z]*\n?/u, '')
2060
+ .replace(/\n?```\s*$/u, '')
2061
+ .trim();
2062
+ }
2063
+
2064
+ /**
2065
+ * Turn a chapter's prose into its synopsis and save it.
2066
+ *
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.
2070
+ */
2071
+ async function startSummarize(ctx, root, registry, body) {
2072
+ const projectId = asText(body.projectId);
2073
+ const chapterId = asText(body.chapterId);
2074
+ const prose = typeof body.content === 'string' ? body.content : '';
2075
+ if (prose.trim() === '') throw httpError(400, '这一章还没有正文,无法生成梗概');
2076
+ const preview = await readProject(root, projectId);
2077
+ if (!preview.chapters.some((entry) => entry.chapterId === chapterId)) {
2078
+ throw httpError(404, `章节不存在: ${chapterId}`);
2079
+ }
2080
+ return startJob(registry, { kind: 'summarize', projectId, chapterId }, async (job) => {
2081
+ const project = await readProject(root, projectId);
2082
+ const chapter = project.chapters.find((entry) => entry.chapterId === chapterId);
2083
+ if (!chapter) throw httpError(404, `章节不存在: ${chapterId}`);
2084
+ job.message = '正在读正文生成梗概';
2085
+ const raw = await askStory(ctx, root, project, summarizePrompt(project, chapter, prose), (note) => {
2086
+ job.message = note;
2087
+ });
2088
+ const synopsis = cleanSynopsis(raw);
2089
+ if (synopsis === '') throw httpError(502, '模型没有给出梗概');
2090
+ const saved = await mutateProject(root, projectId, (draft) => {
2091
+ const target = draft.chapters.find((entry) => entry.chapterId === chapterId);
2092
+ if (!target) throw httpError(404, `章节不存在: ${chapterId}`);
2093
+ const previous = target.outline || {};
2094
+ target.outline = {
2095
+ ...previous,
2096
+ minWords: previous.minWords || 800,
2097
+ maxWords: previous.maxWords || 3000,
2098
+ durationDays: previous.durationDays || target.durationDays,
2099
+ synopsisMinChars: previous.synopsisMinChars || 100,
2100
+ synopsis,
2101
+ savedAt: Date.now(),
2102
+ };
2103
+ target.updatedAt = Date.now();
2104
+ return { chapter: { ...target, content: undefined }, project: summarize(draft) };
2105
+ });
2106
+ await exportChapterOutline(await readProject(root, projectId), saved.chapter);
2107
+ await exportProjectInfo(await readProject(root, projectId));
2108
+ return { synopsis, chapter: saved.chapter };
2109
+ });
2110
+ }
2111
+
2112
+ /** Rewrite one chapter's synopsis from a note, without saving it. */
2113
+ async function startSynopsisRevise(ctx, root, registry, body) {
2114
+ const projectId = asText(body.projectId);
2115
+ const chapterId = asText(body.chapterId);
2116
+ const synopsis = typeof body.synopsis === 'string' ? body.synopsis : '';
2117
+ const instruction = asText(body.instruction);
2118
+ if (synopsis.trim() === '') throw httpError(400, '还没有故事梗概可改,先写一段再来');
2119
+ if (instruction === '') throw httpError(400, '请先写清修改意见');
2120
+ const preview = await readProject(root, projectId);
2121
+ const claimed = preview.chapters.find((entry) => entry.chapterId === chapterId);
2122
+ if (!claimed) throw httpError(404, `章节不存在: ${chapterId}`);
2123
+ return startJob(registry, { kind: 'synopsis-revise', projectId, chapterId }, async (job) => {
2124
+ const project = await readProject(root, projectId);
2125
+ const chapter = project.chapters.find((entry) => entry.chapterId === chapterId) || claimed;
2126
+ job.message = '正在按意见改写梗概';
2127
+ const raw = await askStory(ctx, root, project, synopsisRevisePrompt(project, chapter, synopsis, instruction), (note) => {
2128
+ job.message = note;
2129
+ });
2130
+ const revised = cleanSynopsis(raw);
2131
+ if (revised === '') throw httpError(502, '模型没有给出梗概');
2132
+ return { synopsis: revised, chars: revised.length };
2133
+ });
2134
+ }
2135
+
2015
2136
  async function updateTimeline(root, body) {
2016
2137
  const projectId = asText(body.projectId);
2017
2138
  const unitDays = Number(body.unitDays);
@@ -2806,6 +2927,10 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
2806
2927
  return startGenerate(ctx, root, registry, body);
2807
2928
  case '/chapter/revise':
2808
2929
  return startRevise(ctx, root, registry, body);
2930
+ case '/chapter/summarize':
2931
+ return startSummarize(ctx, root, registry, body);
2932
+ case '/outline/revise':
2933
+ return startSynopsisRevise(ctx, root, registry, body);
2809
2934
  case '/compose':
2810
2935
  return startCompose(ctx, root, registry, body);
2811
2936
  case '/deduce':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-novel-generator",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
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",