dsh-novel-generator 0.1.18 → 0.1.20
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 +3 -1
- package/index.js +52 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,12 +51,14 @@ dsh plugin --profile web add dsh-novel-generator
|
|
|
51
51
|
├── 第01章 章节名.md 章节正文(纯正文)
|
|
52
52
|
├── 章节大纲/
|
|
53
53
|
│ └── 第01章 章节名.md 该章梗概 + 时间跨度 + 字数上下限 + 实际字数
|
|
54
|
+
│ └── .history/ 梗概被改短的旧版本(每章最多留 5 份)
|
|
54
55
|
└── 世界快照/
|
|
55
|
-
└── 第030天.md 世界观 / 角色 / 势力 / 关系 / 阶段变化
|
|
56
|
+
└── 第030天.md 世界观 / 角色 / 势力 / 关系 / 物品 / 事件 / 阶段变化
|
|
56
57
|
```
|
|
57
58
|
|
|
58
59
|
- 每次保存正文、生成章节、保存大纲(改标题)、结算快照都会同步刷新这些文件;改标题会顺手删掉旧文件名。
|
|
59
60
|
- 若你在编辑器里直接改章节正文 `.md` 且比插件记录的更新时间更晚,下次打开作品会以文件内容为准(梗概与快照目前是单向导出)。
|
|
61
|
+
- **梗概是自由文本,可以自带 Markdown 小标题**:大纲卡片里 `## 故事梗概` 之后直到文件结尾都算梗概正文。若某次保存把梗概改短了,旧版本会先落到 `章节大纲/.history/`,随时可以翻回来。
|
|
60
62
|
- **作品名称就是文件夹名,所以受文件名规则限制**:不能为空、最多 60 字、不能以空格或点开头结尾、不能含 `\ / : * ? " < > |` 与控制字符、不能用 `CON`/`NUL`/`COM1` 等系统保留名。创建界面会即时校验并禁用「创建」按钮。
|
|
61
63
|
- HTTP 接口前缀:`/dsh-novel-generator/api`,只接受本机页面同源请求。
|
|
62
64
|
- 生成、推演、快照都是异步任务:`POST /generate`、`POST /compose`、`POST /deduce`、`POST /snapshot/refresh` 返回 `jobId`,再由 `GET /job?id=` 轮询。
|
package/index.js
CHANGED
|
@@ -226,14 +226,49 @@ function snapshotJsonPaths(project, snapshot) {
|
|
|
226
226
|
};
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
/**
|
|
229
|
+
/**
|
|
230
|
+
* Before an outline card is overwritten, keep the previous version when the new
|
|
231
|
+
* synopsis is shorter. The card is the only copy of that text, so this is the
|
|
232
|
+
* difference between a recoverable edit and losing thousands of characters.
|
|
233
|
+
* History lives in `章节大纲/.history/` and is pruned to the newest few files.
|
|
234
|
+
*/
|
|
235
|
+
async function keepOutlineHistory(cardPath, nextSynopsis) {
|
|
236
|
+
let previous = null;
|
|
237
|
+
try {
|
|
238
|
+
previous = await readFile(cardPath, 'utf8');
|
|
239
|
+
} catch {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const oldBody = parseSynopsis(previous);
|
|
243
|
+
if (oldBody.length <= nextSynopsis.length) return;
|
|
244
|
+
try {
|
|
245
|
+
const dir = join(join(cardPath, '..'), '.history');
|
|
246
|
+
await mkdir(dir, { recursive: true });
|
|
247
|
+
const stem = basename(cardPath, '.md');
|
|
248
|
+
await writeFile(join(dir, `${stem}.${Date.now()}.md`), previous, 'utf8');
|
|
249
|
+
const kept = (await readdir(dir)).filter((name) => name.startsWith(`${stem}.`)).sort();
|
|
250
|
+
for (const name of kept.slice(0, Math.max(0, kept.length - 5))) {
|
|
251
|
+
await unlink(join(dir, name)).catch(() => {});
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
// History is a safety net; failing to keep it must not block the save.
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The synopsis body of an outline card (`(还没有写梗概)` reads as empty).
|
|
260
|
+
*
|
|
261
|
+
* The synopsis is user prose and may itself contain Markdown headings, so it is
|
|
262
|
+
* everything after the marker to the end of the file — the writer always puts it
|
|
263
|
+
* last. Cutting at the next `## ` heading used to truncate any synopsis that had
|
|
264
|
+
* one inside it, and the shortened value then overwrote the file on the next
|
|
265
|
+
* save, which is how a 5000-character synopsis came back as a few lines.
|
|
266
|
+
*/
|
|
230
267
|
function parseSynopsis(text) {
|
|
231
268
|
const marker = '## 故事梗概';
|
|
232
269
|
const start = text.indexOf(marker);
|
|
233
270
|
if (start === -1) return '';
|
|
234
|
-
const
|
|
235
|
-
const next = rest.indexOf('\n## ');
|
|
236
|
-
const body = (next === -1 ? rest : rest.slice(0, next)).trim();
|
|
271
|
+
const body = text.slice(start + marker.length).trim();
|
|
237
272
|
return body === '(还没有写梗概)' ? '' : body;
|
|
238
273
|
}
|
|
239
274
|
|
|
@@ -276,6 +311,10 @@ async function exportChapterOutline(project, chapter) {
|
|
|
276
311
|
await mkdir(target.dir, { recursive: true });
|
|
277
312
|
const outline = chapter.outline || null;
|
|
278
313
|
const span = chapterWindow(project, chapter);
|
|
314
|
+
// The card is the only copy of the synopsis, so a write that shortens it a
|
|
315
|
+
// lot keeps the previous card aside first: a truncation bug then costs one
|
|
316
|
+
// click to undo instead of the whole text.
|
|
317
|
+
await keepOutlineHistory(target.file, (outline && outline.synopsis) || '');
|
|
279
318
|
const lines = [];
|
|
280
319
|
lines.push(`# 第 ${(Number(chapter.index) || 0) + 1} 章 ${chapter.title || '未命名'}`);
|
|
281
320
|
lines.push('');
|
|
@@ -1389,7 +1428,7 @@ async function bootstrap(ctx, root) {
|
|
|
1389
1428
|
}
|
|
1390
1429
|
}
|
|
1391
1430
|
return {
|
|
1392
|
-
revision: '2026-10-
|
|
1431
|
+
revision: '2026-10-27.3',
|
|
1393
1432
|
projects,
|
|
1394
1433
|
models: {
|
|
1395
1434
|
default: modelCatalog.default || null,
|
|
@@ -2114,8 +2153,15 @@ function registerSnapshot(ctx, root, registry, projectId, targetDay) {
|
|
|
2114
2153
|
}
|
|
2115
2154
|
|
|
2116
2155
|
/** Wipe every snapshot (and its files) so the work can be rebuilt from day 1. */
|
|
2117
|
-
function startSnapshotReset(ctx, root, registry, body) {
|
|
2156
|
+
async function startSnapshotReset(ctx, root, registry, body) {
|
|
2118
2157
|
const projectId = asText(body.projectId);
|
|
2158
|
+
// Validate before queueing, like the other job routes: an unknown work must
|
|
2159
|
+
// come back as a plain 404 instead of a job that fails a second later.
|
|
2160
|
+
const preview = await readProject(root, projectId);
|
|
2161
|
+
const previewUnit = Math.max(1, preview.timeline.unitDays || 30);
|
|
2162
|
+
if (Math.floor(lastChapterEnd(preview) / previewUnit) * previewUnit < previewUnit) {
|
|
2163
|
+
throw httpError(409, '故事还不到一个快照间隔,没有可重建的快照');
|
|
2164
|
+
}
|
|
2119
2165
|
return startJob(registry, { kind: 'snapshot-reset', projectId }, async (job) => {
|
|
2120
2166
|
const project = await readProject(root, projectId);
|
|
2121
2167
|
const unit = Math.max(1, project.timeline.unitDays || 30);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-novel-generator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
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",
|