dsh-novel-generator 0.1.3 → 0.1.5

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 (4) hide show
  1. package/README.md +1 -1
  2. package/client.js +20 -8
  3. package/index.js +121 -19
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -22,7 +22,7 @@ dsh plugin --profile web add dsh-novel-generator
22
22
  ## 界面流程
23
23
 
24
24
  1. **作品列表**:创建 / 删除 / 打开作品。**删除作品会同时删掉工作目录下以作品名命名的那个文件夹(正文、梗概、快照),二次确认里会说明;作品绑定的会话保留。**创建时选工作目录、模型、预设——**工作目录只能通过「选择」调目录选择器挑选,不能手填**;创建动作本身会调用 `sessionController.create` 为作品开一个会话,会话照常出现在左侧列表里,可以直接进去对话。**每行都显示当前模型,并有「模型」按钮:改完会同时切换作品绑定的会话模型**(走 `sessionController.selectModel`)。**会话会被放进左侧对应的分组:作品目录已经是工作区就并进去,否则为该目录新建一个名为「小说工坊」的工作区**,不再是「未分组」。打开工作室时会对老作品补做这一步。
25
- 2. **时间长河**(作品主界面):最上方是时间线,左端是第 0 天,右端是**最新世界快照的时间**(故事已经写到快照之后时,右端延伸到故事当前的时间)。拖动时下方世界观、角色列表、势力列表、关系(`甲 乙 关系`)切换到该时间点之前最新的那份快照。右上角「快照间隔」是一个可编辑的选择框:候选值 **7 / 15 / 30 / 90 / 180 / 365 天**,也可以直接输入别的天数——这个值同时是「多少天结算一份世界快照」的阈值和自动触发点。**当拖到下一个快照点(上一份快照时间 + 一个间隔)时,页面顶部出现「生成世界快照 · 第 N 天」按钮,点击会先问「是否为当前时间生成世界快照」,确认后才生成。**「进入故事」进入离选中时间最近的章节。界面最底部是一条**每章天数统计图**:横轴为第几章(每章居中占一格),纵轴就是「今天数」——每章自己跨了多少天,柱高即该章天数,折线连接各章天数,鼠标悬停可看到该章在故事里的起止天数;标题栏的「隐藏统计图 / 显示统计图」按钮可以收起或展开它。
25
+ 2. **时间长河**(作品主界面):最上方是时间线,左端是第 0 天,右端是**最新世界快照的时间**(故事已经写到快照之后时,右端延伸到故事当前的时间)。拖动时下方世界观、角色列表、势力列表、关系(`甲 乙 关系`)切换到该时间点之前最新的那份快照。右上角「快照间隔」是一个可编辑的选择框:候选值 **7 / 15 / 30 / 90 / 180 / 365 天**,也可以直接输入别的天数——这个值同时是「多少天结算一份世界快照」的阈值和自动触发点。**拖到下一个快照点时,页面顶部出现「生成世界快照 · 第 N 天」按钮;拖到最新那份快照所在的那天,则出现「重新生成快照 · 第 N 天」(用于改过章节之后刷新这一版快照,会替换旧的那份)。两种情况都会先弹确认再执行。**「进入故事」进入离选中时间最近的章节。界面最底部是一条**每章天数统计图**:横轴为第几章(每章居中占一格),纵轴就是「今天数」——每章自己跨了多少天,柱高即该章天数,折线连接各章天数,鼠标悬停可看到该章在故事里的起止天数;标题栏的「隐藏统计图 / 显示统计图」按钮可以收起或展开它。
26
26
  3. **章节故事**:左上「← 时间长河」回时间线界面,右上「章节大纲 →」把故事页向左收起、翻开大纲页。中间用「← 上一章 / 下一章 →」左右切换;**到了最后一章,右侧的「下一章 →」变成「+ 新建章节」**。正文可直接改,底部「保存」,保存成功用右上角的提示条告知(不是页面上的一行小字)。**正文有未保存的修改时,任何离开动作(回时间线、翻章、进大纲、新建章节、返回作品列表、关闭工作室)都会先弹出确认,可选「不保存离开」或「保存并离开」。**
