dsh-novel-generator 0.1.40 → 0.1.42

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 +35 -0
  2. package/client.js +193 -1
  3. package/index.js +252 -1
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  DSH 的常驻插件:在时间线上写小说。每个作品绑定一个普通会话,章节正文与世界快照都由那个会话里自己的 Agent 生成。
4
4
 
5
+ ## 截图
6
+
7
+ 作品列表(每行「模型 / 预设 / 前置 / 设置 / 导入章节 / 重建会话 / 改目录 / 删除」):
8
+
9
+ ![作品列表](screenshots/1-list.png)
10
+
11
+ 时间长河与世界快照:
12
+
13
+ ![时间长河](screenshots/2-timeline.png)
14
+
15
+ 章节故事页(编辑、同步章节大纲、提取行文风格、AI 修改):
16
+
17
+ ![章节故事](screenshots/3-chapter.png)
18
+
19
+ 章节大纲页(梗概、字数范围、时间长度、AI 修改梗概):
20
+
21
+ ![章节大纲](screenshots/4-outline.png)
22
+
23
+ > 这四张也在 `screenshots.json` 里声明了顺序,插件市场详情页会按这个顺序展示。
24
+
5
25
  ## 安装
6
26
 
7
27
  ```sh
@@ -96,6 +116,21 @@ dsh plugin --profile web add dsh-novel-generator
96
116
  - 内容同时写进作品的 `作品信息.md`(文末「前置提示词」一节),便于外部查看与备份。
97
117
  - 留空即表示不设置。
98
118
 
119
+ ## 作品设置:前文参考程度
120
+
121
+ 作品列表每行的「设置」里,控制**自动推演**与**生成章节故事**时对前文的参考程度:
122
+
123
+ | 选项 | 提示词里带什么 |
124
+ |---|---|
125
+ | **前一章(全文)**(默认) | 上一章整章正文,用来接住语气、场景与最后一句 |
126
+ | 前一章的后一半 | 只从上一章中间那句话开始给到结尾 |
127
+ | 不参考正文 | 只靠世界快照与各章梗概推进 |
128
+
129
+ - 上一章正文超过 8000 字时只取**最后 8000 字**(衔接看结尾,不看开头)。
130
+ - 「上一章」按起始日排序取本章之前最近的一章;它没有正文(空章节/导入未写)时该块自动省略。
131
+ - 参考块出现在提示词里「已发生的章节梗概」之后、「本章梗概」之前。
132
+ - 自动推演(`/deduce`、`/compose`)与生成正文(`/generate`)共用这一设置;同步大纲、提取风格、世界快照**不带**前文正文(那些是测量/结构化任务,带前文只会干扰判断)。
133
+
99
134
  ## 章节故事页
100
135
 
101
136
  - 正文可以直接编辑、保存;工具条上还有三个按钮:
package/client.js CHANGED
@@ -268,6 +268,19 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
268
268
  .dsn-importtitle{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary)}
269
269
  .dsn-importchars{color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}
270
270
  .dsn-importwarn{color:var(--dsw-alias-state-warn-primary)}
271
+ .dsn-prompt-body{display:flex;gap:12px;min-height:320px;max-height:min(58vh,520px)}
272
+ .dsn-prompt-list{display:flex;flex-direction:column;gap:4px;min-width:150px;overflow:auto}
273
+ .dsn-prompt-item{text-align:left;font-size:12px;padding:7px 10px;border-radius:8px;cursor:pointer;
274
+ border:1px solid transparent;background:transparent;color:var(--dsw-alias-label-secondary)}
275
+ .dsn-prompt-item:hover{background:var(--dsw-alias-bg-layer-3)}
276
+ .dsn-prompt-item.active{background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);
277
+ border-color:var(--dsw-alias-border-l2)}
278
+ .dsn-prompt-pane{flex:1;min-width:0;display:flex;flex-direction:column;gap:8px}
279
+ .dsn-prompt-when{font-size:11px;color:var(--dsw-alias-label-secondary)}
280
+ .dsn-prompt-text{flex:1;margin:0;overflow:auto;white-space:pre-wrap;word-break:break-word;
281
+ font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.65;
282
+ padding:10px 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);
283
+ background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary)}
271
284
  .dsn-progress{font-size:12px;color:var(--dsw-alias-label-secondary);display:flex;align-items:center;gap:8px}
272
285
  .dsn-spin{width:12px;height:12px;border-radius:50%;border:2px solid var(--dsw-alias-border-l2);
273
286
  border-top-color:var(--dsw-alias-brand-primary);animation:dsn-spin .9s linear infinite}
@@ -751,6 +764,64 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
751
764
  h('button', { className: 'dsn-btn primary', type: 'button', onClick: onGoTimeline }, t('去时间长河', 'Open timeline')))));
752
765
  }
753
766
 
767
+ /**
768
+ * Work settings. The first section decides how much of the previous chapter's
769
+ * prose the writing and deduction prompts carry — the knob that trades
770
+ * continuity against prompt size.
771
+ */
772
+ function SettingsDialog({ project, onCancel, onSaved }) {
773
+ const [reference, setReference] = useState(project.reference || 'full');
774
+ const [busy, setBusy] = useState(false);
775
+ const [error, setError] = useState('');
776
+
777
+ const save = useCallback(async () => {
778
+ setBusy(true);
779
+ setError('');
780
+ try {
781
+ await api('/project/settings', { projectId: project.projectId, reference });
782
+ await onSaved(reference);
783
+ } catch (saveError) {
784
+ setError(messageOf(saveError));
785
+ } finally {
786
+ setBusy(false);
787
+ }
788
+ }, [project.projectId, reference, onSaved]);
789
+
790
+ const option = (value, title, hint) => h('label', { className: 'dsn-check', style: { alignItems: 'flex-start' } },
791
+ h('input', {
792
+ type: 'radio',
793
+ name: 'dsn-reference',
794
+ checked: reference === value,
795
+ onChange: () => setReference(value),
796
+ }),
797
+ h('span', null,
798
+ h('div', null, title),
799
+ h('div', { className: 'dsn-hint' }, hint)));
800
+
801
+ return h('div', { className: 'dsn-dialog-backdrop' },
802
+ h('div', { className: 'dsn-dialog', style: { width: 'min(560px, calc(100% - 48px))' } },
803
+ h('div', { className: 'dsn-dialog-head' },
804
+ h('div', { className: 'dsn-title' }, t('作品设置', 'Work settings')),
805
+ h('div', { className: 'dsn-sub' }, project.title),
806
+ h('div', { className: 'dsn-spacer' })),
807
+ h('div', { className: 'dsn-dialog-body' },
808
+ h('div', { className: 'dsn-label span4' }, t('自动推演 / 生成章节故事 · 前文参考程度', 'Auto-deduction / chapter generation · previous text')),
809
+ h('div', { className: 'dsn-field span4', style: { gap: 10 } },
810
+ option('full', t('前一章(全文)', 'Previous chapter (full text)'),
811
+ t('衔接最稳,提示词最长:前一章整章正文都会带上(超过 8000 字只给最后 8000 字)。', 'Best continuity, longest prompt: the whole previous chapter, trimmed to its last 8000 characters when it is longer.')),
812
+ option('half', t('前一章的后一半', 'Previous chapter (second half)'),
813
+ t('折中:只给后一半,通常足够接住语气与场景,提示词短不少。', 'A middle ground: the second half, usually enough to pick up the voice and the scene, and much shorter.')),
814
+ option('none', t('不参考正文', 'No prose reference'),
815
+ t('只靠世界快照与各章梗概推进;最省上下文,但接续会偏生硬。', 'Only snapshots and synopses drive it — cheapest, but the join can feel abrupt.'))),
816
+ h('div', { className: 'dsn-hint span4' }, t('改动下一次生成就生效,不影响已有章节。', 'Applies to the next generation; existing chapters are untouched.')),
817
+ error ? h('div', { className: 'dsn-hint warn span4' }, error) : null),
818
+ h('div', { className: 'dsn-dialog-foot' },
819
+ h('button', { className: 'dsn-btn ghost', type: 'button', disabled: busy, onClick: onCancel }, t('取消', 'Cancel')),
820
+ h('div', { className: 'dsn-spacer' }),
821
+ h('button', { className: 'dsn-btn primary', type: 'button', disabled: busy, onClick: save },
822
+ busy ? t('保存中…', 'Saving…') : t('保存', 'Save')))));
823
+ }
824
+
754
825
  function PresetDialog({ project, presets, onCancel, onSaved }) {
755
826
  const [preset, setPreset] = useState(project.agentPreset || '');
756
827
  const [busy, setBusy] = useState(false);
@@ -799,6 +870,85 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
799
870
  }, busy ? t('保存中…', "Saving…") : t('保存', "Save")))));
800
871
  }
801
872
 
873
+ /**
874
+ * Read-only view of every prompt the plugin sends, with the work's own text
875
+ * replaced by placeholders. The Host renders the real builders, so what is
876
+ * shown here is the actual structure — it cannot drift from what is sent.
877
+ */
878
+ function PromptsDialog({ project, onCancel }) {
879
+ const [data, setData] = useState(null);
880
+ const [active, setActive] = useState('');
881
+ const [error, setError] = useState('');
882
+ const [copied, setCopied] = useState(false);
883
+
884
+ useEffect(() => {
885
+ let alive = true;
886
+ (async () => {
887
+ try {
888
+ const result = await api('/prompts/template', { projectId: project.projectId });
889
+ if (!alive) return;
890
+ setData(result);
891
+ setActive(result.modules && result.modules.length ? result.modules[0].id : '');
892
+ } catch (loadError) {
893
+ if (alive) setError(messageOf(loadError));
894
+ }
895
+ })();
896
+ return () => { alive = false; };
897
+ }, [project.projectId]);
898
+
899
+ const modules = data ? data.modules : [];
900
+ const current = modules.find((entry) => entry.id === active) || modules[0] || null;
901
+
902
+ const copy = useCallback(async () => {
903
+ if (!current) return;
904
+ try {
905
+ await navigator.clipboard.writeText(current.text);
906
+ setCopied(true);
907
+ setTimeout(() => setCopied(false), 1500);
908
+ } catch {
909
+ setCopied(false);
910
+ }
911
+ }, [current]);
912
+
913
+ return h('div', { className: 'dsn-dialog-backdrop' },
914
+ h('div', { className: 'dsn-dialog', style: { width: 'min(980px, calc(100% - 48px))' } },
915
+ h('div', { className: 'dsn-dialog-head' },
916
+ h('div', { className: 'dsn-title' }, t('提示词一览', 'Prompts')),
917
+ h('div', { className: 'dsn-sub' }, project.title),
918
+ h('div', { className: 'dsn-spacer' }),
919
+ h('button', { className: 'dsn-btn ghost', type: 'button', onClick: onCancel }, t('关闭', 'Close'))),
920
+ h('div', { className: 'dsn-prompt-body' },
921
+ h('div', { className: 'dsn-prompt-list' },
922
+ modules.map((entry) => h('button', {
923
+ key: entry.id,
924
+ type: 'button',
925
+ className: `dsn-prompt-item${entry.id === active ? ' active' : ''}`,
926
+ onClick: () => setActive(entry.id),
927
+ }, entry.title))),
928
+ h('div', { className: 'dsn-prompt-pane' },
929
+ current
930
+ ? h(React.Fragment, null,
931
+ h('div', { className: 'dsn-prompt-when' }, current.when),
932
+ h('pre', { className: 'dsn-prompt-text' }, current.text))
933
+ : null,
934
+ error ? h('div', { className: 'dsn-hint warn' }, error) : null,
935
+ data && data.settings
936
+ ? h('div', { className: 'dsn-hint' },
937
+ t('当前设置:前文参考程度 = {ref};前置提示词 = {prelude};快照间隔 = 每 {days} 天',
938
+ 'Current settings: previous-text reference = {ref}; preface = {prelude}; snapshot interval = every {days} days',
939
+ {
940
+ ref: data.settings.referenceLabel,
941
+ prelude: data.settings.preludeSet ? t('已设置', 'set') : t('未设置', 'not set'),
942
+ days: data.settings.unitDays,
943
+ }))
944
+ : null)),
945
+ h('div', { className: 'dsn-dialog-foot' },
946
+ h('div', { className: 'dsn-hint' }, t('只读:花括号 {} 里是活数据占位,实际生成时会替换成你的内容。', 'Read-only: text in {} is a placeholder for live data, replaced with your own content when generating.')),
947
+ h('div', { className: 'dsn-spacer' }),
948
+ h('button', { className: 'dsn-btn', type: 'button', disabled: !current, onClick: copy },
949
+ copied ? t('已复制', 'Copied') : t('复制全文', 'Copy')))));
950
+ }
951
+
802
952
  /**
803
953
  * The work-wide preface prompt: a narrative angle preset plus anything else
804
954
  * the author wants pinned in front of every request. The preset wording comes
@@ -874,7 +1024,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
874
1024
  }, busy ? t('保存中…', 'Saving…') : t('保存', 'Save')))));
875
1025
  }
876
1026
 
877
- function ProjectsPage({ boot, resolveWorkspace, onOpen, onReload, onEditModel, onEditPreset, onEditPrelude, onImport }) {
1027
+ function ProjectsPage({ boot, resolveWorkspace, onOpen, onReload, onEditModel, onEditPreset, onEditPrelude, onImport, onSettings, onPrompts }) {
878
1028
  const [title, setTitle] = useState('');
879
1029
  const [cwd, setCwd] = useState('');
880
1030
  const [preset, setPreset] = useState('');
@@ -1128,6 +1278,24 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1128
1278
  onEditPrelude(project);
1129
1279
  },
1130
1280
  }, t('前置', 'Preface')),
1281
+ h('button', {
1282
+ className: 'dsn-btn ghost',
1283
+ type: 'button',
1284
+ title: t('作品设置:自动推演与生成正文时对前文的参考程度', 'Work settings: how much previous text the writing prompts carry'),
1285
+ onClick: (event) => {
1286
+ event.stopPropagation();
1287
+ onSettings(project);
1288
+ },
1289
+ }, t('设置', 'Settings')),
1290
+ h('button', {
1291
+ className: 'dsn-btn ghost',
1292
+ type: 'button',
1293
+ title: t('查看插件会发送的全部提示词(只读,变量用占位符)', 'See every prompt the plugin sends (read-only, variables as placeholders)'),
1294
+ onClick: (event) => {
1295
+ event.stopPropagation();
1296
+ onPrompts(project);
1297
+ },
1298
+ }, t('提示词', 'Prompts')),
1131
1299
  h('button', {
1132
1300
  className: 'dsn-btn ghost',
1133
1301
  type: 'button',
@@ -2888,6 +3056,8 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2888
3056
  const [presetTarget, setPresetTarget] = useState(null);
2889
3057
  const [preludeTarget, setPreludeTarget] = useState(null);
2890
3058
  const [importTarget, setImportTarget] = useState(null);
3059
+ const [settingsTarget, setSettingsTarget] = useState(null);
3060
+ const [promptsTarget, setPromptsTarget] = useState(null);
2891
3061
  // Import's outline sync can move chapters too; the notice belongs here
2892
3062
  // because the import dialog closes before the shift is known.
2893
3063
  const [shiftNotice, setShiftNotice] = useState(null);
@@ -2985,6 +3155,8 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2985
3155
  onEditPreset: setPresetTarget,
2986
3156
  onEditPrelude: setPreludeTarget,
2987
3157
  onImport: setImportTarget,
3158
+ onSettings: setSettingsTarget,
3159
+ onPrompts: setPromptsTarget,
2988
3160
  }))
2989
3161
  : detail === null
2990
3162
  ? h('div', { className: 'dsn-empty', style: { margin: 'auto' } }, t('正在载入作品…', "Loading the work…"))
@@ -3056,6 +3228,26 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
3056
3228
  },
3057
3229
  })
3058
3230
  : null,
3231
+ promptsTarget
3232
+ ? h(PromptsDialog, {
3233
+ project: promptsTarget,
3234
+ onCancel: () => setPromptsTarget(null),
3235
+ })
3236
+ : null,
3237
+ settingsTarget
3238
+ ? h(SettingsDialog, {
3239
+ project: settingsTarget,
3240
+ onCancel: () => setSettingsTarget(null),
3241
+ onSaved: async (reference) => {
3242
+ setSettingsTarget(null);
3243
+ await loadBoot();
3244
+ const label = reference === 'full'
3245
+ ? t('前一章全文', 'the full previous chapter')
3246
+ : (reference === 'half' ? t('前一章的后一半', 'the second half of the previous chapter') : t('不参考正文', 'no prose reference'));
3247
+ setToast({ text: t('已保存:前文参考程度 = {label}', 'Saved: previous-text reference = {label}', { label }), kind: 'success' });
3248
+ },
3249
+ })
3250
+ : null,
3059
3251
  shiftNotice
3060
3252
  ? h(ShiftNoticeDialog, {
3061
3253
  shiftDays: shiftNotice.shiftDays,
package/index.js CHANGED
@@ -358,6 +358,8 @@ async function exportProjectInfo(project) {
358
358
  lines.push(`- 预设:${project.agentPreset || '(默认)'}`);
359
359
  const preludeText = project.prelude && project.prelude.text ? String(project.prelude.text).trim() : '';
360
360
  lines.push(`- 前置提示词:${preludeText === '' ? '(未设置)' : '已设置,见文末'}`);
361
+ const referenceLabel = { full: '前一章全文', half: '前一章的后一半', none: '不参考' }[normalizeReference(project.reference)];
362
+ lines.push(`- 前文参考程度:${referenceLabel}`);
361
363
  lines.push(`- 快照间隔:每 ${timeline.unitDays || 30} 天一份`);
362
364
  lines.push(`- 已写入快照:${snapshots.length ? snapshots.map((snapshot) => `第 ${snapshot.day} 天`).join('、') : '(暂无)'}`);
363
365
  lines.push(`- 故事当前:第 ${lastChapterEnd(project)} 天`);
@@ -675,6 +677,8 @@ function summarize(project) {
675
677
  // The work-wide preface prompt: narrative angle plus anything else the
676
678
  // author wants every request to carry.
677
679
  prelude: project.prelude || null,
680
+ // How much of the previous chapter's prose the writing prompts carry.
681
+ reference: normalizeReference(project.reference),
678
682
  createdAt: project.createdAt,
679
683
  updatedAt: project.updatedAt,
680
684
  chapterCount: chapters.length,
@@ -1234,6 +1238,72 @@ function stretchLines(project, chapter, snapshot) {
1234
1238
  return lines;
1235
1239
  }
1236
1240
 
1241
+ /* --------------------------------------------------- previous-chapter text */
1242
+
1243
+ /** How much of the previous chapter's prose a writing prompt carries. */
1244
+ const REFERENCE_MODES = ['full', 'half', 'none'];
1245
+ const DEFAULT_REFERENCE = 'full';
1246
+ /** Hard ceiling on the quoted prose, so a long chapter cannot swamp the prompt. */
1247
+ const REFERENCE_MAX_CHARS = 8000;
1248
+
1249
+ function normalizeReference(value) {
1250
+ const mode = asText(value);
1251
+ return REFERENCE_MODES.includes(mode) ? mode : DEFAULT_REFERENCE;
1252
+ }
1253
+
1254
+ /** The tail of `text` from its halfway sentence boundary, for the `half` mode. */
1255
+ function secondHalfText(text) {
1256
+ const source = String(text || '');
1257
+ if (source.length === 0) return '';
1258
+ const middle = Math.floor(source.length / 2);
1259
+ const stops = ['。', '!', '?', '…', '\n'];
1260
+ let cut = -1;
1261
+ for (let index = middle; index < Math.min(source.length, middle + 400); index += 1) {
1262
+ if (stops.includes(source[index])) {
1263
+ cut = index + 1;
1264
+ break;
1265
+ }
1266
+ }
1267
+ if (cut < 0) {
1268
+ for (let index = middle; index > Math.max(0, middle - 400); index -= 1) {
1269
+ if (stops.includes(source[index])) {
1270
+ cut = index + 1;
1271
+ break;
1272
+ }
1273
+ }
1274
+ }
1275
+ return source.slice(cut < 0 ? middle : cut).trim();
1276
+ }
1277
+
1278
+ /**
1279
+ * The previous chapter's prose, per the work's reference setting.
1280
+ *
1281
+ * Long chapters are trimmed from the front: what matters for continuity is how
1282
+ * the previous chapter *ends*, since this one has to continue from there.
1283
+ */
1284
+ function previousProseBlock(project, chapter, label) {
1285
+ const mode = normalizeReference(project.reference);
1286
+ if (mode === 'none') return [];
1287
+ const previous = previousChapter(project, chapter);
1288
+ if (!previous) return [];
1289
+ const raw = String(previous.content || '');
1290
+ if (raw.trim() === '') return [];
1291
+ let text = mode === 'half' ? secondHalfText(raw) : raw.trim();
1292
+ let truncated = false;
1293
+ if (text.length > REFERENCE_MAX_CHARS) {
1294
+ text = text.slice(text.length - REFERENCE_MAX_CHARS);
1295
+ truncated = true;
1296
+ }
1297
+ const span = chapterWindow(project, previous);
1298
+ const lines = [];
1299
+ lines.push(`【${label || '上一章正文'}】(第 ${previous.index + 1} 章 ${previous.title || ''},第 ${span.start}→${span.end} 天)`);
1300
+ if (truncated) lines.push(`(正文较长,只给最后 ${REFERENCE_MAX_CHARS} 字)`);
1301
+ if (mode === 'half') lines.push('(只给后一半,用来衔接语气与场景)');
1302
+ lines.push(text);
1303
+ lines.push('');
1304
+ return lines;
1305
+ }
1306
+
1237
1307
  function chapterPrompt(project, chapter, previous) {
1238
1308
  const window = chapterWindow(project, chapter);
1239
1309
  const outline = chapter.outline || {};
@@ -1260,6 +1330,9 @@ function chapterPrompt(project, chapter, previous) {
1260
1330
  lines.push(`${previous.title || ''}:${(previous.outline && previous.outline.synopsis) || '(无)'}`);
1261
1331
  lines.push('');
1262
1332
  }
1333
+ // The previous chapter's own prose, so the new chapter continues in the same
1334
+ // voice and picks up from the exact scene it ended on.
1335
+ lines.push(...previousProseBlock(project, chapter, '上一章正文 · 供衔接'));
1263
1336
  lines.push('【本章梗概 · 待生成的部分】');
1264
1337
  lines.push(outline.synopsis || '(无)');
1265
1338
  lines.push('');
@@ -1288,6 +1361,9 @@ function deducePrompt(project, chapter) {
1288
1361
  lines.push('');
1289
1362
  const stretch = stretchLines(project, target, snapshot);
1290
1363
  if (stretch.length) lines.push(...stretch);
1364
+ // Auto-deduction reads the previous chapter's prose too, so the synopsis it
1365
+ // invents follows on from what was actually written.
1366
+ lines.push(...previousProseBlock(project, target, '上一章正文 · 供衔接'));
1291
1367
  lines.push('【输出 JSON 结构】');
1292
1368
  lines.push('{"synopsis": "本章梗概,300 字以内,包含起因、经过、转折", "durationDays": 30, "minWords": 1200, "maxWords": 3500, "title": "本章标题"}');
1293
1369
  lines.push('');
@@ -1499,7 +1575,7 @@ async function bootstrap(ctx, root) {
1499
1575
  }
1500
1576
  }
1501
1577
  return {
1502
- revision: '2026-10-29.3',
1578
+ revision: '2026-10-30.3',
1503
1579
  projects,
1504
1580
  models: {
1505
1581
  default: modelCatalog.default || null,
@@ -1578,6 +1654,8 @@ async function createProject(ctx, root, body) {
1578
1654
  // Every new work starts with the default narrative preface; the author can
1579
1655
  // edit or clear it from the work list.
1580
1656
  prelude: defaultPrelude(),
1657
+ // Previous-chapter prose in writing prompts: full text by default.
1658
+ reference: DEFAULT_REFERENCE,
1581
1659
  sessionId,
1582
1660
  createdAt: Date.now(),
1583
1661
  updatedAt: Date.now(),
@@ -1954,6 +2032,22 @@ function defaultPrelude() {
1954
2032
  * from; the text itself is what every request carries, so the author can edit a
1955
2033
  * preset's wording or write their own.
1956
2034
  */
2035
+ /** Work-level settings: how much of the previous chapter the prompts carry. */
2036
+ async function updateSettings(root, body) {
2037
+ const projectId = asText(body.projectId);
2038
+ const reference = asText(body.reference);
2039
+ if (reference !== '' && !REFERENCE_MODES.includes(reference)) {
2040
+ throw httpError(400, `参考程度只能是 ${REFERENCE_MODES.join(' / ')}`);
2041
+ }
2042
+ const project = await mutateProject(root, projectId, (draft) => {
2043
+ draft.reference = normalizeReference(reference);
2044
+ draft.updatedAt = Date.now();
2045
+ return summarize(draft);
2046
+ });
2047
+ await exportProjectInfo(await readProject(root, projectId));
2048
+ return { project };
2049
+ }
2050
+
1957
2051
  async function updatePrelude(root, body) {
1958
2052
  const projectId = asText(body.projectId);
1959
2053
  const mode = asText(body.mode);
@@ -2571,6 +2665,159 @@ async function startStyleApply(ctx, root, registry, body) {
2571
2665
  });
2572
2666
  }
2573
2667
 
2668
+ /* ------------------------------------------------------ prompt templates */
2669
+
2670
+ /**
2671
+ * A stand-in project whose *content* is placeholder text.
2672
+ *
2673
+ * The point is that the panel renders the real prompt builders instead of a
2674
+ * hand-written copy of them: the structure can never drift from what is actually
2675
+ * sent, and the author's own text is never read or echoed.
2676
+ */
2677
+ function placeholderProject(project) {
2678
+ const snapshot = {
2679
+ snapshotId: 'placeholder',
2680
+ day: 30,
2681
+ createdAt: 0,
2682
+ unitDays: 30,
2683
+ world: '{世界观:随故事推进更新的设定}',
2684
+ characters: [{ name: '{角色名}', desc: '{身份、处境、变化}' }],
2685
+ factions: [{ name: '{势力名}', desc: '{当前状态}' }],
2686
+ // Relations are stored as plain strings ("A B 师徒"), which is what the
2687
+ // snapshot prompt asks the model for; the placeholder has to match.
2688
+ relations: ['{角色A} {角色B} {关系,例如 师徒}'],
2689
+ items: [{ name: '{物品名}', desc: '{下落或用途}' }],
2690
+ events: [{ day: 20, title: '{事件标题}', desc: '{一句话经过}' }],
2691
+ summary: '{这段时间的梗概}',
2692
+ };
2693
+ const previous = {
2694
+ chapterId: 'placeholder-previous',
2695
+ index: 0,
2696
+ title: '{上一章标题}',
2697
+ startDay: 30,
2698
+ durationDays: 30,
2699
+ wordCount: 0,
2700
+ status: 'generated',
2701
+ createdAt: 0,
2702
+ updatedAt: 0,
2703
+ content: '{上一章正文:按你的「前文参考程度」决定给多少}',
2704
+ outline: {
2705
+ synopsis: '{上一章梗概}',
2706
+ minWords: 800,
2707
+ maxWords: 3000,
2708
+ durationDays: 30,
2709
+ synopsisMinChars: 100,
2710
+ savedAt: 0,
2711
+ },
2712
+ };
2713
+ const current = {
2714
+ chapterId: 'placeholder-current',
2715
+ index: 1,
2716
+ title: '{本章标题}',
2717
+ startDay: 60,
2718
+ durationDays: 30,
2719
+ wordCount: 0,
2720
+ status: 'outlined',
2721
+ createdAt: 0,
2722
+ updatedAt: 0,
2723
+ content: '',
2724
+ outline: {
2725
+ synopsis: '{本章梗概:你在大纲页填写的内容}',
2726
+ minWords: 800,
2727
+ maxWords: 3000,
2728
+ durationDays: 30,
2729
+ synopsisMinChars: 100,
2730
+ savedAt: 0,
2731
+ },
2732
+ };
2733
+ return {
2734
+ ...project,
2735
+ prelude: { mode: 'custom', text: '{本书前置提示词:你在「前置」里写的文本}', updatedAt: 0 },
2736
+ snapshots: [snapshot],
2737
+ chapters: [previous, current],
2738
+ timeline: { ...(project.timeline || {}), unitDays: 30 },
2739
+ reference: normalizeReference(project.reference),
2740
+ };
2741
+ }
2742
+
2743
+ /** Every prompt the plugin sends, rendered against placeholder content. */
2744
+ function promptTemplates(project) {
2745
+ const stub = placeholderProject(project);
2746
+ const [previous, current] = stub.chapters;
2747
+ const mode = normalizeReference(project.reference);
2748
+ const modules = [
2749
+ {
2750
+ id: 'prelude',
2751
+ title: '本书前置设定',
2752
+ when: '写正文 / 自动推演 / AI 修改正文 / AI 修改梗概 / 提取行文风格 / 整理前置 —— 都会放在最前面',
2753
+ text: preludeBlock(stub),
2754
+ },
2755
+ {
2756
+ id: 'chapter',
2757
+ title: '写章节正文',
2758
+ when: '点「生成章节故事」,或「自动推演」走到写正文那一步',
2759
+ text: chapterPrompt(stub, current, previous),
2760
+ },
2761
+ {
2762
+ id: 'deduce',
2763
+ title: '自动推演大纲',
2764
+ when: '点「自动推演」(先推梗概、标题、时长与字数范围)',
2765
+ text: deducePrompt(stub, current),
2766
+ },
2767
+ {
2768
+ id: 'snapshot',
2769
+ title: '世界快照',
2770
+ when: '点「生成世界快照」,或章节推进到网格点时自动触发',
2771
+ text: snapshotPrompt(stub, stub.snapshots[0], [previous], 60),
2772
+ },
2773
+ {
2774
+ id: 'syncOutline',
2775
+ title: '同步章节大纲',
2776
+ when: '点「同步章节大纲」,或导入章节时勾选了自动同步',
2777
+ text: syncOutlinePrompt(stub, current, '{本章正文}'),
2778
+ },
2779
+ {
2780
+ id: 'revise',
2781
+ title: 'AI 修改正文',
2782
+ when: '故事页点「AI 修改」并填入修改意见',
2783
+ text: revisePrompt(stub, current, '{本章现有正文}', '{你填的修改意见}'),
2784
+ },
2785
+ {
2786
+ id: 'synopsisRevise',
2787
+ title: 'AI 修改梗概',
2788
+ when: '大纲页点「AI 修改梗概」并填入修改意见',
2789
+ text: synopsisRevisePrompt(stub, current, '{现有故事梗概}', '{你填的修改意见}'),
2790
+ },
2791
+ {
2792
+ id: 'styleExtract',
2793
+ title: '提取行文风格',
2794
+ when: '故事页点「提取行文风格」',
2795
+ text: styleExtractPrompt(stub, current, '{本章正文:至少 300 字才会提取}'),
2796
+ },
2797
+ {
2798
+ id: 'styleMerge',
2799
+ title: '整理进前置',
2800
+ when: '风格面板里点「写入前置」',
2801
+ text: styleMergePrompt('{现有前置提示词}', '{提取出的行文风格}'),
2802
+ },
2803
+ ];
2804
+ return {
2805
+ modules,
2806
+ settings: {
2807
+ reference: mode,
2808
+ referenceLabel: { full: '前一章全文', half: '前一章的后一半', none: '不参考正文' }[mode],
2809
+ preludeSet: Boolean(project.prelude && project.prelude.text),
2810
+ unitDays: Math.max(1, Number(project.timeline && project.timeline.unitDays) || 30),
2811
+ },
2812
+ };
2813
+ }
2814
+
2815
+ async function readPromptTemplates(root, body) {
2816
+ const projectId = asText(body.projectId);
2817
+ const project = await readProject(root, projectId);
2818
+ return promptTemplates(project);
2819
+ }
2820
+
2574
2821
  async function updateTimeline(root, body) {
2575
2822
  const projectId = asText(body.projectId);
2576
2823
  const unitDays = Number(body.unitDays);
@@ -3417,6 +3664,10 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
3417
3664
  return startStyleExtract(ctx, root, registry, body);
3418
3665
  case '/style/apply':
3419
3666
  return startStyleApply(ctx, root, registry, body);
3667
+ case '/project/settings':
3668
+ return updateSettings(root, body);
3669
+ case '/prompts/template':
3670
+ return readPromptTemplates(root, body);
3420
3671
  case '/import/apply':
3421
3672
  return startImportChapters(ctx, root, registry, body);
3422
3673
  case '/outline/revise':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-novel-generator",
3
- "version": "0.1.40",
3
+ "version": "0.1.42",
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",