dsh-novel-generator 0.1.39 → 0.1.41

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 +119 -5
  2. package/index.js +168 -23
  3. package/package.json +1 -1
package/client.js CHANGED
@@ -722,7 +722,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
722
722
  * After a duration change moves the chapters that follow, the snapshots stay
723
723
  * where they were — this dialog is the only place the author learns about it.
724
724
  */
725
- function ShiftNoticeDialog({ shiftDays, shifted, onClose, onGoTimeline }) {
725
+ function ShiftNoticeDialog({ shiftDays, shifted, scope, onClose, onGoTimeline }) {
726
726
  const list = Array.isArray(shifted) ? shifted : [];
727
727
  const sample = list.slice(0, 6);
728
728
  return h('div', { className: 'dsn-dialog-backdrop' },
@@ -731,7 +731,9 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
731
731
  h('div', { className: 'dsn-title' }, t('时间线已顺移,快照需要你手动更新', 'The timeline moved — snapshots need updating')),
732
732
  h('div', { className: 'dsn-spacer' })),
733
733
  h('div', { className: 'dsn-dialog-body' },
734
- h('div', { className: 'dsn-text span4' }, t('本章的时间长度改变了 {d} 天,之后 {n} 章已整体顺移,保持首尾相接:', 'This chapter’s length changed by {d} days, and {n} following chapters moved with it so the timeline stays contiguous:', { d: shiftDays > 0 ? `+${shiftDays}` : shiftDays, n: list.length })),
734
+ h('div', { className: 'dsn-text span4' }, scope === 'import'
735
+ ? t('导入时同步大纲改动了章节的时间长度,之后 {n} 章已整体顺移,保持首尾相接:', 'Syncing the imported chapters changed their lengths, and {n} following chapters moved with them so the timeline stays contiguous:', { n: list.length })
736
+ : t('本章的时间长度改变了 {d} 天,之后 {n} 章已整体顺移,保持首尾相接:', 'This chapter’s length changed by {d} days, and {n} following chapters moved with it so the timeline stays contiguous:', { d: shiftDays > 0 ? `+${shiftDays}` : shiftDays, n: list.length })),
735
737
  list.length > 0
736
738
  ? h('div', { className: 'dsn-importlist span4' },
737
739
  sample.map((entry) => h('div', { key: entry.chapterId, className: 'dsn-importrow' },
@@ -749,6 +751,64 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
749
751
  h('button', { className: 'dsn-btn primary', type: 'button', onClick: onGoTimeline }, t('去时间长河', 'Open timeline')))));
750
752
  }
751
753
 
754
+ /**
755
+ * Work settings. The first section decides how much of the previous chapter's
756
+ * prose the writing and deduction prompts carry — the knob that trades
757
+ * continuity against prompt size.
758
+ */
759
+ function SettingsDialog({ project, onCancel, onSaved }) {
760
+ const [reference, setReference] = useState(project.reference || 'full');
761
+ const [busy, setBusy] = useState(false);
762
+ const [error, setError] = useState('');
763
+
764
+ const save = useCallback(async () => {
765
+ setBusy(true);
766
+ setError('');
767
+ try {
768
+ await api('/project/settings', { projectId: project.projectId, reference });
769
+ await onSaved(reference);
770
+ } catch (saveError) {
771
+ setError(messageOf(saveError));
772
+ } finally {
773
+ setBusy(false);
774
+ }
775
+ }, [project.projectId, reference, onSaved]);
776
+
777
+ const option = (value, title, hint) => h('label', { className: 'dsn-check', style: { alignItems: 'flex-start' } },
778
+ h('input', {
779
+ type: 'radio',
780
+ name: 'dsn-reference',
781
+ checked: reference === value,
782
+ onChange: () => setReference(value),
783
+ }),
784
+ h('span', null,
785
+ h('div', null, title),
786
+ h('div', { className: 'dsn-hint' }, hint)));
787
+
788
+ return h('div', { className: 'dsn-dialog-backdrop' },
789
+ h('div', { className: 'dsn-dialog', style: { width: 'min(560px, calc(100% - 48px))' } },
790
+ h('div', { className: 'dsn-dialog-head' },
791
+ h('div', { className: 'dsn-title' }, t('作品设置', 'Work settings')),
792
+ h('div', { className: 'dsn-sub' }, project.title),
793
+ h('div', { className: 'dsn-spacer' })),
794
+ h('div', { className: 'dsn-dialog-body' },
795
+ h('div', { className: 'dsn-label span4' }, t('自动推演 / 生成章节故事 · 前文参考程度', 'Auto-deduction / chapter generation · previous text')),
796
+ h('div', { className: 'dsn-field span4', style: { gap: 10 } },
797
+ option('full', t('前一章(全文)', 'Previous chapter (full text)'),
798
+ t('衔接最稳,提示词最长:前一章整章正文都会带上(超过 8000 字只给最后 8000 字)。', 'Best continuity, longest prompt: the whole previous chapter, trimmed to its last 8000 characters when it is longer.')),
799
+ option('half', t('前一章的后一半', 'Previous chapter (second half)'),
800
+ t('折中:只给后一半,通常足够接住语气与场景,提示词短不少。', 'A middle ground: the second half, usually enough to pick up the voice and the scene, and much shorter.')),
801
+ option('none', t('不参考正文', 'No prose reference'),
802
+ t('只靠世界快照与各章梗概推进;最省上下文,但接续会偏生硬。', 'Only snapshots and synopses drive it — cheapest, but the join can feel abrupt.'))),
803
+ h('div', { className: 'dsn-hint span4' }, t('改动下一次生成就生效,不影响已有章节。', 'Applies to the next generation; existing chapters are untouched.')),
804
+ error ? h('div', { className: 'dsn-hint warn span4' }, error) : null),
805
+ h('div', { className: 'dsn-dialog-foot' },
806
+ h('button', { className: 'dsn-btn ghost', type: 'button', disabled: busy, onClick: onCancel }, t('取消', 'Cancel')),
807
+ h('div', { className: 'dsn-spacer' }),
808
+ h('button', { className: 'dsn-btn primary', type: 'button', disabled: busy, onClick: save },
809
+ busy ? t('保存中…', 'Saving…') : t('保存', 'Save')))));
810
+ }
811
+
752
812
  function PresetDialog({ project, presets, onCancel, onSaved }) {
753
813
  const [preset, setPreset] = useState(project.agentPreset || '');
754
814
  const [busy, setBusy] = useState(false);
@@ -872,7 +932,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
872
932
  }, busy ? t('保存中…', 'Saving…') : t('保存', 'Save')))));
