dsh-novel-generator 0.1.34 → 0.1.36

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 +19 -2
  2. package/client.js +432 -2
  3. package/index.js +435 -72
  4. 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
- > 刻意的差别:「生成故事梗概」会覆盖保存(读的是你已经写定的正文),「AI 修改梗概」只改草稿(它改的正是你用来生成正文的依据,值得先看一眼)。
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,280 @@ 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
+
664
+ /**
665
+ * The style profile an extraction produced. It is shown read-only with a copy
666
+ * button, and can be folded into the work's preface — that second step asks
667
+ * the model to tidy the merge, which the confirm says out loud.
668
+ */
669
+ function StyleDialog({ style, source, busy, error, onCancel, onApply, requestConfirm }) {
670
+ const [copied, setCopied] = useState(false);
671
+ const copy = useCallback(async () => {
672
+ try {
673
+ if (navigator.clipboard && navigator.clipboard.writeText) {
674
+ await navigator.clipboard.writeText(style);
675
+ } else {
676
+ throw new Error('clipboard unavailable');
677
+ }
678
+ setCopied(true);
679
+ } catch {
680
+ setCopied(false);
681
+ }
682
+ }, [style]);
683
+
684
+ return h('div', { className: 'dsn-dialog-backdrop' },
685
+ h('div', { className: 'dsn-dialog', style: { width: 'min(640px, calc(100% - 48px))' } },
686
+ h('div', { className: 'dsn-dialog-head' },
687
+ h('div', { className: 'dsn-title' }, t('提取到的行文风格', 'Extracted writing style')),
688
+ h('div', { className: 'dsn-sub' }, source ? t('来源:{src} · {n} 字', 'From {src} · {n} characters', { src: source, n: style.length }) : ''),
689
+ h('div', { className: 'dsn-spacer' })),
690
+ h('div', { className: 'dsn-dialog-body' },
691
+ h('label', { className: 'dsn-field span4' },
692
+ h('span', { className: 'dsn-label' }, t('可以直接复制到作品列表的「前置」里', 'Copy it into the work’s preface')),
693
+ h('textarea', { className: 'dsn-textarea', style: { minHeight: '220px' }, value: style, readOnly: true })),
694
+ h('div', { className: 'dsn-hint span4' }, t('也可以点「写入前置」:插件会让模型把这段风格和你现有前置整理成一段统一的写作约束,再保存。', 'Or press “Write into preface”: the model merges this profile with your current preface into one set of rules, then saves it.')),
695
+ error ? h('div', { className: 'dsn-hint warn span4' }, error) : null),
696
+ h('div', { className: 'dsn-dialog-foot' },
697
+ h('button', { className: 'dsn-btn ghost', type: 'button', disabled: busy, onClick: onCancel }, t('关闭', 'Close')),
698
+ h('div', { className: 'dsn-spacer' }),
699
+ h('button', {
700
+ className: 'dsn-btn',
701
+ type: 'button',
702
+ disabled: busy,
703
+ onClick: copy,
704
+ }, copied ? t('已复制', 'Copied') : t('复制', 'Copy')),
705
+ h('button', {
706
+ className: 'dsn-btn primary',
707
+ type: 'button',
708
+ disabled: busy,
709
+ onClick: () => {
710
+ requestConfirm({
711
+ title: t('写入前置会调用 AI 整理', 'Writing the preface uses the model'),
712
+ text: t('插件会让模型把这段行文风格与你现有的前置提示词整理成一段统一的写作约束,然后覆盖保存为本书前置。整理需要一次模型调用。继续吗?', 'The model merges this style profile with your current preface into one set of rules and saves it as this work’s preface, replacing the current one. It takes one model call. Continue?'),
713
+ confirmLabel: t('整理并写入', 'Merge and write'),
714
+ action: onApply,
715
+ });
716
+ },
717
+ }, busy ? t('整理中…', 'Merging…') : t('写入前置', 'Write into preface')))));
718
+ }
719
+
435
720
  function PresetDialog({ project, presets, onCancel, onSaved }) {
436
721
  const [preset, setPreset] = useState(project.agentPreset || '');
437
722
  const [busy, setBusy] = useState(false);
@@ -555,7 +840,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
555
840
  }, busy ? t('保存中…', 'Saving…') : t('保存', 'Save')))));
