dsh-novel-generator 0.1.13 → 0.1.15
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 +18 -0
- package/index.js +91 -7
- 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',
|
package/index.js
CHANGED
|
@@ -877,6 +877,21 @@ async function resolveAgent(ctx, root, project, onNote) {
|
|
|
877
877
|
if (onNote) onNote('作品没有绑定会话,已新建一个会话');
|
|
878
878
|
return rebuilt;
|
|
879
879
|
}
|
|
880
|
+
// Archived sessions still resolve, so visibility decides instead: a session
|
|
881
|
+
// the user archived is no longer one of the plugin's working sessions.
|
|
882
|
+
let archived = false;
|
|
883
|
+
try {
|
|
884
|
+
const list = await controller.list({}, new AbortController().signal);
|
|
885
|
+
const items = list && Array.isArray(list.items) ? list.items : null;
|
|
886
|
+
if (items) archived = !items.some((entry) => entry.sessionId === project.sessionId);
|
|
887
|
+
} catch {
|
|
888
|
+
archived = false;
|
|
889
|
+
}
|
|
890
|
+
if (archived) {
|
|
891
|
+
const rebuilt = await rebindSession(ctx, root, project);
|
|
892
|
+
if (onNote) onNote('作品的会话已被归档,已自动新建一个会话继续');
|
|
893
|
+
return rebuilt;
|
|
894
|
+
}
|
|
880
895
|
let result = null;
|
|
881
896
|
try {
|
|
882
897
|
result = await controller.resolveAgent(project.sessionId);
|
|
@@ -916,6 +931,15 @@ async function askAgent(ctx, root, project, prompt, onNote) {
|
|
|
916
931
|
const before = project.sessionId;
|
|
917
932
|
const { controller, agent } = await resolveAgent(ctx, root, project, attempt === 0 ? onNote : undefined);
|
|
918
933
|
if (project.sessionId !== before) rebuilt = true;
|
|
934
|
+
// Only messages added by THIS prompt are eligible: reading further back
|
|
935
|
+
// would hand back the previous turn's reply (a snapshot JSON, for one) when
|
|
936
|
+
// the model stays silent.
|
|
937
|
+
let seen = 0;
|
|
938
|
+
try {
|
|
939
|
+
seen = agent.session.deriveMessages().length;
|
|
940
|
+
} catch {
|
|
941
|
+
seen = 0;
|
|
942
|
+
}
|
|
919
943
|
const admission = new AbortController();
|
|
920
944
|
await withTimeout(controller.prompt({
|
|
921
945
|
sessionId: project.sessionId,
|
|
@@ -932,7 +956,7 @@ async function askAgent(ctx, root, project, prompt, onNote) {
|
|
|
932
956
|
}
|
|
933
957
|
});
|
|
934
958
|
const messages = agent.session.deriveMessages();
|
|
935
|
-
for (let index = messages.length - 1; index >=
|
|
959
|
+
for (let index = messages.length - 1; index >= seen; index -= 1) {
|
|
936
960
|
const message = messages[index];
|
|
937
961
|
if (!message || message.role !== 'assistant') continue;
|
|
938
962
|
const text = textOfMessage(message);
|
|
@@ -944,10 +968,40 @@ async function askAgent(ctx, root, project, prompt, onNote) {
|
|
|
944
968
|
}
|
|
945
969
|
}
|
|
946
970
|
throw rebuilt
|
|
947
|
-
? httpError(502, '
|
|
971
|
+
? httpError(502, '作品的旧会话已不存在,已为它新建一个会话,但模型没有回复;请先打开这部作品的会话(「小说工坊」分组里),或在作品列表点「重建会话」后再试')
|
|
948
972
|
: httpError(502, '模型没有返回任何正文');
|
|
949
973
|
}
|
|
950
974
|
|
|
975
|
+
/** True when a reply is structured data rather than prose (a snapshot JSON). */
|
|
976
|
+
function looksStructured(text) {
|
|
977
|
+
const head = String(text || '').trim();
|
|
978
|
+
if (head === '') return false;
|
|
979
|
+
if (!head.startsWith('{') && !head.startsWith('[')) {
|
|
980
|
+
return head.startsWith('```') && (head.includes('"world"') || head.includes('"characters"'));
|
|
981
|
+
}
|
|
982
|
+
try {
|
|
983
|
+
JSON.parse(head);
|
|
984
|
+
return true;
|
|
985
|
+
} catch {
|
|
986
|
+
return head.includes('"world"') || head.includes('"characters"') || head.includes('"synopsis"');
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Ask for prose. A model that answers a writing prompt with JSON (it just
|
|
992
|
+
* produced a snapshot, say) gets one firm reminder instead of having structured
|
|
993
|
+
* text saved as the chapter body.
|
|
994
|
+
*/
|
|
995
|
+
async function askStory(ctx, root, project, prompt, onNote) {
|
|
996
|
+
const first = await askAgent(ctx, root, project, prompt, onNote);
|
|
997
|
+
if (!looksStructured(first)) return first;
|
|
998
|
+
if (onNote) onNote('模型返回了结构化数据,正在要求它改写为正文');
|
|
999
|
+
const retryPrompt = `${prompt}\n\n【纠正】上一轮你返回的是 JSON/结构化数据,那是错的。现在只输出这一章的小说正文(中文散文),不要 JSON、不要字段名、不要标题行。`;
|
|
1000
|
+
const second = await askAgent(ctx, root, project, retryPrompt, onNote);
|
|
1001
|
+
if (looksStructured(second)) throw httpError(502, '模型反复返回结构化数据而不是正文,请稍后重试或换一个模型');
|
|
1002
|
+
return second;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
951
1005
|
function snapshotBlock(snapshot) {
|
|
952
1006
|
if (!snapshot) return '(暂无世界快照,请依据本章梗概自行建立最基本的设定)';
|
|
953
1007
|
const lines = [];
|
|
@@ -1241,7 +1295,7 @@ async function bootstrap(ctx, root) {
|
|
|
1241
1295
|
}
|
|
1242
1296
|
}
|
|
1243
1297
|
return {
|
|
1244
|
-
revision: '2026-10-20.
|
|
1298
|
+
revision: '2026-10-20.42',
|
|
1245
1299
|
projects,
|
|
1246
1300
|
models: {
|
|
1247
1301
|
default: modelCatalog.default || null,
|
|
@@ -1337,6 +1391,34 @@ async function createProject(ctx, root, body) {
|
|
|
1337
1391
|
};
|
|
1338
1392
|
}
|
|
1339
1393
|
|
|
1394
|
+
/**
|
|
1395
|
+
* Give the work a brand-new Session on demand.
|
|
1396
|
+
*
|
|
1397
|
+
* Archiving leaves a session resolvable but unusable for new turns, and the
|
|
1398
|
+
* registry exposes no "is archived" query, so the user gets an explicit button
|
|
1399
|
+
* instead of the plugin guessing.
|
|
1400
|
+
*/
|
|
1401
|
+
async function rebindProject(ctx, root, body) {
|
|
1402
|
+
const projectId = asText(body.projectId);
|
|
1403
|
+
if (projectId === '') throw httpError(400, '缺少 projectId');
|
|
1404
|
+
const project = await readProject(root, projectId);
|
|
1405
|
+
const previous = project.sessionId;
|
|
1406
|
+
const { controller } = await rebindSession(ctx, root, project);
|
|
1407
|
+
let agentReady = true;
|
|
1408
|
+
try {
|
|
1409
|
+
const result = await controller.resolveAgent(project.sessionId);
|
|
1410
|
+
agentReady = Boolean(result && result.agent);
|
|
1411
|
+
} catch {
|
|
1412
|
+
agentReady = false;
|
|
1413
|
+
}
|
|
1414
|
+
return {
|
|
1415
|
+
sessionId: project.sessionId,
|
|
1416
|
+
previousSessionId: previous,
|
|
1417
|
+
agentReady,
|
|
1418
|
+
project: summarize(await readProject(root, projectId)),
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1340
1422
|
/**
|
|
1341
1423
|
* Change a project's model and carry it into the project's own Session, the
|
|
1342
1424
|
* same switch the chat UI's model picker performs.
|
|
@@ -1759,7 +1841,7 @@ async function startGenerate(ctx, root, registry, body) {
|
|
|
1759
1841
|
.filter((entry) => entry.chapterId !== chapter.chapterId && entry.startDay <= chapter.startDay)
|
|
1760
1842
|
.sort((a, b) => b.startDay - a.startDay)[0] || null;
|
|
1761
1843
|
job.message = '正在生成章节正文';
|
|
1762
|
-
const content = await
|
|
1844
|
+
const content = await askStory(ctx, root, project, chapterPrompt(project, chapter, previous), (note) => {
|
|
1763
1845
|
job.message = note;
|
|
1764
1846
|
});
|
|
1765
1847
|
const saved = await mutateProject(root, projectId, (draft) => {
|
|
@@ -1853,7 +1935,7 @@ async function startCompose(ctx, root, registry, body) {
|
|
|
1853
1935
|
job.message = note;
|
|
1854
1936
|
});
|
|
1855
1937
|
const deduced = parseJsonReply(deduceReply) || {};
|
|
1856
|
-
const synopsis = asText(firstOf(deduced, ['synopsis', '梗概', '
|
|
1938
|
+
const synopsis = asText(firstOf(deduced, ['synopsis', '梗概', '本章梗概']));
|
|
1857
1939
|
if (synopsis === '') throw httpError(502, '模型没有给出章节梗概');
|
|
1858
1940
|
const title = draft.title
|
|
1859
1941
|
|| asText(firstOf(deduced, ['title', '标题']))
|
|
@@ -1895,7 +1977,7 @@ async function startCompose(ctx, root, registry, body) {
|
|
|
1895
1977
|
const fresh = await readProject(root, projectId);
|
|
1896
1978
|
const target = fresh.chapters.find((entry) => entry.chapterId === chapterId);
|
|
1897
1979
|
if (!target) throw httpError(500, '章节写入失败');
|
|
1898
|
-
const content = await
|
|
1980
|
+
const content = await askStory(ctx, root, fresh, chapterPrompt(fresh, target, previous), (note) => {
|
|
1899
1981
|
job.message = note;
|
|
1900
1982
|
});
|
|
1901
1983
|
|
|
@@ -1950,7 +2032,7 @@ async function startDeduce(ctx, root, registry, body) {
|
|
|
1950
2032
|
});
|
|
1951
2033
|
const parsed = parseJsonReply(reply);
|
|
1952
2034
|
if (!parsed) throw httpError(502, '模型没有返回可解析的大纲 JSON');
|
|
1953
|
-
const synopsis = asText(firstOf(parsed, ['synopsis', '梗概', '
|
|
2035
|
+
const synopsis = asText(firstOf(parsed, ['synopsis', '梗概', '本章梗概']));
|
|
1954
2036
|
const durationDays = Math.max(1, Math.round(Number(firstOf(parsed, ['durationDays', 'duration', '时间长度', 'days'])) || project.timeline.unitDays));
|
|
1955
2037
|
const minWords = Math.max(1, Math.round(Number(firstOf(parsed, ['minWords', '最低字数'])) || 1200));
|
|
1956
2038
|
const maxWords = Math.max(minWords + 1, Math.round(Number(firstOf(parsed, ['maxWords', '最多字数'])) || 3500));
|
|
@@ -2026,6 +2108,8 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
|
|
|
2026
2108
|
return updateTimeline(root, body);
|
|
2027
2109
|
case '/project/model':
|
|
2028
2110
|
return updateModel(ctx, root, body);
|
|
2111
|
+
case '/project/rebind':
|
|
2112
|
+
return rebindProject(ctx, root, body);
|
|
2029
2113
|
case '/outline/save':
|
|
2030
2114
|
return saveOutline(root, body);
|
|
2031
2115
|
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.15",
|
|
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",
|