@waterwx/dsh-novel-forge 1.2.1-alpha → 1.3.0-alpha

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 (52) hide show
  1. package/README.md +4 -5
  2. package/lib/client.js +2470 -858
  3. package/lib/index.js +2155 -388
  4. package/lib/types/action-guard.d.ts +1 -1
  5. package/lib/types/assets.d.ts +7 -7
  6. package/lib/types/client/api.d.ts +31 -1
  7. package/lib/types/client/llmLive.d.ts +12 -14
  8. package/lib/types/client/panel/LiveFeedLog.d.ts +5 -0
  9. package/lib/types/client/panel/ModelManager.d.ts +2 -2
  10. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  11. package/lib/types/client/panel/TensionView.d.ts +14 -0
  12. package/lib/types/client/panel/TimelineView.d.ts +14 -0
  13. package/lib/types/engine.d.ts +40 -2
  14. package/lib/types/index.d.ts +4 -0
  15. package/lib/types/llm-live.d.ts +65 -34
  16. package/lib/types/llm-retry.d.ts +38 -0
  17. package/lib/types/market-radar-sources.d.ts +1 -1
  18. package/lib/types/prompt-slots.d.ts +57 -0
  19. package/lib/types/prompting.d.ts +2 -2
  20. package/lib/types/protocol.d.ts +283 -3
  21. package/lib/types/snapshots.d.ts +46 -0
  22. package/lib/types/stage-contract.d.ts +1 -1
  23. package/lib/types/tension.d.ts +65 -0
  24. package/lib/types/timeline.d.ts +37 -0
  25. package/package.json +3 -2
  26. package/src/action-guard.ts +1 -1
  27. package/src/assets.ts +300 -268
  28. package/src/assistant.ts +45 -29
  29. package/src/client/api.ts +76 -2
  30. package/src/client/llmLive.ts +26 -16
  31. package/src/client/panel/AssetsTab.tsx +2 -2
  32. package/src/client/panel/LiveFeedLog.tsx +204 -67
  33. package/src/client/panel/ModelManager.tsx +2 -2
  34. package/src/client/panel/NovelPanel.tsx +127 -13
  35. package/src/client/panel/PromptSlotsView.tsx +97 -0
  36. package/src/client/panel/SettingsView.tsx +35 -0
  37. package/src/client/panel/TensionView.tsx +196 -0
  38. package/src/client/panel/TimelineView.tsx +192 -0
  39. package/src/engine.ts +549 -108
  40. package/src/index.ts +12 -0
  41. package/src/llm-live.ts +260 -68
  42. package/src/llm-retry.ts +77 -0
  43. package/src/market-radar-sources.ts +173 -173
  44. package/src/prompt-slots.ts +125 -0
  45. package/src/prompting.ts +8 -8
  46. package/src/protocol.ts +314 -3
  47. package/src/routes.ts +325 -6
  48. package/src/run.ts +4 -0
  49. package/src/snapshots.ts +174 -0
  50. package/src/stage-contract.ts +189 -189
  51. package/src/tension.ts +240 -0
  52. package/src/timeline.ts +159 -0