556
841
  }
557
842
 
558
- function ProjectsPage({ boot, resolveWorkspace, onOpen, onReload, onEditModel, onEditPreset, onEditPrelude }) {
843
+ function ProjectsPage({ boot, resolveWorkspace, onOpen, onReload, onEditModel, onEditPreset, onEditPrelude, onImport }) {
559
844
  const [title, setTitle] = useState('');
560
845
  const [cwd, setCwd] = useState('');
561
846
  const [preset, setPreset] = useState('');
@@ -809,6 +1094,15 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
809
1094
  onEditPrelude(project);
810
1095
  },
811
1096
  }, t('前置', 'Preface')),
1097
+ h('button', {
1098
+ className: 'dsn-btn ghost',
1099
+ type: 'button',
1100
+ title: t('从某个目录按「第N章 标题.md」的顺序导入章节', 'Import chapters from a folder of “第N章 Title.md” files'),
1101
+ onClick: (event) => {
1102
+ event.stopPropagation();
1103
+ onImport(project);
1104
+ },
1105
+ }, t('导入章节', 'Import')),
812
1106
  h('button', {
813
1107
  className: 'dsn-btn ghost',
814
1108
  type: 'button',
@@ -1251,7 +1545,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1251
1545
  }, busy ? t('修改中…', 'Revising…') : t('确认修改', 'Revise')))));
1252
1546
  }
1253
1547
 
1254
- function StoryPage({ detail, chapter, previous, next, text, saving, onEdit, onSave, onBack, onOutline, onOpenChapter, onNewChapter, onRevise, onSummarize, summarizing }) {
1548
+ function StoryPage({ detail, chapter, previous, next, text, saving, onEdit, onSave, onBack, onOutline, onOpenChapter, onNewChapter, onRevise, onSummarize, summarizing, onExtractStyle, extractingStyle }) {
1255
1549
  const words = countWords(text);
1256
1550
  const window = chapter ? chapterWindow(detail, chapter) : null;
1257
1551
 
@@ -1302,6 +1596,15 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1302
1596
  : t('用本章正文重写梗概,并同步时间长度、字数范围与梗概下限(覆盖旧大纲)', 'Rewrite the synopsis from this chapter and re-measure its duration, word bounds and synopsis floor, replacing the old outline'),
1303
1597
  onClick: onSummarize,
1304
1598
  }, summarizing ? t('同步大纲中…', 'Syncing…') : t('同步章节大纲', 'Sync outline')),
