dsh-novel-generator 0.1.17 → 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 +78 -0
  3. package/index.js +261 -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',
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'];
@@ -903,6 +904,27 @@ async function rebindSession(ctx, root, project) {
903
904
  return { controller, agent: result.agent };
904
905
  }
905
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
+
906
928
  /** Resolve (or resume) the ordinary Agent that owns one project's Session. */
907
929
  async function resolveAgent(ctx, root, project, onNote) {
908
930
  const controller = ctx.get('sessionController');
@@ -933,7 +955,10 @@ async function resolveAgent(ctx, root, project, onNote) {
933
955
  } catch {
934
956
  result = null;
935
957
  }
936
- 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
+ }
937
962
  // The old session is gone: rebuild instead of failing every later call.
938
963
  const rebuilt = await rebindSession(ctx, root, project);
939
964
  if (onNote) onNote('原作品会话已不存在,已自动新建一个会话继续');
@@ -1364,7 +1389,7 @@ async function bootstrap(ctx, root) {
1364
1389
  }
1365
1390
  }
1366
1391
  return {
1367
- revision: '2026-10-20.45',
1392
+ revision: '2026-10-21.2',
1368
1393
  projects,
1369
1394
  models: {
1370
1395
  default: modelCatalog.default || null,
@@ -1519,6 +1544,236 @@ async function updateModel(ctx, root, body) {
1519
1544
  return { project: projectSummary, warning };
1520
1545
  }
1521
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
+
1522
1777
  async function updateTimeline(root, body) {
1523
1778
  const projectId = asText(body.projectId);
1524
1779
  const unitDays = Number(body.unitDays);
@@ -2187,6 +2442,10 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
2187
2442
  return updateModel(ctx, root, body);
2188
2443
  case '/project/rebind':
2189
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);
2190
2449
  case '/outline/save':
2191
2450
  return saveOutline(root, body);
2192
2451
  case '/chapter/save':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-novel-generator",
3
- "version": "0.1.17",
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",