dsh-novel-generator 0.1.8 → 0.1.10

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 +59 -20
  2. package/index.js +42 -16
  3. package/package.json +1 -1
package/client.js CHANGED
@@ -197,6 +197,11 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
197
197
 
198
198
  /* workspace track */
199
199
  .dsn-stage{flex:1;position:relative;overflow:hidden;min-width:0}
200
+ .dsn-lock{position:absolute;inset:0;z-index:40;display:flex;align-items:center;justify-content:center;background:color-mix(in srgb, var(--dsw-alias-bg-overlay) 88%, transparent);backdrop-filter:blur(2px);cursor:progress}
201
+ .dsn-lock-box{max-width:420px;padding:20px 24px;border-radius:14px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-3);box-shadow:0 18px 48px rgba(0,0,0,.28);text-align:center}
202
+ .dsn-lock-title{font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary)}
203
+ .dsn-lock-text{margin-top:8px;font-size:13px;line-height:1.6;color:var(--dsw-alias-label-secondary);word-break:break-word}
204
+ .dsn-lock-hint{margin-top:10px;font-size:12px;color:var(--dsw-alias-label-tertiary)}
200
205
  .dsn-track{display:flex;height:100%;transition:transform .28s ease}
201
206
  .dsn-page{width:100%;height:100%;flex:0 0 100%;display:flex;flex-direction:column;min-width:0}
202
207
  .dsn-pagehead{display:flex;align-items:center;gap:10px;padding:10px 16px;border-bottom:1px solid var(--dsw-alias-border-l1)}
@@ -1465,7 +1470,10 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1465
1470
  function ProjectWorkspace({ detail, onReload, notify, onRegisterLeaveGuard }) {
1466
1471
  const [view, setView] = useState('world');
1467
1472
  const [chapterId, setChapterId] = useState(null);
1468
- const [snapshotBusy, setSnapshotBusy] = useState(false);
1473
+ // A running snapshot job: { label, message }. Non-null locks the studio,
1474
+ // because the job keeps rewriting this work's snapshots.
1475
+ const [snapshotJob, setSnapshotJob] = useState(null);
1476
+ const snapshotBusy = snapshotJob !== null;
1469
1477
  const [dialogOpen, setDialogOpen] = useState(false);
1470
1478
  const [text, setText] = useState('');
1471
1479
  const [dirty, setDirty] = useState(false);
@@ -1583,36 +1591,57 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1583
1591
  }
1584
1592
  }, [detail.projectId, onReload, notify]);
1585
1593
 
