dsh-novel-generator 0.1.23 → 0.1.26

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 +17 -0
  2. package/client.js +227 -5
  3. package/index.js +186 -1
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -64,6 +64,23 @@ 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
+ **新建作品默认就带「第三人称全知」那一段**(传统修仙文、禁贴身 POV、环境优先等 6 条),不喜欢可以直接改或清空。
72
+
73
+ - 每次**生成正文、自动推演、AI 修改**时,这段文本都会以「【本书前置设定】…」的形式放在提示最前面;生成世界快照时不带(那是结构化数据任务)。
74
+ - 保存后**下一次生成就生效**,不需要重建会话——它不依赖会话状态。
75
+ - 内容同时写进作品的 `作品信息.md`(文末「前置提示词」一节),便于外部查看与备份。
76
+ - 留空即表示不设置。
77
+
78
+ ## 章节故事页
79
+
80
+ - 正文可以直接编辑、保存;工具条上还有「AI 修改」:填入修改意见后,模型按意见重写整章。
81
+ - 正文为空时按钮不可点,接口也会拒绝(400);修改意见为空同样拒绝。
82
+ - **重写结果只放进编辑区、不自动保存**,状态变成「未保存」,你确认后再点「保存」——避免 AI 的改动直接覆盖原稿。
83
+
67
84
  ## 迁移、导入与会话
68
85
 
69
86
  - **改预设**:作品列表每行的「预设」改的是「下次建会话时用哪个预设」。会话在创建时就固定了预设,所以保存后会提示「重建会话后生效」——要立刻换,点同一行的「重建会话」。
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,82 @@ 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
+ /**
484
+ * The work-wide preface prompt: a narrative angle preset plus anything else
485
+ * the author wants pinned in front of every request. The preset wording comes
486
+ * from the Host so there is one copy of the text.
487
+ */
488
+ function PreludeDialog({ project, presets, defaultMode, onCancel, onSaved }) {
489
+ const modes = Array.isArray(presets) && presets.length ? presets : [];
490
+ const fallbackMode = defaultMode || 'thirdOmniscient';
491
+ const current = project.prelude || null;
492
+ const initialMode = current && current.mode ? current.mode : fallbackMode;
493
+ const initialPreset = modes.find((entry) => entry.id === initialMode);
494
+ const [mode, setMode] = useState(initialMode);
495
+ const [text, setText] = useState(
496
+ current && current.text ? current.text : (initialPreset && initialPreset.text) || '',
497
+ );
498
+ const [busy, setBusy] = useState(false);
499
+ const [error, setError] = useState('');
500
+
501
+ const pickMode = useCallback((id) => {
502
+ setMode(id);
503
+ const preset = modes.find((entry) => entry.id === id);
504
+ setText(preset && preset.text ? preset.text : '');
505
+ }, [modes]);
506
+
507
+ const save = useCallback(async () => {
508
+ setBusy(true);
509
+ setError('');
510
+ try {
511
+ const result = await api('/project/prelude', { projectId: project.projectId, mode, text });
512
+ await onSaved(result.project);
513
+ } catch (saveError) {
514
+ setError(messageOf(saveError));
515
+ } finally {
516
+ setBusy(false);
517
+ }
518
+ }, [project.projectId, mode, text, onSaved]);
519
+
520
+ return h('div', { className: 'dsn-dialog-backdrop' },
521
+ h('div', { className: 'dsn-dialog', style: { width: 'min(620px, calc(100% - 48px))' } },
522
+ h('div', { className: 'dsn-dialog-head' },
523
+ h('div', { className: 'dsn-title' }, t('前置提示词', 'Preface prompt')),
524
+ h('div', { className: 'dsn-sub' }, project.title),
525
+ h('div', { className: 'dsn-spacer' })),
526
+ h('div', { className: 'dsn-dialog-body' },
527
+ h('label', { className: 'dsn-field span2' },
528
+ h('span', { className: 'dsn-label' }, t('叙事角度(选一个会填入下面的文本)', 'Narrative angle (fills the text below)')),
529
+ h('select', {
530
+ className: 'dsn-select',
531
+ value: mode,
532
+ onChange: (event) => pickMode(event.target.value),
533
+ },
534
+ h('option', { value: '' }, t('不设置', 'Not set')),
535
+ modes.map((entry) => h('option', { key: entry.id, value: entry.id }, t(entry.zh, entry.en))))),
536
+ h('label', { className: 'dsn-field span2' },
537
+ h('span', { className: 'dsn-label' }, t('前置提示(可自由修改,最多 2000 字)', 'Preface text (edit freely, up to 2000 characters)')),
538
+ h('textarea', {
539
+ className: 'dsn-textarea',
540
+ style: { minHeight: '200px' },
541
+ value: text,
542
+ placeholder: t('每次生成正文、自动推演、AI 修改时都会放在提示最前面。', 'Added in front of every generation, deduction and revision request.'),
543
+ onChange: (event) => setText(event.target.value),
544
+ })),
545
+ h('div', { className: 'dsn-hint span2' }, t('已填 {n} / 2000 字;留空即表示这本书不带前置提示。', '{n} / 2000 characters; leave it empty to send no preface.', { n: String(text).length })),
546
+ error ? h('div', { className: 'dsn-hint warn span2' }, error) : null),
547
+ h('div', { className: 'dsn-dialog-foot' },
548
+ h('button', { className: 'dsn-btn ghost', type: 'button', disabled: busy, onClick: onCancel }, t('取消', 'Cancel')),
549
+ h('div', { className: 'dsn-spacer' }),
550
+ h('button', {
551
+ className: 'dsn-btn primary',
552
+ type: 'button',
553
+ disabled: busy || String(text).length > 2000,
554
+ onClick: save,
555
+ }, busy ? t('保存中…', 'Saving…') : t('保存', 'Save')))));
556
+ }
557
+
558
+ function ProjectsPage({ boot, resolveWorkspace, onOpen, onReload, onEditModel, onEditPreset, onEditPrelude }) {
484
559
  const [title, setTitle] = useState('');
485
560
  const [cwd, setCwd] = useState('');
486
561
  const [preset, setPreset] = useState('');
@@ -725,6 +800,15 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
725
800
  onEditPreset(project);
726
801
  },