1599
+ h('button', {
1600
+ className: 'dsn-btn',
1601
+ type: 'button',
1602
+ disabled: saving || Boolean(extractingStyle || summarizing) || countWords(text) < 300,
1603
+ title: countWords(text) < 300
1604
+ ? t('正文不足 300 字,提取出来的风格不可靠', 'Fewer than 300 characters — a style profile would not be reliable')
1605
+ : t('让模型读这一章,提炼叙事视角、句式、用词与节奏,可复制到作品前置', 'Let the model read this chapter and describe its viewpoint, sentences, wording and rhythm, ready to paste into the work’s preface'),
1606
+ onClick: onExtractStyle,
1607
+ }, extractingStyle ? t('分析中…', 'Analysing…') : t('提取行文风格', 'Extract style')),
1305
1608
  h('button', {
1306
1609
  className: 'dsn-btn',
1307
1610
  type: 'button',
@@ -1893,6 +2196,10 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1893
2196
  const [reviseError, setReviseError] = useState('');
1894
2197
  const [reviseJob, setReviseJob] = useJob();
1895
2198
  const [summarizeJob, setSummarizeJob] = useJob();
2199
+ const [styleJob, setStyleJob] = useJob();
2200
+ const [styleApplyJob, setStyleApplyJob] = useJob();
2201
+ const [styleResult, setStyleResult] = useState(null);
2202
+ const [styleError, setStyleError] = useState('');
1896
2203
  // The synopsis rewrite lives here, not in OutlinePage: the page renders
1897
2204
  // inside the translated track, where an absolutely positioned dialog would
1898
2205
  // be painted off-screen.
@@ -2034,6 +2341,86 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2034
2341
 
2035
2342
  const revising = Boolean(reviseJob && reviseJob.state === 'running');
2036
2343
 
2344
+ /** 提取行文风格: the model reads this chapter and describes how it is written. */
2345
+ const extractStyle = useCallback(() => {
2346
+ if (!chapter) return;
2347
+ if (countWords(text) < 300) {
2348
+ notify(t('正文不足 300 字,提取出来的风格不可靠', 'Fewer than 300 characters — a style profile would not be reliable'), 'error');
2349
+ return;
2350
+ }
2351
+ setStyleError('');
2352
+ (async () => {
2353
+ try {
2354
+ const started = await api('/style/extract', {
2355
+ projectId: detail.projectId,
2356
+ chapterId: chapter.chapterId,
2357
+ content: text,
2358
+ });
2359
+ setStyleJob(started);
2360
+ } catch (error) {
2361
+ notify(messageOf(error), 'error');
2362
+ }
2363
+ })();
2364
+ }, [chapter, detail.projectId, text, notify, setStyleJob]);
2365
+
2366
+ const extractingStyle = Boolean(styleJob && styleJob.state === 'running');
2367
+ useEffect(() => {
2368
+ if (!styleJob) return undefined;
2369
+ if (styleJob.state === 'running') {
2370
+ setChildBusy({
2371
+ label: t('正在分析行文风格,期间无法操作…', 'Analysing the writing style — the studio is locked meanwhile…'),
2372
+ message: styleJob.message || '',
2373
+ });
2374
+ return undefined;
2375
+ }
2376
+ setChildBusy(null);
2377
+ if (styleJob.state === 'done') {
2378
+ const result = styleJob.result || {};
2379
+ if (result.style) {
2380
+ setStyleResult({ style: result.style, source: result.source || '' });
2381
+ setStyleError('');
2382
+ }
2383
+ } else if (styleJob.state === 'error') {
2384
+ notify(styleJob.error || t('提取行文风格失败', 'Extracting the style failed'), 'error');
2385
+ }
2386
+ return undefined;
2387
+ }, [styleJob ? styleJob.jobId : null, styleJob ? styleJob.state : null, styleJob ? styleJob.message : null]);
2388
+
2389
+ const applyingStyle = Boolean(styleApplyJob && styleApplyJob.state === 'running');
2390
+ const applyStyleToPrelude = useCallback(async () => {
2391
+ if (!styleResult) return;
2392
+ setStyleError('');
2393
+ try {
2394
+ const started = await api('/style/apply', {
2395
+ projectId: detail.projectId,
2396
+ style: styleResult.style,
2397
+ });
2398
+ setStyleApplyJob(started);
2399
+ } catch (error) {
2400
+ setStyleError(messageOf(error));
2401
+ }
2402
+ }, [styleResult, detail.projectId, setStyleApplyJob]);
2403
+
2404
+ useEffect(() => {
2405
+ if (!styleApplyJob) return undefined;
2406
+ if (styleApplyJob.state === 'running') {
2407
+ setChildBusy({
2408
+ label: t('AI 正在把风格整理进前置提示词…', 'The model is folding the style into the preface…'),
2409
+ message: styleApplyJob.message || '',
2410
+ });
2411
+ return undefined;
2412
+ }
2413
+ setChildBusy(null);
2414
+ if (styleApplyJob.state === 'done') {
2415
+ setStyleResult(null);
2416
+ notify(t('已整理并写入本书前置,下一次生成就生效', 'Merged into this work’s preface — it applies to the next generation'), 'success');
2417
+ onReload();
2418
+ } else if (styleApplyJob.state === 'error') {
2419
+ setStyleError(styleApplyJob.error || t('写入前置失败', 'Writing the preface failed'));
2420
+ }
2421
+ return undefined;
2422
+ }, [styleApplyJob ? styleApplyJob.jobId : null, styleApplyJob ? styleApplyJob.state : null, styleApplyJob ? styleApplyJob.message : null]);
2423
+
2037
2424
  /** Run the synopsis rewrite for the chapter the outline page handed up. */
2038
2425
  const sendSynopsisRevise = useCallback(async (instruction) => {
2039
2426
  const target = synopsisTarget;
@@ -2299,6 +2686,8 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2299
2686
  },
2300
2687
  onSummarize: syncOutline,
2301
2688
  summarizing: Boolean(summarizeJob && summarizeJob.state === 'running'),
2689
+ onExtractStyle: extractStyle,
2690
+ extractingStyle,
2302
2691
  })),
