dsh-novel-generator 0.1.38 → 0.1.40

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 +83 -3
  2. package/index.js +79 -19
  3. package/package.json +1 -1
package/client.js CHANGED
@@ -718,6 +718,39 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
718
718
  }, busy ? t('整理中…', 'Merging…') : t('写入前置', 'Write into preface')))));
719
719
  }
720
720
 
721
+ /**
722
+ * After a duration change moves the chapters that follow, the snapshots stay
723
+ * where they were — this dialog is the only place the author learns about it.
724
+ */
725
+ function ShiftNoticeDialog({ shiftDays, shifted, scope, onClose, onGoTimeline }) {
726
+ const list = Array.isArray(shifted) ? shifted : [];
727
+ const sample = list.slice(0, 6);
728
+ return h('div', { className: 'dsn-dialog-backdrop' },
729
+ h('div', { className: 'dsn-dialog', style: { width: 'min(560px, calc(100% - 48px))' } },
730
+ h('div', { className: 'dsn-dialog-head' },
731
+ h('div', { className: 'dsn-title' }, t('时间线已顺移,快照需要你手动更新', 'The timeline moved — snapshots need updating')),
732
+ h('div', { className: 'dsn-spacer' })),
733
+ h('div', { className: 'dsn-dialog-body' },
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 })),
737
+ list.length > 0
738
+ ? h('div', { className: 'dsn-importlist span4' },
739
+ sample.map((entry) => h('div', { key: entry.chapterId, className: 'dsn-importrow' },
740
+ h('span', { className: 'dsn-importtitle' }, entry.title || t('未命名', 'Untitled')),
741
+ h('span', { className: 'dsn-importarrow' }, '→'),
742
+ h('span', { className: 'dsn-importchars' }, t('第 {from} 天 → 第 {to} 天', 'day {from} → day {to}', { from: entry.fromDay, to: entry.toDay })))))
743
+ : null,
744
+ list.length > sample.length
745
+ ? h('div', { className: 'dsn-hint span4' }, t('另有 {n} 章同样顺移。', '{n} more chapters moved as well.', { n: list.length - sample.length }))
746
+ : null,
747
+ h('div', { className: 'dsn-hint warn span4' }, t('世界快照没有跟着移动:已有的那几份仍按旧日期保存。请到时间长河检查,把受影响的落点重新生成;如果生成正文时提示「最近的世界快照停在…」,也是去那里更新。', 'The world snapshots did not move with it — the existing ones still hold the old dates. Open the timeline, regenerate the affected points, and update them if a generation says the snapshot is behind.'))),
748
+ h('div', { className: 'dsn-dialog-foot' },
749
+ h('button', { className: 'dsn-btn ghost', type: 'button', onClick: onClose }, t('知道了', 'Got it')),
750
+ h('div', { className: 'dsn-spacer' }),
751
+ h('button', { className: 'dsn-btn primary', type: 'button', onClick: onGoTimeline }, t('去时间长河', 'Open timeline')))));
752
+ }
753
+
721
754
  function PresetDialog({ project, presets, onCancel, onSaved }) {
722
755
  const [preset, setPreset] = useState(project.agentPreset || '');
723
756
  const [busy, setBusy] = useState(false);
@@ -1672,7 +1705,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1672
1705
  }, busy ? t('改写中…', 'Revising…') : t('确认修改', 'Revise')))));
1673
1706
  }
1674
1707
 
