dsh-novel-generator 0.1.15 → 0.1.17

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 +81 -11
  2. package/index.js +84 -7
  3. package/package.json +1 -1
package/client.js CHANGED
@@ -1041,7 +1041,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1041
1041
 
1042
1042
  /* ---------------------------------------------------------- outline page */
1043
1043
 
1044
- function OutlinePage({ detail, chapter, isFirstChapter, onBack, onSaved, notify, onRegisterGuard, requestConfirm }) {
1044
+ function OutlinePage({ detail, chapter, isFirstChapter, onBack, onSaved, notify, onRegisterGuard, requestConfirm, onBusyChange }) {
1045
1045
  const buildForm = useCallback(() => ({
1046
1046
  title: chapter ? chapter.title || '' : '',
1047
1047
  durationDays: chapter && chapter.outline ? chapter.outline.durationDays : (detail.timeline.unitDays || 30),
@@ -1182,6 +1182,22 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1182
1182
  });
1183
1183
  }, [requestConfirm, startJob]);
1184
1184
 
1185
+ // Report the running job upward so the studio can lock itself and warn
1186
+ // before the panel is closed mid-generation.
1187
+ useEffect(() => {
1188
+ if (typeof onBusyChange !== 'function') return;
1189
+ const running = job && job.state === 'running';
1190
+ if (!running) {
1191
+ onBusyChange(null);
1192
+ return undefined;
1193
+ }
1194
+ onBusyChange({
1195
+ label: job.kind === 'compose' ? t('正在自动推演…', 'Deducing…') : t('正在生成章节故事…', 'Generating the chapter…'),
1196
+ message: job.message || '',
1197
+ });
1198
+ return () => onBusyChange(null);
1199
+ }, [job ? job.jobId : null, job ? job.state : null, job ? job.message : null, onBusyChange]);
1200
+
1185
1201
  useEffect(() => {
1186
1202
  if (!job) return;
1187
1203
  if (job.kind === 'generate' && job.state === 'done') {
@@ -1281,6 +1297,21 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1281
1297
  composing ? (job.message || t('正在自动推演…', "Deducing…")) : t('正在生成章节故事…', "Generating the chapter…"))
1282
1298
  : null,
1283
1299
  h('div', { className: 'dsn-spacer' }),
1300
+ generating || composing
1301
+ ? h('button', {
1302
+ className: 'dsn-btn danger ghost',
1303
+ type: 'button',
1304
+ onClick: async () => {
1305
+ try {
1306
+ await api('/job/cancel', { jobId: job.jobId });
1307
+ setJob((current) => ({ ...(current || {}), state: 'error', error: t('已手动停止', 'Stopped') }));
1308
+ notify(t('已请求停止生成', 'Generation stop requested'), 'success');
1309
+ } catch (stopError) {
1310
+ notify(messageOf(stopError), 'error');
1311
+ }
1312
+ },
1313
+ }, t('停止生成', "Stop"))
1314
+ : null,
1284
1315
  h('button', { className: 'dsn-btn', type: 'button', disabled: !complete || saving || Boolean(generating || composing), onClick: () => submit(false) },
1285
1316
  saving ? t('保存中…', "Saving…") : t('保存', "Save")),
1286
1317
  h('button', {
@@ -1297,7 +1328,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1297
1328
  * The only place a chapter is created: word bounds and the story length are
1298
1329
  * required, and one button can deduce the synopsis and write the chapter.
1299
1330
  */
1300
- function NewChapterDialog({ detail, startDay, onCancel, onCreated, notify }) {
1331
+ function NewChapterDialog({ detail, startDay, onCancel, onCreated, notify, onBusyChange }) {
1301
1332
  const [form, setForm] = useState({
1302
1333
  title: '',
1303
1334
  minWords: 1200,
@@ -1322,6 +1353,20 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1322
1353
 
1323
1354
  const composing = Boolean(job && job.state === 'running' && job.kind === 'compose');
1324
1355
 
1356
+ // A deducing dialog locks the studio behind it and warns before closing.
1357
+ useEffect(() => {
1358
+ if (typeof onBusyChange !== 'function') return undefined;
1359
+ if (!composing) {
1360
+ onBusyChange(null);
1361
+ return undefined;
1362
+ }
1363
+ onBusyChange({
1364
+ label: t('正在自动推演新章节…', 'Deducing a new chapter…'),
1365
+ message: (job && job.message) || '',
1366
+ });
1367
+ return () => onBusyChange(null);
1368
+ }, [composing, job ? job.message : null, onBusyChange]);
1369
+
1325
1370
  const create = useCallback(async () => {
1326
1371
  setBusy(true);
1327
1372
  setError('');
@@ -1485,13 +1530,25 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1485
1530
 
1486
1531
  /* ------------------------------------------------------------- workspace */
1487
1532
 
1488
- function ProjectWorkspace({ detail, onReload, notify, onRegisterLeaveGuard }) {
1533
+ function ProjectWorkspace({ detail, onReload, notify, onRegisterLeaveGuard, onBusyChange }) {
1489
1534
  const [view, setView] = useState('world');
1490
1535
  const [chapterId, setChapterId] = useState(null);
1491
1536
  // A running snapshot job: { label, message }. Non-null locks the studio,
1492
1537
  // because the job keeps rewriting this work's snapshots.
1493
1538
  const [snapshotJob, setSnapshotJob] = useState(null);
1494
- const snapshotBusy = snapshotJob !== null;
1539
+ // The same idea for the chapter jobs (自动推演 / 生成章节故事), reported up
1540
+ // by the pages that own them.
1541
+ const [childBusy, setChildBusy] = useState(null);
1542
+ const busy = snapshotJob || childBusy;
1543
+ const snapshotBusy = Boolean(snapshotJob);
1544
+
1545
+ // Tell the shell whether a long operation is in flight, so closing the
1546
+ // panel can ask first.
1547
+ useEffect(() => {
1548
+ if (typeof onBusyChange !== 'function') return undefined;
1549
+ onBusyChange(busy ? { label: busy.label, message: busy.message } : null);
1550
+ return () => onBusyChange(null);
1551
+ }, [busy ? busy.label : null, busy ? busy.message : null, onBusyChange]);
1495
1552
  const [dialogOpen, setDialogOpen] = useState(false);
1496
1553
  const [text, setText] = useState('');
1497
1554
  const [dirty, setDirty] = useState(false);
@@ -1691,14 +1748,14 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1691
1748
  const trackIndex = view === 'world' ? 0 : view === 'story' ? 1 : 2;
1692
1749
 
1693
1750
  return h('div', { className: 'dsn-stage' },
1694
- // A running snapshot job owns the work: nothing else may be edited
1695
- // while it rewrites snapshots underneath.
1696
- snapshotBusy
1751
+ // Any running job owns the work: nothing else may be edited while it
1752
+ // rewrites the novel underneath.
1753
+ busy
1697
1754
  ? h('div', { className: 'dsn-lock' },
1698
1755
  h('div', { className: 'dsn-lock-box' },
1699
- h('div', { className: 'dsn-lock-title' }, snapshotJob.label),
1700
- h('div', { className: 'dsn-lock-text' }, snapshotJob.message),
1701
- h('div', { className: 'dsn-lock-hint' }, t('快照生成期间界面不可操作,完成后会自动刷新', 'The studio is locked while snapshots are generated; it refreshes when done.'))))
1756
+ h('div', { className: 'dsn-lock-title' }, busy.label),
1757
+ h('div', { className: 'dsn-lock-text' }, busy.message),
1758
+ h('div', { className: 'dsn-lock-hint' }, t('生成期间界面不可操作,完成后会自动刷新', 'The studio is locked while this runs; it refreshes when done.'))))
1702
1759
  : null,
1703
1760
  h('div', { className: 'dsn-track', style: { transform: `translateX(-${trackIndex * 100}%)` } },
1704
1761
  h('section', { className: 'dsn-page' },
@@ -1741,6 +1798,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1741
1798
  notify,
1742
1799
  onRegisterGuard: registerOutlineGuard,
1743
1800
  requestConfirm: setConfirmRequest,
1801
+ onBusyChange: setChildBusy,
1744
1802
  isFirstChapter: Boolean(chapter && chapters.length > 0 && chapter.chapterId === chapters[0].chapterId),
1745
1803
  }))),
1746
1804
  confirmRequest
@@ -1789,6 +1847,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1789
1847
  onCancel: () => setDialogOpen(false),
1790
1848
  onCreated: created,
1791
1849
  notify,
1850
+ onBusyChange: setChildBusy,
1792
1851
  })
1793
1852
  : null);
1794
1853
  }
@@ -1803,6 +1862,8 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1803
1862
  const [projectId, setProjectId] = useState(null);
1804
1863
  const [detail, setDetail] = useState(null);
1805
1864
  const [toast, setToast] = useToast();
1865
+ // Set while a work runs a model job, so leaving the panel can warn first.
1866
+ const [busyNow, setBusyNow] = useState(null);
1806
1867
  const [modelTarget, setModelTarget] = useState(null);
1807
1868
  const leaveGuard = useRef(null);
1808
1869
  const registerLeaveGuard = useCallback((guard) => {
@@ -1810,13 +1871,21 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1810
1871
  }, []);
1811
1872
  /** Every way out of the studio asks the workspace first. */
1812
1873
  const requestLeave = useCallback((proceed) => {
1874
+ // A running generation outranks the editor guard: say it is still going
1875
+ // before anything closes.
1876
+ if (busyNow) {
1877
+ const label = busyNow.label || t('正在生成', 'Generation is running');
1878
+ if (!window.confirm(t('{label},现在离开吗?生成会在后台继续,回来时刷新即可看到结果。', '{label} — leave now? It keeps running in the background; reopen the panel later to see the result.', { label }))) return;
1879
+ proceed();
1880
+ return;
1881
+ }
1813
1882
  const guard = leaveGuard.current;
1814
1883
  if (typeof guard === 'function') {
1815
1884
  guard(proceed);
1816
1885
  return;
1817
1886
  }
1818
1887
  proceed();
1819
- }, []);
1888
+ }, [busyNow]);
1820
1889
  const close = useCallback(() => requestLeave(() => setOverlayOpen(false)), [requestLeave]);
1821
1890
 
1822
1891
  const loadBoot = useCallback(async () => {
@@ -1896,6 +1965,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
1896
1965
  onReload: reloadDetail,
1897
1966
  notify,
1898
1967
  onRegisterLeaveGuard: registerLeaveGuard,
1968
+ onBusyChange: setBusyNow,
1899
1969
  })),
1900
1970
  modelTarget
1901
1971
  ? h(ModelDialog, {
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
@@ -859,6 +891,9 @@ async function rebindSession(ctx, root, project) {
859
891
  draft.sessionId = sessionId;
860
892
  });
861
893
  try {
894
+ // The memo skips projects already grouped; a rebind starts a new session
895
+ // that has never been grouped, so the entry has to go.
896
+ ensuredWorkspaces.delete(project.projectId);
862
897
  await ensureWorkspace(ctx, root, project);
863
898
  } catch {
864
899
  // Grouping is cosmetic; the session works either way.
@@ -1101,11 +1136,15 @@ function stretchLines(project, chapter, snapshot) {
1101
1136
  function chapterPrompt(project, chapter, previous) {
1102
1137
  const window = chapterWindow(project, chapter);
1103
1138
  const outline = chapter.outline || {};
1139
+ const minWords = outline.minWords || 800;
1140
+ const maxWords = outline.maxWords || 3000;
1104
1141
  const { snapshot } = snapshotDistance(project, chapter);
1105
1142
  const lines = [];
1106
1143
  lines.push('你现在是这部小说的执笔作者。只输出本章正文,不要解释、不要标题、不要分点提纲。');
1144
+ lines.push(`【篇幅】本章正文必须控制在 ${minWords}~${maxWords} 字之间,写够 ${maxWords} 字附近就收尾,超过 ${maxWords} 字视为不合格。`);
1107
1145
  lines.push('');
1108
- lines.push(`【本章】第 ${chapter.index + 1} 章 ${chapter.title || ''}(第 ${window.start}→${window.end} 天,跨度 ${chapter.durationDays} 天)`);
1146
+ const shownTitle = chapter.title && !/^第\s*\d+\s*章$/u.test(chapter.title) ? ` ${chapter.title}` : '';
1147
+ lines.push(`【本章】第 ${chapter.index + 1} 章${shownTitle}(第 ${window.start}→${window.end} 天,跨度 ${chapter.durationDays} 天)`);
1109
1148
  lines.push('');
1110
1149
  lines.push('【最近的世界快照】');
1111
1150
  lines.push(snapshotBlock(snapshot));
@@ -1125,7 +1164,7 @@ function chapterPrompt(project, chapter, previous) {
1125
1164
  lines.push('- 以上"已发生的章节梗概"是既定事实,不得改写;本章正文要接着它往下写。');
1126
1165
  lines.push('- 按"本章梗概"把这一章的内容写出来,情节推进、场景与对话要完整。');
1127
1166
  lines.push('- 不要调用任何工具,不要反问,不要输出计划或步骤说明,直接给出最终正文。');
1128
- lines.push(`- 字数控制在 ${outline.minWords || 800} 到 ${outline.maxWords || 3000} 字之间。`);
1167
+ lines.push(`- 再说一次篇幅:正文 ${minWords}~${maxWords} 字,到 ${maxWords} 字就停笔,不要用重复段落凑字。`);
1129
1168
  lines.push('- 与世界观、角色、势力、物品、事件和人物关系保持一致,不要引入与快照冲突的设定。');
1130
1169
  return lines.join('\n');
1131
1170
  }
@@ -1210,6 +1249,36 @@ function createJobRegistry() {
1210
1249
  return { jobs, put, sweep, get: (id) => jobs.get(id) };
1211
1250
  }
1212
1251
 
1252
+ /**
1253
+ * Stop a running job: cancel the work's agent turn, and mark the job so the
1254
+ * Client stops polling it.
1255
+ */
1256
+ async function cancelJob(ctx, root, registry, body) {
1257
+ const jobId = asText(body.jobId);
1258
+ const job = registry.get(jobId);
1259
+ if (!job) throw httpError(404, '任务不存在或已过期');
1260
+ const controller = ctx.get('sessionController');
1261
+ let cancelled = false;
1262
+ if (controller && job.projectId) {
1263
+ try {
1264
+ const project = await readProject(root, job.projectId);
1265
+ if (project.sessionId) {
1266
+ controller.cancel({ sessionId: project.sessionId });
1267
+ cancelled = true;
1268
+ }
1269
+ } catch {
1270
+ cancelled = false;
1271
+ }
1272
+ }
1273
+ if (job.state === 'running') {
1274
+ job.state = 'error';
1275
+ job.error = '已手动停止';
1276
+ job.message = '已手动停止';
1277
+ job.finishedAt = Date.now();
1278
+ }
1279
+ return { cancelled, jobId };
1280
+ }
1281
+
1213
1282
  function startJob(registry, spec, run) {
1214
1283
  const job = registry.put({
1215
1284
  jobId: randomUUID(),
@@ -1295,7 +1364,7 @@ async function bootstrap(ctx, root) {
1295
1364
  }
1296
1365
  }
1297
1366
  return {
1298
- revision: '2026-10-20.42',
1367
+ revision: '2026-10-20.45',
1299
1368
  projects,
1300
1369
  models: {
1301
1370
  default: modelCatalog.default || null,
@@ -1841,9 +1910,12 @@ async function startGenerate(ctx, root, registry, body) {
1841
1910
  .filter((entry) => entry.chapterId !== chapter.chapterId && entry.startDay <= chapter.startDay)
1842
1911
  .sort((a, b) => b.startDay - a.startDay)[0] || null;
1843
1912
  job.message = '正在生成章节正文';
1844
- const content = await askStory(ctx, root, project, chapterPrompt(project, chapter, previous), (note) => {
1913
+ const raw = await askStory(ctx, root, project, chapterPrompt(project, chapter, previous), (note) => {
1845
1914
  job.message = note;
1846
1915
  });
1916
+ const fitted = fitChapterText(raw, (chapter.outline && chapter.outline.maxWords) || 3000);
1917
+ const content = fitted.text;
1918
+ if (fitted.trimmed) job.message = '模型写超了字数,已按上限截断';
1847
1919
  const saved = await mutateProject(root, projectId, (draft) => {
1848
1920
  const target = draft.chapters.find((entry) => entry.chapterId === chapterId);
1849
1921
  if (!target) throw httpError(404, `章节不存在: ${chapterId}`);
@@ -1977,9 +2049,12 @@ async function startCompose(ctx, root, registry, body) {
1977
2049
  const fresh = await readProject(root, projectId);
1978
2050
  const target = fresh.chapters.find((entry) => entry.chapterId === chapterId);
1979
2051
  if (!target) throw httpError(500, '章节写入失败');
1980
- const content = await askStory(ctx, root, fresh, chapterPrompt(fresh, target, previous), (note) => {
2052
+ const raw = await askStory(ctx, root, fresh, chapterPrompt(fresh, target, previous), (note) => {
1981
2053
  job.message = note;
1982
2054
  });
2055
+ const fitted = fitChapterText(raw, (target.outline && target.outline.maxWords) || 3000);
2056
+ const content = fitted.text;
2057
+ if (fitted.trimmed) job.message = '模型写超了字数,已按上限截断';
1983
2058
 
1984
2059
  await mutateProject(root, projectId, (current) => {
1985
2060
  const entry = current.chapters.find((item) => item.chapterId === chapterId);
@@ -2087,6 +2162,8 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
2087
2162
  }
2088
2163
  if (method !== 'POST') throw httpError(405, '只支持 GET 与 POST');
2089
2164
  switch (path) {
2165
+ case '/job/cancel':
2166
+ return cancelJob(ctx, root, registry, body);
2090
2167
  case '/project/create':
2091
2168
  return createProject(ctx, root, body);
2092
2169
  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.17",
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",