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.
- package/client.js +81 -11
- package/index.js +84 -7
- 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
|
-
|
|
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
|
-
//
|
|
1695
|
-
//
|
|
1696
|
-
|
|
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' },
|
|
1700
|
-
h('div', { className: 'dsn-lock-text' },
|
|
1701
|
-
h('div', { className: 'dsn-lock-hint' }, t('
|
|
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
|
-
/**
|
|
26
|
-
|
|
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
|
-
|
|
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(`-
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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",
|