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.
- package/README.md +6 -0
- package/client.js +144 -11
- package/index.js +264 -2
- 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
|
-
|
|
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
|
-
//
|
|
1710
|
-
//
|
|
1711
|
-
|
|
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' },
|
|
1715
|
-
h('div', { className: 'dsn-lock-text' },
|
|
1716
|
-
h('div', { className: 'dsn-lock-hint' }, t('
|
|
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)
|
|
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-
|
|
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.
|
|
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",
|