@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.
- package/README.md +4 -5
- package/lib/client.js +2470 -858
- package/lib/index.js +2155 -388
- package/lib/types/action-guard.d.ts +1 -1
- package/lib/types/assets.d.ts +7 -7
- package/lib/types/client/api.d.ts +31 -1
- package/lib/types/client/llmLive.d.ts +12 -14
- package/lib/types/client/panel/LiveFeedLog.d.ts +5 -0
- package/lib/types/client/panel/ModelManager.d.ts +2 -2
- package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
- package/lib/types/client/panel/TensionView.d.ts +14 -0
- package/lib/types/client/panel/TimelineView.d.ts +14 -0
- package/lib/types/engine.d.ts +40 -2
- package/lib/types/index.d.ts +4 -0
- package/lib/types/llm-live.d.ts +65 -34
- package/lib/types/llm-retry.d.ts +38 -0
- package/lib/types/market-radar-sources.d.ts +1 -1
- package/lib/types/prompt-slots.d.ts +57 -0
- package/lib/types/prompting.d.ts +2 -2
- package/lib/types/protocol.d.ts +283 -3
- package/lib/types/snapshots.d.ts +46 -0
- package/lib/types/stage-contract.d.ts +1 -1
- package/lib/types/tension.d.ts +65 -0
- package/lib/types/timeline.d.ts +37 -0
- package/package.json +3 -2
- package/src/action-guard.ts +1 -1
- package/src/assets.ts +300 -268
- package/src/assistant.ts +45 -29
- package/src/client/api.ts +76 -2
- package/src/client/llmLive.ts +26 -16
- package/src/client/panel/AssetsTab.tsx +2 -2
- package/src/client/panel/LiveFeedLog.tsx +204 -67
- package/src/client/panel/ModelManager.tsx +2 -2
- package/src/client/panel/NovelPanel.tsx +127 -13
- package/src/client/panel/PromptSlotsView.tsx +97 -0
- package/src/client/panel/SettingsView.tsx +35 -0
- package/src/client/panel/TensionView.tsx +196 -0
- package/src/client/panel/TimelineView.tsx +192 -0
- package/src/engine.ts +549 -108
- package/src/index.ts +12 -0
- package/src/llm-live.ts +260 -68
- package/src/llm-retry.ts +77 -0
- package/src/market-radar-sources.ts +173 -173
- package/src/prompt-slots.ts +125 -0
- package/src/prompting.ts +8 -8
- package/src/protocol.ts +314 -3
- package/src/routes.ts +325 -6
- package/src/run.ts +4 -0
- package/src/snapshots.ts +174 -0
- package/src/stage-contract.ts +189 -189
- package/src/tension.ts +240 -0
- 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
|
-
/**
|
|
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
|
|
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: "
|
|
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
|
-
/**
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
/**
|
|
3228
|
-
function
|
|
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
|
-
"
|
|
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
|
-
|
|
4267
|
-
|
|
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
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
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
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
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(
|
|
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
|
-
"
|
|
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
|
-
*
|
|
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
|
|
7107
|
+
const primaryModel = request.model ?? config.model;
|
|
7108
|
+
let rewritten = "";
|
|
6178
7109
|
let streamError;
|
|
6179
|
-
for
|
|
6180
|
-
|
|
6181
|
-
if (
|
|
6182
|
-
|
|
6183
|
-
|
|
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
|
|
7219
|
+
const primaryModel = request.model ?? config.model;
|
|
7220
|
+
let newBody = "";
|
|
6258
7221
|
let streamError;
|
|
6259
|
-
for
|
|
6260
|
-
|
|
6261
|
-
if (
|
|
6262
|
-
|
|
6263
|
-
|
|
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
|
|
7364
|
+
const primaryModel = request.model ?? config.model;
|
|
7365
|
+
let body = "";
|
|
6370
7366
|
let streamError;
|
|
6371
|
-
for
|
|
6372
|
-
|
|
6373
|
-
if (
|
|
6374
|
-
|
|
6375
|
-
|
|
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:
|
|
7793
|
-
chapters:
|
|
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
|
-
|
|
9243
|
-
|
|
9244
|
-
|
|
9245
|
-
|
|
9246
|
-
|
|
9247
|
-
|
|
9248
|
-
|
|
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
|
-
|
|
9251
|
-
|
|
9252
|
-
|
|
9253
|
-
|
|
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;
|
|
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, {
|
|
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;
|