1586
- /** Poll one snapshot job until it settles, then refresh the work. */
1587
- const waitSnapshotJob = useCallback((jobId, doneText) => {
1594
+ /**
1595
+ * Poll one snapshot job until it settles. While it runs the studio stays
1596
+ * locked and the work is reloaded on every tick, so newly written
1597
+ * snapshots appear on the timeline as they land.
1598
+ */
1599
+ const waitSnapshotJob = useCallback((jobId, label, doneText) => {
1600
+ setSnapshotJob({ label, message: label });
1601
+ let failures = 0;
1602
+ const startedAt = Date.now();
1588
1603
  const timer = setInterval(async () => {
1589
1604
  try {
1590
1605
  const job = await api(`/job?id=${encodeURIComponent(jobId)}`);
1591
- if (job.state === 'running') return;
1592
- clearInterval(timer);
1593
- setSnapshotBusy(false);
1594
- if (job.state === 'done') {
1595
- notify(doneText, 'success');
1606
+ failures = 0;
1607
+ if (job.state === 'running') {
1608
+ if (Date.now() - startedAt > 45 * 60 * 1000) {
1609
+ clearInterval(timer);
1610
+ setSnapshotJob(null);
1611
+ notify(t('快照任务超时,已解除锁定', 'The snapshot job timed out; the studio is unlocked.'), 'error');
1612
+ return;
1613
+ }
1614
+ setSnapshotJob({ label, message: job.message || label });
1596
1615
  await onReload();
1597
- } else {
1598
- notify(job.error || t('世界快照更新失败', "World snapshot update failed"), 'error');
1616
+ return;
1599
1617
  }
1600
- } catch (error) {
1601
1618
  clearInterval(timer);
1602
- setSnapshotBusy(false);
1603
- notify(messageOf(error), 'error');
1619
+ setSnapshotJob(null);
1620
+ await onReload();
1621
+ if (job.state === 'done') notify(doneText, 'success');
1622
+ else notify(job.error || t('世界快照更新失败', "World snapshot update failed"), 'error');
1623
+ } catch (error) {
1624
+ failures += 1;
1625
+ // Three failed polls in a row mean the studio cannot be held.
1626
+ if (failures >= 3) {
1627
+ clearInterval(timer);
1628
+ setSnapshotJob(null);
1629
+ notify(messageOf(error), 'error');
1630
+ }
1604
1631
  }
1605
1632
  }, 2500);
1606
1633
  }, [onReload, notify]);
1607
1634
 
1608
1635
  const refreshSnapshot = useCallback(async (day) => {
1609
- setSnapshotBusy(true);
1610
1636
  try {
1611
1637
  const started = await api('/snapshot/refresh', { projectId: detail.projectId, day });
1612
1638
  notify(t('正在结算世界快照…', "Settling the world snapshot…"), 'success');
1613
- waitSnapshotJob(started.jobId, t('世界快照已更新,时间线向前延伸', "World snapshot updated; the timeline moved forward."));
1639
+ waitSnapshotJob(
1640
+ started.jobId,
1641
+ t('正在生成第 {day} 天的世界快照…', 'Generating the world snapshot for day {day}…', { day }),
1642
+ t('世界快照已更新,时间线向前延伸', "World snapshot updated; the timeline moved forward."),
1643
+ );
1614
1644
  } catch (error) {
1615
- setSnapshotBusy(false);
1616
1645
  notify(messageOf(error), 'error');
1617
1646
  }
1618
1647
  }, [detail.projectId, notify, waitSnapshotJob]);
@@ -1627,13 +1656,14 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1627
1656
  text: t('将清除所有世界快照,并按当前间隔(每 {unit} 天)从第 {unit} 天起逐格重建到第 {end} 天,共 {count} 份。每份都要调用一次模型,耗时较长。', 'Every world snapshot is deleted and rebuilt point by point — {count} of them, from day {unit} to day {end} at the current {unit}-day interval. Each one calls the model, so this takes a while.', { unit, end, count: Math.max(0, Math.floor(end / unit)) }),
1628
1657
  confirmLabel: t('清除并重建', 'Clear and rebuild'),
1629
1658
  action: async () => {
1630
- setSnapshotBusy(true);
1631
1659
  try {
1632
1660
  const started = await api('/snapshot/reset', { projectId: detail.projectId });
1633
- notify(t('正在重建世界快照…', 'Rebuilding world snapshots…'), 'success');
1634
- waitSnapshotJob(started.jobId, t('世界快照已全部重建', 'All world snapshots rebuilt'));
1661
+ waitSnapshotJob(
1662
+ started.jobId,
1663
+ t('正在重建世界快照…', 'Rebuilding world snapshots…'),
1664
+ t('世界快照已全部重建', 'All world snapshots rebuilt'),
1665
+ );
1635
1666
  } catch (error) {
1636
- setSnapshotBusy(false);
1637
1667
  notify(messageOf(error), 'error');
1638
1668
  }
1639
1669
  },
@@ -1643,6 +1673,15 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1643
1673
  const trackIndex = view === 'world' ? 0 : view === 'story' ? 1 : 2;
1644
1674
 
1645
1675
  return h('div', { className: 'dsn-stage' },
1676
+ // A running snapshot job owns the work: nothing else may be edited
1677
+ // while it rewrites snapshots underneath.
1678
+ snapshotBusy
1679
+ ? h('div', { className: 'dsn-lock' },
1680
+ h('div', { className: 'dsn-lock-box' },
1681
+ h('div', { className: 'dsn-lock-title' }, snapshotJob.label),
1682
+ h('div', { className: 'dsn-lock-text' }, snapshotJob.message),
1683
+ h('div', { className: 'dsn-lock-hint' }, t('快照生成期间界面不可操作,完成后会自动刷新', 'The studio is locked while snapshots are generated; it refreshes when done.'))))
1684
+ : null,
1646
1685
  h('div', { className: 'dsn-track', style: { transform: `translateX(-${trackIndex * 100}%)` } },
1647
1686
  h('section', { className: 'dsn-page' },
1648
1687
  h(WorldPage, {
package/index.js CHANGED
@@ -1040,8 +1040,10 @@ function snapshotPrompt(project, previous, chapters, pointDay) {
1040
1040
  lines.push('');
1041
1041
  lines.push('要求:');
1042
1042
  lines.push('- 保留上一版中尚未被推翻的设定;角色、势力要包含新登场者;关系用「甲 乙 关系」三段式。');
1043
- lines.push('- items 记录这段时间出现或变化的关键物品(下落、归属、状态);没有就给空数组。');
1044
- lines.push(`- events 记录这段时间发生的具体事件,day 用故事天数(整数,不超过 ${pointDay}),title 一行、desc 一两句,最近的排最前;没有就给空数组。`);
1043
+ lines.push('- items 必填:列出这段时间出现、易手或状态变化的关键物品(法宝、信物、丹药、线索等),每项 name + desc(下落/归属/用途)。');
1044
+ lines.push(`- events 必填:这段时间每发生一件值得记录的事就写一条,day 为故事天数(整数,0~${pointDay}),title 一行,desc 一两句,按 day 从大到小排列。只要这段时间有章节内容,就至少写 1 条。`);
1045
+ lines.push('- 只有这段时间确实没有任何可记录的物品或事件时,才给空数组。');
1046
+ lines.push('注意:输出的 JSON 必须同时包含 items 与 events 两个键,缺任何一个都算不合格(没有内容就写 [])。');
1045
1047
  lines.push('不要调用任何工具,不要反问,只输出这个 JSON。');
1046
1048
  return lines.join('\n');
1047
1049
  }
@@ -1146,7 +1148,7 @@ async function bootstrap(ctx, root) {
1146
1148
  }
1147
1149
  }
1148
1150
  return {
1149
- revision: '2026-10-20.20',
1151
+ revision: '2026-10-20.28',
1150
1152
  projects,
1151
1153
  models: {
1152
1154
  default: modelCatalog.default || null,
@@ -1505,22 +1507,25 @@ function chaptersForPoint(project, fromDay, point) {
1505
1507
  */
1506
1508
  function registerSnapshot(ctx, root, registry, projectId, targetDay) {
1507
1509
  return startJob(registry, { kind: 'snapshot', projectId }, async (job) => {
1510
+ // The sequence is fixed from the state at the start of the job: recomputing
1511
+ // it every round would regenerate the last point forever once it exists.
1512
+ const initial = await readProject(root, projectId);
1513
+ const unit = Math.max(1, initial.timeline.unitDays || 30);
1514
+ const target = Math.floor(Math.max(1, Math.round(Number(targetDay) || 0)) / unit) * unit;
1515
+ if (target < unit) throw httpError(409, '目标时间还不到一个快照间隔,无法生成世界快照');
1516
+ const startBase = (initial.snapshots || []).filter((snapshot) => snapshot.day < target).pop() || null;
1517
+ let first = (startBase ? startBase.day : 0) + unit;
1518
+ // Nothing new below the target means "redo the target point itself".
1519
+ if (first > target) first = target;
1520
+ const points = [];
1521
+ for (let point = first; point <= target; point += unit) points.push(point);
1522
+
1508
1523
  const made = [];
1509
- let target = Math.max(1, Math.round(Number(targetDay) || 0));
1510
- for (;;) {
1524
+ for (const point of points) {
1511
1525
  const project = await readProject(root, projectId);
1512
- const unit = Math.max(1, project.timeline.unitDays || 30);
1513
- target = Math.floor(target / unit) * unit;
1514
- if (target < unit) throw httpError(409, '目标时间还不到一个快照间隔,无法生成世界快照');
1515
- const snapshots = project.snapshots || [];
1516
- const base = snapshots.filter((snapshot) => snapshot.day < target).pop() || null;
1517
- const point = (base ? base.day : 0) + unit;
1518
- if (point > target) {
1519
- await exportProjectInfo(await readProject(root, projectId));
1520
- return { made, target };
1521
- }
1526
+ const base = (project.snapshots || []).filter((snapshot) => snapshot.day < point).pop() || null;
1522
1527
  const chapters = chaptersForPoint(project, base ? base.day : 0, point);
1523
- job.message = `正在生成第 ${point} 天的世界快照(第 ${made.length + 1} 份,目标第 ${target} 天)`;
1528
+ job.message = `正在生成第 ${point} 天的世界快照(第 ${made.length + 1}/${points.length} 份,目标第 ${target} 天)`;
1524
1529
  const reply = await askAgent(ctx, project, snapshotPrompt(project, base, chapters, point));
1525
1530
  const parsed = parseJsonReply(reply);
1526
1531
  if (!parsed) throw httpError(502, '模型没有返回可解析的快照 JSON');
@@ -1528,6 +1533,25 @@ function registerSnapshot(ctx, root, registry, projectId, targetDay) {
1528
1533
  if (normalized.world === '' && normalized.characters.length === 0 && normalized.factions.length === 0) {
1529
1534
  throw httpError(502, '模型返回的快照为空');
1530
1535
  }
1536
+ // A chapter boundary is a fact of the work itself, so it seeds the event
1537
+ // list even when the model leaves `events` empty; model-written events win
1538
+ // by title and anything else is added.
1539
+ const seeded = chapters.map((chapter) => {
1540
+ const span = chapterWindow(project, chapter);
1541
+ return {
1542
+ day: span.end,
1543
+ title: chapter.title || `第 ${chapter.index + 1} 章`,
1544
+ desc: ((chapter.outline && chapter.outline.synopsis) || '').slice(0, 120),
1545
+ };
1546
+ });
1547
+ const known = new Set(normalized.events.map((event) => event.title));
1548
+ for (const event of seeded) {
1549
+ if (!known.has(event.title)) {
1550
+ normalized.events.push(event);
1551
+ known.add(event.title);
1552
+ }
1553
+ }
1554
+ normalized.events.sort((a, b) => (b.day === null ? -1 : b.day) - (a.day === null ? -1 : a.day));
1531
1555
  const snapshot = {
1532
1556
  snapshotId: randomUUID(),
1533
1557
  day: point,
@@ -1545,6 +1569,8 @@ function registerSnapshot(ctx, root, registry, projectId, targetDay) {
1545
1569
  await exportSnapshot(project, snapshot);
1546
1570
  made.push(point);
1547
1571
  }
1572
+ await exportProjectInfo(await readProject(root, projectId));
1573
+ return { made, target };
1548
1574
  });
1549
1575
  }
1550
1576
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-novel-generator",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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",