dsh-novel-generator 0.1.35 → 0.1.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +6 -2
  2. package/client.js +180 -2
  3. package/index.js +112 -1
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -98,12 +98,16 @@ dsh plugin --profile web add dsh-novel-generator
98
98
 
99
99
  ## 章节故事页
100
100
 
101
- - 正文可以直接编辑、保存;工具条上还有两个按钮:
101
+ - 正文可以直接编辑、保存;工具条上还有三个按钮:
102
102
  - **「同步章节大纲」**:确认后把当前正文交给模型,**按这一章的实际内容重算整份大纲**——故事梗概、故事时间长度(天)、章节最低字数、章节最多字数、梗概最低字数,然后直接保存覆盖旧大纲(正文不动)。
103
103
  - 字数范围一定会把正文实际字数包在里面;梗概下限不会超过新梗概本身的长度。
104
104
  - 时间长度只有在不破坏时间线时才会改:末章可以直接改;后面还有章节时,只有「下一章起始日 = 本章起始日 + 新时长」才采纳,否则保留原时长并在提示里说明模型建议值。
105
+ - **「提取行文风格」**:模型读这一章,提炼视角与人称、句式、用词、节奏、描写比例,输出条目式说明(≤600 字)。
106
+ - 分析期间整个工坊被锁定,无法点别的按钮;正文不足 300 字时按钮不可点,接口也返回 400。
107
+ - 完成后弹面板:只读文本 + 「复制」(贴到作品列表的「前置」里)+ 「写入前置」。
108
+ - 「写入前置」会先弹确认,**明确告知要用 AI 整理**:模型把这段风格与你现有前置合并成一段统一的写作约束(≤2000 字)后保存为本书前置,覆盖旧的那份。
105
109
  - **「AI 修改」**:填入修改意见后,模型按意见重写整章,结果只放进编辑区、不自动保存。
106
- - 正文为空时两个按钮都不可点,接口也会拒绝(400);修改意见为空同样拒绝。
110
+ - 正文为空时按钮不可点,接口也会拒绝(400);修改意见为空同样拒绝。
107
111
 
108
112
  ## 章节大纲页
109
113
 
package/client.js CHANGED
@@ -166,6 +166,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
166
166
  border:1px solid var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}
167
167
  .dsn-note{margin:0;padding:8px 12px;border-radius:10px;font-size:12px;
168
168
  border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1)}
169
+ .dsn-note-row{display:flex;align-items:center;gap:10px;justify-content:space-between}
169
170
  .dsn-empty{padding:32px;text-align:center;color:var(--dsw-alias-label-secondary);font-size:13px}
170
171
  .dsn-toast{position:absolute;top:58px;left:50%;transform:translateX(-50%);z-index:30;
171
172
  padding:8px 16px;border-radius:999px;font-size:12px;border-left:3px solid var(--dsw-alias-state-success-primary);
@@ -661,6 +662,62 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
661
662
  }, t('停止', 'Stop')))));
662
663
  }
663
664
 
