dsh-novel-generator 0.1.11 → 0.1.13
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 +150 -48
- 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';
|
|
@@ -835,22 +835,59 @@ function normalizeSnapshot(raw) {
|
|
|
835
835
|
|
|
836
836
|
/* ------------------------------------------------------------ agent work */
|
|
837
837
|
|
|
838
|
+
/**
|
|
839
|
+
* Bind a fresh session to a work whose old one disappeared (deleted in the
|
|
840
|
+
* sidebar, or gone from the session store). The work's memory — snapshots,
|
|
841
|
+
* chapters, outlines — lives in files, so a new session loses only chat
|
|
842
|
+
* history, not the novel itself.
|
|
843
|
+
*/
|
|
844
|
+
async function rebindSession(ctx, root, project) {
|
|
845
|
+
const controller = ctx.get('sessionController');
|
|
846
|
+
if (controller === undefined) throw httpError(503, '当前部署缺少 sessionController 服务,无法为作品重建会话');
|
|
847
|
+
const created = await controller.create(project.agentPreset ? { cwd: project.cwd, agentPreset: project.agentPreset } : { cwd: project.cwd });
|
|
848
|
+
const sessionId = created && created.sessionId ? created.sessionId : null;
|
|
849
|
+
if (!sessionId) throw httpError(502, '没能为作品重建会话');
|
|
850
|
+
if (project.model) {
|
|
851
|
+
try {
|
|
852
|
+
await controller.selectModel({ sessionId, ...project.model });
|
|
853
|
+
} catch {
|
|
854
|
+
// The rebuilt session still works with the profile's default model.
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
project.sessionId = sessionId;
|
|
858
|
+
await mutateProject(root, project.projectId, (draft) => {
|
|
859
|
+
draft.sessionId = sessionId;
|
|
860
|
+
});
|
|
861
|
+
try {
|
|
862
|
+
await ensureWorkspace(ctx, root, project);
|
|
863
|
+
} catch {
|
|
864
|
+
// Grouping is cosmetic; the session works either way.
|
|
865
|
+
}
|
|
866
|
+
const result = await controller.resolveAgent(sessionId);
|
|
867
|
+
if (!result || !result.agent) throw httpError(502, '重建的会话无法启动');
|
|
868
|
+
return { controller, agent: result.agent };
|
|
869
|
+
}
|
|
870
|
+
|
|
838
871
|
/** Resolve (or resume) the ordinary Agent that owns one project's Session. */
|
|
839
|
-
async function resolveAgent(ctx, project) {
|
|
872
|
+
async function resolveAgent(ctx, root, project, onNote) {
|
|
840
873
|
const controller = ctx.get('sessionController');
|
|
841
874
|
if (controller === undefined) throw httpError(503, '当前部署缺少 sessionController 服务,无法驱动创作会话');
|
|
842
|
-
if (!project.sessionId)
|
|
843
|
-
|
|
875
|
+
if (!project.sessionId) {
|
|
876
|
+
const rebuilt = await rebindSession(ctx, root, project);
|
|
877
|
+
if (onNote) onNote('作品没有绑定会话,已新建一个会话');
|
|
878
|
+
return rebuilt;
|
|
879
|
+
}
|
|
880
|
+
let result = null;
|
|
844
881
|
try {
|
|
845
882
|
result = await controller.resolveAgent(project.sessionId);
|
|
846
|
-
} catch
|
|
847
|
-
|
|
848
|
-
}
|
|
849
|
-
if (!result || !result.agent) {
|
|
850
|
-
const code = result && result.error && result.error.code ? result.error.code : 'unknown';
|
|
851
|
-
throw httpError(502, `无法恢复作品会话(${code})`);
|
|
883
|
+
} catch {
|
|
884
|
+
result = null;
|
|
852
885
|
}
|
|
853
|
-
return { controller, agent: result.agent };
|
|
886
|
+
if (result && result.agent) return { controller, agent: result.agent };
|
|
887
|
+
// The old session is gone: rebuild instead of failing every later call.
|
|
888
|
+
const rebuilt = await rebindSession(ctx, root, project);
|
|
889
|
+
if (onNote) onNote('原作品会话已不存在,已自动新建一个会话继续');
|
|
890
|
+
return rebuilt;
|
|
854
891
|
}
|
|
855
892
|
|
|
856
893
|
function withTimeout(promise, ms, onTimeout) {
|
|
@@ -868,32 +905,47 @@ function withTimeout(promise, ms, onTimeout) {
|
|
|
868
905
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
869
906
|
}
|
|
870
907
|
|
|
871
|
-
/**
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
908
|
+
/**
|
|
909
|
+
* Send one prompt into the project's own Session and return the reply text.
|
|
910
|
+
* A freshly rebuilt session occasionally answers nothing on its first turn, so
|
|
911
|
+
* one silent reply earns a single retry before the job fails.
|
|
912
|
+
*/
|
|
913
|
+
async function askAgent(ctx, root, project, prompt, onNote) {
|
|
914
|
+
let rebuilt = false;
|
|
915
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
916
|
+
const before = project.sessionId;
|
|
917
|
+
const { controller, agent } = await resolveAgent(ctx, root, project, attempt === 0 ? onNote : undefined);
|
|
918
|
+
if (project.sessionId !== before) rebuilt = true;
|
|
919
|
+
const admission = new AbortController();
|
|
920
|
+
await withTimeout(controller.prompt({
|
|
921
|
+
sessionId: project.sessionId,
|
|
922
|
+
requestId: `novel-${randomUUID()}`,
|
|
923
|
+
mode: 'queue',
|
|
924
|
+
content: [{ type: 'text', text: prompt }],
|
|
925
|
+
clientTimeZone: 'Asia/Shanghai',
|
|
926
|
+
}, admission.signal), 60 * 1000, () => admission.abort());
|
|
927
|
+
await withTimeout(agent.whenIdle(), AGENT_TIMEOUT_MS, () => {
|
|
928
|
+
try {
|
|
929
|
+
agent.cancel({ kind: 'hook', reason: 'novel-studio generation timeout' });
|
|
930
|
+
} catch {
|
|
931
|
+
// Cancellation is best effort on the failure path.
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
const messages = agent.session.deriveMessages();
|
|
935
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
936
|
+
const message = messages[index];
|
|
937
|
+
if (!message || message.role !== 'assistant') continue;
|
|
938
|
+
const text = textOfMessage(message);
|
|
939
|
+
if (text !== '') return text;
|
|
940
|
+
}
|
|
941
|
+
if (attempt === 0) {
|
|
942
|
+
if (onNote) onNote('模型这一轮没有回复,重试一次');
|
|
943
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
887
944
|
}
|
|
888
|
-
});
|
|
889
|
-
const messages = agent.session.deriveMessages();
|
|
890
|
-
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
891
|
-
const message = messages[index];
|
|
892
|
-
if (!message || message.role !== 'assistant') continue;
|
|
893
|
-
const text = textOfMessage(message);
|
|
894
|
-
if (text !== '') return text;
|
|
895
945
|
}
|
|
896
|
-
throw
|
|
946
|
+
throw rebuilt
|
|
947
|
+
? httpError(502, '作品的旧会话已不存在,已为它新建一个会话,但模型没有回复;请在侧栏打开这部作品的会话(「小说工坊」分组里)后再试一次')
|
|
948
|
+
: httpError(502, '模型没有返回任何正文');
|
|
897
949
|
}
|
|
898
950
|
|
|
899
951
|
function snapshotBlock(snapshot) {
|
|
@@ -1163,9 +1215,11 @@ async function bootstrap(ctx, root) {
|
|
|
1163
1215
|
const projects = await listProjects(root);
|
|
1164
1216
|
// Repair projects created before sessions were placed in the sidebar group,
|
|
1165
1217
|
// and stamp works made before folders carried an ownership mark.
|
|
1218
|
+
const workspaceRegistry = ctx.get('workspaceRegistry');
|
|
1219
|
+
if (workspaceRegistry) tidyWorkspaceGroups(workspaceRegistry).catch(() => {});
|
|
1166
1220
|
for (const project of projects) {
|
|
1167
1221
|
try {
|
|
1168
|
-
await ensureWorkspace(ctx, project);
|
|
1222
|
+
await ensureWorkspace(ctx, root, project);
|
|
1169
1223
|
} catch {
|
|
1170
1224
|
// A missing directory or an older harness simply leaves the session as is.
|
|
1171
1225
|
}
|
|
@@ -1187,7 +1241,7 @@ async function bootstrap(ctx, root) {
|
|
|
1187
1241
|
}
|
|
1188
1242
|
}
|
|
1189
1243
|
return {
|
|
1190
|
-
revision: '2026-10-20.
|
|
1244
|
+
revision: '2026-10-20.39',
|
|
1191
1245
|
projects,
|
|
1192
1246
|
models: {
|
|
1193
1247
|
default: modelCatalog.default || null,
|
|
@@ -1270,7 +1324,7 @@ async function createProject(ctx, root, body) {
|
|
|
1270
1324
|
// Put the session in a sidebar workspace instead of leaving it ungrouped.
|
|
1271
1325
|
let workspace = null;
|
|
1272
1326
|
try {
|
|
1273
|
-
workspace = await ensureWorkspace(ctx, project);
|
|
1327
|
+
workspace = await ensureWorkspace(ctx, root, project);
|
|
1274
1328
|
} catch (error) {
|
|
1275
1329
|
const message = `会话已创建,但没能放进「${WORKSPACE_TITLE}」分组:${error instanceof Error ? error.message : String(error)}`;
|
|
1276
1330
|
warning = warning === null ? message : `${warning} ${message}`;
|
|
@@ -1437,14 +1491,18 @@ const WORKSPACE_TITLE = '小说工坊';
|
|
|
1437
1491
|
const ensuredWorkspaces = new Set();
|
|
1438
1492
|
|
|
1439
1493
|
/**
|
|
1440
|
-
* Put a project's session under a sidebar workspace
|
|
1441
|
-
*
|
|
1442
|
-
*
|
|
1494
|
+
* Put a project's session under a sidebar workspace titled 小说工坊.
|
|
1495
|
+
*
|
|
1496
|
+
* DSH groups sessions by directory: a workspace owns one canonical path, and a
|
|
1497
|
+
* session only shows under the workspace matching its cwd. Works in different
|
|
1498
|
+
* directories therefore need different groups, so each group is titled with its
|
|
1499
|
+
* directory to keep them apart.
|
|
1443
1500
|
*/
|
|
1444
|
-
async function ensureWorkspace(ctx, project) {
|
|
1501
|
+
async function ensureWorkspace(ctx, root, project) {
|
|
1445
1502
|
const registry = ctx.get('workspaceRegistry');
|
|
1446
1503
|
if (!registry || !project || !project.sessionId || !project.cwd) return null;
|
|
1447
1504
|
if (ensuredWorkspaces.has(project.projectId)) return null;
|
|
1505
|
+
|
|
1448
1506
|
const attached = registry.list()
|
|
1449
1507
|
.some((workspace) => Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(project.sessionId));
|
|
1450
1508
|
if (attached) {
|
|
@@ -1457,12 +1515,46 @@ async function ensureWorkspace(ctx, project) {
|
|
|
1457
1515
|
} catch {
|
|
1458
1516
|
workspace = undefined;
|
|
1459
1517
|
}
|
|
1460
|
-
if (!workspace)
|
|
1518
|
+
if (!workspace) {
|
|
1519
|
+
const label = project.cwd.split(/[\\/]/).filter(Boolean).pop() || '根目录';
|
|
1520
|
+
workspace = await registry.create(project.cwd, `${WORKSPACE_TITLE} · ${label}`);
|
|
1521
|
+
}
|
|
1461
1522
|
await workspace.attachSession(project.sessionId);
|
|
1462
1523
|
ensuredWorkspaces.add(project.projectId);
|
|
1463
1524
|
return workspace;
|
|
1464
1525
|
}
|
|
1465
1526
|
|
|
1527
|
+
/** Remove empty groups this plugin leaves behind; never touches a used one. */
|
|
1528
|
+
async function tidyWorkspaceGroups(registry) {
|
|
1529
|
+
for (const workspace of registry.list()) {
|
|
1530
|
+
if (!workspace.title.startsWith(WORKSPACE_TITLE)) continue;
|
|
1531
|
+
const sessions = Array.isArray(workspace.sessionIds) ? workspace.sessionIds : [];
|
|
1532
|
+
if (sessions.length === 0) {
|
|
1533
|
+
try {
|
|
1534
|
+
await registry.delete(workspace.id);
|
|
1535
|
+
} catch {
|
|
1536
|
+
// Leaving one empty group is harmless.
|
|
1537
|
+
}
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
// A group rooted in the OS temp directory can only be a leftover from a
|
|
1541
|
+
// throwaway test work: archive what it holds, then drop the group.
|
|
1542
|
+
if (!workspace.path.toLowerCase().startsWith(tmpdir().toLowerCase())) continue;
|
|
1543
|
+
for (const sessionId of sessions) {
|
|
1544
|
+
try {
|
|
1545
|
+
await registry.archiveSession(sessionId, { stopActivity: true });
|
|
1546
|
+
} catch {
|
|
1547
|
+
// An active session stays; the group stays with it.
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
try {
|
|
1551
|
+
await registry.delete(workspace.id);
|
|
1552
|
+
} catch {
|
|
1553
|
+
// The group stays visible if it could not be removed.
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1466
1558
|
/**
|
|
1467
1559
|
* The grid point a new chapter brings the story to, or null when nothing new is
|
|
1468
1560
|
* due. A chapter spanning several points targets the last point it covers; the
|
|
@@ -1565,7 +1657,9 @@ function registerSnapshot(ctx, root, registry, projectId, targetDay) {
|
|
|
1565
1657
|
const base = (project.snapshots || []).filter((snapshot) => snapshot.day < point).pop() || null;
|
|
1566
1658
|
const chapters = chaptersForPoint(project, base ? base.day : 0, point);
|
|
1567
1659
|
job.message = `正在生成第 ${point} 天的世界快照(第 ${made.length + 1}/${points.length} 份,目标第 ${target} 天)`;
|
|
1568
|
-
const reply = await askAgent(ctx, project, snapshotPrompt(project, base, chapters, point))
|
|
1660
|
+
const reply = await askAgent(ctx, root, project, snapshotPrompt(project, base, chapters, point), (note) => {
|
|
1661
|
+
job.message = note;
|
|
1662
|
+
});
|
|
1569
1663
|
const parsed = parseJsonReply(reply);
|
|
1570
1664
|
if (!parsed) throw httpError(502, '模型没有返回可解析的快照 JSON');
|
|
1571
1665
|
const normalized = normalizeSnapshot(parsed);
|
|
@@ -1665,7 +1759,9 @@ async function startGenerate(ctx, root, registry, body) {
|
|
|
1665
1759
|
.filter((entry) => entry.chapterId !== chapter.chapterId && entry.startDay <= chapter.startDay)
|
|
1666
1760
|
.sort((a, b) => b.startDay - a.startDay)[0] || null;
|
|
1667
1761
|
job.message = '正在生成章节正文';
|
|
1668
|
-
const content = await askAgent(ctx, project, chapterPrompt(project, chapter, previous))
|
|
1762
|
+
const content = await askAgent(ctx, root, project, chapterPrompt(project, chapter, previous), (note) => {
|
|
1763
|
+
job.message = note;
|
|
1764
|
+
});
|
|
1669
1765
|
const saved = await mutateProject(root, projectId, (draft) => {
|
|
1670
1766
|
const target = draft.chapters.find((entry) => entry.chapterId === chapterId);
|
|
1671
1767
|
if (!target) throw httpError(404, `章节不存在: ${chapterId}`);
|
|
@@ -1753,7 +1849,9 @@ async function startCompose(ctx, root, registry, body) {
|
|
|
1753
1849
|
assertSnapshotFresh(project, draft);
|
|
1754
1850
|
|
|
1755
1851
|
job.message = '正在推演本章梗概与名称';
|
|
1756
|
-
const deduceReply = await askAgent(ctx, project, deducePrompt(project, draft))
|
|
1852
|
+
const deduceReply = await askAgent(ctx, root, project, deducePrompt(project, draft), (note) => {
|
|
1853
|
+
job.message = note;
|
|
1854
|
+
});
|
|
1757
1855
|
const deduced = parseJsonReply(deduceReply) || {};
|
|
1758
1856
|
const synopsis = asText(firstOf(deduced, ['synopsis', '梗概', 'summary']));
|
|
1759
1857
|
if (synopsis === '') throw httpError(502, '模型没有给出章节梗概');
|
|
@@ -1797,7 +1895,9 @@ async function startCompose(ctx, root, registry, body) {
|
|
|
1797
1895
|
const fresh = await readProject(root, projectId);
|
|
1798
1896
|
const target = fresh.chapters.find((entry) => entry.chapterId === chapterId);
|
|
1799
1897
|
if (!target) throw httpError(500, '章节写入失败');
|
|
1800
|
-
const content = await askAgent(ctx, fresh, chapterPrompt(fresh, target, previous))
|
|
1898
|
+
const content = await askAgent(ctx, root, fresh, chapterPrompt(fresh, target, previous), (note) => {
|
|
1899
|
+
job.message = note;
|
|
1900
|
+
});
|
|
1801
1901
|
|
|
1802
1902
|
await mutateProject(root, projectId, (current) => {
|
|
1803
1903
|
const entry = current.chapters.find((item) => item.chapterId === chapterId);
|
|
@@ -1845,7 +1945,9 @@ async function startDeduce(ctx, root, registry, body) {
|
|
|
1845
1945
|
const project = await readProject(root, projectId);
|
|
1846
1946
|
const chapter = chapterId === '' ? null : project.chapters.find((entry) => entry.chapterId === chapterId) || null;
|
|
1847
1947
|
job.message = '正在推演下一章大纲';
|
|
1848
|
-
const reply = await askAgent(ctx, project, deducePrompt(project, chapter))
|
|
1948
|
+
const reply = await askAgent(ctx, root, project, deducePrompt(project, chapter), (note) => {
|
|
1949
|
+
job.message = note;
|
|
1950
|
+
});
|
|
1849
1951
|
const parsed = parseJsonReply(reply);
|
|
1850
1952
|
if (!parsed) throw httpError(502, '模型没有返回可解析的大纲 JSON');
|
|
1851
1953
|
const synopsis = asText(firstOf(parsed, ['synopsis', '梗概', 'summary']));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-novel-generator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
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",
|