dsh-novel-generator 0.1.3 → 0.1.4
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/index.js +112 -15
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* UI and watch (or correct) the very same agent the generator drives.
|
|
12
12
|
*/
|
|
13
13
|
import { randomUUID } from 'node:crypto';
|
|
14
|
-
import { mkdir, readFile, readdir, rename, rm, rmdir, unlink, writeFile } from 'node:fs/promises';
|
|
14
|
+
import { mkdir, readFile, readdir, rename, rm, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
|
|
15
15
|
import { homedir } from 'node:os';
|
|
16
16
|
import { basename, join } from 'node:path';
|
|
17
17
|
|
|
@@ -105,6 +105,79 @@ function projectContentDir(project) {
|
|
|
105
105
|
return join(project.cwd, safeSegment(project.title, '未命名作品'));
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/** Ownership stamp written into the work folder, so delete never eats user data. */
|
|
109
|
+
const MARKER_FILE = '.novel-generator.json';
|
|
110
|
+
|
|
111
|
+
function markerFile(project) {
|
|
112
|
+
return join(projectContentDir(project), MARKER_FILE);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function stampWorkFolder(project) {
|
|
116
|
+
try {
|
|
117
|
+
await mkdir(projectContentDir(project), { recursive: true });
|
|
118
|
+
await writeFile(markerFile(project), JSON.stringify({
|
|
119
|
+
projectId: project.projectId,
|
|
120
|
+
title: project.title,
|
|
121
|
+
createdAt: project.createdAt,
|
|
122
|
+
}, null, 2), 'utf8');
|
|
123
|
+
} catch {
|
|
124
|
+
// A folder that cannot be written simply has no stamp.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** True when this project's folder carries this project's own stamp. */
|
|
129
|
+
async function ownsWorkFolder(project) {
|
|
130
|
+
try {
|
|
131
|
+
const data = JSON.parse(await readFile(markerFile(project), 'utf8'));
|
|
132
|
+
return Boolean(data) && data.projectId === project.projectId;
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function pathExists(target) {
|
|
139
|
+
try {
|
|
140
|
+
await stat(target);
|
|
141
|
+
return true;
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Reserve the work folder. An existing folder is reused only when it is empty
|
|
149
|
+
* or already belongs to this plugin for the same project; anything else is a
|
|
150
|
+
* name clash with the user's own files and is reported instead.
|
|
151
|
+
*/
|
|
152
|
+
async function reserveWorkFolder(project, projectId) {
|
|
153
|
+
const dir = projectContentDir(project);
|
|
154
|
+
await mkdir(dir, { recursive: true });
|
|
155
|
+
const entries = (await readdir(dir)).filter((name) => name !== MARKER_FILE);
|
|
156
|
+
if (entries.length === 0) return dir;
|
|
157
|
+
let owner = null;
|
|
158
|
+
try {
|
|
159
|
+
owner = JSON.parse(await readFile(join(dir, MARKER_FILE), 'utf8'));
|
|
160
|
+
} catch {
|
|
161
|
+
owner = null;
|
|
162
|
+
}
|
|
163
|
+
if (owner && owner.projectId === projectId) return dir;
|
|
164
|
+
throw httpError(409, `工作目录下已经有名为「${safeSegment(project.title, '未命名作品')}」的文件夹,请换一个作品名称`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Remove the work folder only when it is provably this project's own. */
|
|
168
|
+
async function discardWorkFolder(project) {
|
|
169
|
+
if (!project || !project.cwd) return null;
|
|
170
|
+
const dir = projectContentDir(project);
|
|
171
|
+
if (dir === project.cwd) return null;
|
|
172
|
+
if (!(await ownsWorkFolder(project))) return null;
|
|
173
|
+
try {
|
|
174
|
+
await rm(dir, { recursive: true, force: true });
|
|
175
|
+
return dir;
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
108
181
|
/** Where one chapter lives inside the work's own folder. */
|
|
109
182
|
function chapterPaths(project, chapter) {
|
|
110
183
|
const number = String((Number(chapter.index) || 0) + 1).padStart(2, '0');
|
|
@@ -408,6 +481,7 @@ function strippedProject(project, unwritten) {
|
|
|
408
481
|
/** Write every content file, then the parameter-only index. */
|
|
409
482
|
async function persistProject(root, project) {
|
|
410
483
|
const unwritten = new Set();
|
|
484
|
+
await stampWorkFolder(project);
|
|
411
485
|
for (const chapter of project.chapters || []) {
|
|
412
486
|
const ok = await exportChapter(project, chapter, chapter.content);
|
|
413
487
|
if (!ok) unwritten.add(chapter.chapterId);
|
|
@@ -973,16 +1047,33 @@ async function bootstrap(ctx, root) {
|
|
|
973
1047
|
}
|
|
974
1048
|
}
|
|
975
1049
|
const projects = await listProjects(root);
|
|
976
|
-
// Repair projects created before sessions were placed in the sidebar group
|
|
1050
|
+
// Repair projects created before sessions were placed in the sidebar group,
|
|
1051
|
+
// and stamp works made before folders carried an ownership mark.
|
|
977
1052
|
for (const project of projects) {
|
|
978
1053
|
try {
|
|
979
1054
|
await ensureWorkspace(ctx, project);
|
|
980
1055
|
} catch {
|
|
981
1056
|
// A missing directory or an older harness simply leaves the session as is.
|
|
982
1057
|
}
|
|
1058
|
+
try {
|
|
1059
|
+
// Stamp a folder that already exists; a work whose folder was never
|
|
1060
|
+
// written (or was deleted on purpose) is left alone.
|
|
1061
|
+
await stat(projectContentDir(project));
|
|
1062
|
+
if (!(await ownsWorkFolder(project))) await stampWorkFolder(project);
|
|
1063
|
+
} catch {
|
|
1064
|
+
// An unreachable folder simply stays unstamped.
|
|
1065
|
+
}
|
|
1066
|
+
try {
|
|
1067
|
+
if (!(await ownsWorkFolder(project)) && !(await pathExists(projectContentDir(project)))) {
|
|
1068
|
+
// A work created before folders were materialised gets one now.
|
|
1069
|
+
await persistProject(root, await readProject(root, project.projectId));
|
|
1070
|
+
}
|
|
1071
|
+
} catch {
|
|
1072
|
+
// Best effort; the next save materialises it anyway.
|
|
1073
|
+
}
|
|
983
1074
|
}
|
|
984
1075
|
return {
|
|
985
|
-
revision: '2026-10-20.
|
|
1076
|
+
revision: '2026-10-20.17',
|
|
986
1077
|
projects,
|
|
987
1078
|
models: {
|
|
988
1079
|
default: modelCatalog.default || null,
|
|
@@ -1021,6 +1112,16 @@ async function createProject(ctx, root, body) {
|
|
|
1021
1112
|
}
|
|
1022
1113
|
: null;
|
|
1023
1114
|
|
|
1115
|
+
// Claim the work folder before anything else, so a name clash costs neither a
|
|
1116
|
+
// session nor a half-written project.
|
|
1117
|
+
const projectId = randomUUID();
|
|
1118
|
+
try {
|
|
1119
|
+
await reserveWorkFolder({ projectId, title, cwd }, projectId);
|
|
1120
|
+
} catch (error) {
|
|
1121
|
+
if (error && error.status) throw error;
|
|
1122
|
+
throw httpError(400, `无法在工作目录下创建作品文件夹:${error instanceof Error ? error.message : String(error)}`);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1024
1125
|
const created = await controller.create(agentPreset === undefined ? { cwd } : { cwd, agentPreset });
|
|
1025
1126
|
const sessionId = created && created.sessionId ? created.sessionId : null;
|
|
1026
1127
|
let warning = null;
|
|
@@ -1035,7 +1136,7 @@ async function createProject(ctx, root, body) {
|
|
|
1035
1136
|
const today = new Date();
|
|
1036
1137
|
const origin = today.toISOString().slice(0, 10);
|
|
1037
1138
|
const project = {
|
|
1038
|
-
projectId
|
|
1139
|
+
projectId,
|
|
1039
1140
|
title,
|
|
1040
1141
|
cwd,
|
|
1041
1142
|
agentPreset: agentPreset || created.agentPreset || null,
|
|
@@ -1048,6 +1149,9 @@ async function createProject(ctx, root, body) {
|
|
|
1048
1149
|
chapters: [],
|
|
1049
1150
|
};
|
|
1050
1151
|
await writeJson(projectFile(root, project.projectId), project);
|
|
1152
|
+
// Write the stamp and 作品信息.md right away: the folder exists the moment the
|
|
1153
|
+
// work does, instead of appearing on the first chapter.
|
|
1154
|
+
await persistProject(root, project);
|
|
1051
1155
|
|
|
1052
1156
|
// Put the session in a sidebar workspace instead of leaving it ungrouped.
|
|
1053
1157
|
let workspace = null;
|
|
@@ -1556,24 +1660,17 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
|
|
|
1556
1660
|
case '/project/delete': {
|
|
1557
1661
|
const projectId = asText(body.projectId);
|
|
1558
1662
|
if (projectId === '') throw httpError(400, '缺少 projectId');
|
|
1559
|
-
//
|
|
1560
|
-
//
|
|
1663
|
+
// Only a folder carrying this project's own stamp is removed: a folder
|
|
1664
|
+
// the user already had under the same name is never touched.
|
|
1561
1665
|
let workDir = null;
|
|
1562
1666
|
try {
|
|
1563
1667
|
const project = await readProject(root, projectId);
|
|
1564
|
-
|
|
1565
|
-
const folder = projectContentDir(project);
|
|
1566
|
-
const relative = folder.slice(project.cwd.length).replace(/^[\\/]+/, '');
|
|
1567
|
-
if (relative !== '') {
|
|
1568
|
-
await rm(folder, { recursive: true, force: true });
|
|
1569
|
-
workDir = folder;
|
|
1570
|
-
}
|
|
1571
|
-
}
|
|
1668
|
+
workDir = await discardWorkFolder(project);
|
|
1572
1669
|
} catch {
|
|
1573
1670
|
// A work whose folder is already gone still gets its record removed.
|
|
1574
1671
|
}
|
|
1575
1672
|
await rm(join(projectsDir(root), projectId), { recursive: true, force: true });
|
|
1576
|
-
return { removed: projectId, removedDir: workDir };
|
|
1673
|
+
return { removed: projectId, removedDir: workDir, folderKept: workDir === null };
|
|
1577
1674
|
}
|
|
1578
1675
|
case '/project/timeline':
|
|
1579
1676
|
return updateTimeline(root, body);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-novel-generator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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",
|