dsh-novel-generator 0.1.4 → 0.1.6
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/README.md +6 -5
- package/client.js +23 -8
- package/index.js +35 -16
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,20 +22,21 @@ dsh plugin --profile web add dsh-novel-generator
|
|
|
22
22
|
## 界面流程
|
|
23
23
|
|
|
24
24
|
1. **作品列表**:创建 / 删除 / 打开作品。**删除作品会同时删掉工作目录下以作品名命名的那个文件夹(正文、梗概、快照),二次确认里会说明;作品绑定的会话保留。**创建时选工作目录、模型、预设——**工作目录只能通过「选择」调目录选择器挑选,不能手填**;创建动作本身会调用 `sessionController.create` 为作品开一个会话,会话照常出现在左侧列表里,可以直接进去对话。**每行都显示当前模型,并有「模型」按钮:改完会同时切换作品绑定的会话模型**(走 `sessionController.selectModel`)。**会话会被放进左侧对应的分组:作品目录已经是工作区就并进去,否则为该目录新建一个名为「小说工坊」的工作区**,不再是「未分组」。打开工作室时会对老作品补做这一步。
|
|
25
|
-
2. **时间长河**(作品主界面):最上方是时间线,左端是第 0 天,右端是**最新世界快照的时间**(故事已经写到快照之后时,右端延伸到故事当前的时间)。拖动时下方世界观、角色列表、势力列表、关系(`甲 乙 关系`)切换到该时间点之前最新的那份快照。右上角「快照间隔」是一个可编辑的选择框:候选值 **7 / 15 / 30 / 90 / 180 / 365
|
|
25
|
+
2. **时间长河**(作品主界面):最上方是时间线,左端是第 0 天,右端是**最新世界快照的时间**(故事已经写到快照之后时,右端延伸到故事当前的时间)。拖动时下方世界观、角色列表、势力列表、关系(`甲 乙 关系`)切换到该时间点之前最新的那份快照。右上角「快照间隔」是一个可编辑的选择框:候选值 **7 / 15 / 30 / 90 / 180 / 365 天**,也可以直接输入别的天数——这个值同时是「多少天结算一份世界快照」的阈值和自动触发点。**拖到下一个快照点时,页面顶部出现「生成世界快照 · 第 N 天」按钮;拖到最新那份快照所在的那天,则出现「重新生成快照 · 第 N 天」(用于改过章节之后刷新这一版快照,会替换旧的那份)。两种情况都会先弹确认再执行。**「进入故事」进入离选中时间最近的章节。界面最底部是一条**每章天数统计图**:横轴为第几章(每章居中占一格),纵轴就是「今天数」——每章自己跨了多少天,柱高即该章天数,折线连接各章天数,鼠标悬停可看到该章在故事里的起止天数;标题栏的「隐藏统计图 / 显示统计图」按钮可以收起或展开它。
|
|
26
26
|
3. **章节故事**:左上「← 时间长河」回时间线界面,右上「章节大纲 →」把故事页向左收起、翻开大纲页。中间用「← 上一章 / 下一章 →」左右切换;**到了最后一章,右侧的「下一章 →」变成「+ 新建章节」**。正文可直接改,底部「保存」,保存成功用右上角的提示条告知(不是页面上的一行小字)。**正文有未保存的修改时,任何离开动作(回时间线、翻章、进大纲、新建章节、返回作品列表、关闭工作室)都会先弹出确认,可选「不保存离开」或「保存并离开」。**
|
|
27
27
|
4. **新建章节**(弹窗):章节名称(可留空)、章节最低字数、章节最多字数、故事时间长度(天),后三项必填。填好后可以「创建空章节」,或点「自动推演」一次拿到本章梗概、章节名称与完整正文。**作品的第一章没有世界快照、也没有上一章可依据,所以这一章的自动推演是禁用的(新建弹窗与章节大纲页都是),先创建空章节、自己写梗概再生成。**
|
|
28
28
|
5. **章节大纲**:左上「← 章节故事」。可改标题、时间长度、字数上下限、梗概最低字数与梗概;梗概达到最低字数且其他项填完才能「保存」,保存成功用提示条告知,保存后「生成章节故事」才可点。右上「自动推演」点下去会先提示「将覆盖现有内容」,确认后按当前字数与时间长度重新生成**故事梗概 + 章节名称 + 整章正文**并直接保存;「生成章节故事」点下去会先问「是否以当前故事梗概生成故事」,确认后才重写正文。**大纲有未保存的修改时,离开同样会先弹确认(不保存离开 / 保存并离开);大纲不完整时「保存并离开」会拒绝并提示。**
|
|
29
29
|
|
|
30
30
|
## 世界快照怎么来
|
|
31
31
|
|
|
32
|
-
- 时间线以「天」计(0 =
|
|
33
|
-
-
|
|
34
|
-
-
|
|
35
|
-
-
|
|
32
|
+
- 时间线以「天」计(0 = 创建作品那天),你选的「快照间隔」(7 / 15 / 30 / 90 / 180 / 365 天,可手输)决定了一条**网格**:第 15、30、45… 天。
|
|
33
|
+
- **手动**:把时间线拖到网格点上(第 N 天且 N 能被间隔整除),标题栏出现「生成世界快照 · 第 N 天」;该点已有快照时按钮变成「重新生成快照 · 第 N 天」,会替换旧的那份。两种情况都先弹确认。
|
|
34
|
+
- **自动**:每次生成章节后检查两个条件,缺一不可:① 本章结束时间**正好落在网格点上**;② **上一格**(该点 − 一个间隔)已经有快照(第 0 天视为已覆盖)。满足才自动生成这一格的快照。
|
|
35
|
+
- 这样改间隔不会把旧快照重排:网格随当前间隔移动,自动生成只沿着「上一格存在」的链条向前推进,断档的地方留给你手动补。
|
|
36
36
|
|
|
37
37
|
## 数据与接口
|
|
38
38
|
|
|
39
|
+
|
|
39
40
|
- **索引文件**:`<DSH_HOME>/novel-studio/projects/<项目id>/project.json`(`DSH_HOME` 缺省为 `~/.dsh`,本机即 `C:\Users\Admin\.dsh\novel-studio`)。时间线、大纲、快照字段都记在这里;`dataDir` 配置可改这块的位置。
|
|
40
41
|
- **正文、梗概、快照全部写到工作目录下、以作品名称命名的文件夹里**:
|
|
41
42
|
|
package/client.js
CHANGED
|
@@ -748,9 +748,18 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
748
748
|
|
|
749
749
|
const snapshot = snapshotAt(detail, day);
|
|
750
750
|
const chapters = detail.chapters || [];
|
|
751
|
-
// The button
|
|
752
|
-
//
|
|
753
|
-
|
|
751
|
+
// The button belongs on the interval grid: the cursor must sit exactly on
|
|
752
|
+
// a multiple of the current interval. There it either fills a missing
|
|
753
|
+
// snapshot or replaces the one already written for that day.
|
|
754
|
+
const onGridPoint = unitDays >= 1 && day > 0 && day % unitDays === 0;
|
|
755
|
+
const hasSnapshotHere = (detail.snapshots || []).some((entry) => entry.day === day);
|
|
756
|
+
const canSnapshot = chapters.length > 0
|
|
757
|
+
&& onGridPoint
|
|
758
|
+
&& (hasSnapshotHere || day <= storyEnd);
|
|
759
|
+
const regenerating = hasSnapshotHere;
|
|
760
|
+
const snapshotLabel = regenerating
|
|
761
|
+
? t('重新生成快照 · 第 {day} 天', 'Regenerate snapshot · day {day}', { day })
|
|
762
|
+
: t('生成世界快照 · 第 {day} 天', 'Generate snapshot · day {day}', { day });
|
|
754
763
|
const nearest = useMemo(() => {
|
|
755
764
|
if (chapters.length === 0) return null;
|
|
756
765
|
let best = chapters[0];
|
|
@@ -767,12 +776,18 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
767
776
|
|
|
768
777
|
const askSnapshot = useCallback(() => {
|
|
769
778
|
requestConfirm({
|
|
770
|
-
title:
|
|
771
|
-
|
|
772
|
-
|
|
779
|
+
title: regenerating
|
|
780
|
+
? t('是否重新生成第 {day} 天的世界快照?', 'Regenerate the world snapshot for day {day}?', { day })
|
|
781
|
+
: t('是否为当前时间生成世界快照?', "Generate a world snapshot for this point in time?"),
|
|
782
|
+
text: regenerating
|
|
783
|
+
? t('会以第 {day} 天之前那版快照和这段时间的章节梗概重写这一天,原有快照被替换。', 'It rewrites day {day} from the snapshot before it and the synopses of this stretch; the existing snapshot is replaced.', { day })
|
|
784
|
+
: t('将为第 {day} 天生成一份世界快照:以上一份快照和这段时间的章节梗概为依据,时间线会延伸到这一天。', 'A world snapshot will be generated for day {day} from the previous snapshot and the synopses of this stretch; the timeline extends to that day.', { day }),
|
|
785
|
+
confirmLabel: regenerating
|
|
786
|
+
? t('重新生成', 'Regenerate')
|
|
787
|
+
: t('生成快照', "Generate snapshot"),
|
|
773
788
|
action: () => onRefreshSnapshot(day),
|
|
774
789
|
});
|
|
775
|
-
}, [requestConfirm, day, detail, onRefreshSnapshot]);
|
|
790
|
+
}, [requestConfirm, day, detail, onRefreshSnapshot, regenerating]);
|
|
776
791
|
|
|
777
792
|
return h(React.Fragment, null,
|
|
778
793
|
h('div', { className: 'dsn-pagehead' },
|
|
@@ -791,7 +806,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
791
806
|
type: 'button',
|
|
792
807
|
disabled: snapshotBusy,
|
|
793
808
|
onClick: askSnapshot,
|
|
794
|
-
}, snapshotBusy ? t('生成中…', "Generating…") :
|
|
809
|
+
}, snapshotBusy ? t('生成中…', "Generating…") : snapshotLabel)
|
|
795
810
|
: null,
|
|
796
811
|
h('button', { className: 'dsn-btn primary', type: 'button', onClick: () => onEnterStory(day) },
|
|
797
812
|
nearest ? t('进入故事 · 第 {n} 章', 'Enter story · chapter {n}', { n: nearest.index + 1 }) : t('进入故事', "Enter story"))),
|
package/index.js
CHANGED
|
@@ -1073,7 +1073,7 @@ async function bootstrap(ctx, root) {
|
|
|
1073
1073
|
}
|
|
1074
1074
|
}
|
|
1075
1075
|
return {
|
|
1076
|
-
revision: '2026-10-20.
|
|
1076
|
+
revision: '2026-10-20.19',
|
|
1077
1077
|
projects,
|
|
1078
1078
|
models: {
|
|
1079
1079
|
default: modelCatalog.default || null,
|
|
@@ -1349,15 +1349,22 @@ async function ensureWorkspace(ctx, project) {
|
|
|
1349
1349
|
return workspace;
|
|
1350
1350
|
}
|
|
1351
1351
|
|
|
1352
|
-
/**
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1352
|
+
/**
|
|
1353
|
+
* Whether a chapter ending at `day` earns an automatic snapshot.
|
|
1354
|
+
*
|
|
1355
|
+
* The rule ignores how many snapshots exist and which interval produced them:
|
|
1356
|
+
* a point counts only when the chapter lands exactly on a multiple of the
|
|
1357
|
+
* *current* interval, and the point one interval earlier is already covered
|
|
1358
|
+
* (day 0 — the story's start — counts as covered). Changing the interval
|
|
1359
|
+
* therefore moves the grid, and generation only continues along an unbroken
|
|
1360
|
+
* chain rather than back-filling gaps by itself.
|
|
1361
|
+
*/
|
|
1362
|
+
function snapshotPointDue(project, day) {
|
|
1356
1363
|
const unit = Math.max(1, project.timeline.unitDays || 30);
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
return
|
|
1364
|
+
if (day <= 0 || day % unit !== 0) return false;
|
|
1365
|
+
const previous = day - unit;
|
|
1366
|
+
if (previous <= 0) return true;
|
|
1367
|
+
return (project.snapshots || []).some((snapshot) => snapshot.day === previous);
|
|
1361
1368
|
}
|
|
1362
1369
|
|
|
1363
1370
|
/** Story day where the next chapter continues: the end of the last chapter. */
|
|
@@ -1385,8 +1392,15 @@ function withTimelineFacts(project) {
|
|
|
1385
1392
|
unitDays,
|
|
1386
1393
|
latestSnapshotDay,
|
|
1387
1394
|
storyEnd: lastChapterEnd(project),
|
|
1388
|
-
|
|
1395
|
+
// The next point on the *current* interval's grid, not "last + unit":
|
|
1396
|
+
// after an interval change the grid moves under the existing snapshots.
|
|
1397
|
+
nextSnapshotDay: Math.floor(latestSnapshotDay / unitDays) * unitDays + unitDays,
|
|
1389
1398
|
snapshotDays: snapshots.map((snapshot) => snapshot.day),
|
|
1399
|
+
// Every grid point up to the story's end, so the Client can mark them.
|
|
1400
|
+
gridDays: Array.from(
|
|
1401
|
+
{ length: Math.max(0, Math.floor(lastChapterEnd(project) / unitDays)) },
|
|
1402
|
+
(_, index) => (index + 1) * unitDays,
|
|
1403
|
+
),
|
|
1390
1404
|
},
|
|
1391
1405
|
};
|
|
1392
1406
|
}
|
|
@@ -1402,15 +1416,17 @@ function registerSnapshot(ctx, root, registry, projectId, day) {
|
|
|
1402
1416
|
return startJob(registry, { kind: 'snapshot', projectId }, async (job) => {
|
|
1403
1417
|
const project = await readProject(root, projectId);
|
|
1404
1418
|
const snapshots = project.snapshots || [];
|
|
1405
|
-
|
|
1406
|
-
|
|
1419
|
+
// Regenerating an existing day replaces it: the basis is the snapshot
|
|
1420
|
+
// before that day, not the one being replaced.
|
|
1421
|
+
const base = snapshots.filter((snapshot) => snapshot.day < day).pop() || null;
|
|
1422
|
+
const since = base ? base.day : 0;
|
|
1407
1423
|
const chapters = (project.chapters || [])
|
|
1408
1424
|
.filter((chapter) => chapterWindow(project, chapter).end > since
|
|
1409
1425
|
&& (((chapter.outline && chapter.outline.synopsis) || '').trim() !== '' || String(chapter.content || '').trim() !== ''))
|
|
1410
1426
|
.sort((a, b) => a.startDay - b.startDay);
|
|
1411
|
-
if (chapters.length === 0) throw httpError(409,
|
|
1427
|
+
if (chapters.length === 0) throw httpError(409, `第 ${day} 天之前还没有可依据的章节,无法生成世界快照`);
|
|
1412
1428
|
job.message = `正在阅读 ${chapters.length} 章的梗概并更新世界快照`;
|
|
1413
|
-
const reply = await askAgent(ctx, project, snapshotPrompt(project,
|
|
1429
|
+
const reply = await askAgent(ctx, project, snapshotPrompt(project, base, chapters));
|
|
1414
1430
|
const parsed = parseJsonReply(reply);
|
|
1415
1431
|
if (!parsed) throw httpError(502, '模型没有返回可解析的快照 JSON');
|
|
1416
1432
|
const normalized = normalizeSnapshot(parsed);
|
|
@@ -1425,7 +1441,10 @@ function registerSnapshot(ctx, root, registry, projectId, day) {
|
|
|
1425
1441
|
...normalized,
|
|
1426
1442
|
};
|
|
1427
1443
|
await mutateProject(root, projectId, (draft) => {
|
|
1444
|
+
// One snapshot per day: regenerating replaces the one already there.
|
|
1445
|
+
draft.snapshots = (draft.snapshots || []).filter((entry) => entry.day !== day);
|
|
1428
1446
|
draft.snapshots.push(snapshot);
|
|
1447
|
+
draft.snapshots.sort((a, b) => a.day - b.day);
|
|
1429
1448
|
return snapshot;
|
|
1430
1449
|
});
|
|
1431
1450
|
await exportSnapshot(project, snapshot);
|
|
@@ -1460,7 +1479,7 @@ function startGenerate(ctx, root, registry, body) {
|
|
|
1460
1479
|
await exportChapter(project, saved, content);
|
|
1461
1480
|
const window = chapterWindow(project, chapter);
|
|
1462
1481
|
let snapshotJobId = null;
|
|
1463
|
-
if (
|
|
1482
|
+
if (snapshotPointDue(project, window.end)) {
|
|
1464
1483
|
const snapshotJob = registerSnapshot(ctx, root, registry, projectId, window.end);
|
|
1465
1484
|
snapshotJobId = snapshotJob.jobId;
|
|
1466
1485
|
}
|
|
@@ -1577,7 +1596,7 @@ function startCompose(ctx, root, registry, body) {
|
|
|
1577
1596
|
if (written) await exportChapter(after, written, content);
|
|
1578
1597
|
const window = chapterWindow(after, target);
|
|
1579
1598
|
let snapshotJobId = null;
|
|
1580
|
-
if (
|
|
1599
|
+
if (snapshotPointDue(after, window.end)) {
|
|
1581
1600
|
snapshotJobId = registerSnapshot(ctx, root, registry, projectId, window.end).jobId;
|
|
1582
1601
|
}
|
|
1583
1602
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-novel-generator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
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",
|