dsh-novel-generator 0.1.31 → 0.1.33
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 +200 -2
- package/index.js +126 -1
- 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
|
@@ -1251,7 +1251,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1251
1251
|
}, busy ? t('修改中…', 'Revising…') : t('确认修改', 'Revise')))));
|
|
1252
1252
|
}
|
|
1253
1253
|
|
|
1254
|
-
function StoryPage({ detail, chapter, previous, next, text, saving, onEdit, onSave, onBack, onOutline, onOpenChapter, onNewChapter, onRevise }) {
|
|
1254
|
+
function StoryPage({ detail, chapter, previous, next, text, saving, onEdit, onSave, onBack, onOutline, onOpenChapter, onNewChapter, onRevise, onSummarize, summarizing }) {
|
|
1255
1255
|
const words = countWords(text);
|
|
1256
1256
|
const window = chapter ? chapterWindow(detail, chapter) : null;
|
|
1257
1257
|
|
|
@@ -1293,6 +1293,15 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1293
1293
|
h('div', { className: 'dsn-muted' },
|
|
1294
1294
|
t('{words} 字{req}', '{words} words{req}', { words, req: chapter.outline ? t(' · 大纲要求 {min}–{max} 字', ' · outline asks for {min}–{max} words', { min: chapter.outline.minWords, max: chapter.outline.maxWords }) : '' })),
|
|
1295
1295
|
h('div', { className: 'dsn-spacer' }),
|
|
1296
|
+
h('button', {
|
|
1297
|
+
className: 'dsn-btn',
|
|
1298
|
+
type: 'button',
|
|
1299
|
+
disabled: saving || Boolean(summarizing) || String(text || '').trim() === '',
|
|
1300
|
+
title: String(text || '').trim() === ''
|
|
1301
|
+
? t('这一章还没有正文,先写或先生成', 'No text yet — write or generate the chapter first')
|
|
1302
|
+
: t('用本章正文重新生成故事梗概,会覆盖现有梗概', 'Recreate the synopsis from this chapter’s text, replacing the current one'),
|
|
1303
|
+
onClick: onSummarize,
|
|
1304
|
+
}, summarizing ? t('生成梗概中…', 'Summarizing…') : t('生成故事梗概', 'Synopsis from text')),
|
|
1296
1305
|
h('button', {
|
|
1297
1306
|
className: 'dsn-btn',
|
|
1298
1307
|
type: 'button',
|
|
@@ -1308,7 +1317,47 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1308
1317
|
|
|
1309
1318
|
/* ---------------------------------------------------------- outline page */
|
|
1310
1319
|
|
|
1311
|
-
|
|
1320
|
+
/**
|
|
1321
|
+
* Ask the model to rewrite the chapter's synopsis from a note. Unlike the
|
|
1322
|
+
* story page's dialog this one works on the outline, and the result comes back
|
|
1323
|
+
* as an unsaved draft for the author to keep.
|
|
1324
|
+
*/
|
|
1325
|
+
function SynopsisReviseDialog({ chapter, synopsis, busy, error, onCancel, onConfirm }) {
|
|
1326
|
+
const [instruction, setInstruction] = useState('');
|
|
1327
|
+
const ready = instruction.trim() !== '' && String(synopsis || '').trim() !== '';
|
|
1328
|
+
|
|
1329
|
+
return h('div', { className: 'dsn-dialog-backdrop' },
|
|
1330
|
+
h('div', { className: 'dsn-dialog', style: { width: 'min(560px, calc(100% - 48px))' } },
|
|
1331
|
+
h('div', { className: 'dsn-dialog-head' },
|
|
1332
|
+
h('div', { className: 'dsn-title' }, t('AI 修改故事梗概', 'Revise the synopsis with AI')),
|
|
1333
|
+
h('div', { className: 'dsn-sub' }, chapter ? t('第 {n} 章 · {title}', 'Chapter {n} · {title}', { n: chapter.index + 1, title: chapter.title || t('未命名', 'Untitled') }) : ''),
|
|
1334
|
+
h('div', { className: 'dsn-spacer' })),
|
|
1335
|
+
h('div', { className: 'dsn-dialog-body' },
|
|
1336
|
+
h('label', { className: 'dsn-field span4' },
|
|
1337
|
+
h('span', { className: 'dsn-label' }, t('修改意见(必填)', 'What to change (required)')),
|
|
1338
|
+
h('textarea', {
|
|
1339
|
+
className: 'dsn-textarea',
|
|
1340
|
+
style: { minHeight: '120px' },
|
|
1341
|
+
value: instruction,
|
|
1342
|
+
placeholder: t('针对「故事梗概」的意见,例如:把转折写得更明确,补上主角离开村庄的动机;删掉与主线无关的配角。', 'Notes about the synopsis itself, e.g. make the turn explicit, add the reason the hero leaves the village, drop the subplot that goes nowhere.'),
|
|
1343
|
+
onChange: (event) => setInstruction(event.target.value),
|
|
1344
|
+
})),
|
|
1345
|
+
String(synopsis || '').trim() === ''
|
|
1346
|
+
? h('div', { className: 'dsn-hint warn span4' }, t('还没有故事梗概,先写一段再来让 AI 改。', 'There is no synopsis yet — write one before revising it.'))
|
|
1347
|
+
: h('div', { className: 'dsn-hint span4' }, t('只改「故事梗概」,不动正文与章节名。结果填回梗概框但不自动保存。', 'Only the synopsis is rewritten; the text and title stay. The result fills the box unsaved.')),
|
|
1348
|
+
error ? h('div', { className: 'dsn-hint warn span4' }, error) : null),
|
|
1349
|
+
h('div', { className: 'dsn-dialog-foot' },
|
|
1350
|
+
h('button', { className: 'dsn-btn ghost', type: 'button', disabled: busy, onClick: onCancel }, t('取消', 'Cancel')),
|
|
1351
|
+
h('div', { className: 'dsn-spacer' }),
|
|
1352
|
+
h('button', {
|
|
1353
|
+
className: 'dsn-btn primary',
|
|
1354
|
+
type: 'button',
|
|
1355
|
+
disabled: busy || !ready,
|
|
1356
|
+
onClick: () => onConfirm(instruction.trim()),
|
|
1357
|
+
}, busy ? t('改写中…', 'Revising…') : t('确认修改', 'Revise')))));
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
function OutlinePage({ detail, chapter, isFirstChapter, onBack, onSaved, notify, onRegisterGuard, requestConfirm, onBusyChange, onReviseSynopsis, synopsisDraft, onSynopsisDraftApplied, revisingSynopsis }) {
|
|
1312
1361
|
const buildForm = useCallback(() => ({
|
|
1313
1362
|
title: chapter ? chapter.title || '' : '',
|
|
1314
1363
|
durationDays: chapter && chapter.outline ? chapter.outline.durationDays : (detail.timeline.unitDays || 30),
|
|
@@ -1412,6 +1461,18 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1412
1461
|
const generating = job && job.state === 'running' && job.kind === 'generate';
|
|
1413
1462
|
const composing = job && job.state === 'running' && job.kind === 'compose';
|
|
1414
1463
|
|
|
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]);
|
|
1475
|
+
|
|
1415
1476
|
/** 自动推演: deduce synopsis, title and the whole chapter, then save. */
|
|
1416
1477
|
const deduce = useCallback(() => {
|
|
1417
1478
|
if (isFirstChapter) {
|
|
@@ -1494,6 +1555,17 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1494
1555
|
h('div', { className: 'dsn-title' }, chapter ? t('章节大纲 · 第 {n} 章', 'Outline · chapter {n}', { n: chapter.index + 1 }) : t('章节大纲 · 新章节', "Outline · new chapter")),
|
|
1495
1556
|
h('div', { className: 'dsn-sub' }, t('起始 第 {day} 天', 'Starts day {day}', { day: chapter ? chapterWindow(detail, chapter).start : (detail.timeline.cursor || 0) })),
|
|
1496
1557
|
h('div', { className: 'dsn-spacer' }),
|
|
1558
|
+
h('button', {
|
|
1559
|
+
className: 'dsn-btn ghost',
|
|
1560
|
+
type: 'button',
|
|
1561
|
+
disabled: !chapter || String(form.synopsis || '').trim() === '' || saving || Boolean(generating || composing || revisingSynopsis),
|
|
1562
|
+
title: String(form.synopsis || '').trim() === ''
|
|
1563
|
+
? t('还没有故事梗概可改', 'There is no synopsis to revise yet')
|
|
1564
|
+
: t('让 AI 按修改意见改写「故事梗概」(不动正文)', 'Let AI rewrite the synopsis from your notes (the chapter text stays)'),
|
|
1565
|
+
onClick: () => {
|
|
1566
|
+
if (typeof onReviseSynopsis === 'function') onReviseSynopsis(form.synopsis);
|
|
1567
|
+
},
|
|
1568
|
+
}, t('AI 修改梗概', 'AI revise synopsis')),
|
|
1497
1569
|
h('button', {
|
|
1498
1570
|
className: 'dsn-btn ghost',
|
|
1499
1571
|
type: 'button',
|
|
@@ -1820,6 +1892,14 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1820
1892
|
const [reviseOpen, setReviseOpen] = useState(false);
|
|
1821
1893
|
const [reviseError, setReviseError] = useState('');
|
|
1822
1894
|
const [reviseJob, setReviseJob] = useJob();
|
|
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();
|
|
1823
1903
|
const [text, setText] = useState('');
|
|
1824
1904
|
const [dirty, setDirty] = useState(false);
|
|
1825
1905
|
const [saving, setSaving] = useState(false);
|
|
@@ -1892,7 +1972,102 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1892
1972
|
}
|
|
1893
1973
|
}, [chapter, detail.projectId, text, setReviseJob]);
|
|
1894
1974
|
|
|
1975
|
+
/** 生成故事梗概: read the prose and overwrite the stored synopsis. */
|
|
1976
|
+
const summarizeChapter = useCallback(() => {
|
|
1977
|
+
if (!chapter) return;
|
|
1978
|
+
if (String(text || '').trim() === '') {
|
|
1979
|
+
notify(t('这一章还没有正文,先写或先生成再生成梗概', 'This chapter has no text yet — write or generate it first.'), 'error');
|
|
1980
|
+
return;
|
|
1981
|
+
}
|
|
1982
|
+
setConfirmRequest({
|
|
1983
|
+
title: t('是否以当前章节故事生成故事梗概?', 'Create the synopsis from this chapter’s text?'),
|
|
1984
|
+
text: t('会用当前正文生成一段新的故事梗概,并「直接保存、覆盖原有梗概」。正文不会被修改。', 'A new synopsis is generated from the text and saved immediately, replacing the existing one. The chapter text itself is untouched.'),
|
|
1985
|
+
confirmLabel: t('生成并覆盖', 'Generate and replace'),
|
|
1986
|
+
action: async () => {
|
|
1987
|
+
try {
|
|
1988
|
+
const started = await api('/chapter/summarize', {
|
|
1989
|
+
projectId: detail.projectId,
|
|
1990
|
+
chapterId: chapter.chapterId,
|
|
1991
|
+
content: text,
|
|
1992
|
+
});
|
|
1993
|
+
setSummarizeJob(started);
|
|
1994
|
+
} catch (error) {
|
|
1995
|
+
notify(messageOf(error), 'error');
|
|
1996
|
+
}
|
|
1997
|
+
},
|
|
1998
|
+
});
|
|
1999
|
+
}, [chapter, detail.projectId, text, notify, setConfirmRequest, setSummarizeJob]);
|
|
2000
|
+
|
|
2001
|
+
const summarizing = Boolean(summarizeJob && summarizeJob.state === 'running');
|
|
2002
|
+
useEffect(() => {
|
|
2003
|
+
if (!summarizeJob) return undefined;
|
|
2004
|
+
if (summarizeJob.state === 'running') {
|
|
2005
|
+
setChildBusy({
|
|
2006
|
+
label: t('正在用正文生成故事梗概…', 'Building the synopsis from the text…'),
|
|
2007
|
+
message: summarizeJob.message || '',
|
|
2008
|
+
});
|
|
2009
|
+
return undefined;
|
|
2010
|
+
}
|
|
2011
|
+
setChildBusy(null);
|
|
2012
|
+
if (summarizeJob.state === 'done') {
|
|
2013
|
+
notify(t('已用正文生成并保存故事梗概', 'Synopsis generated from the text and saved'), 'success');
|
|
2014
|
+
onReload();
|
|
2015
|
+
} else if (summarizeJob.state === 'error') {
|
|
2016
|
+
notify(summarizeJob.error || t('生成梗概失败', 'Building the synopsis failed'), 'error');
|
|
2017
|
+
}
|
|
2018
|
+
return undefined;
|
|
2019
|
+
}, [summarizeJob ? summarizeJob.jobId : null, summarizeJob ? summarizeJob.state : null, summarizeJob ? summarizeJob.message : null]);
|
|
2020
|
+
|
|
1895
2021
|
const revising = Boolean(reviseJob && reviseJob.state === 'running');
|
|
2022
|
+
|
|
2023
|
+
/** Run the synopsis rewrite for the chapter the outline page handed up. */
|
|
2024
|
+
const sendSynopsisRevise = useCallback(async (instruction) => {
|
|
2025
|
+
const target = synopsisTarget;
|
|
2026
|
+
if (!target || !target.chapter) return;
|
|
2027
|
+
if (String(target.synopsis || '').trim() === '') {
|
|
2028
|
+
setSynopsisError(t('还没有故事梗概可改,先写一段再来', 'There is no synopsis yet — write one first.'));
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
if (String(instruction || '').trim() === '') {
|
|
2032
|
+
setSynopsisError(t('请先写清修改意见', 'Write what should change first.'));
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
setSynopsisError('');
|
|
2036
|
+
try {
|
|
2037
|
+
const started = await api('/outline/revise', {
|
|
2038
|
+
projectId: detail.projectId,
|
|
2039
|
+
chapterId: target.chapter.chapterId,
|
|
2040
|
+
synopsis: target.synopsis,
|
|
2041
|
+
instruction: String(instruction).trim(),
|
|
2042
|
+
});
|
|
2043
|
+
setSynopsisJob(started);
|
|
2044
|
+
} catch (error) {
|
|
2045
|
+
setSynopsisError(messageOf(error));
|
|
2046
|
+
}
|
|
2047
|
+
}, [synopsisTarget, detail.projectId, notify, setSynopsisJob]);
|
|
2048
|
+
|
|
2049
|
+
const revisingSynopsis = Boolean(synopsisJob && synopsisJob.state === 'running');
|
|
2050
|
+
useEffect(() => {
|
|
2051
|
+
if (!synopsisJob) return undefined;
|
|
2052
|
+
if (synopsisJob.state === 'running') {
|
|
2053
|
+
setChildBusy({
|
|
2054
|
+
label: t('正在按意见改写故事梗概…', 'Rewriting the synopsis…'),
|
|
2055
|
+
message: synopsisJob.message || '',
|
|
2056
|
+
});
|
|
2057
|
+
return undefined;
|
|
2058
|
+
}
|
|
2059
|
+
setChildBusy(null);
|
|
2060
|
+
if (synopsisJob.state === 'done') {
|
|
2061
|
+
const text = (synopsisJob.result || {}).synopsis;
|
|
2062
|
+
if (typeof text === 'string' && text.trim() !== '') setSynopsisDraft(text);
|
|
2063
|
+
setSynopsisTarget(null);
|
|
2064
|
+
notify(t('AI 已改写故事梗概,确认后点「保存」', 'AI rewrote the synopsis — review it, then press Save'), 'success');
|
|
2065
|
+
} else if (synopsisJob.state === 'error') {
|
|
2066
|
+
setSynopsisError(synopsisJob.error || t('改写失败', 'Revision failed'));
|
|
2067
|
+
}
|
|
2068
|
+
return undefined;
|
|
2069
|
+
}, [synopsisJob ? synopsisJob.jobId : null, synopsisJob ? synopsisJob.state : null, synopsisJob ? synopsisJob.message : null]);
|
|
2070
|
+
|
|
1896
2071
|
useEffect(() => {
|
|
1897
2072
|
if (!reviseJob) return undefined;
|
|
1898
2073
|
if (reviseJob.state === 'running') {
|
|
@@ -2108,6 +2283,8 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
2108
2283
|
setReviseError('');
|
|
2109
2284
|
setReviseOpen(true);
|
|
2110
2285
|
},
|
|
2286
|
+
onSummarize: summarizeChapter,
|
|
2287
|
+
summarizing: Boolean(summarizeJob && summarizeJob.state === 'running'),
|
|
2111
2288
|
})),
|
|
2112
2289
|
h('section', { className: 'dsn-page' },
|
|
2113
2290
|
h(OutlinePage, {
|
|
@@ -2122,6 +2299,13 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
2122
2299
|
onRegisterGuard: registerOutlineGuard,
|
|
2123
2300
|
requestConfirm: setConfirmRequest,
|
|
2124
2301
|
onBusyChange: setChildBusy,
|
|
2302
|
+
onReviseSynopsis: (synopsis) => {
|
|
2303
|
+
setSynopsisError('');
|
|
2304
|
+
setSynopsisTarget({ chapter, synopsis });
|
|
2305
|
+
},
|
|
2306
|
+
synopsisDraft,
|
|
2307
|
+
onSynopsisDraftApplied: () => setSynopsisDraft(null),
|
|
2308
|
+
revisingSynopsis,
|
|
2125
2309
|
isFirstChapter: Boolean(chapter && chapters.length > 0 && chapter.chapterId === chapters[0].chapterId),
|
|
2126
2310
|
}))),
|
|
2127
2311
|
confirmRequest
|
|
@@ -2137,6 +2321,20 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
2137
2321
|
},
|
|
2138
2322
|
})
|
|
2139
2323
|
: null,
|
|
2324
|
+
synopsisTarget
|
|
2325
|
+
? h(SynopsisReviseDialog, {
|
|
2326
|
+
chapter: synopsisTarget.chapter,
|
|
2327
|
+
synopsis: synopsisTarget.synopsis,
|
|
2328
|
+
busy: revisingSynopsis,
|
|
2329
|
+
error: synopsisError,
|
|
2330
|
+
onCancel: () => {
|
|
2331
|
+
if (revisingSynopsis) return;
|
|
2332
|
+
setSynopsisTarget(null);
|
|
2333
|
+
setSynopsisError('');
|
|
2334
|
+
},
|
|
2335
|
+
onConfirm: sendSynopsisRevise,
|
|
2336
|
+
})
|
|
2337
|
+
: null,
|
|
2140
2338
|
reviseOpen
|
|
2141
2339
|
? h(ReviseDialog, {
|
|
2142
2340
|
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-
|
|
1502
|
+
revision: '2026-10-28.4',
|
|
1503
1503
|
projects,
|
|
1504
1504
|
models: {
|
|
1505
1505
|
default: modelCatalog.default || null,
|
|
@@ -2012,6 +2012,127 @@ async function updatePreset(ctx, root, body) {
|
|
|
2012
2012
|
return { project, appliesAfterRebuild: true };
|
|
2013
2013
|
}
|
|
2014
2014
|
|
|
2015
|
+
/** Prompt for turning a chapter's prose back into its synopsis. */
|
|
2016
|
+
function summarizePrompt(project, chapter, prose) {
|
|
2017
|
+
const span = chapterWindow(project, chapter);
|
|
2018
|
+
const lines = [];
|
|
2019
|
+
lines.push('你是这部小说的编辑。请把下面这一章的正文压缩成一段章节梗概。');
|
|
2020
|
+
lines.push('只输出梗概本身:不要标题、不要分点、不要解释、不要评价,300 字以内。');
|
|
2021
|
+
lines.push('梗概要写清起因、经过、转折与结果,供后续章节与世界观使用。');
|
|
2022
|
+
lines.push('');
|
|
2023
|
+
const prelude = preludeBlock(project);
|
|
2024
|
+
if (prelude !== '') lines.push(prelude);
|
|
2025
|
+
lines.push(`【本章】第 ${chapter.index + 1} 章 ${chapter.title || ''}(第 ${span.start}→${span.end} 天)`);
|
|
2026
|
+
lines.push('');
|
|
2027
|
+
lines.push('【正文】');
|
|
2028
|
+
lines.push(prose);
|
|
2029
|
+
return lines.join('\n');
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
/** Prompt for rewriting a synopsis from the author's note. */
|
|
2033
|
+
function synopsisRevisePrompt(project, chapter, synopsis, instruction) {
|
|
2034
|
+
const lines = [];
|
|
2035
|
+
lines.push('你是这部小说的编辑。下面是本章现有的故事梗概,以及作者给出的修改意见。');
|
|
2036
|
+
lines.push('请按修改意见改写这段梗概,输出改写后的**完整梗概**:不要解释、不要列改动点、不要标题,300 字以内。');
|
|
2037
|
+
lines.push('');
|
|
2038
|
+
const prelude = preludeBlock(project);
|
|
2039
|
+
if (prelude !== '') lines.push(prelude);
|
|
2040
|
+
lines.push(`【本章】第 ${chapter.index + 1} 章 ${chapter.title || ''}`);
|
|
2041
|
+
lines.push('');
|
|
2042
|
+
lines.push('【现有梗概】');
|
|
2043
|
+
lines.push(synopsis);
|
|
2044
|
+
lines.push('');
|
|
2045
|
+
lines.push('【修改意见】');
|
|
2046
|
+
lines.push(instruction);
|
|
2047
|
+
lines.push('');
|
|
2048
|
+
lines.push('【要求】');
|
|
2049
|
+
lines.push('- 没被意见推翻的情节保留;与正文一致的事实不要改。');
|
|
2050
|
+
lines.push('- 只输出梗概正文,不要加「梗概:」之类的前缀。');
|
|
2051
|
+
return lines.join('\n');
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
/** Trim a model-written blurb down to plain synopsis text. */
|
|
2055
|
+
function cleanSynopsis(text) {
|
|
2056
|
+
return String(text || '')
|
|
2057
|
+
.replace(/^\s*#{1,6}\s*[^\n]*\n+/u, '')
|
|
2058
|
+
.replace(/^\s*梗概[::]\s*/u, '')
|
|
2059
|
+
.replace(/^```[a-z]*\n?/u, '')
|
|
2060
|
+
.replace(/\n?```\s*$/u, '')
|
|
2061
|
+
.trim();
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
/**
|
|
2065
|
+
* Turn a chapter's prose into its synopsis and save it.
|
|
2066
|
+
*
|
|
2067
|
+
* This is the one place a generated summary overwrites the stored one, so the
|
|
2068
|
+
* Client asks before calling it: an author's own synopsis is the expensive part
|
|
2069
|
+
* of an outline, and replacing it is never silent.
|
|
2070
|
+
*/
|
|
2071
|
+
async function startSummarize(ctx, root, registry, body) {
|
|
2072
|
+
const projectId = asText(body.projectId);
|
|
2073
|
+
const chapterId = asText(body.chapterId);
|
|
2074
|
+
const prose = typeof body.content === 'string' ? body.content : '';
|
|
2075
|
+
if (prose.trim() === '') throw httpError(400, '这一章还没有正文,无法生成梗概');
|
|
2076
|
+
const preview = await readProject(root, projectId);
|
|
2077
|
+
if (!preview.chapters.some((entry) => entry.chapterId === chapterId)) {
|
|
2078
|
+
throw httpError(404, `章节不存在: ${chapterId}`);
|
|
2079
|
+
}
|
|
2080
|
+
return startJob(registry, { kind: 'summarize', projectId, chapterId }, async (job) => {
|
|
2081
|
+
const project = await readProject(root, projectId);
|
|
2082
|
+
const chapter = project.chapters.find((entry) => entry.chapterId === chapterId);
|
|
2083
|
+
if (!chapter) throw httpError(404, `章节不存在: ${chapterId}`);
|
|
2084
|
+
job.message = '正在读正文生成梗概';
|
|
2085
|
+
const raw = await askStory(ctx, root, project, summarizePrompt(project, chapter, prose), (note) => {
|
|
2086
|
+
job.message = note;
|
|
2087
|
+
});
|
|
2088
|
+
const synopsis = cleanSynopsis(raw);
|
|
2089
|
+
if (synopsis === '') throw httpError(502, '模型没有给出梗概');
|
|
2090
|
+
const saved = await mutateProject(root, projectId, (draft) => {
|
|
2091
|
+
const target = draft.chapters.find((entry) => entry.chapterId === chapterId);
|
|
2092
|
+
if (!target) throw httpError(404, `章节不存在: ${chapterId}`);
|
|
2093
|
+
const previous = target.outline || {};
|
|
2094
|
+
target.outline = {
|
|
2095
|
+
...previous,
|
|
2096
|
+
minWords: previous.minWords || 800,
|
|
2097
|
+
maxWords: previous.maxWords || 3000,
|
|
2098
|
+
durationDays: previous.durationDays || target.durationDays,
|
|
2099
|
+
synopsisMinChars: previous.synopsisMinChars || 100,
|
|
2100
|
+
synopsis,
|
|
2101
|
+
savedAt: Date.now(),
|
|
2102
|
+
};
|
|
2103
|
+
target.updatedAt = Date.now();
|
|
2104
|
+
return { chapter: { ...target, content: undefined }, project: summarize(draft) };
|
|
2105
|
+
});
|
|
2106
|
+
await exportChapterOutline(await readProject(root, projectId), saved.chapter);
|
|
2107
|
+
await exportProjectInfo(await readProject(root, projectId));
|
|
2108
|
+
return { synopsis, chapter: saved.chapter };
|
|
2109
|
+
});
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
/** Rewrite one chapter's synopsis from a note, without saving it. */
|
|
2113
|
+
async function startSynopsisRevise(ctx, root, registry, body) {
|
|
2114
|
+
const projectId = asText(body.projectId);
|
|
2115
|
+
const chapterId = asText(body.chapterId);
|
|
2116
|
+
const synopsis = typeof body.synopsis === 'string' ? body.synopsis : '';
|
|
2117
|
+
const instruction = asText(body.instruction);
|
|
2118
|
+
if (synopsis.trim() === '') throw httpError(400, '还没有故事梗概可改,先写一段再来');
|
|
2119
|
+
if (instruction === '') throw httpError(400, '请先写清修改意见');
|
|
2120
|
+
const preview = await readProject(root, projectId);
|
|
2121
|
+
const claimed = preview.chapters.find((entry) => entry.chapterId === chapterId);
|
|
2122
|
+
if (!claimed) throw httpError(404, `章节不存在: ${chapterId}`);
|
|
2123
|
+
return startJob(registry, { kind: 'synopsis-revise', projectId, chapterId }, async (job) => {
|
|
2124
|
+
const project = await readProject(root, projectId);
|
|
2125
|
+
const chapter = project.chapters.find((entry) => entry.chapterId === chapterId) || claimed;
|
|
2126
|
+
job.message = '正在按意见改写梗概';
|
|
2127
|
+
const raw = await askStory(ctx, root, project, synopsisRevisePrompt(project, chapter, synopsis, instruction), (note) => {
|
|
2128
|
+
job.message = note;
|
|
2129
|
+
});
|
|
2130
|
+
const revised = cleanSynopsis(raw);
|
|
2131
|
+
if (revised === '') throw httpError(502, '模型没有给出梗概');
|
|
2132
|
+
return { synopsis: revised, chars: revised.length };
|
|
2133
|
+
});
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2015
2136
|
async function updateTimeline(root, body) {
|
|
2016
2137
|
const projectId = asText(body.projectId);
|
|
2017
2138
|
const unitDays = Number(body.unitDays);
|
|
@@ -2806,6 +2927,10 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
|
|
|
2806
2927
|
return startGenerate(ctx, root, registry, body);
|
|
2807
2928
|
case '/chapter/revise':
|
|
2808
2929
|
return startRevise(ctx, root, registry, body);
|
|
2930
|
+
case '/chapter/summarize':
|
|
2931
|
+
return startSummarize(ctx, root, registry, body);
|
|
2932
|
+
case '/outline/revise':
|
|
2933
|
+
return startSynopsisRevise(ctx, root, registry, body);
|
|
2809
2934
|
case '/compose':
|
|
2810
2935
|
return startCompose(ctx, root, registry, body);
|
|
2811
2936
|
case '/deduce':
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-novel-generator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.33",
|
|
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",
|