dsh-novel-generator 0.1.25 → 0.1.27

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.
Files changed (4) hide show
  1. package/README.md +12 -1
  2. package/client.js +19 -19
  3. package/index.js +85 -4
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -66,7 +66,9 @@ dsh plugin --profile web add dsh-novel-generator
66
66
 
67
67
  ## 前置提示词(作品级)
68
68
 
69
- 作品列表每行的「前置」可以给一本书设定前置提示:内置「第一人称 / 第三人称限知 / 第三人称全知 / 自定义」四种,选中即填入文本框,文本可自由改写(最多 2000 字)。
69
+ 作品列表每行的「前置」可以给一本书设定前置提示:内置「第三人称全知(默认)/ 第三人称限知 / 第一人称 / 自定义」四种,选中即填入文本框,文本可自由改写(最多 2000 字)。
70
+
71
+ **新建作品默认就带「第三人称全知」那一段**(传统修仙文、禁贴身 POV、环境优先等 6 条),不喜欢可以直接改或清空。
70
72
 
71
73
  - 每次**生成正文、自动推演、AI 修改**时,这段文本都会以「【本书前置设定】…」的形式放在提示最前面;生成世界快照时不带(那是结构化数据任务)。
72
74
  - 保存后**下一次生成就生效**,不需要重建会话——它不依赖会话状态。
@@ -79,6 +81,15 @@ dsh plugin --profile web add dsh-novel-generator
79
81
  - 正文为空时按钮不可点,接口也会拒绝(400);修改意见为空同样拒绝。
80
82
  - **重写结果只放进编辑区、不自动保存**,状态变成「未保存」,你确认后再点「保存」——避免 AI 的改动直接覆盖原稿。
81
83
 
84
+ ## 附属子插件:小说工坊 · 作者预设
85
+
86
+ 另有一个**独立包** `dsh-novel-author-preset`,只负责往 DSH 注册一个 Agent 预设「小说工坊 · 作者」:
87
+
88
+ - 装上它(`dsh plugin --profile web add dsh-novel-author-preset`),作品列表的「预设」与「设置 → 预设」里就多出这一项,**新建作品默认用它**;
89
+ - 卸掉它,预设立刻消失;已绑定的作品不会因此损坏——重建会话时检测到预设已不在,会自动退回 profile 的默认预设。
90
+
91
+ 预设内容刻意做得很薄:三句人设(你是小说写作者/只输出正文/文风视角篇幅以用户要求为准)+ 关闭运行时上下文 + **不挂任何工具** + 只保留上下文压缩。所有文学倾向都交给作品级「前置提示词」。
92
+
82
93
  ## 迁移、导入与会话
83
94
 
84
95
  - **改预设**:作品列表每行的「预设」改的是「下次建会话时用哪个预设」。会话在创建时就固定了预设,所以保存后会提示「重建会话后生效」——要立刻换,点同一行的「重建会话」。
package/client.js CHANGED
@@ -480,31 +480,29 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
480
480
  }, busy ? t('保存中…', "Saving…") : t('保存', "Save")))));
481
481
  }
482
482
 
483
- /** Built-in preface prompts; the text is editable after picking one. */
484
- const PRELUDE_MODES = [
485
- { id: 'first', zh: '第一人称', en: 'First person', text: '全书使用第一人称叙述:只写「我」亲眼所见、亲耳所听、亲身所想,不描写别人的内心活动。' },
486
- { id: 'thirdLimited', zh: '第三人称限知', en: 'Third person, limited', text: '全书使用第三人称限知视角:叙述跟随主角,只呈现他能感知到的一切,不越界写其他角色的内心。' },
487
- { id: 'thirdOmniscient', zh: '第三人称全知', en: 'Third person, omniscient', text: '全书使用第三人称全知视角:叙述者可以进入任何角色的内心,但每次切换视角都要有场景依据,不要在同一场景里频繁跳视角。' },
488
- { id: 'custom', zh: '自定义', en: 'Custom', text: '' },
489
- ];
490
-
491
483
  /**
492
484
  * The work-wide preface prompt: a narrative angle preset plus anything else
493
- * the author wants pinned in front of every request.
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.
494
487
  */
