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.
Files changed (3) hide show
  1. package/client.js +26 -12
  2. package/index.js +93 -28
  3. 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('用本章正文重新生成故事梗概,会覆盖现有梗概', 'Recreate the synopsis from this chapter’s text, replacing the current one'),
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('生成梗概中…', 'Summarizing…') : t('生成故事梗概', 'Synopsis from text')),
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 summarizeChapter = useCallback(() => {
1976
+ const syncOutline = useCallback(() => {
1977
1977
  if (!chapter) return;
1978
1978
  if (String(text || '').trim() === '') {
1979
- notify(t('这一章还没有正文,先写或先生成再生成梗概', 'This chapter has no text yet — write or generate it first.'), 'error');
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('是否以当前章节故事生成故事梗概?', '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'),
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/summarize', {
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('正在用正文生成故事梗概…', 'Building the synopsis from the text…'),
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
- notify(t('已用正文生成并保存故事梗概', 'Synopsis generated from the text and saved'), 'success');
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('生成梗概失败', 'Building the synopsis failed'), 'error');
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: summarizeChapter,
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.4',
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
- /** Prompt for turning a chapter's prose back into its synopsis. */
2016
- function summarizePrompt(project, chapter, prose) {
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('只输出梗概本身:不要标题、不要分点、不要解释、不要评价,300 字以内。');
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
- * Turn a chapter's prose into its synopsis and save it.
2074
+ * Sync a written chapter back into its outline.
2066
2075
  *
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.
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 startSummarize(ctx, root, registry, body) {
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: 'summarize', projectId, chapterId }, async (job) => {
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 askStory(ctx, root, project, summarizePrompt(project, chapter, prose), (note) => {
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 synopsis = cleanSynopsis(raw);
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
- await exportChapterOutline(await readProject(root, projectId), saved.chapter);
2107
- await exportProjectInfo(await readProject(root, projectId));
2108
- return { synopsis, chapter: saved.chapter };
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/summarize':
2931
- return startSummarize(ctx, root, registry, body);
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.33",
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",