1675
- function OutlinePage({ detail, chapter, isFirstChapter, onBack, onSaved, notify, onRegisterGuard, requestConfirm, onBusyChange, onReviseSynopsis, synopsisDraft, onSynopsisDraftApplied, revisingSynopsis }) {
1708
+ function OutlinePage({ detail, chapter, isFirstChapter, onBack, onSaved, notify, onRegisterGuard, requestConfirm, onBusyChange, onReviseSynopsis, synopsisDraft, onSynopsisDraftApplied, revisingSynopsis, onShiftNotice }) {
1676
1709
  const buildForm = useCallback(() => ({
1677
1710
  title: chapter ? chapter.title || '' : '',
1678
1711
  durationDays: chapter && chapter.outline ? chapter.outline.durationDays : (detail.timeline.unitDays || 30),
@@ -1730,6 +1763,10 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1730
1763
  setSavedAt(Date.now());
1731
1764
  setDirty(false);
1732
1765
  if (silent !== true) notify(t('章节大纲已保存', "Outline saved"), 'success');
1766
+ // A length change moved the chapters after it; snapshots did not follow.
1767
+ if (result.shiftDays && Array.isArray(result.shifted) && result.shifted.length > 0) {
1768
+ if (typeof onShiftNotice === 'function') onShiftNotice({ shiftDays: result.shiftDays, shifted: result.shifted });
1769
+ }
1733
1770
  await onSaved(result.chapter.chapterId);
1734
1771
  return true;
1735
1772
  } catch (error) {
@@ -1738,7 +1775,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1738
1775
  } finally {
1739
1776
  setSaving(false);
1740
1777
  }
1741
- }, [complete, detail.projectId, chapter, form, onSaved, notify]);
1778
+ }, [complete, detail.projectId, chapter, form, onSaved, notify, onShiftNotice]);
1742
1779
 
1743
1780
  // The workspace asks this page before any navigation discards its edits.
1744
1781
  const guardRef = useRef(null);
@@ -2212,6 +2249,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2212
2249
  const [styleApplyJob, setStyleApplyJob] = useJob();
2213
2250
  const [styleResult, setStyleResult] = useState(null);
2214
2251
  const [styleError, setStyleError] = useState('');
2252
+ const [shiftNotice, setShiftNotice] = useState(null);
2215
2253
  // The synopsis rewrite lives here, not in OutlinePage: the page renders
2216
2254
  // inside the translated track, where an absolutely positioned dialog would
2217
2255
  // be painted off-screen.
@@ -2343,7 +2381,13 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2343
2381
  }),
2344
2382
  'success',
2345
2383
  );
2346
- for (const note of notes) notify(note, 'error');
2384
+ // A duration change moved the chapters after it; snapshots did not move
2385
+ // with them, so this is announced in a dialog instead of a toast.
2386
+ if (result.shiftDays && Array.isArray(result.shifted) && result.shifted.length > 0) {
2387
+ setShiftNotice({ shiftDays: result.shiftDays, shifted: result.shifted });
2388
+ } else {
2389
+ for (const note of notes) notify(note, 'error');
2390
+ }
2347
2391
  onReload();
2348
2392
  } else if (summarizeJob.state === 'error') {
2349
2393
  notify(summarizeJob.error || t('同步大纲失败', 'Syncing the outline failed'), 'error');
@@ -2721,8 +2765,20 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2721
2765
  synopsisDraft,
2722
2766
  onSynopsisDraftApplied: () => setSynopsisDraft(null),
2723
2767
  revisingSynopsis,
2768
+ onShiftNotice: setShiftNotice,
2724
2769
  isFirstChapter: Boolean(chapter && chapters.length > 0 && chapter.chapterId === chapters[0].chapterId),
2725
2770
  }))),
2771
+ shiftNotice
2772
+ ? h(ShiftNoticeDialog, {
2773
+ shiftDays: shiftNotice.shiftDays,
2774
+ shifted: shiftNotice.shifted,
2775
+ onClose: () => setShiftNotice(null),
2776
+ onGoTimeline: () => {
2777
+ setShiftNotice(null);
2778
+ setView('world');
2779
+ },
2780
+ })
2781
+ : null,
2726
2782
  styleResult || applyingStyle
