dsh-novel-generator 0.1.12 → 0.1.14
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 +2 -1
- package/index.js +94 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,7 +38,8 @@ dsh plugin --profile web add dsh-novel-generator
|
|
|
38
38
|
- **物品 / 事件**:快照里除了世界观、角色、势力、关系,还有物品(名称 + 下落/用途)和事件(故事天数 + 标题 + 一句话),事件按时间从新到旧排列。
|
|
39
39
|
- **生成依据(统一规则)**:先找本章之前最近的那份世界快照。若「本章起始日 − 快照落点 > 当前间隔」,判定快照过旧,直接拒绝并提示先去时间长河更新快照;否则自动推演用「最近快照 + 快照落点之后、本章之前各章梗概」,写正文在此基础上再加一段「待生成的本章梗概」,并在提示里区分已发生与待生成两部分。
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
> 侧栏分组:DSH 的分组按目录归属,一个分组只收同目录下的会话,所以不同工作目录的作品会各有一个「小说工坊 · 目录名」分组;空分组与临时目录下的残留分组会被自动清理。
|
|
42
|
+
|
|
42
43
|
|
|
43
44
|
|
|
44
45
|
- **索引文件**:`<DSH_HOME>/novel-studio/projects/<项目id>/project.json`(`DSH_HOME` 缺省为 `~/.dsh`,本机即 `C:\Users\Admin\.dsh\novel-studio`)。时间线、大纲、快照字段都记在这里;`dataDir` 配置可改这块的位置。
|
package/index.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { randomUUID } from 'node:crypto';
|
|
14
14
|
import { mkdir, readFile, readdir, rename, rm, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
|
|
15
|
-
import { homedir } from 'node:os';
|
|
15
|
+
import { homedir, tmpdir } from 'node:os';
|
|
16
16
|
import { basename, join } from 'node:path';
|
|
17
17
|
|
|
18
18
|
export const name = 'dsh-novel-generator';
|
|
@@ -859,7 +859,7 @@ async function rebindSession(ctx, root, project) {
|
|
|
859
859
|
draft.sessionId = sessionId;
|
|
860
860
|
});
|
|
861
861
|
try {
|
|
862
|
-
await ensureWorkspace(ctx, project);
|
|
862
|
+
await ensureWorkspace(ctx, root, project);
|
|
863
863
|
} catch {
|
|
864
864
|
// Grouping is cosmetic; the session works either way.
|
|
865
865
|
}
|
|
@@ -916,6 +916,15 @@ async function askAgent(ctx, root, project, prompt, onNote) {
|
|
|
916
916
|
const before = project.sessionId;
|
|
917
917
|
const { controller, agent } = await resolveAgent(ctx, root, project, attempt === 0 ? onNote : undefined);
|
|
918
918
|
if (project.sessionId !== before) rebuilt = true;
|
|
919
|
+
// Only messages added by THIS prompt are eligible: reading further back
|
|
920
|
+
// would hand back the previous turn's reply (a snapshot JSON, for one) when
|
|
921
|
+
// the model stays silent.
|
|
922
|
+
let seen = 0;
|
|
923
|
+
try {
|
|
924
|
+
seen = agent.session.deriveMessages().length;
|
|
925
|
+
} catch {
|
|
926
|
+
seen = 0;
|
|
927
|
+
}
|
|
919
928
|
const admission = new AbortController();
|
|
920
929
|
await withTimeout(controller.prompt({
|
|
921
930
|
sessionId: project.sessionId,
|
|
@@ -932,7 +941,7 @@ async function askAgent(ctx, root, project, prompt, onNote) {
|
|
|
932
941
|
}
|
|
933
942
|
});
|
|
934
943
|
const messages = agent.session.deriveMessages();
|
|
935
|
-
for (let index = messages.length - 1; index >=
|
|
944
|
+
for (let index = messages.length - 1; index >= seen; index -= 1) {
|
|
936
945
|
const message = messages[index];
|
|
937
946
|
if (!message || message.role !== 'assistant') continue;
|
|
938
947
|
const text = textOfMessage(message);
|
|
@@ -948,6 +957,36 @@ async function askAgent(ctx, root, project, prompt, onNote) {
|
|
|
948
957
|
: httpError(502, '模型没有返回任何正文');
|
|
949
958
|
}
|
|
950
959
|
|
|
960
|
+
/** True when a reply is structured data rather than prose (a snapshot JSON). */
|
|
961
|
+
function looksStructured(text) {
|
|
962
|
+
const head = String(text || '').trim();
|
|
963
|
+
if (head === '') return false;
|
|
964
|
+
if (!head.startsWith('{') && !head.startsWith('[')) {
|
|
965
|
+
return head.startsWith('```') && (head.includes('"world"') || head.includes('"characters"'));
|
|
966
|
+
}
|
|
967
|
+
try {
|
|
968
|
+
JSON.parse(head);
|
|
969
|
+
return true;
|
|
970
|
+
} catch {
|
|
971
|
+
return head.includes('"world"') || head.includes('"characters"') || head.includes('"synopsis"');
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Ask for prose. A model that answers a writing prompt with JSON (it just
|
|
977
|
+
* produced a snapshot, say) gets one firm reminder instead of having structured
|
|
978
|
+
* text saved as the chapter body.
|
|
979
|
+
*/
|
|
980
|
+
async function askStory(ctx, root, project, prompt, onNote) {
|
|
981
|
+
const first = await askAgent(ctx, root, project, prompt, onNote);
|
|
982
|
+
if (!looksStructured(first)) return first;
|
|
983
|
+
if (onNote) onNote('模型返回了结构化数据,正在要求它改写为正文');
|
|
984
|
+
const retryPrompt = `${prompt}\n\n【纠正】上一轮你返回的是 JSON/结构化数据,那是错的。现在只输出这一章的小说正文(中文散文),不要 JSON、不要字段名、不要标题行。`;
|
|
985
|
+
const second = await askAgent(ctx, root, project, retryPrompt, onNote);
|
|
986
|
+
if (looksStructured(second)) throw httpError(502, '模型反复返回结构化数据而不是正文,请稍后重试或换一个模型');
|
|
987
|
+
return second;
|
|
988
|
+
}
|
|
989
|
+
|
|
951
990
|
function snapshotBlock(snapshot) {
|
|
952
991
|
if (!snapshot) return '(暂无世界快照,请依据本章梗概自行建立最基本的设定)';
|
|
953
992
|
const lines = [];
|
|
@@ -1215,9 +1254,11 @@ async function bootstrap(ctx, root) {
|
|
|
1215
1254
|
const projects = await listProjects(root);
|
|
1216
1255
|
// Repair projects created before sessions were placed in the sidebar group,
|
|
1217
1256
|
// and stamp works made before folders carried an ownership mark.
|
|
1257
|
+
const workspaceRegistry = ctx.get('workspaceRegistry');
|
|
1258
|
+
if (workspaceRegistry) tidyWorkspaceGroups(workspaceRegistry).catch(() => {});
|
|
1218
1259
|
for (const project of projects) {
|
|
1219
1260
|
try {
|
|
1220
|
-
await ensureWorkspace(ctx, project);
|
|
1261
|
+
await ensureWorkspace(ctx, root, project);
|
|
1221
1262
|
} catch {
|
|
1222
1263
|
// A missing directory or an older harness simply leaves the session as is.
|
|
1223
1264
|
}
|
|
@@ -1239,7 +1280,7 @@ async function bootstrap(ctx, root) {
|
|
|
1239
1280
|
}
|
|
1240
1281
|
}
|
|
1241
1282
|
return {
|
|
1242
|
-
revision: '2026-10-20.
|
|
1283
|
+
revision: '2026-10-20.40',
|
|
1243
1284
|
projects,
|
|
1244
1285
|
models: {
|
|
1245
1286
|
default: modelCatalog.default || null,
|
|
@@ -1322,7 +1363,7 @@ async function createProject(ctx, root, body) {
|
|
|
1322
1363
|
// Put the session in a sidebar workspace instead of leaving it ungrouped.
|
|
1323
1364
|
let workspace = null;
|
|
1324
1365
|
try {
|
|
1325
|
-
workspace = await ensureWorkspace(ctx, project);
|
|
1366
|
+
workspace = await ensureWorkspace(ctx, root, project);
|
|
1326
1367
|
} catch (error) {
|
|
1327
1368
|
const message = `会话已创建,但没能放进「${WORKSPACE_TITLE}」分组:${error instanceof Error ? error.message : String(error)}`;
|
|
1328
1369
|
warning = warning === null ? message : `${warning} ${message}`;
|
|
@@ -1489,14 +1530,18 @@ const WORKSPACE_TITLE = '小说工坊';
|
|
|
1489
1530
|
const ensuredWorkspaces = new Set();
|
|
1490
1531
|
|
|
1491
1532
|
/**
|
|
1492
|
-
* Put a project's session under a sidebar workspace
|
|
1493
|
-
*
|
|
1494
|
-
*
|
|
1533
|
+
* Put a project's session under a sidebar workspace titled 小说工坊.
|
|
1534
|
+
*
|
|
1535
|
+
* DSH groups sessions by directory: a workspace owns one canonical path, and a
|
|
1536
|
+
* session only shows under the workspace matching its cwd. Works in different
|
|
1537
|
+
* directories therefore need different groups, so each group is titled with its
|
|
1538
|
+
* directory to keep them apart.
|
|
1495
1539
|
*/
|
|
1496
|
-
async function ensureWorkspace(ctx, project) {
|
|
1540
|
+
async function ensureWorkspace(ctx, root, project) {
|
|
1497
1541
|
const registry = ctx.get('workspaceRegistry');
|
|
1498
1542
|
if (!registry || !project || !project.sessionId || !project.cwd) return null;
|
|
1499
1543
|
if (ensuredWorkspaces.has(project.projectId)) return null;
|
|
1544
|
+
|
|
1500
1545
|
const attached = registry.list()
|
|
1501
1546
|
.some((workspace) => Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(project.sessionId));
|
|
1502
1547
|
if (attached) {
|
|
@@ -1509,12 +1554,46 @@ async function ensureWorkspace(ctx, project) {
|
|
|
1509
1554
|
} catch {
|
|
1510
1555
|
workspace = undefined;
|
|
1511
1556
|
}
|
|
1512
|
-
if (!workspace)
|
|
1557
|
+
if (!workspace) {
|
|
1558
|
+
const label = project.cwd.split(/[\\/]/).filter(Boolean).pop() || '根目录';
|
|
1559
|
+
workspace = await registry.create(project.cwd, `${WORKSPACE_TITLE} · ${label}`);
|
|
1560
|
+
}
|
|
1513
1561
|
await workspace.attachSession(project.sessionId);
|
|
1514
1562
|
ensuredWorkspaces.add(project.projectId);
|
|
1515
1563
|
return workspace;
|
|
1516
1564
|
}
|
|
1517
1565
|
|
|
1566
|
+
/** Remove empty groups this plugin leaves behind; never touches a used one. */
|
|
1567
|
+
async function tidyWorkspaceGroups(registry) {
|
|
1568
|
+
for (const workspace of registry.list()) {
|
|
1569
|
+
if (!workspace.title.startsWith(WORKSPACE_TITLE)) continue;
|
|
1570
|
+
const sessions = Array.isArray(workspace.sessionIds) ? workspace.sessionIds : [];
|
|
1571
|
+
if (sessions.length === 0) {
|
|
1572
|
+
try {
|
|
1573
|
+
await registry.delete(workspace.id);
|
|
1574
|
+
} catch {
|
|
1575
|
+
// Leaving one empty group is harmless.
|
|
1576
|
+
}
|
|
1577
|
+
continue;
|
|
1578
|
+
}
|
|
1579
|
+
// A group rooted in the OS temp directory can only be a leftover from a
|
|
1580
|
+
// throwaway test work: archive what it holds, then drop the group.
|
|
1581
|
+
if (!workspace.path.toLowerCase().startsWith(tmpdir().toLowerCase())) continue;
|
|
1582
|
+
for (const sessionId of sessions) {
|
|
1583
|
+
try {
|
|
1584
|
+
await registry.archiveSession(sessionId, { stopActivity: true });
|
|
1585
|
+
} catch {
|
|
1586
|
+
// An active session stays; the group stays with it.
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
try {
|
|
1590
|
+
await registry.delete(workspace.id);
|
|
1591
|
+
} catch {
|
|
1592
|
+
// The group stays visible if it could not be removed.
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1518
1597
|
/**
|
|
1519
1598
|
* The grid point a new chapter brings the story to, or null when nothing new is
|
|
1520
1599
|
* due. A chapter spanning several points targets the last point it covers; the
|
|
@@ -1719,7 +1798,7 @@ async function startGenerate(ctx, root, registry, body) {
|
|
|
1719
1798
|
.filter((entry) => entry.chapterId !== chapter.chapterId && entry.startDay <= chapter.startDay)
|
|
1720
1799
|
.sort((a, b) => b.startDay - a.startDay)[0] || null;
|
|
1721
1800
|
job.message = '正在生成章节正文';
|
|
1722
|
-
const content = await
|
|
1801
|
+
const content = await askStory(ctx, root, project, chapterPrompt(project, chapter, previous), (note) => {
|
|
1723
1802
|
job.message = note;
|
|
1724
1803
|
});
|
|
1725
1804
|
const saved = await mutateProject(root, projectId, (draft) => {
|
|
@@ -1813,7 +1892,7 @@ async function startCompose(ctx, root, registry, body) {
|
|
|
1813
1892
|
job.message = note;
|
|
1814
1893
|
});
|
|
1815
1894
|
const deduced = parseJsonReply(deduceReply) || {};
|
|
1816
|
-
const synopsis = asText(firstOf(deduced, ['synopsis', '梗概', '
|
|
1895
|
+
const synopsis = asText(firstOf(deduced, ['synopsis', '梗概', '本章梗概']));
|
|
1817
1896
|
if (synopsis === '') throw httpError(502, '模型没有给出章节梗概');
|
|
1818
1897
|
const title = draft.title
|
|
1819
1898
|
|| asText(firstOf(deduced, ['title', '标题']))
|
|
@@ -1855,7 +1934,7 @@ async function startCompose(ctx, root, registry, body) {
|
|
|
1855
1934
|
const fresh = await readProject(root, projectId);
|
|
1856
1935
|
const target = fresh.chapters.find((entry) => entry.chapterId === chapterId);
|
|
1857
1936
|
if (!target) throw httpError(500, '章节写入失败');
|
|
1858
|
-
const content = await
|
|
1937
|
+
const content = await askStory(ctx, root, fresh, chapterPrompt(fresh, target, previous), (note) => {
|
|
1859
1938
|
job.message = note;
|
|
1860
1939
|
});
|
|
1861
1940
|
|
|
@@ -1910,7 +1989,7 @@ async function startDeduce(ctx, root, registry, body) {
|
|
|
1910
1989
|
});
|
|
1911
1990
|
const parsed = parseJsonReply(reply);
|
|
1912
1991
|
if (!parsed) throw httpError(502, '模型没有返回可解析的大纲 JSON');
|
|
1913
|
-
const synopsis = asText(firstOf(parsed, ['synopsis', '梗概', '
|
|
1992
|
+
const synopsis = asText(firstOf(parsed, ['synopsis', '梗概', '本章梗概']));
|
|
1914
1993
|
const durationDays = Math.max(1, Math.round(Number(firstOf(parsed, ['durationDays', 'duration', '时间长度', 'days'])) || project.timeline.unitDays));
|
|
1915
1994
|
const minWords = Math.max(1, Math.round(Number(firstOf(parsed, ['minWords', '最低字数'])) || 1200));
|
|
1916
1995
|
const maxWords = Math.max(minWords + 1, Math.round(Number(firstOf(parsed, ['maxWords', '最多字数'])) || 3500));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-novel-generator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
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",
|