dsh-novel-generator 0.1.31 → 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.
Files changed (3) hide show
  1. package/client.js +163 -2
  2. package/index.js +126 -1
  3. package/package.json +1 -1
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/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.31",
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",