2303
2692
  h('section', { className: 'dsn-page' },
2304
2693
  h(OutlinePage, {
@@ -2322,6 +2711,21 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2322
2711
  revisingSynopsis,
2323
2712
  isFirstChapter: Boolean(chapter && chapters.length > 0 && chapter.chapterId === chapters[0].chapterId),
2324
2713
  }))),
2714
+ styleResult || applyingStyle
2715
+ ? h(StyleDialog, {
2716
+ style: styleResult ? styleResult.style : '',
2717
+ source: styleResult ? styleResult.source : '',
2718
+ busy: applyingStyle,
2719
+ error: styleError,
2720
+ requestConfirm: setConfirmRequest,
2721
+ onCancel: () => {
2722
+ if (applyingStyle) return;
2723
+ setStyleResult(null);
2724
+ setStyleError('');
2725
+ },
2726
+ onApply: applyStyleToPrelude,
2727
+ })
2728
+ : null,
2325
2729
  confirmRequest
2326
2730
  ? h(ConfirmDialog, {
2327
2731
  title: confirmRequest.title,
@@ -2415,6 +2819,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2415
2819
  const [modelTarget, setModelTarget] = useState(null);
2416
2820
  const [presetTarget, setPresetTarget] = useState(null);
2417
2821
  const [preludeTarget, setPreludeTarget] = useState(null);
2822
+ const [importTarget, setImportTarget] = useState(null);
2418
2823
  const leaveGuard = useRef(null);
2419
2824
  const registerLeaveGuard = useCallback((guard) => {
2420
2825
  leaveGuard.current = guard;
@@ -2508,6 +2913,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2508
2913
  onEditModel: setModelTarget,
2509
2914
  onEditPreset: setPresetTarget,
2510
2915
  onEditPrelude: setPreludeTarget,
2916
+ onImport: setImportTarget,
2511
2917
  }))
2512
2918
  : detail === null
2513
2919
  ? h('div', { className: 'dsn-empty', style: { margin: 'auto' } }, t('正在载入作品…', "Loading the work…"))
@@ -2547,6 +2953,30 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2547
2953
  },
2548
2954
  })
2549
2955
  : null,
2956
+ importTarget
2957
+ ? h(ImportDialog, {
2958
+ project: importTarget,
2959
+ resolveWorkspace,
2960
+ onCancel: () => setImportTarget(null),
2961
+ onDone: async (result) => {
2962
+ setImportTarget(null);
2963
+ await loadBoot();
2964
+ const imported = (result.imported || []).length;
2965
+ const skipped = (result.skipped || []).length;
2966
+ const failed = (result.failed || []).length;
2967
+ const synced = (result.synced || []).length;
2968
+ const parts = [t('已导入 {n} 章', 'Imported {n} chapters', { n: imported })];
2969
+ if (synced > 0) parts.push(t('同步大纲 {n} 章', 'synced {n} outlines', { n: synced }));
2970
+ if (skipped > 0) parts.push(t('跳过 {n} 章(章号已被占用)', 'skipped {n} taken', { n: skipped }));
2971
+ if (failed > 0) parts.push(t('失败 {n} 个:{list}', '{n} failed: {list}', {
2972
+ n: failed,
2973
+ list: (result.failed || []).map((entry) => `${entry.fileName}(${entry.reason})`).join('、'),
2974
+ }));
2975
+ if (result.snapshotJobId) parts.push(t('世界快照已开始生成', 'World snapshot started'));
2976
+ setToast({ text: parts.join(';'), kind: failed > 0 ? 'error' : 'success' });
2977
+ },
2978
+ })
2979
+ : null,
2550
2980
  preludeTarget
