dsh-novel-generator 0.1.21 → 0.1.25

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 +15 -0
  2. package/client.js +229 -5
  3. package/index.js +132 -1
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -64,6 +64,21 @@ dsh plugin --profile web add dsh-novel-generator
64
64
  - 生成、推演、快照都是异步任务:`POST /generate`、`POST /compose`、`POST /deduce`、`POST /snapshot/refresh` 返回 `jobId`,再由 `GET /job?id=` 轮询。
65
65
  - `timeline.cursor` 现在以「天」为单位;旧数据(按格计数)在首次读取时自动换算一次并标记 `cursorUnit: "day"`。
66
66
 
67
+ ## 前置提示词(作品级)
68
+
69
+ 作品列表每行的「前置」可以给一本书设定前置提示:内置「第一人称 / 第三人称限知 / 第三人称全知 / 自定义」四种,选中即填入文本框,文本可自由改写(最多 2000 字)。
70
+
71
+ - 每次**生成正文、自动推演、AI 修改**时,这段文本都会以「【本书前置设定】…」的形式放在提示最前面;生成世界快照时不带(那是结构化数据任务)。
72
+ - 保存后**下一次生成就生效**,不需要重建会话——它不依赖会话状态。
73
+ - 内容同时写进作品的 `作品信息.md`(文末「前置提示词」一节),便于外部查看与备份。
74
+ - 留空即表示不设置。
75
+
76
+ ## 章节故事页
77
+
78
+ - 正文可以直接编辑、保存;工具条上还有「AI 修改」:填入修改意见后,模型按意见重写整章。
79
+ - 正文为空时按钮不可点,接口也会拒绝(400);修改意见为空同样拒绝。
80
+ - **重写结果只放进编辑区、不自动保存**,状态变成「未保存」,你确认后再点「保存」——避免 AI 的改动直接覆盖原稿。
81
+
67
82
  ## 迁移、导入与会话
68
83
 
69
84
  - **改预设**:作品列表每行的「预设」改的是「下次建会话时用哪个预设」。会话在创建时就固定了预设,所以保存后会提示「重建会话后生效」——要立刻换,点同一行的「重建会话」。
package/client.js CHANGED
@@ -189,7 +189,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
189
189
  .dsn-path{display:flex;gap:6px}
190
190
  .dsn-import{display:flex;align-items:center;gap:10px;margin:2px 0 12px}
191
191
  .dsn-list{display:flex;flex-direction:column;gap:8px}
192
- .dsn-row{display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:12px;
192
+ .dsn-row{display:flex;flex-wrap:wrap;align-items:center;gap:12px;padding:12px 14px;border-radius:12px;
193
193
  border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);cursor:pointer}
194
194
  .dsn-row:hover{border-color:var(--dsw-alias-brand-primary)}
195
195
  .dsn-row-main{display:flex;flex-direction:column;gap:4px;min-width:0}
@@ -480,7 +480,84 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
480
480
  }, busy ? t('保存中…', "Saving…") : t('保存', "Save")))));
481
481
  }
482
482
 