873
933
  }
874
934
 
875
- function ProjectsPage({ boot, resolveWorkspace, onOpen, onReload, onEditModel, onEditPreset, onEditPrelude, onImport }) {
935
+ function ProjectsPage({ boot, resolveWorkspace, onOpen, onReload, onEditModel, onEditPreset, onEditPrelude, onImport, onSettings }) {
876
936
  const [title, setTitle] = useState('');
877
937
  const [cwd, setCwd] = useState('');
878
938
  const [preset, setPreset] = useState('');
@@ -1126,6 +1186,15 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1126
1186
  onEditPrelude(project);
1127
1187
  },
1128
1188
  }, t('前置', 'Preface')),
1189
+ h('button', {
1190
+ className: 'dsn-btn ghost',
1191
+ type: 'button',
1192
+ title: t('作品设置:自动推演与生成正文时对前文的参考程度', 'Work settings: how much previous text the writing prompts carry'),
1193
+ onClick: (event) => {
1194
+ event.stopPropagation();
1195
+ onSettings(project);
1196
+ },
1197
+ }, t('设置', 'Settings')),
1129
1198
  h('button', {
1130
1199
  className: 'dsn-btn ghost',
1131
1200
  type: 'button',
@@ -1703,7 +1772,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1703
1772
  }, busy ? t('改写中…', 'Revising…') : t('确认修改', 'Revise')))));
1704
1773
  }
1705
1774
 