495
- function PreludeDialog({ project, onCancel, onSaved }) {
488
+ function PreludeDialog({ project, presets, defaultMode, onCancel, onSaved }) {
489
+ const modes = Array.isArray(presets) && presets.length ? presets : [];
490
+ const fallbackMode = defaultMode || 'thirdOmniscient';
496
491
  const current = project.prelude || null;
497
- const [mode, setMode] = useState(current && current.mode ? current.mode : '');
498
- const [text, setText] = useState(current && current.text ? current.text : '');
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
+ );
499
498
  const [busy, setBusy] = useState(false);
500
499
  const [error, setError] = useState('');
501
500
 
502
501
  const pickMode = useCallback((id) => {
503
502
  setMode(id);
504
- const preset = PRELUDE_MODES.find((entry) => entry.id === id);
505
- if (preset && preset.text !== '') setText(preset.text);
506
- if (id === '') setText('');
507
- }, []);
503
+ const preset = modes.find((entry) => entry.id === id);
504
+ setText(preset && preset.text ? preset.text : '');
505
+ }, [modes]);
508
506
 
509
507
  const save = useCallback(async () => {
510
508
  setBusy(true);
@@ -534,14 +532,14 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
534
532
  onChange: (event) => pickMode(event.target.value),
535
533
  },
536
534
  h('option', { value: '' }, t('不设置', 'Not set')),
537
- PRELUDE_MODES.map((entry) => h('option', { key: entry.id, value: entry.id }, t(entry.zh, entry.en))))),
535
+ modes.map((entry) => h('option', { key: entry.id, value: entry.id }, t(entry.zh, entry.en))))),
538
536
  h('label', { className: 'dsn-field span2' },
539
537
  h('span', { className: 'dsn-label' }, t('前置提示(可自由修改,最多 2000 字)', 'Preface text (edit freely, up to 2000 characters)')),
540
538
  h('textarea', {
541
539
  className: 'dsn-textarea',
542
- style: { minHeight: '160px' },
540
+ style: { minHeight: '200px' },
543
541
  value: text,
544
- placeholder: t('每次生成正文、自动推演、AI 修改时都会放在提示最前面。例如:全书用第三人称限知;文风冷峻,少用形容词;不要出现现代网络用语。', 'Added in front of every generation, deduction and revision request. e.g. third-person limited; cold, spare prose; no modern slang.'),
542
+ placeholder: t('每次生成正文、自动推演、AI 修改时都会放在提示最前面。', 'Added in front of every generation, deduction and revision request.'),
545
543
  onChange: (event) => setText(event.target.value),
546
544
  })),
547
545
  h('div', { className: 'dsn-hint span2' }, t('已填 {n} / 2000 字;留空即表示这本书不带前置提示。', '{n} / 2000 characters; leave it empty to send no preface.', { n: String(text).length })),
@@ -2340,6 +2338,8 @@ body[data-ds-dark-theme] .dsn-shell{color-scheme:dark}
2340
2338
  preludeTarget
