dsh-novel-generator 0.1.39 → 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 +35 -4
  2. package/index.js +72 -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' },
@@ -1703,7 +1705,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1703
1705
  }, busy ? t('改写中…', 'Revising…') : t('确认修改', 'Revise')))));
1704
1706
  }
1705
1707
 
1706
- 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 }) {
1707
1709
  const buildForm = useCallback(() => ({
1708
1710
  title: chapter ? chapter.title || '' : '',
1709
1711
  durationDays: chapter && chapter.outline ? chapter.outline.durationDays : (detail.timeline.unitDays || 30),
@@ -1761,6 +1763,10 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1761
1763
  setSavedAt(Date.now());
1762
1764
  setDirty(false);
1763
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
+ }
1764
1770
  await onSaved(result.chapter.chapterId);
1765
1771
  return true;
1766
1772
  } catch (error) {
@@ -1769,7 +1775,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1769
1775
  } finally {
1770
1776
  setSaving(false);
1771
1777
  }
1772
- }, [complete, detail.projectId, chapter, form, onSaved, notify]);
1778
+ }, [complete, detail.projectId, chapter, form, onSaved, notify, onShiftNotice]);
1773
1779
 
1774
1780
  // The workspace asks this page before any navigation discards its edits.
1775
1781
  const guardRef = useRef(null);
@@ -2759,6 +2765,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2759
2765
  synopsisDraft,
2760
2766
  onSynopsisDraftApplied: () => setSynopsisDraft(null),
2761
2767
  revisingSynopsis,
2768
+ onShiftNotice: setShiftNotice,
2762
2769
  isFirstChapter: Boolean(chapter && chapters.length > 0 && chapter.chapterId === chapters[0].chapterId),
2763
2770
  }))),
2764
2771
  shiftNotice
@@ -2881,6 +2888,9 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2881
2888
  const [presetTarget, setPresetTarget] = useState(null);
2882
2889
  const [preludeTarget, setPreludeTarget] = useState(null);
2883
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);
2884
2894
  const leaveGuard = useRef(null);
2885
2895
  const registerLeaveGuard = useCallback((guard) => {
2886
2896
  leaveGuard.current = guard;
@@ -3020,6 +3030,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
3020
3030
  resolveWorkspace,
3021
3031
  onCancel: () => setImportTarget(null),
3022
3032
  onDone: async (result) => {
3033
+ const finished = importTarget;
3023
3034
  setImportTarget(null);
3024
3035
  await loadBoot();
3025
3036
  const imported = (result.imported || []).length;
@@ -3035,6 +3046,26 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
3035
3046
  }));
3036
3047
  if (result.snapshotJobId) parts.push(t('世界快照已开始生成', 'World snapshot started'));
3037
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);
3038
3069
  },
3039
3070
  })
3040
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.2',
1502
+ revision: '2026-10-29.3',
1503
1503
  projects,
