dsh-novel-generator 0.1.33 → 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/client.js +26 -12
- package/index.js +93 -28
- package/package.json +1 -1
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',
|
|
@@ -1973,19 +1973,19 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1973
1973
|
}, [chapter, detail.projectId, text, setReviseJob]);
|
|
1974
1974
|
|
|
1975
1975
|
/** 生成故事梗概: read the prose and overwrite the stored synopsis. */
|
|
1976
|
-
const
|
|
1976
|
+
const syncOutline = useCallback(() => {
|
|
1977
1977
|
if (!chapter) return;
|
|
1978
1978
|
if (String(text || '').trim() === '') {
|
|
1979
|
-
notify(t('
|
|
1979
|
+
notify(t('这一章还没有正文,先写或先生成再同步大纲', 'This chapter has no text yet — write or generate it first.'), 'error');
|
|
1980
1980
|
return;
|
|
1981
1981
|
}
|
|
1982
1982
|
setConfirmRequest({
|
|
1983
|
-
title: t('
|
|
1984
|
-
text: t('
|
|
1985
|
-
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'),
|
|
1986
1986
|
action: async () => {
|
|
1987
1987
|
try {
|
|
1988
|
-
const started = await api('/chapter/
|
|
1988
|
+
const started = await api('/chapter/sync-outline', {
|
|
1989
1989
|
projectId: detail.projectId,
|
|
1990
1990
|
chapterId: chapter.chapterId,
|
|
1991
1991
|
content: text,
|
|
@@ -2003,17 +2003,31 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
2003
2003
|
if (!summarizeJob) return undefined;
|
|
2004
2004
|
if (summarizeJob.state === 'running') {
|
|
2005
2005
|
setChildBusy({
|
|
2006
|
-
label: t('
|
|
2006
|
+
label: t('正在用正文同步章节大纲…', 'Syncing the outline from the text…'),
|
|
2007
2007
|
message: summarizeJob.message || '',
|
|
2008
2008
|
});
|
|
2009
2009
|
return undefined;
|
|
2010
2010
|
}
|
|
2011
2011
|
setChildBusy(null);
|
|
2012
2012
|
if (summarizeJob.state === 'done') {
|
|
2013
|
-
|
|
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');
|
|
2014
2028
|
onReload();
|
|
2015
2029
|
} else if (summarizeJob.state === 'error') {
|
|
2016
|
-
notify(summarizeJob.error || t('
|
|
2030
|
+
notify(summarizeJob.error || t('同步大纲失败', 'Syncing the outline failed'), 'error');
|
|
2017
2031
|
}
|
|
2018
2032
|
return undefined;
|
|
2019
2033
|
}, [summarizeJob ? summarizeJob.jobId : null, summarizeJob ? summarizeJob.state : null, summarizeJob ? summarizeJob.message : null]);
|
|
@@ -2283,7 +2297,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
2283
2297
|
setReviseError('');
|
|
2284
2298
|
setReviseOpen(true);
|
|
2285
2299
|
},
|
|
2286
|
-
onSummarize:
|
|
2300
|
+
onSummarize: syncOutline,
|
|
2287
2301
|
summarizing: Boolean(summarizeJob && summarizeJob.state === 'running'),
|
|
2288
2302
|
})),
|
|
2289
2303
|
h('section', { className: 'dsn-page' },
|
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",
|