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