dsh-novel-generator 0.1.35 → 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 (3) hide show
  1. package/client.js +167 -1
  2. package/index.js +112 -1
  3. package/package.json +1 -1
package/client.js CHANGED
@@ -661,6 +661,62 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
661
661
  }, t('停止', 'Stop')))));
662
662
  }
663
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
+
664
720
  function PresetDialog({ project, presets, onCancel, onSaved }) {
665
721
  const [preset, setPreset] = useState(project.agentPreset || '');
666
722
  const [busy, setBusy] = useState(false);
@@ -1489,7 +1545,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1489
1545
  }, busy ? t('修改中…', 'Revising…') : t('确认修改', 'Revise')))));
1490
1546
  }
1491
1547
 
1492
- 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 }) {
1493
1549
  const words = countWords(text);
1494
1550
  const window = chapter ? chapterWindow(detail, chapter) : null;
1495
1551
 
@@ -1540,6 +1596,15 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1540
1596
  : t('用本章正文重写梗概,并同步时间长度、字数范围与梗概下限(覆盖旧大纲)', 'Rewrite the synopsis from this chapter and re-measure its duration, word bounds and synopsis floor, replacing the old outline'),
1541
1597
  onClick: onSummarize,
1542
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')),
1543
1608
  h('button', {
1544
1609
  className: 'dsn-btn',
1545
1610
  type: 'button',
@@ -2131,6 +2196,10 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2131
2196
  const [reviseError, setReviseError] = useState('');
2132
2197
  const [reviseJob, setReviseJob] = useJob();
2133
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('');
2134
2203
  // The synopsis rewrite lives here, not in OutlinePage: the page renders
2135
2204
  // inside the translated track, where an absolutely positioned dialog would
2136
2205
  // be painted off-screen.
@@ -2272,6 +2341,86 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2272
2341
 
2273
2342
  const revising = Boolean(reviseJob && reviseJob.state === 'running');
2274
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
+
2275
2424
  /** Run the synopsis rewrite for the chapter the outline page handed up. */
2276
2425
  const sendSynopsisRevise = useCallback(async (instruction) => {
2277
2426
  const target = synopsisTarget;
@@ -2537,6 +2686,8 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2537
2686
  },
2538
2687
  onSummarize: syncOutline,
2539
2688
  summarizing: Boolean(summarizeJob && summarizeJob.state === 'running'),
2689
+ onExtractStyle: extractStyle,
2690
+ extractingStyle,
2540
2691
  })),
2541
2692
  h('section', { className: 'dsn-page' },
2542
2693
  h(OutlinePage, {
@@ -2560,6 +2711,21 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2560
2711
  revisingSynopsis,
2561
2712
  isFirstChapter: Boolean(chapter && chapters.length > 0 && chapter.chapterId === chapters[0].chapterId),
2562
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,
2563
2729
  confirmRequest
2564
2730
  ? h(ConfirmDialog, {
2565
2731
  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.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",