483
- function ProjectsPage({ boot, resolveWorkspace, onOpen, onReload, onEditModel, onEditPreset }) {
483
+ /** Built-in preface prompts; the text is editable after picking one. */
484
+ const PRELUDE_MODES = [
485
+ { id: 'first', zh: '第一人称', en: 'First person', text: '全书使用第一人称叙述:只写「我」亲眼所见、亲耳所听、亲身所想,不描写别人的内心活动。' },
486
+ { id: 'thirdLimited', zh: '第三人称限知', en: 'Third person, limited', text: '全书使用第三人称限知视角:叙述跟随主角,只呈现他能感知到的一切,不越界写其他角色的内心。' },
487
+ { id: 'thirdOmniscient', zh: '第三人称全知', en: 'Third person, omniscient', text: '全书使用第三人称全知视角:叙述者可以进入任何角色的内心,但每次切换视角都要有场景依据,不要在同一场景里频繁跳视角。' },
488
+ { id: 'custom', zh: '自定义', en: 'Custom', text: '' },
489
+ ];
490
+
491
+ /**
492
+ * The work-wide preface prompt: a narrative angle preset plus anything else
493
+ * the author wants pinned in front of every request.
494
+ */
495
+ function PreludeDialog({ project, onCancel, onSaved }) {
496
+ const current = project.prelude || null;
497
+ const [mode, setMode] = useState(current && current.mode ? current.mode : '');
498
+ const [text, setText] = useState(current && current.text ? current.text : '');
499
+ const [busy, setBusy] = useState(false);
500
+ const [error, setError] = useState('');
501
+
502
+ const pickMode = useCallback((id) => {
503
+ setMode(id);
504
+ const preset = PRELUDE_MODES.find((entry) => entry.id === id);
505
+ if (preset && preset.text !== '') setText(preset.text);
506
+ if (id === '') setText('');
507
+ }, []);
508
+
509
+ const save = useCallback(async () => {
510
+ setBusy(true);
511
+ setError('');
512
+ try {
513
+ const result = await api('/project/prelude', { projectId: project.projectId, mode, text });
514
+ await onSaved(result.project);
515
+ } catch (saveError) {
516
+ setError(messageOf(saveError));
517
+ } finally {
518
+ setBusy(false);
519
+ }
520
+ }, [project.projectId, mode, text, onSaved]);
521
+
522
+ return h('div', { className: 'dsn-dialog-backdrop' },
523
+ h('div', { className: 'dsn-dialog', style: { width: 'min(620px, calc(100% - 48px))' } },
524
+ h('div', { className: 'dsn-dialog-head' },
525
+ h('div', { className: 'dsn-title' }, t('前置提示词', 'Preface prompt')),
526
+ h('div', { className: 'dsn-sub' }, project.title),
527
+ h('div', { className: 'dsn-spacer' })),
528
+ h('div', { className: 'dsn-dialog-body' },
529
+ h('label', { className: 'dsn-field span2' },
530
+ h('span', { className: 'dsn-label' }, t('叙事角度(选一个会填入下面的文本)', 'Narrative angle (fills the text below)')),
531
+ h('select', {
532
+ className: 'dsn-select',
533
+ value: mode,
534
+ onChange: (event) => pickMode(event.target.value),
535
+ },
536
+ h('option', { value: '' }, t('不设置', 'Not set')),
537
+ PRELUDE_MODES.map((entry) => h('option', { key: entry.id, value: entry.id }, t(entry.zh, entry.en))))),
538
+ h('label', { className: 'dsn-field span2' },
539
+ h('span', { className: 'dsn-label' }, t('前置提示(可自由修改,最多 2000 字)', 'Preface text (edit freely, up to 2000 characters)')),
540
+ h('textarea', {
541
+ className: 'dsn-textarea',
542
+ style: { minHeight: '160px' },
543
+ value: text,
544
+ placeholder: t('每次生成正文、自动推演、AI 修改时都会放在提示最前面。例如:全书用第三人称限知;文风冷峻,少用形容词;不要出现现代网络用语。', 'Added in front of every generation, deduction and revision request. e.g. third-person limited; cold, spare prose; no modern slang.'),
545
+ onChange: (event) => setText(event.target.value),
546
+ })),
547
+ h('div', { className: 'dsn-hint span2' }, t('已填 {n} / 2000 字;留空即表示这本书不带前置提示。', '{n} / 2000 characters; leave it empty to send no preface.', { n: String(text).length })),
548
+ error ? h('div', { className: 'dsn-hint warn span2' }, error) : null),
549
+ h('div', { className: 'dsn-dialog-foot' },
550
+ h('button', { className: 'dsn-btn ghost', type: 'button', disabled: busy, onClick: onCancel }, t('取消', 'Cancel')),
551
+ h('div', { className: 'dsn-spacer' }),
552
+ h('button', {
553
+ className: 'dsn-btn primary',
554
+ type: 'button',
555
+ disabled: busy || String(text).length > 2000,
556
+ onClick: save,
557
+ }, busy ? t('保存中…', 'Saving…') : t('保存', 'Save')))));
558
+ }
559
+
560
+ function ProjectsPage({ boot, resolveWorkspace, onOpen, onReload, onEditModel, onEditPreset, onEditPrelude }) {
484
561
  const [title, setTitle] = useState('');
485
562
  const [cwd, setCwd] = useState('');
486
563
  const [preset, setPreset] = useState('');
@@ -725,6 +802,15 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
725
802
  onEditPreset(project);
726
803
  },
727
804
  }, t('预设', "Preset")),
