dsh-novel-generator 0.1.30 → 0.1.32

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 CHANGED
@@ -89,21 +89,20 @@ dsh plugin --profile web add dsh-novel-generator
89
89
 
90
90
  ## 「小说工坊 · 作者」预设(随主插件一起装)
91
91
 
92
- 本插件的 `cordis.patch.yml` 声明了**两行**,所以装一个包就有两件事:
92
+ 本插件把配套包 `dsh-novel-author-preset` 列为依赖,所以装小说工坊就会带上「小说工坊 · 作者」预设。`cordis.patch.yml` 因此声明**两行**:
93
93
 
94
- | 行 id | 作用 |
94
+ | 插件列表里的行 | 是什么 |
95
95
  |---|---|
96
- | `dsh-novel-generator` | 小说工坊本体(时间线、章节、快照、前置提示词) |
97
- | `preset-novel-studio-author` | Agent 预设「小说工坊 · 作者」 |
96
+ | `dsh-novel-generator`(小说工坊) | 工坊本体:时间线、章节、快照、前置提示词 |
97
+ | `dsh-novel-author-preset`(小说工坊 · 作者预设) | Agent 预设:激活时注册,关掉即注销 |
98
98
 
99
- - 在「设置 → 插件」里,这两行都挂在**小说工坊**条目下,**可以分别启停**。
100
- - 关掉预设行只是把它从预设列表里隐藏;小说工坊照常工作(作品可以改用 standard 等任何已装预设)。
101
- - 单独关掉整条小说工坊,预设也会随之消失,两者互不残留。
102
- - 装了它之后**新建作品默认用这个预设**;预设被关掉或卸载时,重建会话会自动退回 profile 默认预设,作品不会坏。
99
+ - 两行都挂在**小说工坊**下面,可以分别启停:关掉预设行只是把它从预设列表隐藏,工坊照常工作(作品可改用 standard 等任何已装预设);关掉工坊本体,预设也随之消失。
100
+ - 预设行显示的是这个配套包**自己的标题与说明**,而不是 `@deepseek-ai/dsh-agent-preset` 的通用文案「Agent 预设定义」——因为这一行由该包注册,不是由 patch 里的通用声明产生。
101
+ - 装了它之后**新建作品默认用这个预设**;预设被关掉或缺席时,重建会话会自动退回 profile 默认预设,作品不会坏。
103
102
 
104
103
  预设内容刻意做得很薄:三句人设(你是小说写作者/只输出正文/文风视角篇幅以用户要求为准)+ 关闭运行时上下文 + **不挂任何工具** + 只保留上下文压缩。所有文学倾向都交给作品级「前置提示词」。
105
104
 
106
- > 早先发布过的独立包 `dsh-novel-author-preset` 已并入本插件;它从 0.2.0 起是空补丁,装过 0.1.0 的环境请 `dsh plugin remove dsh-novel-author-preset`。
105
+ > 0.2.0 之前发布过的 `dsh-novel-author-preset` 是"空补丁"过渡版;从 0.3.0 起它是上面这个真正注册预设的配套插件。
107
106
 
108
107
  ## 迁移、导入与会话
109
108
 
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,6 +1317,46 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1308
1317
 
1309
1318
  /* ---------------------------------------------------------- outline page */
1310
1319
 
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
+
1311
1360
  function OutlinePage({ detail, chapter, isFirstChapter, onBack, onSaved, notify, onRegisterGuard, requestConfirm, onBusyChange }) {
1312
1361
  const buildForm = useCallback(() => ({
1313
1362
  title: chapter ? chapter.title || '' : '',
@@ -1411,6 +1460,34 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1411
1460
 
1412
1461
  const generating = job && job.state === 'running' && job.kind === 'generate';
1413
1462
  const composing = job && job.state === 'running' && job.kind === 'compose';
1463
+ const revisingSynopsis = job && job.state === 'running' && job.kind === 'synopsis-revise';
1464
+
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]);
1414
1491
 
1415
1492
  /** 自动推演: deduce synopsis, title and the whole chapter, then save. */
1416
1493
  const deduce = useCallback(() => {
@@ -1483,7 +1560,17 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1483
1560
  notify(t('已重新推演并保存章节', "Re-deduced and saved the chapter"), 'success');
1484
1561
  onSaved(chapter ? chapter.chapterId : null);
1485
1562
  }
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
+ }
1486
1572
  if (job.state === 'error') {
1573
+ if (job.kind === 'synopsis-revise') setSynopsisReviseError(job.error || t('改写失败', 'Revision failed'));
1487
1574
  notify(job.error || t('任务失败', "Task failed"), 'error');
1488
1575
  }
1489
1576
  }, [job ? job.jobId : null, job ? job.state : null]);