2551
2981
  ? h(PreludeDialog, {
2552
2982
  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.5',
1502
+ revision: '2026-10-28.10',
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
- const raw = await askAgent(ctx, root, project, syncOutlinePrompt(project, chapter, prose), (note) => {
2092
+ return syncChapterOutline(ctx, root, projectId, chapterId, prose, (note) => {
2096
2093
  job.message = note;
2097
2094
  });
2098
- const parsed = parseJsonReply(raw) || {};
2099
- const chars = wordCount(prose);
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
- const previous = chapter.outline || {};
2108
- const span = chapterWindow(project, chapter);
2109
- const notes = [];
2110
-
2111
- // Word bounds must contain what was actually written, whatever the model said.
2112
- const floorHundred = (value) => Math.max(1, Math.floor(value / 100) * 100);
2113
- const ceilHundred = (value) => Math.max(200, Math.ceil(value / 100) * 100);
2114
- let minWords = Number.isFinite(Number(parsed.minWords)) ? Math.max(1, Math.round(Number(parsed.minWords))) : 0;
2115
- let maxWords = Number.isFinite(Number(parsed.maxWords)) ? Math.max(1, Math.round(Number(parsed.maxWords))) : 0;
2116
- if (minWords <= 0 || minWords > chars) minWords = floorHundred(chars * 0.9);
2117
- if (maxWords <= 0 || maxWords < chars) maxWords = ceilHundred(chars * 1.1);
2118
- if (maxWords < minWords) maxWords = minWords + 100;
2119
-
2120
- // The synopsis floor has to stay reachable by the synopsis it was measured on.
2121
- let synopsisMinChars = Number.isFinite(Number(parsed.synopsisMinChars))
2122
- ? Math.round(Number(parsed.synopsisMinChars))
2123
- : Math.max(20, Math.floor(synopsis.length * 0.8));
2124
- synopsisMinChars = Math.max(20, Math.min(400, synopsisMinChars));
2125
- synopsisMinChars = Math.max(1, Math.min(synopsisMinChars, synopsis.length));
2126
-
2127
- // A new duration only lands when it keeps the next chapter where it is.
2128
- let durationDays = chapter.durationDays;
2129
- const proposed = Number.isFinite(Number(parsed.durationDays))
2130
- ? Math.max(1, Math.min(365, Math.round(Number(parsed.durationDays))))
2131
- : null;
2132
- if (proposed !== null && proposed !== chapter.durationDays) {
2133
- const following = (project.chapters || [])
2134
- .filter((entry) => entry.chapterId !== chapter.chapterId && entry.startDay >= span.start)
2135
- .sort((a, b) => a.startDay - b.startDay)[0] || null;
2136
- if (following === null) {
2137
- durationDays = proposed;
2138
- } else if (following.startDay === span.start + proposed) {
2139
- durationDays = proposed;
2140
- } else {
2141
- notes.push(`下一章从第 ${following.startDay} 天开始,改时长会错位,时间长度保持 ${chapter.durationDays} 天(模型建议 ${proposed} 天)`);
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
- const saved = await mutateProject(root, projectId, (draft) => {
2146
- const target = draft.chapters.find((entry) => entry.chapterId === chapterId);
2147
- if (!target) throw httpError(404, `章节不存在: ${chapterId}`);
2148
- target.outline = {
2149
- ...previous,
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
- chars,
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,352 @@ 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
+
2449
+ /* ------------------------------------------------------- style extraction */
2450
+
2451
+ /** A chapter needs this much prose before a style profile means anything. */
2452
+ const STYLE_MIN_CHARS = 300;
2453
+ /** Extracted profiles stay short so they fit in front of every request. */
2454
+ const STYLE_MAX_CHARS = 600;
2455
+
2456
+ function styleExtractPrompt(project, chapter, prose) {
2457
+ const span = chapterWindow(project, chapter);
2458
+ const lines = [];
2459
+ lines.push('你是这部小说的文字编辑。请从下面这段正文里提取这篇小说的行文风格,写成可以直接当作「写作约束」使用的条目。');
2460
+ lines.push('');
2461
+ lines.push('要覆盖:');
2462
+ lines.push('- 叙事视角与人称(第三人称全知 / 第三人称限知、叙述者的位置、是否贴身 POV);');
2463
+ lines.push('- 句长与句式(长句铺陈还是短句推进、是否大量并列);');
2464
+ lines.push('- 用词偏好(书面/口语、有无文言味、是否爱用四字词);');
2465
+ lines.push('- 节奏(场景推进、动作、心理描写的比重);');
2466
+ lines.push('- 描写重心与感官用法(环境、动作、对话、内心各占多少,感官是融入环境还是单独成段);');
2467
+ lines.push('- 标志性的修辞或语气(有没有固定的叙述腔调、常用的比喻方式)。');
2468
+ lines.push('');
2469
+ lines.push('写法要求:');
2470
+ lines.push('- 每条一行,用「· 」开头,短句直述,像给写手的规矩,不要写成文学评论;');
2471
+ lines.push('- 不要出现「这段文字」「作者」之类的话,也不要评价好坏;');
2472
+ lines.push(`- 全文不超过 ${STYLE_MAX_CHARS} 字,宁可少写几条也要写得可执行。`);
2473
+ lines.push('');
2474
+ lines.push(`【本章】第 ${chapter.index + 1} 章 ${chapter.title || ''}(第 ${span.start}→${span.end} 天)`);
2475
+ lines.push('【正文】');
2476
+ lines.push(prose);
2477
+ return lines.join('\n');
2478
+ }
2479
+
2480
+ function styleMergePrompt(existing, style) {
2481
+ const lines = [];
2482
+ lines.push('你是这部小说的编辑。下面是这部作品现有的【前置提示词】,和刚从正文里提炼出的【行文风格】。');
2483
+ lines.push('请把它们整理成一段统一的前置提示词,供每次写作时放在提示最前面:');
2484
+ lines.push('');
2485
+ lines.push('要求:');
2486
+ lines.push('- 现有前置里仍然有效的内容要保留(例如作者自己写的题材、禁忌、写作要求);');
2487
+ lines.push('- 与行文风格重复或冲突的表述合并,以正文实测出的风格为准;');
2488
+ lines.push('- 分条书写,条目要可执行(能直接约束写手的句子),不要出现「提取」「风格分析」这类字眼;');
2489
+ lines.push(`- 全文不超过 ${PRELUDE_MAX_CHARS} 字,只输出整理后的前置提示词本身。`);
2490
+ lines.push('');
2491
+ lines.push('【现有前置提示词】');
2492
+ lines.push(existing.trim() === '' ? '(暂无)' : existing.trim());
2493
+ lines.push('');
2494
+ lines.push('【行文风格】');
2495
+ lines.push(style.trim());
2496
+ return lines.join('\n');
2497
+ }
2498
+
2499
+ /** Read the prose and hand back a style profile the author can put in the preface. */
2500
+ async function startStyleExtract(ctx, root, registry, body) {
2501
+ const projectId = asText(body.projectId);
2502
+ const chapterId = asText(body.chapterId);
2503
+ const prose = typeof body.content === 'string' ? body.content : '';
2504
+ if (wordCount(prose) < STYLE_MIN_CHARS) {
2505
+ throw httpError(400, `正文不足 ${STYLE_MIN_CHARS} 字,提取出来的风格不可靠`);
2506
+ }
2507
+ const preview = await readProject(root, projectId);
2508
+ if (!preview.chapters.some((entry) => entry.chapterId === chapterId)) {
2509
+ throw httpError(404, `章节不存在: ${chapterId}`);
2510
+ }
2511
+ return startJob(registry, { kind: 'style-extract', projectId, chapterId }, async (job) => {
2512
+ const project = await readProject(root, projectId);
2513
+ const chapter = project.chapters.find((entry) => entry.chapterId === chapterId);
2514
+ if (!chapter) throw httpError(404, `章节不存在: ${chapterId}`);
2515
+ job.message = '正在读正文提取行文风格';
2516
+ const raw = await askStory(ctx, root, project, styleExtractPrompt(project, chapter, prose), (note) => {
2517
+ job.message = note;
2518
+ });
2519
+ let style = cleanSynopsis(raw);
2520
+ if (style === '') throw httpError(502, '模型没有给出风格说明');
2521
+ if (style.length > STYLE_MAX_CHARS) style = `${style.slice(0, STYLE_MAX_CHARS - 1)}…`;
2522
+ return { style, chars: style.length, source: chapter.title || `第 ${chapter.index + 1} 章` };
2523
+ });
2524
+ }
2525
+
2526
+ /**
2527
+ * Fold an extracted profile into the work's preface, letting the model tidy the
2528
+ * result: the author's own preface text usually overlaps the profile in wording
2529
+ * but not in detail, and the merged version has to read as one set of rules.
2530
+ */
2531
+ async function startStyleApply(ctx, root, registry, body) {
2532
+ const projectId = asText(body.projectId);
2533
+ const style = typeof body.style === 'string' ? body.style.trim() : '';
2534
+ if (style === '') throw httpError(400, '没有可写入的风格说明');
2535
+ await readProject(root, projectId);
2536
+ return startJob(registry, { kind: 'style-apply', projectId }, async (job) => {
2537
+ const project = await readProject(root, projectId);
2538
+ const existing = project.prelude && project.prelude.text ? String(project.prelude.text) : '';
2539
+ job.message = '正在把风格整理进前置提示词';
2540
+ const raw = await askStory(ctx, root, project, styleMergePrompt(existing, style), (note) => {
2541
+ job.message = note;
2542
+ });
2543
+ let merged = cleanSynopsis(raw);
2544
+ if (merged === '') throw httpError(502, '模型没有给整理后的前置提示词');
2545
+ if (merged.length > PRELUDE_MAX_CHARS) merged = merged.slice(0, PRELUDE_MAX_CHARS).trim();
2546
+ await mutateProject(root, projectId, (draft) => {
2547
+ draft.prelude = { mode: 'custom', text: merged, updatedAt: Date.now() };
2548
+ draft.updatedAt = Date.now();
2549
+ return summarize(draft);
2550
+ });
2551
+ await exportProjectInfo(await readProject(root, projectId));
2552
+ return { prelude: merged, chars: merged.length };
2553
+ });
2554
+ }
2555
+
2201
2556
  async function updateTimeline(root, body) {
2202
2557
  const projectId = asText(body.projectId);
2203
2558
  const unitDays = Number(body.unitDays);
@@ -2994,6 +3349,14 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
2994
3349
  return startRevise(ctx, root, registry, body);
2995
3350
  case '/chapter/sync-outline':
2996
3351
  return startSyncOutline(ctx, root, registry, body);
3352
+ case '/import/scan':
3353
+ return scanImportChapters(root, body);
3354
+ case '/style/extract':
3355
+ return startStyleExtract(ctx, root, registry, body);
3356
+ case '/style/apply':
3357
+ return startStyleApply(ctx, root, registry, body);
3358
+ case '/import/apply':
3359
+ return startImportChapters(ctx, root, registry, body);
2997
3360
  case '/outline/revise':
2998
3361
  return startSynopsisRevise(ctx, root, registry, body);
2999
3362
  case '/compose':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-novel-generator",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
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",