package/lib/index.js CHANGED
@@ -7,12 +7,12 @@ import { randomBytes } from "node:crypto";
7
7
  import { BlockAssembler, ReasoningEffortId, createAssistantMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
8
8
  import { strFromU8, unzipSync } from "fflate";
9
9
  //#region src/assets.ts
10
- /** 预置写法模板(来自 AI-Novel-Writing-Assistant 内置 DEFAULT_STYLE_TEMPLATES)。 */
10
+ /** 预置叙事风格模板(8 套,一键绑定无需样本文本)。 */
11
11
  const BUILTIN_STYLE_TEMPLATES = [
12
12
  {
13
13
  key: "power-up-escalation",
14
- name: "爽文递进推进流",
15
- description: "持续升级冲突和收益点,强化目标推进与爽点兑现。",
14
+ name: "目标推进爽文流",
15
+ description: "冲突与收益逐级抬升,每章都要有可指认的目标推进与爽点兑现。",
16
16
  category: "爽文流",
17
17
  applicableGenres: [
18
18
  "都市",
@@ -24,7 +24,7 @@ const BUILTIN_STYLE_TEMPLATES = [
24
24
  "收益点",
25
25
  "冲突升级"
26
26
  ],
27
- analysisMarkdown: "每段都要有目标推进或爽点兑现,保持明确因果和节奏抬升。",
27
+ analysisMarkdown: "每一段都要落在「推进」或「兑现」上:因果清楚,节奏逐段抬升。",
28
28
  narrative: {
29
29
  progressionMode: "goal_driven",
30
30
  sceneUnitPattern: [
@@ -36,7 +36,7 @@ const BUILTIN_STYLE_TEMPLATES = [
36
36
  multiPov: false,
37
37
  looping: false,
38
38
  endingStyle: "hook",
39
- summary: "围绕目标推进,尽快兑现局部收益。"
39
+ summary: "围绕本章目标滚进,尽快给出局部收益。"
40
40
  },
41
41
  character: {
42
42
  allowSelfReflection: true,
@@ -44,7 +44,7 @@ const BUILTIN_STYLE_TEMPLATES = [
44
44
  defenseMechanisms: [],
45
45
  facePriority: false,
46
46
  dialogueStyle: "direct",
47
- summary: "角色表达直接,情绪跟随胜负切换。"
47
+ summary: "表达直接,情绪跟着胜负走。"
48
48
  },
49
49
  language: {
50
50
  register: "direct",
@@ -53,23 +53,23 @@ const BUILTIN_STYLE_TEMPLATES = [
53
53
  allowSwearing: false,
54
54
  sentenceVariation: "medium",
55
55
  allowUselessDetails: false,
56
- summary: "句式清晰,减少无效分散信息。"
56
+ summary: "句子利落,删掉不承担信息的枝节。"
57
57
  },
58
58
  rhythm: {
59
59
  pace: "fast",
60
60
  paragraphDensity: "medium",
61
61
  allowFragmentedFlow: false,
62
62
  actionOverExplanation: true,
63
- summary: "优先冲突和结果,少停留。"
63
+ summary: "先冲突后解释,不做停顿式铺垫。"
64
64
  },
65
65
  proseRules: [
66
- "围绕目标推进,尽快兑现局部收益;每段都要有目标推进或爽点兑现。",
67
- "保持明确因果和节奏抬升,场景单元按「目标→阻碍→压制→反转收益」推进。",
68
- "优先冲突和结果,少停留;段尾用钩子收束。"
66
+ "每段都要有可指认的推进或兑现,不允许只做情绪铺垫。",
67
+ "场景按「立目标 → 遇阻 → 被压 → 反转收利」组织,节奏逐段抬升。",
68
+ "结尾用钩子收束,不写总结式段落。"
69
69
  ],
70
- dialogueRules: ["角色表达直接,情绪跟随胜负切换。", "对话承担推进与信息功能,但保留角色自己的语气差异。"],
71
- languageRules: ["句式清晰,减少无效分散信息。", "直接、明确,不做无谓铺垫。"],
72
- rhythmRules: ["快节奏,段落密度中等,动作先于解释。", "尽快兑现局部收益,避免拖沓。"],
70
+ dialogueRules: ["台词直来直去,情绪随胜负切换。", "对话承担推进与信息交换,同时保留各角色的语气差别。"],
71
+ languageRules: ["句子清晰,删掉不承载信息的修饰。", "表达明确,不做无谓铺垫。"],
72
+ rhythmRules: ["快节奏、段落密度中等,动作先于解释。", "尽快兑现局部收益,避免拖沓。"],
73
73
  defaultAntiAiRuleKeys: [
74
74
  "forbid-theme-summary",
75
75
  "risk-dialogue-too-functional",
@@ -78,8 +78,8 @@ const BUILTIN_STYLE_TEMPLATES = [
78
78
  },
79
79
  {
80
80
  key: "bottom-loop-reality",
81
- name: "底层循环现实流",
82
- description: "通过碎片化生活与反复落空表现人物困境。",
81
+ name: "底层循环写实流",
82
+ description: "用碎片化的日常和反复落空写人物困境,不给廉价出路。",
83
83
  category: "现实流",
84
84
  applicableGenres: [
85
85
  "都市",
@@ -91,7 +91,7 @@ const BUILTIN_STYLE_TEMPLATES = [
91
91
  "口语化",
92
92
  "碎片叙事"
93
93
  ],
94
- analysisMarkdown: "以时间推进和现实落差构成叙事张力,结尾不解决核心困境。",
94
+ analysisMarkdown: "靠时间推移与现实落差形成张力,结尾不解决核心困境。",
95
95
  narrative: {
96
96
  progressionMode: "time_sequence",
97
97
  sceneUnitPattern: [
@@ -102,7 +102,7 @@ const BUILTIN_STYLE_TEMPLATES = [
102
102
  multiPov: false,
103
103
  looping: true,
104
104
  endingStyle: "unresolved",
105
- summary: "以碎片化生活推进,不做总括式回顾。"
105
+ summary: "碎片化推进,不做总括式回顾。"
106
106
  },
107
107
  character: {
108
108
  allowSelfReflection: false,
@@ -114,7 +114,7 @@ const BUILTIN_STYLE_TEMPLATES = [
114
114
  ],
115
115
  facePriority: true,
116
116
  dialogueStyle: "short_colloquial",
117
- summary: "人物情绪通过动作和嘴硬表达。"
117
+ summary: "情绪靠动作和嘴硬,不做内心解说。"
118
118
  },
119
119
  language: {
120
120
  register: "colloquial",
@@ -123,23 +123,23 @@ const BUILTIN_STYLE_TEMPLATES = [
123
123
  allowSwearing: true,
124
124
  sentenceVariation: "high",
125
125
  allowUselessDetails: true,
126
- summary: "语言粗粝、口语化,允许生活杂音。"
126
+ summary: "口语、粗粝,允许生活杂音。"
127
127
  },
128
128
  rhythm: {
129
129
  pace: "medium_fast",
130
130
  paragraphDensity: "high",
131
131
  allowFragmentedFlow: true,
132
132
  actionOverExplanation: true,
133
- summary: "段落密实,动作先于解释。"
133
+ summary: "段落写实,先给动作再给解释。"
134
134
  },
135
135
  proseRules: [
136
- "以时间推进和现实落差构成叙事张力,结尾不解决核心困境。",
137
- "场景单元按「行为→落差→自我合理化」推进。",
138
- "以碎片化生活推进,不做总括式回顾。"
136
+ "用时间推移与现实落差构成张力,结尾不解决核心困境。",
137
+ "场景按「行为 → 落差 → 自我合理化」组织。",
138
+ "碎片化推进,不做总括式回顾。"
139
139
  ],
140
- dialogueRules: ["人物情绪通过动作和嘴硬表达,允许短促口语化台词。", "对话保留生活杂音与无效信息。"],
141
- languageRules: ["语言粗粝、口语化,允许生活杂音与不完整句。", "句子变化度高,允许无意义细节。"],
142
- rhythmRules: ["段落密实,动作先于解释。", "中快节奏,允许碎片化流动。"],
140
+ dialogueRules: ["情绪通过动作与嘴硬体现,台词短促口语化。", "对话保留生活杂音与无效信息。"],
141
+ languageRules: ["语言粗粝口语化,允许不完整句。", "句长变化大,允许无意义细节。"],
142
+ rhythmRules: ["段落写实,先给动作再给解释。", "中快节奏,允许碎片化流动。"],
143
143
  defaultAntiAiRuleKeys: [
144
144
  "forbid-explicit-psychology",
145
145
  "forbid-ending-elevation",
@@ -150,8 +150,8 @@ const BUILTIN_STYLE_TEMPLATES = [
150
150
  },
151
151
  {
152
152
  key: "suspense-pressure",
153
- name: "悬疑压迫递增流",
154
- description: "通过信息遮蔽、细节异常和压力叠加制造不安感。",
153
+ name: "信息遮蔽压迫流",
154
+ description: "用信息遮蔽、细节异常与压力叠加制造不安。",
155
155
  category: "悬疑流",
156
156
  applicableGenres: [
157
157
  "悬疑",
@@ -163,7 +163,7 @@ const BUILTIN_STYLE_TEMPLATES = [
163
163
  "信息差",
164
164
  "异常细节"
165
165
  ],
166
- analysisMarkdown: "以异常细节、信息差和节奏收束推动悬念层层加压。",
166
+ analysisMarkdown: "异常细节、信息差与节奏收束一起给悬念加压。",
167
167
  narrative: {
168
168
  progressionMode: "mystery_escalation",
169
169
  sceneUnitPattern: [
@@ -175,7 +175,7 @@ const BUILTIN_STYLE_TEMPLATES = [
175
175
  multiPov: false,
176
176
  looping: false,
177
177
  endingStyle: "suspense",
178
- summary: "优先制造信息缺口和压迫氛围。"
178
+ summary: "优先制造信息缺口与压迫氛围。"
179
179
  },
180
180
  character: {
181
181
  allowSelfReflection: true,
@@ -183,7 +183,7 @@ const BUILTIN_STYLE_TEMPLATES = [
183
183
  defenseMechanisms: ["压抑"],
184
184
  facePriority: false,
185
185
  dialogueStyle: "restrained",
186
- summary: "角色反应克制,恐惧通过反应显现。"
186
+ summary: "反应克制,恐惧从反应里透出来。"
187
187
  },
188
188
  language: {
189
189
  register: "restrained",
@@ -192,23 +192,23 @@ const BUILTIN_STYLE_TEMPLATES = [
192
192
  allowSwearing: false,
193
193
  sentenceVariation: "medium_high",
194
194
  allowUselessDetails: true,
195
- summary: "细节精确,保留少量噪音增强现场感。"
195
+ summary: "细节精确,留少量噪音增强现场感。"
196
196
  },
197
197
  rhythm: {
198
198
  pace: "medium",
199
199
  paragraphDensity: "medium_high",
200
200
  allowFragmentedFlow: true,
201
201
  actionOverExplanation: true,
202
- summary: "通过节奏收束和信息延迟制造压力。"
202
+ summary: "用节奏收束与信息延迟制造压力。"
203
203
  },
204
204
  proseRules: [
205
- "以异常细节、信息差和节奏收束推动悬念层层加压。",
206
- "场景单元按「现场细节→异常→误判→新风险」推进。",
207
- "优先制造信息缺口和压迫氛围。"
205
+ "以异常细节、信息差与节奏收束给悬念加压。",
206
+ "场景按「现场细节 → 异常 → 误判 → 新风险」组织。",
207
+ "优先制造信息缺口与压迫氛围。"
208
208
  ],
209
- dialogueRules: ["角色反应克制,恐惧通过反应显现。", "对话保留克制感,不解释恐惧来源。"],
210
- languageRules: ["细节精确,保留少量噪音增强现场感。", "克制、中等偏高句变化。"],
211
- rhythmRules: ["通过节奏收束和信息延迟制造压力。", "中速,段落密度中等偏高。"],
209
+ dialogueRules: ["反应克制,恐惧从反应里透出来。", "台词保持克制,不解释恐惧来源。"],
210
+ languageRules: ["细节写准,留一点杂音换现场感。", "克制,句长变化中等偏高。"],
211
+ rhythmRules: ["用节奏收束与信息延迟制造压力。", "中速,段落密度中等偏高。"],
212
212
  defaultAntiAiRuleKeys: [
213
213
  "forbid-explicit-psychology",
214
214
  "forbid-theme-summary",
@@ -218,8 +218,8 @@ const BUILTIN_STYLE_TEMPLATES = [
218
218
  },
219
219
  {
220
220
  key: "emotional-tension",
221
- name: "情绪拉扯流",
222
- description: "通过错位表达、停顿和误读制造关系张力。",
221
+ name: "关系错位拉扯流",
222
+ description: "用错位表达、停顿与误读制造关系张力。",
223
223
  category: "情感流",
224
224
  applicableGenres: [
225
225
  "言情",
@@ -231,7 +231,7 @@ const BUILTIN_STYLE_TEMPLATES = [
231
231
  "拉扯",
232
232
  "停顿感"
233
233
  ],
234
- analysisMarkdown: "人物不直说核心情绪,靠误读、停顿和反应推动关系变化。",
234
+ analysisMarkdown: "核心情绪不直说,靠误读、停顿与反应推动关系变化。",
235
235
  narrative: {
236
236
  progressionMode: "relationship_push_pull",
237
237
  sceneUnitPattern: [
@@ -243,7 +243,7 @@ const BUILTIN_STYLE_TEMPLATES = [
243
243
  multiPov: false,
244
244
  looping: false,
245
245
  endingStyle: "emotional_hook",
246
- summary: "以关系错位推进,而非直接说明。"
246
+ summary: "靠关系错位推进,不直接说明。"
247
247
  },
248
248
  character: {
249
249
  allowSelfReflection: true,
@@ -255,7 +255,7 @@ const BUILTIN_STYLE_TEMPLATES = [
255
255
  ],
256
256
  facePriority: true,
257
257
  dialogueStyle: "subtext_heavy",
258
- summary: "情绪通过停顿、动作和言外之意体现。"
258
+ summary: "情绪落在停顿、动作与言外之意上。"
259
259
  },
260
260
  language: {
261
261
  register: "natural",
@@ -264,23 +264,23 @@ const BUILTIN_STYLE_TEMPLATES = [
264
264
  allowSwearing: false,
265
265
  sentenceVariation: "high",
266
266
  allowUselessDetails: true,
267
- summary: "语言自然,允许留白与停顿。"
267
+ summary: "语言自然,允许停顿和留白。"
268
268
  },
269
269
  rhythm: {
270
270
  pace: "medium_slow",
271
271
  paragraphDensity: "medium",
272
272
  allowFragmentedFlow: true,
273
273
  actionOverExplanation: false,
274
- summary: "给关系反应留空间,但避免空洞抒情。"
274
+ summary: "给关系反应留空间,但不写空洞抒情。"
275
275
  },
276
276
  proseRules: [
277
- "人物不直说核心情绪,靠误读、停顿和反应推动关系变化。",
278
- "场景单元按「动作→言外之意→误读→回避」推进。",
279
- "以关系错位推进,而非直接说明。"
277
+ "核心情绪不直说,靠误读、停顿与反应推动关系。",
278
+ "场景按「动作 → 言外之意 → 误读 → 回避」组织。",
279
+ "靠关系错位推进,不靠旁白说明。"
280
280
  ],
281
- dialogueRules: ["情绪通过停顿、动作和言外之意体现。", "对话充满潜台词与试探。"],
282
- languageRules: ["语言自然,允许留白与停顿。", "句子变化度高,允许无意义细节。"],
283
- rhythmRules: ["给关系反应留空间,但避免空洞抒情。", "中慢节奏,段落密度中等。"],
281
+ dialogueRules: ["情绪通过停顿、动作与言外之意体现。", "台词充满潜台词与试探。"],
282
+ languageRules: ["语言自然,允许停顿和留白。", "句长变化大,允许无意义细节。"],
283
+ rhythmRules: ["给关系反应留白,但不写空泛抒情。", "中慢节奏,段落密度中等。"],
284
284
  defaultAntiAiRuleKeys: [
285
285
  "forbid-direct-preaching",
286
286
  "forbid-ending-elevation",
@@ -290,8 +290,8 @@ const BUILTIN_STYLE_TEMPLATES = [
290
290
  },
291
291
  {
292
292
  key: "ensemble-weave",
293
- name: "群像交织流",
294
- description: "以多人行动线和视角差异交织推进事件。",
293
+ name: "群像交叉推进流",
294
+ description: "多条人物行动线交叉推进,视角差异清晰可控。",
295
295
  category: "群像流",
296
296
  applicableGenres: [
297
297
  "群像",
@@ -303,7 +303,7 @@ const BUILTIN_STYLE_TEMPLATES = [
303
303
  "交织",
304
304
  "信息流动"
305
305
  ],
306
- analysisMarkdown: "多角色并行推进,但每个角色的表达和认知范围必须区分清楚。",
306
+ analysisMarkdown: "多角色并行推进,但每人的表达与认知边界必须分清。",
307
307
  narrative: {
308
308
  progressionMode: "multi_thread",
309
309
  sceneUnitPattern: [
@@ -314,7 +314,7 @@ const BUILTIN_STYLE_TEMPLATES = [
314
314
  multiPov: true,
315
315
  looping: false,
316
316
  endingStyle: "cross_hook",
317
- summary: "多线并进,但视角切换要受控。"
317
+ summary: "多线并进,视角切换要有控制。"
318
318
  },
319
319
  character: {
320
320
  allowSelfReflection: true,
@@ -322,7 +322,7 @@ const BUILTIN_STYLE_TEMPLATES = [
322
322
  defenseMechanisms: [],
323
323
  facePriority: false,
324
324
  dialogueStyle: "distinct_by_role",
325
- summary: "不同角色口吻必须拉开差异。"
325
+ summary: "不同角色的口吻必须拉开差距。"
326
326
  },
327
327
  language: {
328
328
  register: "flexible",
@@ -331,18 +331,18 @@ const BUILTIN_STYLE_TEMPLATES = [
331
331
  allowSwearing: false,
332
332
  sentenceVariation: "high",
333
333
  allowUselessDetails: false,
334
- summary: "保持角色差异,避免所有人说话一样。"
334
+ summary: "保持角色差异,避免千人一腔。"
335
335
  },
336
336
  rhythm: {
337
337
  pace: "balanced",
338
338
  paragraphDensity: "medium",
339
339
  allowFragmentedFlow: false,
340
340
  actionOverExplanation: true,
341
- summary: "多线交织但节奏不乱。"
341
+ summary: "几条线同时走,节奏别散。"
342
342
  },
343
- proseRules: ["多角色并行推进,但每个角色的表达和认知范围必须区分清楚。", "多线并进,但视角切换要受控。"],
344
- dialogueRules: ["不同角色口吻必须拉开差异,避免所有人说话一样。"],
345
- languageRules: ["保持角色差异,句式变化度高。", "减少无效分散信息。"],
343
+ proseRules: ["多角色并行推进,各人的表达与认知边界必须分清。", "多线并进,切视角要有节制。"],
344
+ dialogueRules: ["不同角色的口吻要拉开差距,避免所有人说话一样。"],
345
+ languageRules: ["保持角色差异,句长变化大。", "砍掉不推进剧情的枝节信息。"],
346
346
  rhythmRules: ["多线交织但节奏不乱,平衡推进。", "动作先于解释。"],
347
347
  defaultAntiAiRuleKeys: [
348
348
  "risk-dialogue-too-functional",
@@ -353,7 +353,7 @@ const BUILTIN_STYLE_TEMPLATES = [
353
353
  {
354
354
  key: "immersive-daily",
355
355
  name: "日常浸没流",
356
- description: "通过生活细节和细微情绪变化建立持续沉浸感。",
356
+ description: "用生活细节与细微情绪变化建立持续沉浸。",
357
357
  category: "日常流",
358
358
  applicableGenres: [
359
359
  "日常",
@@ -365,7 +365,7 @@ const BUILTIN_STYLE_TEMPLATES = [
365
365
  "沉浸",
366
366
  "细碎细节"
367
367
  ],
368
- analysisMarkdown: "允许保留生活性动作和无效信息,但核心情绪仍要通过场景自然流出。",
368
+ analysisMarkdown: "允许生活性动作与无效信息,但核心情绪仍要从场景里自然流出。",
369
369
  narrative: {
370
370
  progressionMode: "scene_immersion",
371
371
  sceneUnitPattern: [
@@ -376,7 +376,7 @@ const BUILTIN_STYLE_TEMPLATES = [
376
376
  multiPov: false,
377
377
  looping: false,
378
378
  endingStyle: "soft_open",
379
- summary: "重场景体验和关系温度。"
379
+ summary: "重场景体验与关系温度。"
380
380
  },
381
381
  character: {
382
382
  allowSelfReflection: true,
@@ -384,7 +384,7 @@ const BUILTIN_STYLE_TEMPLATES = [
384
384
  defenseMechanisms: [],
385
385
  facePriority: false,
386
386
  dialogueStyle: "daily_natural",
387
- summary: "人物表达自然,不用高强度戏剧句。"
387
+ summary: "说话像日常,不端戏剧腔。"
388
388
  },
389
389
  language: {
390
390
  register: "colloquial",
@@ -393,19 +393,19 @@ const BUILTIN_STYLE_TEMPLATES = [
393
393
  allowSwearing: false,
394
394
  sentenceVariation: "medium_high",
395
395
  allowUselessDetails: true,
396
- summary: "保留生活细节和杂音,不追求工整。"
396
+ summary: "保留生活细节与杂音,不追求工整。"
397
397
  },
398
398
  rhythm: {
399
399
  pace: "slow",
400
400
  paragraphDensity: "medium",
401
401
  allowFragmentedFlow: true,
402
402
  actionOverExplanation: false,
403
- summary: "慢节奏沉浸,但避免空转。"
403
+ summary: "慢节奏沉浸,但不空转。"
404
404
  },
405
- proseRules: ["重场景体验和关系温度,核心情绪通过场景自然流出。", "允许保留生活性动作和无效信息。"],
406
- dialogueRules: ["人物表达自然,不用高强度戏剧句。", "对话保留生活气息。"],
407
- languageRules: ["保留生活细节和杂音,不追求工整。", "口语化,句子变化中等偏高。"],
408
- rhythmRules: ["慢节奏沉浸,但避免空转。", "允许碎片化流动。"],
405
+ proseRules: ["重场景体验与关系温度,核心情绪从场景自然流出。", "允许保留生活性动作与无效信息。"],
406
+ dialogueRules: ["台词贴近日常说话,不端戏剧腔。", "对话保留生活气息。"],
407
+ languageRules: ["保留生活细节与杂音,不追求工整。", "口语化,句长变化中等偏高。"],
408
+ rhythmRules: ["节奏放慢,但不许原地打转。", "允许碎片化流动。"],
409
409
  defaultAntiAiRuleKeys: [
410
410
  "forbid-ending-elevation",
411
411
  "risk-even-paragraph-length",
@@ -414,8 +414,8 @@ const BUILTIN_STYLE_TEMPLATES = [
414
414
  },
415
415
  {
416
416
  key: "cold-professional",
417
- name: "冷峻专业流",
418
- description: "以专业事实和行业细节压住情绪,形成克制压力感。",
417
+ name: "专业事实压情绪流",
418
+ description: "用行业事实与程序细节压住情绪,形成克制的压力感。",
419
419
  category: "专业流",
420
420
  applicableGenres: [
421
421
  "职场",
@@ -427,7 +427,7 @@ const BUILTIN_STYLE_TEMPLATES = [
427
427
  "克制",
428
428
  "事实压情绪"
429
429
  ],
430
- analysisMarkdown: "行业事实和程序细节优先,情绪不直说,信息密度高于抒情密度。",
430
+ analysisMarkdown: "行业事实与程序细节优先;情绪不直说,信息密度高于抒情密度。",
431
431
  narrative: {
432
432
  progressionMode: "fact_driven",
433
433
  sceneUnitPattern: [
@@ -439,7 +439,7 @@ const BUILTIN_STYLE_TEMPLATES = [
439
439
  multiPov: false,
440
440
  looping: false,
441
441
  endingStyle: "pressure_continue",
442
- summary: "让专业事实承担叙事重量。"
442
+ summary: "让事实与程序细节扛住叙事重量。"
443
443
  },
444
444
  character: {
445
445
  allowSelfReflection: false,
@@ -447,7 +447,7 @@ const BUILTIN_STYLE_TEMPLATES = [
447
447
  defenseMechanisms: ["克制"],
448
448
  facePriority: false,
449
449
  dialogueStyle: "informational",
450
- summary: "情绪藏在专业动作和事实选择里。"
450
+ summary: "情绪藏在专业动作与事实选择里。"
451
451
  },
452
452
  language: {
453
453
  register: "professional",
@@ -456,23 +456,23 @@ const BUILTIN_STYLE_TEMPLATES = [
456
456
  allowSwearing: false,
457
457
  sentenceVariation: "medium",
458
458
  allowUselessDetails: false,
459
- summary: "术语和事实优先,避免廉价金句。"
459
+ summary: "术语与事实优先,避免廉价金句。"
460
460
  },
461
461
  rhythm: {
462
462
  pace: "balanced",
463
463
  paragraphDensity: "medium_high",
464
464
  allowFragmentedFlow: false,
465
465
  actionOverExplanation: true,
466
- summary: "信息密度高,但不铺张解释。"
466
+ summary: "信息给得密,却不铺开解释。"
467
467
  },
468
468
  proseRules: [
469
- "行业事实和程序细节优先,情绪不直说,信息密度高于抒情密度。",
470
- "场景单元按「事实→动作→专业判断→后果」推进。",
471
- "让专业事实承担叙事重量。"
469
+ "行业事实与程序细节优先,情绪不直说,信息密度高于抒情密度。",
470
+ "场景按「事实 → 动作 → 专业判断 → 后果」组织。",
471
+ "让事实与程序细节扛住叙事重量。"
472
472
  ],
473
- dialogueRules: ["情绪藏在专业动作和事实选择里。", "对话以信息性表达为主,克制。"],
474
- languageRules: ["术语和事实优先,避免廉价金句。", "正式、克制的语言。"],
475
- rhythmRules: ["信息密度高,但不铺张解释。", "平衡节奏,段落密度中等偏高。"],
473
+ dialogueRules: ["情绪藏在专业动作与事实选择里。", "对话以信息性表达为主,克制。"],
474
+ languageRules: ["术语与事实优先,避免廉价金句。", "语言正式、克制。"],
475
+ rhythmRules: ["信息给得密,却不铺开解释。", "节奏平衡,段落密度中等偏高。"],
476
476
  defaultAntiAiRuleKeys: [
477
477
  "forbid-direct-preaching",
478
478
  "forbid-theme-summary",
@@ -481,8 +481,8 @@ const BUILTIN_STYLE_TEMPLATES = [
481
481
  },
482
482
  {
483
483
  key: "absurd-dark-humor",
484
- name: "荒诞黑色幽默流",
485
- description: "通过反差、冷感观察和荒诞细节制造黑色幽默。",
484
+ name: "反差黑色幽默流",
485
+ description: "用反差、冷感观察与荒诞细节制造黑色幽默。",
486
486
  category: "黑色幽默",
487
487
  applicableGenres: [
488
488
  "都市",
@@ -494,7 +494,7 @@ const BUILTIN_STYLE_TEMPLATES = [
494
494
  "反差",
495
495
  "冷感"
496
496
  ],
497
- analysisMarkdown: "用反差和荒诞细节放大现实困境,笑点和压迫感同时存在。",
497
+ analysisMarkdown: "用反差与荒诞细节放大现实困境,笑点与压迫感并存。",
498
498
  narrative: {
499
499
  progressionMode: "contrast_driven",
500
500
  sceneUnitPattern: [
@@ -505,7 +505,7 @@ const BUILTIN_STYLE_TEMPLATES = [
505
505
  multiPov: false,
506
506
  looping: false,
507
507
  endingStyle: "bitter_aftertaste",
508
- summary: "依赖反差和冷感观察,而非热闹吐槽。"
508
+ summary: "靠反差与冷感观察,不靠热闹吐槽。"
509
509
  },
510
510
  character: {
511
511
  allowSelfReflection: false,
@@ -513,7 +513,7 @@ const BUILTIN_STYLE_TEMPLATES = [
513
513
  defenseMechanisms: ["自嘲", "转移"],
514
514
  facePriority: true,
515
515
  dialogueStyle: "deadpan_colloquial",
516
- summary: "情绪藏在冷反应和嘴硬里。"
516
+ summary: "情绪藏在冷反应与嘴硬里。"
517
517
  },
518
518
  language: {
519
519
  register: "colloquial",
@@ -522,23 +522,23 @@ const BUILTIN_STYLE_TEMPLATES = [
522
522
  allowSwearing: true,
523
523
  sentenceVariation: "high",
524
524
  allowUselessDetails: true,
525
- summary: "允许夹带荒诞杂质和冷幽默节奏。"
525
+ summary: "允许夹带荒诞杂质与冷幽默节奏。"
526
526
  },
527
527
  rhythm: {
528
528
  pace: "balanced",
529
529
  paragraphDensity: "medium_high",
530
530
  allowFragmentedFlow: true,
531
531
  actionOverExplanation: true,
532
- summary: "反差点要快落地,不要解释笑点。"
532
+ summary: "反差点要快落地,不解释笑点。"
533
533
  },
534
534
  proseRules: [
535
- "用反差和荒诞细节放大现实困境,笑点和压迫感同时存在。",
536
- "场景单元按「现实细节→荒诞偏差→冷反应」推进。",
537
- "依赖反差和冷感观察,而非热闹吐槽。"
535
+ "用反差与荒诞细节放大现实困境,笑点与压迫感并存。",
536
+ "场景按「现实细节 → 荒诞偏差 → 冷反应」组织。",
537
+ "靠反差与冷感观察,不靠热闹吐槽。"
538
538
  ],
539
- dialogueRules: ["情绪藏在冷反应和嘴硬里。", "台词冷面、口语化,允许自嘲与转移。"],
540
- languageRules: ["允许夹带荒诞杂质和冷幽默节奏。", "口语化,句子变化度高。"],
541
- rhythmRules: ["反差点要快落地,不要解释笑点。", "平衡节奏,段落密度中等偏高。"],
539
+ dialogueRules: ["情绪藏在冷反应与嘴硬里。", "台词冷面口语化,允许自嘲与转移。"],
540
+ languageRules: ["允许夹带荒诞杂质与冷幽默节奏。", "口语化,句长变化大。"],
541
+ rhythmRules: ["反差立刻见效,笑点不解释。", "节奏平衡,段落密度中等偏高。"],
542
542
  defaultAntiAiRuleKeys: [
543
543
  "forbid-explicit-psychology",
544
544
  "forbid-ending-elevation",
@@ -778,12 +778,12 @@ const BUILTIN_STYLE_TEMPLATES = [
778
778
  ]
779
779
  }
780
780
  ];
781
- /** 内置全局反 AI 规则(来自 AI-Novel-Writing-Assistant 内置 DEFAULT_ANTI_AI_RULES)。 */
781
+ /** 内置全局反 AI 规则(12 条,随插件发布;项目可叠加自定义规则)。 */
782
782
  const BUILTIN_ANTI_AI_RULES = [
783
783
  {
784
- name: "禁止解释型心理描写",
785
- avoid: "直接使用\"他感到\"\"他意识到\"\"他明白了\"等句式解释人物心理。",
786
- fix: "把心理解释改成动作、语气、停顿、环境反应或结果。",
784
+ name: "不许直接解说心理",
785
+ avoid: "用「他感到」「他意识到」这类句式替读者总结人物内心。",
786
+ fix: "改成动作、语气、停顿、环境反馈或结果来呈现。",
787
787
  detectPatterns: [
788
788
  "他感到",
789
789
  "她感到",
@@ -800,9 +800,9 @@ const BUILTIN_ANTI_AI_RULES = [
800
800
  globalBaselineEnabled: true
801
801
  },
802
802
  {
803
- name: "禁止段尾升华",
804
- avoid: "在段尾或收尾处用总结句升华主题(如\"生活就是\"\"命运总会\"\"说到底\")。",
805
- fix: "删除升华句,回到具体动作、现场或悬而未决的处境。",
803
+ name: "不许段尾拔高",
804
+ avoid: "在段落或章节收尾处放一句总结式升华(「生活就是…」「说到底…」)。",
805
+ fix: "删掉升华句,把收尾落回具体动作、现场或悬而未决的处境。",
806
806
  detectPatterns: [
807
807
  "生活就是",
808
808
  "命运总会",
@@ -818,9 +818,9 @@ const BUILTIN_ANTI_AI_RULES = [
818
818
  globalBaselineEnabled: true
819
819
  },
820
820
  {
821
- name: "禁止总结主题",
822
- avoid: "把段落写成总结中心思想或提炼人生道理(如\"这说明\"\"这意味着\")。",
823
- fix: "删掉主题总结,让信息通过事件和结果自然显现。",
821
+ name: "不许概括主题",
822
+ avoid: "把段落写成中心思想总结或人生道理(「这说明…」「这意味着…」)。",
823
+ fix: "删掉概括,让信息从事态与结果里自然显出来。",
824
824
  detectPatterns: [
825
825
  "这说明",
826
826
  "这意味着",
@@ -835,9 +835,9 @@ const BUILTIN_ANTI_AI_RULES = [
835
835
  globalBaselineEnabled: true
836
836
  },
837
837
  {
838
- name: "禁止直接说教",
839
- avoid: "作者替角色或读者做直接价值判断和说教(如\"我们都应该\"\"人总要学会\")。",
840
- fix: "改成角色具体处境或对话,不做抽象说教。",
838
+ name: "不许作者说教",
839
+ avoid: "作者替角色或读者下价值判断(「我们都应该…」「人总要学会…」)。",
840
+ fix: "换成角色的具体处境或台词,不做抽象说教。",
841
841
  detectPatterns: [
842
842
  "我们都应该",
843
843
  "人总要学会",
@@ -851,9 +851,9 @@ const BUILTIN_ANTI_AI_RULES = [
851
851
  globalBaselineEnabled: true
852
852
  },
853
853
  {
854
- name: "段落长度过于整齐",
855
- avoid: "段落长度和节奏过于平均,产生 AI 作文感。",
856
- fix: "打破段落长度均衡,让句子和段落有自然起伏。",
854
+ name: "段落长短过于均匀",
855
+ avoid: "段落长度与节奏过于平均,读起来像范文。",
856
+ fix: "打散段落长短,让句子与段落有自然起伏。",
857
857
  detectPatterns: [],
858
858
  builtin: true,
859
859
  key: "risk-even-paragraph-length",
@@ -863,9 +863,9 @@ const BUILTIN_ANTI_AI_RULES = [
863
863
  globalBaselineEnabled: true
864
864
  },
865
865
  {
866
- name: "连续三段解释性叙事",
867
- avoid: "连续几段只有解释没有动作,削弱现场感。",
868
- fix: "插入动作、对话、环境反馈,减少连段说明。",
866
+ name: "连段交代没有动作",
867
+ avoid: "连续几段只在交代背景,没有动作与现场感。",
868
+ fix: "插入动作、对话、环境反馈,把连段说明拆开。",
869
869
  detectPatterns: [],
870
870
  builtin: true,
871
871
  key: "risk-three-paragraphs-exposition",
@@ -875,9 +875,9 @@ const BUILTIN_ANTI_AI_RULES = [
875
875
  globalBaselineEnabled: true
876
876
  },
877
877
  {
878
- name: "对话纯功能推进",
879
- avoid: "对话只有信息推进,没有人物语气和生活噪音(如\"告诉你\"\"我们现在要\")。",
880
- fix: "补入停顿、绕弯、语气差异和无效信息。",
878
+ name: "对话只剩功能",
879
+ avoid: "对话只推进信息,没有语气差异与生活噪音(「告诉你…」「我们现在要…」)。",
880
+ fix: "补入停顿、绕弯、语气差异与无效信息。",
881
881
  detectPatterns: [
882
882
  "告诉你",
883
883
  "我们现在要",
@@ -891,9 +891,9 @@ const BUILTIN_ANTI_AI_RULES = [
891
891
  globalBaselineEnabled: true
892
892
  },
893
893
  {
894
- name: "句式重复率过高",
895
- avoid: "连续句式过于整齐(如\"首先\"\"然后\"\"接着\"\"最后\"),显得机械。",
896
- fix: "拉开句式长度和起句方式,打散结构。",
894
+ name: "句式重复率偏高",
895
+ avoid: "连续使用同构句式(「首先…然后…接着…最后…」),读起来机械。",
896
+ fix: "拉开句长与起句方式,打散结构。",
897
897
  detectPatterns: [
898
898
  "首先",
899
899
  "然后",
@@ -929,9 +929,9 @@ const BUILTIN_ANTI_AI_RULES = [
929
929
  globalBaselineEnabled: true
930
930
  },
931
931
  {
932
- name: "鼓励无意义小动作",
933
- avoid: "(鼓励类)全篇缺少真实但不推动主线的小动作,人物显得空洞。",
934
- fix: "补入挠头、点烟、抠包装、挪椅子等小动作,增加人味与生活感。",
932
+ name: "多点无用小动作",
933
+ avoid: "(鼓励类)通篇没有真实但不推动剧情的小动作,人物显得空。",
934
+ fix: "补入挠头、点烟、抠包装、挪椅子之类的小动作,添人味与生活感。",
935
935
  detectPatterns: [],
936
936
  builtin: true,
937
937
  key: "encourage-useless-action",
@@ -941,9 +941,9 @@ const BUILTIN_ANTI_AI_RULES = [
941
941
  globalBaselineEnabled: false
942
942
  },
943
943
  {
944
- name: "鼓励现实落差",
945
- avoid: "(鼓励类)人物预期和现实结果完全一致,缺少落差。",
946
- fix: "补出人物预期与实际结果之间的差距,制造张力。",
944
+ name: "多点预期落差",
945
+ avoid: "(鼓励类)人物预期与现实结果完全一致,没有落差。",
946
+ fix: "把预期与实际结果之间的差距写出来,制造张力。",
947
947
  detectPatterns: [],
948
948
  builtin: true,
949
949
  key: "encourage-reality-gap",
@@ -953,9 +953,9 @@ const BUILTIN_ANTI_AI_RULES = [
953
953
  globalBaselineEnabled: false
954
954
  },
955
955
  {
956
- name: "鼓励嘴硬补偿",
957
- avoid: "(鼓励类)人物吃瘪后没有维持体面的反应。",
958
- fix: "给角色补一句嘴硬找补或自我合理化,保持人设温度。",
956
+ name: "多点嘴硬找补",
957
+ avoid: "(鼓励类)人物吃瘪后没有任何维持体面的反应。",
958
+ fix: "补一句嘴硬找补或自我合理化,保住人设温度。",
959
959
  detectPatterns: [],
960
960
  builtin: true,
961
961
  key: "encourage-hard-mouth-compensation",
@@ -1056,7 +1056,7 @@ const BUILTIN_GENRE_LIBRARY = [
1056
1056
  name: "仙侠修真",
1057
1057
  description: "以修仙境界、宗门斗争、法宝丹药为核心,读者期待从凡人到强者的成长与长生问道。",
1058
1058
  id: "genre_xianxia",
1059
- template: "强调修行路径、因果代价与宗门/仙途抉择。",
1059
+ template: "落笔在修行路径、因果代价与宗门抉择上。",
1060
1060
  children: [
1061
1061
  {
1062
1062
  name: "凡人流",
@@ -1583,7 +1583,7 @@ const BUILTIN_PROGRESSION_MODES = [
1583
1583
  "小问题解决",
1584
1584
  "关系回温"
1585
1585
  ],
1586
- risks: ["高压主线喧宾夺主", "治愈感被连续打断"],
1586
+ risks: ["高压主线喧宾夺主", "治愈感被反复打断"],
1587
1587
  primary: true,
1588
1588
  progressionUnits: [
1589
1589
  "生活日常",
@@ -1609,8 +1609,8 @@ const BUILTIN_PROGRESSION_MODES = [
1609
1609
  "安抚点",
1610
1610
  "关系回暖"
1611
1611
  ],
1612
- antiSignals: ["高压主线喧宾夺主", "治愈感被连续打断"],
1613
- resolutionStyle: "优先通过陪伴、理解、日常行动和小范围修复化解问题。"
1612
+ antiSignals: ["高压主线喧宾夺主", "治愈感被反复打断"],
1613
+ resolutionStyle: "用陪伴、理解与日常小修补把问题化掉。"
1614
1614
  },
1615
1615
  {
1616
1616
  key: "story_mode_comedy_root",
@@ -1624,7 +1624,7 @@ const BUILTIN_PROGRESSION_MODES = [
1624
1624
  "反差回收",
1625
1625
  "局面失控"
1626
1626
  ],
1627
- risks: ["长时间严肃无包袱", "笑点只靠口癖和段子堆砌"],
1627
+ risks: ["长时间严肃无包袱", "笑点全靠口癖与段子堆"],
1628
1628
  primary: true,
1629
1629
  progressionUnits: [
1630
1630
  "设包袱",
@@ -1647,8 +1647,8 @@ const BUILTIN_PROGRESSION_MODES = [
1647
1647
  "回收",
1648
1648
  "轻松释放"
1649
1649
  ],
1650
- antiSignals: ["长时间严肃无包袱", "笑点只靠口癖和段子堆砌"],
1651
- resolutionStyle: "通过包袱回收和失控场面完成释放。"
1650
+ antiSignals: ["长时间严肃无包袱", "笑点全靠口癖与段子堆"],
1651
+ resolutionStyle: "用包袱回收与失控场面完成释放。"
1652
1652
  },
1653
1653
  {
1654
1654
  key: "story_mode_mystery_root",
@@ -1675,7 +1675,7 @@ const BUILTIN_PROGRESSION_MODES = [
1675
1675
  "隐藏动机",
1676
1676
  "智性对抗"
1677
1677
  ],
1678
- forbiddenConflictForms: ["为了保密故意不讲理", "纯体力对抗吃掉推演感"],
1678
+ forbiddenConflictForms: ["为了保密故意不讲理", "纯拼体力,推演感被吃掉"],
1679
1679
  conflictCeiling: "high",
1680
1680
  chapterUnit: "每章推进一个新疑点或一个旧疑点的验证。",
1681
1681
  volumeReward: "卷末揭开一层更大的真相或完成一次关键博弈。",
@@ -1699,7 +1699,7 @@ const BUILTIN_PROGRESSION_MODES = [
1699
1699
  "信任变化",
1700
1700
  "节点兑现"
1701
1701
  ],
1702
- risks: ["关系原地踏步", "只有设定没有互动"],
1702
+ risks: ["关系原地踏步", "设定堆在那儿,互动没有"],
1703
1703
  primary: true,
1704
1704
  progressionUnits: [
1705
1705
  "关系建立",
@@ -1715,8 +1715,8 @@ const BUILTIN_PROGRESSION_MODES = [
1715
1715
  ],
1716
1716
  forbiddenConflictForms: [
1717
1717
  "关系线长期停滞",
1718
- "无意义狗血反复打转",
1719
- "情感推进被其他线长期吞没"
1718
+ "无意义狗血来回打转",
1719
+ "情感推进长期被别的线吃掉"
1720
1720
  ],
1721
1721
  conflictCeiling: "medium",
1722
1722
  chapterUnit: "每章推进一个关系节点、情绪变化或彼此认知变化。",
@@ -1726,8 +1726,8 @@ const BUILTIN_PROGRESSION_MODES = [
1726
1726
  "情绪节点",
1727
1727
  "兑现感"
1728
1728
  ],
1729
- antiSignals: ["关系原地踏步", "只有设定没有互动"],
1730
- resolutionStyle: "通过情绪交流、关系行动和关键选择完成推进或修复。"
1729
+ antiSignals: ["关系原地踏步", "设定堆在那儿,互动没有"],
1730
+ resolutionStyle: "靠情绪交流、关系行动与关键选择推进或修复。"
1731
1731
  },
1732
1732
  {
1733
1733
  key: "story_mode_growth_root",
@@ -1757,7 +1757,7 @@ const BUILTIN_PROGRESSION_MODES = [
1757
1757
  ],
1758
1758
  forbiddenConflictForms: [
1759
1759
  "长期停滞不成长",
1760
- "只有升级数值没有体验变化",
1760
+ "只见数值上涨,体验没有变化",
1761
1761
  "探索线长期缺席"
1762
1762
  ],
1763
1763
  conflictCeiling: "high",
@@ -1783,7 +1783,7 @@ const BUILTIN_PROGRESSION_MODES = [
1783
1783
  "扩大威慑",
1784
1784
  "重写规则"
1785
1785
  ],
1786
- risks: ["主角像普通升级文一样长期弱势", "核心优势被故意封死"],
1786
+ risks: ["主角像普通升级文一样长期弱势", "核心优势被硬生生封住"],
1787
1787
  primary: false,
1788
1788
  progressionUnits: [
1789
1789
  "展示底牌",
@@ -1796,7 +1796,7 @@ const BUILTIN_PROGRESSION_MODES = [
1796
1796
  "势力挑衅",
1797
1797
  "高手试探"
1798
1798
  ],
1799
- forbiddenConflictForms: ["长期躲藏发育", "长时间无法反击的受虐剧情"],
1799
+ forbiddenConflictForms: ["长期躲藏发育", "长时间挨打却无法反击"],
1800
1800
  conflictCeiling: "high",
1801
1801
  chapterUnit: "单章围绕一次压制、试探或立威展开。",
1802
1802
  volumeReward: "卷末形成新的权力格局或更大范围的承认。",
@@ -1805,8 +1805,8 @@ const BUILTIN_PROGRESSION_MODES = [
1805
1805
  "围观震撼",
1806
1806
  "越级压制"
1807
1807
  ],
1808
- antiSignals: ["主角像普通升级文一样长期弱势", "核心优势被故意封死"],
1809
- resolutionStyle: "冲突以快速反制和高位碾压收束。"
1808
+ antiSignals: ["主角像普通升级文一样长期弱势", "核心优势被硬生生封住"],
1809
+ resolutionStyle: "用迅速反制与高位碾压把冲突收掉。"
1810
1810
  },
1811
1811
  {
1812
1812
  key: "story_mode_face_slap",
@@ -1820,7 +1820,7 @@ const BUILTIN_PROGRESSION_MODES = [
1820
1820
  "公开回收",
1821
1821
  "舆论发酵"
1822
1822
  ],
1823
- risks: ["只挨打不回收", "回收太轻没有爽点"],
1823
+ risks: ["只挨打不回收", "回收力度太轻,爽不起来"],
1824
1824
  primary: false,
1825
1825
  progressionUnits: [
1826
1826
  "误判铺垫",
@@ -1833,7 +1833,7 @@ const BUILTIN_PROGRESSION_MODES = [
1833
1833
  "公开羞辱",
1834
1834
  "资源争夺"
1835
1835
  ],
1836
- forbiddenConflictForms: ["铺垫过长却没有回收", "打脸后没有实际影响"],
1836
+ forbiddenConflictForms: ["铺垫过长却没有回收", "打脸之后没有任何实际影响"],
1837
1837
  conflictCeiling: "medium",
1838
1838
  chapterUnit: "单章重点制造一次误判与回收闭环。",
1839
1839
  volumeReward: "卷末主角从被看轻者转为不可忽视的中心人物。",
@@ -1842,8 +1842,8 @@ const BUILTIN_PROGRESSION_MODES = [
1842
1842
  "反转",
1843
1843
  "当场回收"
1844
1844
  ],
1845
- antiSignals: ["只挨打不回收", "回收太轻没有爽点"],
1846
- resolutionStyle: "在最需要证明自己的场合完成反转和回收。"
1845
+ antiSignals: ["只挨打不回收", "回收力度太轻,爽不起来"],
1846
+ resolutionStyle: "在最需要证明的场合完成反转与回收。"
1847
1847
  },
1848
1848
  {
1849
1849
  key: "story_mode_misread",
@@ -1857,7 +1857,7 @@ const BUILTIN_PROGRESSION_MODES = [
1857
1857
  "群体扩散",
1858
1858
  "误会兑现"
1859
1859
  ],
1860
- risks: ["误会强行", "所有人都很快看穿"],
1860
+ risks: ["误会强行", "所有人一眼就看穿"],
1861
1861
  primary: false,
1862
1862
  progressionUnits: [
1863
1863
  "误读",
@@ -1870,7 +1870,7 @@ const BUILTIN_PROGRESSION_MODES = [
1870
1870
  "误会连锁",
1871
1871
  "群体解读偏差"
1872
1872
  ],
1873
- forbiddenConflictForms: ["直接解释清楚", "误会只持续一次就结束"],
1873
+ forbiddenConflictForms: ["直接解释清楚", "误会只演一次就收"],
1874
1874
  conflictCeiling: "medium",
1875
1875
  chapterUnit: "单章围绕一次误解升级和意外收益展开。",
1876
1876
  volumeReward: "卷末误读体系形成稳定共识或传奇形象。",
@@ -1879,8 +1879,8 @@ const BUILTIN_PROGRESSION_MODES = [
1879
1879
  "失控传播",
1880
1880
  "主角被动受益"
1881
1881
  ],
1882
- antiSignals: ["误会强行", "所有人都很快看穿"],
1883
- resolutionStyle: "让误会自然滚大,并转化为主角资源或声望。"
1882
+ antiSignals: ["误会强行", "所有人一眼就看穿"],
1883
+ resolutionStyle: "让误会自然滚大,最后变成主角的资源或声望。"
1884
1884
  },
1885
1885
  {
1886
1886
  key: "story_mode_secret_identity",
@@ -1894,7 +1894,7 @@ const BUILTIN_PROGRESSION_MODES = [
1894
1894
  "险些掉马",
1895
1895
  "掉马兑现"
1896
1896
  ],
1897
- risks: ["身份线长期静止", "马甲只是名字不同没有用途"],
1897
+ risks: ["身份线长期静止", "马甲只是换个名字,没有实际用途"],
1898
1898
  primary: false,
1899
1899
  progressionUnits: [
1900
1900
  "建立马甲",
@@ -1916,8 +1916,8 @@ const BUILTIN_PROGRESSION_MODES = [
1916
1916
  "身份切换",
1917
1917
  "掉马钩子"
1918
1918
  ],
1919
- antiSignals: ["身份线长期静止", "马甲只是名字不同没有用途"],
1920
- resolutionStyle: "围绕身份切换解决问题,再把压力转移到下一层掉马风险。"
1919
+ antiSignals: ["身份线长期静止", "马甲只是换个名字,没有实际用途"],
1920
+ resolutionStyle: "用身份切换解局,再把压力推到下一层露馅风险上。"
1921
1921
  },
1922
1922
  {
1923
1923
  key: "story_mode_farming",
@@ -1944,7 +1944,7 @@ const BUILTIN_PROGRESSION_MODES = [
1944
1944
  "天气与环境压力",
1945
1945
  "小范围利益摩擦"
1946
1946
  ],
1947
- forbiddenConflictForms: ["长期高压生死线", "无止境的大反派主线"],
1947
+ forbiddenConflictForms: ["长期高压生死线", "大反派主线永远收不掉"],
1948
1948
  conflictCeiling: "low",
1949
1949
  chapterUnit: "单章围绕一项劳作、一项建设或一段生活改善展开。",
1950
1950
  volumeReward: "卷末形成一个明显更稳定、更温暖的生活阶段。",
@@ -1968,7 +1968,7 @@ const BUILTIN_PROGRESSION_MODES = [
1968
1968
  "扩大规模",
1969
1969
  "赢得市场"
1970
1970
  ],
1971
- risks: ["只讲结果不讲过程", "经营主线被反派线吃掉"],
1971
+ risks: ["只讲结果不讲过程", "经营主线被反派线吞掉"],
1972
1972
  primary: false,
1973
1973
  progressionUnits: [
1974
1974
  "找到突破口",
@@ -1981,7 +1981,7 @@ const BUILTIN_PROGRESSION_MODES = [
1981
1981
  "资源调配",
1982
1982
  "团队磨合"
1983
1983
  ],
1984
- forbiddenConflictForms: ["无关的恋爱狗血长期抢戏", "经营过程被一笔带过"],
1984
+ forbiddenConflictForms: ["无关的恋爱狗血长期抢戏", "经营过程一笔带过"],
1985
1985
  conflictCeiling: "medium",
1986
1986
  chapterUnit: "单章推进一个经营动作或组织问题。",
1987
1987
  volumeReward: "卷末形成业务升级、品牌确立或组织跃迁。",
@@ -1990,8 +1990,8 @@ const BUILTIN_PROGRESSION_MODES = [
1990
1990
  "经营动作",
1991
1991
  "结果反馈"
1992
1992
  ],
1993
- antiSignals: ["只讲结果不讲过程", "经营主线被反派线吃掉"],
1994
- resolutionStyle: "靠策略、运营、分工和节奏管理解决问题。"
1993
+ antiSignals: ["只讲结果不讲过程", "经营主线被反派线吞掉"],
1994
+ resolutionStyle: "用策略、运营、分工与节奏管理解决问题。"
1995
1995
  },
1996
1996
  {
1997
1997
  key: "story_mode_territory_building",
@@ -2005,7 +2005,7 @@ const BUILTIN_PROGRESSION_MODES = [
2005
2005
  "规则落地",
2006
2006
  "外部试探回击"
2007
2007
  ],
2008
- risks: ["只有战争没有建设", "领地存在感越来越弱"],
2008
+ risks: ["只有战争没有建设", "领地越来越没存在感"],
2009
2009
  primary: false,
2010
2010
  progressionUnits: [
2011
2011
  "招募与安置",
@@ -2019,7 +2019,7 @@ const BUILTIN_PROGRESSION_MODES = [
2019
2019
  "管理压力",
2020
2020
  "治安危机"
2021
2021
  ],
2022
- forbiddenConflictForms: ["领地长期形同虚设", "主线被纯个人恩怨彻底带走"],
2022
+ forbiddenConflictForms: ["领地长期形同虚设", "主线被私人恩怨整个带偏"],
2023
2023
  conflictCeiling: "medium",
2024
2024
  chapterUnit: "单章推进一个领地建设动作、治理问题或外部试探应对。",
2025
2025
  volumeReward: "卷末让领地规模、秩序或影响力出现清晰跃升。",
@@ -2028,8 +2028,8 @@ const BUILTIN_PROGRESSION_MODES = [
2028
2028
  "治理动作",
2029
2029
  "秩序成型"
2030
2030
  ],
2031
- antiSignals: ["只有战争没有建设", "领地存在感越来越弱"],
2032
- resolutionStyle: "优先通过建设、治理、组织和局部反制稳定局面。"
2031
+ antiSignals: ["只有战争没有建设", "领地越来越没存在感"],
2032
+ resolutionStyle: "用建设、治理、组织与局部反制先把局面稳住。"
2033
2033
  },
2034
2034
  {
2035
2035
  key: "story_mode_family_management",
@@ -2043,7 +2043,7 @@ const BUILTIN_PROGRESSION_MODES = [
2043
2043
  "资源积累",
2044
2044
  "家族地位提升"
2045
2045
  ],
2046
- risks: ["只剩吵架不见经营", "家族线没有传承感"],
2046
+ risks: ["只剩吵架不见经营", "家族线缺少传承味道"],
2047
2047
  primary: false,
2048
2048
  progressionUnits: [
2049
2049
  "家业调整",
@@ -2057,7 +2057,7 @@ const BUILTIN_PROGRESSION_MODES = [
2057
2057
  "外部竞争",
2058
2058
  "代际观念冲突"
2059
2059
  ],
2060
- forbiddenConflictForms: ["狗血撕裂长期压倒经营线", "家族成员只有工具功能没有关系变化"],
2060
+ forbiddenConflictForms: ["狗血撕裂长期压倒经营线", "家族人只剩功能位,关系毫无变化"],
2061
2061
  conflictCeiling: "medium",
2062
2062
  chapterUnit: "单章推进一次家业动作、一场家族互动或一项责任兑现。",
2063
2063
  volumeReward: "卷末让家族更稳固、更有凝聚力,或完成一次地位跃升。",
@@ -2066,8 +2066,8 @@ const BUILTIN_PROGRESSION_MODES = [
2066
2066
  "家业动作",
2067
2067
  "关系重组"
2068
2068
  ],
2069
- antiSignals: ["只剩吵架不见经营", "家族线没有传承感"],
2070
- resolutionStyle: "通过协商、分工、经营结果和共同应对危机完成修复。"
2069
+ antiSignals: ["只剩吵架不见经营", "家族线缺少传承味道"],
2070
+ resolutionStyle: "用协商、分工、经营成果与共同扛危机完成修复。"
2071
2071
  },
2072
2072
  {
2073
2073
  key: "story_mode_healing_daily",
@@ -2081,7 +2081,7 @@ const BUILTIN_PROGRESSION_MODES = [
2081
2081
  "关系熟化",
2082
2082
  "生活改善"
2083
2083
  ],
2084
- risks: ["冲突升级成高压大戏", "读完一章只剩焦虑"],
2084
+ risks: ["冲突升级成高压大戏", "一章读完只剩焦虑"],
2085
2085
  primary: false,
2086
2086
  progressionUnits: [
2087
2087
  "日常互动",
@@ -2094,7 +2094,7 @@ const BUILTIN_PROGRESSION_MODES = [
2094
2094
  "关系别扭",
2095
2095
  "旧伤回响"
2096
2096
  ],
2097
- forbiddenConflictForms: ["大反派压顶", "长期极端痛苦不回收"],
2097
+ forbiddenConflictForms: ["大反派压顶", "极端痛苦长期不回收"],
2098
2098
  conflictCeiling: "low",
2099
2099
  chapterUnit: "每章围绕一个情绪节点和一个安抚点展开。",
2100
2100
  volumeReward: "卷末让角色和读者都感到状态明显回暖。",
@@ -2103,8 +2103,8 @@ const BUILTIN_PROGRESSION_MODES = [
2103
2103
  "生活细节",
2104
2104
  "温柔互动"
2105
2105
  ],
2106
- antiSignals: ["冲突升级成高压大戏", "读完一章只剩焦虑"],
2107
- resolutionStyle: "让人物通过陪伴、倾听和具体行动被一点点治好。"
2106
+ antiSignals: ["冲突升级成高压大戏", "一章读完只剩焦虑"],
2107
+ resolutionStyle: "让人物在陪伴、倾听与具体行动里一点点被治好。"
2108
2108
  },
2109
2109
  {
2110
2110
  key: "story_mode_shop_daily",
@@ -2118,7 +2118,7 @@ const BUILTIN_PROGRESSION_MODES = [
2118
2118
  "社区互动",
2119
2119
  "日常收束"
2120
2120
  ],
2121
- risks: ["只剩经营数据没有人情味", "店铺存在感越来越弱"],
2121
+ risks: ["只剩经营数据没有人情味", "店铺越来越没存在感"],
2122
2122
  primary: false,
2123
2123
  progressionUnits: [
2124
2124
  "来客事件",
@@ -2131,7 +2131,7 @@ const BUILTIN_PROGRESSION_MODES = [
2131
2131
  "人情摩擦",
2132
2132
  "生活琐事"
2133
2133
  ],
2134
- forbiddenConflictForms: ["连续恶性打压", "重悬疑或高压阴谋主导"],
2134
+ forbiddenConflictForms: ["连续恶性打压", "悬疑过重或阴谋高压主导"],
2135
2135
  conflictCeiling: "medium",
2136
2136
  chapterUnit: "每章推进一次来客互动或经营小事件。",
2137
2137
  volumeReward: "卷末让小店更有人气、更有归属感。",
@@ -2140,8 +2140,8 @@ const BUILTIN_PROGRESSION_MODES = [
2140
2140
  "来客故事",
2141
2141
  "社区温度"
2142
2142
  ],
2143
- antiSignals: ["只剩经营数据没有人情味", "店铺存在感越来越弱"],
2144
- resolutionStyle: "通过待人接物、经营调整和社区互助自然化解。"
2143
+ antiSignals: ["只剩经营数据没有人情味", "店铺越来越没存在感"],
2144
+ resolutionStyle: "靠待人接物、经营调整与社区互助自然化解。"
2145
2145
  },
2146
2146
  {
2147
2147
  key: "story_mode_slow_life",
@@ -2155,7 +2155,7 @@ const BUILTIN_PROGRESSION_MODES = [
2155
2155
  "生活改善",
2156
2156
  "情绪回稳"
2157
2157
  ],
2158
- risks: ["为了刺激频繁硬拗大冲突", "节奏越来越躁没有呼吸感"],
2158
+ risks: ["为了刺激频繁硬拗大冲突", "越写越躁,没有呼吸"],
2159
2159
  primary: false,
2160
2160
  progressionUnits: [
2161
2161
  "日常作息",
@@ -2166,9 +2166,9 @@ const BUILTIN_PROGRESSION_MODES = [
2166
2166
  allowedConflictForms: [
2167
2167
  "轻微不适应",
2168
2168
  "生活小困扰",
2169
- "关系疏离后的磨合"
2169
+ "疏远之后的重新磨合"
2170
2170
  ],
2171
- forbiddenConflictForms: ["持续高压倒计时", "强反派长期追杀或压顶阴谋"],
2171
+ forbiddenConflictForms: ["持续高压倒计时", "强反派长期追杀,或阴谋压顶"],
2172
2172
  conflictCeiling: "low",
2173
2173
  chapterUnit: "单章围绕一个生活片段、一项微小改善或一次情绪缓冲展开。",
2174
2174
  volumeReward: "卷末让人物真正拥有一个更稳、更舒服的生活状态。",
@@ -2177,8 +2177,8 @@ const BUILTIN_PROGRESSION_MODES = [
2177
2177
  "松弛感",
2178
2178
  "细节沉浸"
2179
2179
  ],
2180
- antiSignals: ["为了刺激频繁硬拗大冲突", "节奏越来越躁没有呼吸感"],
2181
- resolutionStyle: "通过时间、陪伴、规律生活和小步修复慢慢化解问题。"
2180
+ antiSignals: ["为了刺激频繁硬拗大冲突", "越写越躁,没有呼吸"],
2181
+ resolutionStyle: "用时间、陪伴、规律生活与小步修补慢慢化解。"
2182
2182
  },
2183
2183
  {
2184
2184
  key: "story_mode_companion_healing",
@@ -2192,7 +2192,7 @@ const BUILTIN_PROGRESSION_MODES = [
2192
2192
  "情绪松动",
2193
2193
  "关系确认"
2194
2194
  ],
2195
- risks: ["治愈线突然变虐恋拉扯", "陪伴关系没有实质推进"],
2195
+ risks: ["治愈线突然变虐恋拉扯", "陪伴关系没有实质进展"],
2196
2196
  primary: false,
2197
2197
  progressionUnits: [
2198
2198
  "接近与试探",
@@ -2208,8 +2208,8 @@ const BUILTIN_PROGRESSION_MODES = [
2208
2208
  ],
2209
2209
  forbiddenConflictForms: [
2210
2210
  "反复背刺",
2211
- "虐点长时间不回收",
2212
- "关系恶性拉扯失控"
2211
+ "虐点长期不回收",
2212
+ "拉扯过火到失去控制"
2213
2213
  ],
2214
2214
  conflictCeiling: "low",
2215
2215
  chapterUnit: "单章推进一次陪伴动作、一次关系松动或一次情绪落地。",
@@ -2219,8 +2219,8 @@ const BUILTIN_PROGRESSION_MODES = [
2219
2219
  "信任增长",
2220
2220
  "修复落地"
2221
2221
  ],
2222
- antiSignals: ["治愈线突然变虐恋拉扯", "陪伴关系没有实质推进"],
2223
- resolutionStyle: "以耐心、陪伴、行动支持和情绪回应完成修复。"
2222
+ antiSignals: ["治愈线突然变虐恋拉扯", "陪伴关系没有实质进展"],
2223
+ resolutionStyle: "靠耐心、陪伴与实际行动把关系一点点修回来。"
2224
2224
  },
2225
2225
  {
2226
2226
  key: "story_mode_comedy",
@@ -2234,7 +2234,7 @@ const BUILTIN_PROGRESSION_MODES = [
2234
2234
  "笑点回收",
2235
2235
  "场面翻车"
2236
2236
  ],
2237
- risks: ["整段都很严肃", "只有吐槽没有结构"],
2237
+ risks: ["整段都很严肃", "光有吐槽,没有结构"],
2238
2238
  primary: false,
2239
2239
  progressionUnits: [
2240
2240
  "包袱铺设",
@@ -2248,7 +2248,7 @@ const BUILTIN_PROGRESSION_MODES = [
2248
2248
  "身份反差",
2249
2249
  "认知错位"
2250
2250
  ],
2251
- forbiddenConflictForms: ["长时间沉重苦情线", "笑点稀薄又不推进剧情"],
2251
+ forbiddenConflictForms: ["长时间沉重苦情线", "笑点稀少,剧情也不动"],
2252
2252
  conflictCeiling: "medium",
2253
2253
  chapterUnit: "每章围绕至少一个有效包袱闭环展开。",
2254
2254
  volumeReward: "卷末形成标志性名场面或持续流传的笑料。",
@@ -2257,8 +2257,8 @@ const BUILTIN_PROGRESSION_MODES = [
2257
2257
  "回收",
2258
2258
  "场面感"
2259
2259
  ],
2260
- antiSignals: ["整段都很严肃", "只有吐槽没有结构"],
2261
- resolutionStyle: "用回收和升级继续抬高笑点而不是转沉重。"
2260
+ antiSignals: ["整段都很严肃", "光有吐槽,没有结构"],
2261
+ resolutionStyle: "靠回收与升级继续加码笑点,而不是转向沉重。"
2262
2262
  },
2263
2263
  {
2264
2264
  key: "story_mode_misunderstanding_comedy",
@@ -2272,7 +2272,7 @@ const BUILTIN_PROGRESSION_MODES = [
2272
2272
  "失控扩散",
2273
2273
  "喜剧回收"
2274
2274
  ],
2275
- risks: ["误会无后劲", "澄清过快导致节奏熄火"],
2275
+ risks: ["误会无后劲", "澄清太快,节奏直接熄火"],
2276
2276
  primary: false,
2277
2277
  progressionUnits: [
2278
2278
  "误会建立",
@@ -2285,7 +2285,7 @@ const BUILTIN_PROGRESSION_MODES = [
2285
2285
  "错位沟通",
2286
2286
  "集体误读"
2287
2287
  ],
2288
- forbiddenConflictForms: ["误会太快澄清", "误会只是重复同一个梗"],
2288
+ forbiddenConflictForms: ["误会太快澄清", "误会反复用同一个梗"],
2289
2289
  conflictCeiling: "medium",
2290
2290
  chapterUnit: "每章推进一层新的误解或回收节点。",
2291
2291
  volumeReward: "卷末形成一场大型误会名场面。",
@@ -2294,8 +2294,8 @@ const BUILTIN_PROGRESSION_MODES = [
2294
2294
  "误读",
2295
2295
  "扩散"
2296
2296
  ],
2297
- antiSignals: ["误会无后劲", "澄清过快导致节奏熄火"],
2298
- resolutionStyle: "让误会在最搞笑的位置回收,或者升级成更大笑点。"
2297
+ antiSignals: ["误会无后劲", "澄清太快,节奏直接熄火"],
2298
+ resolutionStyle: "把误会放在最好笑的位置回收,或升级成更大的笑点。"
2299
2299
  },
2300
2300
  {
2301
2301
  key: "story_mode_absurd_subversion",
@@ -2309,7 +2309,7 @@ const BUILTIN_PROGRESSION_MODES = [
2309
2309
  "离谱升级",
2310
2310
  "反套路回收"
2311
2311
  ],
2312
- risks: ["为了搞怪牺牲可读性", "段子化堆叠没有故事推进"],
2312
+ risks: ["为了搞怪牺牲可读性", "段子堆叠,故事不往前"],
2313
2313
  primary: false,
2314
2314
  progressionUnits: [
2315
2315
  "建立预期",
@@ -2325,7 +2325,7 @@ const BUILTIN_PROGRESSION_MODES = [
2325
2325
  ],
2326
2326
  forbiddenConflictForms: [
2327
2327
  "只剩无意义发疯",
2328
- "离谱但没有逻辑支点",
2328
+ "够离谱,却找不到逻辑支点",
2329
2329
  "连续沉重正剧化"
2330
2330
  ],
2331
2331
  conflictCeiling: "medium",
@@ -2336,8 +2336,8 @@ const BUILTIN_PROGRESSION_MODES = [
2336
2336
  "反套路",
2337
2337
  "回收闭环"
2338
2338
  ],
2339
- antiSignals: ["为了搞怪牺牲可读性", "段子化堆叠没有故事推进"],
2340
- resolutionStyle: "通过更高一层的反套路回收局面,而不是落回沉重正统冲突。"
2339
+ antiSignals: ["为了搞怪牺牲可读性", "段子堆叠,故事不往前"],
2340
+ resolutionStyle: "用更高一层的反套路把局面收回来,而不是跌回沉重正统冲突。"
2341
2341
  },
2342
2342
  {
2343
2343
  key: "story_mode_mystery_inference",
@@ -2364,7 +2364,7 @@ const BUILTIN_PROGRESSION_MODES = [
2364
2364
  "证词矛盾",
2365
2365
  "隐藏动机"
2366
2366
  ],
2367
- forbiddenConflictForms: ["关键证据凭空出现", "为了拖延故意含糊"],
2367
+ forbiddenConflictForms: ["关键证据凭空出现", "该说明的地方故意含糊拖节奏"],
2368
2368
  conflictCeiling: "medium",
2369
2369
  chapterUnit: "每章推进一个关键疑点的认知变化。",
2370
2370
  volumeReward: "卷末揭穿核心谜面或打开更大的悬念入口。",
@@ -2401,7 +2401,7 @@ const BUILTIN_PROGRESSION_MODES = [
2401
2401
  "心理博弈",
2402
2402
  "资源算计"
2403
2403
  ],
2404
- forbiddenConflictForms: ["对手太蠢", "所有胜利都靠主角开挂信息"],
2404
+ forbiddenConflictForms: ["对手太蠢", "每次赢都靠主角的信息外挂"],
2405
2405
  conflictCeiling: "medium",
2406
2406
  chapterUnit: "每章推进一轮试探、布子或拆招。",
2407
2407
  volumeReward: "卷末完成一次大局收网或更高阶对手登场。",
@@ -2425,7 +2425,7 @@ const BUILTIN_PROGRESSION_MODES = [
2425
2425
  "资源争夺",
2426
2426
  "短线反制"
2427
2427
  ],
2428
- risks: ["危机廉价化", "总是同一类困境循环"],
2428
+ risks: ["危机廉价化", "困境反复在同一类里打转"],
2429
2429
  primary: false,
2430
2430
  progressionUnits: [
2431
2431
  "压力逼近",
@@ -2441,8 +2441,8 @@ const BUILTIN_PROGRESSION_MODES = [
2441
2441
  ],
2442
2442
  forbiddenConflictForms: [
2443
2443
  "纯靠运气通关",
2444
- "只有肉搏没有策略",
2445
- "长期重复同一种危机"
2444
+ "全是硬碰硬,没有策略",
2445
+ "同一种危机反复来"
2446
2446
  ],
2447
2447
  conflictCeiling: "high",
2448
2448
  chapterUnit: "单章推进一轮生存压力、规则发现或关键抉择。",
@@ -2452,8 +2452,8 @@ const BUILTIN_PROGRESSION_MODES = [
2452
2452
  "选择代价",
2453
2453
  "规则利用"
2454
2454
  ],
2455
- antiSignals: ["危机廉价化", "总是同一类困境循环"],
2456
- resolutionStyle: "依靠规则理解、局势判断和有限资源配置完成破局。"
2455
+ antiSignals: ["危机廉价化", "困境反复在同一类里打转"],
2456
+ resolutionStyle: "靠吃透规则、判断局势与有限资源调度破局。"
2457
2457
  },
2458
2458
  {
2459
2459
  key: "story_mode_romantic_tension",
@@ -2467,7 +2467,7 @@ const BUILTIN_PROGRESSION_MODES = [
2467
2467
  "情绪失衡",
2468
2468
  "关系推进"
2469
2469
  ],
2470
- risks: ["只有误会拖延", "没有实质关系变化"],
2470
+ risks: ["只有误会拖延", "关系没有实质变化"],
2471
2471
  primary: false,
2472
2472
  progressionUnits: [
2473
2473
  "试探靠近",
@@ -2484,7 +2484,7 @@ const BUILTIN_PROGRESSION_MODES = [
2484
2484
  forbiddenConflictForms: [
2485
2485
  "故意降智不说人话",
2486
2486
  "拖太久不推进",
2487
- "关系线完全被外部主线压没"
2487
+ "关系线被主线彻底盖住"
2488
2488
  ],
2489
2489
  conflictCeiling: "medium",
2490
2490
  chapterUnit: "单章推进一个情感试探、认知变化或关系边界松动。",
@@ -2494,8 +2494,8 @@ const BUILTIN_PROGRESSION_MODES = [
2494
2494
  "双向张力",
2495
2495
  "靠近兑现"
2496
2496
  ],
2497
- antiSignals: ["只有误会拖延", "没有实质关系变化"],
2498
- resolutionStyle: "通过互动升级、情绪承认和行动选择完成关系推进。"
2497
+ antiSignals: ["只有误会拖延", "关系没有实质变化"],
2498
+ resolutionStyle: "靠互动升级、情绪承认与行动选择推进关系。"
2499
2499
  },
2500
2500
  {
2501
2501
  key: "story_mode_chasing_wife",
@@ -2509,7 +2509,7 @@ const BUILTIN_PROGRESSION_MODES = [
2509
2509
  "关系试炼",
2510
2510
  "重建兑现"
2511
2511
  ],
2512
- risks: ["只会口头道歉", "关系重建没有成本"],
2512
+ risks: ["只会口头道歉", "修复关系毫无代价"],
2513
2513
  primary: false,
2514
2514
  progressionUnits: [
2515
2515
  "后悔显现",
@@ -2525,8 +2525,8 @@ const BUILTIN_PROGRESSION_MODES = [
2525
2525
  ],
2526
2526
  forbiddenConflictForms: [
2527
2527
  "追偿只停留在嘴上",
2528
- "二次伤害无限循环",
2529
- "被追一方完全失去主体性"
2528
+ "反复受伤却始终没有出口",
2529
+ "被追的一方彻底失去主动权"
2530
2530
  ],
2531
2531
  conflictCeiling: "medium",
2532
2532
  chapterUnit: "单章推进一次追偿动作、一次旧伤碰撞或一次关系松动。",
@@ -2536,8 +2536,8 @@ const BUILTIN_PROGRESSION_MODES = [
2536
2536
  "行动补偿",
2537
2537
  "边界重建"
2538
2538
  ],
2539
- antiSignals: ["只会口头道歉", "关系重建没有成本"],
2540
- resolutionStyle: "用持续行动、代价承担和边界尊重推进关系重建。"
2539
+ antiSignals: ["只会口头道歉", "修复关系毫无代价"],
2540
+ resolutionStyle: "用持续行动、承担代价与尊重边界推进关系重建。"
2541
2541
  },
2542
2542
  {
2543
2543
  key: "story_mode_ensemble_bond",
@@ -2551,7 +2551,7 @@ const BUILTIN_PROGRESSION_MODES = [
2551
2551
  "立场变化",
2552
2552
  "羁绊加深"
2553
2553
  ],
2554
- risks: ["角色只报名字不干活", "群像线断裂成单人独角戏"],
2554
+ risks: ["角色只报名字不干活", "群像散架,成了独角戏"],
2555
2555
  primary: false,
2556
2556
  progressionUnits: [
2557
2557
  "关系串联",
@@ -2568,7 +2568,7 @@ const BUILTIN_PROGRESSION_MODES = [
2568
2568
  forbiddenConflictForms: [
2569
2569
  "群像角色长期工具化",
2570
2570
  "只剩主角独走",
2571
- "关系网不发生变化"
2571
+ "人物关系始终原样不动"
2572
2572
  ],
2573
2573
  conflictCeiling: "medium",
2574
2574
  chapterUnit: "单章推进一个群体互动节点或关键角色对之间的关系变化。",
@@ -2578,8 +2578,8 @@ const BUILTIN_PROGRESSION_MODES = [
2578
2578
  "关系联动",
2579
2579
  "角色互相成就"
2580
2580
  ],
2581
- antiSignals: ["角色只报名字不干活", "群像线断裂成单人独角戏"],
2582
- resolutionStyle: "通过团队互动、共同经历和角色间的选择完成关系演化。"
2581
+ antiSignals: ["角色只报名字不干活", "群像散架,成了独角戏"],
2582
+ resolutionStyle: "靠团队互动、共同经历与角色选择推动关系演化。"
2583
2583
  },
2584
2584
  {
2585
2585
  key: "story_mode_growth_leveling",
@@ -2593,7 +2593,7 @@ const BUILTIN_PROGRESSION_MODES = [
2593
2593
  "完成突破",
2594
2594
  "展示提升"
2595
2595
  ],
2596
- risks: ["升级像流水账", "能力提升没有剧情后果"],
2596
+ risks: ["升级像流水账", "能力提升不带来剧情后果"],
2597
2597
  primary: false,
2598
2598
  progressionUnits: [
2599
2599
  "积累条件",
@@ -2609,8 +2609,8 @@ const BUILTIN_PROGRESSION_MODES = [
2609
2609
  ],
2610
2610
  forbiddenConflictForms: [
2611
2611
  "成长全靠旁白略过",
2612
- "突破没有代价和铺垫",
2613
- "升级后没有任何兑现"
2612
+ "突破既无代价也无铺垫",
2613
+ "升级之后拿不到任何兑现"
2614
2614
  ],
2615
2615
  conflictCeiling: "high",
2616
2616
  chapterUnit: "单章推进一次积累、一次卡点试探或一次成长兑现。",
@@ -2620,8 +2620,8 @@ const BUILTIN_PROGRESSION_MODES = [
2620
2620
  "突破反馈",
2621
2621
  "阶段变化"
2622
2622
  ],
2623
- antiSignals: ["升级像流水账", "能力提升没有剧情后果"],
2624
- resolutionStyle: "通过积累、训练、实战和关键顿悟完成成长突破。"
2623
+ antiSignals: ["升级像流水账", "能力提升不带来剧情后果"],
2624
+ resolutionStyle: "靠积累、训练、实战与关键顿悟完成成长突破。"
2625
2625
  },
2626
2626
  {
2627
2627
  key: "story_mode_adventure_exploration",
@@ -2635,7 +2635,7 @@ const BUILTIN_PROGRESSION_MODES = [
2635
2635
  "遭遇风险",
2636
2636
  "带出新认知"
2637
2637
  ],
2638
- risks: ["探索空转", "新区域没有存在意义"],
2638
+ risks: ["探索空转", "新区域没有存在的必要"],
2639
2639
  primary: false,
2640
2640
  progressionUnits: [
2641
2641
  "进入新区域",
@@ -2651,8 +2651,8 @@ const BUILTIN_PROGRESSION_MODES = [
2651
2651
  ],
2652
2652
  forbiddenConflictForms: [
2653
2653
  "场景只是换皮",
2654
- "探索没有新信息产出",
2655
- "连续宅在原地不推进世界边界"
2654
+ "探索不产出新信息",
2655
+ "一直原地打转,世界边界不动"
2656
2656
  ],
2657
2657
  conflictCeiling: "high",
2658
2658
  chapterUnit: "单章推进一次新发现、一次环境应对或一次地图探索。",
@@ -2662,8 +2662,8 @@ const BUILTIN_PROGRESSION_MODES = [
2662
2662
  "发现感",
2663
2663
  "地图扩展"
2664
2664
  ],
2665
- antiSignals: ["探索空转", "新区域没有存在意义"],
2666
- resolutionStyle: "通过观察、应对、适应和局部突破打开更深层探索。"
2665
+ antiSignals: ["探索空转", "新区域没有存在的必要"],
2666
+ resolutionStyle: "靠观察、应对、适应与局部突破打开更深探索。"
2667
2667
  },
2668
2668
  {
2669
2669
  key: "story_mode_dungeon_challenge",
@@ -2677,7 +2677,7 @@ const BUILTIN_PROGRESSION_MODES = [
2677
2677
  "破解难点",
2678
2678
  "通关结算"
2679
2679
  ],
2680
- risks: ["关卡同质化", "只剩数值堆砌没有机制"],
2680
+ risks: ["关卡同质化", "只有数值叠加,没有机制可言"],
2681
2681
  primary: false,
2682
2682
  progressionUnits: [
2683
2683
  "进入副本",
@@ -2693,8 +2693,8 @@ const BUILTIN_PROGRESSION_MODES = [
2693
2693
  ],
2694
2694
  forbiddenConflictForms: [
2695
2695
  "副本机制全靠硬抗",
2696
- "通关后没有奖励反馈",
2697
- "副本重复到没有新意"
2696
+ "通关后拿不到奖励反馈",
2697
+ "关卡反复刷到失去新意"
2698
2698
  ],
2699
2699
  conflictCeiling: "high",
2700
2700
  chapterUnit: "单章推进一个关卡节点、一个机制破解或一次通关反馈。",
@@ -2704,8 +2704,8 @@ const BUILTIN_PROGRESSION_MODES = [
2704
2704
  "闯关反馈",
2705
2705
  "奖励兑现"
2706
2706
  ],
2707
- antiSignals: ["关卡同质化", "只剩数值堆砌没有机制"],
2708
- resolutionStyle: "通过规则识别、团队配合和关键执行完成通关。"
2707
+ antiSignals: ["关卡同质化", "只有数值叠加,没有机制可言"],
2708
+ resolutionStyle: "靠识别规则、团队配合与关键执行完成通关。"
2709
2709
  }
2710
2710
  ];
2711
2711
  /** 剧情桥段库:可复用情节套路(作者阅读经验沉淀,非某本书的剧情线)。 */
@@ -3009,31 +3009,31 @@ function renderAllAssets(assets) {
3009
3009
  renderAntiAiRules(assets)
3010
3010
  ].filter((part) => part !== "").join("\n\n");
3011
3011
  }
3012
- /** 起始风格画像库:无样本文本也能快速绑定一套写法(对齐上游 DEFAULT_STARTER_STYLE_PROFILES)。 */
3012
+ /** 起始风格画像库:无样本文本也能快速绑定一套写法(4 档起步写法)。 */
3013
3013
  const BUILTIN_STARTER_STYLE_PROFILES = [
3014
3014
  {
3015
3015
  key: "starter-power-up",
3016
3016
  templateKey: "power-up-escalation",
3017
- name: "我的默认爽文推进写法",
3018
- description: "适合第一次开书先跑顺目标推进、爽点兑现和章节收益点,后续可直接在此基础上微调。"
3017
+ name: "开书快跑·目标推进型",
3018
+ description: "第一次开书想先把推进感与爽点兑现跑顺,可以直接拿它起手,之后在这个底子上改。"
3019
3019
  },
3020
3020
  {
3021
3021
  key: "starter-suspense",
3022
3022
  templateKey: "suspense-pressure",
3023
- name: "我的默认悬疑压迫写法",
3024
- description: "适合异常、规则、调查和危险逼近类故事,先帮你把压迫感和信息差稳住。"
3023
+ name: "开书快跑·信息遮蔽型",
3024
+ description: "写给异常、规则、调查、危险逼近这类故事:先把压迫感与信息差立起来。"
3025
3025
  },
3026
3026
  {
3027
3027
  key: "starter-emotional",
3028
3028
  templateKey: "emotional-tension",
3029
- name: "我的默认情绪拉扯写法",
3030
- description: "适合关系推进、误读拉扯和情绪兑现类故事,先有一套能直接开写的关系型表达底座。"
3029
+ name: "开书快跑·关系拉扯型",
3030
+ description: "写给关系推进、误读拉扯类故事:先拿一套能直接开写的关系表达底子。"
3031
3031
  },
3032
3032
  {
3033
3033
  key: "starter-daily",
3034
3034
  templateKey: "immersive-daily",
3035
- name: "我的默认日常浸没写法",
3036
- description: "适合治愈、陪伴、生活经营和轻缓成长类故事,优先保证生活感和沉浸感。"
3035
+ name: "开书快跑·日常浸没型",
3036
+ description: "写给治愈、陪伴、生活经营、轻缓成长类故事:优先保证生活感与沉浸感。"
3037
3037
  }
3038
3038
  ];
3039
3039
  /** 根据指纹风险推荐仿写预设:low→imitate / medium→balanced / high→transfer。 */
@@ -3055,7 +3055,7 @@ function styleEngineSystemPrompt() {
3055
3055
  "6. 判断预设 preset:imitate=高保真仿写(保留全部特征),balanced=平衡(保留可迁移特征、降指纹),transfer=迁移(只取骨架、彻底去指纹)。",
3056
3056
  "7. 判断 fingerprintRisk:low/medium/high,表示照搬本样本会被识别为抄原作的风险。",
3057
3057
  "8. 输出必须是合法 JSON 对象,不要输出任何其他文字。",
3058
- "JSON 结构:",
3058
+ "输出结构:",
3059
3059
  "{\"proseRules\": [\"叙述视角与句式节奏规则\"], \"dialogueRules\": [\"台词风格规则\"], \"descriptionRules\": [\"描写密度与情绪表达规则\"], \"boundaries\": [\"表达边界\"], \"preset\": \"imitate|balanced|transfer\", \"fingerprintRisk\": \"low|medium|high\", \"writingGuidance\": [\"净化后的写作指引\"], \"forbiddenEntities\": [\"不可照搬的原作特有实体\"]}"
3060
3060
  ].join("\n");
3061
3061
  }
@@ -3068,7 +3068,7 @@ function styleFormulaSystemPrompt(depth) {
3068
3068
  "1. 聚焦 focusAreas 指定的重点(如 开场/对话/节奏/爽点密度/段落结构)。",
3069
3069
  "2. formula 用 Markdown 分小节;applyGuidance 说明生成/改写时如何套用(1-3 句)。",
3070
3070
  "3. 只输出合法 JSON 对象,不要任何其他文字。",
3071
- "JSON 结构:",
3071
+ "输出结构:",
3072
3072
  "{\"name\": \"公式名\", \"focusAreas\": [\"重点域\"], \"formula\": \"Markdown 公式正文\", \"applyGuidance\": \"套用指引\"}"
3073
3073
  ].join("\n");
3074
3074
  }
@@ -3202,10 +3202,29 @@ function countOccurrences(text, word) {
3202
3202
  }
3203
3203
  //#endregion
3204
3204
  //#region src/llm-live.ts
3205
+ /** 帧缓冲上限(新连接重放用)。 */
3205
3206
  const MAX_BUFFER = 400;
3207
+ /** Prompt 记录上限(环形,避免长跑吃内存)。 */
3208
+ const MAX_PROMPTS = 40;
3209
+ /** 单条 Prompt 记录的最大字符数(system + user 合计),超出截断并标记。 */
3210
+ const MAX_PROMPT_CHARS = 6e4;
3206
3211
  let buffer = [];
3207
3212
  let listeners = /* @__PURE__ */ new Set();
3208
3213
  let seq = 0;
3214
+ /** 进程内用量账本。 */
3215
+ const usageTotals = {
3216
+ calls: 0,
3217
+ failed: 0,
3218
+ inputTokens: 0,
3219
+ outputTokens: 0,
3220
+ reasoningTokens: 0,
3221
+ totalTokens: 0,
3222
+ elapsedMs: 0,
3223
+ callsWithoutUsage: 0,
3224
+ byLabel: /* @__PURE__ */ new Map()
3225
+ };
3226
+ /** Prompt 记录(按 sessionId,保留最近 MAX_PROMPTS 条)。 */
3227
+ const prompts = /* @__PURE__ */ new Map();
3209
3228
  function nextSessionId() {
3210
3229
  seq++;
3211
3230
  return `ll-${Date.now().toString(36)}-${seq}`;
@@ -3218,28 +3237,892 @@ function subscribeLiveFeed(listener) {
3218
3237
  };
3219
3238
  }
3220
3239
  function emitLive(frame) {
3240
+ if (frame.type === "session_completed") recordUsage(frame);
3221
3241
  buffer.push(frame);
3222
3242
  if (buffer.length > MAX_BUFFER) buffer = buffer.slice(-400);
3223
3243
  for (const l of listeners) l(frame);
3224
3244
  }
3245
+ /** 把 dsh-llm 的 usage 转成线上形状。 */
3246
+ function toLiveUsage(usage) {
3247
+ if (usage === void 0) return void 0;
3248
+ return {
3249
+ inputTokens: usage.inputTokens ?? 0,
3250
+ outputTokens: usage.outputTokens ?? 0,
3251
+ totalTokens: usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
3252
+ reasoningTokens: usage.reasoningTokens ?? 0,
3253
+ cacheReadTokens: usage.cacheReadTokens ?? 0
3254
+ };
3255
+ }
3256
+ /** 记一次已完成的调用(由 emitLive 内部调用)。 */
3257
+ function recordUsage(frame) {
3258
+ const failed = frame.phase === "failed" ? 1 : 0;
3259
+ const usage = frame.usage;
3260
+ usageTotals.calls += 1;
3261
+ usageTotals.failed += failed;
3262
+ usageTotals.elapsedMs += frame.elapsedMs ?? 0;
3263
+ if (usage === void 0) usageTotals.callsWithoutUsage += 1;
3264
+ else {
3265
+ usageTotals.inputTokens += usage.inputTokens;
3266
+ usageTotals.outputTokens += usage.outputTokens;
3267
+ usageTotals.reasoningTokens += usage.reasoningTokens ?? 0;
3268
+ usageTotals.totalTokens += usage.totalTokens ?? usage.inputTokens + usage.outputTokens;
3269
+ }
3270
+ const label = frame.label ?? "未标注调用";
3271
+ const bucket = usageTotals.byLabel.get(label) ?? {
3272
+ label,
3273
+ calls: 0,
3274
+ failed: 0,
3275
+ inputTokens: 0,
3276
+ outputTokens: 0,
3277
+ reasoningTokens: 0,
3278
+ elapsedMs: 0
3279
+ };
3280
+ bucket.calls += 1;
3281
+ bucket.failed += failed;
3282
+ bucket.elapsedMs += frame.elapsedMs ?? 0;
3283
+ if (usage !== void 0) {
3284
+ bucket.inputTokens += usage.inputTokens;
3285
+ bucket.outputTokens += usage.outputTokens;
3286
+ bucket.reasoningTokens += usage.reasoningTokens ?? 0;
3287
+ }
3288
+ usageTotals.byLabel.set(label, bucket);
3289
+ }
3290
+ /** 本次运行的用量汇总(按耗时降序排列用途,便于看谁最贵)。 */
3291
+ function liveUsage() {
3292
+ return {
3293
+ calls: usageTotals.calls,
3294
+ failed: usageTotals.failed,
3295
+ inputTokens: usageTotals.inputTokens,
3296
+ outputTokens: usageTotals.outputTokens,
3297
+ reasoningTokens: usageTotals.reasoningTokens,
3298
+ totalTokens: usageTotals.totalTokens,
3299
+ elapsedMs: usageTotals.elapsedMs,
3300
+ callsWithoutUsage: usageTotals.callsWithoutUsage,
3301
+ byLabel: [...usageTotals.byLabel.values()].sort((a, b) => b.inputTokens + b.outputTokens + b.reasoningTokens - (a.inputTokens + a.outputTokens + a.reasoningTokens))
3302
+ };
3303
+ }
3304
+ /** 清零用量账本(面板「重新计数」用)。 */
3305
+ function resetLiveUsage() {
3306
+ usageTotals.calls = 0;
3307
+ usageTotals.failed = 0;
3308
+ usageTotals.inputTokens = 0;
3309
+ usageTotals.outputTokens = 0;
3310
+ usageTotals.reasoningTokens = 0;
3311
+ usageTotals.totalTokens = 0;
3312
+ usageTotals.elapsedMs = 0;
3313
+ usageTotals.callsWithoutUsage = 0;
3314
+ usageTotals.byLabel.clear();
3315
+ }
3316
+ /** 记一条 Prompt(超长截断并标记)。 */
3317
+ function recordPrompt(input) {
3318
+ const system = input.system ?? "";
3319
+ const user = input.user ?? "";
3320
+ const chars = system.length + user.length;
3321
+ let record = {
3322
+ sessionId: input.sessionId,
3323
+ label: input.label,
3324
+ model: input.model,
3325
+ at: (/* @__PURE__ */ new Date()).toISOString(),
3326
+ system,
3327
+ user,
3328
+ chars
3329
+ };
3330
+ if (chars > MAX_PROMPT_CHARS) {
3331
+ const userPart = user.slice(0, MAX_PROMPT_CHARS);
3332
+ const rest = MAX_PROMPT_CHARS - userPart.length;
3333
+ record = {
3334
+ ...record,
3335
+ user: userPart,
3336
+ system: system.slice(0, Math.max(0, rest)),
3337
+ truncated: true
3338
+ };
3339
+ }
3340
+ prompts.set(record.sessionId, record);
3341
+ while (prompts.size > MAX_PROMPTS) {
3342
+ const oldest = prompts.keys().next().value;
3343
+ if (oldest === void 0) break;
3344
+ prompts.delete(oldest);
3345
+ }
3346
+ return record;
3347
+ }
3348
+ /** 取一条 Prompt 记录(不存在返回 undefined)。 */
3349
+ function livePrompt(sessionId) {
3350
+ return prompts.get(sessionId);
3351
+ }
3352
+ /**
3353
+ * 开始一次 LLM 调用:发 session_started、记 Prompt、起计时。
3354
+ *
3355
+ * @param input 用途/模型/实际发送的 system 与 user。
3356
+ * @returns 打点句柄(结束时交给 endLiveCall)。
3357
+ */
3358
+ function beginLiveCall(input) {
3359
+ const handle = {
3360
+ sessionId: nextSessionId(),
3361
+ startedAt: Date.now(),
3362
+ label: input.label,
3363
+ model: input.model
3364
+ };
3365
+ const record = recordPrompt({
3366
+ sessionId: handle.sessionId,
3367
+ label: input.label,
3368
+ model: input.model,
3369
+ system: input.system,
3370
+ user: input.user
3371
+ });
3372
+ emitLive({
3373
+ type: "session_started",
3374
+ sessionId: handle.sessionId,
3375
+ label: input.label,
3376
+ model: input.model,
3377
+ at: (/* @__PURE__ */ new Date()).toISOString(),
3378
+ context: { interactionId: handle.sessionId },
3379
+ hasPrompt: true,
3380
+ promptChars: record.chars
3381
+ });
3382
+ emitLive({
3383
+ type: "phase_changed",
3384
+ sessionId: handle.sessionId,
3385
+ phase: "streaming",
3386
+ phaseMessage: "模型正在返回内容",
3387
+ at: (/* @__PURE__ */ new Date()).toISOString()
3388
+ });
3389
+ return handle;
3390
+ }
3391
+ /** 首字打点(调用方在收到第一个文本增量时调用,只记第一次)。 */
3392
+ function markFirstToken(handle) {
3393
+ if (handle.firstTokenMs === void 0) handle.firstTokenMs = Date.now() - handle.startedAt;
3394
+ }
3395
+ /**
3396
+ * 结束一次 LLM 调用:从 assembler 取用量,发 session_completed(含 token/耗时/首字)。
3397
+ *
3398
+ * @param handle beginLiveCall 的句柄。
3399
+ * @param assembler 本次调用的 BlockAssembler。
3400
+ * @param extra 字符数/预览/失败信息等。
3401
+ */
3402
+ function endLiveCall(handle, assembler, extra = {}) {
3403
+ const usage = toLiveUsage(assembler.usage);
3404
+ emitLive({
3405
+ type: "session_completed",
3406
+ sessionId: handle.sessionId,
3407
+ label: handle.label,
3408
+ model: handle.model,
3409
+ at: (/* @__PURE__ */ new Date()).toISOString(),
3410
+ totalChars: extra.chars,
3411
+ preview: extra.preview,
3412
+ phase: extra.phase ?? "completed",
3413
+ error: extra.error,
3414
+ usage,
3415
+ elapsedMs: Date.now() - handle.startedAt,
3416
+ firstTokenMs: handle.firstTokenMs
3417
+ });
3418
+ }
3419
+ //#endregion
3420
+ //#region src/llm-retry.ts
3421
+ /**
3422
+ * 模型失败恢复规则(纯逻辑,可离线单测)。
3423
+ *
3424
+ * 解决的问题:批量连写时,主模型偶发限流/超时/额度耗尽会让**整批中断**,
3425
+ * 而换一个模型重试一次往往就能过去。规则:
3426
+ * 1) 只对「可重试」错误切换模型——用户主动取消、参数/契约类错误不重试;
3427
+ * 2) 备用模型为空或与主模型相同则不切换;
3428
+ * 3) 备用模型也失败时,抛**原始错误**(保留主因,不掩盖现场);
3429
+ * 4) 每次调用最多切换一次。
3430
+ */
3431
+ /** 可重试错误特征(网络/超时/限流/服务端/额度类)。 */
3432
+ const RETRYABLE_PATTERNS = [
3433
+ /失败/,
3434
+ /超时/,
3435
+ /timeout/i,
3436
+ /network|fetch failed|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|EAI_AGAIN|socket hang up|terminated/i,
3437
+ /频繁|限流|rate\s*limit|too many requests|\b429\b|\b5\d\d\b/i,
3438
+ /overload|unavailable|capacity|服务不可用|繁忙/i,
3439
+ /quota|balance|credit|insufficient|额度|余额/i
3440
+ ];
3441
+ /** 明确不可重试的特征(用户取消 / 契约类)。 */
3442
+ const NON_RETRYABLE_PATTERNS = [/\baborted\b|已取消|用户取消/i, /不在计划中|尚未生成|参数|非法|无效|需要/i];
3443
+ /**
3444
+ * 判断一次 LLM 失败是否值得换模型重试。
3445
+ *
3446
+ * @param error 捕获到的异常。
3447
+ * @returns 可重试时为 true。
3448
+ */
3449
+ function isRetryableLlmError(error) {
3450
+ const message = error instanceof Error ? error.message : String(error);
3451
+ if (NON_RETRYABLE_PATTERNS.some((re) => re.test(message))) return false;
3452
+ return RETRYABLE_PATTERNS.some((re) => re.test(message));
3453
+ }
3454
+ /**
3455
+ * 判断是否应当切换到备用模型。
3456
+ *
3457
+ * @param fallbackModel 配置的备用模型(空串 = 未配置)。
3458
+ * @param primaryModel 本次实际使用的主模型。
3459
+ * @param error 捕获到的异常。
3460
+ * @returns 应当切换时为 true。
3461
+ */
3462
+ function shouldSwitchModel(fallbackModel, primaryModel, error) {
3463
+ const fallback = (fallbackModel ?? "").trim();
3464
+ if (fallback === "" || fallback === primaryModel) return false;
3465
+ return isRetryableLlmError(error);
3466
+ }
3467
+ /**
3468
+ * 用主模型执行;失败且满足切换条件时,用备用模型再执行一次。
3469
+ *
3470
+ * @param input 主模型 / 备用模型 / 实际执行函数(接收本次要用的模型 id)。
3471
+ * @returns 执行结果(备用模型成功时返回其结果)。
3472
+ * @throws 主模型失败且未切换时抛原错误;切换后仍失败时同样抛**主模型的原始错误**。
3473
+ */
3474
+ async function withModelFallback(input, run) {
3475
+ try {
3476
+ return await run(input.model);
3477
+ } catch (error) {
3478
+ const fallback = (input.fallbackModel ?? "").trim();
3479
+ if (fallback === "" || fallback === input.model || !isRetryableLlmError(error)) throw error;
3480
+ input.onSwitch?.(input.model, fallback, error);
3481
+ try {
3482
+ return await run(fallback);
3483
+ } catch {
3484
+ throw error;
3485
+ }
3486
+ }
3487
+ }
3488
+ /**
3489
+ * 本项目设计的参考曲线形状(0-100 的采样序列)。
3490
+ * 只描述"形状",不含任何外部数值表。
3491
+ */
3492
+ const REFERENCE_SHAPES = {
3493
+ escalation: [
3494
+ 20,
3495
+ 26,
3496
+ 34,
3497
+ 32,
3498
+ 44,
3499
+ 52,
3500
+ 48,
3501
+ 62,
3502
+ 70,
3503
+ 66,
3504
+ 82,
3505
+ 92
3506
+ ],
3507
+ suspense: [
3508
+ 30,
3509
+ 42,
3510
+ 38,
3511
+ 50,
3512
+ 46,
3513
+ 58,
3514
+ 62,
3515
+ 56,
3516
+ 70,
3517
+ 78,
3518
+ 86,
3519
+ 74
3520
+ ],
3521
+ wave: [
3522
+ 45,
3523
+ 30,
3524
+ 55,
3525
+ 34,
3526
+ 62,
3527
+ 40,
3528
+ 70,
3529
+ 46,
3530
+ 78,
3531
+ 52,
3532
+ 84,
3533
+ 60
3534
+ ],
3535
+ frontLoad: [
3536
+ 78,
3537
+ 66,
3538
+ 58,
3539
+ 50,
3540
+ 46,
3541
+ 44,
3542
+ 42,
3543
+ 44,
3544
+ 46,
3545
+ 48,
3546
+ 50,
3547
+ 52
3548
+ ],
3549
+ flat: [
3550
+ 40,
3551
+ 42,
3552
+ 44,
3553
+ 42,
3554
+ 46,
3555
+ 44,
3556
+ 48,
3557
+ 46,
3558
+ 50,
3559
+ 48,
3560
+ 52,
3561
+ 50
3562
+ ]
3563
+ };
3564
+ /** 参考曲线的中文标签(面板展示)。 */
3565
+ const TENSION_PRESET_LABELS = {
3566
+ escalation: "递进爬升",
3567
+ suspense: "悬疑加压",
3568
+ wave: "波浪呼吸",
3569
+ frontLoad: "前紧后稳",
3570
+ flat: "平稳推进",
3571
+ custom: "自定义"
3572
+ };
3573
+ /** 全部可选预设(UI 下拉用)。 */
3574
+ const TENSION_PRESETS = [
3575
+ "escalation",
3576
+ "suspense",
3577
+ "wave",
3578
+ "frontLoad",
3579
+ "flat",
3580
+ "custom"
3581
+ ];
3582
+ /** 把参考形状按章节数重采样(最近邻,保持形状不引入平滑失真)。 */
3583
+ function referenceCurve(preset, chapterCount) {
3584
+ if (chapterCount <= 0) return [];
3585
+ if (preset === "custom") return [];
3586
+ const shape = REFERENCE_SHAPES[preset];
3587
+ if (chapterCount === 1) return [shape[Math.floor(shape.length / 2)] ?? 50];
3588
+ return Array.from({ length: chapterCount }, (_, i) => {
3589
+ const pos = i / (chapterCount - 1) * (shape.length - 1);
3590
+ const lo = Math.floor(pos);
3591
+ const hi = Math.min(shape.length - 1, lo + 1);
3592
+ const t = pos - lo;
3593
+ const a = shape[lo] ?? 50;
3594
+ const b = shape[hi] ?? a;
3595
+ return Math.round(a + (b - a) * t);
3596
+ });
3597
+ }
3598
+ /** 读一章的目标张力(未设置时返回 undefined)。 */
3599
+ function targetTension(chapter) {
3600
+ return typeof chapter.tension === "number" ? clampTension(chapter.tension) : void 0;
3601
+ }
3602
+ /** 读一章的实际张力(审稿时模型打分,未设置时返回 undefined)。 */
3603
+ function actualTension(chapter) {
3604
+ const fromReview = chapter.review?.tension;
3605
+ if (typeof fromReview === "number") return clampTension(fromReview);
3606
+ return typeof chapter.tensionActual === "number" ? clampTension(chapter.tensionActual) : void 0;
3607
+ }
3608
+ /** 收敛到合法区间并取整。 */
3609
+ function clampTension(value) {
3610
+ if (!Number.isFinite(value)) return 0;
3611
+ return Math.max(0, Math.min(100, Math.round(value)));
3612
+ }
3613
+ /**
3614
+ * 渲染注入写作提示词的「本章张力」块。
3615
+ *
3616
+ * @param project 项目状态。
3617
+ * @param chapterNo 本章章号。
3618
+ * @returns 提示词片段;没有张力信息时返回空串。
3619
+ */
3620
+ function renderTensionBlock(project, chapterNo) {
3621
+ const preset = project.tensionCurve?.preset;
3622
+ const chapters = [...project.chapters ?? []].sort((a, b) => a.no - b.no);
3623
+ const idx = chapters.findIndex((c) => c.no === chapterNo);
3624
+ if (idx === -1) return "";
3625
+ const target = targetTension(chapters[idx]);
3626
+ const prev = idx > 0 ? targetTension(chapters[idx - 1]) : void 0;
3627
+ if (target === void 0 && prev === void 0) return "";
3628
+ const ref = preset !== void 0 && preset !== "custom" ? referenceCurve(preset, chapters.length)[idx] : void 0;
3629
+ const parts = [];
3630
+ if (target !== void 0) parts.push(`本章目标张力 ${target}/100`);
3631
+ if (prev !== void 0 && target !== void 0) {
3632
+ const delta = target - prev;
3633
+ parts.push(delta >= 10 ? "比上一章明显更紧" : delta <= -10 ? "比上一章明显放松(给读者呼吸口)" : "与上一章持平");
3634
+ }
3635
+ if (ref !== void 0) parts.push(`参考曲线(${TENSION_PRESET_LABELS[preset ?? "custom"]})在此处约为 ${ref}`);
3636
+ if (parts.length === 0) return "";
3637
+ return [
3638
+ "==================== 本章张力(写作时把握松紧) ====================",
3639
+ parts.join(";") + "。",
3640
+ "低张力段用于铺垫关系、交代信息、留呼吸;高张力段用于对抗、揭示、抉择与代价。",
3641
+ "同一章内可以有起伏,但整章的平均松紧要贴近目标值;不要连续多章都停在同一个紧张度。"
3642
+ ].join("\n");
3643
+ }
3644
+ /**
3645
+ * 规则核对张力曲线(不调模型)。
3646
+ *
3647
+ * 检查四类曲线级问题:
3648
+ * 1) 实际与目标偏差过大(>25);
3649
+ * 2) 连续多章几乎同值(≥4 章落差 <6,平台期无起伏);
3650
+ * 3) 高点之后没有回落(连续 ≥3 章处在高位 ≥80);
3651
+ * 4) 长期低位(连续 ≥5 章 <40,容易拖沓)。
3652
+ *
3653
+ * @param project 项目状态。
3654
+ * @returns 问题清单(可能为空)。
3655
+ */
3656
+ function detectTensionIssues(project) {
3657
+ const values = [...project.chapters ?? []].sort((a, b) => a.no - b.no).map((c) => ({
3658
+ no: c.no,
3659
+ target: targetTension(c),
3660
+ actual: actualTension(c)
3661
+ }));
3662
+ if (values.filter((v) => v.target !== void 0 || v.actual !== void 0).length < 3) return [];
3663
+ const issues = [];
3664
+ for (const v of values) {
3665
+ if (v.target === void 0 || v.actual === void 0) continue;
3666
+ const gap = v.actual - v.target;
3667
+ if (Math.abs(gap) > 25) issues.push({
3668
+ severity: Math.abs(gap) > 40 ? "high" : "medium",
3669
+ chapters: [v.no],
3670
+ item: `第${v.no}章张力偏差较大:目标 ${v.target},实际 ${v.actual}(${gap > 0 ? "比目标更紧" : "比目标更松"})`,
3671
+ suggestion: gap > 0 ? "本章对抗/揭示密度偏高,考虑把部分冲突后移,留出呼吸段。" : "本章推进偏平,考虑加一处对抗、揭示或代价。"
3672
+ });
3673
+ }
3674
+ let run = 1;
3675
+ for (let i = 1; i < values.length; i++) {
3676
+ const a = values[i - 1].target ?? values[i - 1].actual;
3677
+ const b = values[i].target ?? values[i].actual;
3678
+ if (a !== void 0 && b !== void 0 && Math.abs(b - a) < 6) {
3679
+ run++;
3680
+ if (run === 4) {
3681
+ const from = values[i - run + 1].no;
3682
+ issues.push({
3683
+ severity: "medium",
3684
+ chapters: [from, values[i].no],
3685
+ item: `第${from}-${values[i].no}章张力几乎无变化(连续 ${run} 章)`,
3686
+ suggestion: "安排一次明显抬升或一次释放,让曲线有起伏,避免读者疲劳。"
3687
+ });
3688
+ }
3689
+ } else run = 1;
3690
+ }
3691
+ let highRun = 0;
3692
+ for (const v of values) {
3693
+ const val = v.target ?? v.actual;
3694
+ if (val !== void 0 && val >= 80) {
3695
+ highRun++;
3696
+ if (highRun === 3) issues.push({
3697
+ severity: "medium",
3698
+ chapters: [v.no - 2, v.no],
3699
+ item: `连续 ${highRun} 章处在高位(≥80),缺少呼吸口`,
3700
+ suggestion: "高潮之后补一章缓冲(关系推进、信息消化或日常),再进入下一轮对抗。"
3701
+ });
3702
+ } else highRun = 0;
3703
+ }
3704
+ let lowRun = 0;
3705
+ for (const v of values) {
3706
+ const val = v.target ?? v.actual;
3707
+ if (val !== void 0 && val < 40) {
3708
+ lowRun++;
3709
+ if (lowRun === 5) issues.push({
3710
+ severity: "high",
3711
+ chapters: [v.no - 4, v.no],
3712
+ item: `连续 ${lowRun} 章张力低于 40,推进可能偏慢`,
3713
+ suggestion: "提前兑现一个目标、抛出一个新风险,或让配角线产生冲撞。"
3714
+ });
3715
+ } else lowRun = 0;
3716
+ }
3717
+ return issues;
3718
+ }
3719
+ /**
3720
+ * 组装面板需要的曲线数据(章节号 / 目标 / 实际 / 参考)。
3721
+ *
3722
+ * @param project 项目状态。
3723
+ * @returns 逐章数据点与预设名。
3724
+ */
3725
+ function buildCurveData(project) {
3726
+ const preset = project.tensionCurve?.preset ?? "escalation";
3727
+ const chapters = [...project.chapters ?? []].sort((a, b) => a.no - b.no);
3728
+ const custom = project.tensionCurve?.custom ?? [];
3729
+ const reference = preset === "custom" ? custom : referenceCurve(preset, chapters.length);
3730
+ return {
3731
+ preset,
3732
+ points: chapters.map((c, i) => {
3733
+ const point = {
3734
+ no: c.no,
3735
+ title: c.title
3736
+ };
3737
+ const t = targetTension(c);
3738
+ const a = actualTension(c);
3739
+ if (t !== void 0) point.target = t;
3740
+ if (a !== void 0) point.actual = a;
3741
+ if (reference[i] !== void 0) point.reference = reference[i];
3742
+ return point;
3743
+ })
3744
+ };
3745
+ }
3746
+ //#endregion
3747
+ //#region src/prompt-slots.ts
3748
+ /** 允许作者编辑的槽位(白名单;不在此列表的提示词一律不可改)。 */
3749
+ const PROMPT_SLOTS = [
3750
+ {
3751
+ id: "styleExtra",
3752
+ label: "文风补充",
3753
+ hint: "追加到写作提示词末尾的风格偏好(句式、叙述习惯、用词倾向)。",
3754
+ placeholder: "例如:多用短句和对话推进,少用成语;环境描写只写能影响动作的部分。",
3755
+ maxChars: 1200
3756
+ },
3757
+ {
3758
+ id: "antiAiExtra",
3759
+ label: "反 AI 补充",
3760
+ hint: "本书额外的\"别这么写\"清单,与内置反 AI 规则叠加(不能覆盖内置规则)。",
3761
+ placeholder: "例如:不要每段都用比喻;不要用\"空气中弥漫着\"这类句式。",
3762
+ maxChars: 1200
3763
+ },
3764
+ {
3765
+ id: "dialogueExtra",
3766
+ label: "对话补充",
3767
+ hint: "角色说话方式的额外约束,写入时会与角色卡一起注入。",
3768
+ placeholder: "例如:长辈说话带方言词;主角对熟人用绰号称呼。",
3769
+ maxChars: 800
3770
+ },
3771
+ {
3772
+ id: "endingHookExtra",
3773
+ label: "章末钩子偏好",
3774
+ hint: "对结尾钩子的额外要求(与章节计划里的 endingHook 叠加)。",
3775
+ placeholder: "例如:结尾尽量落在一个未闭合的选择上,而不是新出现的敌人。",
3776
+ maxChars: 600
3777
+ }
3778
+ ];
3779
+ /** 槽位 id → 规格。 */
3780
+ const SLOT_BY_ID = new Map(PROMPT_SLOTS.map((s) => [s.id, s]));
3781
+ /**
3782
+ * 读取并规整项目里的槽位内容(去空白、按上限截断)。
3783
+ *
3784
+ * @param project 项目状态。
3785
+ * @returns 非空槽位列表(保持 PROMPT_SLOTS 的声明顺序)。
3786
+ */
3787
+ function readPromptSlots(project) {
3788
+ const stored = project.promptSlots ?? {};
3789
+ const out = [];
3790
+ for (const spec of PROMPT_SLOTS) {
3791
+ const raw = (stored[spec.id] ?? "").trim();
3792
+ if (raw === "") continue;
3793
+ const truncated = raw.length > spec.maxChars;
3794
+ out.push({
3795
+ id: spec.id,
3796
+ label: spec.label,
3797
+ text: truncated ? raw.slice(0, spec.maxChars) : raw,
3798
+ truncated,
3799
+ chars: raw.length
3800
+ });
3801
+ }
3802
+ return out;
3803
+ }
3804
+ /**
3805
+ * 把槽位渲染成注入提示词的段落。
3806
+ *
3807
+ * @param project 项目状态。
3808
+ * @returns 提示词片段;无槽位时返回空串。
3809
+ */
3810
+ function renderPromptSlots(project) {
3811
+ const slots = readPromptSlots(project);
3812
+ if (slots.length === 0) return "";
3813
+ const lines = ["==================== 作者偏好(本书自定义,优先级低于道藏/红线/合规) ===================="];
3814
+ for (const slot of slots) lines.push(`【${slot.label}】${slot.text}${slot.truncated ? `(已按 ${SLOT_BY_ID.get(slot.id)?.maxChars ?? 0} 字上限截断,原 ${slot.chars} 字)` : ""}`);
3815
+ lines.push("以上偏好只影响表达方式;与道藏、写作红线、内容合规红线冲突时,一律以后者为准。");
3816
+ return lines.join("\n");
3817
+ }
3818
+ /**
3819
+ * 校验并规整一次槽位写入。
3820
+ *
3821
+ * @param id 槽位 id。
3822
+ * @param value 内容(空串表示清空该槽位)。
3823
+ * @returns 归一化后的内容。
3824
+ * @throws 槽位 id 不在白名单时抛错。
3825
+ */
3826
+ function normalizeSlotValue(id, value) {
3827
+ if (SLOT_BY_ID.get(id) === void 0) throw new Error(`未知提示词槽位:${id}`);
3828
+ return value.replace(/\r\n/g, "\n").trim();
3829
+ }
3830
+ //#endregion
3831
+ //#region src/timeline.ts
3832
+ /** 时间线矛盾初筛的最少事件数(太少没有意义)。 */
3833
+ const MIN_EVENTS_FOR_CHECK = 2;
3834
+ /** 归一化事件(清洗字符串、补 id/时间戳)。 */
3835
+ function normalizeTimelineEvent(raw, chapterNo, index, now = (/* @__PURE__ */ new Date()).toISOString()) {
3836
+ const characters = Array.isArray(raw.characters) ? raw.characters.filter((c) => typeof c === "string" && c.trim() !== "") : [];
3837
+ return {
3838
+ id: typeof raw.id === "string" && raw.id !== "" ? raw.id : `tl-${chapterNo}-${index}-${Math.random().toString(36).slice(2, 8)}`,
3839
+ chapterNo: typeof raw.chapterNo === "number" ? raw.chapterNo : chapterNo,
3840
+ time: (raw.time ?? "").trim(),
3841
+ order: typeof raw.order === "number" && Number.isFinite(raw.order) ? raw.order : void 0,
3842
+ place: (raw.place ?? "").trim(),
3843
+ characters: characters.map((c) => c.trim()),
3844
+ event: (raw.event ?? "").trim(),
3845
+ source: raw.source === "manual" ? "manual" : "extracted",
3846
+ createdAt: raw.createdAt ?? now
3847
+ };
3848
+ }
3849
+ /** 时间线排序:先章节、再 order(缺省排在该章末尾)、最后 createdAt。 */
3850
+ function sortTimeline(events) {
3851
+ return [...events].sort((a, b) => {
3852
+ if (a.chapterNo !== b.chapterNo) return a.chapterNo - b.chapterNo;
3853
+ const ao = a.order ?? Number.MAX_SAFE_INTEGER;
3854
+ const bo = b.order ?? Number.MAX_SAFE_INTEGER;
3855
+ if (ao !== bo) return ao - bo;
3856
+ return a.createdAt.localeCompare(b.createdAt);
3857
+ });
3858
+ }
3859
+ /**
3860
+ * 渲染注入到「写作/规划」提示词的时间线锚点块。
3861
+ *
3862
+ * @param project 项目状态。
3863
+ * @param beforeChapter 只注入该章之前的锚点(含该章本身不注入,避免剧透)。
3864
+ * @returns 提示词片段(无事件时返回空串)。带显式截断声明。
3865
+ */
3866
+ function renderTimelineBlock(project, beforeChapter) {
3867
+ const all = sortTimeline(project.timeline ?? []).filter((e) => e.chapterNo < beforeChapter);
3868
+ if (all.length === 0) return "";
3869
+ const shown = all.slice(-12);
3870
+ const omitted = all.length - shown.length;
3871
+ return `${omitted > 0 ? `【故事时间线(共 ${all.length} 条,此处只列最近 ${shown.length} 条;更早 ${omitted} 条未列出)】` : `【故事时间线(共 ${shown.length} 条)】`}\n${shown.map((e) => {
3872
+ const parts = [`[第${e.chapterNo}章]`];
3873
+ if (e.time !== "") parts.push(e.time);
3874
+ if (e.place !== "") parts.push(`@${e.place}`);
3875
+ if (e.characters.length > 0) parts.push(`(${e.characters.join("、")})`);
3876
+ parts.push(e.event);
3877
+ return "- " + parts.join(" ");
3878
+ }).join("\n")}\n写作时必须与前文时间顺序、地点衔接一致;不得让时间倒流或让人物瞬移。`;
3879
+ }
3880
+ /**
3881
+ * 规则初筛时间线矛盾(不调模型,瞬时计算)。
3882
+ *
3883
+ * 覆盖三类最容易犯又最容易用规则抓到的错:
3884
+ * 1) 同一章内事件顺序号倒退;
3885
+ * 2) 全书顺序号在章节推进后倒退(时间倒流);
3886
+ * 3) 相邻两章之间地点瞬移(同一角色在未交代移动的情况下跨地点)。
3887
+ *
3888
+ * @param project 项目状态。
3889
+ * @returns 问题清单(可能为空)。
3890
+ */
3891
+ function detectTimelineIssues(project) {
3892
+ const stored = project.timeline ?? [];
3893
+ if (stored.length < MIN_EVENTS_FOR_CHECK) return [];
3894
+ const issues = [];
3895
+ const byChapter = /* @__PURE__ */ new Map();
3896
+ for (const e of stored) {
3897
+ const list = byChapter.get(e.chapterNo) ?? [];
3898
+ list.push(e);
3899
+ byChapter.set(e.chapterNo, list);
3900
+ }
3901
+ for (const [no, list] of byChapter) {
3902
+ let lastOrder;
3903
+ for (const e of list) {
3904
+ if (e.order === void 0) continue;
3905
+ if (lastOrder !== void 0 && e.order < lastOrder) issues.push({
3906
+ severity: "medium",
3907
+ chapters: [no],
3908
+ item: `第${no}章内时间顺序倒退:事件「${e.event}」的顺序号 ${e.order} 小于前一条 ${lastOrder}`,
3909
+ suggestion: "检查这两条事件的先后关系,修正 order 或改写正文的时间描述。"
3910
+ });
3911
+ lastOrder = e.order;
3912
+ }
3913
+ }
3914
+ const chapterMax = [];
3915
+ for (const [no, list] of [...byChapter].sort((a, b) => a[0] - b[0])) {
3916
+ const withOrder = list.filter((e) => e.order !== void 0);
3917
+ if (withOrder.length === 0) continue;
3918
+ const maxEvent = withOrder.reduce((best, e) => (e.order ?? 0) > (best.order ?? 0) ? e : best, withOrder[0]);
3919
+ chapterMax.push({
3920
+ no,
3921
+ max: maxEvent.order ?? 0,
3922
+ event: maxEvent
3923
+ });
3924
+ }
3925
+ for (let i = 1; i < chapterMax.length; i++) {
3926
+ const prev = chapterMax[i - 1];
3927
+ const cur = chapterMax[i];
3928
+ if (cur.max < prev.max) issues.push({
3929
+ severity: "high",
3930
+ chapters: [prev.no, cur.no],
3931
+ item: `时间倒流:第${cur.no}章(${cur.event.time || "未标注时间"})早于第${prev.no}章(${prev.event.time || "未标注时间"})`,
3932
+ suggestion: "确认是否需要补写过渡(回忆/插叙)或调整章节顺序;若是有意插叙,请在正文中明确标注。"
3933
+ });
3934
+ }
3935
+ const seenPlaces = /* @__PURE__ */ new Set();
3936
+ let prevPlace = "";
3937
+ const lastChapterOf = /* @__PURE__ */ new Map();
3938
+ for (const e of sortTimeline(stored)) if (e.place !== "") {
3939
+ if (prevPlace !== "" && e.place !== prevPlace && !seenPlaces.has(e.place)) {
3940
+ const moved = e.characters.filter((c) => lastChapterOf.get(c) !== void 0 && lastChapterOf.get(c) !== e.place);
3941
+ if (moved.length > 0) issues.push({
3942
+ severity: "medium",
3943
+ chapters: [e.chapterNo],
3944
+ item: `地点衔接存疑:${moved.join("、")} 上一场景在「${lastChapterOf.get(moved[0]) ?? "?"}」,本章直接出现在「${e.place}」`,
3945
+ suggestion: "补一句移动/时间流逝的交代,或确认是否漏写了赶路段落。"
3946
+ });
3947
+ }
3948
+ seenPlaces.add(e.place);
3949
+ prevPlace = e.place;
3950
+ for (const c of e.characters) lastChapterOf.set(c, e.place);
3951
+ }
3952
+ return issues;
3953
+ }
3954
+ //#endregion
3955
+ //#region src/snapshots.ts
3956
+ /**
3957
+ * 章节历史版本(快照):覆盖写入前自动留存正文,支持列表 / 回滚 / 清理。
3958
+ *
3959
+ * 为什么需要它:生成、按意见重写、去 AI 味润色、采纳草稿都会**覆盖** `第N章_*.md`,
3960
+ * 一旦改差(尤其是自动修订把好稿改坏)就没有退路。这里在覆盖前把旧稿存到
3961
+ * `<书目录>/snapshots/`,并维护一份 `snapshots/index.json` 索引。
3962
+ *
3963
+ * 纯 Node 文件操作,不依赖模型;路由层只做参数校验与响应。
3964
+ */
3965
+ /** 快照目录名(书目录下)。 */
3966
+ const SNAPSHOT_DIR = "snapshots";
3967
+ /** 索引文件名。 */
3968
+ const INDEX_FILE = "index.json";
3969
+ /** 快照目录绝对路径。 */
3970
+ function snapshotDir(outputDir) {
3971
+ return join(outputDir, SNAPSHOT_DIR);
3972
+ }
3973
+ /** 读取索引(缺失/损坏时返回空数组)。 */
3974
+ function loadSnapshots(outputDir) {
3975
+ const file = join(snapshotDir(outputDir), INDEX_FILE);
3976
+ if (!existsSync(file)) return [];
3977
+ try {
3978
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
3979
+ return Array.isArray(parsed.snapshots) ? parsed.snapshots : [];
3980
+ } catch {
3981
+ return [];
3982
+ }
3983
+ }
3984
+ /** 写回索引。 */
3985
+ function saveSnapshots(outputDir, snapshots) {
3986
+ const dir = snapshotDir(outputDir);
3987
+ mkdirSync(dir, { recursive: true });
3988
+ writeFileSync(join(dir, INDEX_FILE), JSON.stringify({ snapshots }, null, 2), "utf8");
3989
+ }
3990
+ /** 快照占用字节数(索引 + 正文)。 */
3991
+ function snapshotsBytes(outputDir) {
3992
+ const dir = snapshotDir(outputDir);
3993
+ if (!existsSync(dir)) return 0;
3994
+ let total = 0;
3995
+ try {
3996
+ for (const name of readdirSync(dir)) try {
3997
+ total += statSync(join(dir, name)).size;
3998
+ } catch {}
3999
+ } catch {}
4000
+ return total;
4001
+ }
4002
+ /** 生成快照文件名(章节 + 时间戳 + 原因短标识)。 */
4003
+ function snapshotFileName(chapterNo, reason, at) {
4004
+ const stamp = at.replace(/[:.]/g, "-");
4005
+ const tag = reason.replace(/[^\w\u4e00-\u9fa5-]+/g, "").slice(0, 12) || "snap";
4006
+ return `ch${String(chapterNo).padStart(4, "0")}_${stamp}_${tag}.md`;
4007
+ }
4008
+ /**
4009
+ * 为一个章节留存快照(覆盖写入前调用)。
4010
+ *
4011
+ * @param outputDir 书目录。
4012
+ * @param chapter 章节(需要已存在正文文件)。
4013
+ * @param reason 留存原因(生成覆盖 / 采纳草稿 / 修订 / 润色 / 手工)。
4014
+ * @param keep 每章保留数量(超出淘汰最旧)。
4015
+ * @returns 新建的快照;无正文文件时返回 undefined(不产生空快照)。
4016
+ */
4017
+ function createSnapshot(outputDir, chapter, reason, keep = 20) {
4018
+ if (chapter.file === void 0 || chapter.file === "") return void 0;
4019
+ const source = join(outputDir, chapter.file);
4020
+ if (!existsSync(source)) return void 0;
4021
+ const at = (/* @__PURE__ */ new Date()).toISOString();
4022
+ const dir = snapshotDir(outputDir);
4023
+ mkdirSync(dir, { recursive: true });
4024
+ const name = snapshotFileName(chapter.no, reason, at);
4025
+ const target = join(dir, name);
4026
+ try {
4027
+ copyFileSync(source, target);
4028
+ } catch {
4029
+ return;
4030
+ }
4031
+ const chars = chapter.chars ?? 0;
4032
+ const snapshot = {
4033
+ id: name,
4034
+ chapterNo: chapter.no,
4035
+ file: `${SNAPSHOT_DIR}/${name}`,
4036
+ reason,
4037
+ chars,
4038
+ at
4039
+ };
4040
+ saveSnapshots(outputDir, pruneList([...loadSnapshots(outputDir), snapshot], keep));
4041
+ return snapshot;
4042
+ }
4043
+ /** 按「每章保留最近 keep 个」裁剪列表,并删除被淘汰的文件。 */
4044
+ function pruneList(snapshots, keep) {
4045
+ const byChapter = /* @__PURE__ */ new Map();
4046
+ for (const s of snapshots) {
4047
+ const list = byChapter.get(s.chapterNo) ?? [];
4048
+ list.push(s);
4049
+ byChapter.set(s.chapterNo, list);
4050
+ }
4051
+ const kept = [];
4052
+ for (const list of byChapter.values()) {
4053
+ list.sort((a, b) => b.at.localeCompare(a.at));
4054
+ kept.push(...list.slice(0, Math.max(1, keep)));
4055
+ }
4056
+ return kept.sort((a, b) => a.at.localeCompare(b.at));
4057
+ }
4058
+ /**
4059
+ * 回滚到某个快照:把快照内容写回章节正文文件。
4060
+ *
4061
+ * 回滚前会**先给当前正文留一份快照**(reason=回滚前自动留存),所以回滚本身也可撤销。
4062
+ *
4063
+ * @param outputDir 书目录。
4064
+ * @param chapter 目标章节。
4065
+ * @param snapshotId 快照 id。
4066
+ * @returns 回滚后的字符数。
4067
+ * @throws 快照不存在或章节无正文文件时抛错。
4068
+ */
4069
+ function restoreSnapshot(outputDir, chapter, snapshotId) {
4070
+ const snapshot = loadSnapshots(outputDir).find((s) => s.id === snapshotId);
4071
+ if (snapshot === void 0) throw new Error(`快照不存在:${snapshotId}`);
4072
+ if (snapshot.chapterNo !== chapter.no) throw new Error(`快照属于第 ${snapshot.chapterNo} 章,与目标第 ${chapter.no} 章不符`);
4073
+ const source = join(outputDir, snapshot.file);
4074
+ if (!existsSync(source)) throw new Error(`快照文件已丢失:${snapshot.file}`);
4075
+ if (chapter.file === void 0 || chapter.file === "") throw new Error(`第 ${chapter.no} 章没有正文文件,无法回滚`);
4076
+ createSnapshot(outputDir, chapter, "回滚前留存");
4077
+ const body = readFileSync(source, "utf8");
4078
+ writeFileSync(join(outputDir, chapter.file), body, "utf8");
4079
+ return body.length;
4080
+ }
4081
+ /** 删除一个快照(连同文件)。 */
4082
+ function removeSnapshot(outputDir, snapshotId) {
4083
+ const snapshots = loadSnapshots(outputDir);
4084
+ const snapshot = snapshots.find((s) => s.id === snapshotId);
4085
+ if (snapshot === void 0) return false;
4086
+ try {
4087
+ rmSync(join(outputDir, snapshot.file), { force: true });
4088
+ } catch {}
4089
+ saveSnapshots(outputDir, snapshots.filter((s) => s.id !== snapshotId));
4090
+ return true;
4091
+ }
4092
+ /** 按每章保留数量清理(连文件一起删)。 */
4093
+ function pruneSnapshots(outputDir, keep = 20) {
4094
+ const snapshots = loadSnapshots(outputDir);
4095
+ const kept = pruneList(snapshots, keep);
4096
+ const keptIds = new Set(kept.map((s) => s.id));
4097
+ let removed = 0;
4098
+ for (const s of snapshots) {
4099
+ if (keptIds.has(s.id)) continue;
4100
+ try {
4101
+ rmSync(join(outputDir, s.file), { force: true });
4102
+ } catch {}
4103
+ removed++;
4104
+ }
4105
+ saveSnapshots(outputDir, kept);
4106
+ return removed;
4107
+ }
3225
4108
  //#endregion
3226
4109
  //#region src/prompting.ts
3227
- /** 章节写作约束:官方 writer 骨架渲染成可附加到现有系统提示词末尾的必达/禁止/自查块。 */
3228
- function renderOfficialChapterWriterSkeleton(meta) {
4110
+ /** 章节写作约束:把写作骨架渲染成可附加到系统提示词末尾的必达/禁止/自查块。 */
4111
+ function renderChapterWriterSkeleton(meta) {
3229
4112
  const pov = meta.pov ?? "第三人称有限视角,严格跟随主角所见所知。";
3230
4113
  const tone = meta.tonePreference ?? "文风贴合本书设定,用具体细节与动作推进。";
3231
4114
  const hook = meta.endingHookPreference ?? "章末留一个明确的钩子(新信息、新风险或未闭合的选择)。";
3232
4115
  const antiAi = meta.antiAiRules ?? "";
3233
4116
  return [
3234
- "==================== 官方生成骨架(必达 / 禁止 / 输出前自查) ====================",
4117
+ "==================== 章节生成骨架(必达 / 禁止 / 输出前自查) ====================",
3235
4118
  "【叙事视角】" + pov,
3236
- "【本章必达】本章必须发生实质变化(局面、关系、信息、风险、决策至少一项);开头迅速进入情境,禁止复述上一章。",
3237
- "【禁止事项】不得写总结/复盘/解释性段落为主;不得引入新的核心角色或与上下文冲突的设定;不得为空凑字数。",
3238
- "【篇幅】目标 " + meta.targetChars + " 字,区间 " + meta.minChars + "-" + meta.maxChars + " 字;不够就继续推进有效情节,禁止草草收尾。",
4119
+ "【本章必达】本章必须发生实质变化——局面、关系、信息、风险、决策至少动一项;开篇直接进入情境,不复述上一章。",
4120
+ "【禁止事项】不写以总结、复盘、解释为主的段落;不新增核心角色,不写入与上下文冲突的设定;不为凑字数注水。",
4121
+ "【篇幅】目标 " + meta.targetChars + " 字,区间 " + meta.minChars + "-" + meta.maxChars + " 字;不够就继续推进有效情节,不许草草收尾。",
3239
4122
  "【结尾】" + hook,
3240
- "【表达】优先用具体动作、对话与可感知细节推进;" + tone,
4123
+ "【表达】用具体动作、对话与可感知细节往前推;" + tone,
3241
4124
  "【反 AI】" + antiAi,
3242
- "【输出前自查】先确认:读者回报、关键转折、章末净变化是否可见,旧钩子责任是否回应,人物硬事实是否违背;确认通过后再输出,正文中不要输出核查结果。"
4125
+ "【输出前自查】逐项确认:读者的回报、关键转折、章末净变化是否看得见?上一章留下的钩子有没有交代?人物的硬事实有没有被写反?确认无误再输出,正文里不要出现核查过程。"
3243
4126
  ].join("\n");
3244
4127
  }
3245
4128
  //#endregion
@@ -3524,6 +4407,18 @@ const NOVEL_API = {
3524
4407
  director: "/api/dsh-novel-forge/director",
3525
4408
  directorTodos: "/api/dsh-novel-forge/director/todos",
3526
4409
  llmLive: "/api/dsh-novel-forge/llm-live/stream",
4410
+ /** 查看某次调用实际发送的 Prompt(按 sessionId)。 */
4411
+ llmPrompt: "/api/dsh-novel-forge/llm-live/prompt",
4412
+ /** 清零本次运行的用量账本。 */
4413
+ usageReset: "/api/dsh-novel-forge/usage/reset",
4414
+ /** 章节历史版本(快照)。 */
4415
+ snapshot: "/api/dsh-novel-forge/snapshot",
4416
+ /** 张力曲线(GET 数据 / POST 设置)。 */
4417
+ tension: "/api/dsh-novel-forge/tension",
4418
+ /** 提示词槽位(GET 列表 / POST 写入)。 */
4419
+ promptSlots: "/api/dsh-novel-forge/prompt-slots",
4420
+ /** 故事时间线(GET 列表 / POST 操作)。 */
4421
+ timeline: "/api/dsh-novel-forge/timeline",
3527
4422
  marketRadar: "/api/dsh-novel-forge/market-radar",
3528
4423
  marketRadarScan: "/api/dsh-novel-forge/market-radar/scan",
3529
4424
  marketRadarApply: "/api/dsh-novel-forge/market-radar/apply",
@@ -3708,6 +4603,15 @@ const LLM_VENDORS = [
3708
4603
  ]
3709
4604
  }
3710
4605
  ];
4606
+ /** 导出范围全集(宿主校验用;顺序即 UI 展示顺序)。 */
4607
+ const EXPORT_SCOPE_VALUES = [
4608
+ "book",
4609
+ "settings",
4610
+ "plan",
4611
+ "characters",
4612
+ "review",
4613
+ "project"
4614
+ ];
3711
4615
  //#endregion
3712
4616
  //#region src/engine.ts
3713
4617
  /**
@@ -4237,22 +5141,6 @@ function createProject(outline, outlinePath) {
4237
5141
  /** One complete non-streaming LLM call. */
4238
5142
  async function complete(ctx, config, options) {
4239
5143
  const liveLabel = options.liveLabel ?? "LLM 调用";
4240
- const sessionId = nextSessionId();
4241
- emitLive({
4242
- type: "session_started",
4243
- sessionId,
4244
- label: liveLabel,
4245
- model: options.model || config.model,
4246
- at: (/* @__PURE__ */ new Date()).toISOString(),
4247
- context: { interactionId: sessionId }
4248
- });
4249
- emitLive({
4250
- type: "phase_changed",
4251
- sessionId,
4252
- phase: "streaming",
4253
- phaseMessage: "模型正在返回内容",
4254
- at: (/* @__PURE__ */ new Date()).toISOString()
4255
- });
4256
5144
  const messages = [createUserMessage({
4257
5145
  content: [{
4258
5146
  type: "text",
@@ -4263,36 +5151,68 @@ async function complete(ctx, config, options) {
4263
5151
  plugin: "dsh-novel-forge"
4264
5152
  }
4265
5153
  })];
4266
- const request = {
4267
- provider: config.provider,
5154
+ /** 用指定模型跑一次完整调用(含实况打点、token 记账与诊断日志)。 */
5155
+ const runOnce = async (model) => {
5156
+ const live = beginLiveCall({
5157
+ label: liveLabel,
5158
+ model,
5159
+ system: options.system,
5160
+ user: options.user
5161
+ });
5162
+ const request = {
5163
+ provider: config.provider,
5164
+ model,
5165
+ messages,
5166
+ system: options.system,
5167
+ maxTokens: options.maxTokens ?? config.maxTokens,
5168
+ temperature: options.temperature ?? .7,
5169
+ reasoningEffort: ReasoningEffortId(options.reasoning ?? config.reasoningEffort ?? "off")
5170
+ };
5171
+ const assembler = new BlockAssembler();
5172
+ for await (const chunk of ctx.llm.stream(request)) {
5173
+ if (chunk.type === "text-delta") markFirstToken(live);
5174
+ assembler.push(chunk);
5175
+ }
5176
+ const finish = assembler.finish;
5177
+ if (finish.kind === "error" || finish.kind === "aborted") {
5178
+ const message = `LLM 调用失败(${finish.kind}): ${finish.failure.message}`;
5179
+ endLiveCall(live, assembler, {
5180
+ phase: "failed",
5181
+ error: message
5182
+ });
5183
+ throw new Error(message);
5184
+ }
5185
+ if (finish.kind === "max-tokens") {
5186
+ const message = "LLM 输出达到 maxTokens 上限,请增大配置后重试";
5187
+ endLiveCall(live, assembler, {
5188
+ phase: "failed",
5189
+ error: message
5190
+ });
5191
+ throw new Error(message);
5192
+ }
5193
+ if (process.env.DSH_NOVEL_DEBUG === "1") console.error("[dsh-novel-forge] complete: finish=%j blocks=%j", JSON.stringify(finish), assembler.blocks().map((b) => `${b.type}:${"text" in b ? b.text.length : "?"}`));
5194
+ const text = textFromAssembler(assembler);
5195
+ endLiveCall(live, assembler, {
5196
+ chars: text.length,
5197
+ preview: text.slice(0, 320),
5198
+ phase: text === "" ? "failed" : "completed"
5199
+ });
5200
+ return text;
5201
+ };
5202
+ return withModelFallback({
4268
5203
  model: options.model || config.model,
4269
- messages,
4270
- system: options.system,
4271
- maxTokens: options.maxTokens ?? config.maxTokens,
4272
- temperature: options.temperature ?? .7,
4273
- reasoningEffort: ReasoningEffortId(options.reasoning ?? config.reasoningEffort ?? "off")
4274
- };
4275
- const assembler = new BlockAssembler();
4276
- for await (const chunk of ctx.llm.stream(request)) assembler.push(chunk);
4277
- const finish = assembler.finish;
4278
- if (finish.kind === "error" || finish.kind === "aborted") throw new Error(`LLM 调用失败(${finish.kind}): ${finish.failure.message}`);
4279
- if (finish.kind === "max-tokens") throw new Error("LLM 输出达到 maxTokens 上限,请增大配置后重试");
5204
+ fallbackModel: config.fallbackModel
5205
+ }, runOnce);
5206
+ }
5207
+ /**
5208
+ * 从 assembler 取模型文本;无文本块时回落到 reasoning 通道
5209
+ * (部分模型会把答案整个放在思考通道,适配器以 reasoning 块呈现)。
5210
+ */
5211
+ function textFromAssembler(assembler) {
4280
5212
  const blocks = assembler.blocks();
4281
- if (process.env.DSH_NOVEL_DEBUG === "1") console.error("[dsh-novel-forge] complete: finish=%j blocks=%j", JSON.stringify(finish), blocks.map((b) => `${b.type}:${"text" in b ? b.text.length : "?"}`));
4282
- let text = blocks.filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
4283
- if (text === "") {
4284
- const reasoning = blocks.filter((block) => block.type === "reasoning").map((block) => block.text).join("\n").trim();
4285
- if (reasoning !== "") text = reasoning;
4286
- }
4287
- emitLive({
4288
- type: "session_completed",
4289
- sessionId,
4290
- totalChars: text.length,
4291
- preview: text.slice(0, 320),
4292
- at: (/* @__PURE__ */ new Date()).toISOString(),
4293
- phase: text === "" ? "failed" : "completed"
4294
- });
4295
- return text;
5213
+ const text = blocks.filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
5214
+ if (text !== "") return text;
5215
+ return blocks.filter((block) => block.type === "reasoning").map((block) => block.text).join("\n").trim();
4296
5216
  }
4297
5217
  /** 解析 JSON 数组;失败或为空时给模型一次修复重试(对齐上游 structuredInvokeRepair 精神)。 */
4298
5218
  async function completeJsonArray(ctx, config, options, parse) {
@@ -4564,7 +5484,7 @@ function planSystemPrompt(volumes) {
4564
5484
  "3. 严格遵循大纲的人设、金手指规则、战力体系与世界观设定,不得自行发明冲突设定。",
4565
5485
  "4. 输出必须是合法的 JSON 数组,不要输出任何其他文字或 Markdown 代码块标记。",
4566
5486
  "5. 数组每个元素格式:{\"title\": \"章节标题(10字以内,有网文感)\", \"beats\": \"结构化剧情要点(150-250字,必须包含四段,段间用换行分隔):\\n本章目标:本章要完成的核心推进;\\n剧情要点:主要情节的起承转合(2-4 句);\\n爽点/钩子:本章的爽点兑现或情绪钩子;\\n结尾钩子:本章结尾为下一章埋下的悬念\"}",
4567
- "6. 每个章节对象可额外包含以下可选字段(尽量给出,缺失则跳过):mustAdvance(数组,本章必须推进的局面/关系/信息/风险/决策变化);mustPreserve(数组,本章必须保持不破坏的项);characterHardFacts(数组,本章不可违背的人物硬事实:身份/阵营/境界/当前位置/知情度);endingHook(字符串,本章结尾钩子要求);obligation(字符串,本章义务合约一句话)。",
5487
+ "6. 每个章节对象可额外包含以下可选字段(尽量给出,缺失则跳过):tension(0-100 整数:本章张力目标,对抗/揭示/抉择/代价的强度;铺垫与日常章明显偏低,与相邻章节形成起伏)、mustAdvance(数组,本章必须推进的局面/关系/信息/风险/决策变化);mustPreserve(数组,本章必须保持不破坏的项);characterHardFacts(数组,本章不可违背的人物硬事实:身份/阵营/境界/当前位置/知情度);endingHook(字符串,本章结尾钩子要求);obligation(字符串,本章义务合约一句话)。",
4568
5488
  "重要:beats 字段内部必须使用 \\n 转义表示换行(JSON 字符串内不得有真实换行符),其余字符串值也不得包含真实换行符,JSON 必须在一段内完整结束。",
4569
5489
  "重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。",
4570
5490
  volumes !== void 0 && volumes.length > 0 ? ["\n全书分卷结构(规划章节时需落在对应卷内):"].concat(volumes.map((v) => `第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})` + (v.strategy !== void 0 && v.strategy !== "" ? `\n 卷战略:${v.strategy}` : "") + (v.pacing !== void 0 && v.pacing !== "" ? `\n 卷节奏板:${v.pacing}` : ""))).join("\n") : ""
@@ -4574,7 +5494,7 @@ function planSystemPrompt(volumes) {
4574
5494
  * `targetChars` 来自每章计划(规划时快照,= 设置的每章目标字数);无则退回默认 3500。
4575
5495
  * 字数区间按目标动态生成(±15%,取整到百位),避免系统提示词与设置互相冲突。
4576
5496
  * `lengthRule` 可覆盖第 1 条字数要求(整章修订/改编时按原文长度为准)。 */
4577
- function writeSystemPrompt(project, targetChars, lengthRule) {
5497
+ function writeSystemPrompt(project, targetChars, lengthRule, beforeChapter) {
4578
5498
  const bible = project.bible;
4579
5499
  const sections = [];
4580
5500
  if (bible !== void 0) {
@@ -4662,7 +5582,7 @@ function writeSystemPrompt(project, targetChars, lengthRule) {
4662
5582
  sections.push("==================== 内容合规红线(平台硬性要求,最高优先级,违反即失败) ====================");
4663
5583
  sections.push(COMPLIANCE_REDLINES.join("\n"));
4664
5584
  sections.push("以上九条为硬性底线,任何情况下不得以任何形式出现或影射;若剧情确需涉及(如批判、反讽),只能以明确否定、揭露、批判的立场呈现,且不得展开细节。");
4665
- sections.push(renderOfficialChapterWriterSkeleton({
5585
+ sections.push(renderChapterWriterSkeleton({
4666
5586
  targetChars: target,
4667
5587
  minChars: lo,
4668
5588
  maxChars: hi,
@@ -4671,6 +5591,14 @@ function writeSystemPrompt(project, targetChars, lengthRule) {
4671
5591
  tonePreference: "动作、对话、心理交替推进;重大信息用对话/动作/发现呈现。",
4672
5592
  antiAiRules: "严格遵循上方「反 AI 规则」与写法资产,避免套话与 AI 腔。"
4673
5593
  }));
5594
+ const timelineBlock = renderTimelineBlock(project, beforeChapter ?? Number.MAX_SAFE_INTEGER);
5595
+ if (timelineBlock !== "") sections.push(timelineBlock);
5596
+ if (beforeChapter !== void 0) {
5597
+ const tensionBlock = renderTensionBlock(project, beforeChapter);
5598
+ if (tensionBlock !== "") sections.push(tensionBlock);
5599
+ }
5600
+ const slotBlock = renderPromptSlots(project);
5601
+ if (slotBlock !== "") sections.push(slotBlock);
4674
5602
  return sections.join("\n");
4675
5603
  }
4676
5604
  /**
@@ -4789,6 +5717,7 @@ async function planChapters(ctx, config, project, chapterCount, volumeNo, output
4789
5717
  beats,
4790
5718
  targetChars: config.chapterChars,
4791
5719
  status: "pending",
5720
+ tension: typeof entry.tension === "number" && Number.isFinite(entry.tension) ? Math.max(0, Math.min(100, Math.round(entry.tension))) : void 0,
4792
5721
  mustAdvance: strArr(entry.mustAdvance).slice(0, 4),
4793
5722
  mustPreserve: strArr(entry.mustPreserve).slice(0, 4),
4794
5723
  characterHardFacts: strArr(entry.characterHardFacts).slice(0, 6),
@@ -4837,8 +5766,8 @@ function reviewSystemPrompt(project) {
4837
5766
  "8. 呈现方式:整章是否纯内心推理铺陈(无对话/无对抗,推理全靠解说);反派是否纯背景板无行动;重要配角是否无名标签化(瘦高个/灰衣人全程代称)——命中即列为问题。",
4838
5767
  "9. 内容合规(最高优先级):逐条核对下方「内容合规红线」,任何一条命中(含影射、暗示、详细描写)必须列为 high,并给出改写建议。",
4839
5768
  "输出必须是合法 JSON 对象,不要输出任何其他文字:",
4840
- "{\"score\": 0-100的整数, \"riskScore\": 0-100的整数(越高越需人工处理,可结合本地AI味指数), \"verdict\": \"一句话总评\", \"issues\": [{\"severity\": \"high|medium|low\", \"dimension\": \"character|setting|redline|writing|pacing|logic|anti-ai|presentation|compliance\", \"item\": \"问题描述\", \"suggestion\": \"修改建议\", \"ruleName\": \"命中的反AI规则名(见反AI规则清单)\", \"ruleType\": \"forbidden|risk|encourage\", \"category\": \"套话|句式|段落|心理|设定|节奏|对话|其他\", \"excerpt\": \"命中的原文摘录(不超过50字)\", \"reason\": \"判定理由\", \"canAutoRewrite\": true|false}]}",
4841
- "维度 dimension 与上方 9 个审查维度一一对应:人设=character、设定=setting、红线=redline、文笔=writing、节奏=pacing、逻辑=logic、反AI=anti-ai、呈现=presentation、合规=compliance。每条 issue 都必须填 dimension。",
5769
+ "{\"score\": 0-100的整数, \"riskScore\": 0-100的整数(越高越需人工处理,可结合本地AI味指数), \"tension\": 0-100的整数(本章张力强度:对抗/揭示/抉择/代价;铺垫与日常章应明显偏低), \"verdict\": \"一句话总评\", \"issues\": [{\"severity\": \"high|medium|low\", \"dimension\": \"character|setting|redline|writing|pacing|logic|anti-ai|presentation|compliance\", \"item\": \"问题描述\", \"suggestion\": \"修改建议\", \"ruleName\": \"命中的反AI规则名(见反AI规则清单)\", \"ruleType\": \"forbidden|risk|encourage\", \"category\": \"套话|句式|段落|心理|设定|节奏|对话|其他\", \"excerpt\": \"命中的原文摘录(不超过50字)\", \"reason\": \"判定理由\", \"canAutoRewrite\": true|false}]}",
5770
+ "tension 是本章张力强度评分(不是质量分):对决/揭秘/生死抉择偏高,铺垫章、日常章、过渡章偏低;同一本书内保持同一尺度。",
4842
5771
  "反 AI 类 issue 尽量给出 ruleName/ruleType/category/excerpt/reason/canAutoRewrite,便于统计与自动改写。",
4843
5772
  "AI 套话高频模板词示例(集中出现必须整体降密度):仿佛、似乎、极其、完美、深不见底、形成了、莫名、无法形容、难以言喻、精心雕琢、肤光胜雪、眉目如画、歌舞升平、觥筹交错、妙语连珠、不可名状、另一层真相、命运、真相。",
4844
5773
  "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
@@ -4922,6 +5851,7 @@ async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
4922
5851
  const report = {
4923
5852
  score,
4924
5853
  passed: hasHigh ? score >= config.reviewPassScore : score >= softThreshold,
5854
+ tension: typeof raw.tension === "number" && Number.isFinite(raw.tension) ? Math.max(0, Math.min(100, Math.round(raw.tension))) : void 0,
4925
5855
  verdict: typeof raw.verdict === "string" ? raw.verdict.slice(0, 200) : "",
4926
5856
  issues,
4927
5857
  riskScore,
@@ -5606,7 +6536,7 @@ async function suggestOutlines(ctx, config, idea, count = 3, exclude = []) {
5606
6536
  return candidates.slice(0, n);
5607
6537
  }
5608
6538
  /** 拆书分析:对已写章节做结构/人物/文风/卖点四维体检。
5609
- * 两阶段管道(借鉴 AI-Novel-Writing-Assistant):
6539
+ * 两阶段管道(先抽取候选、再落库):
5610
6540
  * ① 源片段笔记:每章抽取结构化笔记(剧情/人物/设定/写法/卖点/短板信号)
5611
6541
  * ② 分节分析:按维度各跑一次 LLM,输出可读分析稿 + 结构化数据 + 证据链。
5612
6542
  * @param scope 'recent'(默认最近20章) | 'volume:N' | 'all'
@@ -6084,8 +7014,8 @@ async function designPlotlinePlan(ctx, config, project, health) {
6084
7014
  };
6085
7015
  }
6086
7016
  /** Build the rewrite system prompt (fix review issues / instructions). */
6087
- function rewriteSystemPrompt(project, targetChars) {
6088
- return writeSystemPrompt(project, targetChars, "1. 输出完整的新正文(不要只输出修改片段、标题、章回名、作者的话或任何 Markdown 标记),字数与原章相当(允许 ±20%)。") + "\n\n额外要求:你正在【修订】一章已写好的正文。保留原文中好的部分,只修改需要修改的地方,输出完整的新正文(不要只输出修改片段),字数与原文相当。";
7017
+ function rewriteSystemPrompt(project, targetChars, beforeChapter) {
7018
+ return writeSystemPrompt(project, targetChars, "1. 输出完整的新正文(不要只输出修改片段、标题、章回名、作者的话或任何 Markdown 标记),字数与原章相当(允许 ±20%)。", beforeChapter) + "\n\n额外要求:你正在【修订】一章已写好的正文。保留原文中好的部分,只修改需要修改的地方,输出完整的新正文(不要只输出修改片段),字数与原文相当。";
6089
7019
  }
6090
7020
  /**
6091
7021
  * Stream a chapter rewrite. With `target` (a passage of the body), only that
@@ -6131,7 +7061,7 @@ async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo,
6131
7061
  "2. 保留该段的情节走向与角色口吻,只按修改要求调整。",
6132
7062
  "3. 段落长度与原文相当。"
6133
7063
  ].filter((line) => line !== "").join("\n");
6134
- const system = localTarget === void 0 ? rewriteSystemPrompt(project, chapter.targetChars || config.chapterChars) : (() => {
7064
+ const system = localTarget === void 0 ? rewriteSystemPrompt(project, chapter.targetChars || config.chapterChars, chapter.no) : (() => {
6135
7065
  const bible = project.bible;
6136
7066
  const lines = [
6137
7067
  "你是一位中文网文润色师。你会收到一章中的一个段落,请按修改要求重写该段。",
@@ -6174,19 +7104,51 @@ async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo,
6174
7104
  reasoningEffort: ReasoningEffortId("off")
6175
7105
  };
6176
7106
  yield { frame: "start" };
6177
- const assembler = new BlockAssembler();
7107
+ const primaryModel = request.model ?? config.model;
7108
+ let rewritten = "";
6178
7109
  let streamError;
6179
- for await (const chunk of ctx.llm.stream(request)) {
6180
- assembler.push(chunk);
6181
- if (chunk.type === "text-delta") yield {
6182
- frame: "delta",
6183
- text: chunk.text
6184
- };
7110
+ for (let attempt = 0; attempt < 2; attempt++) {
7111
+ const model = attempt === 0 ? primaryModel : (config.fallbackModel ?? "").trim();
7112
+ if (model === "") break;
7113
+ const live = beginLiveCall({
7114
+ label: `修订 · 第${chapterNo}章`,
7115
+ model,
7116
+ system: request.system,
7117
+ user: messages[0]?.content !== void 0 ? JSON.stringify(messages[0].content).slice(0, 2e3) : void 0
7118
+ });
7119
+ const assembler = new BlockAssembler();
7120
+ let produced = 0;
7121
+ for await (const chunk of ctx.llm.stream({
7122
+ ...request,
7123
+ model
7124
+ })) {
7125
+ assembler.push(chunk);
7126
+ if (chunk.type === "text-delta") {
7127
+ produced += chunk.text.length;
7128
+ markFirstToken(live);
7129
+ yield {
7130
+ frame: "delta",
7131
+ text: chunk.text
7132
+ };
7133
+ }
7134
+ }
7135
+ const finish = assembler.finish;
7136
+ streamError = void 0;
7137
+ if (finish.kind === "error" || finish.kind === "aborted") streamError = /* @__PURE__ */ new Error(`修订失败(${finish.kind}): ${finish.failure.message}`);
7138
+ else if (finish.kind === "max-tokens") streamError = /* @__PURE__ */ new Error("修订输出达到 maxTokens 上限,请增大配置后重试");
7139
+ rewritten = assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
7140
+ endLiveCall(live, assembler, streamError === void 0 ? {
7141
+ chars: rewritten.length,
7142
+ preview: rewritten.slice(0, 320),
7143
+ phase: rewritten === "" ? "failed" : "completed"
7144
+ } : {
7145
+ chars: rewritten.length,
7146
+ phase: "failed",
7147
+ error: streamError.message
7148
+ });
7149
+ if (streamError === void 0) break;
7150
+ if (produced > 0 || !shouldSwitchModel(config.fallbackModel, model, streamError)) break;
6185
7151
  }
6186
- const finish = assembler.finish;
6187
- if (finish.kind === "error" || finish.kind === "aborted") streamError = /* @__PURE__ */ new Error(`修订失败(${finish.kind}): ${finish.failure.message}`);
6188
- else if (finish.kind === "max-tokens") streamError = /* @__PURE__ */ new Error("修订输出达到 maxTokens 上限,请增大配置后重试");
6189
- const rewritten = assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
6190
7152
  if (streamError !== void 0) throw streamError;
6191
7153
  if (rewritten.length < 20) throw new Error("修订结果过短,可能失败,请重试");
6192
7154
  let newBody;
@@ -6254,19 +7216,51 @@ async function* polishChapterStream(ctx, config, project, outputDir, chapterNo)
6254
7216
  reasoningEffort: ReasoningEffortId("off")
6255
7217
  };
6256
7218
  yield { frame: "start" };
6257
- const assembler = new BlockAssembler();
7219
+ const primaryModel = request.model ?? config.model;
7220
+ let newBody = "";
6258
7221
  let streamError;
6259
- for await (const chunk of ctx.llm.stream(request)) {
6260
- assembler.push(chunk);
6261
- if (chunk.type === "text-delta") yield {
6262
- frame: "delta",
6263
- text: chunk.text
6264
- };
7222
+ for (let attempt = 0; attempt < 2; attempt++) {
7223
+ const model = attempt === 0 ? primaryModel : (config.fallbackModel ?? "").trim();
7224
+ if (model === "") break;
7225
+ const live = beginLiveCall({
7226
+ label: `去 AI 味润色 · 第${chapterNo}章`,
7227
+ model,
7228
+ system: request.system,
7229
+ user: body.slice(0, 2e3)
7230
+ });
7231
+ const assembler = new BlockAssembler();
7232
+ let produced = 0;
7233
+ for await (const chunk of ctx.llm.stream({
7234
+ ...request,
7235
+ model
7236
+ })) {
7237
+ assembler.push(chunk);
7238
+ if (chunk.type === "text-delta") {
7239
+ produced += chunk.text.length;
7240
+ markFirstToken(live);
7241
+ yield {
7242
+ frame: "delta",
7243
+ text: chunk.text
7244
+ };
7245
+ }
7246
+ }
7247
+ const finish = assembler.finish;
7248
+ streamError = void 0;
7249
+ if (finish.kind === "error" || finish.kind === "aborted") streamError = /* @__PURE__ */ new Error(`润色失败(${finish.kind}): ${finish.failure.message}`);
7250
+ else if (finish.kind === "max-tokens") streamError = /* @__PURE__ */ new Error("润色输出达到 maxTokens 上限");
7251
+ newBody = assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
7252
+ endLiveCall(live, assembler, streamError === void 0 ? {
7253
+ chars: newBody.length,
7254
+ preview: newBody.slice(0, 320),
7255
+ phase: newBody === "" ? "failed" : "completed"
7256
+ } : {
7257
+ chars: newBody.length,
7258
+ phase: "failed",
7259
+ error: streamError.message
7260
+ });
7261
+ if (streamError === void 0) break;
7262
+ if (produced > 0 || !shouldSwitchModel(config.fallbackModel, model, streamError)) break;
6265
7263
  }
6266
- const finish = assembler.finish;
6267
- if (finish.kind === "error" || finish.kind === "aborted") streamError = /* @__PURE__ */ new Error(`润色失败(${finish.kind}): ${finish.failure.message}`);
6268
- else if (finish.kind === "max-tokens") streamError = /* @__PURE__ */ new Error("润色输出达到 maxTokens 上限");
6269
- const newBody = assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
6270
7264
  if (streamError !== void 0) throw streamError;
6271
7265
  if (newBody.length < 100) throw new Error("润色结果过短,可能失败,请重试");
6272
7266
  chapter.pendingDraft = newBody;
@@ -6334,23 +7328,24 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
6334
7328
  return parts.length > 0 ? "==================== 本章合同(硬约束,必须满足) ====================\n" + parts.join("\n") : "";
6335
7329
  })();
6336
7330
  const knowledgeBlock = retrieveKnowledge(project, `${chapter.title} ${chapter.beats}`);
7331
+ const user = [
7332
+ `现在写第 ${chapter.no} 章,标题《${chapter.title}》。`,
7333
+ `本章剧情要点:${chapter.beats}`,
7334
+ contractBlock,
7335
+ knowledgeBlock,
7336
+ "",
7337
+ foreshadowHints.length > 0 ? `本章附近需顺势埋下以下暗线(自然带过,不喧宾夺主,1-2 句即可,但细节要可辨识、与描述吻合):\n${foreshadowHints.join("\n")}` : "",
7338
+ recentFacts.length > 0 ? `本书已确立的事实(新写内容不得与之矛盾):\n${recentFacts.join("\n")}` : "",
7339
+ relatedFacts.length > 0 ? `本章相关的既往事实(同样不得违背):\n${relatedFacts.join("\n")}` : "",
7340
+ prevSummary !== void 0 && prevSummary !== "" ? `上一章摘要:${prevSummary}` : "",
7341
+ continuity !== "" ? `上一章结尾(用于衔接,不要复述):\n${continuity}` : "这是第一章,注意开篇要有吸引力。",
7342
+ "",
7343
+ `请写 ${chapter.targetChars} 字左右的正文,只输出正文。`
7344
+ ].filter((line) => line !== "").join("\n");
6337
7345
  const messages = [createUserMessage({
6338
7346
  content: [{
6339
7347
  type: "text",
6340
- text: [
6341
- `现在写第 ${chapter.no} 章,标题《${chapter.title}》。`,
6342
- `本章剧情要点:${chapter.beats}`,
6343
- contractBlock,
6344
- knowledgeBlock,
6345
- "",
6346
- foreshadowHints.length > 0 ? `本章附近需顺势埋下以下暗线(自然带过,不喧宾夺主,1-2 句即可,但细节要可辨识、与描述吻合):\n${foreshadowHints.join("\n")}` : "",
6347
- recentFacts.length > 0 ? `本书已确立的事实(新写内容不得与之矛盾):\n${recentFacts.join("\n")}` : "",
6348
- relatedFacts.length > 0 ? `本章相关的既往事实(同样不得违背):\n${relatedFacts.join("\n")}` : "",
6349
- prevSummary !== void 0 && prevSummary !== "" ? `上一章摘要:${prevSummary}` : "",
6350
- continuity !== "" ? `上一章结尾(用于衔接,不要复述):\n${continuity}` : "这是第一章,注意开篇要有吸引力。",
6351
- "",
6352
- `请写 ${chapter.targetChars} 字左右的正文,只输出正文。`
6353
- ].filter((line) => line !== "").join("\n")
7348
+ text: user
6354
7349
  }],
6355
7350
  source: {
6356
7351
  kind: "plugin",
@@ -6361,24 +7356,56 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
6361
7356
  provider: config.provider,
6362
7357
  model: config.generateModel || config.model,
6363
7358
  messages,
6364
- system: writeSystemPrompt(project, chapter.targetChars || config.chapterChars),
7359
+ system: writeSystemPrompt(project, chapter.targetChars || config.chapterChars, void 0, chapter.no),
6365
7360
  maxTokens: Math.max(config.maxTokens, 2e4),
6366
7361
  temperature: .85
6367
7362
  };
6368
7363
  yield { frame: "start" };
6369
- const assembler = new BlockAssembler();
7364
+ const primaryModel = request.model ?? config.model;
7365
+ let body = "";
6370
7366
  let streamError;
6371
- for await (const chunk of ctx.llm.stream(request)) {
6372
- assembler.push(chunk);
6373
- if (chunk.type === "text-delta") yield {
6374
- frame: "delta",
6375
- text: chunk.text
6376
- };
7367
+ for (let attempt = 0; attempt < 2; attempt++) {
7368
+ const model = attempt === 0 ? primaryModel : (config.fallbackModel ?? "").trim();
7369
+ if (model === "") break;
7370
+ const live = beginLiveCall({
7371
+ label: `正文生成 · 第${chapter.no}章`,
7372
+ model,
7373
+ system: request.system,
7374
+ user
7375
+ });
7376
+ const assembler = new BlockAssembler();
7377
+ let produced = 0;
7378
+ for await (const chunk of ctx.llm.stream({
7379
+ ...request,
7380
+ model
7381
+ })) {
7382
+ assembler.push(chunk);
7383
+ if (chunk.type === "text-delta") {
7384
+ produced += chunk.text.length;
7385
+ markFirstToken(live);
7386
+ yield {
7387
+ frame: "delta",
7388
+ text: chunk.text
7389
+ };
7390
+ }
7391
+ }
7392
+ const finish = assembler.finish;
7393
+ streamError = void 0;
7394
+ if (finish.kind === "error" || finish.kind === "aborted") streamError = /* @__PURE__ */ new Error(`生成失败(${finish.kind}): ${finish.failure.message}`);
7395
+ else if (finish.kind === "max-tokens") streamError = /* @__PURE__ */ new Error("达到 maxTokens 上限,正文可能不完整,请增大 maxTokens 后重试");
7396
+ body = assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
7397
+ endLiveCall(live, assembler, streamError === void 0 ? {
7398
+ chars: body.length,
7399
+ preview: body.slice(0, 320),
7400
+ phase: body === "" ? "failed" : "completed"
7401
+ } : {
7402
+ chars: body.length,
7403
+ phase: "failed",
7404
+ error: streamError.message
7405
+ });
7406
+ if (streamError === void 0) break;
7407
+ if (produced > 0 || !shouldSwitchModel(config.fallbackModel, model, streamError)) break;
6377
7408
  }
6378
- const finish = assembler.finish;
6379
- if (finish.kind === "error" || finish.kind === "aborted") streamError = /* @__PURE__ */ new Error(`生成失败(${finish.kind}): ${finish.failure.message}`);
6380
- else if (finish.kind === "max-tokens") streamError = /* @__PURE__ */ new Error("达到 maxTokens 上限,正文可能不完整,请增大 maxTokens 后重试");
6381
- let body = assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
6382
7409
  if (streamError !== void 0) throw streamError;
6383
7410
  if (body.length < 100) throw new Error("生成内容过短,可能失败,请重试");
6384
7411
  const target = chapter.targetChars > 0 ? chapter.targetChars : config.chapterChars;
@@ -6392,7 +7419,7 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
6392
7419
  `请承接上文继续写约 ${need} 字,不要重复已有内容,不要重写开头,不要总结前文,直接续写新情节,结尾留一个章末钩子。只输出续写的正文,不要输出标题或任何说明。`
6393
7420
  ].join("\n");
6394
7421
  const add = (await complete(ctx, config, {
6395
- system: writeSystemPrompt(project, target) + "\n\n【续写任务】严格承接已有正文往下写,不要重复已有内容、不要重写开头、不要总结前文,直接续写新情节,并在结尾留一个章末钩子。",
7422
+ system: writeSystemPrompt(project, target, void 0, chapter.no) + "\n\n【续写任务】严格承接已有正文往下写,不要重复已有内容、不要重写开头、不要总结前文,直接续写新情节,并在结尾留一个章末钩子。",
6396
7423
  user: contUser,
6397
7424
  temperature: .85,
6398
7425
  maxTokens: Math.max(config.maxTokens, 12e3),
@@ -6403,6 +7430,7 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
6403
7430
  body = `${body}\n\n${add}`;
6404
7431
  hanzi = countHanzi(body);
6405
7432
  }
7433
+ if (chapter.file !== void 0 && chapter.file !== "") createSnapshot(outputDir, chapter, "重新生成前");
6406
7434
  const fileName = chapterFileName(chapter);
6407
7435
  mkdirSync(outputDir, { recursive: true });
6408
7436
  writeFileSync(join(outputDir, fileName), `# 第${chapter.no}章 ${chapter.title}\n\n${body}\n`, "utf8");
@@ -7772,6 +8800,117 @@ async function runDirectorAdvice(ctx, config, project, req) {
7772
8800
  fixes: arr(raw.fixes)
7773
8801
  };
7774
8802
  }
8803
+ /**
8804
+ * 从一章正文抽取时间线事件(时间点 / 地点 / 参与角色 / 事件)。
8805
+ *
8806
+ * 会**替换**该章已有事件(重复抽取幂等);抽取结果按 order 递增排序后写回项目。
8807
+ * 失败时抛错,由调用方决定是否降级(自动流程用 try/catch 包住,不阻断出章)。
8808
+ *
8809
+ * @param ctx 宿主上下文。
8810
+ * @param config 运行时配置。
8811
+ * @param project 项目状态(就地更新 `project.timeline`)。
8812
+ * @param outputDir 书目录。
8813
+ * @param chapterNo 章节号。
8814
+ * @returns 本次抽取到的事件(已写入 project)。
8815
+ */
8816
+ async function extractTimelineForChapter(ctx, config, project, outputDir, chapterNo) {
8817
+ const chapter = project.chapters.find((c) => c.no === chapterNo);
8818
+ if (chapter === void 0) throw new NovelActionError("contract", `章节 ${chapterNo} 不在计划中`);
8819
+ const body = readChapterFile(outputDir, chapter);
8820
+ if (body === void 0) throw new NovelActionError("contract", `章节 ${chapterNo} 的正文文件不存在`);
8821
+ const NL = String.fromCharCode(10);
8822
+ const text = await complete(ctx, config, {
8823
+ system: [
8824
+ "你是小说连续性编辑。任务:把一章正文压缩成「故事时间线」事件,供后续章节保持时间与地点的连续性。",
8825
+ "要求:",
8826
+ "1. 只抽取**正文里真实发生**的场景节点(3-8 条),不要抽取预告、回忆性的泛泛陈述;纯粹的回忆/插叙请在 event 里注明「(回忆)」并给出其故事内时间。",
8827
+ "2. time 用正文里的说法(如「第三日黄昏」「入宗三个月后」「同日夜」);正文没写时间就填相对说法(如「紧接上一场景」)。",
8828
+ "3. order 是本章内的先后序号,从 1 递增;跨章我会用章号排序,你只需要保证章内顺序正确。",
8829
+ "4. place 是场景地点(一处一个词,如「青云宗外门」);characters 只列**在场**的角色名(用正文里的名字,不要写「主角」)。",
8830
+ "5. event 一句话说清发生了什么(不超过 40 字)。",
8831
+ "输出必须是合法 JSON 数组,不要输出任何其他文字或 Markdown 代码块标记:",
8832
+ "[{\"time\":\"第三日黄昏\",\"order\":1,\"place\":\"青云宗外门\",\"characters\":[\"沈青\"],\"event\":\"沈青被外门弟子当众羞辱\"}]",
8833
+ "重要:字符串值内部不得包含换行符;JSON 必须在一段内完整结束。"
8834
+ ].join("@NL@").replace("@NL@", NL),
8835
+ user: `第 ${chapterNo} 章《${chapter.title}》正文:@NL@@NL@${body.replace(/^#\s+.*$/m, "").trim().slice(0, 24e3)}`.replace("@NL@", NL),
8836
+ temperature: .2,
8837
+ maxTokens: Math.max(config.maxTokens, 4e3),
8838
+ model: config.auditModel || config.model,
8839
+ liveLabel: `时间线抽取 · 第${chapterNo}章`
8840
+ });
8841
+ let parsed = [];
8842
+ try {
8843
+ parsed = parseJson(text, true);
8844
+ } catch {
8845
+ parsed = [];
8846
+ }
8847
+ const rawList = Array.isArray(parsed) ? parsed : [];
8848
+ const now = (/* @__PURE__ */ new Date()).toISOString();
8849
+ const extracted = rawList.filter((item) => typeof item === "object" && item !== null).map((item, index) => normalizeTimelineEvent({
8850
+ time: typeof item.time === "string" ? item.time : "",
8851
+ order: typeof item.order === "number" ? item.order : index + 1,
8852
+ place: typeof item.place === "string" ? item.place : "",
8853
+ characters: Array.isArray(item.characters) ? item.characters.filter((c) => typeof c === "string") : [],
8854
+ event: typeof item.event === "string" ? item.event : ""
8855
+ }, chapterNo, index, now)).filter((e) => e.event !== "");
8856
+ project.timeline = [...(project.timeline ?? []).filter((e) => e.chapterNo !== chapterNo), ...extracted];
8857
+ project.updatedAt = now;
8858
+ return extracted;
8859
+ }
8860
+ /**
8861
+ * AI 复核时间线:在规则初筛之外,让模型找规则抓不到的时序问题
8862
+ * (年龄/季节错位、回忆与当前时间混淆、跨章跨度不合理等)。
8863
+ *
8864
+ * @param ctx 宿主上下文。
8865
+ * @param config 运行时配置。
8866
+ * @param project 项目状态。
8867
+ * @returns 模型给出的问题清单(与规则初筛结果合并由调用方决定)。
8868
+ */
8869
+ async function reviewTimeline(ctx, config, project) {
8870
+ const events = sortTimeline(project.timeline ?? []);
8871
+ if (events.length === 0) return [];
8872
+ const NL = String.fromCharCode(10);
8873
+ const lines = events.map((e) => {
8874
+ const parts = [`第${e.chapterNo}章`];
8875
+ if (e.order !== void 0) parts.push(`#${e.order}`);
8876
+ if (e.time !== "") parts.push(e.time);
8877
+ if (e.place !== "") parts.push(`@${e.place}`);
8878
+ if (e.characters.length > 0) parts.push(`(${e.characters.join("、")})`);
8879
+ parts.push(e.event);
8880
+ return "- " + parts.join(" ");
8881
+ });
8882
+ const text = await complete(ctx, config, {
8883
+ system: [
8884
+ "你是小说连续性编辑,只做一件事:找**时间线**上的矛盾。",
8885
+ "检查项:1) 时间倒流(后一章早于前一章且没有明确插叙标注);2) 地点瞬移(同一人物无移动交代就换场景);",
8886
+ "3) 时间跨度与事件量明显不匹配(如「一日之内」发生跨越数月的剧情);4) 季节/年龄/节日前后矛盾;5) 回忆与现实混淆。",
8887
+ "只报**能在给定事件表里指认**的问题,不要臆测;没有问题就返回空数组。",
8888
+ "输出必须是合法 JSON 数组,不要任何其他文字:",
8889
+ "[{\"severity\":\"high|medium|low\",\"chapters\":[3,7],\"item\":\"问题一句话\",\"suggestion\":\"怎么修\"}]",
8890
+ "重要:字符串值内部不得包含换行符。"
8891
+ ].join(NL),
8892
+ user: `故事时间线(按章节顺序):@NL@${lines.join(NL)}`.replace("@NL@", NL),
8893
+ temperature: .2,
8894
+ maxTokens: Math.max(config.maxTokens, 4e3),
8895
+ model: config.auditModel || config.model,
8896
+ liveLabel: "时间线复核"
8897
+ });
8898
+ let parsed = [];
8899
+ try {
8900
+ parsed = parseJson(text, true);
8901
+ } catch {
8902
+ parsed = [];
8903
+ }
8904
+ if (!Array.isArray(parsed)) return [];
8905
+ return parsed.filter((item) => typeof item === "object" && item !== null).map((item) => {
8906
+ return {
8907
+ severity: item.severity === "high" || item.severity === "low" ? item.severity : "medium",
8908
+ chapters: Array.isArray(item.chapters) ? item.chapters.filter((c) => typeof c === "number" && Number.isFinite(c)) : [],
8909
+ item: typeof item.item === "string" ? item.item.slice(0, 300) : "",
8910
+ suggestion: typeof item.suggestion === "string" ? item.suggestion.slice(0, 300) : ""
8911
+ };
8912
+ }).filter((issue) => issue.item !== "");
8913
+ }
7775
8914
  /** Export the whole book as one txt/md file. */
7776
8915
  function exportBook(outputDir, project, format) {
7777
8916
  const parts = [];
@@ -7783,14 +8922,207 @@ function exportBook(outputDir, project, format) {
7783
8922
  if (format === "md") parts.push(`\n## 第${chapter.no}章 ${chapter.title}\n`, body.trim(), "");
7784
8923
  else parts.push("", `第${chapter.no}章 ${chapter.title}`, "", body.trim(), "");
7785
8924
  }
7786
- const content = parts.join("\n");
7787
- const ext = format === "md" ? "md" : "txt";
7788
- const file = `《${safeFileName(project.bookName)}》全本.${ext}`;
7789
- writeFileSync(join(outputDir, file), content, "utf8");
8925
+ const content = parts.join("\n");
8926
+ const ext = format === "md" ? "md" : "txt";
8927
+ const file = `《${safeFileName(project.bookName)}》全本.${ext}`;
8928
+ writeFileSync(join(outputDir, file), content, "utf8");
8929
+ return {
8930
+ file,
8931
+ chars: content.length,
8932
+ chapters: done.length
8933
+ };
8934
+ }
8935
+ /** 逐行渲染「- 名称:值」清单,跳过空值。 */
8936
+ function renderPairs(pairs) {
8937
+ return pairs.filter(([, v]) => v !== void 0 && v !== "").map(([k, v]) => `- ${k}:${v}`).join("\n");
8938
+ }
8939
+ /** 渲染项目设定(卷首语 / 开书定盘 / 总纲 / 道藏 / 大世界 / 写作资产)。 */
8940
+ function renderSettings(project) {
8941
+ const out = [`# ${project.bookName} · 项目设定`, ""];
8942
+ out.push("## 卷首语 / 简介", "", project.blurb !== void 0 && project.blurb !== "" ? project.blurb : "(未填写)", "");
8943
+ const c = project.bookContract;
8944
+ if (c !== void 0) out.push("## 开书定盘", "", renderPairs([
8945
+ ["书籍承诺", c.promise],
8946
+ ["主推进模式", c.primaryModeName],
8947
+ ["辅助推进模式", (c.secondaryModeNames ?? []).join("、")],
8948
+ ["文风基调", c.tone],
8949
+ ["目标平台", c.targetPlatform]
8950
+ ]), "");
8951
+ out.push("## 总纲", "", project.outline.trim() === "" ? "(未导入)" : project.outline.trim(), "");
8952
+ const bible = project.bible;
8953
+ if (bible !== void 0) {
8954
+ out.push("## 道藏", "");
8955
+ if (bible.worldRules.length > 0) out.push("### 世界规则", "", bible.worldRules.map((r, i) => `${i + 1}. ${r}`).join("\n"), "");
8956
+ if (bible.redLines.length > 0) out.push("### 写作红线", "", bible.redLines.map((r) => `- ${r}`).join("\n"), "");
8957
+ if (bible.style.length > 0) out.push("### 文风", "", bible.style.map((x) => `- ${x}`).join("\n"), "");
8958
+ if (bible.characters.length > 0) {
8959
+ out.push("### 角色(道藏)", "");
8960
+ for (const ch of bible.characters) {
8961
+ out.push(`- **${ch.name}**(${ch.role})`);
8962
+ if (ch.traits.length > 0) out.push(` - 标签:${ch.traits.join("、")}`);
8963
+ if (ch.goals !== "") out.push(` - 目标:${ch.goals}`);
8964
+ if (ch.relations !== "") out.push(` - 关系:${ch.relations}`);
8965
+ if ((ch.knowledge ?? []).length > 0) out.push(` - 知情:${(ch.knowledge ?? []).join(";")}`);
8966
+ }
8967
+ out.push("");
8968
+ }
8969
+ }
8970
+ const world = project.world;
8971
+ if (world !== void 0) {
8972
+ out.push("## 大世界", "");
8973
+ if (world.realms.length > 0) out.push("### 境界体系", "", world.realms.map((r, i) => `${i + 1}. ${r.name}${r.description !== "" ? `:${r.description}` : ""}`).join("\n"), "");
8974
+ if (world.regions.length > 0) out.push("### 区域", "", world.regions.map((r) => `- ${r.name}${r.description !== "" ? `:${r.description}` : ""}`).join("\n"), "");
8975
+ if (world.factions.length > 0) out.push("### 势力", "", world.factions.map((f) => `- ${f.name}(${f.kind})${f.description !== "" ? `:${f.description}` : ""}`).join("\n"), "");
8976
+ }
8977
+ const assets = project.assets;
8978
+ if (assets !== void 0) {
8979
+ const head = [];
8980
+ if (assets.genre !== void 0) head.push(["题材", assets.genre.name]);
8981
+ if (assets.primaryProgression !== void 0) head.push(["主推进", assets.primaryProgression.name]);
8982
+ if ((assets.auxiliaryProgressions ?? []).length > 0) head.push(["辅助推进", assets.auxiliaryProgressions.map((a) => a.name).join("、")]);
8983
+ if (head.length > 0) out.push("## 写作资产", "", renderPairs(head), "");
8984
+ if ((assets.antiAiRules ?? []).length > 0) {
8985
+ out.push("### 反 AI 规则", "");
8986
+ out.push(assets.antiAiRules.map((r) => `- ${r.name}:避免「${r.avoid}」→ ${r.fix}`).join("\n"), "");
8987
+ }
8988
+ if ((assets.styleAssets ?? []).length > 0) {
8989
+ out.push("### 笔法帖 / 写法资产", "");
8990
+ out.push(assets.styleAssets.map((x) => `- ${x.name}`).join("\n"), "");
8991
+ }
8992
+ }
8993
+ return out.join("\n").replace(/\n(\n)+/g, "\n\n");
8994
+ }
8995
+ /** 渲染规划(卷计划 + 章节计划)。 */
8996
+ function renderPlan(project) {
8997
+ const out = [`# ${project.bookName} · 规划`, ""];
8998
+ const volumes = (project.volumes ?? []).slice().sort((a, b) => a.no - b.no);
8999
+ if (volumes.length > 0) {
9000
+ out.push("## 卷计划", "");
9001
+ for (const v of volumes) {
9002
+ out.push(`### 第${v.no}卷 ${v.title}`, "");
9003
+ if (v.summary !== "") out.push(v.summary, "");
9004
+ }
9005
+ }
9006
+ out.push(`## 章节计划(共 ${project.chapters.length} 章)`, "");
9007
+ for (const c of project.chapters.slice().sort((a, b) => a.no - b.no)) {
9008
+ out.push(`### 第${c.no}章 ${c.title} [${c.status}]`, "");
9009
+ if (c.beats !== "") out.push(c.beats.trim(), "");
9010
+ const extra = renderPairs([
9011
+ ["本章必达", (c.mustAdvance ?? []).join(";")],
9012
+ ["不可破坏", (c.mustPreserve ?? []).join(";")],
9013
+ ["人物硬事实", (c.characterHardFacts ?? []).join(";")],
9014
+ ["结尾钩子", c.endingHook],
9015
+ ["义务合约", c.obligation]
9016
+ ]);
9017
+ if (extra !== "") out.push(extra, "");
9018
+ if (c.summary !== void 0 && c.summary !== "") out.push(`- 摘要:${c.summary}`, "");
9019
+ }
9020
+ return out.join("\n");
9021
+ }
9022
+ /** 渲染角色(角色库 + 人物志)。 */
9023
+ function renderCharacters(project) {
9024
+ const out = [`# ${project.bookName} · 角色`, ""];
9025
+ const roles = project.roles ?? [];
9026
+ out.push(`## 角色库(${roles.length})`, "");
9027
+ if (roles.length === 0) out.push("(空)", "");
9028
+ for (const r of roles) {
9029
+ out.push(`### ${r.name}(${r.roleLabel})`, "");
9030
+ const info = renderPairs([
9031
+ ["身份", r.identity],
9032
+ ["性格", r.traits.join("、")],
9033
+ ["目标", r.goals],
9034
+ ["关系网", r.relations.join(";")],
9035
+ ["成长线", r.arc.join(" → ")],
9036
+ ["知情度", r.knowledge.join(";")],
9037
+ ["首次出场", r.firstChapter !== void 0 ? `第${r.firstChapter}章` : void 0]
9038
+ ]);
9039
+ if (info !== "") out.push(info, "");
9040
+ }
9041
+ const status = project.roleStatus ?? [];
9042
+ if (status.length > 0) {
9043
+ out.push("## 人物志(当前状态)", "");
9044
+ for (const card of status) out.push(`- **${card.name}**(${card.role})${card.status} — 最近出场第 ${card.lastChapter} 章,累计 ${card.appearances} 次`);
9045
+ }
9046
+ return out.join("\n");
9047
+ }
9048
+ /** 渲染质检记录(逐章审稿 + 作者复盘 + 暗线 + 剧情线 + 待办)。 */
9049
+ function renderReview(project) {
9050
+ const out = [`# ${project.bookName} · 质检记录`, ""];
9051
+ const reviewed = project.chapters.filter((c) => c.review !== void 0).slice().sort((a, b) => a.no - b.no);
9052
+ out.push(`## 审稿(${reviewed.length} / ${project.chapters.length} 章)`, "");
9053
+ for (const c of reviewed) {
9054
+ const r = c.review;
9055
+ out.push(`### 第${c.no}章 ${c.title} ${r.score} 分 · ${r.passed ? "通过" : "未过"}`, "");
9056
+ if (r.verdict !== "") out.push(r.verdict, "");
9057
+ if (r.issues.length > 0) out.push(r.issues.map((i) => `- [${i.severity}] ${i.item}${i.suggestion !== "" ? ` → ${i.suggestion}` : ""}`).join("\n"), "");
9058
+ }
9059
+ const author = project.chapters.filter((c) => c.authorReview !== void 0).slice().sort((a, b) => a.no - b.no);
9060
+ if (author.length > 0) {
9061
+ out.push("## 作者复盘", "");
9062
+ for (const c of author) {
9063
+ const a = c.authorReview;
9064
+ out.push(`### 第${c.no}章 ${c.title}`, "");
9065
+ out.push(renderPairs([
9066
+ ["上一章钩子兑现", a.hookHonored ? "是" : "否"],
9067
+ ["钩子说明", a.hookNote],
9068
+ ["结尾钩子强度", String(a.endingHook)],
9069
+ ["剧情线推进", a.plotlineProgress],
9070
+ ["连续性", a.continuity]
9071
+ ]), "");
9072
+ }
9073
+ }
9074
+ const fores = project.foreshadows ?? [];
9075
+ if (fores.length > 0) {
9076
+ out.push("## 暗线 / 伏笔", "");
9077
+ out.push(fores.map((f) => `- [${f.status}] ${f.description}${f.targetChapter !== void 0 ? `(预计第 ${f.targetChapter} 章回收)` : ""}`).join("\n"), "");
9078
+ }
9079
+ const plotlines = project.plotlines ?? [];
9080
+ if (plotlines.length > 0) {
9081
+ out.push("## 剧情线", "");
9082
+ out.push(plotlines.map((l) => `- [${l.status}] ${l.name}:${l.goal}(${l.progress})`).join("\n"), "");
9083
+ }
9084
+ const todos = project.todos ?? [];
9085
+ if (todos.length > 0) {
9086
+ out.push("## 编辑待办", "");
9087
+ out.push(todos.map((t) => `- [${t.done ? "x" : " "}] ${t.text}`).join("\n"), "");
9088
+ }
9089
+ return out.join("\n");
9090
+ }
9091
+ /**
9092
+ * 按范围导出:整本正文 / 设定 / 规划 / 角色 / 质检记录 / 项目 JSON。
9093
+ *
9094
+ * @param outputDir 书目录。
9095
+ * @param project 项目状态。
9096
+ * @param scope 导出范围。
9097
+ * @param format txt / md / json(json 只对 `project` 范围有独立含义,其余按 md 落盘)。
9098
+ * @returns 文件名、字符数与章节数。
9099
+ */
9100
+ function exportScope(outputDir, project, scope, format) {
9101
+ if (scope === "book") return exportBook(outputDir, project, format === "json" ? "md" : format);
9102
+ if (scope === "project") {
9103
+ const payload = {
9104
+ schema: "dsh-novel-forge/project@1",
9105
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
9106
+ project
9107
+ };
9108
+ const content = JSON.stringify(payload, null, 2);
9109
+ const file = `《${safeFileName(project.bookName)}》项目备份.json`;
9110
+ writeFileSync(join(outputDir, file), content, "utf8");
9111
+ return {
9112
+ file,
9113
+ chars: content.length,
9114
+ chapters: project.chapters.length
9115
+ };
9116
+ }
9117
+ const body = scope === "settings" ? renderSettings(project) : scope === "plan" ? renderPlan(project) : scope === "characters" ? renderCharacters(project) : renderReview(project);
9118
+ const ext = format === "json" ? "md" : format;
9119
+ const label = scope === "settings" ? "项目设定" : scope === "plan" ? "规划" : scope === "characters" ? "角色" : "质检记录";
9120
+ const file = `《${safeFileName(project.bookName)}》${label}.${ext}`;
9121
+ writeFileSync(join(outputDir, file), body, "utf8");
7790
9122
  return {
7791
9123
  file,
7792
- chars: content.length,
7793
- chapters: done.length
9124
+ chars: body.length,
9125
+ chapters: 0
7794
9126
  };
7795
9127
  }
7796
9128
  //#endregion
@@ -8169,6 +9501,11 @@ var ProductionRunner = class {
8169
9501
  } catch (e) {
8170
9502
  console.warn("[dsh-novel-forge] run summary/facts:", e.message);
8171
9503
  }
9504
+ if (config.autoTimeline ?? true) try {
9505
+ await extractTimelineForChapter(ctx, config, project, outputDir, no);
9506
+ } catch (e) {
9507
+ console.warn("[dsh-novel-forge] run timeline:", e.message);
9508
+ }
8172
9509
  try {
8173
9510
  markForeshadowPlanted(project, outputDir, no);
8174
9511
  } catch (e) {
@@ -8888,6 +10225,7 @@ async function* executeAction(ctx, config, project, outputDir, name, args) {
8888
10225
  const chapter = project.chapters.find((c) => c.no === no);
8889
10226
  const draft = chapter?.pendingDraft;
8890
10227
  if (chapter === void 0 || draft === void 0 || draft === "") throw new NovelActionError("contract", `章节 ${no} 修订后没有产出草稿`);
10228
+ if (chapter.file !== void 0 && chapter.file !== "") createSnapshot(outputDir, chapter, "助手修订前");
8891
10229
  const fileName = chapterFileName(chapter);
8892
10230
  mkdirSync(outputDir, { recursive: true });
8893
10231
  writeFileSync(join(outputDir, fileName), `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, "utf8");
@@ -9239,25 +10577,53 @@ async function chatOnce(ctx, config, system, history) {
9239
10577
  };
9240
10578
  }
9241
10579
  }
9242
- const request = {
9243
- provider: config.provider,
9244
- model: config.model,
9245
- messages,
9246
- system,
9247
- maxTokens: Math.max(config.maxTokens, 16e3),
9248
- temperature: .7
10580
+ /** 用指定模型跑一次(含实况打点与 token 记账)。 */
10581
+ const runOnce = async (model) => {
10582
+ const request = {
10583
+ provider: config.provider,
10584
+ model,
10585
+ messages,
10586
+ system,
10587
+ maxTokens: Math.max(config.maxTokens, 16e3),
10588
+ temperature: .7
10589
+ };
10590
+ const live = beginLiveCall({
10591
+ label: "AI 编辑对话",
10592
+ model,
10593
+ system,
10594
+ user: messages.map((m) => JSON.stringify(m.content)).join(" / ").slice(0, 8e3)
10595
+ });
10596
+ const assembler = new BlockAssembler();
10597
+ for await (const chunk of ctx.llm.stream(request)) {
10598
+ if (chunk.type === "text-delta") markFirstToken(live);
10599
+ assembler.push(chunk);
10600
+ }
10601
+ const finish = assembler.finish;
10602
+ if (finish.kind === "error" || finish.kind === "aborted") {
10603
+ const message = `助手调用失败(${finish.kind}): ${finish.failure.message}`;
10604
+ endLiveCall(live, assembler, {
10605
+ phase: "failed",
10606
+ error: message
10607
+ });
10608
+ throw new Error(message);
10609
+ }
10610
+ const blocks = assembler.blocks();
10611
+ let text = blocks.filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
10612
+ if (text === "") {
10613
+ const reasoning = blocks.filter((block) => block.type === "reasoning").map((block) => block.text).join("\n").trim();
10614
+ if (reasoning !== "") text = reasoning;
10615
+ }
10616
+ endLiveCall(live, assembler, {
10617
+ chars: text.length,
10618
+ preview: text.slice(0, 320),
10619
+ phase: text === "" ? "failed" : "completed"
10620
+ });
10621
+ return text;
9249
10622
  };
9250
- const assembler = new BlockAssembler();
9251
- for await (const chunk of ctx.llm.stream(request)) assembler.push(chunk);
9252
- const finish = assembler.finish;
9253
- if (finish.kind === "error" || finish.kind === "aborted") throw new Error(`助手调用失败(${finish.kind}): ${finish.failure.message}`);
9254
- const blocks = assembler.blocks();
9255
- let text = blocks.filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
9256
- if (text === "") {
9257
- const reasoning = blocks.filter((block) => block.type === "reasoning").map((block) => block.text).join("\n").trim();
9258
- if (reasoning !== "") text = reasoning;
9259
- }
9260
- return text;
10623
+ return withModelFallback({
10624
+ model: config.model,
10625
+ fallbackModel: config.fallbackModel
10626
+ }, runOnce);
9261
10627
  }
9262
10628
  /** Run one user turn. Yields stream frames; persists history. */
9263
10629
  async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
@@ -9918,7 +11284,7 @@ function parseJinjiangRanking(html, source) {
9918
11284
  }
9919
11285
  async function fetchHtml(url) {
9920
11286
  const response = await fetch(url, {
9921
- headers: { "user-agent": "Mozilla/5.0 (compatible; AI-Novel-Market-Radar/1.0; public-ranking-metadata-only)" },
11287
+ headers: { "user-agent": "Mozilla/5.0 (compatible; dsh-novel-forge-radar/1.0; public-ranking-metadata-only)" },
9922
11288
  signal: AbortSignal.timeout(2e4)
9923
11289
  });
9924
11290
  if (!response.ok) throw new Error(`榜单页面返回 HTTP ${response.status}`);
@@ -10219,6 +11585,7 @@ function makeRoutes(deps) {
10219
11585
  generatedFiles: listChapterFiles(config.outputDir),
10220
11586
  audit: auditState,
10221
11587
  stage: computeBookStage(project),
11588
+ usage: liveUsage(),
10222
11589
  ...truncations.length > 0 ? { truncations } : {}
10223
11590
  });
10224
11591
  }
@@ -10483,6 +11850,12 @@ function makeRoutes(deps) {
10483
11850
  } catch (error) {
10484
11851
  console.warn("[dsh-novel-forge] summary/facts failed:", error.message);
10485
11852
  }
11853
+ if (config.autoTimeline ?? true) try {
11854
+ await extractTimelineForChapter(ctx, config, project, config.outputDir, no);
11855
+ saveProject(config.outputDir, project);
11856
+ } catch (error) {
11857
+ console.warn("[dsh-novel-forge] timeline extract failed:", error.message);
11858
+ }
10486
11859
  try {
10487
11860
  const marked = markForeshadowPlanted(project, config.outputDir, no);
10488
11861
  if (marked > 0) console.log(`[dsh-novel-forge] 第${no}章已埋伏笔 ${marked} 条`);
@@ -10850,9 +12223,14 @@ function makeRoutes(deps) {
10850
12223
  writeJson(res, 400, { error: "输出目录中没有项目" });
10851
12224
  return;
10852
12225
  }
10853
- const format = body?.format === "md" ? "md" : "txt";
12226
+ const format = body?.format === "json" ? "json" : body?.format === "md" ? "md" : "txt";
12227
+ const scope = body?.scope !== void 0 && EXPORT_SCOPE_VALUES.includes(body.scope) ? body.scope : "book";
10854
12228
  try {
10855
- writeJson(res, 200, { ...exportBook(config.outputDir, project, format) });
12229
+ writeJson(res, 200, {
12230
+ ...exportScope(config.outputDir, project, scope, format),
12231
+ scope,
12232
+ format
12233
+ });
10856
12234
  } catch (error) {
10857
12235
  writeJson(res, 500, { error: error.message });
10858
12236
  }
@@ -10943,6 +12321,7 @@ function makeRoutes(deps) {
10943
12321
  mkdirSync(config.outputDir, { recursive: true });
10944
12322
  const targetPath = join(config.outputDir, fileName);
10945
12323
  if (existsSync(targetPath)) copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, "")}.bak.md`));
12324
+ createSnapshot(config.outputDir, chapter, "采纳草稿前");
10946
12325
  writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${text}\n`, "utf8");
10947
12326
  chapter.status = "written";
10948
12327
  chapter.chars = countHanzi(text);
@@ -11535,6 +12914,380 @@ function makeRoutes(deps) {
11535
12914
  });
11536
12915
  }
11537
12916
  };
12917
+ /** 查看某次调用实际发送的 Prompt(面板「查看 Prompt」按钮用)。 */
12918
+ const llmPromptRoute = {
12919
+ kind: "exact",
12920
+ path: NOVEL_API.llmPrompt,
12921
+ handler: (req, res) => {
12922
+ if (!guard(req, res, "GET")) return;
12923
+ const record = livePrompt(new URL(req.url ?? "/", "http://localhost").searchParams.get("sessionId") ?? "");
12924
+ if (record === void 0) {
12925
+ writeJson(res, 404, { error: "该调用的 Prompt 记录已过期(只保留最近 40 次调用)" });
12926
+ return;
12927
+ }
12928
+ writeJson(res, 200, record);
12929
+ }
12930
+ };
12931
+ /** 清零本次运行的用量账本。 */
12932
+ const usageResetRoute = {
12933
+ kind: "exact",
12934
+ path: NOVEL_API.usageReset,
12935
+ handler: (req, res) => {
12936
+ if (!guard(req, res, "POST")) return;
12937
+ resetLiveUsage();
12938
+ writeJson(res, 200, {
12939
+ ok: true,
12940
+ usage: liveUsage()
12941
+ });
12942
+ }
12943
+ };
12944
+ /**
12945
+ * 故事时间线:GET 列表(含规则初筛问题);POST op=extract|check|update|remove|clear。
12946
+ */
12947
+ const timelineRoute = {
12948
+ kind: "exact",
12949
+ path: NOVEL_API.timeline,
12950
+ handler: async (req, res) => {
12951
+ const config = getConfig();
12952
+ const qurl = new URL(req.url ?? "/", "http://localhost");
12953
+ if (req.method === "GET") {
12954
+ if (!isLoopbackRequest(req)) {
12955
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
12956
+ return;
12957
+ }
12958
+ const project = loadProject(resolveOutputDir(config, qurl.searchParams.get("bookId") ?? void 0));
12959
+ if (project === void 0) {
12960
+ writeJson(res, 400, { error: "输出目录中没有项目" });
12961
+ return;
12962
+ }
12963
+ writeJson(res, 200, {
12964
+ events: project.timeline ?? [],
12965
+ issues: detectTimelineIssues(project)
12966
+ });
12967
+ return;
12968
+ }
12969
+ if (!guard(req, res, "POST")) return;
12970
+ const body = await readJsonBody(req);
12971
+ const outputDir = resolveOutputDir(config, body?.bookId);
12972
+ const project = loadProject(outputDir);
12973
+ if (project === void 0) {
12974
+ writeJson(res, 400, { error: "输出目录中没有项目" });
12975
+ return;
12976
+ }
12977
+ try {
12978
+ if (body?.op === "extract") {
12979
+ const no = body.chapterNo;
12980
+ if (typeof no !== "number" || !Number.isFinite(no)) {
12981
+ writeJson(res, 400, { error: "extract 需要 chapterNo" });
12982
+ return;
12983
+ }
12984
+ const events = await extractTimelineForChapter(ctx, config, project, outputDir, no);
12985
+ saveProject(outputDir, project);
12986
+ writeJson(res, 200, {
12987
+ events,
12988
+ all: project.timeline ?? []
12989
+ });
12990
+ return;
12991
+ }
12992
+ if (body?.op === "check") {
12993
+ const ruleIssues = detectTimelineIssues(project);
12994
+ let aiIssues = [];
12995
+ try {
12996
+ aiIssues = await reviewTimeline(ctx, config, project);
12997
+ } catch (error) {
12998
+ writeJson(res, 200, {
12999
+ issues: ruleIssues,
13000
+ aiError: error.message
13001
+ });
13002
+ return;
13003
+ }
13004
+ writeJson(res, 200, { issues: [...ruleIssues, ...aiIssues] });
13005
+ return;
13006
+ }
13007
+ if (body?.op === "update") {
13008
+ const event = body.event;
13009
+ if (event === void 0 || typeof event.id !== "string") {
13010
+ writeJson(res, 400, { error: "update 需要 event" });
13011
+ return;
13012
+ }
13013
+ const list = project.timeline ?? [];
13014
+ const idx = list.findIndex((e) => e.id === event.id);
13015
+ if (idx === -1) {
13016
+ writeJson(res, 404, { error: "时间线事件不存在" });
13017
+ return;
13018
+ }
13019
+ list[idx] = {
13020
+ ...event,
13021
+ source: "manual"
13022
+ };
13023
+ project.timeline = list;
13024
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
13025
+ saveProject(outputDir, project);
13026
+ writeJson(res, 200, { events: project.timeline });
13027
+ return;
13028
+ }
13029
+ if (body?.op === "remove") {
13030
+ project.timeline = (project.timeline ?? []).filter((e) => e.id !== body.id);
13031
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
13032
+ saveProject(outputDir, project);
13033
+ writeJson(res, 200, { events: project.timeline });
13034
+ return;
13035
+ }
13036
+ if (body?.op === "clear") {
13037
+ project.timeline = [];
13038
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
13039
+ saveProject(outputDir, project);
13040
+ writeJson(res, 200, { events: [] });
13041
+ return;
13042
+ }
13043
+ writeJson(res, 400, { error: `未知 op:${String(body?.op)}` });
13044
+ } catch (error) {
13045
+ writeJson(res, 500, { error: error.message });
13046
+ }
13047
+ }
13048
+ };
13049
+ /** 章节历史版本:GET 列表;POST op=create|restore|remove|prune。 */
13050
+ const snapshotRoute = {
13051
+ kind: "exact",
13052
+ path: NOVEL_API.snapshot,
13053
+ handler: async (req, res) => {
13054
+ const config = getConfig();
13055
+ const qurl = new URL(req.url ?? "/", "http://localhost");
13056
+ if (req.method === "GET") {
13057
+ if (!isLoopbackRequest(req)) {
13058
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
13059
+ return;
13060
+ }
13061
+ const outputDir = resolveOutputDir(config, qurl.searchParams.get("bookId") ?? void 0);
13062
+ writeJson(res, 200, {
13063
+ snapshots: loadSnapshots(outputDir),
13064
+ bytes: snapshotsBytes(outputDir)
13065
+ });
13066
+ return;
13067
+ }
13068
+ if (!guard(req, res, "POST")) return;
13069
+ const body = await readJsonBody(req);
13070
+ const outputDir = resolveOutputDir(config, body?.bookId);
13071
+ const project = loadProject(outputDir);
13072
+ try {
13073
+ if (body?.op === "create") {
13074
+ if (project === void 0) {
13075
+ writeJson(res, 400, { error: "输出目录中没有项目" });
13076
+ return;
13077
+ }
13078
+ const chapter = project.chapters.find((c) => c.no === body.chapterNo);
13079
+ if (chapter === void 0) {
13080
+ writeJson(res, 404, { error: `章节 ${String(body.chapterNo)} 不在计划中` });
13081
+ return;
13082
+ }
13083
+ const snapshot = createSnapshot(outputDir, chapter, "手工存档");
13084
+ if (snapshot === void 0) {
13085
+ writeJson(res, 400, { error: "该章暂无正文文件,无法存档" });
13086
+ return;
13087
+ }
13088
+ writeJson(res, 200, {
13089
+ snapshot,
13090
+ snapshots: loadSnapshots(outputDir)
13091
+ });
13092
+ return;
13093
+ }
13094
+ if (body?.op === "restore") {
13095
+ if (project === void 0) {
13096
+ writeJson(res, 400, { error: "输出目录中没有项目" });
13097
+ return;
13098
+ }
13099
+ const id = body.id ?? "";
13100
+ const snap = loadSnapshots(outputDir).find((x) => x.id === id);
13101
+ if (snap === void 0) {
13102
+ writeJson(res, 404, { error: "快照不存在" });
13103
+ return;
13104
+ }
13105
+ const chapter = project.chapters.find((c) => c.no === snap.chapterNo);
13106
+ if (chapter === void 0) {
13107
+ writeJson(res, 404, { error: `章节 ${snap.chapterNo} 不在计划中` });
13108
+ return;
13109
+ }
13110
+ const chars = restoreSnapshot(outputDir, chapter, id);
13111
+ chapter.chars = countHanzi(readChapterFile(outputDir, chapter) ?? "");
13112
+ chapter.status = chapter.status === "approved" ? "written" : chapter.status;
13113
+ chapter.review = void 0;
13114
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
13115
+ saveProject(outputDir, project);
13116
+ writeJson(res, 200, {
13117
+ ok: true,
13118
+ chapterNo: chapter.no,
13119
+ chars,
13120
+ snapshots: loadSnapshots(outputDir)
13121
+ });
13122
+ return;
13123
+ }
13124
+ if (body?.op === "remove") {
13125
+ const ok = removeSnapshot(outputDir, body.id ?? "");
13126
+ writeJson(res, ok ? 200 : 404, {
13127
+ ok,
13128
+ snapshots: loadSnapshots(outputDir)
13129
+ });
13130
+ return;
13131
+ }
13132
+ if (body?.op === "prune") {
13133
+ writeJson(res, 200, {
13134
+ removed: pruneSnapshots(outputDir, body.keep),
13135
+ snapshots: loadSnapshots(outputDir),
13136
+ bytes: snapshotsBytes(outputDir)
13137
+ });
13138
+ return;
13139
+ }
13140
+ writeJson(res, 400, { error: `未知 op:${String(body?.op)}` });
13141
+ } catch (error) {
13142
+ writeJson(res, 500, { error: error.message });
13143
+ }
13144
+ }
13145
+ };
13146
+ /** 张力曲线:GET 数据(含规则核对);POST op=preset|set|clear。 */
13147
+ const tensionRoute = {
13148
+ kind: "exact",
13149
+ path: NOVEL_API.tension,
13150
+ handler: async (req, res) => {
13151
+ const config = getConfig();
13152
+ const qurl = new URL(req.url ?? "/", "http://localhost");
13153
+ if (req.method === "GET") {
13154
+ if (!isLoopbackRequest(req)) {
13155
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
13156
+ return;
13157
+ }
13158
+ const project = loadProject(resolveOutputDir(config, qurl.searchParams.get("bookId") ?? void 0));
13159
+ if (project === void 0) {
13160
+ writeJson(res, 400, { error: "输出目录中没有项目" });
13161
+ return;
13162
+ }
13163
+ const curve = buildCurveData(project);
13164
+ writeJson(res, 200, {
13165
+ preset: curve.preset,
13166
+ points: curve.points,
13167
+ issues: detectTensionIssues(project)
13168
+ });
13169
+ return;
13170
+ }
13171
+ if (!guard(req, res, "POST")) return;
13172
+ const body = await readJsonBody(req);
13173
+ const outputDir = resolveOutputDir(config, body?.bookId);
13174
+ const project = loadProject(outputDir);
13175
+ if (project === void 0) {
13176
+ writeJson(res, 400, { error: "输出目录中没有项目" });
13177
+ return;
13178
+ }
13179
+ if (body?.op === "preset") {
13180
+ const preset = body.preset;
13181
+ if (preset === void 0 || !TENSION_PRESETS.includes(preset)) {
13182
+ writeJson(res, 400, { error: "未知的张力曲线预设" });
13183
+ return;
13184
+ }
13185
+ project.tensionCurve = {
13186
+ ...project.tensionCurve ?? {},
13187
+ preset
13188
+ };
13189
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
13190
+ saveProject(outputDir, project);
13191
+ writeJson(res, 200, {
13192
+ ok: true,
13193
+ preset
13194
+ });
13195
+ return;
13196
+ }
13197
+ if (body?.op === "set") {
13198
+ const no = body.chapterNo;
13199
+ if (typeof no !== "number" || !Number.isFinite(no)) {
13200
+ writeJson(res, 400, { error: "set 需要 chapterNo" });
13201
+ return;
13202
+ }
13203
+ const chapter = project.chapters.find((c) => c.no === no);
13204
+ if (chapter === void 0) {
13205
+ writeJson(res, 404, { error: `章节 ${no} 不在计划中` });
13206
+ return;
13207
+ }
13208
+ if (body.tension === void 0) chapter.tension = void 0;
13209
+ else {
13210
+ if (body.tension < 0 || body.tension > 100) {
13211
+ writeJson(res, 400, { error: `张力需在 0-100 之间` });
13212
+ return;
13213
+ }
13214
+ chapter.tension = clampTension(body.tension);
13215
+ }
13216
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
13217
+ saveProject(outputDir, project);
13218
+ writeJson(res, 200, {
13219
+ ok: true,
13220
+ chapterNo: no,
13221
+ tension: chapter.tension
13222
+ });
13223
+ return;
13224
+ }
13225
+ if (body?.op === "clear") {
13226
+ for (const c of project.chapters) c.tension = void 0;
13227
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
13228
+ saveProject(outputDir, project);
13229
+ writeJson(res, 200, { ok: true });
13230
+ return;
13231
+ }
13232
+ writeJson(res, 400, { error: `未知 op:${String(body?.op)}` });
13233
+ }
13234
+ };
13235
+ /** 提示词槽位:GET 列表(含说明与上限);POST 写入或清空单个槽位。 */
13236
+ const promptSlotsRoute = {
13237
+ kind: "exact",
13238
+ path: NOVEL_API.promptSlots,
13239
+ handler: async (req, res) => {
13240
+ const config = getConfig();
13241
+ const qurl = new URL(req.url ?? "/", "http://localhost");
13242
+ if (req.method === "GET") {
13243
+ if (!isLoopbackRequest(req)) {
13244
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
13245
+ return;
13246
+ }
13247
+ const project = loadProject(resolveOutputDir(config, qurl.searchParams.get("bookId") ?? void 0));
13248
+ if (project === void 0) {
13249
+ writeJson(res, 400, { error: "输出目录中没有项目" });
13250
+ return;
13251
+ }
13252
+ const values = new Map(readPromptSlots(project).map((s) => [s.id, s.text]));
13253
+ writeJson(res, 200, { slots: PROMPT_SLOTS.map((spec) => ({
13254
+ id: spec.id,
13255
+ label: spec.label,
13256
+ hint: spec.hint,
13257
+ placeholder: spec.placeholder,
13258
+ maxChars: spec.maxChars,
13259
+ value: values.get(spec.id) ?? ""
13260
+ })) });
13261
+ return;
13262
+ }
13263
+ if (!guard(req, res, "POST")) return;
13264
+ const body = await readJsonBody(req);
13265
+ const outputDir = resolveOutputDir(config, body?.bookId);
13266
+ const project = loadProject(outputDir);
13267
+ if (project === void 0) {
13268
+ writeJson(res, 400, { error: "输出目录中没有项目" });
13269
+ return;
13270
+ }
13271
+ let value;
13272
+ try {
13273
+ value = normalizeSlotValue(String(body?.id ?? ""), String(body?.value ?? ""));
13274
+ } catch (error) {
13275
+ writeJson(res, 400, { error: error.message });
13276
+ return;
13277
+ }
13278
+ const slots = { ...project.promptSlots ?? {} };
13279
+ if (value === "") delete slots[body.id];
13280
+ else slots[body.id] = value;
13281
+ project.promptSlots = slots;
13282
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
13283
+ saveProject(outputDir, project);
13284
+ writeJson(res, 200, {
13285
+ ok: true,
13286
+ id: body.id,
13287
+ chars: value.length
13288
+ });
13289
+ }
13290
+ };
11538
13291
  const bookshelfRoute = {
11539
13292
  kind: "exact",
11540
13293
  path: NOVEL_API.bookshelf,
@@ -13172,6 +14925,12 @@ function makeRoutes(deps) {
13172
14925
  llmLiveRoute,
13173
14926
  assistantRoute,
13174
14927
  assistantHistoryRoute,
14928
+ llmPromptRoute,
14929
+ usageResetRoute,
14930
+ timelineRoute,
14931
+ snapshotRoute,
14932
+ tensionRoute,
14933
+ promptSlotsRoute,
13175
14934
  assistantClearRoute,
13176
14935
  bookshelfRoute,
13177
14936
  bookshelfActivateRoute,
@@ -13310,6 +15069,8 @@ const Config = z.object({
13310
15069
  generateModel: z.string().default(""),
13311
15070
  reviewModel: z.string().default(""),
13312
15071
  auditModel: z.string().default(""),
15072
+ fallbackModel: z.string().default(""),
15073
+ autoTimeline: z.boolean().default(true),
13313
15074
  reasoningEffort: z.union([
13314
15075
  "off",
13315
15076
  "low",
@@ -13341,6 +15102,8 @@ const DEFAULT_PROVIDER = "deepseek-official";
13341
15102
  const DEFAULT_MODEL = "deepseek-flash";
13342
15103
  const DEFAULT_REASONING_EFFORT = "off";
13343
15104
  const DEFAULT_ANALYSIS_REASONING = "low";
15105
+ const DEFAULT_FALLBACK_MODEL = "";
15106
+ const DEFAULT_AUTO_TIMELINE = true;
13344
15107
  const DEFAULT_CHAPTER_CHARS = 3500;
13345
15108
  const DEFAULT_MAX_TOKENS = 12e3;
13346
15109
  const DEFAULT_REVIEW_PASS_SCORE = 70;
@@ -13361,6 +15124,8 @@ function resolveConfig(value) {
13361
15124
  generateModel: value?.generateModel,
13362
15125
  reviewModel: value?.reviewModel,
13363
15126
  auditModel: value?.auditModel,
15127
+ fallbackModel: value?.fallbackModel ?? DEFAULT_FALLBACK_MODEL,
15128
+ autoTimeline: value?.autoTimeline ?? DEFAULT_AUTO_TIMELINE,
13364
15129
  reasoningEffort: value?.reasoningEffort ?? DEFAULT_REASONING_EFFORT,
13365
15130
  analysisReasoning: value?.analysisReasoning ?? DEFAULT_ANALYSIS_REASONING,
13366
15131
  chapterChars: value?.chapterChars ?? DEFAULT_CHAPTER_CHARS,
@@ -13413,6 +15178,8 @@ function apply(ctx, config) {
13413
15178
  if (patch.generateModel !== void 0) next.generateModel = patch.generateModel;
13414
15179
  if (patch.reviewModel !== void 0) next.reviewModel = patch.reviewModel;
13415
15180
  if (patch.auditModel !== void 0) next.auditModel = patch.auditModel;
15181
+ if (patch.fallbackModel !== void 0) next.fallbackModel = patch.fallbackModel;
15182
+ if (patch.autoTimeline !== void 0) next.autoTimeline = patch.autoTimeline;
13416
15183
  if (patch.reasoningEffort !== void 0) next.reasoningEffort = patch.reasoningEffort;
13417
15184
  if (patch.analysisReasoning !== void 0) next.analysisReasoning = patch.analysisReasoning;
13418
15185
  if (patch.chapterChars !== void 0) next.chapterChars = patch.chapterChars;