727
802
  }, t('预设', "Preset")),
803
+ h('button', {
804
+ className: 'dsn-btn ghost',
805
+ type: 'button',
806
+ title: t('设置这本书的前置提示词(叙事角度等,每次生成都会带上)', 'Set this work’s preface prompt (narrative angle and more; carried by every request)'),
807
+ onClick: (event) => {
808
+ event.stopPropagation();
809
+ onEditPrelude(project);
810
+ },
811
+ }, t('前置', 'Preface')),
728
812
  h('button', {
729
813
  className: 'dsn-btn ghost',
730
814
  type: 'button',
@@ -1128,7 +1212,46 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1128
1212
  * out of the page — back, next chapter, the outline, another project, or
1129
1213
  * closing the studio — can ask about unsaved text first.
1130
1214
  */
1131
- function StoryPage({ detail, chapter, previous, next, text, saving, onEdit, onSave, onBack, onOutline, onOpenChapter, onNewChapter }) {
1215
+ /**
1216
+ * Ask the model to rewrite the chapter from a note. The result lands back in
1217
+ * the editor as an unsaved draft, so nothing is overwritten unseen.
1218
+ */
1219
+ function ReviseDialog({ chapter, text, busy, error, onCancel, onConfirm }) {
1220
+ const [instruction, setInstruction] = useState('');
1221
+ const ready = instruction.trim() !== '' && String(text || '').trim() !== '';
1222
+
1223
+ return h('div', { className: 'dsn-dialog-backdrop' },
1224
+ h('div', { className: 'dsn-dialog', style: { width: 'min(560px, calc(100% - 48px))' } },
1225
+ h('div', { className: 'dsn-dialog-head' },
1226
+ h('div', { className: 'dsn-title' }, t('AI 修改本章', 'Revise this chapter with AI')),
1227
+ h('div', { className: 'dsn-sub' }, chapter ? t('第 {n} 章 · {title}', 'Chapter {n} · {title}', { n: chapter.index + 1, title: chapter.title || t('未命名', 'Untitled') }) : ''),
1228
+ h('div', { className: 'dsn-spacer' })),
1229
+ h('div', { className: 'dsn-dialog-body' },
1230
+ h('label', { className: 'dsn-field span4' },
1231
+ h('span', { className: 'dsn-label' }, t('修改意见(必填)', 'What to change (required)')),
1232
+ h('textarea', {
1233
+ className: 'dsn-textarea',
1234
+ style: { minHeight: '140px' },
1235
+ value: instruction,
1236
+ placeholder: t('例如:把第三章那段打斗写得更克制,减少形容词;结尾补一句主角的心理活动。', 'e.g. Tone down the fight scene, use fewer adjectives, and end on the protagonist’s inner thought.'),
1237
+ onChange: (event) => setInstruction(event.target.value),
1238
+ })),
1239
+ String(text || '').trim() === ''
1240
+ ? h('div', { className: 'dsn-hint warn span4' }, t('这一章还没有正文,先写或先生成,再让 AI 修改。', 'This chapter has no text yet — write or generate it before revising.'))
1241
+ : 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.')),
1242
+ error ? h('div', { className: 'dsn-hint warn span4' }, error) : null),
1243
+ h('div', { className: 'dsn-dialog-foot' },
1244
+ h('button', { className: 'dsn-btn ghost', type: 'button', disabled: busy, onClick: onCancel }, t('取消', 'Cancel')),
1245
+ h('div', { className: 'dsn-spacer' }),
1246
+ h('button', {
1247
+ className: 'dsn-btn primary',
1248
+ type: 'button',
1249
+ disabled: busy || !ready,
1250
+ onClick: () => onConfirm(instruction.trim()),
1251
+ }, busy ? t('修改中…', 'Revising…') : t('确认修改', 'Revise')))));
1252
+ }
1253
+
1254
+ function StoryPage({ detail, chapter, previous, next, text, saving, onEdit, onSave, onBack, onOutline, onOpenChapter, onNewChapter, onRevise }) {
1132
1255
  const words = countWords(text);
1133
1256
  const window = chapter ? chapterWindow(detail, chapter) : null;
1134
1257
 
@@ -1170,6 +1293,15 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1170
1293
  h('div', { className: 'dsn-muted' },
1171
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 }) : '' })),
1172
1295
  h('div', { className: 'dsn-spacer' }),