@@ -1494,6 +1581,18 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1494
1581
  h('div', { className: 'dsn-title' }, chapter ? t('章节大纲 · 第 {n} 章', 'Outline · chapter {n}', { n: chapter.index + 1 }) : t('章节大纲 · 新章节', "Outline · new chapter")),
1495
1582
  h('div', { className: 'dsn-sub' }, t('起始 第 {day} 天', 'Starts day {day}', { day: chapter ? chapterWindow(detail, chapter).start : (detail.timeline.cursor || 0) })),
1496
1583
  h('div', { className: 'dsn-spacer' }),
1584
+ h('button', {
1585
+ className: 'dsn-btn ghost',
1586
+ type: 'button',
1587
+ disabled: !chapter || String(form.synopsis || '').trim() === '' || saving || Boolean(generating || composing || revisingSynopsis),
1588
+ title: String(form.synopsis || '').trim() === ''
1589
+ ? t('还没有故事梗概可改', 'There is no synopsis to revise yet')
1590
+ : t('让 AI 按修改意见改写「故事梗概」(不动正文)', 'Let AI rewrite the synopsis from your notes (the chapter text stays)'),
1591
+ onClick: () => {
1592
+ setSynopsisReviseError('');
1593
+ setSynopsisReviseOpen(true);
1594
+ },
1595
+ }, t('AI 修改梗概', 'AI revise synopsis')),
1497
1596
  h('button', {
1498
1597
  className: 'dsn-btn ghost',
1499
1598
  type: 'button',
@@ -1586,7 +1685,20 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1586
1685
  type: 'button',
1587
1686
  disabled: !savedAt || Boolean(generating || composing) || !chapter,
1588
1687
  onClick: generate,
1589
- }, generating ? t('生成中…', "Generating…") : t('生成章节故事', "Generate chapter"))));
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);
1590
1702
  }
1591
1703
 
1592
1704
  /* --------------------------------------------------------- new chapter */
@@ -1820,6 +1932,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1820
1932
  const [reviseOpen, setReviseOpen] = useState(false);
1821
1933
  const [reviseError, setReviseError] = useState('');
1822
1934
  const [reviseJob, setReviseJob] = useJob();
1935
+ const [summarizeJob, setSummarizeJob] = useJob();
1823
1936
  const [text, setText] = useState('');
1824
1937
  const [dirty, setDirty] = useState(false);
1825
1938
  const [saving, setSaving] = useState(false);
@@ -1892,6 +2005,52 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1892
2005
  }
1893
2006
  }, [chapter, detail.projectId, text, setReviseJob]);
1894
2007
 
2008
+ /** 生成故事梗概: read the prose and overwrite the stored synopsis. */
2009
+ const summarizeChapter = useCallback(() => {
2010
+ if (!chapter) return;
2011
+ if (String(text || '').trim() === '') {
2012
+ notify(t('这一章还没有正文,先写或先生成再生成梗概', 'This chapter has no text yet — write or generate it first.'), 'error');
2013
+ return;
2014
+ }
2015
+ 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'),
2019
+ action: async () => {
2020
+ try {
2021
+ const started = await api('/chapter/summarize', {
2022
+ projectId: detail.projectId,
2023
+ chapterId: chapter.chapterId,
2024
+ content: text,
2025
+ });
2026
+ setSummarizeJob(started);
2027
+ } catch (error) {
2028
+ notify(messageOf(error), 'error');
2029
+ }
2030
+ },
2031
+ });
2032
+ }, [chapter, detail.projectId, text, notify, setConfirmRequest, setSummarizeJob]);
2033
+
2034
+ const summarizing = Boolean(summarizeJob && summarizeJob.state === 'running');
2035
+ useEffect(() => {
2036
+ if (!summarizeJob) return undefined;
2037
+ if (summarizeJob.state === 'running') {
2038
+ setChildBusy({
2039
+ label: t('正在用正文生成故事梗概…', 'Building the synopsis from the text…'),
2040
+ message: summarizeJob.message || '',
2041
+ });
2042
+ return undefined;
2043
+ }
2044
+ setChildBusy(null);
2045
+ if (summarizeJob.state === 'done') {
2046
+ notify(t('已用正文生成并保存故事梗概', 'Synopsis generated from the text and saved'), 'success');
2047
+ onReload();
2048
+ } else if (summarizeJob.state === 'error') {
2049
+ notify(summarizeJob.error || t('生成梗概失败', 'Building the synopsis failed'), 'error');
2050
+ }
2051
+ return undefined;
2052
+ }, [summarizeJob ? summarizeJob.jobId : null, summarizeJob ? summarizeJob.state : null, summarizeJob ? summarizeJob.message : null]);
2053
+
1895
2054
  const revising = Boolean(reviseJob && reviseJob.state === 'running');
