dsh-novel-generator 0.1.15 → 0.1.16

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 +15 -0
  2. package/index.js +81 -7
  3. package/package.json +1 -1
package/client.js CHANGED
@@ -1281,6 +1281,21 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1281
1281
  composing ? (job.message || t('正在自动推演…', "Deducing…")) : t('正在生成章节故事…', "Generating the chapter…"))
1282
1282
  : null,
1283
1283
  h('div', { className: 'dsn-spacer' }),
1284
+ generating || composing
1285
+ ? h('button', {
1286
+ className: 'dsn-btn danger ghost',
1287
+ type: 'button',
1288
+ onClick: async () => {
1289
+ try {
1290
+ await api('/job/cancel', { jobId: job.jobId });
1291
+ setJob((current) => ({ ...(current || {}), state: 'error', error: t('已手动停止', 'Stopped') }));
1292
+ notify(t('已请求停止生成', 'Generation stop requested'), 'success');
1293
+ } catch (stopError) {
1294
+ notify(messageOf(stopError), 'error');
1295
+ }
1296
+ },
1297
+ }, t('停止生成', "Stop"))
1298
+ : null,
1284
1299
  h('button', { className: 'dsn-btn', type: 'button', disabled: !complete || saving || Boolean(generating || composing), onClick: () => submit(false) },
1285
1300
  saving ? t('保存中…', "Saving…") : t('保存', "Save")),
