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.
- package/client.js +15 -0
- package/index.js +81 -7
- 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
|
-
/**
|
|
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
|
|
@@ -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
|
-
|
|
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(`-
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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",
|