dsh-novel-generator 0.1.40 → 0.1.41
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 +84 -1
- package/index.js +97 -1
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -751,6 +751,64 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
751
751
|
h('button', { className: 'dsn-btn primary', type: 'button', onClick: onGoTimeline }, t('去时间长河', 'Open timeline')))));
|
|
752
752
|
}
|
|
753
753
|
|
|
754
|
+
/**
|
|
755
|
+
* Work settings. The first section decides how much of the previous chapter's
|
|
756
|
+
* prose the writing and deduction prompts carry — the knob that trades
|
|
757
|
+
* continuity against prompt size.
|
|
758
|
+
*/
|
|
759
|
+
function SettingsDialog({ project, onCancel, onSaved }) {
|
|
760
|
+
const [reference, setReference] = useState(project.reference || 'full');
|
|
761
|
+
const [busy, setBusy] = useState(false);
|
|
762
|
+
const [error, setError] = useState('');
|
|
763
|
+
|
|
764
|
+
const save = useCallback(async () => {
|
|
765
|
+
setBusy(true);
|
|
766
|
+
setError('');
|
|
767
|
+
try {
|
|
768
|
+
await api('/project/settings', { projectId: project.projectId, reference });
|
|
769
|
+
await onSaved(reference);
|
|
770
|
+
} catch (saveError) {
|
|
771
|
+
setError(messageOf(saveError));
|
|
772
|
+
} finally {
|
|
773
|
+
setBusy(false);
|
|
774
|
+
}
|
|
775
|
+
}, [project.projectId, reference, onSaved]);
|
|
776
|
+
|
|
777
|
+
const option = (value, title, hint) => h('label', { className: 'dsn-check', style: { alignItems: 'flex-start' } },
|
|
778
|
+
h('input', {
|
|
779
|
+
type: 'radio',
|
|
780
|
+
name: 'dsn-reference',
|
|
781
|
+
checked: reference === value,
|
|
782
|
+
onChange: () => setReference(value),
|
|
783
|
+
}),
|
|
784
|
+
h('span', null,
|
|
785
|
+
h('div', null, title),
|
|
786
|
+
h('div', { className: 'dsn-hint' }, hint)));
|
|
787
|
+
|
|
788
|
+
return h('div', { className: 'dsn-dialog-backdrop' },
|
|
789
|
+
h('div', { className: 'dsn-dialog', style: { width: 'min(560px, calc(100% - 48px))' } },
|
|
790
|
+
h('div', { className: 'dsn-dialog-head' },
|
|
791
|
+
h('div', { className: 'dsn-title' }, t('作品设置', 'Work settings')),
|
|
792
|
+
h('div', { className: 'dsn-sub' }, project.title),
|
|
793
|
+
h('div', { className: 'dsn-spacer' })),
|
|
794
|
+
h('div', { className: 'dsn-dialog-body' },
|
|
795
|
+
h('div', { className: 'dsn-label span4' }, t('自动推演 / 生成章节故事 · 前文参考程度', 'Auto-deduction / chapter generation · previous text')),
|
|
796
|
+
h('div', { className: 'dsn-field span4', style: { gap: 10 } },
|
|
797
|
+
option('full', t('前一章(全文)', 'Previous chapter (full text)'),
|
|
798
|
+
t('衔接最稳,提示词最长:前一章整章正文都会带上(超过 8000 字只给最后 8000 字)。', 'Best continuity, longest prompt: the whole previous chapter, trimmed to its last 8000 characters when it is longer.')),
|
|
799
|
+
option('half', t('前一章的后一半', 'Previous chapter (second half)'),
|
|
800
|
+
t('折中:只给后一半,通常足够接住语气与场景,提示词短不少。', 'A middle ground: the second half, usually enough to pick up the voice and the scene, and much shorter.')),
|
|
801
|
+
option('none', t('不参考正文', 'No prose reference'),
|
|
802
|
+
t('只靠世界快照与各章梗概推进;最省上下文,但接续会偏生硬。', 'Only snapshots and synopses drive it — cheapest, but the join can feel abrupt.'))),
|
|
803
|
+
h('div', { className: 'dsn-hint span4' }, t('改动下一次生成就生效,不影响已有章节。', 'Applies to the next generation; existing chapters are untouched.')),
|
|
804
|
+
error ? h('div', { className: 'dsn-hint warn span4' }, error) : null),
|
|
805
|
+
h('div', { className: 'dsn-dialog-foot' },
|
|
806
|
+
h('button', { className: 'dsn-btn ghost', type: 'button', disabled: busy, onClick: onCancel }, t('取消', 'Cancel')),
|
|
807
|
+
h('div', { className: 'dsn-spacer' }),
|
|
808
|
+
h('button', { className: 'dsn-btn primary', type: 'button', disabled: busy, onClick: save },
|
|
809
|
+
busy ? t('保存中…', 'Saving…') : t('保存', 'Save')))));
|
|
810
|
+
}
|
|
811
|
+
|
|
754
812
|
function PresetDialog({ project, presets, onCancel, onSaved }) {
|
|
755
813
|
const [preset, setPreset] = useState(project.agentPreset || '');
|
|
756
814
|
const [busy, setBusy] = useState(false);
|
|
@@ -874,7 +932,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
874
932
|
}, busy ? t('保存中…', 'Saving…') : t('保存', 'Save')))));
|
|
875
933
|
}
|
|
876
934
|
|
|
877
|
-
function ProjectsPage({ boot, resolveWorkspace, onOpen, onReload, onEditModel, onEditPreset, onEditPrelude, onImport }) {
|
|
935
|
+
function ProjectsPage({ boot, resolveWorkspace, onOpen, onReload, onEditModel, onEditPreset, onEditPrelude, onImport, onSettings }) {
|
|
878
936
|
const [title, setTitle] = useState('');
|
|
879
937
|
const [cwd, setCwd] = useState('');
|
|
880
938
|
const [preset, setPreset] = useState('');
|
|
@@ -1128,6 +1186,15 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1128
1186
|
onEditPrelude(project);
|
|
1129
1187
|
},
|
|
1130
1188
|
}, t('前置', 'Preface')),
|
|
1189
|
+
h('button', {
|
|
1190
|
+
className: 'dsn-btn ghost',
|
|
1191
|
+
type: 'button',
|
|
1192
|
+
title: t('作品设置:自动推演与生成正文时对前文的参考程度', 'Work settings: how much previous text the writing prompts carry'),
|
|
1193
|
+
onClick: (event) => {
|
|
1194
|
+
event.stopPropagation();
|
|
1195
|
+
onSettings(project);
|
|
1196
|
+
},
|
|
1197
|
+
}, t('设置', 'Settings')),
|
|
1131
1198
|
h('button', {
|
|
1132
1199
|
className: 'dsn-btn ghost',
|
|
1133
1200
|
type: 'button',
|
|
@@ -2888,6 +2955,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
2888
2955
|
const [presetTarget, setPresetTarget] = useState(null);
|
|
2889
2956
|
const [preludeTarget, setPreludeTarget] = useState(null);
|
|
2890
2957
|
const [importTarget, setImportTarget] = useState(null);
|
|
2958
|
+
const [settingsTarget, setSettingsTarget] = useState(null);
|
|
2891
2959
|
// Import's outline sync can move chapters too; the notice belongs here
|
|
2892
2960
|
// because the import dialog closes before the shift is known.
|
|
2893
2961
|
const [shiftNotice, setShiftNotice] = useState(null);
|
|
@@ -2985,6 +3053,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
2985
3053
|
onEditPreset: setPresetTarget,
|
|
2986
3054
|
onEditPrelude: setPreludeTarget,
|
|
2987
3055
|
onImport: setImportTarget,
|
|
3056
|
+
onSettings: setSettingsTarget,
|
|
2988
3057
|
}))
|
|
2989
3058
|
: detail === null
|
|
2990
3059
|
? h('div', { className: 'dsn-empty', style: { margin: 'auto' } }, t('正在载入作品…', "Loading the work…"))
|
|
@@ -3056,6 +3125,20 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
3056
3125
|
},
|
|
3057
3126
|
})
|
|
3058
3127
|
: null,
|
|
3128
|
+
settingsTarget
|
|
3129
|
+
? h(SettingsDialog, {
|
|
3130
|
+
project: settingsTarget,
|
|
3131
|
+
onCancel: () => setSettingsTarget(null),
|
|
3132
|
+
onSaved: async (reference) => {
|
|
3133
|
+
setSettingsTarget(null);
|
|
3134
|
+
await loadBoot();
|
|
3135
|
+
const label = reference === 'full'
|
|
3136
|
+
? t('前一章全文', 'the full previous chapter')
|
|
3137
|
+
: (reference === 'half' ? t('前一章的后一半', 'the second half of the previous chapter') : t('不参考正文', 'no prose reference'));
|
|
3138
|
+
setToast({ text: t('已保存:前文参考程度 = {label}', 'Saved: previous-text reference = {label}', { label }), kind: 'success' });
|
|
3139
|
+
},
|
|
3140
|
+
})
|
|
3141
|
+
: null,
|
|
3059
3142
|
shiftNotice
|
|
3060
3143
|
? h(ShiftNoticeDialog, {
|
|
3061
3144
|
shiftDays: shiftNotice.shiftDays,
|
package/index.js
CHANGED
|
@@ -358,6 +358,8 @@ async function exportProjectInfo(project) {
|
|
|
358
358
|
lines.push(`- 预设:${project.agentPreset || '(默认)'}`);
|
|
359
359
|
const preludeText = project.prelude && project.prelude.text ? String(project.prelude.text).trim() : '';
|
|
360
360
|
lines.push(`- 前置提示词:${preludeText === '' ? '(未设置)' : '已设置,见文末'}`);
|
|
361
|
+
const referenceLabel = { full: '前一章全文', half: '前一章的后一半', none: '不参考' }[normalizeReference(project.reference)];
|
|
362
|
+
lines.push(`- 前文参考程度:${referenceLabel}`);
|
|
361
363
|
lines.push(`- 快照间隔:每 ${timeline.unitDays || 30} 天一份`);
|
|
362
364
|
lines.push(`- 已写入快照:${snapshots.length ? snapshots.map((snapshot) => `第 ${snapshot.day} 天`).join('、') : '(暂无)'}`);
|
|
363
365
|
lines.push(`- 故事当前:第 ${lastChapterEnd(project)} 天`);
|
|
@@ -675,6 +677,8 @@ function summarize(project) {
|
|
|
675
677
|
// The work-wide preface prompt: narrative angle plus anything else the
|
|
676
678
|
// author wants every request to carry.
|
|
677
679
|
prelude: project.prelude || null,
|
|
680
|
+
// How much of the previous chapter's prose the writing prompts carry.
|
|
681
|
+
reference: normalizeReference(project.reference),
|
|
678
682
|
createdAt: project.createdAt,
|
|
679
683
|
updatedAt: project.updatedAt,
|
|
680
684
|
chapterCount: chapters.length,
|
|
@@ -1234,6 +1238,72 @@ function stretchLines(project, chapter, snapshot) {
|
|
|
1234
1238
|
return lines;
|
|
1235
1239
|
}
|
|
1236
1240
|
|
|
1241
|
+
/* --------------------------------------------------- previous-chapter text */
|
|
1242
|
+
|
|
1243
|
+
/** How much of the previous chapter's prose a writing prompt carries. */
|
|
1244
|
+
const REFERENCE_MODES = ['full', 'half', 'none'];
|
|
1245
|
+
const DEFAULT_REFERENCE = 'full';
|
|
1246
|
+
/** Hard ceiling on the quoted prose, so a long chapter cannot swamp the prompt. */
|
|
1247
|
+
const REFERENCE_MAX_CHARS = 8000;
|
|
1248
|
+
|
|
1249
|
+
function normalizeReference(value) {
|
|
1250
|
+
const mode = asText(value);
|
|
1251
|
+
return REFERENCE_MODES.includes(mode) ? mode : DEFAULT_REFERENCE;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/** The tail of `text` from its halfway sentence boundary, for the `half` mode. */
|
|
1255
|
+
function secondHalfText(text) {
|
|
1256
|
+
const source = String(text || '');
|
|
1257
|
+
if (source.length === 0) return '';
|
|
1258
|
+
const middle = Math.floor(source.length / 2);
|
|
1259
|
+
const stops = ['。', '!', '?', '…', '\n'];
|
|
1260
|
+
let cut = -1;
|
|
1261
|
+
for (let index = middle; index < Math.min(source.length, middle + 400); index += 1) {
|
|
1262
|
+
if (stops.includes(source[index])) {
|
|
1263
|
+
cut = index + 1;
|
|
1264
|
+
break;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
if (cut < 0) {
|
|
1268
|
+
for (let index = middle; index > Math.max(0, middle - 400); index -= 1) {
|
|
1269
|
+
if (stops.includes(source[index])) {
|
|
1270
|
+
cut = index + 1;
|
|
1271
|
+
break;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
return source.slice(cut < 0 ? middle : cut).trim();
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* The previous chapter's prose, per the work's reference setting.
|
|
1280
|
+
*
|
|
1281
|
+
* Long chapters are trimmed from the front: what matters for continuity is how
|
|
1282
|
+
* the previous chapter *ends*, since this one has to continue from there.
|
|
1283
|
+
*/
|
|
1284
|
+
function previousProseBlock(project, chapter, label) {
|
|
1285
|
+
const mode = normalizeReference(project.reference);
|
|
1286
|
+
if (mode === 'none') return [];
|
|
1287
|
+
const previous = previousChapter(project, chapter);
|
|
1288
|
+
if (!previous) return [];
|
|
1289
|
+
const raw = String(previous.content || '');
|
|
1290
|
+
if (raw.trim() === '') return [];
|
|
1291
|
+
let text = mode === 'half' ? secondHalfText(raw) : raw.trim();
|
|
1292
|
+
let truncated = false;
|
|
1293
|
+
if (text.length > REFERENCE_MAX_CHARS) {
|
|
1294
|
+
text = text.slice(text.length - REFERENCE_MAX_CHARS);
|
|
1295
|
+
truncated = true;
|
|
1296
|
+
}
|
|
1297
|
+
const span = chapterWindow(project, previous);
|
|
1298
|
+
const lines = [];
|
|
1299
|
+
lines.push(`【${label || '上一章正文'}】(第 ${previous.index + 1} 章 ${previous.title || ''},第 ${span.start}→${span.end} 天)`);
|
|
1300
|
+
if (truncated) lines.push(`(正文较长,只给最后 ${REFERENCE_MAX_CHARS} 字)`);
|
|
1301
|
+
if (mode === 'half') lines.push('(只给后一半,用来衔接语气与场景)');
|
|
1302
|
+
lines.push(text);
|
|
1303
|
+
lines.push('');
|
|
1304
|
+
return lines;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1237
1307
|
function chapterPrompt(project, chapter, previous) {
|
|
1238
1308
|
const window = chapterWindow(project, chapter);
|
|
1239
1309
|
const outline = chapter.outline || {};
|
|
@@ -1260,6 +1330,9 @@ function chapterPrompt(project, chapter, previous) {
|
|
|
1260
1330
|
lines.push(`${previous.title || ''}:${(previous.outline && previous.outline.synopsis) || '(无)'}`);
|
|
1261
1331
|
lines.push('');
|
|
1262
1332
|
}
|
|
1333
|
+
// The previous chapter's own prose, so the new chapter continues in the same
|
|
1334
|
+
// voice and picks up from the exact scene it ended on.
|
|
1335
|
+
lines.push(...previousProseBlock(project, chapter, '上一章正文 · 供衔接'));
|
|
1263
1336
|
lines.push('【本章梗概 · 待生成的部分】');
|
|
1264
1337
|
lines.push(outline.synopsis || '(无)');
|
|
1265
1338
|
lines.push('');
|
|
@@ -1288,6 +1361,9 @@ function deducePrompt(project, chapter) {
|
|
|
1288
1361
|
lines.push('');
|
|
1289
1362
|
const stretch = stretchLines(project, target, snapshot);
|
|
1290
1363
|
if (stretch.length) lines.push(...stretch);
|
|
1364
|
+
// Auto-deduction reads the previous chapter's prose too, so the synopsis it
|
|
1365
|
+
// invents follows on from what was actually written.
|
|
1366
|
+
lines.push(...previousProseBlock(project, target, '上一章正文 · 供衔接'));
|
|
1291
1367
|
lines.push('【输出 JSON 结构】');
|
|
1292
1368
|
lines.push('{"synopsis": "本章梗概,300 字以内,包含起因、经过、转折", "durationDays": 30, "minWords": 1200, "maxWords": 3500, "title": "本章标题"}');
|
|
1293
1369
|
lines.push('');
|
|
@@ -1499,7 +1575,7 @@ async function bootstrap(ctx, root) {
|
|
|
1499
1575
|
}
|
|
1500
1576
|
}
|
|
1501
1577
|
return {
|
|
1502
|
-
revision: '2026-10-29.
|
|
1578
|
+
revision: '2026-10-29.4',
|
|
1503
1579
|
projects,
|
|
1504
1580
|
models: {
|
|
1505
1581
|
default: modelCatalog.default || null,
|
|
@@ -1578,6 +1654,8 @@ async function createProject(ctx, root, body) {
|
|
|
1578
1654
|
// Every new work starts with the default narrative preface; the author can
|
|
1579
1655
|
// edit or clear it from the work list.
|
|
1580
1656
|
prelude: defaultPrelude(),
|
|
1657
|
+
// Previous-chapter prose in writing prompts: full text by default.
|
|
1658
|
+
reference: DEFAULT_REFERENCE,
|
|
1581
1659
|
sessionId,
|
|
1582
1660
|
createdAt: Date.now(),
|
|
1583
1661
|
updatedAt: Date.now(),
|
|
@@ -1954,6 +2032,22 @@ function defaultPrelude() {
|
|
|
1954
2032
|
* from; the text itself is what every request carries, so the author can edit a
|
|
1955
2033
|
* preset's wording or write their own.
|
|
1956
2034
|
*/
|
|
2035
|
+
/** Work-level settings: how much of the previous chapter the prompts carry. */
|
|
2036
|
+
async function updateSettings(root, body) {
|
|
2037
|
+
const projectId = asText(body.projectId);
|
|
2038
|
+
const reference = asText(body.reference);
|
|
2039
|
+
if (reference !== '' && !REFERENCE_MODES.includes(reference)) {
|
|
2040
|
+
throw httpError(400, `参考程度只能是 ${REFERENCE_MODES.join(' / ')}`);
|
|
2041
|
+
}
|
|
2042
|
+
const project = await mutateProject(root, projectId, (draft) => {
|
|
2043
|
+
draft.reference = normalizeReference(reference);
|
|
2044
|
+
draft.updatedAt = Date.now();
|
|
2045
|
+
return summarize(draft);
|
|
2046
|
+
});
|
|
2047
|
+
await exportProjectInfo(await readProject(root, projectId));
|
|
2048
|
+
return { project };
|
|
2049
|
+
}
|
|
2050
|
+
|
|
1957
2051
|
async function updatePrelude(root, body) {
|
|
1958
2052
|
const projectId = asText(body.projectId);
|
|
1959
2053
|
const mode = asText(body.mode);
|
|
@@ -3417,6 +3511,8 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
|
|
|
3417
3511
|
return startStyleExtract(ctx, root, registry, body);
|
|
3418
3512
|
case '/style/apply':
|
|
3419
3513
|
return startStyleApply(ctx, root, registry, body);
|
|
3514
|
+
case '/project/settings':
|
|
3515
|
+
return updateSettings(root, body);
|
|
3420
3516
|
case '/import/apply':
|
|
3421
3517
|
return startImportChapters(ctx, root, registry, body);
|
|
3422
3518
|
case '/outline/revise':
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-novel-generator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.41",
|
|
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",
|