1706
- function OutlinePage({ detail, chapter, isFirstChapter, onBack, onSaved, notify, onRegisterGuard, requestConfirm, onBusyChange, onReviseSynopsis, synopsisDraft, onSynopsisDraftApplied, revisingSynopsis }) {
1775
+ function OutlinePage({ detail, chapter, isFirstChapter, onBack, onSaved, notify, onRegisterGuard, requestConfirm, onBusyChange, onReviseSynopsis, synopsisDraft, onSynopsisDraftApplied, revisingSynopsis, onShiftNotice }) {
1707
1776
  const buildForm = useCallback(() => ({
1708
1777
  title: chapter ? chapter.title || '' : '',
1709
1778
  durationDays: chapter && chapter.outline ? chapter.outline.durationDays : (detail.timeline.unitDays || 30),
@@ -1761,6 +1830,10 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1761
1830
  setSavedAt(Date.now());
1762
1831
  setDirty(false);
1763
1832
  if (silent !== true) notify(t('章节大纲已保存', "Outline saved"), 'success');
1833
+ // A length change moved the chapters after it; snapshots did not follow.
1834
+ if (result.shiftDays && Array.isArray(result.shifted) && result.shifted.length > 0) {
1835
+ if (typeof onShiftNotice === 'function') onShiftNotice({ shiftDays: result.shiftDays, shifted: result.shifted });
1836
+ }
1764
1837
  await onSaved(result.chapter.chapterId);
1765
1838
  return true;
1766
1839
  } catch (error) {
@@ -1769,7 +1842,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1769
1842
  } finally {
1770
1843
  setSaving(false);
1771
1844
  }
1772
- }, [complete, detail.projectId, chapter, form, onSaved, notify]);
1845
+ }, [complete, detail.projectId, chapter, form, onSaved, notify, onShiftNotice]);
1773
1846
 
1774
1847
  // The workspace asks this page before any navigation discards its edits.
1775
1848
  const guardRef = useRef(null);
@@ -2759,6 +2832,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2759
2832
  synopsisDraft,
2760
2833
  onSynopsisDraftApplied: () => setSynopsisDraft(null),
2761
2834
  revisingSynopsis,
2835
+ onShiftNotice: setShiftNotice,
2762
2836
  isFirstChapter: Boolean(chapter && chapters.length > 0 && chapter.chapterId === chapters[0].chapterId),
2763
2837
  }))),
2764
2838
  shiftNotice
@@ -2881,6 +2955,10 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2881
2955
  const [presetTarget, setPresetTarget] = useState(null);
2882
2956
  const [preludeTarget, setPreludeTarget] = useState(null);
2883
2957
  const [importTarget, setImportTarget] = useState(null);
2958
+ const [settingsTarget, setSettingsTarget] = useState(null);
2959
+ // Import's outline sync can move chapters too; the notice belongs here
2960
+ // because the import dialog closes before the shift is known.
2961
+ const [shiftNotice, setShiftNotice] = useState(null);
2884
2962
  const leaveGuard = useRef(null);
2885
2963
  const registerLeaveGuard = useCallback((guard) => {
2886
2964
  leaveGuard.current = guard;
@@ -2975,6 +3053,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2975
3053
  onEditPreset: setPresetTarget,
2976
3054
  onEditPrelude: setPreludeTarget,
2977
3055
  onImport: setImportTarget,
3056
+ onSettings: setSettingsTarget,
2978
3057
  }))
2979
3058
  : detail === null
2980
3059
  ? h('div', { className: 'dsn-empty', style: { margin: 'auto' } }, t('正在载入作品…', "Loading the work…"))
@@ -3020,6 +3099,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
3020
3099
  resolveWorkspace,
3021
3100
  onCancel: () => setImportTarget(null),
3022
3101
  onDone: async (result) => {
3102
+ const finished = importTarget;
3023
3103
  setImportTarget(null);
3024
3104
  await loadBoot();
3025
3105
  const imported = (result.imported || []).length;
@@ -3035,6 +3115,40 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
3035
3115
  }));
3036
3116
  if (result.snapshotJobId) parts.push(t('世界快照已开始生成', 'World snapshot started'));
3037
3117
  setToast({ text: parts.join(';'), kind: failed > 0 ? 'error' : 'success' });
