dsh-novel-generator 0.1.26 → 0.1.28
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 +16 -2
- package/index.js +78 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,8 +31,13 @@ dsh plugin --profile web add dsh-novel-generator
|
|
|
31
31
|
|
|
32
32
|
- 时间线以「天」计(0 = 创建作品那天),你选的「快照间隔」(7 / 15 / 30 / 90 / 180 / 365 天,可手输)决定了一条**网格**:第 15、30、45… 天。
|
|
33
33
|
- **手动**:把时间线拖到网格点上(第 N 天且 N 能被间隔整除),标题栏出现「生成世界快照 · 第 N 天」;该点已有快照时按钮变成「重新生成快照 · 第 N 天」,会替换旧的那份。两种情况都先弹确认。
|
|
34
|
-
-
|
|
35
|
-
-
|
|
34
|
+
- **自动**:只要故事时间被推进到新的网格点就会触发——**新建章节(含只有大纲的空章节)、自动推演、生成正文、手动保存正文**都算。判定是幂等的:已经存在的点不会被普通保存再次生成。
|
|
35
|
+
- **每个点收录哪些章节**(`[上一份快照日, 本点)` 半开区间):
|
|
36
|
+
- 只收录**结束时间严格早于本点**的章节——**结束在本点那一天的那一章不算**,它正是这次快照要"为之而拍"的那段,快照生成时它还没写出来;
|
|
37
|
+
- 也不收录上一份快照已经收录过的章节(`结束日 < 上一快照日` 的跳过),所以**每一章恰好被一份快照收录一次**。
|
|
38
|
+
- 例:间隔 30、章节各 10 天 → 第 30 天快照收录第 1、2 章;第 60 天快照收录第 3、4、5 章。
|
|
39
|
+
- 这样改间隔不会把旧快照重排:网格随当前间隔移动,自动生成只沿着链条向前推进,断档的地方留给你手动补。
|
|
40
|
+
- 同一部作品同一时间只跑一个快照任务,避免并发把同一个点重复生成。
|
|
36
41
|
|
|
37
42
|
- **重置**:时间线右上角的「重置快照」会清空全部快照,再按当前间隔从第 1 格逐格重建到故事末端(确认框里写明要生成几份)。
|
|
38
43
|
- **物品 / 事件**:快照里除了世界观、角色、势力、关系,还有物品(名称 + 下落/用途)和事件(故事天数 + 标题 + 一句话),事件按时间从新到旧排列。
|
|
@@ -81,6 +86,15 @@ dsh plugin --profile web add dsh-novel-generator
|
|
|
81
86
|
- 正文为空时按钮不可点,接口也会拒绝(400);修改意见为空同样拒绝。
|
|
82
87
|
- **重写结果只放进编辑区、不自动保存**,状态变成「未保存」,你确认后再点「保存」——避免 AI 的改动直接覆盖原稿。
|
|
83
88
|
|
|
89
|
+
## 附属子插件:小说工坊 · 作者预设
|
|
90
|
+
|
|
91
|
+
另有一个**独立包** `dsh-novel-author-preset`,只负责往 DSH 注册一个 Agent 预设「小说工坊 · 作者」:
|
|
92
|
+
|
|
93
|
+
- 装上它(`dsh plugin --profile web add dsh-novel-author-preset`),作品列表的「预设」与「设置 → 预设」里就多出这一项,**新建作品默认用它**;
|
|
94
|
+
- 卸掉它,预设立刻消失;已绑定的作品不会因此损坏——重建会话时检测到预设已不在,会自动退回 profile 的默认预设。
|
|
95
|
+
|
|
96
|
+
预设内容刻意做得很薄:三句人设(你是小说写作者/只输出正文/文风视角篇幅以用户要求为准)+ 关闭运行时上下文 + **不挂任何工具** + 只保留上下文压缩。所有文学倾向都交给作品级「前置提示词」。
|
|
97
|
+
|
|
84
98
|
## 迁移、导入与会话
|
|
85
99
|
|
|
86
100
|
- **改预设**:作品列表每行的「预设」改的是「下次建会话时用哪个预设」。会话在创建时就固定了预设,所以保存后会提示「重建会话后生效」——要立刻换,点同一行的「重建会话」。
|
package/index.js
CHANGED
|
@@ -922,6 +922,20 @@ function normalizeSnapshot(raw) {
|
|
|
922
922
|
|
|
923
923
|
/* ------------------------------------------------------------ agent work */
|
|
924
924
|
|
|
925
|
+
/** The preset a work records, but only when the deployment still offers it. */
|
|
926
|
+
async function usablePreset(ctx, presetId) {
|
|
927
|
+
if (!presetId) return false;
|
|
928
|
+
const presets = ctx.get('agentPresets');
|
|
929
|
+
if (!presets || typeof presets.list !== 'function') return true;
|
|
930
|
+
try {
|
|
931
|
+
const list = await presets.list();
|
|
932
|
+
if (!Array.isArray(list) || list.length === 0) return true;
|
|
933
|
+
return list.some((entry) => entry.id === presetId);
|
|
934
|
+
} catch {
|
|
935
|
+
return true;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
925
939
|
/**
|
|
926
940
|
* Bind a fresh session to a work whose old one disappeared (deleted in the
|
|
927
941
|
* sidebar, or gone from the session store). The work's memory — snapshots,
|
|
@@ -931,7 +945,12 @@ function normalizeSnapshot(raw) {
|
|
|
931
945
|
async function rebindSession(ctx, root, project) {
|
|
932
946
|
const controller = ctx.get('sessionController');
|
|
933
947
|
if (controller === undefined) throw httpError(503, '当前部署缺少 sessionController 服务,无法为作品重建会话');
|
|
934
|
-
|
|
948
|
+
// A preset can disappear when its plugin is uninstalled; falling back to the
|
|
949
|
+
// profile default keeps the work usable instead of failing every rebuild.
|
|
950
|
+
const presetOk = await usablePreset(ctx, project.agentPreset);
|
|
951
|
+
const created = await controller.create(presetOk && project.agentPreset
|
|
952
|
+
? { cwd: project.cwd, agentPreset: project.agentPreset }
|
|
953
|
+
: { cwd: project.cwd });
|
|
935
954
|
const sessionId = created && created.sessionId ? created.sessionId : null;
|
|
936
955
|
if (!sessionId) throw httpError(502, '没能为作品重建会话');
|
|
937
956
|
if (project.model) {
|
|
@@ -1182,7 +1201,10 @@ function stretchBefore(project, chapter, snapshot) {
|
|
|
1182
1201
|
return (project.chapters || [])
|
|
1183
1202
|
.filter((entry) => entry.chapterId !== chapter.chapterId
|
|
1184
1203
|
&& entry.startDay < chapter.startDay
|
|
1185
|
-
|
|
1204
|
+
// The snapshot at day `base` covers chapters ending strictly before it, so
|
|
1205
|
+
// a chapter ending exactly on that day is still unwritten history for this
|
|
1206
|
+
// stretch and must be handed to the writer (>= rather than >).
|
|
1207
|
+
&& chapterWindow(project, entry).end >= base
|
|
1186
1208
|
&& ((entry.outline && entry.outline.synopsis) || '').trim() !== '')
|
|
1187
1209
|
.sort((a, b) => a.startDay - b.startDay);
|
|
1188
1210
|
}
|
|
@@ -1477,7 +1499,7 @@ async function bootstrap(ctx, root) {
|
|
|
1477
1499
|
}
|
|
1478
1500
|
}
|
|
1479
1501
|
return {
|
|
1480
|
-
revision: '2026-10-27.
|
|
1502
|
+
revision: '2026-10-27.16',
|
|
1481
1503
|
projects,
|
|
1482
1504
|
models: {
|
|
1483
1505
|
default: modelCatalog.default || null,
|
|
@@ -1511,6 +1533,11 @@ async function createProject(ctx, root, body) {
|
|
|
1511
1533
|
const cwd = asText(body.cwd);
|
|
1512
1534
|
if (cwd === '') throw httpError(400, '请先选择作品的工作目录');
|
|
1513
1535
|
const agentPreset = asText(body.agentPreset) || undefined;
|
|
1536
|
+
// New works prefer the bundled author preset when its plugin is installed, and
|
|
1537
|
+
// otherwise fall back to the profile default instead of failing.
|
|
1538
|
+
const chosenPreset = agentPreset !== undefined && await usablePreset(ctx, agentPreset)
|
|
1539
|
+
? agentPreset
|
|
1540
|
+
: (await usablePreset(ctx, AUTHOR_PRESET_ID) ? AUTHOR_PRESET_ID : undefined);
|
|
1514
1541
|
const model = body.model && asText(body.model.provider) && asText(body.model.model)
|
|
1515
1542
|
? {
|
|
1516
1543
|
provider: asText(body.model.provider),
|
|
@@ -1529,7 +1556,7 @@ async function createProject(ctx, root, body) {
|
|
|
1529
1556
|
throw httpError(400, `无法在工作目录下创建作品文件夹:${error instanceof Error ? error.message : String(error)}`);
|
|
1530
1557
|
}
|
|
1531
1558
|
|
|
1532
|
-
const created = await controller.create(
|
|
1559
|
+
const created = await controller.create(chosenPreset === undefined ? { cwd } : { cwd, agentPreset: chosenPreset });
|
|
1533
1560
|
const sessionId = created && created.sessionId ? created.sessionId : null;
|
|
1534
1561
|
let warning = null;
|
|
1535
1562
|
if (sessionId && model) {
|
|
@@ -1546,7 +1573,7 @@ async function createProject(ctx, root, body) {
|
|
|
1546
1573
|
projectId,
|
|
1547
1574
|
title,
|
|
1548
1575
|
cwd,
|
|
1549
|
-
agentPreset:
|
|
1576
|
+
agentPreset: chosenPreset || created.agentPreset || null,
|
|
1550
1577
|
model,
|
|
1551
1578
|
// Every new work starts with the default narrative preface; the author can
|
|
1552
1579
|
// edit or clear it from the work list.
|
|
@@ -1914,6 +1941,9 @@ const PRELUDE_PRESETS = [
|
|
|
1914
1941
|
/** The preface a fresh work starts with. */
|
|
1915
1942
|
const DEFAULT_PRELUDE_MODE = 'thirdOmniscient';
|
|
1916
1943
|
|
|
1944
|
+
/** Preset id contributed by the optional `dsh-novel-author-preset` sub-plugin. */
|
|
1945
|
+
const AUTHOR_PRESET_ID = 'novel-studio-author';
|
|
1946
|
+
|
|
1917
1947
|
function defaultPrelude() {
|
|
1918
1948
|
const preset = PRELUDE_PRESETS.find((entry) => entry.id === DEFAULT_PRELUDE_MODE);
|
|
1919
1949
|
return preset ? { mode: preset.id, text: preset.text, updatedAt: Date.now() } : null;
|
|
@@ -2019,7 +2049,7 @@ function nextChapterIndex(project) {
|
|
|
2019
2049
|
return max + 1;
|
|
2020
2050
|
}
|
|
2021
2051
|
|
|
2022
|
-
async function saveOutline(root, body) {
|
|
2052
|
+
async function saveOutline(ctx, root, registry, body) {
|
|
2023
2053
|
const projectId = asText(body.projectId);
|
|
2024
2054
|
const chapterId = asText(body.chapterId);
|
|
2025
2055
|
const outline = normalizeOutline(body.outline);
|
|
@@ -2073,10 +2103,21 @@ async function saveOutline(root, body) {
|
|
|
2073
2103
|
await exportChapterOutline(savedProject, savedChapter);
|
|
2074
2104
|
await exportProjectInfo(savedProject);
|
|
2075
2105
|
}
|
|
2076
|
-
|
|
2106
|
+
// Creating (or re-timing) a chapter that brings the story onto an interval
|
|
2107
|
+
// point is what earns the snapshot for that point: the chapter ending exactly
|
|
2108
|
+
// there is still unwritten, so the snapshot is built from what came before it.
|
|
2109
|
+
let snapshotJobId = null;
|
|
2110
|
+
if (savedChapter) {
|
|
2111
|
+
const fresh = await readProject(root, projectId);
|
|
2112
|
+
const target = autoSnapshotTarget(fresh, chapterWindow(fresh, savedChapter).end);
|
|
2113
|
+
if (target !== null) {
|
|
2114
|
+
snapshotJobId = registerSnapshot(ctx, root, registry, projectId, target).jobId;
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
return snapshotJobId === null ? result : { ...result, snapshotJobId };
|
|
2077
2118
|
}
|
|
2078
2119
|
|
|
2079
|
-
async function saveChapter(root, body) {
|
|
2120
|
+
async function saveChapter(ctx, root, registry, body) {
|
|
2080
2121
|
const projectId = asText(body.projectId);
|
|
2081
2122
|
const chapterId = asText(body.chapterId);
|
|
2082
2123
|
const content = typeof body.content === 'string' ? body.content : '';
|
|
@@ -2096,7 +2137,16 @@ async function saveChapter(root, body) {
|
|
|
2096
2137
|
return { chapter: { ...chapter, content: undefined }, project: summarize(project) };
|
|
2097
2138
|
});
|
|
2098
2139
|
await exportChapter(savedProject, savedChapter, content);
|
|
2099
|
-
|
|
2140
|
+
// Saving prose is the other way story time advances, so a point that is due
|
|
2141
|
+
// gets its snapshot here too. The check is idempotent: an existing point is
|
|
2142
|
+
// never re-created by an ordinary save.
|
|
2143
|
+
let snapshotJobId = null;
|
|
2144
|
+
if (savedChapter) {
|
|
2145
|
+
const fresh = await readProject(root, projectId);
|
|
2146
|
+
const target = autoSnapshotTarget(fresh, chapterWindow(fresh, savedChapter).end);
|
|
2147
|
+
if (target !== null) snapshotJobId = registerSnapshot(ctx, root, registry, projectId, target).jobId;
|
|
2148
|
+
}
|
|
2149
|
+
return snapshotJobId === null ? result : { ...result, snapshotJobId };
|
|
2100
2150
|
}
|
|
2101
2151
|
|
|
2102
2152
|
/** Display name the plugin gives the sidebar workspace that holds its sessions. */
|
|
@@ -2229,15 +2279,21 @@ function previousChapter(project, chapter) {
|
|
|
2229
2279
|
}
|
|
2230
2280
|
|
|
2231
2281
|
/**
|
|
2232
|
-
* Chapters that
|
|
2233
|
-
*
|
|
2234
|
-
*
|
|
2282
|
+
* Chapters that feed the snapshot at `point`: the stretch `[fromDay, point)`.
|
|
2283
|
+
*
|
|
2284
|
+
* A chapter belongs to the snapshot of the *next* point after it ends:
|
|
2285
|
+
*
|
|
2286
|
+
* - `end < point` — the chapter ending exactly on the point is the one this
|
|
2287
|
+
* snapshot is being taken for; it is still unwritten when the snapshot is
|
|
2288
|
+
* created, so counting it would pull unwritten plot into the world state.
|
|
2289
|
+
* - `end >= fromDay` — every chapter is read by exactly one snapshot, and the
|
|
2290
|
+
* previous snapshot's last chapter is not read a second time.
|
|
2235
2291
|
*/
|
|
2236
2292
|
function chaptersForPoint(project, fromDay, point) {
|
|
2237
2293
|
return (project.chapters || [])
|
|
2238
2294
|
.filter((chapter) => {
|
|
2239
2295
|
const span = chapterWindow(project, chapter);
|
|
2240
|
-
if (span.end
|
|
2296
|
+
if (span.end >= point || span.end < fromDay) return false;
|
|
2241
2297
|
return ((chapter.outline && chapter.outline.synopsis) || '').trim() !== ''
|
|
2242
2298
|
|| String(chapter.content || '').trim() !== '';
|
|
2243
2299
|
})
|
|
@@ -2251,6 +2307,13 @@ function chaptersForPoint(project, fromDay, point) {
|
|
|
2251
2307
|
* in order instead of skipping to the end.
|
|
2252
2308
|
*/
|
|
2253
2309
|
function registerSnapshot(ctx, root, registry, projectId, targetDay) {
|
|
2310
|
+
// One snapshot job per work at a time: two concurrent cascades would each read
|
|
2311
|
+
// a half-written snapshot list and produce the same point twice.
|
|
2312
|
+
for (const running of registry.jobs.values()) {
|
|
2313
|
+
if (running.kind === 'snapshot' && running.projectId === projectId && running.state === 'running') {
|
|
2314
|
+
return running;
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2254
2317
|
return startJob(registry, { kind: 'snapshot', projectId }, async (job) => {
|
|
2255
2318
|
// The sequence is fixed from the state at the start of the job: recomputing
|
|
2256
2319
|
// it every round would regenerate the last point forever once it exists.
|
|
@@ -2706,9 +2769,9 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
|
|
|
2706
2769
|
case '/project/import':
|
|
2707
2770
|
return importProject(ctx, root, body);
|
|
2708
2771
|
case '/outline/save':
|
|
2709
|
-
return saveOutline(root, body);
|
|
2772
|
+
return saveOutline(ctx, root, registry, body);
|
|
2710
2773
|
case '/chapter/save':
|
|
2711
|
-
return saveChapter(root, body);
|
|
2774
|
+
return saveChapter(ctx, root, registry, body);
|
|
2712
2775
|
case '/generate':
|
|
2713
2776
|
return startGenerate(ctx, root, registry, body);
|
|
2714
2777
|
case '/chapter/revise':
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-novel-generator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
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",
|