1286
1301
  h('button', {
package/index.js CHANGED
@@ -22,8 +22,12 @@ export const inject = ['webServer'];
22
22
  const ROUTE_PREFIX = '/dsh-novel-generator/api';
23
23
  /** Largest accepted JSON body: a long chapter plus its outline. */
24
24
  const MAX_BODY_BYTES = 8 * 1024 * 1024;
25
- /** A generation can legitimately take minutes on a long chapter. */
26
- const AGENT_TIMEOUT_MS = 20 * 60 * 1000;
25
+ /**
26
+ * How long one model turn may run. A model that ignores the word limit can
27
+ * stream for many minutes, so the bound keeps the studio responsive; what it
28
+ * did write is still taken when the turn is cut.
29
+ */
30
+ const AGENT_TIMEOUT_MS = 6 * 60 * 1000;
27
31
  /** Finished jobs stay readable this long for a polling Client. */
28
32
  const JOB_TTL_MS = 30 * 60 * 1000;
29
33
 
@@ -722,6 +726,34 @@ function wordCount(text) {
722
726
  return String(text || '').replace(/\s+/gu, '').length;
723
727
  }
724
728
 
729
+ /**
730
+ * Bring a runaway reply back inside the requested length.
731
+ *
732
+ * Models that ignore the word range keep streaming until their token cap (one
733
+ * real reply was 50k characters for a 1200–3500 字 request), which also repeats
734
+ * itself. The reply is cut at the first sign of a loop and trimmed at a sentence
735
+ * boundary near the upper bound, so what lands in the chapter is a chapter.
736
+ */
737
+ function fitChapterText(text, maxWords) {
738
+ let body = String(text || '').replace(/\r\n/gu, '\n').trim();
739
+ const limit = Math.max(200, Math.round(Number(maxWords) || 3000));
740
+ // Drop a leading Markdown title line the prompt asked not to add.
741
+ body = body.replace(/^#{1,6}\s*[^\n]*\n+/u, '');
742
+ // A phrase repeated back-to-back is a degenerate loop: stop at the first one.
743
+ const loop = body.match(/([\s\S]{12,80}?)\1{3,}/u);
744
+ if (loop) body = body.slice(0, loop.index).trim();
745
+ if (wordCount(body) <= limit * 1.15) return { text: body, trimmed: false };
746
+ // Cut on a sentence end as close to the limit as possible.
747
+ const sentences = body.split(/(?<=[。!?…”』\n])/u);
748
+ let kept = '';
749
+ for (const sentence of sentences) {
750
+ if (wordCount(kept + sentence) > limit) break;
751
+ kept += sentence;
752
+ }
753
+ if (kept.trim() === '') kept = body.slice(0, Math.round(limit * 1.2));
754
+ return { text: kept.trim(), trimmed: true };
755
+ }
756
+
725
757
  function textOfMessage(message) {
726
758
  const blocks = Array.isArray(message && message.content) ? message.content : [];
727
759
  return blocks
@@ -1101,11 +1133,15 @@ function stretchLines(project, chapter, snapshot) {
1101
1133
  function chapterPrompt(project, chapter, previous) {
1102
1134
  const window = chapterWindow(project, chapter);
1103
1135
  const outline = chapter.outline || {};
1136
+ const minWords = outline.minWords || 800;
1137
+ const maxWords = outline.maxWords || 3000;
1104
1138
  const { snapshot } = snapshotDistance(project, chapter);
1105
1139
  const lines = [];
1106
1140
  lines.push('你现在是这部小说的执笔作者。只输出本章正文,不要解释、不要标题、不要分点提纲。');
1141
+ lines.push(`【篇幅】本章正文必须控制在 ${minWords}~${maxWords} 字之间,写够 ${maxWords} 字附近就收尾,超过 ${maxWords} 字视为不合格。`);
1107
1142
  lines.push('');
1108
- lines.push(`【本章】第 ${chapter.index + 1} 章 ${chapter.title || ''}(第 ${window.start}→${window.end} 天,跨度 ${chapter.durationDays} 天)`);
1143
+ const shownTitle = chapter.title && !/^第\s*\d+\s*章$/u.test(chapter.title) ? ` ${chapter.title}` : '';
1144
+ lines.push(`【本章】第 ${chapter.index + 1} 章${shownTitle}(第 ${window.start}→${window.end} 天,跨度 ${chapter.durationDays} 天)`);
1109
1145
  lines.push('');
1110
1146
  lines.push('【最近的世界快照】');
1111
1147
  lines.push(snapshotBlock(snapshot));
@@ -1125,7 +1161,7 @@ function chapterPrompt(project, chapter, previous) {
1125
1161
  lines.push('- 以上"已发生的章节梗概"是既定事实,不得改写;本章正文要接着它往下写。');
1126
1162
  lines.push('- 按"本章梗概"把这一章的内容写出来,情节推进、场景与对话要完整。');
1127
1163
  lines.push('- 不要调用任何工具,不要反问,不要输出计划或步骤说明,直接给出最终正文。');
1128
- lines.push(`- 字数控制在 ${outline.minWords || 800} 到 ${outline.maxWords || 3000} 字之间。`);
1164
+ lines.push(`- 再说一次篇幅:正文 ${minWords}~${maxWords} 字,到 ${maxWords} 字就停笔,不要用重复段落凑字。`);
1129
1165
  lines.push('- 与世界观、角色、势力、物品、事件和人物关系保持一致,不要引入与快照冲突的设定。');
1130
1166
  return lines.join('\n');
1131
1167
  }
@@ -1210,6 +1246,36 @@ function createJobRegistry() {
1210
1246
  return { jobs, put, sweep, get: (id) => jobs.get(id) };
1211
1247
  }
1212
1248
 
1249
+ /**
1250
+ * Stop a running job: cancel the work's agent turn, and mark the job so the
1251
+ * Client stops polling it.
1252
+ */
1253
+ async function cancelJob(ctx, root, registry, body) {
1254
+ const jobId = asText(body.jobId);
1255
+ const job = registry.get(jobId);
1256
+ if (!job) throw httpError(404, '任务不存在或已过期');
1257
+ const controller = ctx.get('sessionController');
1258
+ let cancelled = false;
1259
+ if (controller && job.projectId) {
1260
+ try {
1261
+ const project = await readProject(root, job.projectId);
1262
+ if (project.sessionId) {
1263
+ controller.cancel({ sessionId: project.sessionId });
1264
+ cancelled = true;
1265
+ }
1266
+ } catch {
1267
+ cancelled = false;
1268
+ }
1269
+ }
1270
+ if (job.state === 'running') {
1271
+ job.state = 'error';
1272
+ job.error = '已手动停止';
1273
+ job.message = '已手动停止';
1274
+ job.finishedAt = Date.now();
1275
+ }
1276
+ return { cancelled, jobId };
1277
+ }
1278
+
1213
1279
  function startJob(registry, spec, run) {
1214
1280
  const job = registry.put({
1215
1281
  jobId: randomUUID(),
@@ -1295,7 +1361,7 @@ async function bootstrap(ctx, root) {
1295
1361
  }
1296
1362
  }
1297
1363
  return {
1298
- revision: '2026-10-20.42',
1364
+ revision: '2026-10-20.44',
1299
1365
  projects,
1300
1366
  models: {
1301
1367
  default: modelCatalog.default || null,
@@ -1841,9 +1907,12 @@ async function startGenerate(ctx, root, registry, body) {
1841
1907
  .filter((entry) => entry.chapterId !== chapter.chapterId && entry.startDay <= chapter.startDay)
1842
1908
  .sort((a, b) => b.startDay - a.startDay)[0] || null;
1843
1909
  job.message = '正在生成章节正文';
1844
- const content = await askStory(ctx, root, project, chapterPrompt(project, chapter, previous), (note) => {
1910
+ const raw = await askStory(ctx, root, project, chapterPrompt(project, chapter, previous), (note) => {
1845
1911
  job.message = note;
1846
1912
  });
1913
+ const fitted = fitChapterText(raw, (chapter.outline && chapter.outline.maxWords) || 3000);
1914
+ const content = fitted.text;
1915
+ if (fitted.trimmed) job.message = '模型写超了字数,已按上限截断';
1847
1916
  const saved = await mutateProject(root, projectId, (draft) => {
1848
1917
  const target = draft.chapters.find((entry) => entry.chapterId === chapterId);
1849
1918
  if (!target) throw httpError(404, `章节不存在: ${chapterId}`);
@@ -1977,9 +2046,12 @@ async function startCompose(ctx, root, registry, body) {
1977
2046
  const fresh = await readProject(root, projectId);
1978
2047
  const target = fresh.chapters.find((entry) => entry.chapterId === chapterId);
1979
2048
  if (!target) throw httpError(500, '章节写入失败');
1980
- const content = await askStory(ctx, root, fresh, chapterPrompt(fresh, target, previous), (note) => {
2049
+ const raw = await askStory(ctx, root, fresh, chapterPrompt(fresh, target, previous), (note) => {
1981
2050
  job.message = note;
1982
2051
  });
2052
+ const fitted = fitChapterText(raw, (target.outline && target.outline.maxWords) || 3000);
2053
+ const content = fitted.text;
2054
+ if (fitted.trimmed) job.message = '模型写超了字数,已按上限截断';
1983
2055
 
1984
2056
  await mutateProject(root, projectId, (current) => {
1985
2057
  const entry = current.chapters.find((item) => item.chapterId === chapterId);
@@ -2087,6 +2159,8 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
2087
2159
  }
2088
2160
  if (method !== 'POST') throw httpError(405, '只支持 GET 与 POST');
2089
2161
  switch (path) {
2162
+ case '/job/cancel':
2163
+ return cancelJob(ctx, root, registry, body);
2090
2164
  case '/project/create':
2091
2165
  return createProject(ctx, root, body);
2092
2166
  case '/project/delete': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-novel-generator",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
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",