805
+ h('button', {
806
+ className: 'dsn-btn ghost',
807
+ type: 'button',
808
+ title: t('设置这本书的前置提示词(叙事角度等,每次生成都会带上)', 'Set this work’s preface prompt (narrative angle and more; carried by every request)'),
809
+ onClick: (event) => {
810
+ event.stopPropagation();
811
+ onEditPrelude(project);
812
+ },
813
+ }, t('前置', 'Preface')),
728
814
  h('button', {
729
815
  className: 'dsn-btn ghost',
730
816
  type: 'button',
@@ -1128,7 +1214,46 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1128
1214
  * out of the page — back, next chapter, the outline, another project, or
1129
1215
  * closing the studio — can ask about unsaved text first.
1130
1216
  */
1131
- function StoryPage({ detail, chapter, previous, next, text, saving, onEdit, onSave, onBack, onOutline, onOpenChapter, onNewChapter }) {
1217
+ /**
1218
+ * Ask the model to rewrite the chapter from a note. The result lands back in
1219
+ * the editor as an unsaved draft, so nothing is overwritten unseen.
1220
+ */
1221
+ function ReviseDialog({ chapter, text, busy, error, onCancel, onConfirm }) {
1222
+ const [instruction, setInstruction] = useState('');
1223
+ const ready = instruction.trim() !== '' && String(text || '').trim() !== '';
1224
+
1225
+ return h('div', { className: 'dsn-dialog-backdrop' },
1226
+ h('div', { className: 'dsn-dialog', style: { width: 'min(560px, calc(100% - 48px))' } },
1227
+ h('div', { className: 'dsn-dialog-head' },
1228
+ h('div', { className: 'dsn-title' }, t('AI 修改本章', 'Revise this chapter with AI')),
1229
+ h('div', { className: 'dsn-sub' }, chapter ? t('第 {n} 章 · {title}', 'Chapter {n} · {title}', { n: chapter.index + 1, title: chapter.title || t('未命名', 'Untitled') }) : ''),
1230
+ h('div', { className: 'dsn-spacer' })),
1231
+ h('div', { className: 'dsn-dialog-body' },
1232
+ h('label', { className: 'dsn-field span4' },
1233
+ h('span', { className: 'dsn-label' }, t('修改意见(必填)', 'What to change (required)')),
1234
+ h('textarea', {
1235
+ className: 'dsn-textarea',
1236
+ style: { minHeight: '140px' },
1237
+ value: instruction,
1238
+ placeholder: t('例如:把第三章那段打斗写得更克制,减少形容词;结尾补一句主角的心理活动。', 'e.g. Tone down the fight scene, use fewer adjectives, and end on the protagonist’s inner thought.'),
1239
+ onChange: (event) => setInstruction(event.target.value),
1240
+ })),
1241
+ String(text || '').trim() === ''
1242
+ ? h('div', { className: 'dsn-hint warn span4' }, t('这一章还没有正文,先写或先生成,再让 AI 修改。', 'This chapter has no text yet — write or generate it before revising.'))
1243
+ : h('div', { className: 'dsn-hint span4' }, t('AI 会按意见重写整章,结果放进编辑区但不自动保存;你确认后再点「保存」。', 'The whole chapter is rewritten from your note. The result goes into the editor unsaved — review it, then press Save.')),
1244
+ error ? h('div', { className: 'dsn-hint warn span4' }, error) : null),
1245
+ h('div', { className: 'dsn-dialog-foot' },
1246
+ h('button', { className: 'dsn-btn ghost', type: 'button', disabled: busy, onClick: onCancel }, t('取消', 'Cancel')),
1247
+ h('div', { className: 'dsn-spacer' }),
1248
+ h('button', {
1249
+ className: 'dsn-btn primary',
1250
+ type: 'button',
1251
+ disabled: busy || !ready,
1252
+ onClick: () => onConfirm(instruction.trim()),
1253
+ }, busy ? t('修改中…', 'Revising…') : t('确认修改', 'Revise')))));
1254
+ }
1255
+
1256
+ function StoryPage({ detail, chapter, previous, next, text, saving, onEdit, onSave, onBack, onOutline, onOpenChapter, onNewChapter, onRevise }) {
1132
1257
  const words = countWords(text);
1133
1258
  const window = chapter ? chapterWindow(detail, chapter) : null;
1134
1259
 
@@ -1170,6 +1295,15 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1170
1295
  h('div', { className: 'dsn-muted' },
1171
1296
  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 }) : '' })),