665
+ /**
666
+ * The style profile an extraction produced. It is shown read-only with a copy
667
+ * button, and can be folded into the work's preface — that second step asks
668
+ * the model to tidy the merge, which the confirm says out loud.
669
+ */
670
+ function StyleDialog({ style, source, busy, error, onCancel, onApply, requestConfirm }) {
671
+ const [copied, setCopied] = useState(false);
672
+ const copy = useCallback(async () => {
673
+ try {
674
+ if (navigator.clipboard && navigator.clipboard.writeText) {
675
+ await navigator.clipboard.writeText(style);
676
+ } else {
677
+ throw new Error('clipboard unavailable');
678
+ }
679
+ setCopied(true);
680
+ } catch {
681
+ setCopied(false);
682
+ }
683
+ }, [style]);
684
+
685
+ return h('div', { className: 'dsn-dialog-backdrop' },
686
+ h('div', { className: 'dsn-dialog', style: { width: 'min(640px, calc(100% - 48px))' } },
687
+ h('div', { className: 'dsn-dialog-head' },
688
+ h('div', { className: 'dsn-title' }, t('提取到的行文风格', 'Extracted writing style')),
689
+ h('div', { className: 'dsn-sub' }, source ? t('来源:{src} · {n} 字', 'From {src} · {n} characters', { src: source, n: style.length }) : ''),
690
+ h('div', { className: 'dsn-spacer' })),
691
+ h('div', { className: 'dsn-dialog-body' },
692
+ h('label', { className: 'dsn-field span4' },
693
+ h('span', { className: 'dsn-label' }, t('可以直接复制到作品列表的「前置」里', 'Copy it into the work’s preface')),
694
+ h('textarea', { className: 'dsn-textarea', style: { minHeight: '220px' }, value: style, readOnly: true })),
695
+ 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.')),
696
+ error ? h('div', { className: 'dsn-hint warn span4' }, error) : null),
697
+ h('div', { className: 'dsn-dialog-foot' },
698
+ h('button', { className: 'dsn-btn ghost', type: 'button', disabled: busy, onClick: onCancel }, t('关闭', 'Close')),
699
+ h('div', { className: 'dsn-spacer' }),
700
+ h('button', {
701
+ className: 'dsn-btn',
702
+ type: 'button',
703
+ disabled: busy,
704
+ onClick: copy,
705
+ }, copied ? t('已复制', 'Copied') : t('复制', 'Copy')),
706
+ h('button', {
707
+ className: 'dsn-btn primary',
708
+ type: 'button',
709
+ disabled: busy,
710
+ onClick: () => {
711
+ requestConfirm({
712
+ title: t('写入前置会调用 AI 整理', 'Writing the preface uses the model'),
713
+ 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?'),
714
+ confirmLabel: t('整理并写入', 'Merge and write'),
715
+ action: onApply,
716
+ });
717
+ },
718
+ }, busy ? t('整理中…', 'Merging…') : t('写入前置', 'Write into preface')))));
719
+ }
720
+
664
721
  function PresetDialog({ project, presets, onCancel, onSaved }) {
665
722
  const [preset, setPreset] = useState(project.agentPreset || '');
666
723
  const [busy, setBusy] = useState(false);
@@ -1489,7 +1546,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1489
1546
  }, busy ? t('修改中…', 'Revising…') : t('确认修改', 'Revise')))));
1490
1547
  }
1491
1548
 
