dsh-novel-generator 0.1.16 → 0.1.18

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 +6 -0
  2. package/client.js +144 -11
  3. package/index.js +264 -2
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -62,6 +62,12 @@ dsh plugin --profile web add dsh-novel-generator
62
62
  - 生成、推演、快照都是异步任务:`POST /generate`、`POST /compose`、`POST /deduce`、`POST /snapshot/refresh` 返回 `jobId`,再由 `GET /job?id=` 轮询。
63
63
  - `timeline.cursor` 现在以「天」为单位;旧数据(按格计数)在首次读取时自动换算一次并标记 `cursorUnit: "day"`。
64
64
 
65
+ ## 迁移、导入与会话
66
+
67
+ - **改目录**:作品列表每行的「改目录」会把整个作品文件夹搬到新目录,作品的 `cwd` 随之更新;目标目录已有同名非空文件夹时拒绝。
68
+ - **导入已有作品**:列表上方的「导入已有作品…」选中一个作品文件夹(含 `作品信息.md`),插件从文件重建条目——章节、大纲、梗概、字数、时间长度、世界快照都会读回来,并新建一个会话。这既是手动迁移入口,也是 `.dsh/novel-studio` 数据丢失后的恢复路径。
69
+ - **会话**:作品与会话一对一。会话被删除时插件会自动重建;被**归档**时探测不到(DSH 无查询接口),用「重建会话」按钮手动换一个。会话的模型会在每次任务前与作品记录的模型对齐。
70
+
65
71
  ## 自检脚本(开发用,不随包发布)
66
72
 
