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.
- package/README.md +6 -2
- package/client.js +180 -2
- package/index.js +112 -1
- 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
|
-
-
|
|
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
|
|
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.
|
|
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.
|
|
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",
|