1504
1504
  models: {
1505
1505
  default: modelCatalog.default || null,
@@ -2103,10 +2103,7 @@ async function startSyncOutline(ctx, root, registry, body) {
2103
2103
  * back into its outline". Used by the story page's button and by chapter import,
2104
2104
  * so both produce identical outlines.
2105
2105
  */
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;
2106
+ async function syncChapterOutline(ctx, root, projectId, chapterId, prose, note) {
2110
2107
  const project = await readProject(root, projectId);
2111
2108
  const chapter = project.chapters.find((entry) => entry.chapterId === chapterId);
2112
2109
  if (!chapter) throw httpError(404, `章节不存在: ${chapterId}`);
@@ -2143,12 +2140,11 @@ async function syncChapterOutline(ctx, root, projectId, chapterId, prose, note,
2143
2140
  // The measured duration wins, and the timeline stays contiguous: every later
2144
2141
  // chapter moves with it. Snapshots are deliberately left where they are — the
2145
2142
  // Client tells the author to regenerate the ones that no longer line up.
2146
- let durationDays = chapter.durationDays;
2147
2143
  const proposed = Number.isFinite(Number(parsed.durationDays))
2148
2144
  ? Math.max(1, Math.min(365, Math.round(Number(parsed.durationDays))))
2149
2145
  : null;
2150
- const shiftDays = proposed !== null && !keepDuration ? proposed - chapter.durationDays : 0;
2151
- if (proposed !== null && !keepDuration) durationDays = proposed;
2146
+ let durationDays = proposed === null ? chapter.durationDays : proposed;
2147
+ let shiftDays = 0;
2152
2148
  const shifted = [];
2153
2149
 
2154
2150
  const saved = await mutateProject(root, projectId, (draft) => {
@@ -2163,18 +2159,14 @@ async function syncChapterOutline(ctx, root, projectId, chapterId, prose, note,
2163
2159
  synopsisMinChars,
2164
2160
  savedAt: Date.now(),
2165
2161
  };
2166
- target.durationDays = durationDays;
2167
2162
  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
- }
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;
2178
2170
  return { chapter: { ...target, content: undefined }, project: summarize(draft) };
2179
2171
  });
2180
2172
  const fresh = await readProject(root, projectId);
@@ -2423,6 +2415,8 @@ async function startImportChapters(ctx, root, registry, body) {
2423
2415
  }
2424
2416
 
2425
2417
  const synced = [];
2418
+ const shifted = [];
2419
+ let shiftDaysTotal = 0;
2426
2420
  if (syncOutline && imported.length > 0) {
2427
2421
  for (let index = 0; index < imported.length; index += 1) {
2428
2422
  const entry = imported[index];
@@ -2432,8 +2426,17 @@ async function startImportChapters(ctx, root, registry, body) {
2432
2426
  const chapter = project.chapters.find((item) => item.chapterId === entry.chapterId);
2433
2427
  const result = await syncChapterOutline(ctx, root, projectId, entry.chapterId, chapter ? (chapter.content || '') : '', (note) => {
2434
2428
  job.message = note;
2435
- }, { keepDuration: true });
2429
+ });
2436
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;
2437
2440
  } catch (error) {
2438
2441
  failed.push({ fileName: entry.title, reason: `同步大纲失败:${error instanceof Error ? error.message : String(error)}` });
2439
2442
  }
@@ -2454,6 +2457,8 @@ async function startImportChapters(ctx, root, registry, body) {
2454
2457
  skipped,
2455
2458
  failed,
2456
2459
  synced,
2460
+ shifted,
2461
+ shiftDays: shiftDaysTotal,
2457
2462
  snapshotJobId,
2458
2463
  project: summarize(await readProject(root, projectId)),
2459
2464
  };
@@ -2603,6 +2608,33 @@ function nextChapterIndex(project) {
2603
2608
  return max + 1;
2604
2609
  }
2605
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
+
2606
2638
  async function saveOutline(ctx, root, registry, body) {
2607
2639
  const projectId = asText(body.projectId);
2608
2640
  const chapterId = asText(body.chapterId);
@@ -2614,6 +2646,7 @@ async function saveOutline(ctx, root, registry, body) {
2614
2646
  let savedProject = null;
2615
2647
  let savedChapter = null;
2616
2648
  let renamedFrom = null;
2649
+ let outlineShift = { shiftDays: 0, shifted: [] };
2617
2650
  const result = await mutateProject(root, projectId, (project) => {
2618
2651
  let chapter = chapterId === '' ? null : project.chapters.find((entry) => entry.chapterId === chapterId);
2619
2652
  if (!chapter) {
@@ -2637,7 +2670,10 @@ async function saveOutline(ctx, root, registry, body) {
2637
2670
  }
2638
2671
  if (title !== '') chapter.title = title;
2639
2672
  chapter.outline = outline;
2640
- 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;
2641
2677
  if (chapter.status === 'draft') chapter.status = 'outlined';
2642
2678
  chapter.updatedAt = Date.now();
2643
2679
  savedProject = project;
@@ -2668,7 +2704,13 @@ async function saveOutline(ctx, root, registry, body) {
2668
2704
  snapshotJobId = registerSnapshot(ctx, root, registry, projectId, target).jobId;
2669
2705
  }
2670
2706
  }
2671
- 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
+ };
2672
2714
  }
2673
2715
 
2674
2716
  async function saveChapter(ctx, root, registry, body) {
@@ -3133,6 +3175,8 @@ async function startCompose(ctx, root, registry, body) {
3133
3175
  const existing = existingId === ''
3134
3176
  ? null
3135
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: [] };
3136
3180
  if (existingId !== '' && !existing) throw httpError(404, `章节不存在: ${existingId}`);
3137
3181
 
3138
3182
  // The first chapter of a work has no snapshot and no previous chapter, so
@@ -3183,9 +3227,12 @@ async function startCompose(ctx, root, registry, body) {
3183
3227
  const target = current.chapters.find((entry) => entry.chapterId === draft.chapterId);
3184
3228
  if (target) {
3185
3229
  target.title = title;
3186
- target.durationDays = durationDays;
3187
3230
  target.outline = outline;
3188
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;
3189
3236
  } else {
3190
3237
  current.chapters.push({
3191
3238
  ...draft,
@@ -3238,6 +3285,8 @@ async function startCompose(ctx, root, registry, body) {
3238
3285
  synopsis,
3239
3286
  wordCount: wordCount(content),
3240
3287
  snapshotJobId,
3288
+ shiftDays: composeShift.shiftDays,
3289
+ shifted: composeShift.shifted,
3241
3290
  };
3242
3291
  });
3243
3292
  }
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.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",