2727
2783
  ? h(StyleDialog, {
2728
2784
  style: styleResult ? styleResult.style : '',
@@ -2832,6 +2888,9 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2832
2888
  const [presetTarget, setPresetTarget] = useState(null);
2833
2889
  const [preludeTarget, setPreludeTarget] = useState(null);
2834
2890
  const [importTarget, setImportTarget] = useState(null);
2891
+ // Import's outline sync can move chapters too; the notice belongs here
2892
+ // because the import dialog closes before the shift is known.
2893
+ const [shiftNotice, setShiftNotice] = useState(null);
2835
2894
  const leaveGuard = useRef(null);
2836
2895
  const registerLeaveGuard = useCallback((guard) => {
2837
2896
  leaveGuard.current = guard;
@@ -2971,6 +3030,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2971
3030
  resolveWorkspace,
2972
3031
  onCancel: () => setImportTarget(null),
2973
3032
  onDone: async (result) => {
3033
+ const finished = importTarget;
2974
3034
  setImportTarget(null);
2975
3035
  await loadBoot();
2976
3036
  const imported = (result.imported || []).length;
@@ -2986,6 +3046,26 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2986
3046
  }));
2987
3047
  if (result.snapshotJobId) parts.push(t('世界快照已开始生成', 'World snapshot started'));
2988
3048
  setToast({ text: parts.join(';'), kind: failed > 0 ? 'error' : 'success' });
3049
+ if (result.shiftDays && Array.isArray(result.shifted) && result.shifted.length > 0) {
3050
+ setShiftNotice({
3051
+ shiftDays: result.shiftDays,
3052
+ shifted: result.shifted,
3053
+ projectId: finished ? finished.projectId : '',
3054
+ });
3055
+ }
3056
+ },
3057
+ })
3058
+ : null,
3059
+ shiftNotice
3060
+ ? h(ShiftNoticeDialog, {
3061
+ shiftDays: shiftNotice.shiftDays,
3062
+ shifted: shiftNotice.shifted,
3063
+ scope: 'import',
3064
+ onClose: () => setShiftNotice(null),
3065
+ onGoTimeline: () => {
3066
+ const targetId = shiftNotice.projectId || '';
3067
+ setShiftNotice(null);
3068
+ if (targetId !== '') openProject(targetId);
2989
3069
  },
2990
3070
  })
2991
3071
  : null,
package/index.js CHANGED
@@ -1499,7 +1499,7 @@ async function bootstrap(ctx, root) {
1499
1499
  }
1500
1500
  }
1501
1501
  return {
1502
- revision: '2026-10-29.1',
1502
+ revision: '2026-10-29.3',
1503
1503
  projects,
1504
1504
  models: {
1505
1505
  default: modelCatalog.default || null,
@@ -2137,23 +2137,15 @@ async function syncChapterOutline(ctx, root, projectId, chapterId, prose, note)
2137
2137
  synopsisMinChars = Math.max(20, Math.min(400, synopsisMinChars));
2138
2138
  synopsisMinChars = Math.max(1, Math.min(synopsisMinChars, synopsis.length));
2139
2139
 
2140
- // A new duration only lands when it keeps the next chapter where it is.
2141
- let durationDays = chapter.durationDays;
2140
+ // The measured duration wins, and the timeline stays contiguous: every later
2141
+ // chapter moves with it. Snapshots are deliberately left where they are — the
2142
+ // Client tells the author to regenerate the ones that no longer line up.
2142
2143
  const proposed = Number.isFinite(Number(parsed.durationDays))
2143
2144
  ? Math.max(1, Math.min(365, Math.round(Number(parsed.durationDays))))
2144
2145
  : null;
2145
- if (proposed !== null && proposed !== chapter.durationDays) {
2146
- const following = (project.chapters || [])
2147
- .filter((entry) => entry.chapterId !== chapter.chapterId && entry.startDay >= span.start)
2148
- .sort((a, b) => a.startDay - b.startDay)[0] || null;
2149
- if (following === null) {
2150
- durationDays = proposed;
2151
- } else if (following.startDay === span.start + proposed) {
2152
- durationDays = proposed;
2153
- } else {
2154
- notes.push(`下一章从第 ${following.startDay} 天开始,改时长会错位,时间长度保持 ${chapter.durationDays} 天(模型建议 ${proposed} 天)`);
2155
- }
2156
- }
2146
+ let durationDays = proposed === null ? chapter.durationDays : proposed;
2147
+ let shiftDays = 0;
2148
+ const shifted = [];
2157
2149
 
2158
2150
  const saved = await mutateProject(root, projectId, (draft) => {
2159
2151
  const target = draft.chapters.find((entry) => entry.chapterId === chapterId);
@@ -2167,13 +2159,22 @@ async function syncChapterOutline(ctx, root, projectId, chapterId, prose, note)
2167
2159
  synopsisMinChars,
2168
2160
  savedAt: Date.now(),
2169
2161
  };
2170
- target.durationDays = durationDays;
2171
2162
  target.updatedAt = Date.now();
2163
+ // Shared with the outline save and chapter import: any length change moves
2164
+ // the chapters that follow.
2165
+ const moved = applyDurationShift(draft.chapters, target, durationDays);
2166
+ durationDays = target.durationDays;
2167
+ shiftDays = moved.shiftDays;
2168
+ shifted.push(...moved.shifted);
2169
+ target.outline.durationDays = durationDays;
2172
2170
  return { chapter: { ...target, content: undefined }, project: summarize(draft) };
2173
2171
  });
2174
2172
  const fresh = await readProject(root, projectId);
2175
2173
  await exportChapterOutline(fresh, saved.chapter);
2176
2174
  await exportProjectInfo(fresh);
2175
+ if (shifted.length > 0 && shiftDays !== 0) {
2176
+ notes.push(`本章时间长度 ${chapter.durationDays} → ${durationDays} 天,之后 ${shifted.length} 章整体顺移 ${shiftDays > 0 ? '+' : ''}${shiftDays} 天;世界快照没有跟着移动`);
2177
+ }
2177
2178
  return {
2178
2179
  synopsis,
2179
2180
  minWords,
@@ -2182,6 +2183,8 @@ async function syncChapterOutline(ctx, root, projectId, chapterId, prose, note)
2182
2183
  synopsisMinChars,
2183
2184
  chars,
2184
2185
  notes,
2186
+ shifted,
2187
+ shiftDays,
2185
2188
  chapter: saved.chapter,
2186
2189
  };
2187
2190
  }
