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.
Files changed (3) hide show
  1. package/client.js +17 -10
  2. package/index.js +115 -17
  3. 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
- const effectiveTitle = title.trim() === '' ? t('未命名作品', "Untitled work") : title;
442
- const titleIssue = titleProblem(effectiveTitle);
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: title.trim() || t('未命名作品', "Untitled work"),
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
- }, [title, cwd, preset, provider, model, onOpen, onReload]);
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
- disabled: busy || Boolean(titleIssue),
559
- title: titleIssue || '',
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 || titleIssue
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.14',
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
- const title = asText(body.title) || '未命名作品';
1009
- // The title becomes a folder name inside the chosen directory.
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: randomUUID(),
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
- // The work folder is this plugin's own output inside the chosen
1559
- // directory, so removing the work removes it too.
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
- if (project.cwd) {
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.2",
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",