1492
- function StoryPage({ detail, chapter, previous, next, text, saving, onEdit, onSave, onBack, onOutline, onOpenChapter, onNewChapter, onRevise, onSummarize, summarizing }) {
1549
+ function StoryPage({ detail, chapter, previous, next, text, saving, onEdit, onSave, onBack, onOutline, onOpenChapter, onNewChapter, onRevise, onSummarize, summarizing, onExtractStyle, extractingStyle }) {
1493
1550
  const words = countWords(text);
1494
1551
  const window = chapter ? chapterWindow(detail, chapter) : null;
1495
1552
 
@@ -1520,7 +1577,18 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1520
1577
  ? h('div', { className: 'dsn-empty' }, t('还没有章节。点右上角「+ 新建章节」开始第一章。', "No chapters yet. Use \"+ New chapter\" at the top right to start the first one."))
1521
1578
  : h(React.Fragment, null,
1522
1579
  h('div', { className: 'dsn-editor' },
1523
- !chapter.outline ? h('div', { className: 'dsn-note' }, t('这一章还没有保存过章节大纲,可以先回章节大纲里填写并生成。', "This chapter has no saved outline yet; fill one in under \"Outline\" and generate from there.")) : null,
1580
+ !chapter.outline
1581
+ ? (String(text || '').trim() === ''
1582
+ ? h('div', { className: 'dsn-note' }, t('这一章还没有章节大纲:先去「章节大纲」填写,再回来生成正文。', 'This chapter has no outline yet — fill one in under "Outline", then generate from there.'))
1583
+ : h('div', { className: 'dsn-note dsn-note-row' },
1584
+ h('span', null, t('这一章是导入的,没有章节大纲记录:点「同步章节大纲」会根据正文生成梗概与字数范围。', 'This chapter came from an import and has no outline recorded — “Sync outline” builds one from the text.')),
1585
+ h('button', {
1586
+ className: 'dsn-btn ghost',
1587
+ type: 'button',
1588
+ disabled: saving || Boolean(summarizing),
1589
+ onClick: onSummarize,
1590
+ }, t('现在同步', 'Sync now'))))
1591
+ : null,
1524
1592
  h('textarea', {
1525
1593
  className: 'dsn-textarea dsn-story',
1526
1594
  value: text,
@@ -1540,6 +1608,15 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1540
1608
  : t('用本章正文重写梗概,并同步时间长度、字数范围与梗概下限(覆盖旧大纲)', 'Rewrite the synopsis from this chapter and re-measure its duration, word bounds and synopsis floor, replacing the old outline'),
1541
1609
  onClick: onSummarize,
1542
1610
  }, summarizing ? t('同步大纲中…', 'Syncing…') : t('同步章节大纲', 'Sync outline')),
1611
+ h('button', {
1612
+ className: 'dsn-btn',
1613
+ type: 'button',
1614
+ disabled: saving || Boolean(extractingStyle || summarizing) || countWords(text) < 300,
1615
+ title: countWords(text) < 300
1616
+ ? t('正文不足 300 字,提取出来的风格不可靠', 'Fewer than 300 characters — a style profile would not be reliable')
1617
+ : t('让模型读这一章,提炼叙事视角、句式、用词与节奏,可复制到作品前置', 'Let the model read this chapter and describe its viewpoint, sentences, wording and rhythm, ready to paste into the work’s preface'),
1618
+ onClick: onExtractStyle,
1619
+ }, extractingStyle ? t('分析中…', 'Analysing…') : t('提取行文风格', 'Extract style')),
1543
1620
  h('button', {
1544
1621
  className: 'dsn-btn',
1545
1622
  type: 'button',
@@ -2131,6 +2208,10 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2131
2208
  const [reviseError, setReviseError] = useState('');
2132
2209
  const [reviseJob, setReviseJob] = useJob();
2133
2210
  const [summarizeJob, setSummarizeJob] = useJob();
2211
+ const [styleJob, setStyleJob] = useJob();
2212
+ const [styleApplyJob, setStyleApplyJob] = useJob();
2213
+ const [styleResult, setStyleResult] = useState(null);
2214
+ const [styleError, setStyleError] = useState('');
2134
2215
  // The synopsis rewrite lives here, not in OutlinePage: the page renders
2135
2216
  // inside the translated track, where an absolutely positioned dialog would
2136
2217
  // be painted off-screen.
@@ -2272,6 +2353,86 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2272
2353
 
2273
2354
  const revising = Boolean(reviseJob && reviseJob.state === 'running');
2274
2355
 
2356
+ /** 提取行文风格: the model reads this chapter and describes how it is written. */
2357
+ const extractStyle = useCallback(() => {
2358
+ if (!chapter) return;
2359
+ if (countWords(text) < 300) {
2360
+ notify(t('正文不足 300 字,提取出来的风格不可靠', 'Fewer than 300 characters — a style profile would not be reliable'), 'error');
2361
+ return;
2362
+ }
2363
+ setStyleError('');
2364
+ (async () => {
2365
+ try {
2366
+ const started = await api('/style/extract', {
2367
+ projectId: detail.projectId,
2368
+ chapterId: chapter.chapterId,
2369
+ content: text,
2370
+ });
2371
+ setStyleJob(started);
2372
+ } catch (error) {
2373
+ notify(messageOf(error), 'error');
2374
+ }
2375
+ })();
2376
+ }, [chapter, detail.projectId, text, notify, setStyleJob]);
2377
+
2378
+ const extractingStyle = Boolean(styleJob && styleJob.state === 'running');
2379
+ useEffect(() => {
2380
+ if (!styleJob) return undefined;
2381
+ if (styleJob.state === 'running') {
2382
+ setChildBusy({
2383
+ label: t('正在分析行文风格,期间无法操作…', 'Analysing the writing style — the studio is locked meanwhile…'),
2384
+ message: styleJob.message || '',
2385
+ });
2386
+ return undefined;
2387
+ }
2388
+ setChildBusy(null);
2389
+ if (styleJob.state === 'done') {
2390
+ const result = styleJob.result || {};
2391
+ if (result.style) {
2392
+ setStyleResult({ style: result.style, source: result.source || '' });
2393
+ setStyleError('');
2394
+ }
2395
+ } else if (styleJob.state === 'error') {
2396
+ notify(styleJob.error || t('提取行文风格失败', 'Extracting the style failed'), 'error');
2397
+ }
2398
+ return undefined;
2399
+ }, [styleJob ? styleJob.jobId : null, styleJob ? styleJob.state : null, styleJob ? styleJob.message : null]);
2400
+
2401
+ const applyingStyle = Boolean(styleApplyJob && styleApplyJob.state === 'running');
2402
+ const applyStyleToPrelude = useCallback(async () => {
2403
+ if (!styleResult) return;
2404
+ setStyleError('');
2405
+ try {
2406
+ const started = await api('/style/apply', {
2407
+ projectId: detail.projectId,
2408
+ style: styleResult.style,
2409
+ });
2410
+ setStyleApplyJob(started);
2411
+ } catch (error) {
2412
+ setStyleError(messageOf(error));
2413
+ }
2414
+ }, [styleResult, detail.projectId, setStyleApplyJob]);
2415
+
2416
+ useEffect(() => {
2417
+ if (!styleApplyJob) return undefined;
2418
+ if (styleApplyJob.state === 'running') {
2419
+ setChildBusy({
2420
+ label: t('AI 正在把风格整理进前置提示词…', 'The model is folding the style into the preface…'),
2421
+ message: styleApplyJob.message || '',
2422
+ });
2423
+ return undefined;
2424
+ }
2425
+ setChildBusy(null);
2426
+ if (styleApplyJob.state === 'done') {
2427
+ setStyleResult(null);
2428
+ notify(t('已整理并写入本书前置,下一次生成就生效', 'Merged into this work’s preface — it applies to the next generation'), 'success');
2429
+ onReload();
2430
+ } else if (styleApplyJob.state === 'error') {
2431
+ setStyleError(styleApplyJob.error || t('写入前置失败', 'Writing the preface failed'));
2432
+ }
2433
+ return undefined;
2434
+ }, [styleApplyJob ? styleApplyJob.jobId : null, styleApplyJob ? styleApplyJob.state : null, styleApplyJob ? styleApplyJob.message : null]);
2435
+
2275
2436
  /** Run the synopsis rewrite for the chapter the outline page handed up. */
2276
2437
  const sendSynopsisRevise = useCallback(async (instruction) => {
2277
2438
  const target = synopsisTarget;
@@ -2537,6 +2698,8 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2537
2698
  },
2538
2699
  onSummarize: syncOutline,
2539
2700
  summarizing: Boolean(summarizeJob && summarizeJob.state === 'running'),
2701
+ onExtractStyle: extractStyle,
2702
+ extractingStyle,
2540
2703
  })),
2541
2704
  h('section', { className: 'dsn-page' },
2542
2705
  h(OutlinePage, {
@@ -2560,6 +2723,21 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2560
2723
  revisingSynopsis,
2561
2724
  isFirstChapter: Boolean(chapter && chapters.length > 0 && chapter.chapterId === chapters[0].chapterId),
2562
2725
  }))),
2726
+ styleResult || applyingStyle
2727
+ ? h(StyleDialog, {
2728
+ style: styleResult ? styleResult.style : '',
2729
+ source: styleResult ? styleResult.source : '',
2730
+ busy: applyingStyle,
2731
+ error: styleError,
2732
+ requestConfirm: setConfirmRequest,
2733
+ onCancel: () => {
2734
+ if (applyingStyle) return;
2735
+ setStyleResult(null);
2736
+ setStyleError('');
2737
+ },
2738
+ onApply: applyStyleToPrelude,
2739
+ })
2740
+ : null,
2563
2741
  confirmRequest
2564
2742
  ? h(ConfirmDialog, {
2565
2743
  title: confirmRequest.title,
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.9',
1502
+ revision: '2026-10-28.10',
1503
1503
  projects,
1504
1504
  models: {
1505
1505
  default: modelCatalog.default || null,
@@ -2446,6 +2446,113 @@ async function startImportChapters(ctx, root, registry, body) {
2446
2446
  });
2447
2447
  }
2448
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
+
2449
2556
  async function updateTimeline(root, body) {
2450
2557
  const projectId = asText(body.projectId);
2451
2558
  const unitDays = Number(body.unitDays);
@@ -3244,6 +3351,10 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
3244
3351
  return startSyncOutline(ctx, root, registry, body);
3245
3352
  case '/import/scan':
3246
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);
3247
3358
  case '/import/apply':
3248
3359
  return startImportChapters(ctx, root, registry, body);
3249
3360
  case '/outline/revise':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-novel-generator",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
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",