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.
- package/client.js +59 -20
- package/index.js +42 -16
- 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
|
-
|
|
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
|
-
/**
|
|
1587
|
-
|
|
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
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
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
|
-
|
|
1598
|
-
notify(job.error || t('世界快照更新失败', "World snapshot update failed"), 'error');
|
|
1616
|
+
return;
|
|
1599
1617
|
}
|
|
1600
|
-
} catch (error) {
|
|
1601
1618
|
clearInterval(timer);
|
|
1602
|
-
|
|
1603
|
-
|
|
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(
|
|
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
|
-
|
|
1634
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
1510
|
-
for (;;) {
|
|
1524
|
+
for (const point of points) {
|
|
1511
1525
|
const project = await readProject(root, projectId);
|
|
1512
|
-
const
|
|
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.
|
|
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",
|