3118
+ if (result.shiftDays && Array.isArray(result.shifted) && result.shifted.length > 0) {
3119
+ setShiftNotice({
3120
+ shiftDays: result.shiftDays,
3121
+ shifted: result.shifted,
3122
+ projectId: finished ? finished.projectId : '',
3123
+ });
3124
+ }
3125
+ },
3126
+ })
3127
+ : null,
3128
+ settingsTarget
3129
+ ? h(SettingsDialog, {
3130
+ project: settingsTarget,
3131
+ onCancel: () => setSettingsTarget(null),
3132
+ onSaved: async (reference) => {
3133
+ setSettingsTarget(null);
3134
+ await loadBoot();
3135
+ const label = reference === 'full'
3136
+ ? t('前一章全文', 'the full previous chapter')
3137
+ : (reference === 'half' ? t('前一章的后一半', 'the second half of the previous chapter') : t('不参考正文', 'no prose reference'));
3138
+ setToast({ text: t('已保存:前文参考程度 = {label}', 'Saved: previous-text reference = {label}', { label }), kind: 'success' });
3139
+ },
3140
+ })
3141
+ : null,
3142
+ shiftNotice
3143
+ ? h(ShiftNoticeDialog, {
3144
+ shiftDays: shiftNotice.shiftDays,
3145
+ shifted: shiftNotice.shifted,
3146
+ scope: 'import',
3147
+ onClose: () => setShiftNotice(null),
3148
+ onGoTimeline: () => {
3149
+ const targetId = shiftNotice.projectId || '';
3150
+ setShiftNotice(null);
3151
+ if (targetId !== '') openProject(targetId);
3038
3152
  },
3039
3153
  })
