dsh-novel-generator 0.1.14 → 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 +33 -0
  2. package/index.js +127 -8
  3. package/package.json +1 -1
package/client.js CHANGED
@@ -599,6 +599,24 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
599
599
  onEditModel(project);
600
600
  },
601
601
  }, t('模型', "Model")),
602
+ h('button', {
603
+ className: 'dsn-btn ghost',
604
+ type: 'button',
605
+ title: t('给这部作品换一个新的创作会话(会话被归档或删掉、生成时提示无回复时使用)', 'Give this work a fresh session (use it after archiving or deleting the old one, or when generation reports no reply)'),
606
+ onClick: async (event) => {
607
+ event.stopPropagation();
608
+ if (!window.confirm(t('为「{title}」新建一个创作会话?旧会话会留在侧栏,作品正文与快照不受影响。', 'Create a fresh session for “{title}”? The old one stays in the sidebar; chapters and snapshots are untouched.', { title: project.title }))) return;
609
+ try {
610
+ const result = await api('/project/rebind', { projectId: project.projectId });
611
+ await onReload();
612
+ setError(result.agentReady
613
+ ? t('已为作品新建创作会话', 'A fresh session is bound to this work')
614
+ : t('会话已新建,但还无法启动;请稍后再试或重开面板', 'The session was created but could not start yet — try again shortly.'));
615
+ } catch (rebindError) {
616
+ setError(messageOf(rebindError));
617
+ }
618
+ },
619
+ }, t('重建会话', "New session")),
602
620
  h('button', {
603
621
  className: 'dsn-btn danger ghost',
604
622
  type: 'button',
@@ -1263,6 +1281,21 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1263
1281
  composing ? (job.message || t('正在自动推演…', "Deducing…")) : t('正在生成章节故事…', "Generating the chapter…"))
1264
1282
  : null,
1265
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,
1266
1299
  h('button', { className: 'dsn-btn', type: 'button', disabled: !complete || saving || Boolean(generating || composing), onClick: () => submit(false) },
1267
1300
  saving ? t('保存中…', "Saving…") : t('保存', "Save")),
1268
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
@@ -877,6 +909,21 @@ async function resolveAgent(ctx, root, project, onNote) {
877
909
  if (onNote) onNote('作品没有绑定会话,已新建一个会话');
878
910
  return rebuilt;
879
911
  }
912
+ // Archived sessions still resolve, so visibility decides instead: a session
913
+ // the user archived is no longer one of the plugin's working sessions.
914
+ let archived = false;
915
+ try {
916
+ const list = await controller.list({}, new AbortController().signal);
917
+ const items = list && Array.isArray(list.items) ? list.items : null;
918
+ if (items) archived = !items.some((entry) => entry.sessionId === project.sessionId);
919
+ } catch {
920
+ archived = false;
921
+ }
922
+ if (archived) {
923
+ const rebuilt = await rebindSession(ctx, root, project);
924
+ if (onNote) onNote('作品的会话已被归档,已自动新建一个会话继续');
925
+ return rebuilt;
926
+ }
880
927
  let result = null;
881
928
  try {
882
929
  result = await controller.resolveAgent(project.sessionId);
@@ -953,7 +1000,7 @@ async function askAgent(ctx, root, project, prompt, onNote) {
953
1000
  }
954
1001
  }
955
1002
  throw rebuilt
956
- ? httpError(502, '作品的旧会话已不存在,已为它新建一个会话,但模型没有回复;请在侧栏打开这部作品的会话(「小说工坊」分组里)后再试一次')
1003
+ ? httpError(502, '作品的旧会话已不存在,已为它新建一个会话,但模型没有回复;请先打开这部作品的会话(「小说工坊」分组里),或在作品列表点「重建会话」后再试')
957
1004
  : httpError(502, '模型没有返回任何正文');
958
1005
  }
959
1006
 