1172
1297
  h('div', { className: 'dsn-spacer' }),
1298
+ h('button', {
1299
+ className: 'dsn-btn',
1300
+ type: 'button',
1301
+ disabled: saving || String(text || '').trim() === '',
1302
+ title: String(text || '').trim() === ''
1303
+ ? t('这一章还没有正文,先写或先生成', 'No text yet — write or generate the chapter first')
1304
+ : t('把本章正文和修改意见交给 AI 重写', 'Send the chapter and a note to the model for a rewrite'),
1305
+ onClick: onRevise,
1306
+ }, t('AI 修改', 'AI revise')),
1173
1307
  h('button', { className: 'dsn-btn primary', type: 'button', disabled: saving, onClick: onSave },
1174
1308
  saving ? t('保存中…', "Saving…") : t('保存', "Save")))));
1175
1309
  }
@@ -1685,6 +1819,9 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1685
1819
  return () => onBusyChange(null);
1686
1820
  }, [busy ? busy.label : null, busy ? busy.message : null, onBusyChange]);
1687
1821
  const [dialogOpen, setDialogOpen] = useState(false);
1822
+ const [reviseOpen, setReviseOpen] = useState(false);
1823
+ const [reviseError, setReviseError] = useState('');
1824
+ const [reviseJob, setReviseJob] = useJob();
1688
1825
  const [text, setText] = useState('');
1689
1826
  const [dirty, setDirty] = useState(false);
1690
1827
  const [saving, setSaving] = useState(false);
@@ -1732,9 +1869,58 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1732
1869
  }
1733
1870
  }, [chapter, detail.projectId, text, onReload, notify]);
1734
1871
 
1872
+ /** Send the chapter and a note to the model; the reply becomes a draft. */
1873
+ const startRevise = useCallback(async (instruction) => {
1874
+ if (!chapter) return;
1875
+ if (String(text || '').trim() === '') {
1876
+ setReviseError(t('这一章还没有正文,先写或先生成再让 AI 修改', 'This chapter has no text yet — write or generate it before revising.'));
1877
+ return;
1878
+ }
1879
+ if (String(instruction || '').trim() === '') {
1880
+ setReviseError(t('请先写清修改意见', 'Write what should change first.'));
1881
+ return;
1882
+ }
1883
+ setReviseError('');
1884
+ try {
1885
+ const started = await api('/chapter/revise', {
1886
+ projectId: detail.projectId,
1887
+ chapterId: chapter.chapterId,
1888
+ instruction: String(instruction).trim(),
1889
+ content: text,
1890
+ });
1891
+ setReviseJob(started);
1892
+ } catch (error) {
1893
+ setReviseError(messageOf(error));
1894
+ }
1895
+ }, [chapter, detail.projectId, text, setReviseJob]);
1896
+
1897
+ const revising = Boolean(reviseJob && reviseJob.state === 'running');
1898
+ useEffect(() => {
1899
+ if (!reviseJob) return undefined;
1900
+ if (reviseJob.state === 'running') {
1901
+ setChildBusy({
1902
+ label: t('正在按修改意见重写本章…', 'Revising the chapter…'),
1903
+ message: reviseJob.message || '',
1904
+ });
1905
+ return undefined;
1906
+ }
1907
+ setChildBusy(null);
1908
+ if (reviseJob.state === 'done') {
1909
+ const result = reviseJob.result || {};
1910
+ if (typeof result.content === 'string' && result.content.trim() !== '') {
1911
+ setText(result.content);
1912
+ setDirty(true);
1913
+ }
1914
+ setReviseOpen(false);
1915
+ notify(t('AI 已重写本章,确认后点「保存」', 'AI rewrote the chapter — review it, then press Save'), 'success');
1916
+ } else if (reviseJob.state === 'error') {
1917
+ setReviseError(reviseJob.error || t('AI 修改失败', 'Revision failed'));
1918
+ }
1919
+ return undefined;
1920
+ }, [reviseJob ? reviseJob.jobId : null, reviseJob ? reviseJob.state : null, reviseJob ? reviseJob.message : null]);
1921
+
1735
1922
  /** Run a navigation step, asking about the active page's unsaved edits. */
