dsh-novel-generator 0.1.39 → 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 +119 -5
- package/index.js +168 -23
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -722,7 +722,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
722
722
|
* After a duration change moves the chapters that follow, the snapshots stay
|
|
723
723
|
* where they were — this dialog is the only place the author learns about it.
|
|
724
724
|
*/
|
|
725
|
-
function ShiftNoticeDialog({ shiftDays, shifted, onClose, onGoTimeline }) {
|
|
725
|
+
function ShiftNoticeDialog({ shiftDays, shifted, scope, onClose, onGoTimeline }) {
|
|
726
726
|
const list = Array.isArray(shifted) ? shifted : [];
|
|
727
727
|
const sample = list.slice(0, 6);
|
|
728
728
|
return h('div', { className: 'dsn-dialog-backdrop' },
|
|
@@ -731,7 +731,9 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
731
731
|
h('div', { className: 'dsn-title' }, t('时间线已顺移,快照需要你手动更新', 'The timeline moved — snapshots need updating')),
|
|
732
732
|
h('div', { className: 'dsn-spacer' })),
|
|
733
733
|
h('div', { className: 'dsn-dialog-body' },
|
|
734
|
-
h('div', { className: 'dsn-text span4' },
|
|
734
|
+
h('div', { className: 'dsn-text span4' }, scope === 'import'
|
|
735
|
+
? t('导入时同步大纲改动了章节的时间长度,之后 {n} 章已整体顺移,保持首尾相接:', 'Syncing the imported chapters changed their lengths, and {n} following chapters moved with them so the timeline stays contiguous:', { n: list.length })
|
|
736
|
+
: t('本章的时间长度改变了 {d} 天,之后 {n} 章已整体顺移,保持首尾相接:', 'This chapter’s length changed by {d} days, and {n} following chapters moved with it so the timeline stays contiguous:', { d: shiftDays > 0 ? `+${shiftDays}` : shiftDays, n: list.length })),
|
|
735
737
|
list.length > 0
|
|
736
738
|
? h('div', { className: 'dsn-importlist span4' },
|
|
737
739
|
sample.map((entry) => h('div', { key: entry.chapterId, className: 'dsn-importrow' },
|
|
@@ -749,6 +751,64 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
749
751
|
h('button', { className: 'dsn-btn primary', type: 'button', onClick: onGoTimeline }, t('去时间长河', 'Open timeline')))));
|
|
750
752
|
}
|
|
751
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
|
+
|
|
752
812
|
function PresetDialog({ project, presets, onCancel, onSaved }) {
|
|
753
813
|
const [preset, setPreset] = useState(project.agentPreset || '');
|
|
754
814
|
const [busy, setBusy] = useState(false);
|
|
@@ -872,7 +932,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
872
932
|
}, busy ? t('保存中…', 'Saving…') : t('保存', 'Save')))));
|
|
873
933
|
}
|
|
874
934
|
|
|
875
|
-
function ProjectsPage({ boot, resolveWorkspace, onOpen, onReload, onEditModel, onEditPreset, onEditPrelude, onImport }) {
|
|
935
|
+
function ProjectsPage({ boot, resolveWorkspace, onOpen, onReload, onEditModel, onEditPreset, onEditPrelude, onImport, onSettings }) {
|
|
876
936
|
const [title, setTitle] = useState('');
|
|
877
937
|
const [cwd, setCwd] = useState('');
|
|
878
938
|
const [preset, setPreset] = useState('');
|
|
@@ -1126,6 +1186,15 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1126
1186
|
onEditPrelude(project);
|
|
1127
1187
|
},
|
|
1128
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')),
|
|
1129
1198
|
h('button', {
|
|
1130
1199
|
className: 'dsn-btn ghost',
|
|
1131
1200
|
type: 'button',
|
|
@@ -1703,7 +1772,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1703
1772
|
}, busy ? t('改写中…', 'Revising…') : t('确认修改', 'Revise')))));
|
|
1704
1773
|
}
|
|
1705
1774
|
|
|
1706
|
-
function OutlinePage({ detail, chapter, isFirstChapter, onBack, onSaved, notify, onRegisterGuard, requestConfirm, onBusyChange, onReviseSynopsis, synopsisDraft, onSynopsisDraftApplied, revisingSynopsis }) {
|
|
1775
|
+
function OutlinePage({ detail, chapter, isFirstChapter, onBack, onSaved, notify, onRegisterGuard, requestConfirm, onBusyChange, onReviseSynopsis, synopsisDraft, onSynopsisDraftApplied, revisingSynopsis, onShiftNotice }) {
|
|
1707
1776
|
const buildForm = useCallback(() => ({
|
|
1708
1777
|
title: chapter ? chapter.title || '' : '',
|
|
1709
1778
|
durationDays: chapter && chapter.outline ? chapter.outline.durationDays : (detail.timeline.unitDays || 30),
|
|
@@ -1761,6 +1830,10 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1761
1830
|
setSavedAt(Date.now());
|
|
1762
1831
|
setDirty(false);
|
|
1763
1832
|
if (silent !== true) notify(t('章节大纲已保存', "Outline saved"), 'success');
|
|
1833
|
+
// A length change moved the chapters after it; snapshots did not follow.
|
|
1834
|
+
if (result.shiftDays && Array.isArray(result.shifted) && result.shifted.length > 0) {
|
|
1835
|
+
if (typeof onShiftNotice === 'function') onShiftNotice({ shiftDays: result.shiftDays, shifted: result.shifted });
|
|
1836
|
+
}
|
|
1764
1837
|
await onSaved(result.chapter.chapterId);
|
|
1765
1838
|
return true;
|
|
1766
1839
|
} catch (error) {
|
|
@@ -1769,7 +1842,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1769
1842
|
} finally {
|
|
1770
1843
|
setSaving(false);
|
|
1771
1844
|
}
|
|
1772
|
-
}, [complete, detail.projectId, chapter, form, onSaved, notify]);
|
|
1845
|
+
}, [complete, detail.projectId, chapter, form, onSaved, notify, onShiftNotice]);
|
|
1773
1846
|
|
|
1774
1847
|
// The workspace asks this page before any navigation discards its edits.
|
|
1775
1848
|
const guardRef = useRef(null);
|
|
@@ -2759,6 +2832,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
2759
2832
|
synopsisDraft,
|
|
2760
2833
|
onSynopsisDraftApplied: () => setSynopsisDraft(null),
|
|
2761
2834
|
revisingSynopsis,
|
|
2835
|
+
onShiftNotice: setShiftNotice,
|
|
2762
2836
|
isFirstChapter: Boolean(chapter && chapters.length > 0 && chapter.chapterId === chapters[0].chapterId),
|
|
2763
2837
|
}))),
|
|
2764
2838
|
shiftNotice
|
|
@@ -2881,6 +2955,10 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
2881
2955
|
const [presetTarget, setPresetTarget] = useState(null);
|
|
2882
2956
|
const [preludeTarget, setPreludeTarget] = useState(null);
|
|
2883
2957
|
const [importTarget, setImportTarget] = useState(null);
|
|
2958
|
+
const [settingsTarget, setSettingsTarget] = useState(null);
|
|
2959
|
+
// Import's outline sync can move chapters too; the notice belongs here
|
|
2960
|
+
// because the import dialog closes before the shift is known.
|
|
2961
|
+
const [shiftNotice, setShiftNotice] = useState(null);
|
|
2884
2962
|
const leaveGuard = useRef(null);
|
|
2885
2963
|
const registerLeaveGuard = useCallback((guard) => {
|
|
2886
2964
|
leaveGuard.current = guard;
|
|
@@ -2975,6 +3053,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
2975
3053
|
onEditPreset: setPresetTarget,
|
|
2976
3054
|
onEditPrelude: setPreludeTarget,
|
|
2977
3055
|
onImport: setImportTarget,
|
|
3056
|
+
onSettings: setSettingsTarget,
|
|
2978
3057
|
}))
|
|
2979
3058
|
: detail === null
|
|
2980
3059
|
? h('div', { className: 'dsn-empty', style: { margin: 'auto' } }, t('正在载入作品…', "Loading the work…"))
|
|
@@ -3020,6 +3099,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
3020
3099
|
resolveWorkspace,
|
|
3021
3100
|
onCancel: () => setImportTarget(null),
|
|
3022
3101
|
onDone: async (result) => {
|
|
3102
|
+
const finished = importTarget;
|
|
3023
3103
|
setImportTarget(null);
|
|
3024
3104
|
await loadBoot();
|
|
3025
3105
|
const imported = (result.imported || []).length;
|
|
@@ -3035,6 +3115,40 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
3035
3115
|
}));
|
|
3036
3116
|
if (result.snapshotJobId) parts.push(t('世界快照已开始生成', 'World snapshot started'));
|
|
3037
3117
|
setToast({ text: parts.join(';'), kind: failed > 0 ? 'error' : 'success' });
|
|
3118
|
+
if (result.shiftDays && Array.isArray(result.shifted) && result.shifted.length > 0) {
|
|
3119
|
+
setShiftNotice({
|
|
3120
|
+
shiftDays: result.shiftDays,
|
|
3121
|
+
shifted: result.shifted,
|
|
3122
|
+
projectId: finished ? finished.projectId : '',
|
|
3123
|
+
});
|
|
3124
|
+
}
|
|
3125
|
+
},
|
|
3126
|
+
})
|
|
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,
|
|
3142
|
+
shiftNotice
|
|
3143
|
+
? h(ShiftNoticeDialog, {
|
|
3144
|
+
shiftDays: shiftNotice.shiftDays,
|
|
3145
|
+
shifted: shiftNotice.shifted,
|
|
3146
|
+
scope: 'import',
|
|
3147
|
+
onClose: () => setShiftNotice(null),
|
|
3148
|
+
onGoTimeline: () => {
|
|
3149
|
+
const targetId = shiftNotice.projectId || '';
|
|
3150
|
+
setShiftNotice(null);
|
|
3151
|
+
if (targetId !== '') openProject(targetId);
|
|
3038
3152
|
},
|
|
3039
3153
|
})
|
|
3040
3154
|
: null,
|
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);
|
|
@@ -2103,10 +2197,7 @@ async function startSyncOutline(ctx, root, registry, body) {
|
|
|
2103
2197
|
* back into its outline". Used by the story page's button and by chapter import,
|
|
2104
2198
|
* so both produce identical outlines.
|
|
2105
2199
|
*/
|
|
2106
|
-
async function syncChapterOutline(ctx, root, projectId, chapterId, prose, note
|
|
2107
|
-
// Import sets the per-chapter length explicitly, so its sync keeps that number
|
|
2108
|
-
// and never shifts the chapters that follow.
|
|
2109
|
-
const keepDuration = options.keepDuration === true;
|
|
2200
|
+
async function syncChapterOutline(ctx, root, projectId, chapterId, prose, note) {
|
|
2110
2201
|
const project = await readProject(root, projectId);
|
|
2111
2202
|
const chapter = project.chapters.find((entry) => entry.chapterId === chapterId);
|
|
2112
2203
|
if (!chapter) throw httpError(404, `章节不存在: ${chapterId}`);
|
|
@@ -2143,12 +2234,11 @@ async function syncChapterOutline(ctx, root, projectId, chapterId, prose, note,
|
|
|
2143
2234
|
// The measured duration wins, and the timeline stays contiguous: every later
|
|
2144
2235
|
// chapter moves with it. Snapshots are deliberately left where they are — the
|
|
2145
2236
|
// Client tells the author to regenerate the ones that no longer line up.
|
|
2146
|
-
let durationDays = chapter.durationDays;
|
|
2147
2237
|
const proposed = Number.isFinite(Number(parsed.durationDays))
|
|
2148
2238
|
? Math.max(1, Math.min(365, Math.round(Number(parsed.durationDays))))
|
|
2149
2239
|
: null;
|
|
2150
|
-
|
|
2151
|
-
|
|
2240
|
+
let durationDays = proposed === null ? chapter.durationDays : proposed;
|
|
2241
|
+
let shiftDays = 0;
|
|
2152
2242
|
const shifted = [];
|
|
2153
2243
|
|
|
2154
2244
|
const saved = await mutateProject(root, projectId, (draft) => {
|
|
@@ -2163,18 +2253,14 @@ async function syncChapterOutline(ctx, root, projectId, chapterId, prose, note,
|
|
|
2163
2253
|
synopsisMinChars,
|
|
2164
2254
|
savedAt: Date.now(),
|
|
2165
2255
|
};
|
|
2166
|
-
target.durationDays = durationDays;
|
|
2167
2256
|
target.updatedAt = Date.now();
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
shifted.push({ chapterId: other.chapterId, title: other.title, fromDay: from, toDay: other.startDay });
|
|
2176
|
-
}
|
|
2177
|
-
}
|
|
2257
|
+
// Shared with the outline save and chapter import: any length change moves
|
|
2258
|
+
// the chapters that follow.
|
|
2259
|
+
const moved = applyDurationShift(draft.chapters, target, durationDays);
|
|
2260
|
+
durationDays = target.durationDays;
|
|
2261
|
+
shiftDays = moved.shiftDays;
|
|
2262
|
+
shifted.push(...moved.shifted);
|
|
2263
|
+
target.outline.durationDays = durationDays;
|
|
2178
2264
|
return { chapter: { ...target, content: undefined }, project: summarize(draft) };
|
|
2179
2265
|
});
|
|
2180
2266
|
const fresh = await readProject(root, projectId);
|
|
@@ -2423,6 +2509,8 @@ async function startImportChapters(ctx, root, registry, body) {
|
|
|
2423
2509
|
}
|
|
2424
2510
|
|
|
2425
2511
|
const synced = [];
|
|
2512
|
+
const shifted = [];
|
|
2513
|
+
let shiftDaysTotal = 0;
|
|
2426
2514
|
if (syncOutline && imported.length > 0) {
|
|
2427
2515
|
for (let index = 0; index < imported.length; index += 1) {
|
|
2428
2516
|
const entry = imported[index];
|
|
@@ -2432,8 +2520,17 @@ async function startImportChapters(ctx, root, registry, body) {
|
|
|
2432
2520
|
const chapter = project.chapters.find((item) => item.chapterId === entry.chapterId);
|
|
2433
2521
|
const result = await syncChapterOutline(ctx, root, projectId, entry.chapterId, chapter ? (chapter.content || '') : '', (note) => {
|
|
2434
2522
|
job.message = note;
|
|
2435
|
-
}
|
|
2523
|
+
});
|
|
2436
2524
|
synced.push({ chapterId: entry.chapterId, synopsisChars: (result.synopsis || '').length });
|
|
2525
|
+
for (const move of (result.shifted || [])) {
|
|
2526
|
+
const seen = shifted.find((item) => item.chapterId === move.chapterId);
|
|
2527
|
+
if (seen) {
|
|
2528
|
+
seen.toDay = move.toDay;
|
|
2529
|
+
} else {
|
|
2530
|
+
shifted.push({ ...move });
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
shiftDaysTotal += Number(result.shiftDays) || 0;
|
|
2437
2534
|
} catch (error) {
|
|
2438
2535
|
failed.push({ fileName: entry.title, reason: `同步大纲失败:${error instanceof Error ? error.message : String(error)}` });
|
|
2439
2536
|
}
|
|
@@ -2454,6 +2551,8 @@ async function startImportChapters(ctx, root, registry, body) {
|
|
|
2454
2551
|
skipped,
|
|
2455
2552
|
failed,
|
|
2456
2553
|
synced,
|
|
2554
|
+
shifted,
|
|
2555
|
+
shiftDays: shiftDaysTotal,
|
|
2457
2556
|
snapshotJobId,
|
|
2458
2557
|
project: summarize(await readProject(root, projectId)),
|
|
2459
2558
|
};
|
|
@@ -2603,6 +2702,33 @@ function nextChapterIndex(project) {
|
|
|
2603
2702
|
return max + 1;
|
|
2604
2703
|
}
|
|
2605
2704
|
|
|
2705
|
+
/**
|
|
2706
|
+
* Keep story time contiguous after one chapter's length changes.
|
|
2707
|
+
*
|
|
2708
|
+
* The chapter takes the new length and every chapter starting at or after it
|
|
2709
|
+
* moves by the same number of days, so the timeline never opens a gap or an
|
|
2710
|
+
* overlap. Snapshots are not touched — the Client tells the author to update
|
|
2711
|
+
* them. Returns the moves so callers can report them.
|
|
2712
|
+
*/
|
|
2713
|
+
function applyDurationShift(chapters, chapter, nextDuration) {
|
|
2714
|
+
const previous = Math.max(1, Math.round(Number(chapter.durationDays) || 1));
|
|
2715
|
+
const next = Math.max(1, Math.round(Number(nextDuration) || previous));
|
|
2716
|
+
const shifted = [];
|
|
2717
|
+
chapter.durationDays = next;
|
|
2718
|
+
const delta = next - previous;
|
|
2719
|
+
if (delta === 0) return { shiftDays: 0, shifted };
|
|
2720
|
+
const start = Math.max(0, Math.round(Number(chapter.startDay) || 0));
|
|
2721
|
+
for (const other of chapters) {
|
|
2722
|
+
if (other.chapterId === chapter.chapterId) continue;
|
|
2723
|
+
if ((Number(other.startDay) || 0) < start) continue;
|
|
2724
|
+
const from = other.startDay;
|
|
2725
|
+
other.startDay = Math.max(start + 1, from + delta);
|
|
2726
|
+
other.updatedAt = Date.now();
|
|
2727
|
+
shifted.push({ chapterId: other.chapterId, title: other.title, fromDay: from, toDay: other.startDay });
|
|
2728
|
+
}
|
|
2729
|
+
return { shiftDays: delta, shifted };
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2606
2732
|
async function saveOutline(ctx, root, registry, body) {
|
|
2607
2733
|
const projectId = asText(body.projectId);
|
|
2608
2734
|
const chapterId = asText(body.chapterId);
|
|
@@ -2614,6 +2740,7 @@ async function saveOutline(ctx, root, registry, body) {
|
|
|
2614
2740
|
let savedProject = null;
|
|
2615
2741
|
let savedChapter = null;
|
|
2616
2742
|
let renamedFrom = null;
|
|
2743
|
+
let outlineShift = { shiftDays: 0, shifted: [] };
|
|
2617
2744
|
const result = await mutateProject(root, projectId, (project) => {
|
|
2618
2745
|
let chapter = chapterId === '' ? null : project.chapters.find((entry) => entry.chapterId === chapterId);
|
|
2619
2746
|
if (!chapter) {
|
|
@@ -2637,7 +2764,10 @@ async function saveOutline(ctx, root, registry, body) {
|
|
|
2637
2764
|
}
|
|
2638
2765
|
if (title !== '') chapter.title = title;
|
|
2639
2766
|
chapter.outline = outline;
|
|
2640
|
-
|
|
2767
|
+
// Length changes move the chapters after this one, whatever the caller is.
|
|
2768
|
+
const moved = applyDurationShift(project.chapters, chapter, outline.durationDays);
|
|
2769
|
+
outlineShift = moved;
|
|
2770
|
+
chapter.outline.durationDays = chapter.durationDays;
|
|
2641
2771
|
if (chapter.status === 'draft') chapter.status = 'outlined';
|
|
2642
2772
|
chapter.updatedAt = Date.now();
|
|
2643
2773
|
savedProject = project;
|
|
@@ -2668,7 +2798,13 @@ async function saveOutline(ctx, root, registry, body) {
|
|
|
2668
2798
|
snapshotJobId = registerSnapshot(ctx, root, registry, projectId, target).jobId;
|
|
2669
2799
|
}
|
|
2670
2800
|
}
|
|
2671
|
-
return
|
|
2801
|
+
return {
|
|
2802
|
+
...result,
|
|
2803
|
+
...(snapshotJobId === null ? {} : { snapshotJobId }),
|
|
2804
|
+
...(outlineShift.shifted.length === 0
|
|
2805
|
+
? {}
|
|
2806
|
+
: { shiftDays: outlineShift.shiftDays, shifted: outlineShift.shifted }),
|
|
2807
|
+
};
|
|
2672
2808
|
}
|
|
2673
2809
|
|
|
2674
2810
|
async function saveChapter(ctx, root, registry, body) {
|
|
@@ -3133,6 +3269,8 @@ async function startCompose(ctx, root, registry, body) {
|
|
|
3133
3269
|
const existing = existingId === ''
|
|
3134
3270
|
? null
|
|
3135
3271
|
: (project.chapters || []).find((entry) => entry.chapterId === existingId) || null;
|
|
3272
|
+
// Set inside the mutation below when re-deducing moves the chapters after it.
|
|
3273
|
+
let composeShift = { shiftDays: 0, shifted: [] };
|
|
3136
3274
|
if (existingId !== '' && !existing) throw httpError(404, `章节不存在: ${existingId}`);
|
|
3137
3275
|
|
|
3138
3276
|
// The first chapter of a work has no snapshot and no previous chapter, so
|
|
@@ -3183,9 +3321,12 @@ async function startCompose(ctx, root, registry, body) {
|
|
|
3183
3321
|
const target = current.chapters.find((entry) => entry.chapterId === draft.chapterId);
|
|
3184
3322
|
if (target) {
|
|
3185
3323
|
target.title = title;
|
|
3186
|
-
target.durationDays = durationDays;
|
|
3187
3324
|
target.outline = outline;
|
|
3188
3325
|
target.updatedAt = Date.now();
|
|
3326
|
+
// Re-deducing an existing chapter can change its length too.
|
|
3327
|
+
const moved = applyDurationShift(current.chapters, target, durationDays);
|
|
3328
|
+
composeShift = moved;
|
|
3329
|
+
target.outline.durationDays = target.durationDays;
|
|
3189
3330
|
} else {
|
|
3190
3331
|
current.chapters.push({
|
|
3191
3332
|
...draft,
|
|
@@ -3238,6 +3379,8 @@ async function startCompose(ctx, root, registry, body) {
|
|
|
3238
3379
|
synopsis,
|
|
3239
3380
|
wordCount: wordCount(content),
|
|
3240
3381
|
snapshotJobId,
|
|
3382
|
+
shiftDays: composeShift.shiftDays,
|
|
3383
|
+
shifted: composeShift.shifted,
|
|
3241
3384
|
};
|
|
3242
3385
|
});
|
|
3243
3386
|
}
|
|
@@ -3368,6 +3511,8 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
|
|
|
3368
3511
|
return startStyleExtract(ctx, root, registry, body);
|
|
3369
3512
|
case '/style/apply':
|
|
3370
3513
|
return startStyleApply(ctx, root, registry, body);
|
|
3514
|
+
case '/project/settings':
|
|
3515
|
+
return updateSettings(root, body);
|
|
3371
3516
|
case '/import/apply':
|
|
3372
3517
|
return startImportChapters(ctx, root, registry, body);
|
|
3373
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",
|