dsh-novel-generator 0.1.32 → 0.1.34
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 +11 -3
- package/client.js +117 -66
- package/index.js +93 -28
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -83,9 +83,17 @@ dsh plugin --profile web add dsh-novel-generator
|
|
|
83
83
|
|
|
84
84
|
## 章节故事页
|
|
85
85
|
|
|
86
|
-
-
|
|
87
|
-
-
|
|
88
|
-
-
|
|
86
|
+
- 正文可以直接编辑、保存;工具条上还有两个按钮:
|
|
87
|
+
- **「生成故事梗概」**:确认后把当前正文交给模型,生成一段新梗概并**直接保存、覆盖原有梗概**(正文不动)。
|
|
88
|
+
- **「AI 修改」**:填入修改意见后,模型按意见重写整章,结果只放进编辑区、不自动保存。
|
|
89
|
+
- 正文为空时两个按钮都不可点,接口也会拒绝(400);修改意见为空同样拒绝。
|
|
90
|
+
|
|
91
|
+
## 章节大纲页
|
|
92
|
+
|
|
93
|
+
- **「AI 修改梗概」**:与章节故事页的「AI 修改」区分——它只改**故事梗概**,依据是「原梗概 + 你的修改意见」,不动正文和章节名。结果填回梗概框并标记未保存,确认后点「保存」。
|
|
94
|
+
- 梗概为空或意见为空时按钮不可点,接口也会 400。
|
|
95
|
+
|
|
96
|
+
> 刻意的差别:「生成故事梗概」会覆盖保存(读的是你已经写定的正文),「AI 修改梗概」只改草稿(它改的正是你用来生成正文的依据,值得先看一眼)。
|
|
89
97
|
|
|
90
98
|
## 「小说工坊 · 作者」预设(随主插件一起装)
|
|
91
99
|
|
package/client.js
CHANGED
|
@@ -1299,9 +1299,9 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1299
1299
|
disabled: saving || Boolean(summarizing) || String(text || '').trim() === '',
|
|
1300
1300
|
title: String(text || '').trim() === ''
|
|
1301
1301
|
? t('这一章还没有正文,先写或先生成', 'No text yet — write or generate the chapter first')
|
|
1302
|
-
: t('
|
|
1302
|
+
: t('用本章正文重写梗概,并同步时间长度、字数范围与梗概下限(覆盖旧大纲)', 'Rewrite the synopsis from this chapter and re-measure its duration, word bounds and synopsis floor, replacing the old outline'),
|
|
1303
1303
|
onClick: onSummarize,
|
|
1304
|
-
}, summarizing ? t('
|
|
1304
|
+
}, summarizing ? t('同步大纲中…', 'Syncing…') : t('同步章节大纲', 'Sync outline')),
|
|
1305
1305
|
h('button', {
|
|
1306
1306
|
className: 'dsn-btn',
|
|
1307
1307
|
type: 'button',
|
|
@@ -1357,7 +1357,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1357
1357
|
}, busy ? t('改写中…', 'Revising…') : t('确认修改', 'Revise')))));
|
|
1358
1358
|
}
|
|
1359
1359
|
|
|
1360
|
-
function OutlinePage({ detail, chapter, isFirstChapter, onBack, onSaved, notify, onRegisterGuard, requestConfirm, onBusyChange }) {
|
|
1360
|
+
function OutlinePage({ detail, chapter, isFirstChapter, onBack, onSaved, notify, onRegisterGuard, requestConfirm, onBusyChange, onReviseSynopsis, synopsisDraft, onSynopsisDraftApplied, revisingSynopsis }) {
|
|
1361
1361
|
const buildForm = useCallback(() => ({
|
|
1362
1362
|
title: chapter ? chapter.title || '' : '',
|
|
1363
1363
|
durationDays: chapter && chapter.outline ? chapter.outline.durationDays : (detail.timeline.unitDays || 30),
|
|
@@ -1460,34 +1460,18 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1460
1460
|
|
|
1461
1461
|
const generating = job && job.state === 'running' && job.kind === 'generate';
|
|
1462
1462
|
const composing = job && job.state === 'running' && job.kind === 'compose';
|
|
1463
|
-
const revisingSynopsis = job && job.state === 'running' && job.kind === 'synopsis-revise';
|
|
1464
1463
|
|
|
1465
|
-
/**
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
if (
|
|
1475
|
-
|
|
1476
|
-
return;
|
|
1477
|
-
}
|
|
1478
|
-
setSynopsisReviseError('');
|
|
1479
|
-
try {
|
|
1480
|
-
const started = await api('/outline/revise', {
|
|
1481
|
-
projectId: detail.projectId,
|
|
1482
|
-
chapterId: chapter.chapterId,
|
|
1483
|
-
synopsis: form.synopsis,
|
|
1484
|
-
instruction: String(instruction).trim(),
|
|
1485
|
-
});
|
|
1486
|
-
setJob(started);
|
|
1487
|
-
} catch (error) {
|
|
1488
|
-
setSynopsisReviseError(messageOf(error));
|
|
1489
|
-
}
|
|
1490
|
-
}, [detail.projectId, chapter, form.synopsis, setJob]);
|
|
1464
|
+
/**
|
|
1465
|
+
* A rewritten synopsis arrives from the workspace (which owns the dialog,
|
|
1466
|
+
* because the workspace stage sits outside this page's transformed track
|
|
1467
|
+
* and a dialog rendered in here would be drawn off-screen).
|
|
1468
|
+
*/
|
|
1469
|
+
useEffect(() => {
|
|
1470
|
+
if (typeof synopsisDraft !== 'string' || synopsisDraft === '') return;
|
|
1471
|
+
setForm((current) => ({ ...current, synopsis: synopsisDraft }));
|
|
1472
|
+
setDirty(true);
|
|
1473
|
+
if (typeof onSynopsisDraftApplied === 'function') onSynopsisDraftApplied();
|
|
1474
|
+
}, [synopsisDraft]);
|
|
1491
1475
|
|
|
1492
1476
|
/** 自动推演: deduce synopsis, title and the whole chapter, then save. */
|
|
1493
1477
|
const deduce = useCallback(() => {
|
|
@@ -1560,17 +1544,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1560
1544
|
notify(t('已重新推演并保存章节', "Re-deduced and saved the chapter"), 'success');
|
|
1561
1545
|
onSaved(chapter ? chapter.chapterId : null);
|
|
1562
1546
|
}
|
|
1563
|
-
if (job.kind === 'synopsis-revise' && job.state === 'done') {
|
|
1564
|
-
const result = job.result || {};
|
|
1565
|
-
if (typeof result.synopsis === 'string' && result.synopsis.trim() !== '') {
|
|
1566
|
-
setForm((current) => ({ ...current, synopsis: result.synopsis }));
|
|
1567
|
-
setDirty(true);
|
|
1568
|
-
}
|
|
1569
|
-
setSynopsisReviseOpen(false);
|
|
1570
|
-
notify(t('AI 已改写故事梗概,确认后点「保存」', 'AI rewrote the synopsis — review it, then press Save'), 'success');
|
|
1571
|
-
}
|
|
1572
1547
|
if (job.state === 'error') {
|
|
1573
|
-
if (job.kind === 'synopsis-revise') setSynopsisReviseError(job.error || t('改写失败', 'Revision failed'));
|
|
1574
1548
|
notify(job.error || t('任务失败', "Task failed"), 'error');
|
|
1575
1549
|
}
|
|
1576
1550
|
}, [job ? job.jobId : null, job ? job.state : null]);
|
|
@@ -1589,8 +1563,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1589
1563
|
? t('还没有故事梗概可改', 'There is no synopsis to revise yet')
|
|
1590
1564
|
: t('让 AI 按修改意见改写「故事梗概」(不动正文)', 'Let AI rewrite the synopsis from your notes (the chapter text stays)'),
|
|
1591
1565
|
onClick: () => {
|
|
1592
|
-
|
|
1593
|
-
setSynopsisReviseOpen(true);
|
|
1566
|
+
if (typeof onReviseSynopsis === 'function') onReviseSynopsis(form.synopsis);
|
|
1594
1567
|
},
|
|
1595
1568
|
}, t('AI 修改梗概', 'AI revise synopsis')),
|
|
1596
1569
|
h('button', {
|
|
@@ -1685,20 +1658,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1685
1658
|
type: 'button',
|
|
1686
1659
|
disabled: !savedAt || Boolean(generating || composing) || !chapter,
|
|
1687
1660
|
onClick: generate,
|
|
1688
|
-
}, generating ? t('生成中…', "Generating…") : t('生成章节故事', "Generate chapter")))
|
|
1689
|
-
synopsisReviseOpen
|
|
1690
|
-
? h(SynopsisReviseDialog, {
|
|
1691
|
-
chapter,
|
|
1692
|
-
synopsis: form.synopsis,
|
|
1693
|
-
busy: Boolean(revisingSynopsis),
|
|
1694
|
-
error: synopsisReviseError,
|
|
1695
|
-
onCancel: () => {
|
|
1696
|
-
if (revisingSynopsis) return;
|
|
1697
|
-
setSynopsisReviseOpen(false);
|
|
1698
|
-
},
|
|
1699
|
-
onConfirm: startSynopsisRevise,
|
|
1700
|
-
})
|
|
1701
|
-
: null);
|
|
1661
|
+
}, generating ? t('生成中…', "Generating…") : t('生成章节故事', "Generate chapter"))));
|
|
1702
1662
|
}
|
|
1703
1663
|
|
|
1704
1664
|
/* --------------------------------------------------------- new chapter */
|
|
@@ -1933,6 +1893,13 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1933
1893
|
const [reviseError, setReviseError] = useState('');
|
|
1934
1894
|
const [reviseJob, setReviseJob] = useJob();
|
|
1935
1895
|
const [summarizeJob, setSummarizeJob] = useJob();
|
|
1896
|
+
// The synopsis rewrite lives here, not in OutlinePage: the page renders
|
|
1897
|
+
// inside the translated track, where an absolutely positioned dialog would
|
|
1898
|
+
// be painted off-screen.
|
|
1899
|
+
const [synopsisTarget, setSynopsisTarget] = useState(null);
|
|
1900
|
+
const [synopsisError, setSynopsisError] = useState('');
|
|
1901
|
+
const [synopsisDraft, setSynopsisDraft] = useState(null);
|
|
1902
|
+
const [synopsisJob, setSynopsisJob] = useJob();
|
|
1936
1903
|
const [text, setText] = useState('');
|
|
1937
1904
|
const [dirty, setDirty] = useState(false);
|
|
1938
1905
|
const [saving, setSaving] = useState(false);
|
|
@@ -2006,19 +1973,19 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
2006
1973
|
}, [chapter, detail.projectId, text, setReviseJob]);
|
|
2007
1974
|
|
|
2008
1975
|
/** 生成故事梗概: read the prose and overwrite the stored synopsis. */
|
|
2009
|
-
const
|
|
1976
|
+
const syncOutline = useCallback(() => {
|
|
2010
1977
|
if (!chapter) return;
|
|
2011
1978
|
if (String(text || '').trim() === '') {
|
|
2012
|
-
notify(t('
|
|
1979
|
+
notify(t('这一章还没有正文,先写或先生成再同步大纲', 'This chapter has no text yet — write or generate it first.'), 'error');
|
|
2013
1980
|
return;
|
|
2014
1981
|
}
|
|
2015
1982
|
setConfirmRequest({
|
|
2016
|
-
title: t('
|
|
2017
|
-
text: t('
|
|
2018
|
-
confirmLabel: t('
|
|
1983
|
+
title: t('是否用当前章节故事同步章节大纲?', 'Sync the outline from this chapter’s text?'),
|
|
1984
|
+
text: t('会用当前正文重写「故事梗概」,并同步「故事时间长度」「最低字数」「最多字数」「梗概最低字数」四项,然后直接保存覆盖旧大纲。正文不会被修改。', 'The synopsis is rewritten from the text, and the story duration, minimum words, maximum words and synopsis floor are re-measured from it. The outline is saved immediately, replacing the old one; the chapter text is untouched.'),
|
|
1985
|
+
confirmLabel: t('同步并覆盖', 'Sync and replace'),
|
|
2019
1986
|
action: async () => {
|
|
2020
1987
|
try {
|
|
2021
|
-
const started = await api('/chapter/
|
|
1988
|
+
const started = await api('/chapter/sync-outline', {
|
|
2022
1989
|
projectId: detail.projectId,
|
|
2023
1990
|
chapterId: chapter.chapterId,
|
|
2024
1991
|
content: text,
|
|
@@ -2036,22 +2003,85 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
2036
2003
|
if (!summarizeJob) return undefined;
|
|
2037
2004
|
if (summarizeJob.state === 'running') {
|
|
2038
2005
|
setChildBusy({
|
|
2039
|
-
label: t('
|
|
2006
|
+
label: t('正在用正文同步章节大纲…', 'Syncing the outline from the text…'),
|
|
2040
2007
|
message: summarizeJob.message || '',
|
|
2041
2008
|
});
|
|
2042
2009
|
return undefined;
|
|
2043
2010
|
}
|
|
2044
2011
|
setChildBusy(null);
|
|
2045
2012
|
if (summarizeJob.state === 'done') {
|
|
2046
|
-
|
|
2013
|
+
const result = summarizeJob.result || {};
|
|
2014
|
+
const notes = Array.isArray(result.notes) ? result.notes.filter(Boolean) : [];
|
|
2015
|
+
notify(
|
|
2016
|
+
t('已同步大纲:梗概 {chars} 字 · 时间 {days} 天 · 字数 {min}–{max} · 梗概下限 {floor}',
|
|
2017
|
+
'Outline synced: {chars} characters of synopsis · {days} days · {min}–{max} words · synopsis floor {floor}',
|
|
2018
|
+
{
|
|
2019
|
+
chars: result.synopsis ? result.synopsis.length : 0,
|
|
2020
|
+
days: result.durationDays || 0,
|
|
2021
|
+
min: result.minWords || 0,
|
|
2022
|
+
max: result.maxWords || 0,
|
|
2023
|
+
floor: result.synopsisMinChars || 0,
|
|
2024
|
+
}),
|
|
2025
|
+
'success',
|
|
2026
|
+
);
|
|
2027
|
+
for (const note of notes) notify(note, 'error');
|
|
2047
2028
|
onReload();
|
|
2048
2029
|
} else if (summarizeJob.state === 'error') {
|
|
2049
|
-
notify(summarizeJob.error || t('
|
|
2030
|
+
notify(summarizeJob.error || t('同步大纲失败', 'Syncing the outline failed'), 'error');
|
|
2050
2031
|
}
|
|
2051
2032
|
return undefined;
|
|
2052
2033
|
}, [summarizeJob ? summarizeJob.jobId : null, summarizeJob ? summarizeJob.state : null, summarizeJob ? summarizeJob.message : null]);
|
|
2053
2034
|
|
|
2054
2035
|
const revising = Boolean(reviseJob && reviseJob.state === 'running');
|
|
2036
|
+
|
|
2037
|
+
/** Run the synopsis rewrite for the chapter the outline page handed up. */
|
|
2038
|
+
const sendSynopsisRevise = useCallback(async (instruction) => {
|
|
2039
|
+
const target = synopsisTarget;
|
|
2040
|
+
if (!target || !target.chapter) return;
|
|
2041
|
+
if (String(target.synopsis || '').trim() === '') {
|
|
2042
|
+
setSynopsisError(t('还没有故事梗概可改,先写一段再来', 'There is no synopsis yet — write one first.'));
|
|
2043
|
+
return;
|
|
2044
|
+
}
|
|
2045
|
+
if (String(instruction || '').trim() === '') {
|
|
2046
|
+
setSynopsisError(t('请先写清修改意见', 'Write what should change first.'));
|
|
2047
|
+
return;
|
|
2048
|
+
}
|
|
2049
|
+
setSynopsisError('');
|
|
2050
|
+
try {
|
|
2051
|
+
const started = await api('/outline/revise', {
|
|
2052
|
+
projectId: detail.projectId,
|
|
2053
|
+
chapterId: target.chapter.chapterId,
|
|
2054
|
+
synopsis: target.synopsis,
|
|
2055
|
+
instruction: String(instruction).trim(),
|
|
2056
|
+
});
|
|
2057
|
+
setSynopsisJob(started);
|
|
2058
|
+
} catch (error) {
|
|
2059
|
+
setSynopsisError(messageOf(error));
|
|
2060
|
+
}
|
|
2061
|
+
}, [synopsisTarget, detail.projectId, notify, setSynopsisJob]);
|
|
2062
|
+
|
|
2063
|
+
const revisingSynopsis = Boolean(synopsisJob && synopsisJob.state === 'running');
|
|
2064
|
+
useEffect(() => {
|
|
2065
|
+
if (!synopsisJob) return undefined;
|
|
2066
|
+
if (synopsisJob.state === 'running') {
|
|
2067
|
+
setChildBusy({
|
|
2068
|
+
label: t('正在按意见改写故事梗概…', 'Rewriting the synopsis…'),
|
|
2069
|
+
message: synopsisJob.message || '',
|
|
2070
|
+
});
|
|
2071
|
+
return undefined;
|
|
2072
|
+
}
|
|
2073
|
+
setChildBusy(null);
|
|
2074
|
+
if (synopsisJob.state === 'done') {
|
|
2075
|
+
const text = (synopsisJob.result || {}).synopsis;
|
|
2076
|
+
if (typeof text === 'string' && text.trim() !== '') setSynopsisDraft(text);
|
|
2077
|
+
setSynopsisTarget(null);
|
|
2078
|
+
notify(t('AI 已改写故事梗概,确认后点「保存」', 'AI rewrote the synopsis — review it, then press Save'), 'success');
|
|
2079
|
+
} else if (synopsisJob.state === 'error') {
|
|
2080
|
+
setSynopsisError(synopsisJob.error || t('改写失败', 'Revision failed'));
|
|
2081
|
+
}
|
|
2082
|
+
return undefined;
|
|
2083
|
+
}, [synopsisJob ? synopsisJob.jobId : null, synopsisJob ? synopsisJob.state : null, synopsisJob ? synopsisJob.message : null]);
|
|
2084
|
+
|
|
2055
2085
|
useEffect(() => {
|
|
2056
2086
|
if (!reviseJob) return undefined;
|
|
2057
2087
|
if (reviseJob.state === 'running') {
|
|
@@ -2267,7 +2297,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
2267
2297
|
setReviseError('');
|
|
2268
2298
|
setReviseOpen(true);
|
|
2269
2299
|
},
|
|
2270
|
-
onSummarize:
|
|
2300
|
+
onSummarize: syncOutline,
|
|
2271
2301
|
summarizing: Boolean(summarizeJob && summarizeJob.state === 'running'),
|
|
2272
2302
|
})),
|
|
2273
2303
|
h('section', { className: 'dsn-page' },
|
|
@@ -2283,6 +2313,13 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
2283
2313
|
onRegisterGuard: registerOutlineGuard,
|
|
2284
2314
|
requestConfirm: setConfirmRequest,
|
|
2285
2315
|
onBusyChange: setChildBusy,
|
|
2316
|
+
onReviseSynopsis: (synopsis) => {
|
|
2317
|
+
setSynopsisError('');
|
|
2318
|
+
setSynopsisTarget({ chapter, synopsis });
|
|
2319
|
+
},
|
|
2320
|
+
synopsisDraft,
|
|
2321
|
+
onSynopsisDraftApplied: () => setSynopsisDraft(null),
|
|
2322
|
+
revisingSynopsis,
|
|
2286
2323
|
isFirstChapter: Boolean(chapter && chapters.length > 0 && chapter.chapterId === chapters[0].chapterId),
|
|
2287
2324
|
}))),
|
|
2288
2325
|
confirmRequest
|
|
@@ -2298,6 +2335,20 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
2298
2335
|
},
|
|
2299
2336
|
})
|
|
2300
2337
|
: null,
|
|
2338
|
+
synopsisTarget
|
|
2339
|
+
? h(SynopsisReviseDialog, {
|
|
2340
|
+
chapter: synopsisTarget.chapter,
|
|
2341
|
+
synopsis: synopsisTarget.synopsis,
|
|
2342
|
+
busy: revisingSynopsis,
|
|
2343
|
+
error: synopsisError,
|
|
2344
|
+
onCancel: () => {
|
|
2345
|
+
if (revisingSynopsis) return;
|
|
2346
|
+
setSynopsisTarget(null);
|
|
2347
|
+
setSynopsisError('');
|
|
2348
|
+
},
|
|
2349
|
+
onConfirm: sendSynopsisRevise,
|
|
2350
|
+
})
|
|
2351
|
+
: null,
|
|
2301
2352
|
reviseOpen
|
|
2302
2353
|
? h(ReviseDialog, {
|
|
2303
2354
|
chapter,
|
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.5',
|
|
1503
1503
|
projects,
|
|
1504
1504
|
models: {
|
|
1505
1505
|
default: modelCatalog.default || null,
|
|
@@ -2012,19 +2012,28 @@ async function updatePreset(ctx, root, body) {
|
|
|
2012
2012
|
return { project, appliesAfterRebuild: true };
|
|
2013
2013
|
}
|
|
2014
2014
|
|
|
2015
|
-
/**
|
|
2016
|
-
|
|
2015
|
+
/**
|
|
2016
|
+
* Prompt for syncing a written chapter back into its outline: the synopsis plus
|
|
2017
|
+
* the four numbers the outline records, all measured from this one chapter.
|
|
2018
|
+
*/
|
|
2019
|
+
function syncOutlinePrompt(project, chapter, prose) {
|
|
2017
2020
|
const span = chapterWindow(project, chapter);
|
|
2021
|
+
const chars = wordCount(prose);
|
|
2018
2022
|
const lines = [];
|
|
2019
|
-
lines.push('
|
|
2020
|
-
lines.push('
|
|
2021
|
-
lines.push('
|
|
2023
|
+
lines.push('你是这部小说的编辑。作者已经写完了下面这一章,请据此同步它的章节大纲。');
|
|
2024
|
+
lines.push('读正文,输出一个 JSON 对象,不要输出别的文字、不要代码块:');
|
|
2025
|
+
lines.push('{"synopsis":"……","durationDays":N,"minWords":N,"maxWords":N,"synopsisMinChars":N}');
|
|
2026
|
+
lines.push('');
|
|
2027
|
+
lines.push('各项含义:');
|
|
2028
|
+
lines.push('- synopsis:这一章的故事梗概,写清起因、经过、转折、结果,供后续章节与世界观使用;300 字以内,纯文本。');
|
|
2029
|
+
lines.push(`- durationDays:这段剧情在故事里覆盖的天数,整数 1–365。当前记为第 ${span.start}→${span.end} 天(共 ${span.end - span.start} 天),按正文实际推进判断是否需要改。`);
|
|
2030
|
+
lines.push(`- minWords / maxWords:这一章应有的字数下限与上限,整数;本章正文实际 ${chars} 字,范围应当把 ${chars} 包在里面。`);
|
|
2031
|
+
lines.push('- synopsisMinChars:上面这段梗概算「合格」的最低字数,整数 20–400,取梗概长度的八成左右。');
|
|
2022
2032
|
lines.push('');
|
|
2023
2033
|
const prelude = preludeBlock(project);
|
|
2024
2034
|
if (prelude !== '') lines.push(prelude);
|
|
2025
2035
|
lines.push(`【本章】第 ${chapter.index + 1} 章 ${chapter.title || ''}(第 ${span.start}→${span.end} 天)`);
|
|
2026
|
-
lines.push(
|
|
2027
|
-
lines.push('【正文】');
|
|
2036
|
+
lines.push(`【正文】(共 ${chars} 字,不含空白)`);
|
|
2028
2037
|
lines.push(prose);
|
|
2029
2038
|
return lines.join('\n');
|
|
2030
2039
|
}
|
|
@@ -2062,50 +2071,106 @@ function cleanSynopsis(text) {
|
|
|
2062
2071
|
}
|
|
2063
2072
|
|
|
2064
2073
|
/**
|
|
2065
|
-
*
|
|
2074
|
+
* Sync a written chapter back into its outline.
|
|
2066
2075
|
*
|
|
2067
|
-
*
|
|
2068
|
-
*
|
|
2069
|
-
*
|
|
2076
|
+
* Everything the outline records is re-measured from the finished chapter: the
|
|
2077
|
+
* synopsis, the story duration, the word bounds and the synopsis floor. This is
|
|
2078
|
+
* the one place generated values overwrite what the author had, so the Client
|
|
2079
|
+
* asks first: an outline is the material every later generation leans on.
|
|
2070
2080
|
*/
|
|
2071
|
-
async function
|
|
2081
|
+
async function startSyncOutline(ctx, root, registry, body) {
|
|
2072
2082
|
const projectId = asText(body.projectId);
|
|
2073
2083
|
const chapterId = asText(body.chapterId);
|
|
2074
2084
|
const prose = typeof body.content === 'string' ? body.content : '';
|
|
2075
|
-
if (prose.trim() === '') throw httpError(400, '
|
|
2085
|
+
if (prose.trim() === '') throw httpError(400, '这一章还没有正文,无法同步章节大纲');
|
|
2076
2086
|
const preview = await readProject(root, projectId);
|
|
2077
2087
|
if (!preview.chapters.some((entry) => entry.chapterId === chapterId)) {
|
|
2078
2088
|
throw httpError(404, `章节不存在: ${chapterId}`);
|
|
2079
2089
|
}
|
|
2080
|
-
return startJob(registry, { kind: '
|
|
2090
|
+
return startJob(registry, { kind: 'sync-outline', projectId, chapterId }, async (job) => {
|
|
2081
2091
|
const project = await readProject(root, projectId);
|
|
2082
2092
|
const chapter = project.chapters.find((entry) => entry.chapterId === chapterId);
|
|
2083
2093
|
if (!chapter) throw httpError(404, `章节不存在: ${chapterId}`);
|
|
2084
|
-
job.message = '
|
|
2085
|
-
const raw = await
|
|
2094
|
+
job.message = '正在读正文同步章节大纲';
|
|
2095
|
+
const raw = await askAgent(ctx, root, project, syncOutlinePrompt(project, chapter, prose), (note) => {
|
|
2086
2096
|
job.message = note;
|
|
2087
2097
|
});
|
|
2088
|
-
const
|
|
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
|
+
);
|
|
2089
2105
|
if (synopsis === '') throw httpError(502, '模型没有给出梗概');
|
|
2106
|
+
|
|
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
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2090
2145
|
const saved = await mutateProject(root, projectId, (draft) => {
|
|
2091
2146
|
const target = draft.chapters.find((entry) => entry.chapterId === chapterId);
|
|
2092
2147
|
if (!target) throw httpError(404, `章节不存在: ${chapterId}`);
|
|
2093
|
-
const previous = target.outline || {};
|
|
2094
2148
|
target.outline = {
|
|
2095
2149
|
...previous,
|
|
2096
|
-
minWords: previous.minWords || 800,
|
|
2097
|
-
maxWords: previous.maxWords || 3000,
|
|
2098
|
-
durationDays: previous.durationDays || target.durationDays,
|
|
2099
|
-
synopsisMinChars: previous.synopsisMinChars || 100,
|
|
2100
2150
|
synopsis,
|
|
2151
|
+
minWords,
|
|
2152
|
+
maxWords,
|
|
2153
|
+
durationDays,
|
|
2154
|
+
synopsisMinChars,
|
|
2101
2155
|
savedAt: Date.now(),
|
|
2102
2156
|
};
|
|
2157
|
+
target.durationDays = durationDays;
|
|
2103
2158
|
target.updatedAt = Date.now();
|
|
2104
2159
|
return { chapter: { ...target, content: undefined }, project: summarize(draft) };
|
|
2105
2160
|
});
|
|
2106
|
-
|
|
2107
|
-
await
|
|
2108
|
-
|
|
2161
|
+
const fresh = await readProject(root, projectId);
|
|
2162
|
+
await exportChapterOutline(fresh, saved.chapter);
|
|
2163
|
+
await exportProjectInfo(fresh);
|
|
2164
|
+
return {
|
|
2165
|
+
synopsis,
|
|
2166
|
+
minWords,
|
|
2167
|
+
maxWords,
|
|
2168
|
+
durationDays,
|
|
2169
|
+
synopsisMinChars,
|
|
2170
|
+
chars,
|
|
2171
|
+
notes,
|
|
2172
|
+
chapter: saved.chapter,
|
|
2173
|
+
};
|
|
2109
2174
|
});
|
|
2110
2175
|
}
|
|
2111
2176
|
|
|
@@ -2927,8 +2992,8 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
|
|
|
2927
2992
|
return startGenerate(ctx, root, registry, body);
|
|
2928
2993
|
case '/chapter/revise':
|
|
2929
2994
|
return startRevise(ctx, root, registry, body);
|
|
2930
|
-
case '/chapter/
|
|
2931
|
-
return
|
|
2995
|
+
case '/chapter/sync-outline':
|
|
2996
|
+
return startSyncOutline(ctx, root, registry, body);
|
|
2932
2997
|
case '/outline/revise':
|
|
2933
2998
|
return startSynopsisRevise(ctx, root, registry, body);
|
|
2934
2999
|
case '/compose':
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-novel-generator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.34",
|
|
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",
|