1736
- const leave = useCallback((action) => {
1737
- const unsaved = view === 'outline' ? outlineGuard.current.isDirty() : dirty;
1923
+ const leave = useCallback((action) => { const unsaved = view === 'outline' ? outlineGuard.current.isDirty() : dirty;
1738
1924
  if (!unsaved) {
1739
1925
  action();
1740
1926
  return;
@@ -1920,6 +2106,10 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1920
2106
  onOutline: () => leave(() => setView('outline')),
1921
2107
  onOpenChapter: openStory,
1922
2108
  onNewChapter: () => leave(() => setDialogOpen(true)),
2109
+ onRevise: () => {
2110
+ setReviseError('');
2111
+ setReviseOpen(true);
2112
+ },
1923
2113
  })),
1924
2114
  h('section', { className: 'dsn-page' },
1925
2115
  h(OutlinePage, {
@@ -1949,6 +2139,19 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1949
2139
  },
1950
2140
  })
1951
2141
  : null,
2142
+ reviseOpen
2143
+ ? h(ReviseDialog, {
2144
+ chapter,
2145
+ text,
2146
+ busy: revising,
2147
+ error: reviseError,
2148
+ onCancel: () => {
2149
+ if (revising) return;
2150
+ setReviseOpen(false);
2151
+ },
2152
+ onConfirm: startRevise,
2153
+ })
2154
+ : null,
1952
2155
  pendingLeave
1953
2156
  ? h(LeaveConfirm, {
1954
2157
  what: pendingLeave.page === 'outline' ? t('这份章节大纲', "This outline") : t('这一章的正文', "this chapter's text"),
@@ -2001,6 +2204,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2001
2204
  const [busyNow, setBusyNow] = useState(null);
2002
2205
  const [modelTarget, setModelTarget] = useState(null);
2003
2206
  const [presetTarget, setPresetTarget] = useState(null);
2207
+ const [preludeTarget, setPreludeTarget] = useState(null);
2004
2208
  const leaveGuard = useRef(null);
2005
2209
  const registerLeaveGuard = useCallback((guard) => {
2006
2210
  leaveGuard.current = guard;
@@ -2093,6 +2297,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2093
2297
  onReload: loadBoot,
2094
2298
  onEditModel: setModelTarget,
2095
2299
  onEditPreset: setPresetTarget,
2300
+ onEditPrelude: setPreludeTarget,
2096
2301
  }))
2097
2302
  : detail === null
2098
2303
  ? h('div', { className: 'dsn-empty', style: { margin: 'auto' } }, t('正在载入作品…', "Loading the work…"))
@@ -2126,9 +2331,28 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2126
2331
  onSaved: async () => {
2127
2332
  setPresetTarget(null);
2128
2333
  await loadBoot();
2334
+ // The open work shows its preset in the top bar, so refresh it too.
2335
+ if (projectId) await loadDetail(projectId);
2129
2336
  setToast({ text: t('预设已更新,重建会话后生效', 'Preset updated — it applies once the work’s session is rebuilt'), kind: 'success' });
2130
2337
  },
2131
2338
  })
2339
+ : null,
2340
+ preludeTarget
2341
+ ? h(PreludeDialog, {
2342
+ project: preludeTarget,
2343
+ onCancel: () => setPreludeTarget(null),
2344
+ onSaved: async (saved) => {
2345
+ setPreludeTarget(null);
2346
+ await loadBoot();
2347
+ const empty = !saved || !saved.prelude || !saved.prelude.text;
2348
+ setToast({
2349
+ text: empty
2350
+ ? t('已清除前置提示词', 'Preface prompt cleared')
2351
+ : t('前置提示词已保存,下一次生成就生效', 'Preface prompt saved — it applies to the next generation'),
2352
+ kind: 'success',
2353
+ });
2354
+ },
2355
+ })
2132
2356
  : null);