@@ -2412,6 +2415,8 @@ async function startImportChapters(ctx, root, registry, body) {
2412
2415
  }
2413
2416
 
2414
2417
  const synced = [];
2418
+ const shifted = [];
2419
+ let shiftDaysTotal = 0;
2415
2420
  if (syncOutline && imported.length > 0) {
2416
2421
  for (let index = 0; index < imported.length; index += 1) {
2417
2422
  const entry = imported[index];
@@ -2423,6 +2428,15 @@ async function startImportChapters(ctx, root, registry, body) {
2423
2428
  job.message = note;
2424
2429
  });
2425
2430
  synced.push({ chapterId: entry.chapterId, synopsisChars: (result.synopsis || '').length });
2431
+ for (const move of (result.shifted || [])) {
2432
+ const seen = shifted.find((item) => item.chapterId === move.chapterId);
2433
+ if (seen) {
2434
+ seen.toDay = move.toDay;
2435
+ } else {
2436
+ shifted.push({ ...move });
2437
+ }
2438
+ }
2439
+ shiftDaysTotal += Number(result.shiftDays) || 0;
2426
2440
  } catch (error) {
2427
2441
  failed.push({ fileName: entry.title, reason: `同步大纲失败:${error instanceof Error ? error.message : String(error)}` });
2428
2442
  }
@@ -2443,6 +2457,8 @@ async function startImportChapters(ctx, root, registry, body) {
2443
2457
  skipped,
2444
2458
  failed,
2445
2459
  synced,
2460
+ shifted,
2461
+ shiftDays: shiftDaysTotal,
2446
2462
  snapshotJobId,
2447
2463
  project: summarize(await readProject(root, projectId)),
2448
2464
  };
@@ -2592,6 +2608,33 @@ function nextChapterIndex(project) {
2592
2608
  return max + 1;
2593
2609
  }
2594
2610
 
