dsh-novel-generator 0.1.2 → 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/client.js +17 -10
- package/index.js +115 -17
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -437,9 +437,10 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
437
437
|
const [busy, setBusy] = useState(false);
|
|
438
438
|
const [error, setError] = useState('');
|
|
439
439
|
|
|
440
|
-
// The title becomes a folder name inside the working directory
|
|
441
|
-
|
|
442
|
-
const
|
|
440
|
+
// The title becomes a folder name inside the working directory, and it is
|
|
441
|
+
// required: an empty name is reported when the user tries to create.
|
|
442
|
+
const trimmedTitle = title.trim();
|
|
443
|
+
const titleIssue = trimmedTitle === '' ? '' : titleProblem(trimmedTitle);
|
|
443
444
|
|
|
444
445
|
const groups = (boot.models && boot.models.groups) || [];
|
|
445
446
|
const models = useMemo(() => {
|
|
@@ -469,6 +470,14 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
469
470
|
|
|
470
471
|
const create = useCallback(async () => {
|
|
471
472
|
setError('');
|
|
473
|
+
if (trimmedTitle === '') {
|
|
474
|
+
setError(t('作品名称不能为空', "Title cannot be empty."));
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (titleIssue) {
|
|
478
|
+
setError(titleIssue);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
472
481
|
if (!cwd.trim()) {
|
|
473
482
|
setError(t('请先点「选择」挑一个工作目录', "Pick a working directory with \"Select\" first."));
|
|
474
483
|
return;
|
|
@@ -476,7 +485,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
476
485
|
setBusy(true);
|
|
477
486
|
try {
|
|
478
487
|
const result = await api('/project/create', {
|
|
479
|
-
title:
|
|
488
|
+
title: trimmedTitle,
|
|
480
489
|
cwd: cwd.trim(),
|
|
481
490
|
agentPreset: preset || undefined,
|
|
482
491
|
model: provider && model ? { provider, model } : null,
|
|
@@ -489,7 +498,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
489
498
|
} finally {
|
|
490
499
|
setBusy(false);
|
|
491
500
|
}
|
|
492
|
-
}, [
|
|
501
|
+
}, [trimmedTitle, titleIssue, cwd, preset, provider, model, onOpen, onReload]);
|
|
493
502
|
|
|
494
503
|
const remove = useCallback(async (project) => {
|
|
495
504
|
if (!window.confirm(t('确定删除作品「{title}」?这会同时删除工作目录下的作品文件夹(正文、梗概、世界快照)。作品的会话本身保留。', 'Delete the work “{title}”? This also deletes its folder in the working directory (chapters, outlines, world snapshots). The session itself stays.', { title: project.title }))) return;
|
|
@@ -555,14 +564,12 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
555
564
|
h('button', {
|
|
556
565
|
className: 'dsn-btn primary',
|
|
557
566
|
type: 'button',
|
|
558
|
-
|
|
559
|
-
|
|
567
|
+
// Enabled even when the form is incomplete: the click itself says why.
|
|
568
|
+
disabled: busy,
|
|
560
569
|
onClick: create,
|
|
561
570
|
}, busy ? t('创建中…', "Creating…") : t('创建作品并开会话', "Create work and open its session")))),
|
|
562
571
|
|
|
563
|
-
error
|
|
564
|
-
? h('div', { className: 'dsn-error', style: { margin: 0 } }, error || titleIssue)
|
|
565
|
-
: null,
|
|
572
|
+
error ? h('div', { className: 'dsn-error', style: { margin: 0 } }, error) : null,
|
|
566
573
|
|
|
567
574
|
h('div', { className: 'dsn-list' },
|
|
568
575
|
boot.projects.length === 0
|
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,
|
|
@@ -1005,8 +1096,9 @@ async function bootstrap(ctx, root) {
|
|
|
1005
1096
|
async function createProject(ctx, root, body) {
|
|
1006
1097
|
const controller = ctx.get('sessionController');
|
|
1007
1098
|
if (controller === undefined) throw httpError(503, '当前部署缺少 sessionController 服务,无法创建会话');
|
|
1008
|
-
|
|
1009
|
-
//
|
|
1099
|
+
// The title is required and becomes a folder name inside the chosen
|
|
1100
|
+
// directory, so an empty or illegal one is refused rather than defaulted.
|
|
1101
|
+
const title = asText(body.title);
|
|
1010
1102
|
const titleIssue = titleProblem(title);
|
|
1011
1103
|
if (titleIssue !== null) throw httpError(400, titleIssue);
|
|
1012
1104
|
const cwd = asText(body.cwd);
|
|
@@ -1020,6 +1112,16 @@ async function createProject(ctx, root, body) {
|
|
|
1020
1112
|
}
|
|
1021
1113
|
: null;
|
|
1022
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
|
+
|
|
1023
1125
|
const created = await controller.create(agentPreset === undefined ? { cwd } : { cwd, agentPreset });
|
|
1024
1126
|
const sessionId = created && created.sessionId ? created.sessionId : null;
|
|
1025
1127
|
let warning = null;
|
|
@@ -1034,7 +1136,7 @@ async function createProject(ctx, root, body) {
|
|
|
1034
1136
|
const today = new Date();
|
|
1035
1137
|
const origin = today.toISOString().slice(0, 10);
|
|
1036
1138
|
const project = {
|
|
1037
|
-
projectId
|
|
1139
|
+
projectId,
|
|
1038
1140
|
title,
|
|
1039
1141
|
cwd,
|
|
1040
1142
|
agentPreset: agentPreset || created.agentPreset || null,
|
|
@@ -1047,6 +1149,9 @@ async function createProject(ctx, root, body) {
|
|
|
1047
1149
|
chapters: [],
|
|
1048
1150
|
};
|
|
1049
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);
|
|
1050
1155
|
|
|
1051
1156
|
// Put the session in a sidebar workspace instead of leaving it ungrouped.
|
|
1052
1157
|
let workspace = null;
|
|
@@ -1555,24 +1660,17 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
|
|
|
1555
1660
|
case '/project/delete': {
|
|
1556
1661
|
const projectId = asText(body.projectId);
|
|
1557
1662
|
if (projectId === '') throw httpError(400, '缺少 projectId');
|
|
1558
|
-
//
|
|
1559
|
-
//
|
|
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.
|
|
1560
1665
|
let workDir = null;
|
|
1561
1666
|
try {
|
|
1562
1667
|
const project = await readProject(root, projectId);
|
|
1563
|
-
|
|
1564
|
-
const folder = projectContentDir(project);
|
|
1565
|
-
const relative = folder.slice(project.cwd.length).replace(/^[\\/]+/, '');
|
|
1566
|
-
if (relative !== '') {
|
|
1567
|
-
await rm(folder, { recursive: true, force: true });
|
|
1568
|
-
workDir = folder;
|
|
1569
|
-
}
|
|
1570
|
-
}
|
|
1668
|
+
workDir = await discardWorkFolder(project);
|
|
1571
1669
|
} catch {
|
|
1572
1670
|
// A work whose folder is already gone still gets its record removed.
|
|
1573
1671
|
}
|
|
1574
1672
|
await rm(join(projectsDir(root), projectId), { recursive: true, force: true });
|
|
1575
|
-
return { removed: projectId, removedDir: workDir };
|
|
1673
|
+
return { removed: projectId, removedDir: workDir, folderKept: workDir === null };
|
|
1576
1674
|
}
|
|
1577
1675
|
case '/project/timeline':
|
|
1578
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",
|