dsh-novel-generator 0.1.23 → 0.1.26
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/README.md +17 -0
- package/client.js +227 -5
- package/index.js +186 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -64,6 +64,23 @@ dsh plugin --profile web add dsh-novel-generator
|
|
|
64
64
|
- 生成、推演、快照都是异步任务:`POST /generate`、`POST /compose`、`POST /deduce`、`POST /snapshot/refresh` 返回 `jobId`,再由 `GET /job?id=` 轮询。
|
|
65
65
|
- `timeline.cursor` 现在以「天」为单位;旧数据(按格计数)在首次读取时自动换算一次并标记 `cursorUnit: "day"`。
|
|
66
66
|
|
|
67
|
+
## 前置提示词(作品级)
|
|
68
|
+
|
|
69
|
+
作品列表每行的「前置」可以给一本书设定前置提示:内置「第三人称全知(默认)/ 第三人称限知 / 第一人称 / 自定义」四种,选中即填入文本框,文本可自由改写(最多 2000 字)。
|
|
70
|
+
|
|
71
|
+
**新建作品默认就带「第三人称全知」那一段**(传统修仙文、禁贴身 POV、环境优先等 6 条),不喜欢可以直接改或清空。
|
|
72
|
+
|
|
73
|
+
- 每次**生成正文、自动推演、AI 修改**时,这段文本都会以「【本书前置设定】…」的形式放在提示最前面;生成世界快照时不带(那是结构化数据任务)。
|
|
74
|
+
- 保存后**下一次生成就生效**,不需要重建会话——它不依赖会话状态。
|
|
75
|
+
- 内容同时写进作品的 `作品信息.md`(文末「前置提示词」一节),便于外部查看与备份。
|
|
76
|
+
- 留空即表示不设置。
|
|
77
|
+
|
|
78
|
+
## 章节故事页
|
|
79
|
+
|
|
80
|
+
- 正文可以直接编辑、保存;工具条上还有「AI 修改」:填入修改意见后,模型按意见重写整章。
|
|
81
|
+
- 正文为空时按钮不可点,接口也会拒绝(400);修改意见为空同样拒绝。
|
|
82
|
+
- **重写结果只放进编辑区、不自动保存**,状态变成「未保存」,你确认后再点「保存」——避免 AI 的改动直接覆盖原稿。
|
|
83
|
+
|
|
67
84
|
## 迁移、导入与会话
|
|
68
85
|
|
|
69
86
|
- **改预设**:作品列表每行的「预设」改的是「下次建会话时用哪个预设」。会话在创建时就固定了预设,所以保存后会提示「重建会话后生效」——要立刻换,点同一行的「重建会话」。
|
package/client.js
CHANGED
|
@@ -189,7 +189,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
189
189
|
.dsn-path{display:flex;gap:6px}
|
|
190
190
|
.dsn-import{display:flex;align-items:center;gap:10px;margin:2px 0 12px}
|
|
191
191
|
.dsn-list{display:flex;flex-direction:column;gap:8px}
|
|
192
|
-
.dsn-row{display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:12px;
|
|
192
|
+
.dsn-row{display:flex;flex-wrap:wrap;align-items:center;gap:12px;padding:12px 14px;border-radius:12px;
|
|
193
193
|
border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);cursor:pointer}
|
|
194
194
|
.dsn-row:hover{border-color:var(--dsw-alias-brand-primary)}
|
|
195
195
|
.dsn-row-main{display:flex;flex-direction:column;gap:4px;min-width:0}
|
|
@@ -480,7 +480,82 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
480
480
|
}, busy ? t('保存中…', "Saving…") : t('保存', "Save")))));
|
|
481
481
|
}
|
|
482
482
|
|
|
483
|
-
|
|
483
|
+
/**
|
|
484
|
+
* The work-wide preface prompt: a narrative angle preset plus anything else
|
|
485
|
+
* the author wants pinned in front of every request. The preset wording comes
|
|
486
|
+
* from the Host so there is one copy of the text.
|
|
487
|
+
*/
|
|
488
|
+
function PreludeDialog({ project, presets, defaultMode, onCancel, onSaved }) {
|
|
489
|
+
const modes = Array.isArray(presets) && presets.length ? presets : [];
|
|
490
|
+
const fallbackMode = defaultMode || 'thirdOmniscient';
|
|
491
|
+
const current = project.prelude || null;
|
|
492
|
+
const initialMode = current && current.mode ? current.mode : fallbackMode;
|
|
493
|
+
const initialPreset = modes.find((entry) => entry.id === initialMode);
|
|
494
|
+
const [mode, setMode] = useState(initialMode);
|
|
495
|
+
const [text, setText] = useState(
|
|
496
|
+
current && current.text ? current.text : (initialPreset && initialPreset.text) || '',
|
|
497
|
+
);
|
|
498
|
+
const [busy, setBusy] = useState(false);
|
|
499
|
+
const [error, setError] = useState('');
|
|
500
|
+
|
|
501
|
+
const pickMode = useCallback((id) => {
|
|
502
|
+
setMode(id);
|
|
503
|
+
const preset = modes.find((entry) => entry.id === id);
|
|
504
|
+
setText(preset && preset.text ? preset.text : '');
|
|
505
|
+
}, [modes]);
|
|
506
|
+
|
|
507
|
+
const save = useCallback(async () => {
|
|
508
|
+
setBusy(true);
|
|
509
|
+
setError('');
|
|
510
|
+
try {
|
|
511
|
+
const result = await api('/project/prelude', { projectId: project.projectId, mode, text });
|
|
512
|
+
await onSaved(result.project);
|
|
513
|
+
} catch (saveError) {
|
|
514
|
+
setError(messageOf(saveError));
|
|
515
|
+
} finally {
|
|
516
|
+
setBusy(false);
|
|
517
|
+
}
|
|
518
|
+
}, [project.projectId, mode, text, onSaved]);
|
|
519
|
+
|
|
520
|
+
return h('div', { className: 'dsn-dialog-backdrop' },
|
|
521
|
+
h('div', { className: 'dsn-dialog', style: { width: 'min(620px, calc(100% - 48px))' } },
|
|
522
|
+
h('div', { className: 'dsn-dialog-head' },
|
|
523
|
+
h('div', { className: 'dsn-title' }, t('前置提示词', 'Preface prompt')),
|
|
524
|
+
h('div', { className: 'dsn-sub' }, project.title),
|
|
525
|
+
h('div', { className: 'dsn-spacer' })),
|
|
526
|
+
h('div', { className: 'dsn-dialog-body' },
|
|
527
|
+
h('label', { className: 'dsn-field span2' },
|
|
528
|
+
h('span', { className: 'dsn-label' }, t('叙事角度(选一个会填入下面的文本)', 'Narrative angle (fills the text below)')),
|
|
529
|
+
h('select', {
|
|
530
|
+
className: 'dsn-select',
|
|
531
|
+
value: mode,
|
|
532
|
+
onChange: (event) => pickMode(event.target.value),
|
|
533
|
+
},
|
|
534
|
+
h('option', { value: '' }, t('不设置', 'Not set')),
|
|
535
|
+
modes.map((entry) => h('option', { key: entry.id, value: entry.id }, t(entry.zh, entry.en))))),
|
|
536
|
+
h('label', { className: 'dsn-field span2' },
|
|
537
|
+
h('span', { className: 'dsn-label' }, t('前置提示(可自由修改,最多 2000 字)', 'Preface text (edit freely, up to 2000 characters)')),
|
|
538
|
+
h('textarea', {
|
|
539
|
+
className: 'dsn-textarea',
|
|
540
|
+
style: { minHeight: '200px' },
|
|
541
|
+
value: text,
|
|
542
|
+
placeholder: t('每次生成正文、自动推演、AI 修改时都会放在提示最前面。', 'Added in front of every generation, deduction and revision request.'),
|
|
543
|
+
onChange: (event) => setText(event.target.value),
|
|
544
|
+
})),
|
|
545
|
+
h('div', { className: 'dsn-hint span2' }, t('已填 {n} / 2000 字;留空即表示这本书不带前置提示。', '{n} / 2000 characters; leave it empty to send no preface.', { n: String(text).length })),
|
|
546
|
+
error ? h('div', { className: 'dsn-hint warn span2' }, error) : null),
|
|
547
|
+
h('div', { className: 'dsn-dialog-foot' },
|
|
548
|
+
h('button', { className: 'dsn-btn ghost', type: 'button', disabled: busy, onClick: onCancel }, t('取消', 'Cancel')),
|
|
549
|
+
h('div', { className: 'dsn-spacer' }),
|
|
550
|
+
h('button', {
|
|
551
|
+
className: 'dsn-btn primary',
|
|
552
|
+
type: 'button',
|
|
553
|
+
disabled: busy || String(text).length > 2000,
|
|
554
|
+
onClick: save,
|
|
555
|
+
}, busy ? t('保存中…', 'Saving…') : t('保存', 'Save')))));
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function ProjectsPage({ boot, resolveWorkspace, onOpen, onReload, onEditModel, onEditPreset, onEditPrelude }) {
|
|
484
559
|
const [title, setTitle] = useState('');
|
|
485
560
|
const [cwd, setCwd] = useState('');
|
|
486
561
|
const [preset, setPreset] = useState('');
|
|
@@ -725,6 +800,15 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
725
800
|
onEditPreset(project);
|
|
726
801
|
},
|
|
727
802
|
}, t('预设', "Preset")),
|
|
803
|
+
h('button', {
|
|
804
|
+
className: 'dsn-btn ghost',
|
|
805
|
+
type: 'button',
|
|
806
|
+
title: t('设置这本书的前置提示词(叙事角度等,每次生成都会带上)', 'Set this work’s preface prompt (narrative angle and more; carried by every request)'),
|
|
807
|
+
onClick: (event) => {
|
|
808
|
+
event.stopPropagation();
|
|
809
|
+
onEditPrelude(project);
|
|
810
|
+
},
|
|
811
|
+
}, t('前置', 'Preface')),
|
|
728
812
|
h('button', {
|
|
729
813
|
className: 'dsn-btn ghost',
|
|
730
814
|
type: 'button',
|
|
@@ -1128,7 +1212,46 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1128
1212
|
* out of the page — back, next chapter, the outline, another project, or
|
|
1129
1213
|
* closing the studio — can ask about unsaved text first.
|
|
1130
1214
|
*/
|
|
1131
|
-
|
|
1215
|
+
/**
|
|
1216
|
+
* Ask the model to rewrite the chapter from a note. The result lands back in
|
|
1217
|
+
* the editor as an unsaved draft, so nothing is overwritten unseen.
|
|
1218
|
+
*/
|
|
1219
|
+
function ReviseDialog({ chapter, text, busy, error, onCancel, onConfirm }) {
|
|
1220
|
+
const [instruction, setInstruction] = useState('');
|
|
1221
|
+
const ready = instruction.trim() !== '' && String(text || '').trim() !== '';
|
|
1222
|
+
|
|
1223
|
+
return h('div', { className: 'dsn-dialog-backdrop' },
|
|
1224
|
+
h('div', { className: 'dsn-dialog', style: { width: 'min(560px, calc(100% - 48px))' } },
|
|
1225
|
+
h('div', { className: 'dsn-dialog-head' },
|
|
1226
|
+
h('div', { className: 'dsn-title' }, t('AI 修改本章', 'Revise this chapter with AI')),
|
|
1227
|
+
h('div', { className: 'dsn-sub' }, chapter ? t('第 {n} 章 · {title}', 'Chapter {n} · {title}', { n: chapter.index + 1, title: chapter.title || t('未命名', 'Untitled') }) : ''),
|
|
1228
|
+
h('div', { className: 'dsn-spacer' })),
|
|
1229
|
+
h('div', { className: 'dsn-dialog-body' },
|
|
1230
|
+
h('label', { className: 'dsn-field span4' },
|
|
1231
|
+
h('span', { className: 'dsn-label' }, t('修改意见(必填)', 'What to change (required)')),
|
|
1232
|
+
h('textarea', {
|
|
1233
|
+
className: 'dsn-textarea',
|
|
1234
|
+
style: { minHeight: '140px' },
|
|
1235
|
+
value: instruction,
|
|
1236
|
+
placeholder: t('例如:把第三章那段打斗写得更克制,减少形容词;结尾补一句主角的心理活动。', 'e.g. Tone down the fight scene, use fewer adjectives, and end on the protagonist’s inner thought.'),
|
|
1237
|
+
onChange: (event) => setInstruction(event.target.value),
|
|
1238
|
+
})),
|
|
1239
|
+
String(text || '').trim() === ''
|
|
1240
|
+
? h('div', { className: 'dsn-hint warn span4' }, t('这一章还没有正文,先写或先生成,再让 AI 修改。', 'This chapter has no text yet — write or generate it before revising.'))
|
|
1241
|
+
: h('div', { className: 'dsn-hint span4' }, t('AI 会按意见重写整章,结果放进编辑区但不自动保存;你确认后再点「保存」。', 'The whole chapter is rewritten from your note. The result goes into the editor unsaved — review it, then press Save.')),
|
|
1242
|
+
error ? h('div', { className: 'dsn-hint warn span4' }, error) : null),
|
|
1243
|
+
h('div', { className: 'dsn-dialog-foot' },
|
|
1244
|
+
h('button', { className: 'dsn-btn ghost', type: 'button', disabled: busy, onClick: onCancel }, t('取消', 'Cancel')),
|
|
1245
|
+
h('div', { className: 'dsn-spacer' }),
|
|
1246
|
+
h('button', {
|
|
1247
|
+
className: 'dsn-btn primary',
|
|
1248
|
+
type: 'button',
|
|
1249
|
+
disabled: busy || !ready,
|
|
1250
|
+
onClick: () => onConfirm(instruction.trim()),
|
|
1251
|
+
}, busy ? t('修改中…', 'Revising…') : t('确认修改', 'Revise')))));
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function StoryPage({ detail, chapter, previous, next, text, saving, onEdit, onSave, onBack, onOutline, onOpenChapter, onNewChapter, onRevise }) {
|
|
1132
1255
|
const words = countWords(text);
|
|
1133
1256
|
const window = chapter ? chapterWindow(detail, chapter) : null;
|
|
1134
1257
|
|
|
@@ -1170,6 +1293,15 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1170
1293
|
h('div', { className: 'dsn-muted' },
|
|
1171
1294
|
t('{words} 字{req}', '{words} words{req}', { words, req: chapter.outline ? t(' · 大纲要求 {min}–{max} 字', ' · outline asks for {min}–{max} words', { min: chapter.outline.minWords, max: chapter.outline.maxWords }) : '' })),
|
|
1172
1295
|
h('div', { className: 'dsn-spacer' }),
|
|
1296
|
+
h('button', {
|
|
1297
|
+
className: 'dsn-btn',
|
|
1298
|
+
type: 'button',
|
|
1299
|
+
disabled: saving || String(text || '').trim() === '',
|
|
1300
|
+
title: String(text || '').trim() === ''
|
|
1301
|
+
? t('这一章还没有正文,先写或先生成', 'No text yet — write or generate the chapter first')
|
|
1302
|
+
: t('把本章正文和修改意见交给 AI 重写', 'Send the chapter and a note to the model for a rewrite'),
|
|
1303
|
+
onClick: onRevise,
|
|
1304
|
+
}, t('AI 修改', 'AI revise')),
|
|
1173
1305
|
h('button', { className: 'dsn-btn primary', type: 'button', disabled: saving, onClick: onSave },
|
|
1174
1306
|
saving ? t('保存中…', "Saving…") : t('保存', "Save")))));
|
|
1175
1307
|
}
|
|
@@ -1685,6 +1817,9 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1685
1817
|
return () => onBusyChange(null);
|
|
1686
1818
|
}, [busy ? busy.label : null, busy ? busy.message : null, onBusyChange]);
|
|
1687
1819
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
1820
|
+
const [reviseOpen, setReviseOpen] = useState(false);
|
|
1821
|
+
const [reviseError, setReviseError] = useState('');
|
|
1822
|
+
const [reviseJob, setReviseJob] = useJob();
|
|
1688
1823
|
const [text, setText] = useState('');
|
|
1689
1824
|
const [dirty, setDirty] = useState(false);
|
|
1690
1825
|
const [saving, setSaving] = useState(false);
|
|
@@ -1732,9 +1867,58 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1732
1867
|
}
|
|
1733
1868
|
}, [chapter, detail.projectId, text, onReload, notify]);
|
|
1734
1869
|
|
|
1870
|
+
/** Send the chapter and a note to the model; the reply becomes a draft. */
|
|
1871
|
+
const startRevise = useCallback(async (instruction) => {
|
|
1872
|
+
if (!chapter) return;
|
|
1873
|
+
if (String(text || '').trim() === '') {
|
|
1874
|
+
setReviseError(t('这一章还没有正文,先写或先生成再让 AI 修改', 'This chapter has no text yet — write or generate it before revising.'));
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
if (String(instruction || '').trim() === '') {
|
|
1878
|
+
setReviseError(t('请先写清修改意见', 'Write what should change first.'));
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1881
|
+
setReviseError('');
|
|
1882
|
+
try {
|
|
1883
|
+
const started = await api('/chapter/revise', {
|
|
1884
|
+
projectId: detail.projectId,
|
|
1885
|
+
chapterId: chapter.chapterId,
|
|
1886
|
+
instruction: String(instruction).trim(),
|
|
1887
|
+
content: text,
|
|
1888
|
+
});
|
|
1889
|
+
setReviseJob(started);
|
|
1890
|
+
} catch (error) {
|
|
1891
|
+
setReviseError(messageOf(error));
|
|
1892
|
+
}
|
|
1893
|
+
}, [chapter, detail.projectId, text, setReviseJob]);
|
|
1894
|
+
|
|
1895
|
+
const revising = Boolean(reviseJob && reviseJob.state === 'running');
|
|
1896
|
+
useEffect(() => {
|
|
1897
|
+
if (!reviseJob) return undefined;
|
|
1898
|
+
if (reviseJob.state === 'running') {
|
|
1899
|
+
setChildBusy({
|
|
1900
|
+
label: t('正在按修改意见重写本章…', 'Revising the chapter…'),
|
|
1901
|
+
message: reviseJob.message || '',
|
|
1902
|
+
});
|
|
1903
|
+
return undefined;
|
|
1904
|
+
}
|
|
1905
|
+
setChildBusy(null);
|
|
1906
|
+
if (reviseJob.state === 'done') {
|
|
1907
|
+
const result = reviseJob.result || {};
|
|
1908
|
+
if (typeof result.content === 'string' && result.content.trim() !== '') {
|
|
1909
|
+
setText(result.content);
|
|
1910
|
+
setDirty(true);
|
|
1911
|
+
}
|
|
1912
|
+
setReviseOpen(false);
|
|
1913
|
+
notify(t('AI 已重写本章,确认后点「保存」', 'AI rewrote the chapter — review it, then press Save'), 'success');
|
|
1914
|
+
} else if (reviseJob.state === 'error') {
|
|
1915
|
+
setReviseError(reviseJob.error || t('AI 修改失败', 'Revision failed'));
|
|
1916
|
+
}
|
|
1917
|
+
return undefined;
|
|
1918
|
+
}, [reviseJob ? reviseJob.jobId : null, reviseJob ? reviseJob.state : null, reviseJob ? reviseJob.message : null]);
|
|
1919
|
+
|
|
1735
1920
|
/** Run a navigation step, asking about the active page's unsaved edits. */
|
|
1736
|
-
const leave = useCallback((action) => {
|
|
1737
|
-
const unsaved = view === 'outline' ? outlineGuard.current.isDirty() : dirty;
|
|
1921
|
+
const leave = useCallback((action) => { const unsaved = view === 'outline' ? outlineGuard.current.isDirty() : dirty;
|
|
1738
1922
|
if (!unsaved) {
|
|
1739
1923
|
action();
|
|
1740
1924
|
return;
|
|
@@ -1920,6 +2104,10 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1920
2104
|
onOutline: () => leave(() => setView('outline')),
|
|
1921
2105
|
onOpenChapter: openStory,
|
|
1922
2106
|
onNewChapter: () => leave(() => setDialogOpen(true)),
|
|
2107
|
+
onRevise: () => {
|
|
2108
|
+
setReviseError('');
|
|
2109
|
+
setReviseOpen(true);
|
|
2110
|
+
},
|
|
1923
2111
|
})),
|
|
1924
2112
|
h('section', { className: 'dsn-page' },
|
|
1925
2113
|
h(OutlinePage, {
|
|
@@ -1949,6 +2137,19 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
1949
2137
|
},
|
|
1950
2138
|
})
|
|
1951
2139
|
: null,
|
|
2140
|
+
reviseOpen
|
|
2141
|
+
? h(ReviseDialog, {
|
|
2142
|
+
chapter,
|
|
2143
|
+
text,
|
|
2144
|
+
busy: revising,
|
|
2145
|
+
error: reviseError,
|
|
2146
|
+
onCancel: () => {
|
|
2147
|
+
if (revising) return;
|
|
2148
|
+
setReviseOpen(false);
|
|
2149
|
+
},
|
|
2150
|
+
onConfirm: startRevise,
|
|
2151
|
+
})
|
|
2152
|
+
: null,
|
|
1952
2153
|
pendingLeave
|
|
1953
2154
|
? h(LeaveConfirm, {
|
|
1954
2155
|
what: pendingLeave.page === 'outline' ? t('这份章节大纲', "This outline") : t('这一章的正文', "this chapter's text"),
|
|
@@ -2001,6 +2202,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
2001
2202
|
const [busyNow, setBusyNow] = useState(null);
|
|
2002
2203
|
const [modelTarget, setModelTarget] = useState(null);
|
|
2003
2204
|
const [presetTarget, setPresetTarget] = useState(null);
|
|
2205
|
+
const [preludeTarget, setPreludeTarget] = useState(null);
|
|
2004
2206
|
const leaveGuard = useRef(null);
|
|
2005
2207
|
const registerLeaveGuard = useCallback((guard) => {
|
|
2006
2208
|
leaveGuard.current = guard;
|
|
@@ -2093,6 +2295,7 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
2093
2295
|
onReload: loadBoot,
|
|
2094
2296
|
onEditModel: setModelTarget,
|
|
2095
2297
|
onEditPreset: setPresetTarget,
|
|
2298
|
+
onEditPrelude: setPreludeTarget,
|
|
2096
2299
|
}))
|
|
2097
2300
|
: detail === null
|
|
2098
2301
|
? h('div', { className: 'dsn-empty', style: { margin: 'auto' } }, t('正在载入作品…', "Loading the work…"))
|
|
@@ -2131,6 +2334,25 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
|
|
|
2131
2334
|
setToast({ text: t('预设已更新,重建会话后生效', 'Preset updated — it applies once the work’s session is rebuilt'), kind: 'success' });
|
|
2132
2335
|
},
|
|
2133
2336
|
})
|
|
2337
|
+
: null,
|
|
2338
|
+
preludeTarget
|
|
2339
|
+
? h(PreludeDialog, {
|
|
2340
|
+
project: preludeTarget,
|
|
2341
|
+
presets: boot ? boot.preludePresets : [],
|
|
2342
|
+
defaultMode: boot ? boot.defaultPreludeMode : 'thirdOmniscient',
|
|
2343
|
+
onCancel: () => setPreludeTarget(null),
|
|
2344
|
+
onSaved: async (saved) => {
|
|
2345
|
+
setPreludeTarget(null);
|
|
2346
|
+
await loadBoot();
|
|
2347
|
+
const empty = !saved || !saved.prelude || !saved.prelude.text;
|
|
2348
|
+
setToast({
|
|
2349
|
+
text: empty
|
|
2350
|
+
? t('已清除前置提示词', 'Preface prompt cleared')
|
|
2351
|
+
: t('前置提示词已保存,下一次生成就生效', 'Preface prompt saved — it applies to the next generation'),
|
|
2352
|
+
kind: 'success',
|
|
2353
|
+
});
|
|
2354
|
+
},
|
|
2355
|
+
})
|
|
2134
2356
|
: null);
|
|
2135
2357
|
}
|
|
2136
2358
|
|
package/index.js
CHANGED
|
@@ -356,6 +356,8 @@ async function exportProjectInfo(project) {
|
|
|
356
356
|
lines.push(`- 绑定会话:${project.sessionId || '(无)'}`);
|
|
357
357
|
lines.push(`- 模型:${project.model ? `${project.model.provider} / ${project.model.model}` : '(默认)'}`);
|
|
358
358
|
lines.push(`- 预设:${project.agentPreset || '(默认)'}`);
|
|
359
|
+
const preludeText = project.prelude && project.prelude.text ? String(project.prelude.text).trim() : '';
|
|
360
|
+
lines.push(`- 前置提示词:${preludeText === '' ? '(未设置)' : '已设置,见文末'}`);
|
|
359
361
|
lines.push(`- 快照间隔:每 ${timeline.unitDays || 30} 天一份`);
|
|
360
362
|
lines.push(`- 已写入快照:${snapshots.length ? snapshots.map((snapshot) => `第 ${snapshot.day} 天`).join('、') : '(暂无)'}`);
|
|
361
363
|
lines.push(`- 故事当前:第 ${lastChapterEnd(project)} 天`);
|
|
@@ -382,6 +384,16 @@ async function exportProjectInfo(project) {
|
|
|
382
384
|
lines.push('');
|
|
383
385
|
lines.push('> 章节正文在同目录的「第NN章 标题.md」,各章梗概在「章节大纲」目录,世界快照在「世界快照」目录。');
|
|
384
386
|
lines.push('');
|
|
387
|
+
if (preludeText !== '') {
|
|
388
|
+
lines.push('## 前置提示词');
|
|
389
|
+
lines.push('');
|
|
390
|
+
lines.push('每次生成正文、自动推演、AI 修改时都会放在提示最前面:');
|
|
391
|
+
lines.push('');
|
|
392
|
+
lines.push('```text');
|
|
393
|
+
lines.push(preludeText);
|
|
394
|
+
lines.push('```');
|
|
395
|
+
lines.push('');
|
|
396
|
+
}
|
|
385
397
|
await writeFile(projectInfoFile(project), lines.join('\n'), 'utf8');
|
|
386
398
|
} catch {
|
|
387
399
|
// Best effort.
|
|
@@ -660,6 +672,9 @@ function summarize(project) {
|
|
|
660
672
|
sessionId: project.sessionId,
|
|
661
673
|
agentPreset: project.agentPreset,
|
|
662
674
|
model: project.model,
|
|
675
|
+
// The work-wide preface prompt: narrative angle plus anything else the
|
|
676
|
+
// author wants every request to carry.
|
|
677
|
+
prelude: project.prelude || null,
|
|
663
678
|
createdAt: project.createdAt,
|
|
664
679
|
updatedAt: project.updatedAt,
|
|
665
680
|
chapterCount: chapters.length,
|
|
@@ -1205,6 +1220,8 @@ function chapterPrompt(project, chapter, previous) {
|
|
|
1205
1220
|
const { snapshot } = snapshotDistance(project, chapter);
|
|
1206
1221
|
const lines = [];
|
|
1207
1222
|
lines.push('你现在是这部小说的执笔作者。只输出本章正文,不要解释、不要标题、不要分点提纲。');
|
|
1223
|
+
const prelude = preludeBlock(project);
|
|
1224
|
+
if (prelude !== '') lines.push('', prelude);
|
|
1208
1225
|
lines.push(`【篇幅】本章正文必须控制在 ${minWords}~${maxWords} 字之间,写够 ${maxWords} 字附近就收尾,超过 ${maxWords} 字视为不合格。`);
|
|
1209
1226
|
lines.push('');
|
|
1210
1227
|
const shownTitle = chapter.title && !/^第\s*\d+\s*章$/u.test(chapter.title) ? ` ${chapter.title}` : '';
|
|
@@ -1242,6 +1259,8 @@ function deducePrompt(project, chapter) {
|
|
|
1242
1259
|
lines.push('你是这部小说的剧情推演器。请依据最近的世界快照,以及从该快照之后已经写过的各章梗概,推演下一章的大纲。');
|
|
1243
1260
|
lines.push('只输出一个 JSON 对象,不要任何额外文字。');
|
|
1244
1261
|
lines.push('');
|
|
1262
|
+
const prelude = preludeBlock(project);
|
|
1263
|
+
if (prelude !== '') lines.push(prelude);
|
|
1245
1264
|
lines.push('【最近的世界快照】');
|
|
1246
1265
|
lines.push(snapshotBlock(snapshot));
|
|
1247
1266
|
lines.push('');
|
|
@@ -1255,6 +1274,36 @@ function deducePrompt(project, chapter) {
|
|
|
1255
1274
|
return lines.join('\n');
|
|
1256
1275
|
}
|
|
1257
1276
|
|
|
1277
|
+
/**
|
|
1278
|
+
* Ask the model to rewrite one chapter's text according to a note.
|
|
1279
|
+
*
|
|
1280
|
+
* The current text is given in full (the Client sends the version on screen, so
|
|
1281
|
+
* unsaved edits are included), and the reply is the whole revised chapter — not a
|
|
1282
|
+
* patch and not commentary.
|
|
1283
|
+
*/
|
|
1284
|
+
function revisePrompt(project, chapter, currentText, instruction) {
|
|
1285
|
+
const span = chapterWindow(project, chapter);
|
|
1286
|
+
const lines = [];
|
|
1287
|
+
lines.push('你是这部小说的修订者。下面是本章现有正文,以及作者给出的修改意见。');
|
|
1288
|
+
lines.push('请按修改意见重写这一章,输出修订后的**完整正文**:不要解释、不要列出改动点、不要加标题行。');
|
|
1289
|
+
lines.push('');
|
|
1290
|
+
const prelude = preludeBlock(project);
|
|
1291
|
+
if (prelude !== '') lines.push(prelude);
|
|
1292
|
+
lines.push(`【本章】第 ${chapter.index + 1} 章 ${chapter.title || ''}(第 ${span.start}→${span.end} 天)`);
|
|
1293
|
+
lines.push('');
|
|
1294
|
+
lines.push('【现有正文】');
|
|
1295
|
+
lines.push(currentText);
|
|
1296
|
+
lines.push('');
|
|
1297
|
+
lines.push('【修改意见】');
|
|
1298
|
+
lines.push(instruction);
|
|
1299
|
+
lines.push('');
|
|
1300
|
+
lines.push('【要求】');
|
|
1301
|
+
lines.push('- 只改需要按意见改的地方,保持原有的叙事人称、语气和已经成立的情节;没有被意见推翻的段落尽量保留。');
|
|
1302
|
+
lines.push('- 输出必须是完整的一章,从开头到结尾,不能只给片段或省略号。');
|
|
1303
|
+
lines.push('- 不要调用任何工具,不要反问,不要输出计划或步骤说明。');
|
|
1304
|
+
return lines.join('\n');
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1258
1307
|
function snapshotPrompt(project, previous, chapters, pointDay) {
|
|
1259
1308
|
const lines = [];
|
|
1260
1309
|
lines.push(`你是这部小说的世界观维护者。当前要产出第 ${pointDay} 天的世界快照。`);
|
|
@@ -1428,7 +1477,7 @@ async function bootstrap(ctx, root) {
|
|
|
1428
1477
|
}
|
|
1429
1478
|
}
|
|
1430
1479
|
return {
|
|
1431
|
-
revision: '2026-10-27.
|
|
1480
|
+
revision: '2026-10-27.10',
|
|
1432
1481
|
projects,
|
|
1433
1482
|
models: {
|
|
1434
1483
|
default: modelCatalog.default || null,
|
|
@@ -1445,6 +1494,9 @@ async function bootstrap(ctx, root) {
|
|
|
1445
1494
|
failures: modelCatalog.failures || [],
|
|
1446
1495
|
},
|
|
1447
1496
|
presets: presetList,
|
|
1497
|
+
// Canonical preface wording, so the Client does not keep a second copy.
|
|
1498
|
+
preludePresets: PRELUDE_PRESETS,
|
|
1499
|
+
defaultPreludeMode: DEFAULT_PRELUDE_MODE,
|
|
1448
1500
|
};
|
|
1449
1501
|
}
|
|
1450
1502
|
|
|
@@ -1496,6 +1548,9 @@ async function createProject(ctx, root, body) {
|
|
|
1496
1548
|
cwd,
|
|
1497
1549
|
agentPreset: agentPreset || created.agentPreset || null,
|
|
1498
1550
|
model,
|
|
1551
|
+
// Every new work starts with the default narrative preface; the author can
|
|
1552
|
+
// edit or clear it from the work list.
|
|
1553
|
+
prelude: defaultPrelude(),
|
|
1499
1554
|
sessionId,
|
|
1500
1555
|
createdAt: Date.now(),
|
|
1501
1556
|
updatedAt: Date.now(),
|
|
@@ -1813,6 +1868,92 @@ async function createSessionFor(ctx, root, project) {
|
|
|
1813
1868
|
}
|
|
1814
1869
|
}
|
|
1815
1870
|
|
|
1871
|
+
/** Longest preface prompt accepted, in characters. */
|
|
1872
|
+
const PRELUDE_MAX_CHARS = 2000;
|
|
1873
|
+
|
|
1874
|
+
/**
|
|
1875
|
+
* Built-in preface prompts. The Host owns the canonical wording, so the Client,
|
|
1876
|
+
* the creation default and any migration all read the same text.
|
|
1877
|
+
*/
|
|
1878
|
+
const PRELUDE_PRESETS = [
|
|
1879
|
+
{
|
|
1880
|
+
id: 'thirdOmniscient',
|
|
1881
|
+
zh: '第三人称全知',
|
|
1882
|
+
en: 'Third person, omniscient',
|
|
1883
|
+
text: [
|
|
1884
|
+
'写作要求:传统修仙文第三人称叙事,参考忘语《凡人修仙传》风格,禁止贴身 POV 视角。',
|
|
1885
|
+
'',
|
|
1886
|
+
'1. 叙事优先用环境、场景作为句子开头,不要每一句都从主角的感受出发;',
|
|
1887
|
+
'2. 控制“他”字的出现频率,不要大量以“他”开头造句;',
|
|
1888
|
+
'3. 描写动作、环境、事件为主,不要细碎拆解主角每一丝触觉、听觉;',
|
|
1889
|
+
'4. 可以写主角的反应,但不要连续堆砌“他看见、他听见、他感到”;',
|
|
1890
|
+
'5. 句子连贯,不要大量一句一段的超短碎句;',
|
|
1891
|
+
'6. 以作者旁白来铺陈场景,把感官描写融入环境,不要单独拎出来写角色内心感受。',
|
|
1892
|
+
].join('\n'),
|
|
1893
|
+
},
|
|
1894
|
+
{
|
|
1895
|
+
id: 'thirdLimited',
|
|
1896
|
+
zh: '第三人称限知',
|
|
1897
|
+
en: 'Third person, limited',
|
|
1898
|
+
text: '全书使用第三人称限知视角:叙述跟随主角,只呈现他能感知到的一切,不越界写其他角色的内心。',
|
|
1899
|
+
},
|
|
1900
|
+
{
|
|
1901
|
+
id: 'first',
|
|
1902
|
+
zh: '第一人称',
|
|
1903
|
+
en: 'First person',
|
|
1904
|
+
text: '全书使用第一人称叙述:只写「我」亲眼所见、亲耳所听、亲身所想,不描写别人的内心活动。',
|
|
1905
|
+
},
|
|
1906
|
+
{
|
|
1907
|
+
id: 'custom',
|
|
1908
|
+
zh: '自定义',
|
|
1909
|
+
en: 'Custom',
|
|
1910
|
+
text: '',
|
|
1911
|
+
},
|
|
1912
|
+
];
|
|
1913
|
+
|
|
1914
|
+
/** The preface a fresh work starts with. */
|
|
1915
|
+
const DEFAULT_PRELUDE_MODE = 'thirdOmniscient';
|
|
1916
|
+
|
|
1917
|
+
function defaultPrelude() {
|
|
1918
|
+
const preset = PRELUDE_PRESETS.find((entry) => entry.id === DEFAULT_PRELUDE_MODE);
|
|
1919
|
+
return preset ? { mode: preset.id, text: preset.text, updatedAt: Date.now() } : null;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
/**
|
|
1923
|
+
* Save one work's preface prompt. `mode` only labels which preset the text came
|
|
1924
|
+
* from; the text itself is what every request carries, so the author can edit a
|
|
1925
|
+
* preset's wording or write their own.
|
|
1926
|
+
*/
|
|
1927
|
+
async function updatePrelude(root, body) {
|
|
1928
|
+
const projectId = asText(body.projectId);
|
|
1929
|
+
const mode = asText(body.mode);
|
|
1930
|
+
const text = typeof body.text === 'string' ? body.text : '';
|
|
1931
|
+
if (text.length > PRELUDE_MAX_CHARS) {
|
|
1932
|
+
throw httpError(400, `前置提示词最多 ${PRELUDE_MAX_CHARS} 字`);
|
|
1933
|
+
}
|
|
1934
|
+
const trimmed = text.trim();
|
|
1935
|
+
const project = await mutateProject(root, projectId, (draft) => {
|
|
1936
|
+
draft.prelude = trimmed === '' && mode === ''
|
|
1937
|
+
? null
|
|
1938
|
+
: { mode, text: trimmed, updatedAt: Date.now() };
|
|
1939
|
+
draft.updatedAt = Date.now();
|
|
1940
|
+
return summarize(draft);
|
|
1941
|
+
});
|
|
1942
|
+
await exportProjectInfo(await readProject(root, projectId));
|
|
1943
|
+
return { project };
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
/** The preface block every writing request starts with, or '' when unset. */
|
|
1947
|
+
function preludeBlock(project) {
|
|
1948
|
+
const text = project && project.prelude && project.prelude.text ? String(project.prelude.text).trim() : '';
|
|
1949
|
+
if (text === '') return '';
|
|
1950
|
+
return [
|
|
1951
|
+
'【本书前置设定】(作者为全书设定的写作约束,优先于下文提示,但不得违反本章梗概与已发生的事实)',
|
|
1952
|
+
text,
|
|
1953
|
+
'',
|
|
1954
|
+
].join('\n');
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1816
1957
|
/**
|
|
1817
1958
|
* Record the preset a work's session should run under.
|
|
1818
1959
|
*
|
|
@@ -2219,6 +2360,46 @@ async function startSnapshotReset(ctx, root, registry, body) {
|
|
|
2219
2360
|
});
|
|
2220
2361
|
}
|
|
2221
2362
|
|
|
2363
|
+
/**
|
|
2364
|
+
* Revise a chapter's text from a note, without saving it.
|
|
2365
|
+
*
|
|
2366
|
+
* The Client keeps the result in the editor as an unsaved draft, so a rewrite
|
|
2367
|
+
* never replaces text the author has not looked at. Both inputs are required:
|
|
2368
|
+
* there is nothing to revise without text, and nothing to do without a note.
|
|
2369
|
+
*/
|
|
2370
|
+
async function startRevise(ctx, root, registry, body) {
|
|
2371
|
+
const projectId = asText(body.projectId);
|
|
2372
|
+
const chapterId = asText(body.chapterId);
|
|
2373
|
+
const instruction = asText(body.instruction);
|
|
2374
|
+
const draft = typeof body.content === 'string' ? body.content : '';
|
|
2375
|
+
if (draft.trim() === '') throw httpError(400, '这一章还没有正文,先写或先生成再让 AI 修改');
|
|
2376
|
+
if (instruction === '') throw httpError(400, '请先写清修改意见');
|
|
2377
|
+
const preview = await readProject(root, projectId);
|
|
2378
|
+
const claimed = preview.chapters.find((entry) => entry.chapterId === chapterId);
|
|
2379
|
+
if (!claimed) throw httpError(404, `章节不存在: ${chapterId}`);
|
|
2380
|
+
return startJob(registry, { kind: 'revise', projectId, chapterId }, async (job) => {
|
|
2381
|
+
const project = await readProject(root, projectId);
|
|
2382
|
+
const chapter = project.chapters.find((entry) => entry.chapterId === chapterId);
|
|
2383
|
+
if (!chapter) throw httpError(404, `章节不存在: ${chapterId}`);
|
|
2384
|
+
job.message = '正在按修改意见重写本章';
|
|
2385
|
+
const raw = await askStory(ctx, root, project, revisePrompt(project, chapter, draft, instruction), (note) => {
|
|
2386
|
+
job.message = note;
|
|
2387
|
+
});
|
|
2388
|
+
// Keep the author's own length: never cut below what they already had.
|
|
2389
|
+
const limit = Math.max(
|
|
2390
|
+
(chapter.outline && chapter.outline.maxWords) || 3000,
|
|
2391
|
+
Math.round(wordCount(draft) * 1.3),
|
|
2392
|
+
);
|
|
2393
|
+
const fitted = fitChapterText(raw, limit);
|
|
2394
|
+
job.message = fitted.trimmed ? '模型写超了字数,已按上限截断' : '完成';
|
|
2395
|
+
return {
|
|
2396
|
+
content: fitted.text,
|
|
2397
|
+
wordCount: wordCount(fitted.text),
|
|
2398
|
+
trimmed: fitted.trimmed,
|
|
2399
|
+
};
|
|
2400
|
+
});
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2222
2403
|
async function startGenerate(ctx, root, registry, body) {
|
|
2223
2404
|
const projectId = asText(body.projectId);
|
|
2224
2405
|
const chapterId = asText(body.chapterId);
|
|
@@ -2516,6 +2697,8 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
|
|
|
2516
2697
|
return updateModel(ctx, root, body);
|
|
2517
2698
|
case '/project/preset':
|
|
2518
2699
|
return updatePreset(ctx, root, body);
|
|
2700
|
+
case '/project/prelude':
|
|
2701
|
+
return updatePrelude(root, body);
|
|
2519
2702
|
case '/project/rebind':
|
|
2520
2703
|
return rebindProject(ctx, root, body);
|
|
2521
2704
|
case '/project/move':
|
|
@@ -2528,6 +2711,8 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
|
|
|
2528
2711
|
return saveChapter(root, body);
|
|
2529
2712
|
case '/generate':
|
|
2530
2713
|
return startGenerate(ctx, root, registry, body);
|
|
2714
|
+
case '/chapter/revise':
|
|
2715
|
+
return startRevise(ctx, root, registry, body);
|
|
2531
2716
|
case '/compose':
|
|
2532
2717
|
return startCompose(ctx, root, registry, body);
|
|
2533
2718
|
case '/deduce':
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-novel-generator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.26",
|
|
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",
|