2611
+ /**
2612
+ * Keep story time contiguous after one chapter's length changes.
2613
+ *
2614
+ * The chapter takes the new length and every chapter starting at or after it
2615
+ * moves by the same number of days, so the timeline never opens a gap or an
2616
+ * overlap. Snapshots are not touched — the Client tells the author to update
2617
+ * them. Returns the moves so callers can report them.
2618
+ */
2619
+ function applyDurationShift(chapters, chapter, nextDuration) {
2620
+ const previous = Math.max(1, Math.round(Number(chapter.durationDays) || 1));
2621
+ const next = Math.max(1, Math.round(Number(nextDuration) || previous));
2622
+ const shifted = [];
2623
+ chapter.durationDays = next;
2624
+ const delta = next - previous;
2625
+ if (delta === 0) return { shiftDays: 0, shifted };
2626
+ const start = Math.max(0, Math.round(Number(chapter.startDay) || 0));
2627
+ for (const other of chapters) {
2628
+ if (other.chapterId === chapter.chapterId) continue;
2629
+ if ((Number(other.startDay) || 0) < start) continue;
2630
+ const from = other.startDay;
2631
+ other.startDay = Math.max(start + 1, from + delta);
2632
+ other.updatedAt = Date.now();
2633
+ shifted.push({ chapterId: other.chapterId, title: other.title, fromDay: from, toDay: other.startDay });
2634
+ }
2635
+ return { shiftDays: delta, shifted };
2636
+ }
2637
+
2595
2638
  async function saveOutline(ctx, root, registry, body) {
2596
2639
  const projectId = asText(body.projectId);
2597
2640
  const chapterId = asText(body.chapterId);
@@ -2603,6 +2646,7 @@ async function saveOutline(ctx, root, registry, body) {
2603
2646
  let savedProject = null;
2604
2647
  let savedChapter = null;
2605
2648
  let renamedFrom = null;
2649
+ let outlineShift = { shiftDays: 0, shifted: [] };
2606
2650
  const result = await mutateProject(root, projectId, (project) => {
2607
2651
  let chapter = chapterId === '' ? null : project.chapters.find((entry) => entry.chapterId === chapterId);
2608
2652
  if (!chapter) {
@@ -2626,7 +2670,10 @@ async function saveOutline(ctx, root, registry, body) {
2626
2670
  }
2627
2671
  if (title !== '') chapter.title = title;
2628
2672
  chapter.outline = outline;
2629
- chapter.durationDays = outline.durationDays;
2673
+ // Length changes move the chapters after this one, whatever the caller is.
2674
+ const moved = applyDurationShift(project.chapters, chapter, outline.durationDays);
2675
+ outlineShift = moved;
2676
+ chapter.outline.durationDays = chapter.durationDays;
2630
2677
  if (chapter.status === 'draft') chapter.status = 'outlined';
2631
2678
  chapter.updatedAt = Date.now();
2632
2679
  savedProject = project;
@@ -2657,7 +2704,13 @@ async function saveOutline(ctx, root, registry, body) {
2657
2704
  snapshotJobId = registerSnapshot(ctx, root, registry, projectId, target).jobId;
2658
2705
  }
2659
2706
  }
2660
- return snapshotJobId === null ? result : { ...result, snapshotJobId };
2707
+ return {
2708
+ ...result,
2709
+ ...(snapshotJobId === null ? {} : { snapshotJobId }),
2710
+ ...(outlineShift.shifted.length === 0
2711
+ ? {}
2712
+ : { shiftDays: outlineShift.shiftDays, shifted: outlineShift.shifted }),
2713
+ };
2661
2714
  }
2662
2715
 
2663
2716
  async function saveChapter(ctx, root, registry, body) {
@@ -3122,6 +3175,8 @@ async function startCompose(ctx, root, registry, body) {
3122
3175
  const existing = existingId === ''
3123
3176
  ? null
3124
3177
  : (project.chapters || []).find((entry) => entry.chapterId === existingId) || null;
3178
+ // Set inside the mutation below when re-deducing moves the chapters after it.
3179
+ let composeShift = { shiftDays: 0, shifted: [] };
3125
3180
  if (existingId !== '' && !existing) throw httpError(404, `章节不存在: ${existingId}`);
3126
3181
 
3127
3182
  // The first chapter of a work has no snapshot and no previous chapter, so
@@ -3172,9 +3227,12 @@ async function startCompose(ctx, root, registry, body) {
3172
3227
  const target = current.chapters.find((entry) => entry.chapterId === draft.chapterId);
3173
3228
  if (target) {
3174
3229
  target.title = title;
3175
- target.durationDays = durationDays;
3176
3230
  target.outline = outline;
3177
3231
  target.updatedAt = Date.now();
3232
+ // Re-deducing an existing chapter can change its length too.
3233
+ const moved = applyDurationShift(current.chapters, target, durationDays);
3234
+ composeShift = moved;
3235
+ target.outline.durationDays = target.durationDays;
3178
3236
  } else {
3179
3237
  current.chapters.push({
3180
3238
  ...draft,
@@ -3227,6 +3285,8 @@ async function startCompose(ctx, root, registry, body) {
3227
3285
  synopsis,
3228
3286
  wordCount: wordCount(content),
3229
3287
  snapshotJobId,
3288
+ shiftDays: composeShift.shiftDays,
3289
+ shifted: composeShift.shifted,
3230
3290
  };
3231
3291
  });
3232
3292
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-novel-generator",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
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",