3040
3154
  : null,
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.2',
1578
+ revision: '2026-10-29.4',
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);
@@ -2103,10 +2197,7 @@ async function startSyncOutline(ctx, root, registry, body) {
2103
2197
  * back into its outline". Used by the story page's button and by chapter import,
2104
2198
  * so both produce identical outlines.
2105
2199
  */
2106
- async function syncChapterOutline(ctx, root, projectId, chapterId, prose, note, options = {}) {
2107
- // Import sets the per-chapter length explicitly, so its sync keeps that number
2108
- // and never shifts the chapters that follow.
2109
- const keepDuration = options.keepDuration === true;
2200
+ async function syncChapterOutline(ctx, root, projectId, chapterId, prose, note) {
2110
2201
  const project = await readProject(root, projectId);
2111
2202
  const chapter = project.chapters.find((entry) => entry.chapterId === chapterId);
2112
2203
  if (!chapter) throw httpError(404, `章节不存在: ${chapterId}`);
@@ -2143,12 +2234,11 @@ async function syncChapterOutline(ctx, root, projectId, chapterId, prose, note,
2143
2234
  // The measured duration wins, and the timeline stays contiguous: every later
2144
2235
  // chapter moves with it. Snapshots are deliberately left where they are — the
2145
2236
  // Client tells the author to regenerate the ones that no longer line up.
2146
- let durationDays = chapter.durationDays;
2147
2237
  const proposed = Number.isFinite(Number(parsed.durationDays))
2148
2238
  ? Math.max(1, Math.min(365, Math.round(Number(parsed.durationDays))))
2149
2239
  : null;
2150
- const shiftDays = proposed !== null && !keepDuration ? proposed - chapter.durationDays : 0;
2151
- if (proposed !== null && !keepDuration) durationDays = proposed;
2240
+ let durationDays = proposed === null ? chapter.durationDays : proposed;
2241
+ let shiftDays = 0;
2152
2242
  const shifted = [];
2153
2243
 
2154
2244
  const saved = await mutateProject(root, projectId, (draft) => {
@@ -2163,18 +2253,14 @@ async function syncChapterOutline(ctx, root, projectId, chapterId, prose, note,
2163
2253
  synopsisMinChars,
2164
2254
  savedAt: Date.now(),
2165
2255
  };
2166
- target.durationDays = durationDays;
2167
2256
  target.updatedAt = Date.now();
2168
- if (shiftDays !== 0) {
2169
- for (const other of draft.chapters) {
2170
- if (other.chapterId === target.chapterId) continue;
2171
- if (other.startDay < span.start) continue;
2172
- const from = other.startDay;
2173
- other.startDay = Math.max(span.start + 1, from + shiftDays);
2174
- other.updatedAt = Date.now();
2175
- shifted.push({ chapterId: other.chapterId, title: other.title, fromDay: from, toDay: other.startDay });
2176
- }
2177
- }
2257
+ // Shared with the outline save and chapter import: any length change moves
2258
+ // the chapters that follow.
2259
+ const moved = applyDurationShift(draft.chapters, target, durationDays);
2260
+ durationDays = target.durationDays;
2261
+ shiftDays = moved.shiftDays;
2262
+ shifted.push(...moved.shifted);
2263
+ target.outline.durationDays = durationDays;
2178
2264
  return { chapter: { ...target, content: undefined }, project: summarize(draft) };
2179
2265
  });
2180
2266
  const fresh = await readProject(root, projectId);
@@ -2423,6 +2509,8 @@ async function startImportChapters(ctx, root, registry, body) {
2423
2509
  }
2424
2510
 
2425
2511
  const synced = [];
2512
+ const shifted = [];
2513
+ let shiftDaysTotal = 0;
2426
2514
  if (syncOutline && imported.length > 0) {
2427
2515
  for (let index = 0; index < imported.length; index += 1) {
2428
2516
  const entry = imported[index];
@@ -2432,8 +2520,17 @@ async function startImportChapters(ctx, root, registry, body) {
2432
2520
  const chapter = project.chapters.find((item) => item.chapterId === entry.chapterId);
2433
2521
  const result = await syncChapterOutline(ctx, root, projectId, entry.chapterId, chapter ? (chapter.content || '') : '', (note) => {
2434
2522
  job.message = note;
2435
- }, { keepDuration: true });
2523
+ });
2436
2524
  synced.push({ chapterId: entry.chapterId, synopsisChars: (result.synopsis || '').length });
2525
+ for (const move of (result.shifted || [])) {
2526
+ const seen = shifted.find((item) => item.chapterId === move.chapterId);
2527
+ if (seen) {
2528
+ seen.toDay = move.toDay;
2529
+ } else {
2530
+ shifted.push({ ...move });
2531
+ }
2532
+ }
2533
+ shiftDaysTotal += Number(result.shiftDays) || 0;
2437
2534
  } catch (error) {
2438
2535
  failed.push({ fileName: entry.title, reason: `同步大纲失败:${error instanceof Error ? error.message : String(error)}` });
2439
2536
  }
@@ -2454,6 +2551,8 @@ async function startImportChapters(ctx, root, registry, body) {
2454
2551
  skipped,
2455
2552
  failed,
2456
2553
  synced,
2554
+ shifted,
2555
+ shiftDays: shiftDaysTotal,
2457
2556
  snapshotJobId,
2458
2557
  project: summarize(await readProject(root, projectId)),
2459
2558
  };
@@ -2603,6 +2702,33 @@ function nextChapterIndex(project) {
2603
2702
  return max + 1;
2604
2703
  }
2605
2704
 
2705
+ /**
2706
+ * Keep story time contiguous after one chapter's length changes.
2707
+ *
2708
+ * The chapter takes the new length and every chapter starting at or after it
2709
+ * moves by the same number of days, so the timeline never opens a gap or an
2710
+ * overlap. Snapshots are not touched — the Client tells the author to update
2711
+ * them. Returns the moves so callers can report them.
2712
+ */
2713
+ function applyDurationShift(chapters, chapter, nextDuration) {
2714
+ const previous = Math.max(1, Math.round(Number(chapter.durationDays) || 1));
2715
+ const next = Math.max(1, Math.round(Number(nextDuration) || previous));
2716
+ const shifted = [];
2717
+ chapter.durationDays = next;
2718
+ const delta = next - previous;
2719
+ if (delta === 0) return { shiftDays: 0, shifted };
2720
+ const start = Math.max(0, Math.round(Number(chapter.startDay) || 0));
2721
+ for (const other of chapters) {
2722
+ if (other.chapterId === chapter.chapterId) continue;
2723
+ if ((Number(other.startDay) || 0) < start) continue;
2724
+ const from = other.startDay;
2725
+ other.startDay = Math.max(start + 1, from + delta);
2726
+ other.updatedAt = Date.now();
2727
+ shifted.push({ chapterId: other.chapterId, title: other.title, fromDay: from, toDay: other.startDay });
2728
+ }
2729
+ return { shiftDays: delta, shifted };
2730
+ }
2731
+
2606
2732
  async function saveOutline(ctx, root, registry, body) {
2607
2733
  const projectId = asText(body.projectId);
2608
2734
  const chapterId = asText(body.chapterId);
@@ -2614,6 +2740,7 @@ async function saveOutline(ctx, root, registry, body) {
2614
2740
  let savedProject = null;
2615
2741
  let savedChapter = null;
2616
2742
  let renamedFrom = null;
2743
+ let outlineShift = { shiftDays: 0, shifted: [] };
2617
2744
  const result = await mutateProject(root, projectId, (project) => {
2618
2745
  let chapter = chapterId === '' ? null : project.chapters.find((entry) => entry.chapterId === chapterId);
2619
2746
  if (!chapter) {
@@ -2637,7 +2764,10 @@ async function saveOutline(ctx, root, registry, body) {
2637
2764
  }
2638
2765
  if (title !== '') chapter.title = title;
2639
2766
  chapter.outline = outline;
2640
- chapter.durationDays = outline.durationDays;
2767
+ // Length changes move the chapters after this one, whatever the caller is.
2768
+ const moved = applyDurationShift(project.chapters, chapter, outline.durationDays);
2769
+ outlineShift = moved;
2770
+ chapter.outline.durationDays = chapter.durationDays;
2641
2771
  if (chapter.status === 'draft') chapter.status = 'outlined';
2642
2772
  chapter.updatedAt = Date.now();
2643
2773
  savedProject = project;
@@ -2668,7 +2798,13 @@ async function saveOutline(ctx, root, registry, body) {
2668
2798
  snapshotJobId = registerSnapshot(ctx, root, registry, projectId, target).jobId;
2669
2799
  }
2670
2800
  }
2671
- return snapshotJobId === null ? result : { ...result, snapshotJobId };
2801
+ return {
2802
+ ...result,
2803
+ ...(snapshotJobId === null ? {} : { snapshotJobId }),
2804
+ ...(outlineShift.shifted.length === 0
2805
+ ? {}
2806
+ : { shiftDays: outlineShift.shiftDays, shifted: outlineShift.shifted }),
2807
+ };
2672
2808
  }
2673
2809
 
2674
2810
  async function saveChapter(ctx, root, registry, body) {
@@ -3133,6 +3269,8 @@ async function startCompose(ctx, root, registry, body) {
3133
3269
  const existing = existingId === ''
3134
3270
  ? null
3135
3271
  : (project.chapters || []).find((entry) => entry.chapterId === existingId) || null;
3272
+ // Set inside the mutation below when re-deducing moves the chapters after it.
3273
+ let composeShift = { shiftDays: 0, shifted: [] };
3136
3274
  if (existingId !== '' && !existing) throw httpError(404, `章节不存在: ${existingId}`);
3137
3275
 
3138
3276
  // The first chapter of a work has no snapshot and no previous chapter, so
@@ -3183,9 +3321,12 @@ async function startCompose(ctx, root, registry, body) {
3183
3321
  const target = current.chapters.find((entry) => entry.chapterId === draft.chapterId);
3184
3322
  if (target) {
3185
3323
  target.title = title;
3186
- target.durationDays = durationDays;
3187
3324
  target.outline = outline;
3188
3325
  target.updatedAt = Date.now();
3326
+ // Re-deducing an existing chapter can change its length too.
3327
+ const moved = applyDurationShift(current.chapters, target, durationDays);
3328
+ composeShift = moved;
3329
+ target.outline.durationDays = target.durationDays;
3189
3330
  } else {
3190
3331
  current.chapters.push({
3191
3332
  ...draft,
@@ -3238,6 +3379,8 @@ async function startCompose(ctx, root, registry, body) {
3238
3379
  synopsis,
3239
3380
  wordCount: wordCount(content),
3240
3381
  snapshotJobId,
3382
+ shiftDays: composeShift.shiftDays,
3383
+ shifted: composeShift.shifted,
3241
3384
  };
3242
3385
  });
3243
3386
  }
@@ -3368,6 +3511,8 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
3368
3511
  return startStyleExtract(ctx, root, registry, body);
3369
3512
  case '/style/apply':
3370
3513
  return startStyleApply(ctx, root, registry, body);
3514
+ case '/project/settings':
3515
+ return updateSettings(root, body);
3371
3516
  case '/import/apply':
3372
3517
  return startImportChapters(ctx, root, registry, body);
3373
3518
  case '/outline/revise':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-novel-generator",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
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",