27
27
  4. **新建章节**(弹窗):章节名称(可留空)、章节最低字数、章节最多字数、故事时间长度(天),后三项必填。填好后可以「创建空章节」,或点「自动推演」一次拿到本章梗概、章节名称与完整正文。**作品的第一章没有世界快照、也没有上一章可依据,所以这一章的自动推演是禁用的(新建弹窗与章节大纲页都是),先创建空章节、自己写梗概再生成。**
28
28
  5. **章节大纲**:左上「← 章节故事」。可改标题、时间长度、字数上下限、梗概最低字数与梗概;梗概达到最低字数且其他项填完才能「保存」,保存成功用提示条告知,保存后「生成章节故事」才可点。右上「自动推演」点下去会先提示「将覆盖现有内容」,确认后按当前字数与时间长度重新生成**故事梗概 + 章节名称 + 整章正文**并直接保存;「生成章节故事」点下去会先问「是否以当前故事梗概生成故事」,确认后才重写正文。**大纲有未保存的修改时,离开同样会先弹确认(不保存离开 / 保存并离开);大纲不完整时「保存并离开」会拒绝并提示。**
package/client.js CHANGED
@@ -748,9 +748,15 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
748
748
 
749
749
  const snapshot = snapshotAt(detail, day);
750
750
  const chapters = detail.chapters || [];
751
- // The button only exists where the story has actually reached the next
752
- // snapshot point the project's own unit defines.
753
- const canSnapshot = chapters.length > 0 && storyEnd >= nextDay && day >= nextDay;
751
+ // Two moments offer the button: the story has reached the next snapshot
752
+ // point (a new snapshot), or the slider sits on the newest snapshot
753
+ // (regenerate it after editing the chapters behind it).
754
+ const atLatestSnapshot = latest > 0 && day === latest;
755
+ const canSnapshot = chapters.length > 0
756
+ && ((storyEnd >= nextDay && day >= nextDay) || atLatestSnapshot);
757
+ const snapshotLabel = atLatestSnapshot
758
+ ? t('重新生成快照 · 第 {day} 天', 'Regenerate snapshot · day {day}', { day })
759
+ : t('生成世界快照 · 第 {day} 天', 'Generate snapshot · day {day}', { day });
754
760
  const nearest = useMemo(() => {
755
761
  if (chapters.length === 0) return null;
756
762
  let best = chapters[0];
@@ -767,12 +773,18 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
767
773
 
768
774
  const askSnapshot = useCallback(() => {
769
775
  requestConfirm({
770
- title: t('是否为当前时间生成世界快照?', "Generate a world snapshot for this point in time?"),
771
- text: t('将为第 {day} 天生成一份世界快照:以上一份快照和这段时间的章节梗概为依据,时间线会延伸到这一天。', 'A world snapshot will be generated for day {day} from the previous snapshot and the synopses of this stretch; the timeline extends to that day.', { day }),
772
- confirmLabel: t('生成快照', "Generate snapshot"),
776
+ title: atLatestSnapshot
777
+ ? t('是否重新生成第 {day} 天的世界快照?', 'Regenerate the world snapshot for day {day}?', { day })
778
+ : t('是否为当前时间生成世界快照?', "Generate a world snapshot for this point in time?"),
779
+ text: atLatestSnapshot
780
+ ? t('会以第 {day} 天之前那版快照和这段时间的章节梗概重写这一天,原有快照被替换。', 'It rewrites day {day} from the snapshot before it and the synopses of this stretch; the existing snapshot is replaced.', { day })
781
+ : t('将为第 {day} 天生成一份世界快照:以上一份快照和这段时间的章节梗概为依据,时间线会延伸到这一天。', 'A world snapshot will be generated for day {day} from the previous snapshot and the synopses of this stretch; the timeline extends to that day.', { day }),
782
+ confirmLabel: atLatestSnapshot
783
+ ? t('重新生成', 'Regenerate')
784
+ : t('生成快照', "Generate snapshot"),
773
785
  action: () => onRefreshSnapshot(day),
774
786
  });
775
- }, [requestConfirm, day, detail, onRefreshSnapshot]);
787
+ }, [requestConfirm, day, detail, onRefreshSnapshot, atLatestSnapshot]);
776
788
 
777
789
  return h(React.Fragment, null,
778
790
  h('div', { className: 'dsn-pagehead' },
@@ -791,7 +803,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
791
803
  type: 'button',
792
804
  disabled: snapshotBusy,
793
805
  onClick: askSnapshot,
794
- }, snapshotBusy ? t('生成中…', "Generating…") : t('生成世界快照 · 第 {day} 天', 'Generate snapshot · day {day}', { day }))
806
+ }, snapshotBusy ? t('生成中…', "Generating…") : snapshotLabel)
795
807
  : null,