1296
+ h('button', {
1297
+ className: 'dsn-btn',
1298
+ type: 'button',
1299
+ disabled: saving || String(text || '').trim() === '',
1300
+ title: String(text || '').trim() === ''
1301
+ ? t('这一章还没有正文,先写或先生成', 'No text yet — write or generate the chapter first')
1302
+ : t('把本章正文和修改意见交给 AI 重写', 'Send the chapter and a note to the model for a rewrite'),
1303
+ onClick: onRevise,
1304
+ }, t('AI 修改', 'AI revise')),
1173
1305
  h('button', { className: 'dsn-btn primary', type: 'button', disabled: saving, onClick: onSave },
1174
1306
  saving ? t('保存中…', "Saving…") : t('保存', "Save")))));
1175
1307
  }
@@ -1685,6 +1817,9 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1685
1817
  return () => onBusyChange(null);
1686
1818
  }, [busy ? busy.label : null, busy ? busy.message : null, onBusyChange]);
1687
1819
  const [dialogOpen, setDialogOpen] = useState(false);
1820
+ const [reviseOpen, setReviseOpen] = useState(false);
1821
+ const [reviseError, setReviseError] = useState('');
1822
+ const [reviseJob, setReviseJob] = useJob();
1688
1823
  const [text, setText] = useState('');