67
73
  ```bash
package/client.js CHANGED
@@ -187,6 +187,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
187
187
  .dsn-input.dsn-readonly:focus{border-color:var(--dsw-alias-border-l1)}
188
188
  .dsn-input.dsn-input-invalid{border-color:var(--dsw-alias-state-warn-primary)}
189
189
  .dsn-path{display:flex;gap:6px}
190
+ .dsn-import{display:flex;align-items:center;gap:10px;margin:2px 0 12px}
190
191
  .dsn-list{display:flex;flex-direction:column;gap:8px}
191
192
  .dsn-row{display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:12px;
192
193
  border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);cursor:pointer}
@@ -515,6 +516,65 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
515
516
  }
516
517
  }, [onReload]);
517
518
 
519
+ /** Move a work's folder to another directory. */
520
+ const moveWork = useCallback(async (project) => {
521
+ const ui = typeof resolveWorkspace === 'function' ? resolveWorkspace() : null;
522
+ if (!ui || typeof ui.pickDirectory !== 'function') {
523
+ setError(t('目录选择器还没就绪,请稍后再试', 'The directory picker is not ready yet — try again in a moment.'));
524
+ return;
525
+ }
526
+ let picked = '';
527
+ try {
528
+ picked = (await ui.pickDirectory()) || '';
529
+ } catch (pickError) {
530
+ setError(messageOf(pickError));
531
+ return;
532
+ }
533
+ if (!picked) return;
534
+ if (!window.confirm(t('把「{title}」整个文件夹移到 {cwd} 吗?正文、梗概、快照会一起搬过去。', 'Move the whole “{title}” folder to {cwd}? Chapters, outlines and snapshots travel with it.', { title: project.title, cwd: picked }))) return;
535
+ try {
536
+ await api('/project/move', { projectId: project.projectId, cwd: picked });
537
+ await onReload();
538
+ } catch (moveError) {
539
+ setError(messageOf(moveError));
540
+ }
541
+ }, [resolveWorkspace, onReload]);
542
+
543
+ /** Adopt an existing work folder that the plugin does not know about. */
544
+ const importWork = useCallback(async () => {
545
+ const ui = typeof resolveWorkspace === 'function' ? resolveWorkspace() : null;
546
+ if (!ui || typeof ui.pickDirectory !== 'function') {
547
+ setError(t('目录选择器还没就绪,请稍后再试', 'The directory picker is not ready yet — try again in a moment.'));
548
+ return;
549
+ }
550
+ let picked = '';
551
+ try {
552
+ picked = (await ui.pickDirectory()) || '';
553
+ } catch (pickError) {
554
+ setError(messageOf(pickError));
555
+ return;
556
+ }
557
+ if (!picked) return;
558
+ const parts = picked.split(/[\\/]+/).filter(Boolean);
559
+ const folder = parts.pop() || '';
560
+ const parent = parts.join('\\');
561
+ if (!folder || !parent) {
562
+ setError(t('请选择作品文件夹本身,而不是磁盘根目录', 'Pick the work folder itself, not a drive root.'));
563
+ return;
564
+ }
565
+ try {
566
+ const result = await api('/project/import', { cwd: parent, folder });
567
+ await onReload();
568
+ setError(t('已导入「{title}」:{chapters} 章、{snapshots} 份快照', 'Imported “{title}”: {chapters} chapters, {snapshots} snapshots', {
569
+ title: result.project.title,
570
+ chapters: result.imported.chapters,
571
+ snapshots: result.imported.snapshots,
572
+ }));
573
+ } catch (importError) {
574
+ setError(messageOf(importError));
575
+ }
576
+ }, [resolveWorkspace, onReload]);
577
+
518
578
  return h('div', { className: 'dsn-projects' },
519
579
  h('div', { className: 'dsn-create' },
520
580
  h('label', { className: 'dsn-field' },
@@ -574,6 +634,15 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
574
634
  onClick: create,
575
635
  }, busy ? t('创建中…', "Creating…") : t('创建作品并开会话', "Create work and open its session")))),
576
636
 
637
+ h('div', { className: 'dsn-import' },
638
+ h('button', {
639
+ className: 'dsn-btn ghost',
640
+ type: 'button',
641
+ title: t('把别处已有的作品文件夹接进来(章节、梗概、快照会从文件读回)', 'Adopt an existing work folder: chapters, outlines and snapshots are read back from the files'),
642
+ onClick: importWork,
643
+ }, t('导入已有作品…', "Import a work…")),
644
+ h('span', { className: 'dsn-hint' }, t('选择那个作品文件夹本身(含「作品信息.md」的目录)', 'Pick the work folder itself — the one holding 作品信息.md'))),
645
+
577
646
  error ? h('div', { className: 'dsn-error', style: { margin: 0 } }, error) : null,
578
647
 
579
648
  h('div', { className: 'dsn-list' },
@@ -617,6 +686,15 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
617
686
  }
618
687
  },
619
688
  }, t('重建会话', "New session")),
689
+ h('button', {
690
+ className: 'dsn-btn ghost',
691
+ type: 'button',
692
+ title: t('把这个作品的文件夹搬到别的工作目录', 'Move this work’s folder to another working directory'),
693
+ onClick: (event) => {
694
+ event.stopPropagation();
695
+ moveWork(project);
696
+ },
697
+ }, t('改目录', "Move")),
620
698
  h('button', {
621
699
  className: 'dsn-btn danger ghost',
622
700
  type: 'button',
@@ -1041,7 +1119,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1041
1119
 
1042
1120
  /* ---------------------------------------------------------- outline page */
1043
1121
 
1044
- function OutlinePage({ detail, chapter, isFirstChapter, onBack, onSaved, notify, onRegisterGuard, requestConfirm }) {
1122
+ function OutlinePage({ detail, chapter, isFirstChapter, onBack, onSaved, notify, onRegisterGuard, requestConfirm, onBusyChange }) {
1045
1123
  const buildForm = useCallback(() => ({
1046
1124
  title: chapter ? chapter.title || '' : '',
1047
1125
  durationDays: chapter && chapter.outline ? chapter.outline.durationDays : (detail.timeline.unitDays || 30),
@@ -1182,6 +1260,22 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1182
1260
  });
1183
1261
  }, [requestConfirm, startJob]);
1184
1262
 
1263
+ // Report the running job upward so the studio can lock itself and warn
1264
+ // before the panel is closed mid-generation.
1265
+ useEffect(() => {
1266
+ if (typeof onBusyChange !== 'function') return;
1267
+ const running = job && job.state === 'running';
1268
+ if (!running) {
1269
+ onBusyChange(null);
1270
+ return undefined;
1271
+ }
1272
+ onBusyChange({
1273
+ label: job.kind === 'compose' ? t('正在自动推演…', 'Deducing…') : t('正在生成章节故事…', 'Generating the chapter…'),
1274
+ message: job.message || '',
1275
+ });
1276
+ return () => onBusyChange(null);
1277
+ }, [job ? job.jobId : null, job ? job.state : null, job ? job.message : null, onBusyChange]);
1278
+
1185
1279
  useEffect(() => {
1186
1280
  if (!job) return;
1187
1281
  if (job.kind === 'generate' && job.state === 'done') {
@@ -1312,7 +1406,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1312
1406
  * The only place a chapter is created: word bounds and the story length are
1313
1407
  * required, and one button can deduce the synopsis and write the chapter.
1314
1408
  */
1315
- function NewChapterDialog({ detail, startDay, onCancel, onCreated, notify }) {
1409
+ function NewChapterDialog({ detail, startDay, onCancel, onCreated, notify, onBusyChange }) {
1316
1410
  const [form, setForm] = useState({
1317
1411
  title: '',
1318
1412
  minWords: 1200,
@@ -1337,6 +1431,20 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1337
1431
 
1338
1432
  const composing = Boolean(job && job.state === 'running' && job.kind === 'compose');
1339
1433
 
1434
+ // A deducing dialog locks the studio behind it and warns before closing.
1435
+ useEffect(() => {
1436
+ if (typeof onBusyChange !== 'function') return undefined;
1437
+ if (!composing) {
1438
+ onBusyChange(null);
1439
+ return undefined;
1440
+ }
1441
+ onBusyChange({
1442
+ label: t('正在自动推演新章节…', 'Deducing a new chapter…'),
1443
+ message: (job && job.message) || '',
1444
+ });
1445
+ return () => onBusyChange(null);
1446
+ }, [composing, job ? job.message : null, onBusyChange]);
1447
+
1340
1448
  const create = useCallback(async () => {
1341
1449
  setBusy(true);
1342
1450
  setError('');
@@ -1500,13 +1608,25 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1500
1608
 
1501
1609
  /* ------------------------------------------------------------- workspace */
1502
1610
 
1503
- function ProjectWorkspace({ detail, onReload, notify, onRegisterLeaveGuard }) {
1611
+ function ProjectWorkspace({ detail, onReload, notify, onRegisterLeaveGuard, onBusyChange }) {
1504
1612
  const [view, setView] = useState('world');
1505
1613
  const [chapterId, setChapterId] = useState(null);
1506
1614
  // A running snapshot job: { label, message }. Non-null locks the studio,
1507
1615
  // because the job keeps rewriting this work's snapshots.
1508
1616
  const [snapshotJob, setSnapshotJob] = useState(null);
1509
- const snapshotBusy = snapshotJob !== null;
1617
+ // The same idea for the chapter jobs (自动推演 / 生成章节故事), reported up
1618
+ // by the pages that own them.
1619
+ const [childBusy, setChildBusy] = useState(null);
1620
+ const busy = snapshotJob || childBusy;
1621
+ const snapshotBusy = Boolean(snapshotJob);
1622
+
1623
+ // Tell the shell whether a long operation is in flight, so closing the
1624
+ // panel can ask first.
1625
+ useEffect(() => {
1626
+ if (typeof onBusyChange !== 'function') return undefined;
1627
+ onBusyChange(busy ? { label: busy.label, message: busy.message } : null);
1628
+ return () => onBusyChange(null);
1629
+ }, [busy ? busy.label : null, busy ? busy.message : null, onBusyChange]);
1510
1630
  const [dialogOpen, setDialogOpen] = useState(false);
1511
1631
  const [text, setText] = useState('');
1512
1632
  const [dirty, setDirty] = useState(false);
@@ -1706,14 +1826,14 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1706
1826
  const trackIndex = view === 'world' ? 0 : view === 'story' ? 1 : 2;
1707
1827
 
1708
1828
  return h('div', { className: 'dsn-stage' },
1709
- // A running snapshot job owns the work: nothing else may be edited
1710
- // while it rewrites snapshots underneath.
1711
- snapshotBusy
1829
+ // Any running job owns the work: nothing else may be edited while it
1830
+ // rewrites the novel underneath.
1831
+ busy
1712
1832
  ? h('div', { className: 'dsn-lock' },
1713
1833
  h('div', { className: 'dsn-lock-box' },
1714
- h('div', { className: 'dsn-lock-title' }, snapshotJob.label),
1715
- h('div', { className: 'dsn-lock-text' }, snapshotJob.message),
1716
- h('div', { className: 'dsn-lock-hint' }, t('快照生成期间界面不可操作,完成后会自动刷新', 'The studio is locked while snapshots are generated; it refreshes when done.'))))
1834
+ h('div', { className: 'dsn-lock-title' }, busy.label),
1835
+ h('div', { className: 'dsn-lock-text' }, busy.message),
1836
+ h('div', { className: 'dsn-lock-hint' }, t('生成期间界面不可操作,完成后会自动刷新', 'The studio is locked while this runs; it refreshes when done.'))))
1717
1837
  : null,
1718
1838
  h('div', { className: 'dsn-track', style: { transform: `translateX(-${trackIndex * 100}%)` } },
1719
1839
  h('section', { className: 'dsn-page' },
@@ -1756,6 +1876,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1756
1876
  notify,
1757
1877
  onRegisterGuard: registerOutlineGuard,
1758
1878
  requestConfirm: setConfirmRequest,
1879
+ onBusyChange: setChildBusy,
1759
1880
  isFirstChapter: Boolean(chapter && chapters.length > 0 && chapter.chapterId === chapters[0].chapterId),
1760
1881
  }))),
1761
1882
  confirmRequest
@@ -1804,6 +1925,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1804
1925
  onCancel: () => setDialogOpen(false),
1805
1926
  onCreated: created,
1806
1927
  notify,
1928
+ onBusyChange: setChildBusy,
1807
1929
  })
1808
1930
  : null);
1809
1931
  }
@@ -1818,6 +1940,8 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1818
1940
  const [projectId, setProjectId] = useState(null);
1819
1941
  const [detail, setDetail] = useState(null);
1820
1942
  const [toast, setToast] = useToast();
1943
+ // Set while a work runs a model job, so leaving the panel can warn first.
1944
+ const [busyNow, setBusyNow] = useState(null);
1821
1945
  const [modelTarget, setModelTarget] = useState(null);
1822
1946
  const leaveGuard = useRef(null);
1823
1947
  const registerLeaveGuard = useCallback((guard) => {
@@ -1825,13 +1949,21 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1825
1949
  }, []);
1826
1950
  /** Every way out of the studio asks the workspace first. */
1827
1951
  const requestLeave = useCallback((proceed) => {
1952
+ // A running generation outranks the editor guard: say it is still going
1953
+ // before anything closes.
1954
+ if (busyNow) {
1955
+ const label = busyNow.label || t('正在生成', 'Generation is running');
1956
+ if (!window.confirm(t('{label},现在离开吗?生成会在后台继续,回来时刷新即可看到结果。', '{label} — leave now? It keeps running in the background; reopen the panel later to see the result.', { label }))) return;
1957
+ proceed();
1958
+ return;
1959
+ }
1828
1960
  const guard = leaveGuard.current;
1829
1961
  if (typeof guard === 'function') {
1830
1962
  guard(proceed);
1831
1963
  return;
1832
1964
  }
1833
1965
  proceed();
1834
- }, []);
1966
+ }, [busyNow]);
1835
1967
  const close = useCallback(() => requestLeave(() => setOverlayOpen(false)), [requestLeave]);
1836
1968
 
1837
1969
  const loadBoot = useCallback(async () => {
@@ -1911,6 +2043,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1911
2043
  onReload: reloadDetail,
1912
2044
  notify,
1913
2045
  onRegisterLeaveGuard: registerLeaveGuard,
2046
+ onBusyChange: setBusyNow,
1914
2047
  })),
1915
2048
  modelTarget
1916
2049
  ? h(ModelDialog, {
package/index.js CHANGED
@@ -14,6 +14,7 @@ import { randomUUID } from 'node:crypto';
14
14
  import { mkdir, readFile, readdir, rename, rm, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
15
15
  import { homedir, tmpdir } from 'node:os';
16
16
  import { basename, join } from 'node:path';
17
+ import path from 'node:path';
17
18
 
18
19
  export const name = 'dsh-novel-generator';
19
20
  export const inject = ['webServer'];
@@ -891,6 +892,9 @@ async function rebindSession(ctx, root, project) {
891
892
  draft.sessionId = sessionId;
892
893
  });
893
894
  try {
895
+ // The memo skips projects already grouped; a rebind starts a new session
896
+ // that has never been grouped, so the entry has to go.
897
+ ensuredWorkspaces.delete(project.projectId);
894
898
  await ensureWorkspace(ctx, root, project);
895
899
  } catch {
896
900
  // Grouping is cosmetic; the session works either way.
@@ -900,6 +904,27 @@ async function rebindSession(ctx, root, project) {
900
904
  return { controller, agent: result.agent };
901
905
  }
902
906
 
907
+ /**
908
+ * Keep the work's Session on the work's model.
909
+ *
910
+ * A session remembers the model it was last given, so a rebuilt session — or a
911
+ * switch that quietly failed on an archived one — can leave it on a different
912
+ * model than the work records. The pairing is re-applied whenever they differ.
913
+ */
914
+ const syncedSessionModels = new Map();
915
+
916
+ async function syncSessionModel(controller, project) {
917
+ if (!project || !project.sessionId || !project.model || !project.model.provider || !project.model.model) return;
918
+ const key = `${project.model.provider}/${project.model.model}`;
919
+ if (syncedSessionModels.get(project.sessionId) === key) return;
920
+ try {
921
+ await controller.selectModel({ sessionId: project.sessionId, ...project.model });
922
+ syncedSessionModels.set(project.sessionId, key);
923
+ } catch {
924
+ // The work still records the model; the next job tries again.
925
+ }
926
+ }
927
+
903
928
  /** Resolve (or resume) the ordinary Agent that owns one project's Session. */
904
929
  async function resolveAgent(ctx, root, project, onNote) {
905
930
  const controller = ctx.get('sessionController');
@@ -930,7 +955,10 @@ async function resolveAgent(ctx, root, project, onNote) {
930
955
  } catch {
931
956
  result = null;
932
957
  }
933
- if (result && result.agent) return { controller, agent: result.agent };
958
+ if (result && result.agent) {
959
+ await syncSessionModel(controller, project);
960
+ return { controller, agent: result.agent };
961
+ }
934
962
  // The old session is gone: rebuild instead of failing every later call.
935
963
  const rebuilt = await rebindSession(ctx, root, project);
936
964
  if (onNote) onNote('原作品会话已不存在,已自动新建一个会话继续');
@@ -1361,7 +1389,7 @@ async function bootstrap(ctx, root) {
1361
1389
  }
1362
1390
  }
1363
1391
  return {
1364
- revision: '2026-10-20.44',
1392
+ revision: '2026-10-21.2',
1365
1393
  projects,
1366
1394
  models: {
1367
1395
  default: modelCatalog.default || null,
@@ -1516,6 +1544,236 @@ async function updateModel(ctx, root, body) {
1516
1544
  return { project: projectSummary, warning };
1517
1545
  }
1518
1546
 
1547
+ /**
1548
+ * Move one work's folder to another directory and repoint the work at it.
1549
+ *
1550
+ * The folder is the work: chapter text, outlines and snapshots travel with it.
1551
+ * A destination that already holds someone else's files is refused, exactly as
1552
+ * creation refuses it.
1553
+ */
1554
+ async function moveProject(ctx, root, body) {
1555
+ const projectId = asText(body.projectId);
1556
+ if (projectId === '') throw httpError(400, '缺少 projectId');
1557
+ const cwd = asText(body.cwd);
1558
+ if (cwd === '') throw httpError(400, '请选择新的工作目录');
1559
+ try {
1560
+ const info = await stat(cwd);
1561
+ if (!info.isDirectory()) throw new Error('not a directory');
1562
+ } catch {
1563
+ throw httpError(400, '新的工作目录不存在或不是文件夹');
1564
+ }
1565
+ const project = await readProject(root, projectId);
1566
+ if (path.resolve(cwd) === path.resolve(project.cwd)) {
1567
+ throw httpError(409, '新旧工作目录相同,无需移动');
1568
+ }
1569
+ const from = projectContentDir(project);
1570
+ const to = join(cwd, safeSegment(project.title, '未命名作品'));
1571
+ const claimed = await reserveWorkFolder({ projectId, title: project.title, cwd }, projectId);
1572
+ if (path.resolve(claimed) !== path.resolve(to)) {
1573
+ throw httpError(409, `目标目录下已经有名为「${basename(to)}」的文件夹,请先在那边清空或删掉`);
1574
+ }
1575
+ if (await pathExists(from)) {
1576
+ await moveTree(from, to);
1577
+ }
1578
+ const moved = await mutateProject(root, projectId, (draft) => {
1579
+ draft.cwd = cwd;
1580
+ draft.updatedAt = Date.now();
1581
+ return summarize(draft);
1582
+ });
1583
+ await persistProject(root, await readProject(root, projectId));
1584
+ return { project: moved, from, to };
1585
+ }
1586
+
1587
+ /** Copy-then-delete move that also works across drives. */
1588
+ async function moveTree(from, to) {
1589
+ try {
1590
+ await rename(from, to);
1591
+ return;
1592
+ } catch {
1593
+ // Cross-device rename fails; fall through to a copy.
1594
+ }
1595
+ await mkdir(to, { recursive: true });
1596
+ for (const entry of await readdir(from, { withFileTypes: true })) {
1597
+ const source = join(from, entry.name);
1598
+ const target = join(to, entry.name);
1599
+ if (entry.isDirectory()) await moveTree(source, target);
1600
+ else await rename(source, target).catch(async () => {
1601
+ await writeFile(target, await readFile(source));
1602
+ await unlink(source);
1603
+ });
1604
+ }
1605
+ await rm(from, { recursive: true, force: true });
1606
+ }
1607
+
1608
+ /**
1609
+ * Adopt a work folder that lives outside the plugin's index — the manual
1610
+ * migration path, and the way back from a lost `.dsh/novel-studio` data
1611
+ * directory. Chapters, outlines and snapshots are read back from the files.
1612
+ */
1613
+ async function importProject(ctx, root, body) {
1614
+ const cwd = asText(body.cwd);
1615
+ const folder = asText(body.folder);
1616
+ if (cwd === '' || folder === '') throw httpError(400, '请填写工作目录与作品文件夹名');
1617
+ const dir = join(cwd, folder);
1618
+ let info;
1619
+ try {
1620
+ info = await stat(dir);
1621
+ if (!info.isDirectory()) throw new Error('not a directory');
1622
+ } catch {
1623
+ throw httpError(400, `找不到作品文件夹:${dir}`);
1624
+ }
1625
+ const title = folder;
1626
+ const titleIssue = titleProblem(title);
1627
+ if (titleIssue !== null) throw httpError(400, titleIssue);
1628
+
1629
+ const existing = (await listProjects(root)).find((entry) => path.resolve(entry.cwd) === path.resolve(cwd) && entry.title === title);
1630
+ if (existing) throw httpError(409, '这部作品已经导入过了');
1631
+
1632
+ const files = (await readdir(dir).catch(() => [])).filter((name) => name.endsWith('.md'));
1633
+ let subtitle = '';
1634
+ try {
1635
+ const infoText = await readFile(join(dir, '作品信息.md'), 'utf8');
1636
+ const match = infoText.match(/#\s*作品[::]\s*(.+)/u);
1637
+ if (match) subtitle = match[1].trim();
1638
+ } catch {
1639
+ // No 作品信息.md: the folder name is the title.
1640
+ }
1641
+
1642
+ const chapters = [];
1643
+ const outlineDir = join(dir, '章节大纲');
1644
+ let outlineNames = [];
1645
+ try {
1646
+ outlineNames = (await readdir(outlineDir)).filter((name) => name.endsWith('.md'));
1647
+ } catch {
1648
+ outlineNames = [];
1649
+ }
1650
+ let runningStart = 0;
1651
+ const sorted = outlineNames
1652
+ .map((name) => ({ name, index: Number((name.match(/第(\d+)章/u) || [])[1] || 0) }))
1653
+ .filter((entry) => entry.index > 0)
1654
+ .sort((a, b) => a.index - b.index);
1655
+ for (const entry of sorted) {
1656
+ let text = '';
1657
+ try {
1658
+ text = await readFile(join(outlineDir, entry.name), 'utf8');
1659
+ } catch {
1660
+ continue;
1661
+ }
1662
+ const heading = (text.match(/^#\s*第\s*\d+\s*章\s*(.*)$/mu) || [])[1] || '';
1663
+ const span = text.match(/故事时间:第\s*(\d+)\s*→\s*(\d+)\s*天/u);
1664
+ const words = text.match(/字数要求:\s*(\d+)\s*[–-]\s*(\d+)/u);
1665
+ const synopsisMin = text.match(/梗概最低字数:\s*(\d+)/u);
1666
+ const synopsis = (text.split('## 故事梗概')[1] || '').trim();
1667
+ const startDay = span ? Number(span[1]) : runningStart;
1668
+ const endDay = span ? Number(span[2]) : startDay + 30;
1669
+ runningStart = endDay;
1670
+ const chapterTitle = heading.trim() || `第 ${entry.index} 章`;
1671
+ let content = '';
1672
+ const bodyName = files.find((name) => name.startsWith(`第${String(entry.index).padStart(2, '0')}章`));
1673
+ if (bodyName) {
1674
+ try {
1675
+ content = await readFile(join(dir, bodyName), 'utf8');
1676
+ } catch {
1677
+ content = '';
1678
+ }
1679
+ }
1680
+ chapters.push({
1681
+ chapterId: randomUUID(),
1682
+ index: entry.index - 1,
1683
+ title: chapterTitle,
1684
+ startDay,
1685
+ durationDays: Math.max(1, endDay - startDay),
1686
+ outline: {
1687
+ synopsis,
1688
+ synopsisMinChars: synopsisMin ? Number(synopsisMin[1]) : 100,
1689
+ durationDays: Math.max(1, endDay - startDay),
1690
+ minWords: words ? Number(words[1]) : 1200,
1691
+ maxWords: words ? Number(words[2]) : 3500,
1692
+ savedAt: Date.now(),
1693
+ },
1694
+ content,
1695
+ wordCount: wordCount(content),
1696
+ status: content.trim() === '' ? 'outlined' : 'generated',
1697
+ createdAt: Date.now(),
1698
+ updatedAt: Date.now(),
1699
+ generatedAt: content.trim() === '' ? null : Date.now(),
1700
+ });
1701
+ }
1702
+
1703
+ const snapshots = [];
1704
+ const snapshotDir = join(dir, '世界快照');
1705
+ let snapshotNames = [];
1706
+ try {
1707
+ snapshotNames = (await readdir(snapshotDir)).filter((name) => name.endsWith('.json'));
1708
+ } catch {
1709
+ snapshotNames = [];
1710
+ }
1711
+ for (const name of snapshotNames.sort()) {
1712
+ try {
1713
+ const data = JSON.parse(await readFile(join(snapshotDir, name), 'utf8'));
1714
+ const day = Number((name.match(/第(\d+)天/u) || [])[1] || 0);
1715
+ const normalized = normalizeSnapshot(data);
1716
+ snapshots.push({
1717
+ snapshotId: randomUUID(),
1718
+ day,
1719
+ createdAt: Date.now(),
1720
+ unitDays: Number(data.unitDays) || 30,
1721
+ ...normalized,
1722
+ });
1723
+ } catch {
1724
+ // A malformed sidecar is skipped, not fatal.
1725
+ }
1726
+ }
1727
+ snapshots.sort((a, b) => a.day - b.day);
1728
+
1729
+ const unitDays = snapshots.length ? snapshots[snapshots.length - 1].unitDays : 30;
1730
+ const project = {
1731
+ projectId: randomUUID(),
1732
+ title,
1733
+ cwd,
1734
+ agentPreset: null,
1735
+ model: null,
1736
+ sessionId: null,
1737
+ createdAt: Date.now(),
1738
+ updatedAt: Date.now(),
1739
+ timeline: {
1740
+ unitDays,
1741
+ cursor: Math.max(lastChapterEnd({ chapters }), snapshots.length ? snapshots[snapshots.length - 1].day : 0),
1742
+ origin: new Date().toISOString().slice(0, 10),
1743
+ cursorUnit: 'day',
1744
+ },
1745
+ chapters,
1746
+ snapshots,
1747
+ };
1748
+ await writeJson(projectFile(root, project.projectId), project);
1749
+ await persistProject(root, project);
1750
+ const created = await createSessionFor(ctx, root, project);
1751
+ return {
1752
+ project: summarize(await readProject(root, project.projectId)),
1753
+ imported: { chapters: chapters.length, snapshots: snapshots.length, subtitle },
1754
+ sessionId: created,
1755
+ };
1756
+ }
1757
+
1758
+ /** Give a freshly imported work its own session (best effort). */
1759
+ async function createSessionFor(ctx, root, project) {
1760
+ const controller = ctx.get('sessionController');
1761
+ if (!controller) return null;
1762
+ try {
1763
+ const created = await controller.create({ cwd: project.cwd });
1764
+ const sessionId = created && created.sessionId ? created.sessionId : null;
1765
+ if (!sessionId) return null;
1766
+ project.sessionId = sessionId;
1767
+ await mutateProject(root, project.projectId, (draft) => {
1768
+ draft.sessionId = sessionId;
1769
+ });
1770
+ await ensureWorkspace(ctx, root, project);
1771
+ return sessionId;
1772
+ } catch {
1773
+ return null;
1774
+ }
1775
+ }
1776
+
1519
1777
  async function updateTimeline(root, body) {
1520
1778
  const projectId = asText(body.projectId);
1521
1779
  const unitDays = Number(body.unitDays);
@@ -2184,6 +2442,10 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
2184
2442
  return updateModel(ctx, root, body);
2185
2443
  case '/project/rebind':
2186
2444
  return rebindProject(ctx, root, body);
2445
+ case '/project/move':
2446
+ return moveProject(ctx, root, body);
2447
+ case '/project/import':
2448
+ return importProject(ctx, root, body);
2187
2449
  case '/outline/save':
2188
2450
  return saveOutline(root, body);
2189
2451
  case '/chapter/save':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-novel-generator",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
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",