dsh-novel-generator 0.1.34 → 0.1.35
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 +19 -2
- package/client.js +265 -1
- package/index.js +324 -72
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -70,6 +70,21 @@ dsh plugin --profile web add dsh-novel-generator
|
|
|
70
70
|
- 生成、推演、快照都是异步任务:`POST /generate`、`POST /compose`、`POST /deduce`、`POST /snapshot/refresh` 返回 `jobId`,再由 `GET /job?id=` 轮询。
|
|
71
71
|
- `timeline.cursor` 现在以「天」为单位;旧数据(按格计数)在首次读取时自动换算一次并标记 `cursorUnit: "day"`。
|
|
72
72
|
|
|
73
|
+
## 导入章节
|
|
74
|
+
|
|
75
|
+
作品列表每行的「导入章节」把现成的 `.md` 章节文件导进作品。两步:选来源 → 看匹配结果 → 确认。
|
|
76
|
+
|
|
77
|
+
**匹配规则**(文件名要按 `第N章 标题.md` 命名):
|
|
78
|
+
|
|
79
|
+
- 认 `第1章`、`第007章`、`第十二章` 三种编号写法,后缀也可以是 `回` / `节`;扩展名 `.md` / `.markdown` / `.txt`;
|
|
80
|
+
- 从你选的起点开始按 `1, 2, 3…`(或 `上一章编号+1`)**顺序配对**,配不上就停;后面即使还有文件也不会导入;
|
|
81
|
+
- 起点选「从第 1 章开始」时,**作品里已占用的章号会跳过**(不覆盖、不重复);
|
|
82
|
+
- 标题取编号之后的部分(`第3章.md` 没有标题,就用「第 3 章」)。
|
|
83
|
+
|
|
84
|
+
**匹配展示界面**会列出:将导入的每一条(`文件名 → 第N章 标题 · 字数`)、被跳过的章号、**没连上的文件**、**认不出章号的文件**,以及疑似编码乱码的标记。
|
|
85
|
+
|
|
86
|
+
**导入后**:正文写进作品并导出成 `第NN章 标题.md`,章节按你填的「每章故事时间长度(天)」依次推进;勾选「自动同步章节大纲」时逐章调用模型生成梗概与字数范围(进度可见、可停止);导入推进到网格点时会顺带触发世界快照。
|
|
87
|
+
|
|
73
88
|
## 前置提示词(作品级)
|
|
74
89
|
|
|
75
90
|
作品列表每行的「前置」可以给一本书设定前置提示:内置「第三人称全知(默认)/ 第三人称限知 / 第一人称 / 自定义」四种,选中即填入文本框,文本可自由改写(最多 2000 字)。
|
|
@@ -84,7 +99,9 @@ dsh plugin --profile web add dsh-novel-generator
|
|
|
84
99
|
## 章节故事页
|
|
85
100
|
|
|
86
101
|
- 正文可以直接编辑、保存;工具条上还有两个按钮:
|
|
87
|
-
-
|
|
102
|
+
- **「同步章节大纲」**:确认后把当前正文交给模型,**按这一章的实际内容重算整份大纲**——故事梗概、故事时间长度(天)、章节最低字数、章节最多字数、梗概最低字数,然后直接保存覆盖旧大纲(正文不动)。
|
|
103
|
+
- 字数范围一定会把正文实际字数包在里面;梗概下限不会超过新梗概本身的长度。
|
|
104
|
+
- 时间长度只有在不破坏时间线时才会改:末章可以直接改;后面还有章节时,只有「下一章起始日 = 本章起始日 + 新时长」才采纳,否则保留原时长并在提示里说明模型建议值。
|
|
88
105
|
- **「AI 修改」**:填入修改意见后,模型按意见重写整章,结果只放进编辑区、不自动保存。
|
|
89
106
|
- 正文为空时两个按钮都不可点,接口也会拒绝(400);修改意见为空同样拒绝。
|
|
90
107
|
|
|
@@ -93,7 +110,7 @@ dsh plugin --profile web add dsh-novel-generator
|
|
|
93
110
|
- **「AI 修改梗概」**:与章节故事页的「AI 修改」区分——它只改**故事梗概**,依据是「原梗概 + 你的修改意见」,不动正文和章节名。结果填回梗概框并标记未保存,确认后点「保存」。
|
|
94
111
|
- 梗概为空或意见为空时按钮不可点,接口也会 400。
|
|
95
112
|
|
|
96
|
-
>
|
|
113
|
+
> 刻意的差别:「同步章节大纲」会覆盖保存(读的是你已经写定的正文,一次对齐五项),「AI 修改梗概」只改草稿(它改的正是你用来生成正文的依据,值得先看一眼)。
|
|
97
114
|
|
|
98
115
|
## 「小说工坊 · 作者」预设(随主插件一起装)
|
|
99
116
|
|
package/client.js
CHANGED
|
@@ -256,6 +256,17 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
256
256
|
.dsn-synopsis{min-height:220px;resize:vertical;line-height:1.8;font-size:13px}
|
|
257
257
|
.dsn-hint{font-size:11px;color:var(--dsw-alias-label-secondary)}
|
|
258
258
|
.dsn-hint.warn{color:var(--dsw-alias-state-warn-primary)}
|
|
259
|
+
.dsn-check{display:flex;align-items:flex-start;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer}
|
|
260
|
+
.dsn-check input{margin:2px 0 0}
|
|
261
|
+
.dsn-importlist{max-height:280px;overflow:auto;display:flex;flex-direction:column;gap:2px;
|
|
262
|
+
border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 8px;background:var(--dsw-alias-bg-layer-3)}
|
|
263
|
+
.dsn-importrow{display:flex;align-items:baseline;gap:8px;font-size:12px;padding:3px 0}
|
|
264
|
+
.dsn-importno{min-width:44px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}
|
|
265
|
+
.dsn-importfile{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
266
|
+
.dsn-importarrow{color:var(--dsw-alias-label-secondary)}
|
|
267
|
+
.dsn-importtitle{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary)}
|
|
268
|
+
.dsn-importchars{color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}
|
|
269
|
+
.dsn-importwarn{color:var(--dsw-alias-state-warn-primary)}
|
|
259
270
|
.dsn-progress{font-size:12px;color:var(--dsw-alias-label-secondary);display:flex;align-items:center;gap:8px}
|
|
260
271
|
.dsn-spin{width:12px;height:12px;border-radius:50%;border:2px solid var(--dsw-alias-border-l2);
|
|
261
272
|
border-top-color:var(--dsw-alias-brand-primary);animation:dsn-spin .9s linear infinite}
|
|
@@ -432,6 +443,224 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
432
443
|
}, busy ? t('保存中…', "Saving…") : t('保存', "Save")))));
|
|
433
444
|
}
|
|
434
445
|
|
|
446
|
+
/**
|
|
447
|
+
* Import chapters from a folder of `.md` files.
|
|
448
|
+
*
|
|
449
|
+
* Three steps in one dialog: choose the folder and how to line the files up
|
|
450
|
+
* with the work's chapters, look at exactly which files will become which
|
|
451
|
+
* chapters, then import. The preview exists because the matcher stops at the
|
|
452
|
+
* first gap, so guessing wrong would silently drop chapters.
|
|
453
|
+
*/
|
|
454
|
+
function ImportDialog({ project, resolveWorkspace, onCancel, onDone }) {
|
|
455
|
+
const [phase, setPhase] = useState('form');
|
|
456
|
+
const [dir, setDir] = useState('');
|
|
457
|
+
const [from, setFrom] = useState('next');
|
|
458
|
+
const [durationDays, setDurationDays] = useState(project.timeline ? (project.timeline.unitDays || 30) : 30);
|
|
459
|
+
const [syncOutline, setSyncOutline] = useState(true);
|
|
460
|
+
const [busy, setBusy] = useState(false);
|
|
461
|
+
const [error, setError] = useState('');
|
|
462
|
+
const [scan, setScan] = useState(null);
|
|
463
|
+
const [job, setJob] = useJob();
|
|
464
|
+
|
|
465
|
+
const pickDirectory = useCallback(async () => {
|
|
466
|
+
const ui = typeof resolveWorkspace === 'function' ? resolveWorkspace() : null;
|
|
467
|
+
if (!ui || typeof ui.pickDirectory !== 'function') {
|
|
468
|
+
setError(t('目录选择器还没就绪,请稍后再点一次「选择目录」', 'The directory picker is not ready yet — try again in a moment.'));
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
const picked = await ui.pickDirectory();
|
|
473
|
+
if (picked) setDir(picked);
|
|
474
|
+
} catch (pickError) {
|
|
475
|
+
setError(messageOf(pickError));
|
|
476
|
+
}
|
|
477
|
+
}, [resolveWorkspace]);
|
|
478
|
+
|
|
479
|
+
const runScan = useCallback(async () => {
|
|
480
|
+
if (dir.trim() === '') {
|
|
481
|
+
setError(t('请先选择要导入的章节目录', 'Choose the folder to import from first.'));
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
setBusy(true);
|
|
485
|
+
setError('');
|
|
486
|
+
try {
|
|
487
|
+
const result = await api('/import/scan', { projectId: project.projectId, dir, from });
|
|
488
|
+
setScan(result);
|
|
489
|
+
setPhase('preview');
|
|
490
|
+
} catch (scanError) {
|
|
491
|
+
setError(messageOf(scanError));
|
|
492
|
+
} finally {
|
|
493
|
+
setBusy(false);
|
|
494
|
+
}
|
|
495
|
+
}, [dir, from, project.projectId]);
|
|
496
|
+
|
|
497
|
+
const startImport = useCallback(async () => {
|
|
498
|
+
const pending = (scan ? scan.entries : []).filter((entry) => !entry.skip);
|
|
499
|
+
if (pending.length === 0) {
|
|
500
|
+
setError(t('没有可导入的章节(都已被作品里的章节占用)', 'Nothing to import — those chapter numbers are already taken.'));
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
setError('');
|
|
504
|
+
try {
|
|
505
|
+
const started = await api('/import/apply', {
|
|
506
|
+
projectId: project.projectId,
|
|
507
|
+
dir: scan.dir,
|
|
508
|
+
files: pending.map((entry) => entry.fileName),
|
|
509
|
+
durationDays: Number(durationDays) || 1,
|
|
510
|
+
syncOutline: Boolean(syncOutline),
|
|
511
|
+
});
|
|
512
|
+
setJob(started);
|
|
513
|
+
setPhase('running');
|
|
514
|
+
} catch (importError) {
|
|
515
|
+
setError(messageOf(importError));
|
|
516
|
+
}
|
|
517
|
+
}, [scan, durationDays, syncOutline, project.projectId, setJob]);
|
|
518
|
+
|
|
519
|
+
useEffect(() => {
|
|
520
|
+
if (!job || job.state === 'running') return;
|
|
521
|
+
if (job.state === 'done') onDone(job.result || {});
|
|
522
|
+
else if (job.state === 'error') setError(job.error || t('导入失败', 'Import failed'));
|
|
523
|
+
}, [job ? job.jobId : null, job ? job.state : null]);
|
|
524
|
+
|
|
525
|
+
const entries = scan ? scan.entries : [];
|
|
526
|
+
const willImport = entries.filter((entry) => !entry.skip);
|
|
527
|
+
const taken = entries.filter((entry) => entry.skip);
|
|
528
|
+
|
|
529
|
+
const head = h('div', { className: 'dsn-dialog-head' },
|
|
530
|
+
h('div', { className: 'dsn-title' }, phase === 'form'
|
|
531
|
+
? t('导入章节 · 选择来源', 'Import chapters · source')
|
|
532
|
+
: t('导入章节 · 匹配结果', 'Import chapters · match')),
|
|
533
|
+
h('div', { className: 'dsn-sub' }, project.title),
|
|
534
|
+
h('div', { className: 'dsn-spacer' }));
|
|
535
|
+
|
|
536
|
+
if (phase === 'form') {
|
|
537
|
+
return h('div', { className: 'dsn-dialog-backdrop' },
|
|
538
|
+
h('div', { className: 'dsn-dialog', style: { width: 'min(620px, calc(100% - 48px))' } },
|
|
539
|
+
head,
|
|
540
|
+
h('div', { className: 'dsn-dialog-body' },
|
|
541
|
+
h('label', { className: 'dsn-field span4' },
|
|
542
|
+
h('span', { className: 'dsn-label' }, t('待导入章节目录(必填)', 'Folder to import from (required)')),
|
|
543
|
+
h('div', { style: { display: 'flex', gap: 8 } },
|
|
544
|
+
h('input', {
|
|
545
|
+
className: 'dsn-input',
|
|
546
|
+
value: dir,
|
|
547
|
+
placeholder: t('例如:D:\\小说\\第二部', 'e.g. D:\\novel\\part-two'),
|
|
548
|
+
onChange: (event) => setDir(event.target.value),
|
|
549
|
+
}),
|
|
550
|
+
h('button', { className: 'dsn-btn ghost', type: 'button', onClick: pickDirectory }, t('选择目录', 'Browse')))),
|
|
551
|
+
h('div', { className: 'dsn-hint span4' }, t('文件名要按「第N章 标题.md」命名(N 可以是 1、007、十二)。插件从你选定的起点按 1、2、3… 顺序配对,配对到第一个缺号为止,后面的文件不会被导入。', 'Files must be named like “第N章 Title.md” (N may be 1, 007 or 十二). Matching walks 1, 2, 3… from the chosen start and stops at the first missing number; anything after that gap is left out.')),
|
|
552
|
+
h('div', { className: 'dsn-field span2' },
|
|
553
|
+
h('span', { className: 'dsn-label' }, t('匹配起点', 'Where to start matching')),
|
|
554
|
+
h('label', { className: 'dsn-check' },
|
|
555
|
+
h('input', {
|
|
556
|
+
type: 'radio',
|
|
557
|
+
name: 'dsn-import-from',
|
|
558
|
+
checked: from === 'first',
|
|
559
|
+
onChange: () => setFrom('first'),
|
|
560
|
+
}),
|
|
561
|
+
h('span', null, t('从第 1 章开始(已存在的章号会跳过)', 'From chapter 1 (numbers already taken are skipped)'))),
|
|
562
|
+
h('label', { className: 'dsn-check' },
|
|
563
|
+
h('input', {
|
|
564
|
+
type: 'radio',
|
|
565
|
+
name: 'dsn-import-from',
|
|
566
|
+
checked: from === 'next',
|
|
567
|
+
onChange: () => setFrom('next'),
|
|
568
|
+
}),
|
|
569
|
+
h('span', null, t('从作品的下一章开始', 'From the chapter after this work’s last one')))),
|
|
570
|
+
h('label', { className: 'dsn-field span2' },
|
|
571
|
+
h('span', { className: 'dsn-label' }, t('每章故事时间长度(天)', 'Story days per chapter')),
|
|
572
|
+
h('input', {
|
|
573
|
+
className: 'dsn-input',
|
|
574
|
+
type: 'number',
|
|
575
|
+
min: 1,
|
|
576
|
+
value: durationDays,
|
|
577
|
+
onChange: (event) => setDurationDays(Number(event.target.value)),
|
|
578
|
+
})),
|
|
579
|
+
h('label', { className: 'dsn-check span4' },
|
|
580
|
+
h('input', {
|
|
581
|
+
type: 'checkbox',
|
|
582
|
+
checked: syncOutline,
|
|
583
|
+
onChange: (event) => setSyncOutline(event.target.checked),
|
|
584
|
+
}),
|
|
585
|
+
h('span', null, t('导入后自动同步章节大纲(每章调用一次模型,生成梗概与字数范围)', 'Sync each outline after import (one model call per chapter, for the synopsis and word bounds)'))),
|
|
586
|
+
syncOutline ? null : h('div', { className: 'dsn-hint warn span4' }, t('不勾选时导入的章节没有梗概:后续生成正文时它们不会出现在「已发生的章节梗概」里,可以之后逐章用「同步章节大纲」补。', 'Without it the imported chapters have no synopsis, so later generations will not see them in “what already happened”. You can sync each one later.')),
|
|
587
|
+
error ? h('div', { className: 'dsn-hint warn span4' }, error) : null),
|
|
588
|
+
h('div', { className: 'dsn-dialog-foot' },
|
|
589
|
+
h('button', { className: 'dsn-btn ghost', type: 'button', disabled: busy, onClick: onCancel }, t('取消', 'Cancel')),
|
|
590
|
+
h('div', { className: 'dsn-spacer' }),
|
|
591
|
+
h('button', { className: 'dsn-btn primary', type: 'button', disabled: busy, onClick: runScan },
|
|
592
|
+
busy ? t('匹配中…', 'Matching…') : t('确定', 'Next')))));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (phase === 'preview') {
|
|
596
|
+
return h('div', { className: 'dsn-dialog-backdrop' },
|
|
597
|
+
h('div', { className: 'dsn-dialog', style: { width: 'min(720px, calc(100% - 48px))' } },
|
|
598
|
+
head,
|
|
599
|
+
h('div', { className: 'dsn-dialog-body' },
|
|
600
|
+
h('div', { className: 'dsn-hint span4' }, t('来源:{dir} · 起点:第 {start} 章 · 将导入 {count} 章 · 每章 {days} 天{extra}',
|
|
601
|
+
'From {dir} · starts at chapter {start} · {count} chapters · {days} days each{extra}', {
|
|
602
|
+
dir: scan.dir,
|
|
603
|
+
start: scan.startNumber,
|
|
604
|
+
count: willImport.length,
|
|
605
|
+
days: durationDays,
|
|
606
|
+
extra: taken.length ? t('(跳过 {n} 章)', ' ({n} skipped)', { n: taken.length }) : '',
|
|
607
|
+
})),
|
|
608
|
+
willImport.length === 0
|
|
609
|
+
? h('div', { className: 'dsn-hint warn span4' }, t('没有可导入的章节。', 'Nothing to import.'))
|
|
610
|
+
: h('div', { className: 'dsn-importlist span4' },
|
|
611
|
+
willImport.map((entry) => h('div', { key: entry.fileName, className: 'dsn-importrow' },
|
|
612
|
+
h('span', { className: 'dsn-importno' }, `#${entry.number}`),
|
|
613
|
+
h('span', { className: 'dsn-importfile' }, entry.fileName),
|
|
614
|
+
h('span', { className: 'dsn-importarrow' }, '→'),
|
|
615
|
+
h('span', { className: 'dsn-importtitle' }, t('第 {n} 章 {title}', 'Chapter {n} · {title}', { n: entry.number, title: entry.title || t('未命名', 'Untitled') })),
|
|
616
|
+
h('span', { className: 'dsn-importchars' }, t('{n} 字', '{n} chars', { n: entry.chars })),
|
|
617
|
+
entry.garbled ? h('span', { className: 'dsn-importwarn' }, t('疑似乱码', 'encoding?') ) : null))),
|
|
618
|
+
taken.length > 0
|
|
619
|
+
? h('div', { className: 'dsn-hint warn span4' }, t('这些章号已被作品占用,会跳过:{list}', 'These numbers are already taken and will be skipped: {list}', { list: taken.map((entry) => `第${entry.number}章`).join('、') }))
|
|
620
|
+
: null,
|
|
621
|
+
scan.leftover.length > 0
|
|
622
|
+
? h('div', { className: 'dsn-hint span4' }, t('没连上、不会导入:{list}', 'Unmatched, not imported: {list}', { list: scan.leftover.join('、') }))
|
|
623
|
+
: null,
|
|
624
|
+
scan.unrecognized.length > 0
|
|
625
|
+
? h('div', { className: 'dsn-hint span4' }, t('文件名认不出章号:{list}', 'No chapter number in the name: {list}', { list: scan.unrecognized.join('、') }))
|
|
626
|
+
: null,
|
|
627
|
+
error ? h('div', { className: 'dsn-hint warn span4' }, error) : null),
|
|
628
|
+
h('div', { className: 'dsn-dialog-foot' },
|
|
629
|
+
h('button', { className: 'dsn-btn ghost', type: 'button', onClick: () => { setPhase('form'); setError(''); } }, t('返回修改', 'Back')),
|
|
630
|
+
h('div', { className: 'dsn-spacer' }),
|
|
631
|
+
h('button', { className: 'dsn-btn', type: 'button', onClick: onCancel }, t('取消', 'Cancel')),
|
|
632
|
+
h('button', {
|
|
633
|
+
className: 'dsn-btn primary',
|
|
634
|
+
type: 'button',
|
|
635
|
+
disabled: willImport.length === 0,
|
|
636
|
+
onClick: startImport,
|
|
637
|
+
}, t('确认导入 {n} 章', 'Import {n} chapters', { n: willImport.length })))));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return h('div', { className: 'dsn-dialog-backdrop' },
|
|
641
|
+
h('div', { className: 'dsn-dialog', style: { width: 'min(560px, calc(100% - 48px))' } },
|
|
642
|
+
head,
|
|
643
|
+
h('div', { className: 'dsn-dialog-body' },
|
|
644
|
+
h('div', { className: 'dsn-progress span4' }, h('span', { className: 'dsn-spin' }), job ? (job.message || t('正在导入…', 'Importing…')) : t('正在导入…', 'Importing…')),
|
|
645
|
+
error ? h('div', { className: 'dsn-hint warn span4' }, error) : null),
|
|
646
|
+
h('div', { className: 'dsn-dialog-foot' },
|
|
647
|
+
h('div', { className: 'dsn-spacer' }),
|
|
648
|
+
h('button', {
|
|
649
|
+
className: 'dsn-btn',
|
|
650
|
+
type: 'button',
|
|
651
|
+
onClick: async () => {
|
|
652
|
+
if (job) {
|
|
653
|
+
try {
|
|
654
|
+
await api('/job/cancel', { jobId: job.jobId });
|
|
655
|
+
} catch (stopError) {
|
|
656
|
+
setError(messageOf(stopError));
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
onCancel();
|
|
660
|
+
},
|
|
661
|
+
}, t('停止', 'Stop')))));
|
|
662
|
+
}
|
|
663
|
+
|
|
435
664
|
function PresetDialog({ project, presets, onCancel, onSaved }) {
|
|
436
665
|
const [preset, setPreset] = useState(project.agentPreset || '');
|
|
437
666
|
const [busy, setBusy] = useState(false);
|
|
@@ -555,7 +784,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
555
784
|
}, busy ? t('保存中…', 'Saving…') : t('保存', 'Save')))));
|
|
556
785
|
}
|
|
557
786
|
|
|
558
|
-
function ProjectsPage({ boot, resolveWorkspace, onOpen, onReload, onEditModel, onEditPreset, onEditPrelude }) {
|
|
787
|
+
function ProjectsPage({ boot, resolveWorkspace, onOpen, onReload, onEditModel, onEditPreset, onEditPrelude, onImport }) {
|
|
559
788
|
const [title, setTitle] = useState('');
|
|
560
789
|
const [cwd, setCwd] = useState('');
|
|
561
790
|
const [preset, setPreset] = useState('');
|
|
@@ -809,6 +1038,15 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
809
1038
|
onEditPrelude(project);
|
|
810
1039
|
},
|
|
811
1040
|
}, t('前置', 'Preface')),
|
|
1041
|
+
h('button', {
|
|
1042
|
+
className: 'dsn-btn ghost',
|
|
1043
|
+
type: 'button',
|
|
1044
|
+
title: t('从某个目录按「第N章 标题.md」的顺序导入章节', 'Import chapters from a folder of “第N章 Title.md” files'),
|
|
1045
|
+
onClick: (event) => {
|
|
1046
|
+
event.stopPropagation();
|
|
1047
|
+
onImport(project);
|
|
1048
|
+
},
|
|
1049
|
+
}, t('导入章节', 'Import')),
|
|
812
1050
|
h('button', {
|
|
813
1051
|
className: 'dsn-btn ghost',
|
|
814
1052
|
type: 'button',
|
|
@@ -2415,6 +2653,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
2415
2653
|
const [modelTarget, setModelTarget] = useState(null);
|
|
2416
2654
|
const [presetTarget, setPresetTarget] = useState(null);
|
|
2417
2655
|
const [preludeTarget, setPreludeTarget] = useState(null);
|
|
2656
|
+
const [importTarget, setImportTarget] = useState(null);
|
|
2418
2657
|
const leaveGuard = useRef(null);
|
|
2419
2658
|
const registerLeaveGuard = useCallback((guard) => {
|
|
2420
2659
|
leaveGuard.current = guard;
|
|
@@ -2508,6 +2747,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
2508
2747
|
onEditModel: setModelTarget,
|
|
2509
2748
|
onEditPreset: setPresetTarget,
|
|
2510
2749
|
onEditPrelude: setPreludeTarget,
|
|
2750
|
+
onImport: setImportTarget,
|
|
2511
2751
|
}))
|
|
2512
2752
|
: detail === null
|
|
2513
2753
|
? h('div', { className: 'dsn-empty', style: { margin: 'auto' } }, t('正在载入作品…', "Loading the work…"))
|
|
@@ -2547,6 +2787,30 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
2547
2787
|
},
|
|
2548
2788
|
})
|
|
2549
2789
|
: null,
|
|
2790
|
+
importTarget
|
|
2791
|
+
? h(ImportDialog, {
|
|
2792
|
+
project: importTarget,
|
|
2793
|
+
resolveWorkspace,
|
|
2794
|
+
onCancel: () => setImportTarget(null),
|
|
2795
|
+
onDone: async (result) => {
|
|
2796
|
+
setImportTarget(null);
|
|
2797
|
+
await loadBoot();
|
|
2798
|
+
const imported = (result.imported || []).length;
|
|
2799
|
+
const skipped = (result.skipped || []).length;
|
|
2800
|
+
const failed = (result.failed || []).length;
|
|
2801
|
+
const synced = (result.synced || []).length;
|
|
2802
|
+
const parts = [t('已导入 {n} 章', 'Imported {n} chapters', { n: imported })];
|
|
2803
|
+
if (synced > 0) parts.push(t('同步大纲 {n} 章', 'synced {n} outlines', { n: synced }));
|
|
2804
|
+
if (skipped > 0) parts.push(t('跳过 {n} 章(章号已被占用)', 'skipped {n} taken', { n: skipped }));
|
|
2805
|
+
if (failed > 0) parts.push(t('失败 {n} 个:{list}', '{n} failed: {list}', {
|
|
2806
|
+
n: failed,
|
|
2807
|
+
list: (result.failed || []).map((entry) => `${entry.fileName}(${entry.reason})`).join('、'),
|
|
2808
|
+
}));
|
|
2809
|
+
if (result.snapshotJobId) parts.push(t('世界快照已开始生成', 'World snapshot started'));
|
|
2810
|
+
setToast({ text: parts.join(';'), kind: failed > 0 ? 'error' : 'success' });
|
|
2811
|
+
},
|
|
2812
|
+
})
|
|
2813
|
+
: null,
|
|
2550
2814
|
preludeTarget
|
|
2551
2815
|
? h(PreludeDialog, {
|
|
2552
2816
|
project: preludeTarget,
|
package/index.js
CHANGED
|
@@ -1499,7 +1499,7 @@ async function bootstrap(ctx, root) {
|
|
|
1499
1499
|
}
|
|
1500
1500
|
}
|
|
1501
1501
|
return {
|
|
1502
|
-
revision: '2026-10-28.
|
|
1502
|
+
revision: '2026-10-28.9',
|
|
1503
1503
|
projects,
|
|
1504
1504
|
models: {
|
|
1505
1505
|
default: modelCatalog.default || null,
|
|
@@ -2088,90 +2088,99 @@ async function startSyncOutline(ctx, root, registry, body) {
|
|
|
2088
2088
|
throw httpError(404, `章节不存在: ${chapterId}`);
|
|
2089
2089
|
}
|
|
2090
2090
|
return startJob(registry, { kind: 'sync-outline', projectId, chapterId }, async (job) => {
|
|
2091
|
-
const project = await readProject(root, projectId);
|
|
2092
|
-
const chapter = project.chapters.find((entry) => entry.chapterId === chapterId);
|
|
2093
|
-
if (!chapter) throw httpError(404, `章节不存在: ${chapterId}`);
|
|
2094
2091
|
job.message = '正在读正文同步章节大纲';
|
|
2095
|
-
|
|
2092
|
+
return syncChapterOutline(ctx, root, projectId, chapterId, prose, (note) => {
|
|
2096
2093
|
job.message = note;
|
|
2097
2094
|
});
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
// A reply without usable JSON still yields a synopsis: the model's prose is
|
|
2101
|
-
// the summary, and the numbers fall back to what the chapter actually is.
|
|
2102
|
-
const synopsis = cleanSynopsis(
|
|
2103
|
-
typeof parsed.synopsis === 'string' && parsed.synopsis.trim() !== '' ? parsed.synopsis : raw,
|
|
2104
|
-
);
|
|
2105
|
-
if (synopsis === '') throw httpError(502, '模型没有给出梗概');
|
|
2095
|
+
});
|
|
2096
|
+
}
|
|
2106
2097
|
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2098
|
+
/**
|
|
2099
|
+
* The shared body of "measure this chapter from its prose and write the result
|
|
2100
|
+
* back into its outline". Used by the story page's button and by chapter import,
|
|
2101
|
+
* so both produce identical outlines.
|
|
2102
|
+
*/
|
|
2103
|
+
async function syncChapterOutline(ctx, root, projectId, chapterId, prose, note) {
|
|
2104
|
+
const project = await readProject(root, projectId);
|
|
2105
|
+
const chapter = project.chapters.find((entry) => entry.chapterId === chapterId);
|
|
2106
|
+
if (!chapter) throw httpError(404, `章节不存在: ${chapterId}`);
|
|
2107
|
+
const raw = await askAgent(ctx, root, project, syncOutlinePrompt(project, chapter, prose), note);
|
|
2108
|
+
const parsed = parseJsonReply(raw) || {};
|
|
2109
|
+
const chars = wordCount(prose);
|
|
2110
|
+
// A reply without usable JSON still yields a synopsis: the model's prose is
|
|
2111
|
+
// the summary, and the numbers fall back to what the chapter actually is.
|
|
2112
|
+
const synopsis = cleanSynopsis(
|
|
2113
|
+
typeof parsed.synopsis === 'string' && parsed.synopsis.trim() !== '' ? parsed.synopsis : raw,
|
|
2114
|
+
);
|
|
2115
|
+
if (synopsis === '') throw httpError(502, '模型没有给出梗概');
|
|
2116
|
+
|
|
2117
|
+
const previous = chapter.outline || {};
|
|
2118
|
+
const span = chapterWindow(project, chapter);
|
|
2119
|
+
const notes = [];
|
|
2120
|
+
|
|
2121
|
+
// Word bounds must contain what was actually written, whatever the model said.
|
|
2122
|
+
const floorHundred = (value) => Math.max(1, Math.floor(value / 100) * 100);
|
|
2123
|
+
const ceilHundred = (value) => Math.max(200, Math.ceil(value / 100) * 100);
|
|
2124
|
+
let minWords = Number.isFinite(Number(parsed.minWords)) ? Math.max(1, Math.round(Number(parsed.minWords))) : 0;
|
|
2125
|
+
let maxWords = Number.isFinite(Number(parsed.maxWords)) ? Math.max(1, Math.round(Number(parsed.maxWords))) : 0;
|
|
2126
|
+
if (minWords <= 0 || minWords > chars) minWords = floorHundred(chars * 0.9);
|
|
2127
|
+
if (maxWords <= 0 || maxWords < chars) maxWords = ceilHundred(chars * 1.1);
|
|
2128
|
+
if (maxWords < minWords) maxWords = minWords + 100;
|
|
2129
|
+
|
|
2130
|
+
// The synopsis floor has to stay reachable by the synopsis it was measured on.
|
|
2131
|
+
let synopsisMinChars = Number.isFinite(Number(parsed.synopsisMinChars))
|
|
2132
|
+
? Math.round(Number(parsed.synopsisMinChars))
|
|
2133
|
+
: Math.max(20, Math.floor(synopsis.length * 0.8));
|
|
2134
|
+
synopsisMinChars = Math.max(20, Math.min(400, synopsisMinChars));
|
|
2135
|
+
synopsisMinChars = Math.max(1, Math.min(synopsisMinChars, synopsis.length));
|
|
2136
|
+
|
|
2137
|
+
// A new duration only lands when it keeps the next chapter where it is.
|
|
2138
|
+
let durationDays = chapter.durationDays;
|
|
2139
|
+
const proposed = Number.isFinite(Number(parsed.durationDays))
|
|
2140
|
+
? Math.max(1, Math.min(365, Math.round(Number(parsed.durationDays))))
|
|
2141
|
+
: null;
|
|
2142
|
+
if (proposed !== null && proposed !== chapter.durationDays) {
|
|
2143
|
+
const following = (project.chapters || [])
|
|
2144
|
+
.filter((entry) => entry.chapterId !== chapter.chapterId && entry.startDay >= span.start)
|
|
2145
|
+
.sort((a, b) => a.startDay - b.startDay)[0] || null;
|
|
2146
|
+
if (following === null) {
|
|
2147
|
+
durationDays = proposed;
|
|
2148
|
+
} else if (following.startDay === span.start + proposed) {
|
|
2149
|
+
durationDays = proposed;
|
|
2150
|
+
} else {
|
|
2151
|
+
notes.push(`下一章从第 ${following.startDay} 天开始,改时长会错位,时间长度保持 ${chapter.durationDays} 天(模型建议 ${proposed} 天)`);
|
|
2143
2152
|
}
|
|
2153
|
+
}
|
|
2144
2154
|
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
synopsis,
|
|
2151
|
-
minWords,
|
|
2152
|
-
maxWords,
|
|
2153
|
-
durationDays,
|
|
2154
|
-
synopsisMinChars,
|
|
2155
|
-
savedAt: Date.now(),
|
|
2156
|
-
};
|
|
2157
|
-
target.durationDays = durationDays;
|
|
2158
|
-
target.updatedAt = Date.now();
|
|
2159
|
-
return { chapter: { ...target, content: undefined }, project: summarize(draft) };
|
|
2160
|
-
});
|
|
2161
|
-
const fresh = await readProject(root, projectId);
|
|
2162
|
-
await exportChapterOutline(fresh, saved.chapter);
|
|
2163
|
-
await exportProjectInfo(fresh);
|
|
2164
|
-
return {
|
|
2155
|
+
const saved = await mutateProject(root, projectId, (draft) => {
|
|
2156
|
+
const target = draft.chapters.find((entry) => entry.chapterId === chapterId);
|
|
2157
|
+
if (!target) throw httpError(404, `章节不存在: ${chapterId}`);
|
|
2158
|
+
target.outline = {
|
|
2159
|
+
...previous,
|
|
2165
2160
|
synopsis,
|
|
2166
2161
|
minWords,
|
|
2167
2162
|
maxWords,
|
|
2168
2163
|
durationDays,
|
|
2169
2164
|
synopsisMinChars,
|
|
2170
|
-
|
|
2171
|
-
notes,
|
|
2172
|
-
chapter: saved.chapter,
|
|
2165
|
+
savedAt: Date.now(),
|
|
2173
2166
|
};
|
|
2167
|
+
target.durationDays = durationDays;
|
|
2168
|
+
target.updatedAt = Date.now();
|
|
2169
|
+
return { chapter: { ...target, content: undefined }, project: summarize(draft) };
|
|
2174
2170
|
});
|
|
2171
|
+
const fresh = await readProject(root, projectId);
|
|
2172
|
+
await exportChapterOutline(fresh, saved.chapter);
|
|
2173
|
+
await exportProjectInfo(fresh);
|
|
2174
|
+
return {
|
|
2175
|
+
synopsis,
|
|
2176
|
+
minWords,
|
|
2177
|
+
maxWords,
|
|
2178
|
+
durationDays,
|
|
2179
|
+
synopsisMinChars,
|
|
2180
|
+
chars,
|
|
2181
|
+
notes,
|
|
2182
|
+
chapter: saved.chapter,
|
|
2183
|
+
};
|
|
2175
2184
|
}
|
|
2176
2185
|
|
|
2177
2186
|
/** Rewrite one chapter's synopsis from a note, without saving it. */
|
|
@@ -2198,6 +2207,245 @@ async function startSynopsisRevise(ctx, root, registry, body) {
|
|
|
2198
2207
|
});
|
|
2199
2208
|
}
|
|
2200
2209
|
|
|
2210
|
+
/* ------------------------------------------------------- chapter importing */
|
|
2211
|
+
|
|
2212
|
+
/** Chinese numerals as used in chapter headings, e.g. 第十二章. */
|
|
2213
|
+
const CHINESE_DIGITS = { 零: 0, 〇: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9 };
|
|
2214
|
+
const CHINESE_UNITS = { 十: 10, 百: 100, 千: 1000 };
|
|
2215
|
+
|
|
2216
|
+
/** Turn `十二` / `一百零三` / `12` into a number, or null. */
|
|
2217
|
+
function parseChapterNumber(text) {
|
|
2218
|
+
const raw = String(text || '').trim();
|
|
2219
|
+
if (raw === '') return null;
|
|
2220
|
+
if (/^\d+$/u.test(raw)) {
|
|
2221
|
+
const value = Number(raw);
|
|
2222
|
+
return Number.isSafeInteger(value) && value > 0 ? value : null;
|
|
2223
|
+
}
|
|
2224
|
+
if (!/^[零〇一二两三四五六七八九十百千]+$/u.test(raw)) return null;
|
|
2225
|
+
let total = 0;
|
|
2226
|
+
let section = 0;
|
|
2227
|
+
let digit = 0;
|
|
2228
|
+
for (const char of raw) {
|
|
2229
|
+
if (char in CHINESE_DIGITS) {
|
|
2230
|
+
digit = CHINESE_DIGITS[char];
|
|
2231
|
+
continue;
|
|
2232
|
+
}
|
|
2233
|
+
const unit = CHINESE_UNITS[char];
|
|
2234
|
+
if (unit === undefined) continue;
|
|
2235
|
+
if (digit === 0 && unit === 10) digit = 1;
|
|
2236
|
+
section += digit * unit;
|
|
2237
|
+
digit = 0;
|
|
2238
|
+
}
|
|
2239
|
+
total = section + digit;
|
|
2240
|
+
return total > 0 && total < 100000 ? total : null;
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
/**
|
|
2244
|
+
* Chapter number and title from a file name: `第12章 雨夜.md`, `第007章雨夜.md`
|
|
2245
|
+
* and `第十二章 雨夜.md` all yield a number, and the title is whatever follows
|
|
2246
|
+
* the suffix (`章` / `回` / `节`) or trailing separators.
|
|
2247
|
+
*/
|
|
2248
|
+
function parseChapterFileName(fileName) {
|
|
2249
|
+
// The extension goes first: `第3章.md` would otherwise leave `md` as the title.
|
|
2250
|
+
const stem = String(fileName || '').replace(/\.(md|markdown|txt)$/iu, '');
|
|
2251
|
+
const match = stem.match(/^第\s*([0-9零〇一二两三四五六七八九十百千]+)\s*[章回节][\s._\-—::]*(.*)$/u);
|
|
2252
|
+
if (!match) return null;
|
|
2253
|
+
const number = parseChapterNumber(match[1]);
|
|
2254
|
+
if (number === null) return null;
|
|
2255
|
+
return { number, title: String(match[2] || '').trim() };
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
/** Read a directory of chapter files and match them from the chosen start. */
|
|
2259
|
+
async function scanImportChapters(root, body) {
|
|
2260
|
+
const projectId = asText(body.projectId);
|
|
2261
|
+
const project = await readProject(root, projectId);
|
|
2262
|
+
const dir = asText(body.dir);
|
|
2263
|
+
if (dir === '') throw httpError(400, '请先选择要导入的章节目录');
|
|
2264
|
+
const mode = asText(body.from) === 'next' ? 'next' : 'first';
|
|
2265
|
+
let entries = [];
|
|
2266
|
+
try {
|
|
2267
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
2268
|
+
} catch (error) {
|
|
2269
|
+
throw httpError(400, `无法读取目录 ${dir}:${error instanceof Error ? error.message : String(error)}`);
|
|
2270
|
+
}
|
|
2271
|
+
const files = entries.filter((entry) => entry.isFile() && /\.(md|markdown|txt)$/iu.test(entry.name));
|
|
2272
|
+
if (files.length === 0) throw httpError(400, '这个目录里没有 .md 章节文件');
|
|
2273
|
+
|
|
2274
|
+
const byNumber = new Map();
|
|
2275
|
+
const unrecognized = [];
|
|
2276
|
+
for (const file of files) {
|
|
2277
|
+
const parsed = parseChapterFileName(file.name);
|
|
2278
|
+
if (!parsed) {
|
|
2279
|
+
unrecognized.push(file.name);
|
|
2280
|
+
continue;
|
|
2281
|
+
}
|
|
2282
|
+
if (!byNumber.has(parsed.number)) byNumber.set(parsed.number, { ...parsed, fileName: file.name });
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
const existingNumbers = new Set(
|
|
2286
|
+
(project.chapters || []).map((chapter) => (Number(chapter.index) || 0) + 1),
|
|
2287
|
+
);
|
|
2288
|
+
const startNumber = mode === 'next'
|
|
2289
|
+
? Math.max(0, ...existingNumbers) + 1
|
|
2290
|
+
: 1;
|
|
2291
|
+
|
|
2292
|
+
const matched = [];
|
|
2293
|
+
for (let number = startNumber; byNumber.has(number); number += 1) {
|
|
2294
|
+
const entry = byNumber.get(number);
|
|
2295
|
+
let chars = 0;
|
|
2296
|
+
let garbled = false;
|
|
2297
|
+
try {
|
|
2298
|
+
const text = await readFile(join(dir, entry.fileName), 'utf8');
|
|
2299
|
+
chars = wordCount(text);
|
|
2300
|
+
garbled = text.includes('\uFFFD');
|
|
2301
|
+
} catch {
|
|
2302
|
+
// The preview still lists the file; the import reports a read failure.
|
|
2303
|
+
}
|
|
2304
|
+
matched.push({
|
|
2305
|
+
number,
|
|
2306
|
+
fileName: entry.fileName,
|
|
2307
|
+
title: entry.title,
|
|
2308
|
+
chars,
|
|
2309
|
+
garbled,
|
|
2310
|
+
skip: existingNumbers.has(number),
|
|
2311
|
+
});
|
|
2312
|
+
if (matched.length >= 500) break;
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
const matchedNumbers = new Set(matched.map((entry) => entry.number));
|
|
2316
|
+
const leftover = [...byNumber.values()]
|
|
2317
|
+
.filter((entry) => !matchedNumbers.has(entry.number))
|
|
2318
|
+
.sort((a, b) => a.number - b.number)
|
|
2319
|
+
.map((entry) => entry.fileName);
|
|
2320
|
+
|
|
2321
|
+
const notes = [];
|
|
2322
|
+
if (leftover.length > 0) notes.push(`另有 ${leftover.length} 个章节文件没有连上,不会导入`);
|
|
2323
|
+
if (unrecognized.length > 0) notes.push(`另有 ${unrecognized.length} 个文件名认不出章号,不会导入`);
|
|
2324
|
+
if (matched.length === 0) notes.push(`从第 ${startNumber} 章开始没有找到能连上的文件`);
|
|
2325
|
+
|
|
2326
|
+
return {
|
|
2327
|
+
dir,
|
|
2328
|
+
mode,
|
|
2329
|
+
startNumber,
|
|
2330
|
+
total: matched.length,
|
|
2331
|
+
entries: matched,
|
|
2332
|
+
leftover,
|
|
2333
|
+
unrecognized,
|
|
2334
|
+
notes,
|
|
2335
|
+
};
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
/**
|
|
2339
|
+
* Copy the confirmed files in as chapters and, when asked, sync each outline.
|
|
2340
|
+
*
|
|
2341
|
+
* The import is deliberately one chapter at a time: a failure names the file it
|
|
2342
|
+
* stopped on and leaves the chapters already written in place.
|
|
2343
|
+
*/
|
|
2344
|
+
async function startImportChapters(ctx, root, registry, body) {
|
|
2345
|
+
const projectId = asText(body.projectId);
|
|
2346
|
+
const dir = asText(body.dir);
|
|
2347
|
+
const files = Array.isArray(body.files) ? body.files.filter((name) => typeof name === 'string' && name !== '') : [];
|
|
2348
|
+
if (dir === '') throw httpError(400, '请先选择要导入的章节目录');
|
|
2349
|
+
if (files.length === 0) throw httpError(400, '没有选中要导入的章节文件');
|
|
2350
|
+
const durationDays = Math.max(1, Math.min(3650, Math.round(Number(body.durationDays) || 0) || 1));
|
|
2351
|
+
const syncOutline = body.syncOutline === true;
|
|
2352
|
+
const preview = await readProject(root, projectId);
|
|
2353
|
+
|
|
2354
|
+
return startJob(registry, { kind: 'import-chapters', projectId }, async (job) => {
|
|
2355
|
+
const imported = [];
|
|
2356
|
+
const skipped = [];
|
|
2357
|
+
const failed = [];
|
|
2358
|
+
for (let index = 0; index < files.length; index += 1) {
|
|
2359
|
+
const fileName = files[index];
|
|
2360
|
+
job.message = `正在导入 ${index + 1}/${files.length}:${fileName}`;
|
|
2361
|
+
const parsed = parseChapterFileName(fileName);
|
|
2362
|
+
if (!parsed) {
|
|
2363
|
+
failed.push({ fileName, reason: '文件名认不出章号' });
|
|
2364
|
+
continue;
|
|
2365
|
+
}
|
|
2366
|
+
let content;
|
|
2367
|
+
try {
|
|
2368
|
+
content = await readFile(join(dir, fileName), 'utf8');
|
|
2369
|
+
} catch (error) {
|
|
2370
|
+
failed.push({ fileName, reason: error instanceof Error ? error.message : String(error) });
|
|
2371
|
+
continue;
|
|
2372
|
+
}
|
|
2373
|
+
if (content.trim() === '') {
|
|
2374
|
+
failed.push({ fileName, reason: '文件是空的' });
|
|
2375
|
+
continue;
|
|
2376
|
+
}
|
|
2377
|
+
let created = null;
|
|
2378
|
+
const project = await readProject(root, projectId);
|
|
2379
|
+
const taken = (project.chapters || []).some((chapter) => (Number(chapter.index) || 0) + 1 === parsed.number);
|
|
2380
|
+
if (taken) {
|
|
2381
|
+
skipped.push({ fileName, number: parsed.number });
|
|
2382
|
+
continue;
|
|
2383
|
+
}
|
|
2384
|
+
await mutateProject(root, projectId, (draft) => {
|
|
2385
|
+
const chapter = {
|
|
2386
|
+
chapterId: randomUUID(),
|
|
2387
|
+
index: nextChapterIndex(draft),
|
|
2388
|
+
title: parsed.title !== '' ? parsed.title : `第 ${parsed.number} 章`,
|
|
2389
|
+
startDay: lastChapterEnd(draft),
|
|
2390
|
+
durationDays,
|
|
2391
|
+
outline: null,
|
|
2392
|
+
content,
|
|
2393
|
+
wordCount: wordCount(content),
|
|
2394
|
+
status: 'imported',
|
|
2395
|
+
createdAt: Date.now(),
|
|
2396
|
+
updatedAt: Date.now(),
|
|
2397
|
+
generatedAt: null,
|
|
2398
|
+
};
|
|
2399
|
+
draft.chapters.push(chapter);
|
|
2400
|
+
created = chapter;
|
|
2401
|
+
return chapter;
|
|
2402
|
+
});
|
|
2403
|
+
const fresh = await readProject(root, projectId);
|
|
2404
|
+
const saved = fresh.chapters.find((chapter) => chapter.chapterId === created.chapterId) || created;
|
|
2405
|
+
await exportChapter(fresh, saved, content);
|
|
2406
|
+
await exportChapterOutline(fresh, saved);
|
|
2407
|
+
await exportProjectInfo(fresh);
|
|
2408
|
+
imported.push({ chapterId: saved.chapterId, number: parsed.number, title: saved.title, chars: wordCount(content) });
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
const synced = [];
|
|
2412
|
+
if (syncOutline && imported.length > 0) {
|
|
2413
|
+
for (let index = 0; index < imported.length; index += 1) {
|
|
2414
|
+
const entry = imported[index];
|
|
2415
|
+
job.message = `正在同步章节大纲 ${index + 1}/${imported.length}:第 ${entry.number} 章`;
|
|
2416
|
+
try {
|
|
2417
|
+
const project = await readProject(root, projectId);
|
|
2418
|
+
const chapter = project.chapters.find((item) => item.chapterId === entry.chapterId);
|
|
2419
|
+
const result = await syncChapterOutline(ctx, root, projectId, entry.chapterId, chapter ? (chapter.content || '') : '', (note) => {
|
|
2420
|
+
job.message = note;
|
|
2421
|
+
});
|
|
2422
|
+
synced.push({ chapterId: entry.chapterId, synopsisChars: (result.synopsis || '').length });
|
|
2423
|
+
} catch (error) {
|
|
2424
|
+
failed.push({ fileName: entry.title, reason: `同步大纲失败:${error instanceof Error ? error.message : String(error)}` });
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
// Imported chapters advance story time like any other, so the grid points
|
|
2430
|
+
// they cross get their snapshots.
|
|
2431
|
+
let snapshotJobId = null;
|
|
2432
|
+
if (imported.length > 0) {
|
|
2433
|
+
const after = await readProject(root, projectId);
|
|
2434
|
+
const target = autoSnapshotTarget(after, lastChapterEnd(after));
|
|
2435
|
+
if (target !== null) snapshotJobId = registerSnapshot(ctx, root, registry, projectId, target).jobId;
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
return {
|
|
2439
|
+
imported,
|
|
2440
|
+
skipped,
|
|
2441
|
+
failed,
|
|
2442
|
+
synced,
|
|
2443
|
+
snapshotJobId,
|
|
2444
|
+
project: summarize(await readProject(root, projectId)),
|
|
2445
|
+
};
|
|
2446
|
+
});
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2201
2449
|
async function updateTimeline(root, body) {
|
|
2202
2450
|
const projectId = asText(body.projectId);
|
|
2203
2451
|
const unitDays = Number(body.unitDays);
|
|
@@ -2994,6 +3242,10 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
|
|
|
2994
3242
|
return startRevise(ctx, root, registry, body);
|
|
2995
3243
|
case '/chapter/sync-outline':
|
|
2996
3244
|
return startSyncOutline(ctx, root, registry, body);
|
|
3245
|
+
case '/import/scan':
|
|
3246
|
+
return scanImportChapters(root, body);
|
|
3247
|
+
case '/import/apply':
|
|
3248
|
+
return startImportChapters(ctx, root, registry, body);
|
|
2997
3249
|
case '/outline/revise':
|
|
2998
3250
|
return startSynopsisRevise(ctx, root, registry, body);
|
|
2999
3251
|
case '/compose':
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-novel-generator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.35",
|
|
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",
|