796
808
  h('button', { className: 'dsn-btn primary', type: 'button', onClick: () => onEnterStory(day) },
797
809
  nearest ? t('进入故事 · 第 {n} 章', 'Enter story · chapter {n}', { n: nearest.index + 1 }) : t('进入故事', "Enter story"))),
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.15',
1076
+ revision: '2026-10-20.18',
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: randomUUID(),
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;
@@ -1298,15 +1402,17 @@ function registerSnapshot(ctx, root, registry, projectId, day) {
1298
1402
  return startJob(registry, { kind: 'snapshot', projectId }, async (job) => {
1299
1403
  const project = await readProject(root, projectId);
1300
1404
  const snapshots = project.snapshots || [];
1301
- const last = snapshots[snapshots.length - 1] || null;
1302
- const since = last ? last.day : 0;
1405
+ // Regenerating an existing day replaces it: the basis is the snapshot
1406
+ // before that day, not the one being replaced.
1407
+ const base = snapshots.filter((snapshot) => snapshot.day < day).pop() || null;
1408
+ const since = base ? base.day : 0;
1303
1409
  const chapters = (project.chapters || [])
1304
1410
  .filter((chapter) => chapterWindow(project, chapter).end > since
1305
1411
  && (((chapter.outline && chapter.outline.synopsis) || '').trim() !== '' || String(chapter.content || '').trim() !== ''))
1306
1412
  .sort((a, b) => a.startDay - b.startDay);
1307
- if (chapters.length === 0) throw httpError(409, '自上一版快照以来还没有已生成的章节,无法更新世界快照');
1413
+ if (chapters.length === 0) throw httpError(409, `第 ${day} 天之前还没有可依据的章节,无法生成世界快照`);
1308
1414
  job.message = `正在阅读 ${chapters.length} 章的梗概并更新世界快照`;
1309
- const reply = await askAgent(ctx, project, snapshotPrompt(project, last, chapters));
1415
+ const reply = await askAgent(ctx, project, snapshotPrompt(project, base, chapters));
1310
1416
  const parsed = parseJsonReply(reply);
1311
1417
  if (!parsed) throw httpError(502, '模型没有返回可解析的快照 JSON');
1312
1418
  const normalized = normalizeSnapshot(parsed);
@@ -1321,7 +1427,10 @@ function registerSnapshot(ctx, root, registry, projectId, day) {
1321
1427
  ...normalized,
1322
1428
  };
1323
1429
  await mutateProject(root, projectId, (draft) => {
1430
+ // One snapshot per day: regenerating replaces the one already there.
1431
+ draft.snapshots = (draft.snapshots || []).filter((entry) => entry.day !== day);
1324
1432
  draft.snapshots.push(snapshot);
1433
+ draft.snapshots.sort((a, b) => a.day - b.day);
1325
1434
  return snapshot;
1326
1435
  });
1327
1436
  await exportSnapshot(project, snapshot);
@@ -1556,24 +1665,17 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
1556
1665
  case '/project/delete': {
1557
1666
  const projectId = asText(body.projectId);
1558
1667
  if (projectId === '') throw httpError(400, '缺少 projectId');
1559
- // The work folder is this plugin's own output inside the chosen
1560
- // directory, so removing the work removes it too.
1668
+ // Only a folder carrying this project's own stamp is removed: a folder
1669
+ // the user already had under the same name is never touched.
1561
1670
  let workDir = null;
1562
1671
  try {
1563
1672
  const project = await readProject(root, projectId);
1564
- if (project.cwd) {
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
- }
1673
+ workDir = await discardWorkFolder(project);
1572
1674
  } catch {
1573
1675
  // A work whose folder is already gone still gets its record removed.
1574
1676
  }
1575
1677
  await rm(join(projectsDir(root), projectId), { recursive: true, force: true });
1576
- return { removed: projectId, removedDir: workDir };
1678
+ return { removed: projectId, removedDir: workDir, folderKept: workDir === null };
1577
1679
  }
1578
1680
  case '/project/timeline':
1579
1681
  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",
3
+ "version": "0.1.5",
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",