1896
2055
  useEffect(() => {
1897
2056
  if (!reviseJob) return undefined;
@@ -2108,6 +2267,8 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2108
2267
  setReviseError('');
2109
2268
  setReviseOpen(true);
2110
2269
  },
2270
+ onSummarize: summarizeChapter,
2271
+ summarizing: Boolean(summarizeJob && summarizeJob.state === 'running'),
2111
2272
  })),
2112
2273
  h('section', { className: 'dsn-page' },
2113
2274
  h(OutlinePage, {
package/cordis.patch.yml CHANGED
@@ -2,42 +2,18 @@
2
2
  # switched independently:
3
3
  #
4
4
  # 1. `dsh-novel-generator` — the studio itself (timeline, chapters, snapshots).
5
- # 2. `preset-novel-studio-author` — the blank author Agent preset. Turning this
6
- # row off in the plugin list simply hides the preset; the studio keeps
7
- # working with whatever preset the profile already offers.
5
+ # 2. `preset-novel-studio-author` — the author preset, owned by the companion
6
+ # package `dsh-novel-author-preset`. The row names that package (not the
7
+ # generic `@deepseek-ai/dsh-agent-preset`) so the plugin list shows its own
8
+ # title and description; the package registers the preset when it activates
9
+ # and withdraws it when the row is switched off.
8
10
  #
9
- # The second row is what used to be the separate `dsh-novel-author-preset`
10
- # package: shipping it here means installing the studio brings the preset too.
11
+ # The companion package is a dependency of this one, so installing the studio
12
+ # brings the preset with it.
11
13
  - insert:
12
14
  - id: dsh-novel-generator
13
15
  name: 'dsh-novel-generator'
14
16
  config: {}
15
17
  - id: preset-novel-studio-author
16
- name: '@deepseek-ai/dsh-agent-preset'
17
- config:
18
- id: novel-studio-author
19
- name: 小说工坊 · 作者
20
- description: 小说工坊自带的空白写作预设,只规定"写正文"这件事,不预设任何文风或视角。
21
- order: 60
22
- plugins:
23
- - id: persona
24
- name: '@deepseek-ai/dsh-persona'
25
- config:
26
- # Short, neutral, output contract only; style and viewpoint belong
27
- # to the work-level preface prompt.
28
- prefix: |-
29
- 你是小说写作者,负责按用户给出的设定、梗概与要求,写出完整的中文小说正文。
30
- 只输出正文本身:不要解释、不要提纲、不要评论、不要加标题行。
31
- 文风、叙事视角、人称与篇幅一律以上下文中的要求为准,不要自行添加偏好。
32
- complete: true
33
- includeRuntimeContext: false
34
- # Compaction only: a long serial is a long conversation, and the writer
35
- # has no use for shell, files, web or delegation.
36
- - id: compaction
37
- name: cordis:group
38
- group: true
39
- isolate:
40
- compaction: true
41
- config:
42
- - id: compaction-basic
43
- name: '@deepseek-ai/dsh-compaction-basic'
18
+ name: 'dsh-novel-author-preset'
19
+ config: {}
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.2',
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.30",
3
+ "version": "0.1.32",
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",
@@ -55,5 +55,8 @@
55
55
  "@deepseek-ai/dsh-client-ui-workspace"
56
56
  ]
57
57
  }
58
+ },
59
+ "dependencies": {
60
+ "dsh-novel-author-preset": "^0.3.0"
58
61
  }
59
62
  }