1689
1824
  const [dirty, setDirty] = useState(false);
1690
1825
  const [saving, setSaving] = useState(false);
@@ -1732,9 +1867,58 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1732
1867
  }
1733
1868
  }, [chapter, detail.projectId, text, onReload, notify]);
1734
1869
 
1870
+ /** Send the chapter and a note to the model; the reply becomes a draft. */
1871
+ const startRevise = useCallback(async (instruction) => {
1872
+ if (!chapter) return;
1873
+ if (String(text || '').trim() === '') {
1874
+ setReviseError(t('这一章还没有正文,先写或先生成再让 AI 修改', 'This chapter has no text yet — write or generate it before revising.'));
1875
+ return;
1876
+ }
1877
+ if (String(instruction || '').trim() === '') {
1878
+ setReviseError(t('请先写清修改意见', 'Write what should change first.'));
1879
+ return;
1880
+ }
1881
+ setReviseError('');
1882
+ try {
1883
+ const started = await api('/chapter/revise', {
1884
+ projectId: detail.projectId,
1885
+ chapterId: chapter.chapterId,
1886
+ instruction: String(instruction).trim(),
1887
+ content: text,
1888
+ });
1889
+ setReviseJob(started);
1890
+ } catch (error) {
1891
+ setReviseError(messageOf(error));
1892
+ }
1893
+ }, [chapter, detail.projectId, text, setReviseJob]);
1894
+
1895
+ const revising = Boolean(reviseJob && reviseJob.state === 'running');
1896
+ useEffect(() => {
1897
+ if (!reviseJob) return undefined;
1898
+ if (reviseJob.state === 'running') {
1899
+ setChildBusy({
1900
+ label: t('正在按修改意见重写本章…', 'Revising the chapter…'),
1901
+ message: reviseJob.message || '',
1902
+ });
1903
+ return undefined;
1904
+ }
1905
+ setChildBusy(null);
1906
+ if (reviseJob.state === 'done') {
1907
+ const result = reviseJob.result || {};
1908
+ if (typeof result.content === 'string' && result.content.trim() !== '') {
1909
+ setText(result.content);
1910
+ setDirty(true);
1911
+ }
1912
+ setReviseOpen(false);
1913
+ notify(t('AI 已重写本章,确认后点「保存」', 'AI rewrote the chapter — review it, then press Save'), 'success');
1914
+ } else if (reviseJob.state === 'error') {
1915
+ setReviseError(reviseJob.error || t('AI 修改失败', 'Revision failed'));
1916
+ }
1917
+ return undefined;
1918
+ }, [reviseJob ? reviseJob.jobId : null, reviseJob ? reviseJob.state : null, reviseJob ? reviseJob.message : null]);
1919
+
1735
1920
  /** 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;
1921
+ const leave = useCallback((action) => { const unsaved = view === 'outline' ? outlineGuard.current.isDirty() : dirty;
1738
1922
  if (!unsaved) {
1739
1923
  action();
1740
1924
  return;
@@ -1920,6 +2104,10 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1920
2104
  onOutline: () => leave(() => setView('outline')),
1921
2105
  onOpenChapter: openStory,
1922
2106
  onNewChapter: () => leave(() => setDialogOpen(true)),
2107
+ onRevise: () => {
2108
+ setReviseError('');
2109
+ setReviseOpen(true);
2110
+ },
1923
2111
  })),
1924
2112
  h('section', { className: 'dsn-page' },
1925
2113
  h(OutlinePage, {
@@ -1949,6 +2137,19 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1949
2137
  },
1950
2138
  })
1951
2139
  : null,
2140
+ reviseOpen
2141
+ ? h(ReviseDialog, {
2142
+ chapter,
2143
+ text,
2144
+ busy: revising,
2145
+ error: reviseError,
2146
+ onCancel: () => {
2147
+ if (revising) return;
2148
+ setReviseOpen(false);
2149
+ },
2150
+ onConfirm: startRevise,
2151
+ })
2152
+ : null,
1952
2153
  pendingLeave
1953
2154
  ? h(LeaveConfirm, {
1954
2155
  what: pendingLeave.page === 'outline' ? t('这份章节大纲', "This outline") : t('这一章的正文', "this chapter's text"),
@@ -2001,6 +2202,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2001
2202
  const [busyNow, setBusyNow] = useState(null);
2002
2203
  const [modelTarget, setModelTarget] = useState(null);
2003
2204
  const [presetTarget, setPresetTarget] = useState(null);
2205
+ const [preludeTarget, setPreludeTarget] = useState(null);
2004
2206
  const leaveGuard = useRef(null);
2005
2207
  const registerLeaveGuard = useCallback((guard) => {
2006
2208
  leaveGuard.current = guard;
@@ -2093,6 +2295,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2093
2295
  onReload: loadBoot,
2094
2296
  onEditModel: setModelTarget,
2095
2297
  onEditPreset: setPresetTarget,
2298
+ onEditPrelude: setPreludeTarget,
2096
2299
  }))
2097
2300
  : detail === null
2098
2301
  ? h('div', { className: 'dsn-empty', style: { margin: 'auto' } }, t('正在载入作品…', "Loading the work…"))
@@ -2131,6 +2334,25 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2131
2334
  setToast({ text: t('预设已更新,重建会话后生效', 'Preset updated — it applies once the work’s session is rebuilt'), kind: 'success' });
2132
2335
  },
2133
2336
  })
2337
+ : null,
2338
+ preludeTarget
2339
+ ? h(PreludeDialog, {
2340
+ project: preludeTarget,
2341
+ presets: boot ? boot.preludePresets : [],
2342
+ defaultMode: boot ? boot.defaultPreludeMode : 'thirdOmniscient',
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
+ })
2134
2356
  : null);
2135
2357
  }
2136
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.6',
1480
+ revision: '2026-10-27.10',
1432
1481
  projects,
1433
1482
  models: {
1434
1483
  default: modelCatalog.default || null,
@@ -1445,6 +1494,9 @@ async function bootstrap(ctx, root) {
1445
1494
  failures: modelCatalog.failures || [],
1446
1495
  },
1447
1496
  presets: presetList,
1497
+ // Canonical preface wording, so the Client does not keep a second copy.
1498
+ preludePresets: PRELUDE_PRESETS,
1499
+ defaultPreludeMode: DEFAULT_PRELUDE_MODE,
1448
1500
  };
1449
1501
  }
1450
1502
 
@@ -1496,6 +1548,9 @@ async function createProject(ctx, root, body) {
1496
1548
  cwd,
1497
1549
  agentPreset: agentPreset || created.agentPreset || null,
1498
1550
  model,
1551
+ // Every new work starts with the default narrative preface; the author can
1552
+ // edit or clear it from the work list.
1553
+ prelude: defaultPrelude(),
1499
1554
  sessionId,
1500
1555
  createdAt: Date.now(),
1501
1556
  updatedAt: Date.now(),
@@ -1813,6 +1868,92 @@ async function createSessionFor(ctx, root, project) {
1813
1868
  }
1814
1869
  }
1815
1870
 
1871
+ /** Longest preface prompt accepted, in characters. */
1872
+ const PRELUDE_MAX_CHARS = 2000;
1873
+
1874
+ /**
1875
+ * Built-in preface prompts. The Host owns the canonical wording, so the Client,
1876
+ * the creation default and any migration all read the same text.
1877
+ */
1878
+ const PRELUDE_PRESETS = [
1879
+ {
1880
+ id: 'thirdOmniscient',
1881
+ zh: '第三人称全知',
1882
+ en: 'Third person, omniscient',
1883
+ text: [
1884
+ '写作要求:传统修仙文第三人称叙事,参考忘语《凡人修仙传》风格,禁止贴身 POV 视角。',
1885
+ '',
1886
+ '1. 叙事优先用环境、场景作为句子开头,不要每一句都从主角的感受出发;',
1887
+ '2. 控制“他”字的出现频率,不要大量以“他”开头造句;',
1888
+ '3. 描写动作、环境、事件为主,不要细碎拆解主角每一丝触觉、听觉;',
1889
+ '4. 可以写主角的反应,但不要连续堆砌“他看见、他听见、他感到”;',
1890
+ '5. 句子连贯,不要大量一句一段的超短碎句;',
1891
+ '6. 以作者旁白来铺陈场景,把感官描写融入环境,不要单独拎出来写角色内心感受。',
1892
+ ].join('\n'),
1893
+ },
1894
+ {
1895
+ id: 'thirdLimited',
1896
+ zh: '第三人称限知',
1897
+ en: 'Third person, limited',
1898
+ text: '全书使用第三人称限知视角:叙述跟随主角,只呈现他能感知到的一切,不越界写其他角色的内心。',
1899
+ },
1900
+ {
1901
+ id: 'first',
1902
+ zh: '第一人称',
1903
+ en: 'First person',
1904
+ text: '全书使用第一人称叙述:只写「我」亲眼所见、亲耳所听、亲身所想,不描写别人的内心活动。',
1905
+ },
1906
+ {
1907
+ id: 'custom',
1908
+ zh: '自定义',
1909
+ en: 'Custom',
1910
+ text: '',
1911
+ },
1912
+ ];
1913
+
1914
+ /** The preface a fresh work starts with. */
1915
+ const DEFAULT_PRELUDE_MODE = 'thirdOmniscient';
1916
+
1917
+ function defaultPrelude() {
1918
+ const preset = PRELUDE_PRESETS.find((entry) => entry.id === DEFAULT_PRELUDE_MODE);
1919
+ return preset ? { mode: preset.id, text: preset.text, updatedAt: Date.now() } : null;
1920
+ }
1921
+
1922
+ /**
1923
+ * Save one work's preface prompt. `mode` only labels which preset the text came
1924
+ * from; the text itself is what every request carries, so the author can edit a
1925
+ * preset's wording or write their own.
1926
+ */
1927
+ async function updatePrelude(root, body) {
1928
+ const projectId = asText(body.projectId);
1929
+ const mode = asText(body.mode);
1930
+ const text = typeof body.text === 'string' ? body.text : '';
1931
+ if (text.length > PRELUDE_MAX_CHARS) {
1932
+ throw httpError(400, `前置提示词最多 ${PRELUDE_MAX_CHARS} 字`);
1933
+ }
1934
+ const trimmed = text.trim();
1935
+ const project = await mutateProject(root, projectId, (draft) => {
1936
+ draft.prelude = trimmed === '' && mode === ''
1937
+ ? null
1938
+ : { mode, text: trimmed, updatedAt: Date.now() };
1939
+ draft.updatedAt = Date.now();
1940
+ return summarize(draft);
1941
+ });
1942
+ await exportProjectInfo(await readProject(root, projectId));
1943
+ return { project };
1944
+ }
1945
+
1946
+ /** The preface block every writing request starts with, or '' when unset. */
1947
+ function preludeBlock(project) {
1948
+ const text = project && project.prelude && project.prelude.text ? String(project.prelude.text).trim() : '';
1949
+ if (text === '') return '';
1950
+ return [
1951
+ '【本书前置设定】(作者为全书设定的写作约束,优先于下文提示,但不得违反本章梗概与已发生的事实)',
1952
+ text,
1953
+ '',
1954
+ ].join('\n');
1955
+ }
1956
+
1816
1957
  /**
1817
1958
  * Record the preset a work's session should run under.
1818
1959
  *
@@ -2219,6 +2360,46 @@ async function startSnapshotReset(ctx, root, registry, body) {
2219
2360
  });
2220
2361
  }
2221
2362
 
2363
+ /**
2364
+ * Revise a chapter's text from a note, without saving it.
2365
+ *
2366
+ * The Client keeps the result in the editor as an unsaved draft, so a rewrite
2367
+ * never replaces text the author has not looked at. Both inputs are required:
2368
+ * there is nothing to revise without text, and nothing to do without a note.
2369
+ */
2370
+ async function startRevise(ctx, root, registry, body) {
2371
+ const projectId = asText(body.projectId);
2372
+ const chapterId = asText(body.chapterId);
2373
+ const instruction = asText(body.instruction);
2374
+ const draft = typeof body.content === 'string' ? body.content : '';
2375
+ if (draft.trim() === '') throw httpError(400, '这一章还没有正文,先写或先生成再让 AI 修改');
2376
+ if (instruction === '') throw httpError(400, '请先写清修改意见');
2377
+ const preview = await readProject(root, projectId);
2378
+ const claimed = preview.chapters.find((entry) => entry.chapterId === chapterId);
2379
+ if (!claimed) throw httpError(404, `章节不存在: ${chapterId}`);
2380
+ return startJob(registry, { kind: 'revise', projectId, chapterId }, async (job) => {
2381
+ const project = await readProject(root, projectId);
2382
+ const chapter = project.chapters.find((entry) => entry.chapterId === chapterId);
2383
+ if (!chapter) throw httpError(404, `章节不存在: ${chapterId}`);
2384
+ job.message = '正在按修改意见重写本章';
2385
+ const raw = await askStory(ctx, root, project, revisePrompt(project, chapter, draft, instruction), (note) => {
2386
+ job.message = note;
2387
+ });
2388
+ // Keep the author's own length: never cut below what they already had.
2389
+ const limit = Math.max(
2390
+ (chapter.outline && chapter.outline.maxWords) || 3000,
2391
+ Math.round(wordCount(draft) * 1.3),
2392
+ );
2393
+ const fitted = fitChapterText(raw, limit);
2394
+ job.message = fitted.trimmed ? '模型写超了字数,已按上限截断' : '完成';
2395
+ return {
2396
+ content: fitted.text,
2397
+ wordCount: wordCount(fitted.text),
2398
+ trimmed: fitted.trimmed,
2399
+ };
2400
+ });
2401
+ }
2402
+
2222
2403
  async function startGenerate(ctx, root, registry, body) {
2223
2404
  const projectId = asText(body.projectId);
2224
2405
  const chapterId = asText(body.chapterId);
@@ -2516,6 +2697,8 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
2516
2697
  return updateModel(ctx, root, body);
2517
2698
  case '/project/preset':
2518
2699
  return updatePreset(ctx, root, body);
2700
+ case '/project/prelude':
2701
+ return updatePrelude(root, body);
2519
2702
  case '/project/rebind':
2520
2703
  return rebindProject(ctx, root, body);
2521
2704
  case '/project/move':
@@ -2528,6 +2711,8 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
2528
2711
  return saveChapter(root, body);
2529
2712
  case '/generate':
2530
2713
  return startGenerate(ctx, root, registry, body);
2714
+ case '/chapter/revise':
2715
+ return startRevise(ctx, root, registry, body);
2531
2716
  case '/compose':
2532
2717
  return startCompose(ctx, root, registry, body);
2533
2718
  case '/deduce':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-novel-generator",
3
- "version": "0.1.23",
3
+ "version": "0.1.26",
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",