2341
2339
  ? h(PreludeDialog, {
2342
2340
  project: preludeTarget,
2341
+ presets: boot ? boot.preludePresets : [],
2342
+ defaultMode: boot ? boot.defaultPreludeMode : 'thirdOmniscient',
2343
2343
  onCancel: () => setPreludeTarget(null),
2344
2344
  onSaved: async (saved) => {
2345
2345
  setPreludeTarget(null);
package/index.js CHANGED
@@ -922,6 +922,20 @@ function normalizeSnapshot(raw) {
922
922
 
923
923
  /* ------------------------------------------------------------ agent work */
924
924
 
925
+ /** The preset a work records, but only when the deployment still offers it. */
926
+ async function usablePreset(ctx, presetId) {
927
+ if (!presetId) return false;
928
+ const presets = ctx.get('agentPresets');
929
+ if (!presets || typeof presets.list !== 'function') return true;
930
+ try {
931
+ const list = await presets.list();
932
+ if (!Array.isArray(list) || list.length === 0) return true;
933
+ return list.some((entry) => entry.id === presetId);
934
+ } catch {
935
+ return true;
936
+ }
937
+ }
938
+
925
939
  /**
926
940
  * Bind a fresh session to a work whose old one disappeared (deleted in the
927
941
  * sidebar, or gone from the session store). The work's memory — snapshots,
@@ -931,7 +945,12 @@ function normalizeSnapshot(raw) {
931
945
  async function rebindSession(ctx, root, project) {
932
946
  const controller = ctx.get('sessionController');
933
947
  if (controller === undefined) throw httpError(503, '当前部署缺少 sessionController 服务,无法为作品重建会话');
934
- const created = await controller.create(project.agentPreset ? { cwd: project.cwd, agentPreset: project.agentPreset } : { cwd: project.cwd });
948
+ // A preset can disappear when its plugin is uninstalled; falling back to the
949
+ // profile default keeps the work usable instead of failing every rebuild.
950
+ const presetOk = await usablePreset(ctx, project.agentPreset);
951
+ const created = await controller.create(presetOk && project.agentPreset
952
+ ? { cwd: project.cwd, agentPreset: project.agentPreset }
953
+ : { cwd: project.cwd });
935
954
  const sessionId = created && created.sessionId ? created.sessionId : null;
936
955
  if (!sessionId) throw httpError(502, '没能为作品重建会话');
937
956
  if (project.model) {
@@ -1477,7 +1496,7 @@ async function bootstrap(ctx, root) {
1477
1496
  }
1478
1497
  }
1479
1498
  return {
1480
- revision: '2026-10-27.9',
1499
+ revision: '2026-10-27.11',
1481
1500
  projects,
1482
1501
  models: {
1483
1502
  default: modelCatalog.default || null,
@@ -1494,6 +1513,9 @@ async function bootstrap(ctx, root) {
1494
1513
  failures: modelCatalog.failures || [],
1495
1514
  },
1496
1515
  presets: presetList,
1516
+ // Canonical preface wording, so the Client does not keep a second copy.
1517
+ preludePresets: PRELUDE_PRESETS,
1518
+ defaultPreludeMode: DEFAULT_PRELUDE_MODE,
1497
1519
  };
1498
1520
  }
1499
1521
 
@@ -1508,6 +1530,11 @@ async function createProject(ctx, root, body) {
1508
1530
  const cwd = asText(body.cwd);
1509
1531
  if (cwd === '') throw httpError(400, '请先选择作品的工作目录');
1510
1532
  const agentPreset = asText(body.agentPreset) || undefined;
1533
+ // New works prefer the bundled author preset when its plugin is installed, and
1534
+ // otherwise fall back to the profile default instead of failing.
1535
+ const chosenPreset = agentPreset !== undefined && await usablePreset(ctx, agentPreset)
1536
+ ? agentPreset
1537
+ : (await usablePreset(ctx, AUTHOR_PRESET_ID) ? AUTHOR_PRESET_ID : undefined);
1511
1538
  const model = body.model && asText(body.model.provider) && asText(body.model.model)
1512
1539
  ? {
1513
1540
  provider: asText(body.model.provider),
@@ -1526,7 +1553,7 @@ async function createProject(ctx, root, body) {
1526
1553
  throw httpError(400, `无法在工作目录下创建作品文件夹:${error instanceof Error ? error.message : String(error)}`);
1527
1554
  }
1528
1555
 
1529
- const created = await controller.create(agentPreset === undefined ? { cwd } : { cwd, agentPreset });
1556
+ const created = await controller.create(chosenPreset === undefined ? { cwd } : { cwd, agentPreset: chosenPreset });
1530
1557
  const sessionId = created && created.sessionId ? created.sessionId : null;
1531
1558
  let warning = null;
1532
1559
  if (sessionId && model) {
@@ -1543,8 +1570,11 @@ async function createProject(ctx, root, body) {
1543
1570
  projectId,
1544
1571
  title,
1545
1572
  cwd,
1546
- agentPreset: agentPreset || created.agentPreset || null,
1573
+ agentPreset: chosenPreset || created.agentPreset || null,
1547
1574
  model,
1575
+ // Every new work starts with the default narrative preface; the author can
1576
+ // edit or clear it from the work list.
1577
+ prelude: defaultPrelude(),
1548
1578
  sessionId,
1549
1579
  createdAt: Date.now(),
1550
1580
  updatedAt: Date.now(),
@@ -1865,6 +1895,57 @@ async function createSessionFor(ctx, root, project) {
1865
1895
  /** Longest preface prompt accepted, in characters. */
1866
1896
  const PRELUDE_MAX_CHARS = 2000;
1867
1897
 
1898
+ /**
1899
+ * Built-in preface prompts. The Host owns the canonical wording, so the Client,
1900
+ * the creation default and any migration all read the same text.
1901
+ */
1902
+ const PRELUDE_PRESETS = [
1903
+ {
1904
+ id: 'thirdOmniscient',
1905
+ zh: '第三人称全知',
1906
+ en: 'Third person, omniscient',
1907
+ text: [
1908
+ '写作要求:传统修仙文第三人称叙事,参考忘语《凡人修仙传》风格,禁止贴身 POV 视角。',
1909
+ '',
1910
+ '1. 叙事优先用环境、场景作为句子开头,不要每一句都从主角的感受出发;',
1911
+ '2. 控制“他”字的出现频率,不要大量以“他”开头造句;',
1912
+ '3. 描写动作、环境、事件为主,不要细碎拆解主角每一丝触觉、听觉;',
1913
+ '4. 可以写主角的反应,但不要连续堆砌“他看见、他听见、他感到”;',
1914
+ '5. 句子连贯,不要大量一句一段的超短碎句;',
1915
+ '6. 以作者旁白来铺陈场景,把感官描写融入环境,不要单独拎出来写角色内心感受。',
1916
+ ].join('\n'),
1917
+ },
1918
+ {
1919
+ id: 'thirdLimited',
1920
+ zh: '第三人称限知',
1921
+ en: 'Third person, limited',
1922
+ text: '全书使用第三人称限知视角:叙述跟随主角,只呈现他能感知到的一切,不越界写其他角色的内心。',
1923
+ },
1924
+ {
1925
+ id: 'first',
1926
+ zh: '第一人称',
1927
+ en: 'First person',
1928
+ text: '全书使用第一人称叙述:只写「我」亲眼所见、亲耳所听、亲身所想,不描写别人的内心活动。',
1929
+ },
1930
+ {
1931
+ id: 'custom',
1932
+ zh: '自定义',
1933
+ en: 'Custom',
1934
+ text: '',
1935
+ },
1936
+ ];
1937
+
1938
+ /** The preface a fresh work starts with. */
1939
+ const DEFAULT_PRELUDE_MODE = 'thirdOmniscient';
1940
+
1941
+ /** Preset id contributed by the optional `dsh-novel-author-preset` sub-plugin. */
1942
+ const AUTHOR_PRESET_ID = 'novel-studio-author';
1943
+
1944
+ function defaultPrelude() {
1945
+ const preset = PRELUDE_PRESETS.find((entry) => entry.id === DEFAULT_PRELUDE_MODE);
1946
+ return preset ? { mode: preset.id, text: preset.text, updatedAt: Date.now() } : null;
1947
+ }
1948
+
1868
1949
  /**
1869
1950
  * Save one work's preface prompt. `mode` only labels which preset the text came
1870
1951
  * from; the text itself is what every request carries, so the author can edit a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-novel-generator",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
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",