@@ -1086,11 +1133,15 @@ function stretchLines(project, chapter, snapshot) {
1086
1133
  function chapterPrompt(project, chapter, previous) {
1087
1134
  const window = chapterWindow(project, chapter);
1088
1135
  const outline = chapter.outline || {};
1136
+ const minWords = outline.minWords || 800;
1137
+ const maxWords = outline.maxWords || 3000;
1089
1138
  const { snapshot } = snapshotDistance(project, chapter);
1090
1139
  const lines = [];
1091
1140
  lines.push('你现在是这部小说的执笔作者。只输出本章正文,不要解释、不要标题、不要分点提纲。');
1141
+ lines.push(`【篇幅】本章正文必须控制在 ${minWords}~${maxWords} 字之间,写够 ${maxWords} 字附近就收尾,超过 ${maxWords} 字视为不合格。`);
1092
1142
  lines.push('');
1093
- 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} 天)`);
1094
1145
  lines.push('');
1095
1146
  lines.push('【最近的世界快照】');
1096
1147
  lines.push(snapshotBlock(snapshot));
@@ -1110,7 +1161,7 @@ function chapterPrompt(project, chapter, previous) {
1110
1161
  lines.push('- 以上"已发生的章节梗概"是既定事实,不得改写;本章正文要接着它往下写。');
1111
1162
  lines.push('- 按"本章梗概"把这一章的内容写出来,情节推进、场景与对话要完整。');
1112
1163
  lines.push('- 不要调用任何工具,不要反问,不要输出计划或步骤说明,直接给出最终正文。');
1113
- lines.push(`- 字数控制在 ${outline.minWords || 800} 到 ${outline.maxWords || 3000} 字之间。`);
1164
+ lines.push(`- 再说一次篇幅:正文 ${minWords}~${maxWords} 字,到 ${maxWords} 字就停笔,不要用重复段落凑字。`);
1114
1165
  lines.push('- 与世界观、角色、势力、物品、事件和人物关系保持一致,不要引入与快照冲突的设定。');
1115
1166
  return lines.join('\n');
1116
1167
  }
@@ -1195,6 +1246,36 @@ function createJobRegistry() {
1195
1246
  return { jobs, put, sweep, get: (id) => jobs.get(id) };
1196
1247
  }
1197
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
+
1198
1279
  function startJob(registry, spec, run) {
1199
1280
  const job = registry.put({
1200
1281
  jobId: randomUUID(),
@@ -1280,7 +1361,7 @@ async function bootstrap(ctx, root) {
1280
1361
  }
1281
1362
  }
1282
1363
  return {
1283
- revision: '2026-10-20.40',
1364
+ revision: '2026-10-20.44',
1284
1365
  projects,
1285
1366
  models: {
1286
1367
  default: modelCatalog.default || null,
@@ -1376,6 +1457,34 @@ async function createProject(ctx, root, body) {
1376
1457
  };
1377
1458
  }
1378
1459
 
1460
+ /**
1461
+ * Give the work a brand-new Session on demand.
1462
+ *
1463
+ * Archiving leaves a session resolvable but unusable for new turns, and the
1464
+ * registry exposes no "is archived" query, so the user gets an explicit button
1465
+ * instead of the plugin guessing.
1466
+ */
1467
+ async function rebindProject(ctx, root, body) {
1468
+ const projectId = asText(body.projectId);
1469
+ if (projectId === '') throw httpError(400, '缺少 projectId');
1470
+ const project = await readProject(root, projectId);
1471
+ const previous = project.sessionId;
1472
+ const { controller } = await rebindSession(ctx, root, project);
1473
+ let agentReady = true;
1474
+ try {
1475
+ const result = await controller.resolveAgent(project.sessionId);
1476
+ agentReady = Boolean(result && result.agent);
1477
+ } catch {
1478
+ agentReady = false;
1479
+ }
1480
+ return {
1481
+ sessionId: project.sessionId,
1482
+ previousSessionId: previous,
1483
+ agentReady,
1484
+ project: summarize(await readProject(root, projectId)),
1485
+ };
1486
+ }
1487
+
1379
1488
  /**
1380
1489
  * Change a project's model and carry it into the project's own Session, the
1381
1490
  * same switch the chat UI's model picker performs.
@@ -1798,9 +1907,12 @@ async function startGenerate(ctx, root, registry, body) {
1798
1907
  .filter((entry) => entry.chapterId !== chapter.chapterId && entry.startDay <= chapter.startDay)
1799
1908
  .sort((a, b) => b.startDay - a.startDay)[0] || null;
1800
1909
  job.message = '正在生成章节正文';
1801
- 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) => {
1802
1911
  job.message = note;
1803
1912
  });
1913
+ const fitted = fitChapterText(raw, (chapter.outline && chapter.outline.maxWords) || 3000);
1914
+ const content = fitted.text;
1915
+ if (fitted.trimmed) job.message = '模型写超了字数,已按上限截断';
1804
1916
  const saved = await mutateProject(root, projectId, (draft) => {
1805
1917
  const target = draft.chapters.find((entry) => entry.chapterId === chapterId);
1806
1918
  if (!target) throw httpError(404, `章节不存在: ${chapterId}`);
@@ -1934,9 +2046,12 @@ async function startCompose(ctx, root, registry, body) {
1934
2046
  const fresh = await readProject(root, projectId);
1935
2047
  const target = fresh.chapters.find((entry) => entry.chapterId === chapterId);
1936
2048
  if (!target) throw httpError(500, '章节写入失败');
1937
- 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) => {
1938
2050
  job.message = note;
1939
2051
  });
2052
+ const fitted = fitChapterText(raw, (target.outline && target.outline.maxWords) || 3000);
2053
+ const content = fitted.text;
2054
+ if (fitted.trimmed) job.message = '模型写超了字数,已按上限截断';
1940
2055
 
1941
2056
  await mutateProject(root, projectId, (current) => {
1942
2057
  const entry = current.chapters.find((item) => item.chapterId === chapterId);
@@ -2044,6 +2159,8 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
2044
2159
  }
2045
2160
  if (method !== 'POST') throw httpError(405, '只支持 GET 与 POST');
2046
2161
  switch (path) {
2162
+ case '/job/cancel':
2163
+ return cancelJob(ctx, root, registry, body);
2047
2164
  case '/project/create':
2048
2165
  return createProject(ctx, root, body);
2049
2166
  case '/project/delete': {
@@ -2065,6 +2182,8 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
2065
2182
  return updateTimeline(root, body);
2066
2183
  case '/project/model':
2067
2184
  return updateModel(ctx, root, body);
2185
+ case '/project/rebind':
2186
+ return rebindProject(ctx, root, body);
2068
2187
  case '/outline/save':
2069
2188
  return saveOutline(root, body);
2070
2189
  case '/chapter/save':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-novel-generator",
3
- "version": "0.1.14",
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",