2133
2357
  }
2134
2358
 
package/index.js CHANGED
@@ -356,6 +356,8 @@ async function exportProjectInfo(project) {
356
356
  lines.push(`- 绑定会话:${project.sessionId || '(无)'}`);
357
357
  lines.push(`- 模型:${project.model ? `${project.model.provider} / ${project.model.model}` : '(默认)'}`);
358
358
  lines.push(`- 预设:${project.agentPreset || '(默认)'}`);
359
+ const preludeText = project.prelude && project.prelude.text ? String(project.prelude.text).trim() : '';
360
+ lines.push(`- 前置提示词:${preludeText === '' ? '(未设置)' : '已设置,见文末'}`);
359
361
  lines.push(`- 快照间隔:每 ${timeline.unitDays || 30} 天一份`);
360
362
  lines.push(`- 已写入快照:${snapshots.length ? snapshots.map((snapshot) => `第 ${snapshot.day} 天`).join('、') : '(暂无)'}`);
361
363
  lines.push(`- 故事当前:第 ${lastChapterEnd(project)} 天`);
@@ -382,6 +384,16 @@ async function exportProjectInfo(project) {
382
384
  lines.push('');
383
385
  lines.push('> 章节正文在同目录的「第NN章 标题.md」,各章梗概在「章节大纲」目录,世界快照在「世界快照」目录。');
384
386
  lines.push('');
387
+ if (preludeText !== '') {
388
+ lines.push('## 前置提示词');
389
+ lines.push('');
390
+ lines.push('每次生成正文、自动推演、AI 修改时都会放在提示最前面:');
391
+ lines.push('');
392
+ lines.push('```text');
393
+ lines.push(preludeText);
394
+ lines.push('```');
395
+ lines.push('');
396
+ }
385
397
  await writeFile(projectInfoFile(project), lines.join('\n'), 'utf8');
386
398
  } catch {
387
399
  // Best effort.
@@ -660,6 +672,9 @@ function summarize(project) {
660
672
  sessionId: project.sessionId,
661
673
  agentPreset: project.agentPreset,
662
674
  model: project.model,
675
+ // The work-wide preface prompt: narrative angle plus anything else the
676
+ // author wants every request to carry.
677
+ prelude: project.prelude || null,
663
678
  createdAt: project.createdAt,
664
679
  updatedAt: project.updatedAt,
665
680
  chapterCount: chapters.length,
@@ -1205,6 +1220,8 @@ function chapterPrompt(project, chapter, previous) {
1205
1220
  const { snapshot } = snapshotDistance(project, chapter);
1206
1221
  const lines = [];
1207
1222
  lines.push('你现在是这部小说的执笔作者。只输出本章正文,不要解释、不要标题、不要分点提纲。');
1223
+ const prelude = preludeBlock(project);
1224
+ if (prelude !== '') lines.push('', prelude);
1208
1225
  lines.push(`【篇幅】本章正文必须控制在 ${minWords}~${maxWords} 字之间,写够 ${maxWords} 字附近就收尾,超过 ${maxWords} 字视为不合格。`);
1209
1226
  lines.push('');
1210
1227
  const shownTitle = chapter.title && !/^第\s*\d+\s*章$/u.test(chapter.title) ? ` ${chapter.title}` : '';
@@ -1242,6 +1259,8 @@ function deducePrompt(project, chapter) {
1242
1259
  lines.push('你是这部小说的剧情推演器。请依据最近的世界快照,以及从该快照之后已经写过的各章梗概,推演下一章的大纲。');
1243
1260
  lines.push('只输出一个 JSON 对象,不要任何额外文字。');
1244
1261
  lines.push('');
1262
+ const prelude = preludeBlock(project);
1263
+ if (prelude !== '') lines.push(prelude);
1245
1264
  lines.push('【最近的世界快照】');
1246
1265
  lines.push(snapshotBlock(snapshot));
1247
1266
  lines.push('');
@@ -1255,6 +1274,36 @@ function deducePrompt(project, chapter) {
1255
1274
  return lines.join('\n');
1256
1275
  }
1257
1276
 
1277
+ /**
1278
+ * Ask the model to rewrite one chapter's text according to a note.
1279
+ *
1280
+ * The current text is given in full (the Client sends the version on screen, so
1281
+ * unsaved edits are included), and the reply is the whole revised chapter — not a
1282
+ * patch and not commentary.
1283
+ */
1284
+ function revisePrompt(project, chapter, currentText, instruction) {
1285
+ const span = chapterWindow(project, chapter);
1286
+ const lines = [];
1287
+ lines.push('你是这部小说的修订者。下面是本章现有正文,以及作者给出的修改意见。');
1288
+ lines.push('请按修改意见重写这一章,输出修订后的**完整正文**:不要解释、不要列出改动点、不要加标题行。');
1289
+ lines.push('');
1290
+ const prelude = preludeBlock(project);
1291
+ if (prelude !== '') lines.push(prelude);
1292
+ lines.push(`【本章】第 ${chapter.index + 1} 章 ${chapter.title || ''}(第 ${span.start}→${span.end} 天)`);
1293
+ lines.push('');
1294
+ lines.push('【现有正文】');
1295
+ lines.push(currentText);
1296
+ lines.push('');
1297
+ lines.push('【修改意见】');
1298
+ lines.push(instruction);
1299
+ lines.push('');
1300
+ lines.push('【要求】');
1301
+ lines.push('- 只改需要按意见改的地方,保持原有的叙事人称、语气和已经成立的情节;没有被意见推翻的段落尽量保留。');
1302
+ lines.push('- 输出必须是完整的一章,从开头到结尾,不能只给片段或省略号。');
1303
+ lines.push('- 不要调用任何工具,不要反问,不要输出计划或步骤说明。');
1304
+ return lines.join('\n');
1305
+ }
1306
+
1258
1307
  function snapshotPrompt(project, previous, chapters, pointDay) {
1259
1308
  const lines = [];
1260
1309
  lines.push(`你是这部小说的世界观维护者。当前要产出第 ${pointDay} 天的世界快照。`);
@@ -1428,7 +1477,7 @@ async function bootstrap(ctx, root) {
1428
1477
  }
1429
1478
  }
1430
1479
  return {
1431
- revision: '2026-10-27.4',
1480
+ revision: '2026-10-27.9',
1432
1481
  projects,
1433
1482
  models: {
1434
1483
  default: modelCatalog.default || null,
@@ -1813,6 +1862,44 @@ async function createSessionFor(ctx, root, project) {
1813
1862
  }
1814
1863
  }
1815
1864
 
1865
+ /** Longest preface prompt accepted, in characters. */
1866
+ const PRELUDE_MAX_CHARS = 2000;
1867
+
1868
+ /**
1869
+ * Save one work's preface prompt. `mode` only labels which preset the text came
1870
+ * from; the text itself is what every request carries, so the author can edit a
1871
+ * preset's wording or write their own.
1872
+ */
1873
+ async function updatePrelude(root, body) {
1874
+ const projectId = asText(body.projectId);
1875
+ const mode = asText(body.mode);
1876
+ const text = typeof body.text === 'string' ? body.text : '';
1877
+ if (text.length > PRELUDE_MAX_CHARS) {
1878
+ throw httpError(400, `前置提示词最多 ${PRELUDE_MAX_CHARS} 字`);
1879
+ }
1880
+ const trimmed = text.trim();
1881
+ const project = await mutateProject(root, projectId, (draft) => {
1882
+ draft.prelude = trimmed === '' && mode === ''
1883
+ ? null
1884
+ : { mode, text: trimmed, updatedAt: Date.now() };
1885
+ draft.updatedAt = Date.now();
1886
+ return summarize(draft);
1887
+ });
1888
+ await exportProjectInfo(await readProject(root, projectId));
1889
+ return { project };
1890
+ }
1891
+
1892
+ /** The preface block every writing request starts with, or '' when unset. */
1893
+ function preludeBlock(project) {
1894
+ const text = project && project.prelude && project.prelude.text ? String(project.prelude.text).trim() : '';
1895
+ if (text === '') return '';
1896
+ return [
1897
+ '【本书前置设定】(作者为全书设定的写作约束,优先于下文提示,但不得违反本章梗概与已发生的事实)',
1898
+ text,
1899
+ '',
1900
+ ].join('\n');
1901
+ }
1902
+
1816
1903
  /**
1817
1904
  * Record the preset a work's session should run under.
1818
1905
  *
@@ -2219,6 +2306,46 @@ async function startSnapshotReset(ctx, root, registry, body) {
2219
2306
  });
2220
2307
  }
2221
2308
 
2309
+ /**
2310
+ * Revise a chapter's text from a note, without saving it.
2311
+ *
2312
+ * The Client keeps the result in the editor as an unsaved draft, so a rewrite
2313
+ * never replaces text the author has not looked at. Both inputs are required:
2314
+ * there is nothing to revise without text, and nothing to do without a note.
2315
+ */
2316
+ async function startRevise(ctx, root, registry, body) {
2317
+ const projectId = asText(body.projectId);
2318
+ const chapterId = asText(body.chapterId);
2319
+ const instruction = asText(body.instruction);
2320
+ const draft = typeof body.content === 'string' ? body.content : '';
2321
+ if (draft.trim() === '') throw httpError(400, '这一章还没有正文,先写或先生成再让 AI 修改');
2322
+ if (instruction === '') throw httpError(400, '请先写清修改意见');
2323
+ const preview = await readProject(root, projectId);
2324
+ const claimed = preview.chapters.find((entry) => entry.chapterId === chapterId);
2325
+ if (!claimed) throw httpError(404, `章节不存在: ${chapterId}`);
2326
+ return startJob(registry, { kind: 'revise', projectId, chapterId }, async (job) => {
2327
+ const project = await readProject(root, projectId);
2328
+ const chapter = project.chapters.find((entry) => entry.chapterId === chapterId);
2329
+ if (!chapter) throw httpError(404, `章节不存在: ${chapterId}`);
2330
+ job.message = '正在按修改意见重写本章';
2331
+ const raw = await askStory(ctx, root, project, revisePrompt(project, chapter, draft, instruction), (note) => {
2332
+ job.message = note;
2333
+ });
2334
+ // Keep the author's own length: never cut below what they already had.
2335
+ const limit = Math.max(
2336
+ (chapter.outline && chapter.outline.maxWords) || 3000,
2337
+ Math.round(wordCount(draft) * 1.3),
2338
+ );
2339
+ const fitted = fitChapterText(raw, limit);
2340
+ job.message = fitted.trimmed ? '模型写超了字数,已按上限截断' : '完成';
2341
+ return {
2342
+ content: fitted.text,
2343
+ wordCount: wordCount(fitted.text),
2344
+ trimmed: fitted.trimmed,
2345
+ };
2346
+ });
2347
+ }
2348
+
2222
2349
  async function startGenerate(ctx, root, registry, body) {
2223
2350
  const projectId = asText(body.projectId);
2224
2351
  const chapterId = asText(body.chapterId);
@@ -2516,6 +2643,8 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
2516
2643
  return updateModel(ctx, root, body);
2517
2644
  case '/project/preset':
2518
2645
  return updatePreset(ctx, root, body);
2646
+ case '/project/prelude':
2647
+ return updatePrelude(root, body);
2519
2648
  case '/project/rebind':
2520
2649
  return rebindProject(ctx, root, body);
2521
2650
  case '/project/move':
@@ -2528,6 +2657,8 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
2528
2657
  return saveChapter(root, body);
2529
2658
  case '/generate':
2530
2659
  return startGenerate(ctx, root, registry, body);
2660
+ case '/chapter/revise':
2661
+ return startRevise(ctx, root, registry, body);
2531
2662
  case '/compose':
2532
2663
  return startCompose(ctx, root, registry, body);
2533
2664
  case '/deduce':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-novel-generator",
3
- "version": "0.1.21",
3
+ "version": "0.1.25",
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",