@waterwx/dsh-novel-forge 1.10.1 → 2.0.1-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 +8 -3
- package/assets/styles/guoman-2d-gufeng.webp +0 -0
- package/assets/styles/guoman-3d-tianyuan.webp +0 -0
- package/assets/styles/guoman-3d-xuanhuan.webp +0 -0
- package/assets/styles/hyperreal-3d.webp +0 -0
- package/assets/styles/sandiao-qban.webp +0 -0
- package/assets/styles/urban-lightreal-3d.webp +0 -0
- package/lib/client.js +3456 -2133
- package/lib/index.js +1975 -438
- package/lib/types/ai-scan.d.ts +36 -0
- package/lib/types/assets.d.ts +1 -1
- package/lib/types/client/api.d.ts +8 -1
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/panel/AuthorHome.d.ts +4 -2
- package/lib/types/client/panel/FlowGuide.d.ts +6 -5
- package/lib/types/client/panel/MangaRoleLibrary.d.ts +3 -1
- package/lib/types/client/panel/MangaWorkspace.d.ts +2 -5
- package/lib/types/client/panel/PropLibrary.d.ts +8 -0
- package/lib/types/client/panel/SceneLibrary.d.ts +3 -1
- package/lib/types/client/panel/SettingsView.d.ts +4 -2
- package/lib/types/engine.d.ts +50 -21
- package/lib/types/index.d.ts +2 -2
- package/lib/types/manga-genre-rules.d.ts +22 -0
- package/lib/types/novel-context.d.ts +80 -0
- package/lib/types/prompt-utils.d.ts +35 -0
- package/lib/types/protocol.d.ts +104 -12
- package/lib/types/review-policy.d.ts +97 -0
- package/lib/types/style-library.d.ts +3 -3
- package/package.json +21 -24
- package/src/ai-scan.ts +179 -0
- package/src/assets.ts +353 -9
- package/src/client/api.ts +11 -1
- package/src/client/index.ts +1 -1
- package/src/client/mount.tsx +1 -1
- package/src/client/panel/AdaptModeView.tsx +2 -2
- package/src/client/panel/AssistantTab.tsx +1 -1
- package/src/client/panel/AuthorHome.tsx +5 -3
- package/src/client/panel/FlowGuide.tsx +15 -14
- package/src/client/panel/MangaRoleLibrary.tsx +1002 -810
- package/src/client/panel/MangaWorkspace.tsx +715 -359
- package/src/client/panel/ModelManager.tsx +1 -1
- package/src/client/panel/NovelPanel.tsx +4620 -4655
- package/src/client/panel/PropLibrary.tsx +175 -0
- package/src/client/panel/SceneLibrary.tsx +453 -396
- package/src/client/panel/SettingsView.tsx +24 -30
- package/src/client/panel/StoryboardTab.tsx +6 -0
- package/src/client/panel/panel.module.css +165 -4
- package/src/engine.ts +796 -286
- package/src/index.ts +9 -7
- package/src/manga-genre-rules.ts +87 -0
- package/src/novel-context.ts +287 -0
- package/src/prompt-utils.ts +73 -0
- package/src/protocol.ts +2233 -2148
- package/src/review-policy.ts +161 -0
- package/src/routes.ts +3419 -3359
- package/src/style-library.ts +32 -26
package/lib/index.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { homedir, tmpdir } from "node:os";
|
|
2
2
|
import { basename, extname, join } from "node:path";
|
|
3
|
-
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
4
3
|
import z from "schemastery";
|
|
5
4
|
import { spawn } from "node:child_process";
|
|
6
|
-
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
7
6
|
import { randomBytes } from "node:crypto";
|
|
8
7
|
import { fileURLToPath } from "node:url";
|
|
9
8
|
import { BlockAssembler, ReasoningEffortId, createAssistantMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
@@ -199,6 +198,237 @@ const BUILTIN_STYLE_TEMPLATES = [
|
|
|
199
198
|
"鼓励现实落差",
|
|
200
199
|
"鼓励嘴硬补偿"
|
|
201
200
|
]
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
key: "cultivation-breakthrough",
|
|
204
|
+
name: "修炼突破流",
|
|
205
|
+
description: "以境界突破、战力碾压和资源争夺为核心,强调突破前后的反差与爽感。",
|
|
206
|
+
category: "修炼流",
|
|
207
|
+
applicableGenres: [
|
|
208
|
+
"仙侠",
|
|
209
|
+
"玄幻",
|
|
210
|
+
"都市异能"
|
|
211
|
+
],
|
|
212
|
+
proseRules: [
|
|
213
|
+
"突破前铺垫压抑与困境,突破时释放能量与威压,突破后立即兑现碾压收益。",
|
|
214
|
+
"场景单元按「困境→闭关/机缘→突破→碾压→新目标」推进。",
|
|
215
|
+
"战力体系严格遵守道藏设定,不随意膨胀;每次突破有明确代价或限制。"
|
|
216
|
+
],
|
|
217
|
+
dialogueRules: ["突破前角色隐忍克制,突破后语气自信但不浮夸。", "对手从轻视到震惊的反应通过对话和动作体现,不直接解说。"],
|
|
218
|
+
languageRules: ["战斗场景用短句和动作词,突破场景用感官描写(光/声/压力)。", "减少修炼过程的流水账,聚焦关键节点和突破瞬间。"],
|
|
219
|
+
rhythmRules: ["突破节奏先抑后扬,压抑段不超过3段,突破段要快且有冲击力。", "每章至少一个战力或境界的明确进展点。"],
|
|
220
|
+
defaultAntiAiRuleKeys: [
|
|
221
|
+
"禁止总结主题",
|
|
222
|
+
"禁止段尾升华",
|
|
223
|
+
"连续三段解释性叙事"
|
|
224
|
+
]
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
key: "face-slapping",
|
|
228
|
+
name: "装逼打脸流",
|
|
229
|
+
description: "身份隐藏→被轻视→展露实力→全场震惊,强调反差爽感与节奏控制。",
|
|
230
|
+
category: "爽文流",
|
|
231
|
+
applicableGenres: [
|
|
232
|
+
"都市",
|
|
233
|
+
"玄幻",
|
|
234
|
+
"仙侠",
|
|
235
|
+
"重生"
|
|
236
|
+
],
|
|
237
|
+
proseRules: [
|
|
238
|
+
"打脸前三段内必须建立轻视/挑衅,打脸过程不超过两段,震惊反应要充分。",
|
|
239
|
+
"场景单元按「隐藏→挑衅→展露→震惊→新挑衅」循环推进。",
|
|
240
|
+
"主角实力展露要有铺垫和依据,不凭空开挂。"
|
|
241
|
+
],
|
|
242
|
+
dialogueRules: [
|
|
243
|
+
"挑衅者台词要具体且有针对性,避免泛泛的\"你也配\"。",
|
|
244
|
+
"主角话少而精准,用行动和结果说话,不嘴炮解释。",
|
|
245
|
+
"围观者反应分层:先不信→再震惊→最后讨好/畏惧。"
|
|
246
|
+
],
|
|
247
|
+
languageRules: ["打脸场景用短句和动作,震惊场景用群像反应。", "避免\"全场寂静\"\"众人哗然\"等套话,用具体人物反应代替。"],
|
|
248
|
+
rhythmRules: ["快节奏,打脸间隔不超过3章,每章至少一个小反转。", "装逼要克制,主角不主动炫耀,被动展露更有爽感。"],
|
|
249
|
+
defaultAntiAiRuleKeys: [
|
|
250
|
+
"禁止总结主题",
|
|
251
|
+
"对话纯功能推进",
|
|
252
|
+
"AI 高频套话"
|
|
253
|
+
]
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
key: "power-struggle",
|
|
257
|
+
name: "权谋博弈流",
|
|
258
|
+
description: "以信息差、布局和反制为核心,强调对话潜台词与多方博弈。",
|
|
259
|
+
category: "权谋流",
|
|
260
|
+
applicableGenres: [
|
|
261
|
+
"历史",
|
|
262
|
+
"宫斗",
|
|
263
|
+
"官场",
|
|
264
|
+
"仙侠争霸"
|
|
265
|
+
],
|
|
266
|
+
proseRules: [
|
|
267
|
+
"每章至少一次信息不对称的利用或破解,布局要有伏笔和回收。",
|
|
268
|
+
"场景单元按「情报→布局→试探→反制→结果」推进。",
|
|
269
|
+
"多方势力各有目标和底线,不做纯粹的工具人反派。"
|
|
270
|
+
],
|
|
271
|
+
dialogueRules: [
|
|
272
|
+
"对话充满潜台词,表面客气实则交锋,关键信息藏在半句和停顿里。",
|
|
273
|
+
"不同势力角色的语言风格和立场要明确区分。",
|
|
274
|
+
"避免角色直接说出计划和意图,通过行动和结果揭示。"
|
|
275
|
+
],
|
|
276
|
+
languageRules: ["正式、克制的语言,避免口语化和现代网络用语。", "用细节(眼神/手势/器物)暗示人物真实想法。"],
|
|
277
|
+
rhythmRules: ["中慢节奏,布局段可以慢,但反转和收网段要快。", "每3-5章一个小高潮(布局见效或反制成功)。"],
|
|
278
|
+
defaultAntiAiRuleKeys: [
|
|
279
|
+
"禁止解释型心理描写",
|
|
280
|
+
"禁止直接说教",
|
|
281
|
+
"对话纯功能推进"
|
|
282
|
+
]
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
key: "sweet-romance",
|
|
286
|
+
name: "甜宠撒糖流",
|
|
287
|
+
description: "高糖互动+宠溺细节+情感升温,少虐多甜,强调心动瞬间。",
|
|
288
|
+
category: "言情流",
|
|
289
|
+
applicableGenres: [
|
|
290
|
+
"现言",
|
|
291
|
+
"古言",
|
|
292
|
+
"甜宠",
|
|
293
|
+
"校园"
|
|
294
|
+
],
|
|
295
|
+
proseRules: [
|
|
296
|
+
"每章至少一个心动或撒糖细节,情感进展要有明确节点。",
|
|
297
|
+
"场景单元按「日常互动→心动瞬间→关系推进→新暧昧」推进。",
|
|
298
|
+
"误会不超过2章,冲突要小而温馨,不搞虐恋。"
|
|
299
|
+
],
|
|
300
|
+
dialogueRules: [
|
|
301
|
+
"对话自然亲昵,有专属称呼和互动习惯,避免书面化表白。",
|
|
302
|
+
"男主台词宠溺但不油腻,女主可以害羞但不傻白甜。",
|
|
303
|
+
"用对话中的停顿、转移话题暗示心动,不直接说\"我喜欢你\"。"
|
|
304
|
+
],
|
|
305
|
+
languageRules: ["温暖、细腻的语言,多用感官细节(温度/气味/触感)。", "避免\"心如鹿撞\"\"脸红心跳\"等套话,用具体动作代替。"],
|
|
306
|
+
rhythmRules: ["中慢节奏,日常段可以慢,但心动瞬间要聚焦和放大。", "每5章一个关系突破(牵手/拥抱/表白等)。"],
|
|
307
|
+
defaultAntiAiRuleKeys: [
|
|
308
|
+
"禁止段尾升华",
|
|
309
|
+
"禁止解释型心理描写",
|
|
310
|
+
"鼓励无意义小动作"
|
|
311
|
+
]
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
key: "competitive-blood",
|
|
315
|
+
name: "竞技热血流",
|
|
316
|
+
description: "操作细节+战术博弈+赛事逆转,强调燃点密集与成长曲线。",
|
|
317
|
+
category: "竞技流",
|
|
318
|
+
applicableGenres: [
|
|
319
|
+
"电竞",
|
|
320
|
+
"体育",
|
|
321
|
+
"网游",
|
|
322
|
+
"卡牌"
|
|
323
|
+
],
|
|
324
|
+
proseRules: [
|
|
325
|
+
"比赛场景要有具体操作/战术细节,不写\"他很强\"而是写\"他怎么强\"。",
|
|
326
|
+
"场景单元按「训练/准备→劣势→战术调整→逆转→赛后成长」推进。",
|
|
327
|
+
"对手要有实力和特点,不做纯粹的经验包。"
|
|
328
|
+
],
|
|
329
|
+
dialogueRules: [
|
|
330
|
+
"队友对话有战术讨论和互相鼓励,对手台词有挑衅和认可。",
|
|
331
|
+
"解说/旁白可以有,但不能代替比赛过程本身。",
|
|
332
|
+
"角色在高压下的语言要简短有力,避免长篇大论。"
|
|
333
|
+
],
|
|
334
|
+
languageRules: ["比赛场景用短句和动作词,节奏快,有画面感。", "操作描述要专业且准确,避免外行话。"],
|
|
335
|
+
rhythmRules: ["快节奏,比赛段要紧凑,日常训练段可以稍缓。", "每场比赛至少一个逆转或高光时刻,每章一个小燃点。"],
|
|
336
|
+
defaultAntiAiRuleKeys: [
|
|
337
|
+
"禁止总结主题",
|
|
338
|
+
"连续三段解释性叙事",
|
|
339
|
+
"AI 高频套话"
|
|
340
|
+
]
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
key: "comedy-roast",
|
|
344
|
+
name: "吐槽搞笑流",
|
|
345
|
+
description: "旁白吐槽+角色反差+无厘头,节奏轻快,强调笑点密度。",
|
|
346
|
+
category: "搞笑流",
|
|
347
|
+
applicableGenres: [
|
|
348
|
+
"轻小说",
|
|
349
|
+
"二次元",
|
|
350
|
+
"都市",
|
|
351
|
+
"无限流"
|
|
352
|
+
],
|
|
353
|
+
proseRules: [
|
|
354
|
+
"每章至少3个笑点,笑点来自反差、误解或吐槽,不依赖网络梗。",
|
|
355
|
+
"场景单元按「正常展开→反差/误解→吐槽→意外结果」推进。",
|
|
356
|
+
"搞笑不影响主线推进,笑点服务于剧情和人物。"
|
|
357
|
+
],
|
|
358
|
+
dialogueRules: [
|
|
359
|
+
"主角吐槽要精准且有个人风格,其他角色负责一本正经地制造槽点。",
|
|
360
|
+
"对话节奏快,有来有回,避免冷场。",
|
|
361
|
+
"允许打破第四面墙的吐槽,但不能滥用。"
|
|
362
|
+
],
|
|
363
|
+
languageRules: ["口语化、轻快的语言,允许夸张和无厘头。", "吐槽用括号或单独段落,不与叙事混淆。"],
|
|
364
|
+
rhythmRules: ["快节奏,笑点密集,不拖沓。", "每段不超过3句,长段落要拆。"],
|
|
365
|
+
defaultAntiAiRuleKeys: [
|
|
366
|
+
"禁止段尾升华",
|
|
367
|
+
"禁止直接说教",
|
|
368
|
+
"鼓励无意义小动作"
|
|
369
|
+
]
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
key: "atmosphere-horror",
|
|
373
|
+
name: "氛围惊悚流",
|
|
374
|
+
description: "环境细节+心理暗示+信息延迟,恐惧不直说,靠氛围营造。",
|
|
375
|
+
category: "恐怖流",
|
|
376
|
+
applicableGenres: [
|
|
377
|
+
"灵异",
|
|
378
|
+
"惊悚",
|
|
379
|
+
"克苏鲁",
|
|
380
|
+
"无限恐怖"
|
|
381
|
+
],
|
|
382
|
+
proseRules: [
|
|
383
|
+
"恐惧来自未知和异常,不直接描写怪物/鬼魂,用反应和痕迹暗示。",
|
|
384
|
+
"场景单元按「日常→异常细节→误解/忽视→危机爆发→余悸」推进。",
|
|
385
|
+
"信息延迟:读者和主角同时发现异常,不提前剧透。"
|
|
386
|
+
],
|
|
387
|
+
dialogueRules: [
|
|
388
|
+
"角色对话克制,恐惧通过停顿、重复、语无伦次体现。",
|
|
389
|
+
"避免角色直接说\"好可怕\"\"有鬼\",用行动和反应代替。",
|
|
390
|
+
"关键信息藏在半句和打断里。"
|
|
391
|
+
],
|
|
392
|
+
languageRules: [
|
|
393
|
+
"冷色调、精确的环境描写,多用听觉和触觉(视觉反而少)。",
|
|
394
|
+
"句子短而碎,制造紧张感;长句用于压抑和拖延。",
|
|
395
|
+
"避免\"毛骨悚然\"\"不寒而栗\"等套话,用具体感官代替。"
|
|
396
|
+
],
|
|
397
|
+
rhythmRules: ["慢节奏铺垫,快节奏爆发,爆发后留余悸。", "每3章一个小高潮(异常确认或危机爆发)。"],
|
|
398
|
+
defaultAntiAiRuleKeys: [
|
|
399
|
+
"禁止解释型心理描写",
|
|
400
|
+
"禁止总结主题",
|
|
401
|
+
"段落长度过于整齐"
|
|
402
|
+
]
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
key: "hard-scifi",
|
|
406
|
+
name: "硬核科技流",
|
|
407
|
+
description: "技术细节+逻辑推演+文明思辨,信息密度高,强调设定自洽。",
|
|
408
|
+
category: "科幻流",
|
|
409
|
+
applicableGenres: [
|
|
410
|
+
"科幻",
|
|
411
|
+
"星际",
|
|
412
|
+
"赛博朋克",
|
|
413
|
+
"机甲"
|
|
414
|
+
],
|
|
415
|
+
proseRules: [
|
|
416
|
+
"科技设定要有逻辑自洽的原理,不做\"黑箱\"解释。",
|
|
417
|
+
"场景单元按「问题→技术分析→方案→实施→后果」推进。",
|
|
418
|
+
"技术服务于剧情和人物,不为炫技而炫技。"
|
|
419
|
+
],
|
|
420
|
+
dialogueRules: [
|
|
421
|
+
"专业对话有术语和逻辑,但要让非专业读者能理解核心。",
|
|
422
|
+
"角色争论要有技术依据,不做情绪化争吵。",
|
|
423
|
+
"避免角色直接解说设定,通过问题和讨论揭示。"
|
|
424
|
+
],
|
|
425
|
+
languageRules: ["精确、理性的语言,避免模糊和情绪化表达。", "技术描述要具体且可想象,不堆砌名词。"],
|
|
426
|
+
rhythmRules: ["中慢节奏,技术推演段可以慢,但行动段要快。", "每5章一个技术突破或危机解决。"],
|
|
427
|
+
defaultAntiAiRuleKeys: [
|
|
428
|
+
"禁止直接说教",
|
|
429
|
+
"禁止总结主题",
|
|
430
|
+
"连续三段解释性叙事"
|
|
431
|
+
]
|
|
202
432
|
}
|
|
203
433
|
];
|
|
204
434
|
/** 内置全局反 AI 规则(来自 AI-Novel-Writing-Assistant 内置 DEFAULT_ANTI_AI_RULES)。 */
|
|
@@ -326,6 +556,61 @@ const BUILTIN_ANTI_AI_RULES = [
|
|
|
326
556
|
fix: "给角色补一句嘴硬找补或自我合理化,保持人设温度。",
|
|
327
557
|
detectPatterns: [],
|
|
328
558
|
builtin: true
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
name: "禁止心中暗道/脑海浮现",
|
|
562
|
+
avoid: "用\"心中暗道\"\"脑海中\"\"心里想\"\"暗自思忖\"等句式直接暴露角色内心。",
|
|
563
|
+
fix: "把内心活动改成动作、表情、语气或行为结果,让读者自己推断。",
|
|
564
|
+
detectPatterns: [
|
|
565
|
+
"心中暗道",
|
|
566
|
+
"脑海中",
|
|
567
|
+
"心里想",
|
|
568
|
+
"暗自思忖",
|
|
569
|
+
"心中暗想",
|
|
570
|
+
"心里暗道"
|
|
571
|
+
],
|
|
572
|
+
builtin: true
|
|
573
|
+
},
|
|
574
|
+
{
|
|
575
|
+
name: "禁止仿佛/好像比喻滥用",
|
|
576
|
+
avoid: "单章\"仿佛\"\"好像\"\"犹如\"\"宛如\"等比喻词超过5次,产生AI套路感。",
|
|
577
|
+
fix: "减少比喻频率,每个比喻必须是新造的、有具体画面的,不用陈词滥调。",
|
|
578
|
+
detectPatterns: [
|
|
579
|
+
"仿佛",
|
|
580
|
+
"好像",
|
|
581
|
+
"犹如",
|
|
582
|
+
"宛如",
|
|
583
|
+
"好似"
|
|
584
|
+
],
|
|
585
|
+
builtin: true
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
name: "禁止过度排比堆砌",
|
|
589
|
+
avoid: "连续3句以上相同句式或排比结构,显得机械和刻意。",
|
|
590
|
+
fix: "打散句式,用长短句交替,排比不超过2句。",
|
|
591
|
+
detectPatterns: [],
|
|
592
|
+
builtin: true
|
|
593
|
+
},
|
|
594
|
+
{
|
|
595
|
+
name: "禁止角色全知泄露",
|
|
596
|
+
avoid: "角色说出或知道他/她不可能知道的信息,破坏视角一致性。",
|
|
597
|
+
fix: "严格遵守视角限制,角色只能基于已有信息行动和判断。",
|
|
598
|
+
detectPatterns: [],
|
|
599
|
+
builtin: true
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
name: "鼓励环境细节锚定",
|
|
603
|
+
avoid: "(鼓励类)情绪和氛围缺少具体环境物件锚定,全靠抽象形容词。",
|
|
604
|
+
fix: "用具体物件、光线、声音、温度等环境细节承载情绪,不直接说\"紧张\"\"悲伤\"。",
|
|
605
|
+
detectPatterns: [],
|
|
606
|
+
builtin: true
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
name: "鼓励角色语言差异化",
|
|
610
|
+
avoid: "(鼓励类)所有角色说话风格雷同,没有口头禅、用词习惯或句式差异。",
|
|
611
|
+
fix: "给每个主要角色设定独特的语言习惯(口头禅/用词偏好/句式长短),对话时严格区分。",
|
|
612
|
+
detectPatterns: [],
|
|
613
|
+
builtin: true
|
|
329
614
|
}
|
|
330
615
|
];
|
|
331
616
|
/** 内置题材基底库(常用网文题材树,跨书复用)。 */
|
|
@@ -439,6 +724,224 @@ const BUILTIN_GENRE_LIBRARY = [
|
|
|
439
724
|
description: "末世异变中觉醒能力不断进化,强调战力成长与危机求生。",
|
|
440
725
|
children: []
|
|
441
726
|
}]
|
|
727
|
+
},
|
|
728
|
+
{
|
|
729
|
+
name: "都市生活",
|
|
730
|
+
description: "现代都市日常背景,无超能力或超能力为辅,读者期待身份反差、职场逆袭与生活烟火气。",
|
|
731
|
+
children: [
|
|
732
|
+
{
|
|
733
|
+
name: "赘婿逆袭",
|
|
734
|
+
description: "开局隐忍赘婿,展露真实身份后一路打脸,强调身份反差与爽感。",
|
|
735
|
+
children: []
|
|
736
|
+
},
|
|
737
|
+
{
|
|
738
|
+
name: "神医归来",
|
|
739
|
+
description: "医术高超的主角回归都市,治病救人积累人脉,强调专业碾压与感恩回馈。",
|
|
740
|
+
children: []
|
|
741
|
+
},
|
|
742
|
+
{
|
|
743
|
+
name: "兵王归隐",
|
|
744
|
+
description: "退役兵王/特工回归都市,低调行事却屡被招惹,强调武力碾压与守护。",
|
|
745
|
+
children: []
|
|
746
|
+
},
|
|
747
|
+
{
|
|
748
|
+
name: "奶爸日常",
|
|
749
|
+
description: "主角带娃生活,温馨治愈与成长并行,强调亲子互动与生活细节。",
|
|
750
|
+
children: []
|
|
751
|
+
},
|
|
752
|
+
{
|
|
753
|
+
name: "校园青春",
|
|
754
|
+
description: "校园背景的成长与恋爱,强调青涩感、友情与梦想。",
|
|
755
|
+
children: []
|
|
756
|
+
},
|
|
757
|
+
{
|
|
758
|
+
name: "鉴宝捡漏",
|
|
759
|
+
description: "古玩/收藏/拍卖背景,主角凭眼力捡漏暴富,强调专业知识与反差爽感。",
|
|
760
|
+
children: []
|
|
761
|
+
}
|
|
762
|
+
]
|
|
763
|
+
},
|
|
764
|
+
{
|
|
765
|
+
name: "言情",
|
|
766
|
+
description: "以情感关系为核心,读者期待心动、拉扯、甜虐交织与情感归宿。",
|
|
767
|
+
children: [
|
|
768
|
+
{
|
|
769
|
+
name: "现言甜宠",
|
|
770
|
+
description: "现代背景甜蜜恋爱,男主强势专一,强调撒糖与日常互动。",
|
|
771
|
+
children: []
|
|
772
|
+
},
|
|
773
|
+
{
|
|
774
|
+
name: "古言宫斗",
|
|
775
|
+
description: "古代后宫/宅斗背景,女主在权谋中求生与上位,强调心机与反转。",
|
|
776
|
+
children: []
|
|
777
|
+
},
|
|
778
|
+
{
|
|
779
|
+
name: "虐恋重生",
|
|
780
|
+
description: "前世被虐重生后改写命运,爱恨交织,强调复仇与情感救赎。",
|
|
781
|
+
children: []
|
|
782
|
+
},
|
|
783
|
+
{
|
|
784
|
+
name: "快穿女配",
|
|
785
|
+
description: "穿梭不同世界完成任务,女主逆袭原剧情,强调多变设定与成长。",
|
|
786
|
+
children: []
|
|
787
|
+
},
|
|
788
|
+
{
|
|
789
|
+
name: "年代文",
|
|
790
|
+
description: "七八十年代背景,家长里短与发家致富,强调时代质感与生活细节。",
|
|
791
|
+
children: []
|
|
792
|
+
}
|
|
793
|
+
]
|
|
794
|
+
},
|
|
795
|
+
{
|
|
796
|
+
name: "游戏竞技",
|
|
797
|
+
description: "以游戏或体育竞技为舞台,读者期待操作碾压、战术博弈与冠军荣耀。",
|
|
798
|
+
children: [
|
|
799
|
+
{
|
|
800
|
+
name: "电竞荣耀",
|
|
801
|
+
description: "职业电竞选手成长,强调操作细节、团队配合与赛事热血。",
|
|
802
|
+
children: []
|
|
803
|
+
},
|
|
804
|
+
{
|
|
805
|
+
name: "网游重生",
|
|
806
|
+
description: "重生回游戏开服前,凭先知优势抢占资源,强调攻略与碾压。",
|
|
807
|
+
children: []
|
|
808
|
+
},
|
|
809
|
+
{
|
|
810
|
+
name: "体育竞技",
|
|
811
|
+
description: "篮球/足球/赛车等体育项目,强调训练成长、比赛逆转与体育精神。",
|
|
812
|
+
children: []
|
|
813
|
+
},
|
|
814
|
+
{
|
|
815
|
+
name: "卡牌桌游",
|
|
816
|
+
description: "卡牌/桌游/战棋背景,强调策略构筑与阵容搭配。",
|
|
817
|
+
children: []
|
|
818
|
+
}
|
|
819
|
+
]
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
name: "二次元轻小说",
|
|
823
|
+
description: "ACG风格叙事,节奏轻快,设定新奇,读者期待脑洞展开与角色萌点。",
|
|
824
|
+
children: [
|
|
825
|
+
{
|
|
826
|
+
name: "异世界穿越",
|
|
827
|
+
description: "穿越到异世界获得能力/身份,强调新奇设定与冒险展开。",
|
|
828
|
+
children: []
|
|
829
|
+
},
|
|
830
|
+
{
|
|
831
|
+
name: "系统流",
|
|
832
|
+
description: "主角获得系统辅助成长,任务/奖励驱动剧情,强调数值与反馈。",
|
|
833
|
+
children: []
|
|
834
|
+
},
|
|
835
|
+
{
|
|
836
|
+
name: "搞笑日常",
|
|
837
|
+
description: "以吐槽和反差制造笑点,角色个性鲜明,强调轻松愉快。",
|
|
838
|
+
children: []
|
|
839
|
+
},
|
|
840
|
+
{
|
|
841
|
+
name: "反派转生",
|
|
842
|
+
description: "转生成游戏/小说中的反派角色,利用剧情认知规避死亡结局,强调反转与谋略。",
|
|
843
|
+
children: []
|
|
844
|
+
}
|
|
845
|
+
]
|
|
846
|
+
},
|
|
847
|
+
{
|
|
848
|
+
name: "武侠",
|
|
849
|
+
description: "江湖侠义背景,武功秘籍、门派恩怨、家国情怀,读者期待侠气与江湖质感。",
|
|
850
|
+
children: [
|
|
851
|
+
{
|
|
852
|
+
name: "传统武侠",
|
|
853
|
+
description: "金庸/古龙风格,江湖恩怨与侠义精神,强调武功描写与人情世故。",
|
|
854
|
+
children: []
|
|
855
|
+
},
|
|
856
|
+
{
|
|
857
|
+
name: "江湖恩怨",
|
|
858
|
+
description: "门派/家族/帮会争斗,主角在江湖中成长与抉择,强调义气与复仇。",
|
|
859
|
+
children: []
|
|
860
|
+
},
|
|
861
|
+
{
|
|
862
|
+
name: "庙堂江湖",
|
|
863
|
+
description: "朝堂与江湖交织,武侠与权谋结合,强调格局与抉择。",
|
|
864
|
+
children: []
|
|
865
|
+
}
|
|
866
|
+
]
|
|
867
|
+
},
|
|
868
|
+
{
|
|
869
|
+
name: "现实题材",
|
|
870
|
+
description: "贴近现实生活的行业/职场故事,读者期待专业质感、人性刻画与现实共鸣。",
|
|
871
|
+
children: [
|
|
872
|
+
{
|
|
873
|
+
name: "职场商战",
|
|
874
|
+
description: "商场/职场博弈,主角从底层崛起,强调商业谋略与人际周旋。",
|
|
875
|
+
children: []
|
|
876
|
+
},
|
|
877
|
+
{
|
|
878
|
+
name: "官场沉浮",
|
|
879
|
+
description: "体制内升迁与抉择,强调政治智慧与现实质感。",
|
|
880
|
+
children: []
|
|
881
|
+
},
|
|
882
|
+
{
|
|
883
|
+
name: "医疗行业",
|
|
884
|
+
description: "医院/医生视角,治病救人与行业生态,强调专业与人性。",
|
|
885
|
+
children: []
|
|
886
|
+
},
|
|
887
|
+
{
|
|
888
|
+
name: "法律政律",
|
|
889
|
+
description: "律师/法官视角,案件辩护与司法博弈,强调逻辑与正义。",
|
|
890
|
+
children: []
|
|
891
|
+
}
|
|
892
|
+
]
|
|
893
|
+
},
|
|
894
|
+
{
|
|
895
|
+
name: "恐怖灵异",
|
|
896
|
+
description: "超自然/惊悚背景,读者期待氛围营造、悬念反转与心理恐惧。",
|
|
897
|
+
children: [
|
|
898
|
+
{
|
|
899
|
+
name: "灵异惊悚",
|
|
900
|
+
description: "鬼魂/诅咒/灵异事件,强调氛围营造与心理恐惧。",
|
|
901
|
+
children: []
|
|
902
|
+
},
|
|
903
|
+
{
|
|
904
|
+
name: "克苏鲁",
|
|
905
|
+
description: "不可名状的未知存在与疯狂,强调悬疑与绝望感。",
|
|
906
|
+
children: []
|
|
907
|
+
},
|
|
908
|
+
{
|
|
909
|
+
name: "民俗诡异",
|
|
910
|
+
description: "中国民间习俗/禁忌/传说,强调地域文化与诡异氛围。",
|
|
911
|
+
children: []
|
|
912
|
+
},
|
|
913
|
+
{
|
|
914
|
+
name: "无限恐怖",
|
|
915
|
+
description: "穿梭恐怖副本求生,强调规则破解与团队协作。",
|
|
916
|
+
children: []
|
|
917
|
+
}
|
|
918
|
+
]
|
|
919
|
+
},
|
|
920
|
+
{
|
|
921
|
+
name: "科幻",
|
|
922
|
+
description: "未来/太空/科技背景,读者期待宏大设定、技术想象与文明思辨。",
|
|
923
|
+
children: [
|
|
924
|
+
{
|
|
925
|
+
name: "星际文明",
|
|
926
|
+
description: "太空探索与文明碰撞,强调宇宙尺度与科技发展。",
|
|
927
|
+
children: []
|
|
928
|
+
},
|
|
929
|
+
{
|
|
930
|
+
name: "机甲战争",
|
|
931
|
+
description: "机甲驾驶与星际战争,强调战斗场面与军人成长。",
|
|
932
|
+
children: []
|
|
933
|
+
},
|
|
934
|
+
{
|
|
935
|
+
name: "赛博朋克",
|
|
936
|
+
description: "高科技低生活的近未来,义体/黑客/大企业,强调反差与反叛。",
|
|
937
|
+
children: []
|
|
938
|
+
},
|
|
939
|
+
{
|
|
940
|
+
name: "时间穿梭",
|
|
941
|
+
description: "时间旅行/平行世界,强调因果逻辑与命运抉择。",
|
|
942
|
+
children: []
|
|
943
|
+
}
|
|
944
|
+
]
|
|
442
945
|
}
|
|
443
946
|
];
|
|
444
947
|
/** 内置常用推进模式。 */
|
|
@@ -694,12 +1197,25 @@ function effectiveAntiAiRules(assets) {
|
|
|
694
1197
|
const customNames = new Set(custom.map((r) => r.name));
|
|
695
1198
|
return [...BUILTIN_ANTI_AI_RULES.filter((r) => !customNames.has(r.name)), ...custom];
|
|
696
1199
|
}
|
|
697
|
-
/**
|
|
1200
|
+
/** 把生效规则渲染成提示词块(禁止/鼓励分列,压缩省 token)。 */
|
|
698
1201
|
function renderAntiAiRules(assets) {
|
|
699
|
-
const rules = effectiveAntiAiRules(assets);
|
|
1202
|
+
const rules = effectiveAntiAiRules(assets).filter((r) => r.enabled !== false);
|
|
700
1203
|
if (rules.length === 0) return "";
|
|
701
1204
|
const clip = (value, max) => value.length > max ? value.slice(0, max) + "…" : value;
|
|
702
|
-
|
|
1205
|
+
const isEncourage = (r) => r.severity === "encourage" || r.name.startsWith("鼓励") || r.avoid.startsWith("(鼓励类)");
|
|
1206
|
+
const forbidden = rules.filter((r) => !isEncourage(r));
|
|
1207
|
+
const encourage = rules.filter(isEncourage);
|
|
1208
|
+
const lines = [];
|
|
1209
|
+
lines.push("==================== 反 AI 规则(写作时必须遵守的表达边界) ====================");
|
|
1210
|
+
if (forbidden.length > 0) {
|
|
1211
|
+
lines.push("禁止类(命中即问题,审稿时列为 high/medium):");
|
|
1212
|
+
for (const r of forbidden) lines.push(`- ${r.name}:避免——${clip(r.avoid, 90)}${r.fix !== "" ? `;修正——${clip(r.fix, 50)}` : ""}`);
|
|
1213
|
+
}
|
|
1214
|
+
if (encourage.length > 0) {
|
|
1215
|
+
lines.push("鼓励类(希望出现,不命中不算错,审稿时只作低优先级建议、不阻塞通过):");
|
|
1216
|
+
for (const r of encourage) lines.push(`- ${r.name}:${clip(r.fix !== "" ? r.fix : r.avoid, 90)}`);
|
|
1217
|
+
}
|
|
1218
|
+
return lines.join("\n");
|
|
703
1219
|
}
|
|
704
1220
|
/** 渲染题材与推进模式提示词块。 */
|
|
705
1221
|
function renderGenreAndProgression(assets) {
|
|
@@ -766,121 +1282,171 @@ function styleEngineSystemPrompt() {
|
|
|
766
1282
|
].join("\n");
|
|
767
1283
|
}
|
|
768
1284
|
//#endregion
|
|
769
|
-
//#region src/
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
{
|
|
784
|
-
id: "china",
|
|
785
|
-
label: "国漫 / 古风",
|
|
786
|
-
prompt: "chinese ink painting style, flowing brush strokes, traditional palette, ethereal atmosphere, xianxia aesthetic",
|
|
787
|
-
layoutHint: "页漫/条漫均可"
|
|
1285
|
+
//#region src/manga-genre-rules.ts
|
|
1286
|
+
/** 内置题材(通用题材 default 为空规则)。 */
|
|
1287
|
+
const GENRES = [
|
|
1288
|
+
{
|
|
1289
|
+
id: "xuanhuan",
|
|
1290
|
+
label: "玄幻 / 仙侠",
|
|
1291
|
+
rules: [
|
|
1292
|
+
"施法必须经法宝媒介(笛/扇/印/笔等实体法宝),写清法宝本体材质+施法动作+特效出口,禁止只写\"施法\"。",
|
|
1293
|
+
"每个角色绑定主属性+双色粒子(雷=蓝白+紫电、冰=冰蓝+银白、金=亮金+暖白、火=赤红+暗金、木=粉白+草木青),属性决定术法形态与配色。",
|
|
1294
|
+
"术法攻防走\"罡气→破罡→防御转攻\":护体罡气被划开、防御术法炸裂化千万飞刃/剑气,防守转攻是高光点,必写。",
|
|
1295
|
+
"能量具象化:光柱/雷龙/法相/领域;\"光柱\"要写成\"洪流/弧光/波纹/光丝雨\"(禁裸光柱,防呆板发光管)。",
|
|
1296
|
+
"终招蓄力必写:天地灵韵向角色/法宝汇聚、能量漩涡成型,再接幂击。",
|
|
1297
|
+
"对军清场:扇形冲击波/剑气暴雨洗地/能量扇镇压大军核心,战场弹坑/碎片/云层撕开反馈。"
|
|
1298
|
+
]
|
|
788
1299
|
},
|
|
789
1300
|
{
|
|
790
|
-
id: "
|
|
791
|
-
label: "
|
|
792
|
-
|
|
793
|
-
|
|
1301
|
+
id: "wuxia",
|
|
1302
|
+
label: "武侠 / 国风",
|
|
1303
|
+
rules: [
|
|
1304
|
+
"打斗用 Hit-Stop 三件套:碰撞瞬间 1~2 帧微停顿 + 接触点火花 + 双刃咬死高频震颤,杜绝滑步/空气假打。",
|
|
1305
|
+
"武术动作链:起手/攻防/收势,标明具体招式(刀/剑/轻功/擒拿),剑走轻灵、刀走厚重。",
|
|
1306
|
+
"镜头强化打击感:碰撞点微环绕、贴身横移、命中定格,禁静止对峙。",
|
|
1307
|
+
"轻功/身法:衣袂翻飞、发丝飘动、残影,身形轻盈不僵硬。"
|
|
1308
|
+
]
|
|
794
1309
|
},
|
|
795
1310
|
{
|
|
796
|
-
id: "
|
|
797
|
-
label: "
|
|
798
|
-
|
|
799
|
-
|
|
1311
|
+
id: "anime",
|
|
1312
|
+
label: "二次元 / 动漫",
|
|
1313
|
+
rules: [
|
|
1314
|
+
"大招三段式:蓄力→释放→爆发,能量形态+爆发特效,段段画质声明复用。",
|
|
1315
|
+
"双色粒子:每角色一套主色+材质+轨迹,双方互补对冲(冷 vs 暖),防混战混淆。",
|
|
1316
|
+
"色指定:明确主色调+高光色+特效撞色,防发灰。",
|
|
1317
|
+
"禁止评价词/裸光效/空泛\"粒子\";能量写\"光丝雨/弧光/波纹\"+方向+材质。"
|
|
1318
|
+
]
|
|
800
1319
|
},
|
|
801
1320
|
{
|
|
802
|
-
id: "
|
|
803
|
-
label: "
|
|
804
|
-
|
|
805
|
-
|
|
1321
|
+
id: "urban",
|
|
1322
|
+
label: "都市 / 现实",
|
|
1323
|
+
rules: [
|
|
1324
|
+
"微表情生理递进:眼神/眉心/唇部/鼻翼/喉头/手指,情绪逐层推进,不夸张不脸谱。",
|
|
1325
|
+
"对白语速 3.5~5 字/秒(台词从声音层按此把控,留 0.3~0.8s 落口发酵)。",
|
|
1326
|
+
"防棚拍假冷光:用真实光线(自然光/路灯/窗光/霓虹),日景不陰间冷蓝、局部留暖光。",
|
|
1327
|
+
"场景真实还原(街头/室内/医院等),人物服装生活化,肢体动作自然不律動化。"
|
|
1328
|
+
]
|
|
806
1329
|
},
|
|
807
1330
|
{
|
|
808
|
-
id: "
|
|
809
|
-
label: "
|
|
810
|
-
|
|
811
|
-
|
|
1331
|
+
id: "scifi",
|
|
1332
|
+
label: "科幻 / 未来",
|
|
1333
|
+
rules: [
|
|
1334
|
+
"机甲/能量武器设定明确(颜色/材质/发光部位),能量轨迹具体。",
|
|
1335
|
+
"霓虹/屏幕光/激光防眩光,用冷色主调+重点发光处。",
|
|
1336
|
+
"宏大场景(飞船/城市/废墟)用大远景/俯拍,人物相对渺小。"
|
|
1337
|
+
]
|
|
812
1338
|
}
|
|
813
1339
|
];
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
1340
|
+
/** 通用题材(空规则,走通用生成)。 */
|
|
1341
|
+
const DEFAULT_GENRE = {
|
|
1342
|
+
id: "default",
|
|
1343
|
+
label: "通用(不限定)",
|
|
1344
|
+
rules: []
|
|
1345
|
+
};
|
|
1346
|
+
/** 按题材 id 取规则(未找到返回通用)。 */
|
|
1347
|
+
function getGenreRules(id) {
|
|
1348
|
+
if (id === void 0 || id === "") return DEFAULT_GENRE.rules;
|
|
1349
|
+
const g = GENRES.find((x) => x.id === id);
|
|
1350
|
+
return g !== void 0 ? g.rules : DEFAULT_GENRE.rules;
|
|
817
1351
|
}
|
|
818
1352
|
//#endregion
|
|
819
1353
|
//#region src/style-library.ts
|
|
820
|
-
/**
|
|
1354
|
+
/** 内置风格模板(基底 21 个 + 滤镜 3 个)。 */
|
|
821
1355
|
const STYLE_LIBRARY = [
|
|
1356
|
+
{
|
|
1357
|
+
id: "hyperreal-3d",
|
|
1358
|
+
name: "超写实仿真人 3D",
|
|
1359
|
+
category: "3d",
|
|
1360
|
+
traits: "红果2026重点扶持,真人肤质+电影级渲染,国产网剧质感,分成最高",
|
|
1361
|
+
keywords: "超写实3D仿真人,真人肤质纹理,电影级渲染,国产网剧质感,细腻皮肤,真实毛发,8K高清,五官清晰面部稳定,人体结构正常,真实材质",
|
|
1362
|
+
weight: 1
|
|
1363
|
+
},
|
|
1364
|
+
{
|
|
1365
|
+
id: "guoman-3d-xuanhuan",
|
|
1366
|
+
name: "3D 国漫玄幻",
|
|
1367
|
+
category: "3d",
|
|
1368
|
+
traits: "红果主力形态,仙侠建模+东方美学,男性向修仙逆袭爆款画风",
|
|
1369
|
+
keywords: "3D国漫玄幻风格,仙侠建模,东方美学,精致古风服饰,特效氛围感拉满,硬朗立体光影,高饱和色彩,8K超精细建模,五官清晰面部稳定,真实材质",
|
|
1370
|
+
weight: 1
|
|
1371
|
+
},
|
|
1372
|
+
{
|
|
1373
|
+
id: "guoman-3d-tianyuan",
|
|
1374
|
+
name: "3D 古风田园治愈",
|
|
1375
|
+
category: "3d",
|
|
1376
|
+
traits: "当下大火慢节奏长线,开荒经营/美食创业/市井烟火,清新治愈",
|
|
1377
|
+
keywords: "3D古风田园,清新治愈画风,暖色调,市井烟火气,美食场景细节,柔和自然光,温暖通透,8K高清,五官清晰面部稳定,真实材质",
|
|
1378
|
+
weight: .9
|
|
1379
|
+
},
|
|
1380
|
+
{
|
|
1381
|
+
id: "urban-lightreal-3d",
|
|
1382
|
+
name: "现代都市轻写实 3D",
|
|
1383
|
+
category: "3d",
|
|
1384
|
+
traits: "都市言情/职场/甜宠通用,真实街景+时尚穿搭,冷暖对比光影",
|
|
1385
|
+
keywords: "3D现代都市轻写实,都市夜景,时尚穿搭,真实街景,冷暖对比光影,生活质感,8K高清,五官清晰面部稳定,真实材质纹理",
|
|
1386
|
+
weight: .9
|
|
1387
|
+
},
|
|
822
1388
|
{
|
|
823
1389
|
id: "pixar-adult-3d",
|
|
824
1390
|
name: "成人向皮克斯 3D",
|
|
825
1391
|
category: "3d",
|
|
826
1392
|
traits: "CG 三维渲染,材质细腻,微表情表现力强;需压制 Q 版可爱感",
|
|
827
|
-
keywords: "3D皮克斯动画,成人写实CGI,电影光影,拒绝Q
|
|
828
|
-
weight:
|
|
1393
|
+
keywords: "3D皮克斯动画,成人写实CGI,电影光影,拒绝Q版,8K超精细建模,五官清晰面部稳定,真实材质纹理",
|
|
1394
|
+
weight: .85
|
|
829
1395
|
},
|
|
830
1396
|
{
|
|
831
1397
|
id: "arcane-thick-3d",
|
|
832
1398
|
name: "双城之战美漫厚涂 3D",
|
|
833
1399
|
category: "3d",
|
|
834
1400
|
traits: "粗重轮廓线,高对比硬阴影,材质粗粝,悲剧、压抑、科幻题材适配高",
|
|
835
|
-
keywords: "双城之战美术,3D
|
|
836
|
-
weight: .
|
|
1401
|
+
keywords: "双城之战美术,3D美漫厚涂,强轮廓光影,暗调粗粝渲染,8K高清,五官清晰面部稳定,真实材质",
|
|
1402
|
+
weight: .8
|
|
837
1403
|
},
|
|
838
1404
|
{
|
|
839
1405
|
id: "nextgen-cg",
|
|
840
1406
|
name: "次世代游戏 CG",
|
|
841
1407
|
category: "3d",
|
|
842
1408
|
traits: "接近真人的三维渲染,照片级布料、皮肤材质,冷峻写实",
|
|
843
|
-
keywords: "次世代游戏CG渲染,超写实3D
|
|
844
|
-
weight: .
|
|
1409
|
+
keywords: "次世代游戏CG渲染,超写实3D,真实物理材质,电影布光,8K高清,五官清晰面部稳定",
|
|
1410
|
+
weight: .8
|
|
845
1411
|
},
|
|
846
1412
|
{
|
|
847
1413
|
id: "low-cyberpunk-3d",
|
|
848
1414
|
name: "底层赛博朋克 3D",
|
|
849
1415
|
category: "3d",
|
|
850
1416
|
traits: "锈蚀工业、破碎霓虹、屏幕噪点;避开华丽高楼,偏向破败底层",
|
|
851
|
-
keywords: "底层赛博朋克3D
|
|
852
|
-
weight: .
|
|
1417
|
+
keywords: "底层赛博朋克3D,废土工业,破碎霓虹,画面噪点色散,8K高清,五官清晰面部稳定,真实材质",
|
|
1418
|
+
weight: .75
|
|
853
1419
|
},
|
|
854
1420
|
{
|
|
855
1421
|
id: "vaporwave-3d",
|
|
856
1422
|
name: "复古蒸汽波 3D",
|
|
857
1423
|
category: "3d",
|
|
858
1424
|
traits: "粉-蓝-紫霓虹轮廓、90 年代复古网格、胶片色散颗粒,颓废疏离",
|
|
859
|
-
keywords: "3D复古蒸汽波,90
|
|
860
|
-
weight: .
|
|
1425
|
+
keywords: "3D复古蒸汽波,90年代三维渲染,霓虹轮廓光,胶片颗粒,8K高清,五官清晰面部稳定",
|
|
1426
|
+
weight: .7
|
|
861
1427
|
},
|
|
862
1428
|
{
|
|
863
1429
|
id: "dark-gothic-3d",
|
|
864
1430
|
name: "暗黑哥特 3D",
|
|
865
1431
|
category: "3d",
|
|
866
1432
|
traits: "大面积阴影、冷青色调、衰败破败质感,适合幽暗密闭空间",
|
|
867
|
-
keywords: "3D
|
|
868
|
-
weight: .
|
|
1433
|
+
keywords: "3D暗黑哥特,冷青暗光,衰败质感,大面积阴影,8K高清,五官清晰面部稳定,真实材质",
|
|
1434
|
+
weight: .7
|
|
869
1435
|
},
|
|
870
1436
|
{
|
|
871
1437
|
id: "clay-stopmotion-3d",
|
|
872
1438
|
name: "黏土 / 定格 3D",
|
|
873
1439
|
category: "3d",
|
|
874
1440
|
traits: "捏塑颗粒肌理,手工定格质感;可做压抑向,不局限可爱风",
|
|
875
|
-
keywords: "3D
|
|
876
|
-
weight: .
|
|
1441
|
+
keywords: "3D黏土定格渲染,泥塑肌理,电影打光,8K高清,五官清晰",
|
|
1442
|
+
weight: .6
|
|
877
1443
|
},
|
|
878
1444
|
{
|
|
879
1445
|
id: "openworld-survival",
|
|
880
1446
|
name: "开放世界生存游戏风",
|
|
881
1447
|
category: "game",
|
|
882
|
-
traits: "
|
|
883
|
-
keywords: "
|
|
1448
|
+
traits: "场景破败感强,货架、仓库、管线、灰尘、锈蚀明显;人物带一点\"生存者\"气质;适合探索感、逃亡感、世界观展示",
|
|
1449
|
+
keywords: "开放世界生存游戏风格,废土场景,破败仓库,锈蚀金属货架,灰尘粒子,冷色写实光影,8K高清,五官清晰面部稳定,真实材质",
|
|
884
1450
|
weight: .8
|
|
885
1451
|
},
|
|
886
1452
|
{
|
|
@@ -888,7 +1454,7 @@ const STYLE_LIBRARY = [
|
|
|
888
1454
|
name: "恐怖游戏 3D",
|
|
889
1455
|
category: "game",
|
|
890
1456
|
traits: "大面积阴影,荧光灯频闪,空间压抑;适合悬疑、压迫、精神异常段落",
|
|
891
|
-
keywords: "恐怖游戏 3D
|
|
1457
|
+
keywords: "恐怖游戏 3D 风格,昏暗仓库,老旧荧光灯,硬阴影,压迫空间,冷灰蓝调,低饱和,8K高清,五官清晰面部稳定",
|
|
892
1458
|
weight: .75
|
|
893
1459
|
},
|
|
894
1460
|
{
|
|
@@ -896,48 +1462,64 @@ const STYLE_LIBRARY = [
|
|
|
896
1462
|
name: "战术潜行游戏风",
|
|
897
1463
|
category: "game",
|
|
898
1464
|
traits: "低视角、第三人称镜头感;人物在货架之间移动,光影克制;适合紧张剧情",
|
|
899
|
-
keywords: "战术潜行游戏视角,第三人称镜头,货架通道,冷色暗光,真实3D
|
|
1465
|
+
keywords: "战术潜行游戏视角,第三人称镜头,货架通道,冷色暗光,真实3D渲染,紧张压迫氛围,8K高清,五官清晰面部稳定,真实材质",
|
|
900
1466
|
weight: .7
|
|
901
1467
|
},
|
|
1468
|
+
{
|
|
1469
|
+
id: "guoman-2d-gufeng",
|
|
1470
|
+
name: "2D 国漫古风",
|
|
1471
|
+
category: "2d",
|
|
1472
|
+
traits: "国产2D主流,古言/仙侠/重生虐恋女性向爆款,精致线条+东方色彩",
|
|
1473
|
+
keywords: "2D国漫古风,精致线条,东方色彩美学,仙侠氛围,飘逸服饰,唯美意境,干净勾线,8K高清,五官清晰",
|
|
1474
|
+
weight: 1
|
|
1475
|
+
},
|
|
902
1476
|
{
|
|
903
1477
|
id: "korean-webtoon",
|
|
904
1478
|
name: "韩漫条漫风",
|
|
905
1479
|
category: "2d",
|
|
906
1480
|
traits: "修长人物,低饱和高级灰,氛围感强,都市向漫剧爆款画风",
|
|
907
|
-
keywords: "韩漫2D
|
|
908
|
-
weight:
|
|
1481
|
+
keywords: "韩漫2D绘画,精致人物,低饱和灰调,氛围感插画,8K高清,五官清晰,干净线条",
|
|
1482
|
+
weight: .9
|
|
909
1483
|
},
|
|
910
1484
|
{
|
|
911
1485
|
id: "japan-cel-dark",
|
|
912
1486
|
name: "日系赛璐璐动画",
|
|
913
1487
|
category: "2d",
|
|
914
1488
|
traits: "干净勾线,硬阴影;分明亮版、暗调悲剧版(边缘行者质感)",
|
|
915
|
-
keywords: "
|
|
916
|
-
weight: .
|
|
1489
|
+
keywords: "日系赛璐璐动画,清晰轮廓线,硬阴影,暗调都市动画,8K高清,五官清晰",
|
|
1490
|
+
weight: .85
|
|
917
1491
|
},
|
|
918
1492
|
{
|
|
919
1493
|
id: "shinkai-anime",
|
|
920
1494
|
name: "新海诚动画风",
|
|
921
1495
|
category: "2d",
|
|
922
1496
|
traits: "极致环境光影、丁达尔、空气中尘埃粒子,擅长雨夜、夜景外景",
|
|
923
|
-
keywords: "
|
|
924
|
-
weight: .
|
|
1497
|
+
keywords: "新海诚动画渲染,细腻环境光,漂浮尘埃粒子,8K高清,五官清晰,干净线条",
|
|
1498
|
+
weight: .8
|
|
925
1499
|
},
|
|
926
1500
|
{
|
|
927
1501
|
id: "pop-marvel-2d",
|
|
928
1502
|
name: "波普美漫",
|
|
929
1503
|
category: "2d",
|
|
930
1504
|
traits: "网点半调、强撞色,线条张扬,节奏冲击力强",
|
|
931
|
-
keywords: "
|
|
932
|
-
weight: .
|
|
1505
|
+
keywords: "平行宇宙波普美漫,网点半调,漫画粗线条,8K高清,五官清晰",
|
|
1506
|
+
weight: .75
|
|
933
1507
|
},
|
|
934
1508
|
{
|
|
935
1509
|
id: "dark-ghibli",
|
|
936
1510
|
name: "暗黑吉卜力手绘",
|
|
937
1511
|
category: "2d",
|
|
938
1512
|
traits: "手绘水彩肌理;强制关闭温暖治愈,做残酷冷调版本",
|
|
939
|
-
keywords: "
|
|
940
|
-
weight: .
|
|
1513
|
+
keywords: "吉卜力手绘动画,暗黑写实,水彩肌理,拒绝暖光治愈,8K高清,五官清晰,干净线条",
|
|
1514
|
+
weight: .7
|
|
1515
|
+
},
|
|
1516
|
+
{
|
|
1517
|
+
id: "sandiao-qban",
|
|
1518
|
+
name: "沙雕 Q 版搞笑",
|
|
1519
|
+
category: "2d",
|
|
1520
|
+
traits: "表情包动态漫主流,低成本高产量,夸张表情+大头小身,搞笑解压",
|
|
1521
|
+
keywords: "Q版卡通沙雕风格,夸张表情,大头小身,简洁线条,明亮色块,搞笑解压,扁平化设计,8K高清",
|
|
1522
|
+
weight: .7
|
|
941
1523
|
},
|
|
942
1524
|
{
|
|
943
1525
|
id: "noir-film",
|
|
@@ -971,7 +1553,7 @@ const STYLE_LIBRARY = [
|
|
|
971
1553
|
name: "新中式水墨动画",
|
|
972
1554
|
category: "craft",
|
|
973
1555
|
traits: "水墨晕染,留白写意",
|
|
974
|
-
keywords: "水墨动画渲染,写意晕染,国风2D",
|
|
1556
|
+
keywords: "水墨动画渲染,写意晕染,国风2D,8K高清,五官清晰,干净线条",
|
|
975
1557
|
weight: 1
|
|
976
1558
|
},
|
|
977
1559
|
{
|
|
@@ -979,7 +1561,7 @@ const STYLE_LIBRARY = [
|
|
|
979
1561
|
name: "纸雕定格",
|
|
980
1562
|
category: "craft",
|
|
981
1563
|
traits: "多层纸张镂空叠层,光影穿透纸面",
|
|
982
|
-
keywords: "
|
|
1564
|
+
keywords: "立体纸雕定格,纸张肌理,层叠镂空光影,8K高清,五官清晰",
|
|
983
1565
|
weight: .9
|
|
984
1566
|
},
|
|
985
1567
|
{
|
|
@@ -987,7 +1569,7 @@ const STYLE_LIBRARY = [
|
|
|
987
1569
|
name: "工笔重彩国风",
|
|
988
1570
|
category: "craft",
|
|
989
1571
|
traits: "精细线条,矿物颜料质感,古典华美",
|
|
990
|
-
keywords: "工笔重彩,国风2D
|
|
1572
|
+
keywords: "工笔重彩,国风2D绘画,矿物颜料质感,精细勾线,8K高清,五官清晰",
|
|
991
1573
|
weight: .8
|
|
992
1574
|
}
|
|
993
1575
|
];
|
|
@@ -1005,6 +1587,283 @@ function styleKeywords(styleId, filterId) {
|
|
|
1005
1587
|
return parts.join(",") || "3D动漫,超精细建模,电影光影";
|
|
1006
1588
|
}
|
|
1007
1589
|
//#endregion
|
|
1590
|
+
//#region src/ai-scan.ts
|
|
1591
|
+
/** 重灾区套话(高频出现即问题) */
|
|
1592
|
+
const HEAVY_CLICHES = [
|
|
1593
|
+
"不禁",
|
|
1594
|
+
"仿佛",
|
|
1595
|
+
"一时间",
|
|
1596
|
+
"不由得",
|
|
1597
|
+
"顿时",
|
|
1598
|
+
"然而",
|
|
1599
|
+
"缓缓",
|
|
1600
|
+
"轻轻",
|
|
1601
|
+
"微微",
|
|
1602
|
+
"默默",
|
|
1603
|
+
"似乎",
|
|
1604
|
+
"终于",
|
|
1605
|
+
"显然",
|
|
1606
|
+
"其实",
|
|
1607
|
+
"无法形容",
|
|
1608
|
+
"难以言喻",
|
|
1609
|
+
"不由自主"
|
|
1610
|
+
];
|
|
1611
|
+
/** 轻微套话(偶尔出现可接受,高频才问题) */
|
|
1612
|
+
const LIGHT_CLICHES = [
|
|
1613
|
+
"心中",
|
|
1614
|
+
"脑海",
|
|
1615
|
+
"眼神",
|
|
1616
|
+
"嘴角",
|
|
1617
|
+
"眉头",
|
|
1618
|
+
"身影",
|
|
1619
|
+
"气息",
|
|
1620
|
+
"光芒",
|
|
1621
|
+
"力量",
|
|
1622
|
+
"感觉",
|
|
1623
|
+
"知道",
|
|
1624
|
+
"明白"
|
|
1625
|
+
];
|
|
1626
|
+
/** 解释性叙事开头模式 */
|
|
1627
|
+
const EXPOSITORY_STARTS = [
|
|
1628
|
+
"原来",
|
|
1629
|
+
"因为",
|
|
1630
|
+
"由于",
|
|
1631
|
+
"所以",
|
|
1632
|
+
"因此",
|
|
1633
|
+
"于是",
|
|
1634
|
+
"这就是",
|
|
1635
|
+
"也就是说",
|
|
1636
|
+
"换句话说",
|
|
1637
|
+
"事实上",
|
|
1638
|
+
"实际上"
|
|
1639
|
+
];
|
|
1640
|
+
function scanAiFlavor(text) {
|
|
1641
|
+
const paragraphs = text.split(/\n{2,}/).map((p) => p.trim()).filter((p) => p.length > 0);
|
|
1642
|
+
const totalChars = text.length;
|
|
1643
|
+
const clicheHits = [];
|
|
1644
|
+
let clicheTotal = 0;
|
|
1645
|
+
for (const word of [...HEAVY_CLICHES, ...LIGHT_CLICHES]) {
|
|
1646
|
+
const count = countOccurrences(text, word);
|
|
1647
|
+
if (count > 0) {
|
|
1648
|
+
clicheHits.push({
|
|
1649
|
+
word,
|
|
1650
|
+
count
|
|
1651
|
+
});
|
|
1652
|
+
clicheTotal += count;
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
clicheHits.sort((a, b) => b.count - a.count);
|
|
1656
|
+
const paraLengths = paragraphs.map((p) => p.length);
|
|
1657
|
+
const avgLen = paraLengths.length > 0 ? paraLengths.reduce((a, b) => a + b, 0) / paraLengths.length : 0;
|
|
1658
|
+
const variance = paraLengths.length > 0 ? paraLengths.reduce((sum, len) => sum + Math.pow(len - avgLen, 2), 0) / paraLengths.length : 0;
|
|
1659
|
+
let maxConsecutive = 0;
|
|
1660
|
+
let currentConsecutive = 0;
|
|
1661
|
+
for (const p of paragraphs) if (EXPOSITORY_STARTS.some((s) => p.startsWith(s)) || p.length > 150 && !p.includes("\"") && !p.includes("「") && !p.includes("『")) {
|
|
1662
|
+
currentConsecutive++;
|
|
1663
|
+
maxConsecutive = Math.max(maxConsecutive, currentConsecutive);
|
|
1664
|
+
} else currentConsecutive = 0;
|
|
1665
|
+
const sentences = text.split(/[。!?!?]/).map((s) => s.trim()).filter((s) => s.length > 0);
|
|
1666
|
+
const starterCounts = /* @__PURE__ */ new Map();
|
|
1667
|
+
for (const s of sentences) {
|
|
1668
|
+
const starter = s.slice(0, 2);
|
|
1669
|
+
starterCounts.set(starter, (starterCounts.get(starter) ?? 0) + 1);
|
|
1670
|
+
}
|
|
1671
|
+
const repeatedStarters = Array.from(starterCounts.values()).filter((c) => c >= 3).reduce((a, b) => a + b, 0);
|
|
1672
|
+
const sentenceRepetitionRate = sentences.length > 0 ? repeatedStarters / sentences.length : 0;
|
|
1673
|
+
const longParagraphCount = paraLengths.filter((l) => l > 300).length;
|
|
1674
|
+
const shortParagraphCount = paraLengths.filter((l) => l < 20).length;
|
|
1675
|
+
const dialogueChars = (text.match(/["「『][^"」』]*["」』]/g) ?? []).join("").length;
|
|
1676
|
+
const dialogueRatio = totalChars > 0 ? dialogueChars / totalChars : 0;
|
|
1677
|
+
let aiScore = 0;
|
|
1678
|
+
const clicheDensity = totalChars > 0 ? clicheTotal / totalChars * 1e3 : 0;
|
|
1679
|
+
aiScore += Math.min(40, clicheDensity * 8);
|
|
1680
|
+
if (variance < 2e3 && paraLengths.length >= 5) aiScore += 15;
|
|
1681
|
+
aiScore += Math.min(20, maxConsecutive * 5);
|
|
1682
|
+
aiScore += Math.min(15, sentenceRepetitionRate * 30);
|
|
1683
|
+
if (dialogueRatio < .05 && totalChars > 1e3) aiScore += 10;
|
|
1684
|
+
aiScore = Math.min(100, Math.round(aiScore));
|
|
1685
|
+
const issues = [];
|
|
1686
|
+
if (clicheDensity > 3) {
|
|
1687
|
+
const topCliches = clicheHits.slice(0, 5).map((h) => `${h.word}×${h.count}`).join("、");
|
|
1688
|
+
issues.push(`套话密度偏高(每千字 ${clicheDensity.toFixed(1)} 次):${topCliches}`);
|
|
1689
|
+
}
|
|
1690
|
+
if (maxConsecutive >= 3) issues.push(`连续 ${maxConsecutive} 段解释性叙事,缺少对话/动作`);
|
|
1691
|
+
if (sentenceRepetitionRate > .15) issues.push(`句式重复率 ${(sentenceRepetitionRate * 100).toFixed(0)}%,开头句式单一`);
|
|
1692
|
+
if (longParagraphCount > 3) issues.push(`${longParagraphCount} 段超过 300 字,段落过长`);
|
|
1693
|
+
if (dialogueRatio < .05 && totalChars > 1e3) issues.push("对话占比过低,整章偏叙述");
|
|
1694
|
+
const summary = issues.length > 0 ? `本地 AI 味扫描(AI 味指数 ${aiScore}/100):\n` + issues.map((i) => `- ${i}`).join("\n") : `本地 AI 味扫描(AI 味指数 ${aiScore}/100):未发现明显问题。`;
|
|
1695
|
+
return {
|
|
1696
|
+
aiScore,
|
|
1697
|
+
clicheHits,
|
|
1698
|
+
paragraphLengthVariance: Math.round(variance),
|
|
1699
|
+
consecutiveExpositoryParagraphs: maxConsecutive,
|
|
1700
|
+
sentenceRepetitionRate: Math.round(sentenceRepetitionRate * 100) / 100,
|
|
1701
|
+
longParagraphCount,
|
|
1702
|
+
shortParagraphCount,
|
|
1703
|
+
dialogueRatio: Math.round(dialogueRatio * 100) / 100,
|
|
1704
|
+
summary
|
|
1705
|
+
};
|
|
1706
|
+
}
|
|
1707
|
+
function countOccurrences(text, word) {
|
|
1708
|
+
if (word.length === 0) return 0;
|
|
1709
|
+
let count = 0;
|
|
1710
|
+
let idx = text.indexOf(word);
|
|
1711
|
+
while (idx !== -1) {
|
|
1712
|
+
count++;
|
|
1713
|
+
idx = text.indexOf(word, idx + word.length);
|
|
1714
|
+
}
|
|
1715
|
+
return count;
|
|
1716
|
+
}
|
|
1717
|
+
//#endregion
|
|
1718
|
+
//#region src/novel-context.ts
|
|
1719
|
+
/**
|
|
1720
|
+
* 相关事实检索:trigram 重合度 + 角色名命中加权 + 近因加权
|
|
1721
|
+
* 从 generateChapterStream 抽取,扩展为通用函数。
|
|
1722
|
+
*/
|
|
1723
|
+
function retrieveRelatedFacts(facts, beatsText, roleNames, limit = 15) {
|
|
1724
|
+
if (facts.length === 0) return [];
|
|
1725
|
+
const trigrams = (s) => {
|
|
1726
|
+
const out = /* @__PURE__ */ new Set();
|
|
1727
|
+
for (let i = 0; i + 3 <= s.length; i++) {
|
|
1728
|
+
const tri = s.slice(i, i + 3);
|
|
1729
|
+
if (tri.trim() !== "") out.add(tri);
|
|
1730
|
+
}
|
|
1731
|
+
return out;
|
|
1732
|
+
};
|
|
1733
|
+
const beatsTri = trigrams(beatsText);
|
|
1734
|
+
const beatRoles = roleNames.filter((n) => beatsText.includes(n));
|
|
1735
|
+
const recentTexts = new Set(facts.slice(-20).map((f) => f.text));
|
|
1736
|
+
return facts.map((f, idx) => {
|
|
1737
|
+
const head = f.text.slice(0, 80);
|
|
1738
|
+
let score = 0;
|
|
1739
|
+
for (const tri of trigrams(head)) if (beatsTri.has(tri)) score += 1;
|
|
1740
|
+
if (beatRoles.length > 0) {
|
|
1741
|
+
for (const n of beatRoles) if (head.includes(n)) score += 8;
|
|
1742
|
+
}
|
|
1743
|
+
score += Math.min(idx, 40) / 10;
|
|
1744
|
+
return {
|
|
1745
|
+
f,
|
|
1746
|
+
score
|
|
1747
|
+
};
|
|
1748
|
+
}).filter((x) => x.score >= 3).sort((a, b) => b.score - a.score).slice(0, limit).map((x) => `[第${x.f.chapterNo}章] ${x.f.text}`).filter((t) => !recentTexts.has(t.slice(t.indexOf("]") + 2)));
|
|
1749
|
+
}
|
|
1750
|
+
/**
|
|
1751
|
+
* 相关角色检索:beats 中出现的角色 + 主角/主要反派永远全量
|
|
1752
|
+
*/
|
|
1753
|
+
function retrieveRelevantRoles(bible, roles, beatsText) {
|
|
1754
|
+
const result = [];
|
|
1755
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1756
|
+
const alwaysRoles = (bible?.characters ?? []).filter((c) => c.role === "protagonist" || c.role === "antagonist");
|
|
1757
|
+
for (const card of alwaysRoles) {
|
|
1758
|
+
if (seen.has(card.name)) continue;
|
|
1759
|
+
seen.add(card.name);
|
|
1760
|
+
result.push({
|
|
1761
|
+
name: card.name,
|
|
1762
|
+
role: card.role,
|
|
1763
|
+
traits: card.traits,
|
|
1764
|
+
goals: card.goals !== "" ? card.goals : void 0,
|
|
1765
|
+
knowledge: Array.isArray(card.knowledge) && card.knowledge.length > 0 ? card.knowledge : void 0
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
const beatRoles = (bible?.characters ?? []).filter((c) => beatsText.includes(c.name));
|
|
1769
|
+
for (const card of beatRoles) {
|
|
1770
|
+
if (seen.has(card.name)) continue;
|
|
1771
|
+
seen.add(card.name);
|
|
1772
|
+
result.push({
|
|
1773
|
+
name: card.name,
|
|
1774
|
+
role: card.role,
|
|
1775
|
+
traits: card.traits,
|
|
1776
|
+
goals: card.goals !== "" ? card.goals : void 0,
|
|
1777
|
+
knowledge: Array.isArray(card.knowledge) && card.knowledge.length > 0 ? card.knowledge : void 0
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
for (const r of roles ?? []) {
|
|
1781
|
+
if (seen.has(r.name)) continue;
|
|
1782
|
+
if (!beatsText.includes(r.name) && r.roleLabel !== "protagonist" && r.roleLabel !== "antagonist") continue;
|
|
1783
|
+
seen.add(r.name);
|
|
1784
|
+
result.push({
|
|
1785
|
+
name: r.name,
|
|
1786
|
+
role: r.roleLabel,
|
|
1787
|
+
traits: Array.isArray(r.traits) ? r.traits : [],
|
|
1788
|
+
goals: r.goals !== void 0 && r.goals !== "" ? r.goals : void 0
|
|
1789
|
+
});
|
|
1790
|
+
}
|
|
1791
|
+
return result;
|
|
1792
|
+
}
|
|
1793
|
+
/**
|
|
1794
|
+
* 构建章节统一上下文
|
|
1795
|
+
*
|
|
1796
|
+
* 各阶段调用此函数获取同一来源的事实与设定,消除上下文断层。
|
|
1797
|
+
* 检索结果不足时自动回退全量,保证不丢失关键信息。
|
|
1798
|
+
*/
|
|
1799
|
+
function buildChapterContext(project, chapter, outputDir, options = {}) {
|
|
1800
|
+
const { stage = "writing", fullRoleCards = true, relatedFactsLimit = 15, recentFactsLimit = 20 } = options;
|
|
1801
|
+
const bible = project.bible;
|
|
1802
|
+
const allFacts = project.facts ?? [];
|
|
1803
|
+
const roleNames = (project.roles ?? []).map((r) => r.name).filter((n) => typeof n === "string" && n !== "");
|
|
1804
|
+
let prevChapterTail = "";
|
|
1805
|
+
let prevChapterSummary;
|
|
1806
|
+
const prev = project.chapters.find((c) => c.no === chapter.no - 1);
|
|
1807
|
+
if (prev?.file !== void 0) try {
|
|
1808
|
+
const prevPath = join(outputDir, prev.file);
|
|
1809
|
+
if (existsSync(prevPath)) prevChapterTail = readFileSync(prevPath, "utf8").replace(/^#\s+.*$/m, "").trim().slice(-900);
|
|
1810
|
+
} catch {}
|
|
1811
|
+
prevChapterSummary = prev?.summary;
|
|
1812
|
+
const recentFacts = allFacts.slice(-recentFactsLimit).map((f) => `[第${f.chapterNo}章] ${f.text}`);
|
|
1813
|
+
const relatedFacts = retrieveRelatedFacts(allFacts, chapter.beats, roleNames, relatedFactsLimit);
|
|
1814
|
+
const relevantRoles = retrieveRelevantRoles(bible, project.roles, chapter.beats);
|
|
1815
|
+
const relevantWorldRules = bible?.worldRules ?? [];
|
|
1816
|
+
const activePlotlines = (project.plotlines ?? []).filter((l) => l.status === "active" || l.status === "paused");
|
|
1817
|
+
const activeForeshadows = (project.foreshadows ?? []).filter((f) => f.status === "planted" || f.status === "progressing");
|
|
1818
|
+
const volumeNo = chapter.volume ?? 0;
|
|
1819
|
+
const volume = project.volumes?.find((v) => v.no === volumeNo);
|
|
1820
|
+
const currentVolumeOutline = volume !== void 0 ? `第${volume.no}卷《${volume.title}》:${volume.summary}` : "";
|
|
1821
|
+
return {
|
|
1822
|
+
complianceRedLines: [],
|
|
1823
|
+
bookRedLines: bible?.redLines ?? [],
|
|
1824
|
+
relevantRoles: fullRoleCards ? relevantRoles : relevantRoles.map((r) => ({
|
|
1825
|
+
name: r.name,
|
|
1826
|
+
role: r.role,
|
|
1827
|
+
traits: []
|
|
1828
|
+
})),
|
|
1829
|
+
relevantWorldRules,
|
|
1830
|
+
activePlotlines,
|
|
1831
|
+
activeForeshadows,
|
|
1832
|
+
prevChapterTail,
|
|
1833
|
+
prevChapterSummary,
|
|
1834
|
+
recentFacts,
|
|
1835
|
+
relatedFacts,
|
|
1836
|
+
currentVolumeOutline,
|
|
1837
|
+
beats: chapter.beats,
|
|
1838
|
+
chapterTitle: chapter.title
|
|
1839
|
+
};
|
|
1840
|
+
}
|
|
1841
|
+
/**
|
|
1842
|
+
* 将上下文渲染为 prompt 文本块
|
|
1843
|
+
* 各阶段可按需选择注入哪些块。
|
|
1844
|
+
*/
|
|
1845
|
+
function renderContextBlocks(ctx) {
|
|
1846
|
+
const roleName = {
|
|
1847
|
+
protagonist: "主角",
|
|
1848
|
+
supporting: "配角",
|
|
1849
|
+
antagonist: "反派",
|
|
1850
|
+
other: "其他",
|
|
1851
|
+
female_lead: "女主",
|
|
1852
|
+
female_support: "女配",
|
|
1853
|
+
support: "配角",
|
|
1854
|
+
extra: "路人"
|
|
1855
|
+
};
|
|
1856
|
+
return {
|
|
1857
|
+
rolesBlock: ctx.relevantRoles.length > 0 ? "==================== 相关角色卡 ====================\n" + ctx.relevantRoles.map((r) => `- ${r.name}(${roleName[r.role] ?? r.role}):${r.traits.join("、")}${r.goals ? `;目标:${r.goals}` : ""}${r.knowledge ? `\n 已知信息:${r.knowledge.join(";")}(未列出的该角色不知道)` : ""}`).join("\n") : "",
|
|
1858
|
+
worldRulesBlock: ctx.relevantWorldRules.length > 0 ? "==================== 世界规则 ====================\n" + ctx.relevantWorldRules.map((r) => `- ${r}`).join("\n") : "",
|
|
1859
|
+
factsBlock: ctx.recentFacts.length > 0 || ctx.relatedFacts.length > 0 ? "==================== 事实库(最近 + 相关) ====================\n" + (ctx.recentFacts.length > 0 ? `【最近 ${ctx.recentFacts.length} 条】\n${ctx.recentFacts.join("\n")}` : "") + (ctx.relatedFacts.length > 0 ? `\n【相关旧事实 ${ctx.relatedFacts.length} 条】\n${ctx.relatedFacts.join("\n")}` : "") : "",
|
|
1860
|
+
plotlinesBlock: ctx.activePlotlines.length > 0 ? "==================== 活跃剧情线 ====================\n" + ctx.activePlotlines.map((l) => `- [${l.kind}${l.status === "paused" ? "·暂停" : ""}] ${l.name}:${l.goal}${l.progress ? `(进度:${l.progress})` : ""}`).join("\n") : "",
|
|
1861
|
+
foreshadowsBlock: ctx.activeForeshadows.length > 0 ? "==================== 活跃伏笔 ====================\n" + ctx.activeForeshadows.map((f) => `- [${f.status === "planted" ? "已埋设" : "推进中"}] ${f.description}${f.targetChapter ? `(预计第${f.targetChapter}章回收)` : ""}`).join("\n") : "",
|
|
1862
|
+
continuityBlock: ctx.prevChapterTail !== "" ? `==================== 上一章结尾(紧接此状态继续) ====================\n${ctx.prevChapterTail}` : "",
|
|
1863
|
+
redLinesBlock: ctx.bookRedLines.length > 0 ? "==================== 本书红线 ====================\n" + ctx.bookRedLines.map((r) => `- ${r}`).join("\n") : ""
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1866
|
+
//#endregion
|
|
1008
1867
|
//#region src/shot-language.ts
|
|
1009
1868
|
const SHOT_SIZES = [
|
|
1010
1869
|
{
|
|
@@ -1292,6 +2151,129 @@ function lightZh(ids) {
|
|
|
1292
2151
|
}
|
|
1293
2152
|
//#endregion
|
|
1294
2153
|
//#region src/story-beat-language.ts
|
|
2154
|
+
/** 情绪词表(按能量/阶段排序)。 */
|
|
2155
|
+
const EMOTIONS = [
|
|
2156
|
+
{
|
|
2157
|
+
id: "calm",
|
|
2158
|
+
zh: "平静",
|
|
2159
|
+
en: "calm",
|
|
2160
|
+
hint: "无波澜"
|
|
2161
|
+
},
|
|
2162
|
+
{
|
|
2163
|
+
id: "indifferent",
|
|
2164
|
+
zh: "淡然",
|
|
2165
|
+
en: "indifferent",
|
|
2166
|
+
hint: "不在乎"
|
|
2167
|
+
},
|
|
2168
|
+
{
|
|
2169
|
+
id: "expectant",
|
|
2170
|
+
zh: "期待",
|
|
2171
|
+
en: "expectant",
|
|
2172
|
+
hint: "有所期盼"
|
|
2173
|
+
},
|
|
2174
|
+
{
|
|
2175
|
+
id: "curious",
|
|
2176
|
+
zh: "好奇",
|
|
2177
|
+
en: "curious",
|
|
2178
|
+
hint: "想要探究"
|
|
2179
|
+
},
|
|
2180
|
+
{
|
|
2181
|
+
id: "alert",
|
|
2182
|
+
zh: "警觉",
|
|
2183
|
+
en: "alert",
|
|
2184
|
+
hint: "察觉到异样"
|
|
2185
|
+
},
|
|
2186
|
+
{
|
|
2187
|
+
id: "suppressed",
|
|
2188
|
+
zh: "压抑",
|
|
2189
|
+
en: "suppressed",
|
|
2190
|
+
hint: "情绪被压制"
|
|
2191
|
+
},
|
|
2192
|
+
{
|
|
2193
|
+
id: "enduring",
|
|
2194
|
+
zh: "隐忍",
|
|
2195
|
+
en: "enduring",
|
|
2196
|
+
hint: "强忍着"
|
|
2197
|
+
},
|
|
2198
|
+
{
|
|
2199
|
+
id: "worried",
|
|
2200
|
+
zh: "担忧",
|
|
2201
|
+
en: "worried",
|
|
2202
|
+
hint: "担心后果"
|
|
2203
|
+
},
|
|
2204
|
+
{
|
|
2205
|
+
id: "irritable",
|
|
2206
|
+
zh: "焦躁",
|
|
2207
|
+
en: "irritable",
|
|
2208
|
+
hint: "烦躁不耐"
|
|
2209
|
+
},
|
|
2210
|
+
{
|
|
2211
|
+
id: "uneasy",
|
|
2212
|
+
zh: "不安",
|
|
2213
|
+
en: "uneasy",
|
|
2214
|
+
hint: "心里没底"
|
|
2215
|
+
},
|
|
2216
|
+
{
|
|
2217
|
+
id: "terrified",
|
|
2218
|
+
zh: "惊惧",
|
|
2219
|
+
en: "terrified",
|
|
2220
|
+
hint: "极度恐惧"
|
|
2221
|
+
},
|
|
2222
|
+
{
|
|
2223
|
+
id: "angry",
|
|
2224
|
+
zh: "愤怒",
|
|
2225
|
+
en: "angry",
|
|
2226
|
+
hint: "强烈不满"
|
|
2227
|
+
},
|
|
2228
|
+
{
|
|
2229
|
+
id: "collapsing",
|
|
2230
|
+
zh: "崩溃",
|
|
2231
|
+
en: "collapsing",
|
|
2232
|
+
hint: "情绪失控"
|
|
2233
|
+
},
|
|
2234
|
+
{
|
|
2235
|
+
id: "resolute",
|
|
2236
|
+
zh: "决绝",
|
|
2237
|
+
en: "resolute",
|
|
2238
|
+
hint: "下定狠心"
|
|
2239
|
+
},
|
|
2240
|
+
{
|
|
2241
|
+
id: "grieved",
|
|
2242
|
+
zh: "痛心",
|
|
2243
|
+
en: "grieved",
|
|
2244
|
+
hint: "悲伤心痛"
|
|
2245
|
+
},
|
|
2246
|
+
{
|
|
2247
|
+
id: "relieved",
|
|
2248
|
+
zh: "释然",
|
|
2249
|
+
en: "relieved",
|
|
2250
|
+
hint: "放下包袱"
|
|
2251
|
+
},
|
|
2252
|
+
{
|
|
2253
|
+
id: "bittersweet",
|
|
2254
|
+
zh: "悲凉",
|
|
2255
|
+
en: "bittersweet",
|
|
2256
|
+
hint: "苦涩无奈"
|
|
2257
|
+
},
|
|
2258
|
+
{
|
|
2259
|
+
id: "triumphant",
|
|
2260
|
+
zh: "得意",
|
|
2261
|
+
en: "triumphant",
|
|
2262
|
+
hint: "占据上风"
|
|
2263
|
+
},
|
|
2264
|
+
{
|
|
2265
|
+
id: "reborn",
|
|
2266
|
+
zh: "重生",
|
|
2267
|
+
en: "reborn",
|
|
2268
|
+
hint: "脱胎换骨"
|
|
2269
|
+
},
|
|
2270
|
+
{
|
|
2271
|
+
id: "numb",
|
|
2272
|
+
zh: "麻木",
|
|
2273
|
+
en: "numb",
|
|
2274
|
+
hint: "失去感知"
|
|
2275
|
+
}
|
|
2276
|
+
];
|
|
1295
2277
|
/** 从中文归一化叙事功能(未知回退 exposition)。 */
|
|
1296
2278
|
function normalizeStoryFunction(text) {
|
|
1297
2279
|
if (text === void 0 || text === "") return "exposition";
|
|
@@ -1336,6 +2318,11 @@ function normalizeEmotions(text) {
|
|
|
1336
2318
|
if (out.length === 0) push("calm");
|
|
1337
2319
|
return out;
|
|
1338
2320
|
}
|
|
2321
|
+
/** 情绪中文(箭头连接)。 */
|
|
2322
|
+
function emotionZh(ids) {
|
|
2323
|
+
if (ids === void 0 || ids.length === 0) return "平静";
|
|
2324
|
+
return EMOTIONS.filter((e) => ids.includes(e.id)).map((e) => e.zh).join("→");
|
|
2325
|
+
}
|
|
1339
2326
|
//#endregion
|
|
1340
2327
|
//#region src/protocol.ts
|
|
1341
2328
|
/** The /api/dsh-novel-forge route family (same-origin, loopback-fenced). */
|
|
@@ -1379,6 +2366,10 @@ const NOVEL_API = {
|
|
|
1379
2366
|
manhuaPlans: "/api/dsh-novel-forge/manhua/plans",
|
|
1380
2367
|
/** 漫剧角色库:从分镜提名(规则+LLM两段式)/ 建卡 / 更新 / 删除 / 形象锚点 / 精修提示词。 */
|
|
1381
2368
|
mangaRoles: "/api/dsh-novel-forge/manga/roles",
|
|
2369
|
+
/** 漫剧道具库:从已写章节提炼常驻道具(跨镜头需一致)/ 保存清单。 */
|
|
2370
|
+
mangaProps: "/api/dsh-novel-forge/manga/props",
|
|
2371
|
+
/** 导出「即梦素材包」落盘到资产库 manga-assets/素材包/。 */
|
|
2372
|
+
exportPackage: "/api/dsh-novel-forge/manga/export-package",
|
|
1382
2373
|
/** 分镜·编剧级:单章 → 剧情骨架(节拍链)。 */
|
|
1383
2374
|
storyboardSkeleton: "/api/dsh-novel-forge/storyboard/skeleton",
|
|
1384
2375
|
/** 分镜·导演级:骨架 → 分镜表(镜头级)。 */
|
|
@@ -1407,7 +2398,7 @@ const NOVEL_API = {
|
|
|
1407
2398
|
charactersRefresh: "/api/dsh-novel-forge/characters/refresh",
|
|
1408
2399
|
/** 事实库回填:对历史已生成章节批量抽取事实(旧章节无事实记录时用)。 */
|
|
1409
2400
|
factsBackfill: "/api/dsh-novel-forge/facts/backfill",
|
|
1410
|
-
/**
|
|
2401
|
+
/** 道藏局部修补(如世界观规则编辑)。 */
|
|
1411
2402
|
biblePatch: "/api/dsh-novel-forge/bible/patch",
|
|
1412
2403
|
/** 小说简介:生成(AI)/补全(AI)/保存。 */
|
|
1413
2404
|
blurb: "/api/dsh-novel-forge/blurb",
|
|
@@ -1556,7 +2547,7 @@ const LLM_VENDORS = [
|
|
|
1556
2547
|
*/
|
|
1557
2548
|
/**
|
|
1558
2549
|
* 内容合规红线(平台硬性要求):所有书籍、所有章节无条件生效,
|
|
1559
|
-
*
|
|
2550
|
+
* 优先级高于单书大纲/道藏中的任何设定与作者自定义红线。
|
|
1560
2551
|
* 注入点:章节生成系统提示 + 审稿系统提示(命中即 high)。
|
|
1561
2552
|
*/
|
|
1562
2553
|
const COMPLIANCE_REDLINES = [
|
|
@@ -1570,6 +2561,18 @@ const COMPLIANCE_REDLINES = [
|
|
|
1570
2561
|
"8. 不得出现侮辱或者诽谤他人、侵害他人合法权益的内容(不得以真实人物、组织为原型进行侮辱或影射攻击)。",
|
|
1571
2562
|
"9. 不得出现法律法规禁止的其他内容。"
|
|
1572
2563
|
];
|
|
2564
|
+
/** 审稿维度取值(与 review-policy.ts 的 REVIEW_DIMENSIONS 对齐,用于归一化模型输出的 dimension 字段)。 */
|
|
2565
|
+
const REVIEW_DIMENSION_IDS = /* @__PURE__ */ new Set([
|
|
2566
|
+
"character",
|
|
2567
|
+
"setting",
|
|
2568
|
+
"redline",
|
|
2569
|
+
"writing",
|
|
2570
|
+
"pacing",
|
|
2571
|
+
"logic",
|
|
2572
|
+
"anti-ai",
|
|
2573
|
+
"presentation",
|
|
2574
|
+
"compliance"
|
|
2575
|
+
]);
|
|
1573
2576
|
/** Project state file name inside the output dir. */
|
|
1574
2577
|
const PROJECT_FILE = "novel-project.json";
|
|
1575
2578
|
/** 智能解码文本文件:UTF-8 BOM / UTF-16 BOM / UTF-8(严格校验)/ GB18030 回退。 */
|
|
@@ -1628,7 +2631,16 @@ function loadProject(outputDir) {
|
|
|
1628
2631
|
/** Persist the project state next to the chapters. */
|
|
1629
2632
|
function saveProject(outputDir, project) {
|
|
1630
2633
|
mkdirSync(outputDir, { recursive: true });
|
|
1631
|
-
|
|
2634
|
+
const target = join(outputDir, PROJECT_FILE);
|
|
2635
|
+
const data = JSON.stringify(project, null, 2);
|
|
2636
|
+
try {
|
|
2637
|
+
if (existsSync(target)) {
|
|
2638
|
+
if (readFileSync(target, "utf8") === data) return;
|
|
2639
|
+
}
|
|
2640
|
+
} catch {}
|
|
2641
|
+
const tmp = target + ".tmp";
|
|
2642
|
+
writeFileSync(tmp, data, "utf8");
|
|
2643
|
+
renameSync(tmp, target);
|
|
1632
2644
|
}
|
|
1633
2645
|
/**
|
|
1634
2646
|
* 并发保护:长任务(章节计划生成/正文生成)在内存中持有旧快照,
|
|
@@ -2107,6 +3119,60 @@ function parseJson(text, wantArray) {
|
|
|
2107
3119
|
push(trimmed);
|
|
2108
3120
|
const start2 = trimmed.indexOf(opener);
|
|
2109
3121
|
if (start2 !== -1) push(trimmed.slice(start2));
|
|
3122
|
+
const repairTruncated = (value) => {
|
|
3123
|
+
const firstOpen = value.indexOf(opener);
|
|
3124
|
+
if (firstOpen === -1) return value;
|
|
3125
|
+
const body = value.slice(firstOpen);
|
|
3126
|
+
let inStr = false;
|
|
3127
|
+
let depth = 0;
|
|
3128
|
+
let lastComplete = -1;
|
|
3129
|
+
for (let i = 0; i < body.length; i++) {
|
|
3130
|
+
const ch = body[i];
|
|
3131
|
+
if (inStr) {
|
|
3132
|
+
if (ch === "\\") {
|
|
3133
|
+
i++;
|
|
3134
|
+
continue;
|
|
3135
|
+
}
|
|
3136
|
+
if (ch === "\"") inStr = false;
|
|
3137
|
+
continue;
|
|
3138
|
+
}
|
|
3139
|
+
if (ch === "\"") {
|
|
3140
|
+
inStr = true;
|
|
3141
|
+
continue;
|
|
3142
|
+
}
|
|
3143
|
+
if (ch === "{" || ch === "[") depth++;
|
|
3144
|
+
if (ch === "}" || ch === "]") {
|
|
3145
|
+
depth--;
|
|
3146
|
+
if (depth === 1) lastComplete = i;
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
if (lastComplete === -1 || depth <= 0) return value;
|
|
3150
|
+
let result = body.slice(0, lastComplete + 1);
|
|
3151
|
+
let d = 0;
|
|
3152
|
+
let inS = false;
|
|
3153
|
+
for (let i = 0; i < result.length; i++) {
|
|
3154
|
+
const c = result[i];
|
|
3155
|
+
if (inS) {
|
|
3156
|
+
if (c === "\\") {
|
|
3157
|
+
i++;
|
|
3158
|
+
continue;
|
|
3159
|
+
}
|
|
3160
|
+
if (c === "\"") inS = false;
|
|
3161
|
+
continue;
|
|
3162
|
+
}
|
|
3163
|
+
if (c === "\"") {
|
|
3164
|
+
inS = true;
|
|
3165
|
+
continue;
|
|
3166
|
+
}
|
|
3167
|
+
if (c === "{" || c === "[") d++;
|
|
3168
|
+
if (c === "}" || c === "]") d--;
|
|
3169
|
+
}
|
|
3170
|
+
while (d > 0) {
|
|
3171
|
+
result += closer;
|
|
3172
|
+
d--;
|
|
3173
|
+
}
|
|
3174
|
+
return result;
|
|
3175
|
+
};
|
|
2110
3176
|
const repair = (value) => {
|
|
2111
3177
|
let out = "";
|
|
2112
3178
|
let inString = false;
|
|
@@ -2135,7 +3201,11 @@ function parseJson(text, wantArray) {
|
|
|
2135
3201
|
}
|
|
2136
3202
|
return out;
|
|
2137
3203
|
};
|
|
2138
|
-
for (const candidate of candidates) for (const attempt of [
|
|
3204
|
+
for (const candidate of candidates) for (const attempt of [
|
|
3205
|
+
candidate,
|
|
3206
|
+
repair(candidate),
|
|
3207
|
+
repairTruncated(candidate)
|
|
3208
|
+
]) try {
|
|
2139
3209
|
const value = JSON.parse(attempt);
|
|
2140
3210
|
if (!wantArray || Array.isArray(value)) return value;
|
|
2141
3211
|
if (typeof value === "object" && value !== null) for (const key of Object.keys(value)) {
|
|
@@ -2160,7 +3230,7 @@ function parseJsonObject(text) {
|
|
|
2160
3230
|
/** System prompt for story-bible extraction. */
|
|
2161
3231
|
function bibleSystemPrompt() {
|
|
2162
3232
|
return [
|
|
2163
|
-
"
|
|
3233
|
+
"你是一位资深网文编辑兼设定架构师。你会收到一份小说大纲,请把它提炼成结构化的「道藏」,供后续写作时严格引用。",
|
|
2164
3234
|
"要求:",
|
|
2165
3235
|
"1. 忠于大纲,不自行发明大纲之外的设定。",
|
|
2166
3236
|
"2. 角色卡覆盖大纲明确出现的角色(主角必含),每个角色给出性格标签、目标、关键关系。",
|
|
@@ -2187,7 +3257,7 @@ async function extractBible(ctx, config, outline, project) {
|
|
|
2187
3257
|
}
|
|
2188
3258
|
const facts = (project?.facts ?? []).slice(-40);
|
|
2189
3259
|
const user = [
|
|
2190
|
-
|
|
3260
|
+
`请为下面这部小说提炼道藏:\n\n${outline}`,
|
|
2191
3261
|
facts.length > 0 ? `\n\n【已写章节事实(编年录)】用于确认真实角色姓名与已确立设定;忠于大纲,不要新增大纲外设定:\n${facts.map((f) => `[第${f.chapterNo}章] ${f.text.slice(0, 100)}`).join("\n")}` : "",
|
|
2192
3262
|
excerpts.length > 0 ? `\n\n【已写章节正文摘录】角色姓名、身份以正文为准(大纲未点名时用正文里的真实姓名,禁止用「主角(描述)」占位名):\n${excerpts.join("\n\n")}` : ""
|
|
2193
3263
|
].filter((s) => s !== "").join("\n");
|
|
@@ -2195,7 +3265,7 @@ async function extractBible(ctx, config, outline, project) {
|
|
|
2195
3265
|
system: bibleSystemPrompt(),
|
|
2196
3266
|
user,
|
|
2197
3267
|
temperature: .4,
|
|
2198
|
-
maxTokens: Math.max(config.maxTokens,
|
|
3268
|
+
maxTokens: Math.max(config.maxTokens, 8e3),
|
|
2199
3269
|
reasoning: config.analysisReasoning ?? "low"
|
|
2200
3270
|
}));
|
|
2201
3271
|
const strArray = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string" && v.trim() !== "") : [];
|
|
@@ -2290,41 +3360,54 @@ function planSystemPrompt(volumes) {
|
|
|
2290
3360
|
"重要:beats 字段内部必须使用 \\n 转义表示换行(JSON 字符串内不得有真实换行符),其余字符串值也不得包含真实换行符,JSON 必须在一段内完整结束。",
|
|
2291
3361
|
"重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。",
|
|
2292
3362
|
volumes !== void 0 && volumes.length > 0 ? ["\n全书分卷结构(规划章节时需落在对应卷内):"].concat(volumes.map((v) => `第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})`)).join("\n") : ""
|
|
2293
|
-
].join("\n");
|
|
2294
|
-
}
|
|
2295
|
-
/** Build the writing system prompt (bible + outline + active foreshadows).
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
const roleName = {
|
|
2307
|
-
protagonist: "主角",
|
|
2308
|
-
supporting: "配角",
|
|
2309
|
-
antagonist: "反派",
|
|
2310
|
-
other: "其他"
|
|
2311
|
-
}[card.role];
|
|
2312
|
-
sections.push(`- ${card.name}(${roleName}):${card.traits.join("、")}${card.goals !== "" ? `;目标:${card.goals}` : ""}${card.relations !== "" ? `;关系:${card.relations}` : ""}`);
|
|
2313
|
-
if (Array.isArray(card.knowledge) && card.knowledge.length > 0) sections.push(` 已知信息(该角色知道的:${card.knowledge.join(";")};未列出的信息该角色一律不知道,不得写其知晓或提及)`);
|
|
2314
|
-
}
|
|
2315
|
-
}
|
|
3363
|
+
].join("\n");
|
|
3364
|
+
}
|
|
3365
|
+
/** Build the writing system prompt (bible + outline + active foreshadows).
|
|
3366
|
+
* `targetChars` 来自每章计划(规划时快照,= 设置的每章目标字数);无则退回默认 3500。
|
|
3367
|
+
* 字数区间按目标动态生成(±15%,取整到百位),避免系统提示词与设置互相冲突。
|
|
3368
|
+
* `lengthRule` 可覆盖第 1 条字数要求(整章修订/改编时按原文长度为准)。 */
|
|
3369
|
+
function writeSystemPrompt(project, targetChars, lengthRule) {
|
|
3370
|
+
const bible = project.bible;
|
|
3371
|
+
const sections = [];
|
|
3372
|
+
if (bible !== void 0) {
|
|
3373
|
+
sections.push("==================== 道藏(写作时严格遵守) ====================");
|
|
3374
|
+
if (bible.genre !== "") sections.push(`题材基调:${bible.genre}`);
|
|
3375
|
+
if (bible.worldRules.length > 0) sections.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
|
|
2316
3376
|
const roleLib = project.roles ?? [];
|
|
2317
|
-
|
|
2318
|
-
|
|
3377
|
+
const labelName = {
|
|
3378
|
+
protagonist: "主角",
|
|
3379
|
+
female_lead: "女主",
|
|
3380
|
+
female_support: "女配",
|
|
3381
|
+
support: "配角",
|
|
3382
|
+
antagonist: "反派",
|
|
3383
|
+
extra: "路人"
|
|
3384
|
+
};
|
|
3385
|
+
const seenRole = /* @__PURE__ */ new Set();
|
|
3386
|
+
const mergedRoles = [];
|
|
3387
|
+
for (const r of roleLib) {
|
|
3388
|
+
seenRole.add(r.name);
|
|
3389
|
+
const card = bible.characters.find((c) => c.name === r.name);
|
|
3390
|
+
const traits = card !== void 0 ? card.traits : Array.isArray(r.traits) ? r.traits : [];
|
|
3391
|
+
const goals = card !== void 0 && card.goals !== "" ? card.goals : r.goals;
|
|
3392
|
+
const relations = card !== void 0 && card.relations !== "" ? card.relations : Array.isArray(r.relations) && r.relations.length > 0 ? r.relations.join("、") : "";
|
|
3393
|
+
mergedRoles.push(`- ${r.name}(${labelName[r.roleLabel]}):${r.identity}${traits.length > 0 ? `;性格:${traits.join("、")}` : ""}${goals !== "" ? `;目标:${goals}` : ""}${relations !== "" ? `;关系:${relations}` : ""}`);
|
|
3394
|
+
if (card !== void 0 && Array.isArray(card.knowledge) && card.knowledge.length > 0) mergedRoles.push(` 已知信息(该角色知道的:${card.knowledge.join(";")};未列出的信息该角色一律不知道,不得写其知晓或提及)`);
|
|
3395
|
+
}
|
|
3396
|
+
for (const card of bible.characters) {
|
|
3397
|
+
if (seenRole.has(card.name)) continue;
|
|
3398
|
+
seenRole.add(card.name);
|
|
3399
|
+
const roleName = {
|
|
2319
3400
|
protagonist: "主角",
|
|
2320
|
-
|
|
2321
|
-
female_support: "女配",
|
|
2322
|
-
support: "配角",
|
|
3401
|
+
supporting: "配角",
|
|
2323
3402
|
antagonist: "反派",
|
|
2324
|
-
|
|
2325
|
-
};
|
|
2326
|
-
|
|
2327
|
-
|
|
3403
|
+
other: "其他"
|
|
3404
|
+
}[card.role];
|
|
3405
|
+
mergedRoles.push(`- ${card.name}(${roleName}):${card.traits.join("、")}${card.goals !== "" ? `;目标:${card.goals}` : ""}${card.relations !== "" ? `;关系:${card.relations}` : ""}`);
|
|
3406
|
+
if (Array.isArray(card.knowledge) && card.knowledge.length > 0) mergedRoles.push(` 已知信息(该角色知道的:${card.knowledge.join(";")};未列出的信息该角色一律不知道,不得写其知晓或提及)`);
|
|
3407
|
+
}
|
|
3408
|
+
if (mergedRoles.length > 0) {
|
|
3409
|
+
sections.push("角色卡(角色库与道藏已合并去重):");
|
|
3410
|
+
sections.push(...mergedRoles);
|
|
2328
3411
|
}
|
|
2329
3412
|
if (bible.redLines.length > 0) sections.push("写作红线(违反即失败):\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
|
|
2330
3413
|
if (bible.style.length > 0) sections.push("风格要求:\n" + bible.style.map((r) => `- ${r}`).join("\n"));
|
|
@@ -2355,9 +3438,12 @@ function writeSystemPrompt(project) {
|
|
|
2355
3438
|
}
|
|
2356
3439
|
sections.push("");
|
|
2357
3440
|
sections.push("写作硬性要求:");
|
|
2358
|
-
|
|
3441
|
+
const target = targetChars !== void 0 && targetChars > 0 ? targetChars : 3500;
|
|
3442
|
+
const lo = Math.max(1e3, Math.round(target * .85 / 100) * 100);
|
|
3443
|
+
const hi = Math.max(lo + 100, Math.round(target * 1.15 / 100) * 100);
|
|
3444
|
+
sections.push(lengthRule ?? `1. 每章 ${lo}-${hi} 字(目标 ${target} 字,按中文字符计),只输出章节正文,不要输出标题、章回名、作者的话或任何 Markdown 标记。`);
|
|
2359
3445
|
sections.push("2. 以主角视角展开,动作、对话、心理描写交替推进,禁止大段设定说明。");
|
|
2360
|
-
sections.push("3.
|
|
3446
|
+
sections.push("3. 尊重大纲与道藏:人设不崩、金手指规则不自相矛盾、战力不随意膨胀。");
|
|
2361
3447
|
sections.push("4. 章末留一个钩子(悬念、反转或新线索),吸引读者读下一章。");
|
|
2362
3448
|
sections.push("5. 语言流畅自然,符合中文网文语感,避免翻译腔与病句。");
|
|
2363
3449
|
sections.push("6. 对话与冲突密度:每章至少 1 处实质对话或正面对抗/交锋场面;推理与心理活动必须用动作、环境细节、微表情、对话呈现,禁止整章纯内心独白铺陈(禁止\"解说式\"交代线索)。");
|
|
@@ -2389,14 +3475,22 @@ async function planChapters(ctx, config, project, chapterCount, volumeNo, output
|
|
|
2389
3475
|
}
|
|
2390
3476
|
const outlineBlock = continuation ? (() => {
|
|
2391
3477
|
if (project.outline.length <= 2e4) return project.outline;
|
|
2392
|
-
const
|
|
2393
|
-
|
|
2394
|
-
|
|
3478
|
+
const volMarkers = ["第" + (volume?.no ?? "") + "卷", "卷" + (volume?.no ?? "")];
|
|
3479
|
+
let cut = -1;
|
|
3480
|
+
for (const marker of volMarkers) {
|
|
3481
|
+
const idx = project.outline.lastIndexOf(marker);
|
|
3482
|
+
if (idx > cut) cut = idx;
|
|
3483
|
+
}
|
|
3484
|
+
if (cut < 2e3) cut = Math.min(15e3, project.outline.length);
|
|
3485
|
+
return project.outline.slice(0, cut).trimEnd() + "\n…(大纲过长,已保留当前卷及之前内容,后续从略)";
|
|
2395
3486
|
})() : project.outline;
|
|
2396
3487
|
const user = [
|
|
2397
3488
|
"请为下面这部小说规划章节。",
|
|
2398
3489
|
volume !== void 0 ? `本次只规划第 ${volume.no} 卷《${volume.title}》的章节:\n${volume.summary}` : continuation ? `本书已有 ${existing.length} 章已规划/已写作(见下方「已有章节」)。请规划**后续**章节:从第 ${startNo} 章开始。` : "请规划全书开篇章节。",
|
|
2399
|
-
continuation ?
|
|
3490
|
+
continuation ? (() => {
|
|
3491
|
+
const eventLines = existing.slice(-20).filter((c) => c.summary !== void 0 && c.summary.trim() !== "").map((c) => "第" + c.no + "章《" + c.title + "》:" + c.summary.slice(0, 80));
|
|
3492
|
+
return "【续写硬性要求】已有章节的剧情不得重写或重复,章节标题也不得与已有章节重复。\n" + (eventLines.length > 0 ? "以下情节已在已有章节中发生过(最近 " + eventLines.length + " 章摘要),后续章节**绝对不得重写或重复**:\n" + eventLines.join("\n") : "已有章节的剧情不得重写或重复(无章节摘要时以编年录为准)。") + "\n若本次规划已进入大纲的收尾区间(接近全书规划总章数),最后 5-10 章必须按大纲推进到大结局(终极抉择/清算/双结局等),**禁止以悬念、逃离、未解之谜收尾**——收尾区间按大纲卷定位判断,不以当前剧情是否\"感觉像结尾\"为准。";
|
|
3493
|
+
})() : "",
|
|
2400
3494
|
prevTail !== "" ? `【上一章(第 ${startNo - 1} 章)结尾原文】第 ${startNo} 章必须紧接此状态继续,从新的事件写起,不得回顾重述:\n${prevTail}` : "",
|
|
2401
3495
|
latestFacts !== "" ? `【最新剧情状态(本书编年录,第 ${startNo - 1} 章结尾的事实)】规划续写时必须以此为起点,时间线、人物状态与地点衔接一致:\n${latestFacts}` : "",
|
|
2402
3496
|
continuation ? "已有章节(共 " + existing.length + " 章;仅列最近 80 章,更早的以标题计数为准,剧情以「编年录」为权威):\n" + existing.slice(-80).map((c) => {
|
|
@@ -2443,26 +3537,27 @@ async function planChapters(ctx, config, project, chapterCount, volumeNo, output
|
|
|
2443
3537
|
function reviewSystemPrompt(project) {
|
|
2444
3538
|
const bible = project.bible;
|
|
2445
3539
|
const sections = [
|
|
2446
|
-
"
|
|
3540
|
+
"你是一位严格的网文审稿编辑。你会收到一章正文以及本书的道藏与红线。",
|
|
2447
3541
|
"请从以下维度审查本章:",
|
|
2448
|
-
"1.
|
|
2449
|
-
"2.
|
|
2450
|
-
"3.
|
|
3542
|
+
"1. 人设一致性:角色行为是否符合下方角色卡的设定(性格/目标/知情度/说话方式)。",
|
|
3543
|
+
"2. 设定一致性:金手指规则、战力体系、世界观是否与道藏冲突。",
|
|
3544
|
+
"3. 红线检查:是否触犯下方「本书红线」与「内容合规红线」。",
|
|
2451
3545
|
"4. 文笔质量:语病、翻译腔、AI 套话(\"不禁\"\"仿佛\"\"一时间\"等高频词滥用)、流水账。",
|
|
2452
3546
|
"5. 节奏与爽点:本章是否有推进、有钩子,是否拖沓灌水。",
|
|
2453
3547
|
"6. 逻辑漏洞:前后矛盾、时间线错误、对话失真。",
|
|
2454
|
-
"7. 反 AI 规则:逐条核对下方「反 AI
|
|
3548
|
+
"7. 反 AI 规则:逐条核对下方「反 AI 规则」清单——禁止类命中即列为问题,鼓励类只作低优先级建议、不阻塞通过。",
|
|
2455
3549
|
"8. 呈现方式:整章是否纯内心推理铺陈(无对话/无对抗,推理全靠解说);反派是否纯背景板无行动;重要配角是否无名标签化(瘦高个/灰衣人全程代称)——命中即列为问题。",
|
|
2456
3550
|
"9. 内容合规(最高优先级):逐条核对下方「内容合规红线」,任何一条命中(含影射、暗示、详细描写)必须列为 high,并给出改写建议。",
|
|
2457
3551
|
"输出必须是合法 JSON 对象,不要输出任何其他文字:",
|
|
2458
|
-
"{\"score\": 0-100的整数, \"verdict\": \"一句话总评\", \"issues\": [{\"severity\": \"high|medium|low\", \"item\": \"问题描述\", \"suggestion\": \"修改建议\"}]}",
|
|
3552
|
+
"{\"score\": 0-100的整数, \"verdict\": \"一句话总评\", \"issues\": [{\"severity\": \"high|medium|low\", \"dimension\": \"character|setting|redline|writing|pacing|logic|anti-ai|presentation|compliance\", \"item\": \"问题描述\", \"suggestion\": \"修改建议\"}]}",
|
|
3553
|
+
"维度 dimension 与上方 9 个审查维度一一对应:人设=character、设定=setting、红线=redline、文笔=writing、节奏=pacing、逻辑=logic、反AI=anti-ai、呈现=presentation、合规=compliance。每条 issue 都必须填 dimension。",
|
|
2459
3554
|
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
2460
3555
|
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
2461
3556
|
];
|
|
2462
3557
|
const assetsBlock = renderAllAssets(project.assets);
|
|
2463
3558
|
if (assetsBlock !== "") sections.push("\n" + assetsBlock);
|
|
2464
3559
|
if (bible !== void 0) {
|
|
2465
|
-
sections.push("\n====================
|
|
3560
|
+
sections.push("\n==================== 道藏 ====================");
|
|
2466
3561
|
if (bible.worldRules.length > 0) sections.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
|
|
2467
3562
|
if (bible.characters.length > 0) {
|
|
2468
3563
|
sections.push("角色卡:");
|
|
@@ -2484,17 +3579,28 @@ async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
|
|
|
2484
3579
|
if (chapter === void 0) throw new Error(`章节 ${chapterNo} 不在计划中`);
|
|
2485
3580
|
const body = readChapterFile(outputDir, chapter);
|
|
2486
3581
|
if (body === void 0) throw new Error(`章节 ${chapterNo} 的正文文件不存在`);
|
|
3582
|
+
const bodyText = body.replace(/^#\s+.*$/m, "").trim();
|
|
3583
|
+
const aiScan = scanAiFlavor(bodyText);
|
|
3584
|
+
const blocks = renderContextBlocks(buildChapterContext(project, chapter, outputDir, { stage: "review" }));
|
|
3585
|
+
const crossChapter = [
|
|
3586
|
+
blocks.continuityBlock,
|
|
3587
|
+
blocks.factsBlock,
|
|
3588
|
+
blocks.plotlinesBlock,
|
|
3589
|
+
blocks.foreshadowsBlock
|
|
3590
|
+
].filter((b) => b !== "").join("\n");
|
|
2487
3591
|
const user = [
|
|
2488
3592
|
`本章标题:《${chapter.title}》`,
|
|
2489
3593
|
`本章剧情要点:${chapter.beats}`,
|
|
3594
|
+
`==================== 本地 AI 味扫描(事实锚点,你只需复核判断,不必再逐字统计) ====================\n${aiScan.summary}`,
|
|
3595
|
+
crossChapter,
|
|
2490
3596
|
"==================== 章节正文 ====================",
|
|
2491
|
-
|
|
2492
|
-
].join("\n");
|
|
3597
|
+
bodyText
|
|
3598
|
+
].filter((line) => line !== "").join("\n");
|
|
2493
3599
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
2494
3600
|
system: reviewSystemPrompt(project),
|
|
2495
3601
|
user,
|
|
2496
3602
|
temperature: .3,
|
|
2497
|
-
maxTokens: Math.max(config.maxTokens,
|
|
3603
|
+
maxTokens: Math.max(config.maxTokens, 8e3)
|
|
2498
3604
|
}));
|
|
2499
3605
|
const issues = Array.isArray(raw.issues) ? raw.issues.filter((v) => typeof v === "object" && v !== null).map((entry) => ({
|
|
2500
3606
|
severity: [
|
|
@@ -2502,13 +3608,16 @@ async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
|
|
|
2502
3608
|
"medium",
|
|
2503
3609
|
"low"
|
|
2504
3610
|
].includes(entry.severity) ? entry.severity : "medium",
|
|
3611
|
+
dimension: typeof entry.dimension === "string" && REVIEW_DIMENSION_IDS.has(entry.dimension) ? entry.dimension : void 0,
|
|
2505
3612
|
item: typeof entry.item === "string" ? entry.item : "",
|
|
2506
3613
|
suggestion: typeof entry.suggestion === "string" ? entry.suggestion : ""
|
|
2507
3614
|
})).filter((issue) => issue.item !== "") : [];
|
|
2508
3615
|
const score = typeof raw.score === "number" ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60;
|
|
3616
|
+
const hasHigh = issues.some((i) => i.severity === "high");
|
|
3617
|
+
const softThreshold = Math.max(65, config.reviewPassScore - 5);
|
|
2509
3618
|
const report = {
|
|
2510
3619
|
score,
|
|
2511
|
-
passed: score >= config.reviewPassScore
|
|
3620
|
+
passed: hasHigh ? score >= config.reviewPassScore : score >= softThreshold,
|
|
2512
3621
|
verdict: typeof raw.verdict === "string" ? raw.verdict.slice(0, 200) : "",
|
|
2513
3622
|
issues,
|
|
2514
3623
|
reviewedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -2524,17 +3633,20 @@ async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
|
|
|
2524
3633
|
* 复用审稿提示词与红线/道藏/反AI规则;仅返回报告,不改文件不改状态。
|
|
2525
3634
|
*/
|
|
2526
3635
|
async function reviewChapterText(ctx, config, project, text, previousReport) {
|
|
3636
|
+
const bodyText = text.slice(0, 2e4);
|
|
3637
|
+
const aiScan = scanAiFlavor(bodyText);
|
|
2527
3638
|
const user = [
|
|
2528
3639
|
`书名:《${project.bookName}》`,
|
|
2529
3640
|
previousReport !== void 0 ? "==================== 上一轮审稿意见(逐条核对是否已解决) ====================\n" + previousReport.issues.map((it, i) => `${i + 1}. [${it.severity}] ${it.item}${it.suggestion !== "" ? ` → ${it.suggestion}` : ""}`).join("\n") : "",
|
|
2530
3641
|
previousReport !== void 0 ? "==================== 修订稿(上一轮审稿后按意见修订的正文) ====================" : "==================== 待审查正文 ====================",
|
|
2531
|
-
|
|
3642
|
+
`==================== 本地 AI 味扫描(事实锚点,你只需复核判断,不必再逐字统计) ====================\n${aiScan.summary}`,
|
|
3643
|
+
bodyText
|
|
2532
3644
|
].join("\n");
|
|
2533
3645
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
2534
3646
|
system: previousReport !== void 0 ? verifySystemPrompt(project) : reviewSystemPrompt(project),
|
|
2535
3647
|
user,
|
|
2536
3648
|
temperature: .3,
|
|
2537
|
-
maxTokens: Math.max(config.maxTokens,
|
|
3649
|
+
maxTokens: Math.max(config.maxTokens, 8e3)
|
|
2538
3650
|
}));
|
|
2539
3651
|
const issues = Array.isArray(raw.issues) ? raw.issues.filter((v) => typeof v === "object" && v !== null).map((entry) => ({
|
|
2540
3652
|
severity: [
|
|
@@ -2542,14 +3654,20 @@ async function reviewChapterText(ctx, config, project, text, previousReport) {
|
|
|
2542
3654
|
"medium",
|
|
2543
3655
|
"low"
|
|
2544
3656
|
].includes(entry.severity) ? entry.severity : "medium",
|
|
3657
|
+
dimension: typeof entry.dimension === "string" && REVIEW_DIMENSION_IDS.has(entry.dimension) ? entry.dimension : void 0,
|
|
2545
3658
|
item: typeof entry.item === "string" ? entry.item : "",
|
|
2546
3659
|
suggestion: typeof entry.suggestion === "string" ? entry.suggestion : ""
|
|
2547
3660
|
})).filter((issue) => issue.item !== "") : [];
|
|
2548
3661
|
const score = typeof raw.score === "number" ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60;
|
|
2549
|
-
let passed =
|
|
3662
|
+
let passed = issues.some((i) => i.severity === "high") ? score >= config.reviewPassScore : score >= Math.max(65, config.reviewPassScore - 5);
|
|
2550
3663
|
if (previousReport !== void 0) {
|
|
2551
3664
|
const hasHigh = issues.some((i) => i.severity === "high");
|
|
2552
|
-
const
|
|
3665
|
+
const prevHigh = previousReport.issues.filter((i) => i.severity === "high");
|
|
3666
|
+
const unresolvedIds = Array.isArray(raw.unresolvedIds) ? raw.unresolvedIds.filter((v) => typeof v === "number") : [];
|
|
3667
|
+
const resolvedIds = Array.isArray(raw.resolvedIds) ? raw.resolvedIds.filter((v) => typeof v === "number") : [];
|
|
3668
|
+
let prevHighResolved;
|
|
3669
|
+
if (unresolvedIds.length > 0 || resolvedIds.length > 0) prevHighResolved = prevHigh.every((_, idx) => !unresolvedIds.includes(idx + 1) || resolvedIds.includes(idx + 1));
|
|
3670
|
+
else prevHighResolved = prevHigh.every((p) => !issues.some((i) => i.item.replace(/^未解决\(\d+\):/, "").includes(p.item.replace(/^未解决\(\d+\):/, "").slice(0, 20))));
|
|
2553
3671
|
passed = !hasHigh && prevHighResolved;
|
|
2554
3672
|
}
|
|
2555
3673
|
return {
|
|
@@ -2565,16 +3683,20 @@ function verifySystemPrompt(project) {
|
|
|
2565
3683
|
return [
|
|
2566
3684
|
"你是一位网文审稿验证员。作者已按上一轮审稿意见修订了本章,你需要验证修订效果。",
|
|
2567
3685
|
"你的任务(严格按此执行):",
|
|
2568
|
-
"1.
|
|
3686
|
+
"1. 逐条核对「上一轮意见」中的每一条(按编号 1、2、3...)是否已在修订稿中解决。",
|
|
2569
3687
|
"2. 只挑修订【新引入】的 high 级问题(设定矛盾/逻辑硬伤/事实错误)——新引入的 medium/low 主观项(文笔/套话/节奏)不要列。",
|
|
2570
3688
|
"3. 禁止重复挑剔上一轮已指出且本次已解决的主观项(如\"缓缓/微微\"等套话、错别字)——即使换个说法再提也不行。",
|
|
2571
|
-
"4. 严禁为了显得专业而新增\"换一批毛病\"
|
|
2572
|
-
"
|
|
2573
|
-
"
|
|
2574
|
-
"
|
|
3689
|
+
"4. 严禁为了显得专业而新增\"换一批毛病\"式的意见。",
|
|
3690
|
+
"输出必须是合法 JSON 对象,包含以下字段:",
|
|
3691
|
+
"- resolvedIds:已解决的上一轮意见编号数组(如 [1, 3, 5])。",
|
|
3692
|
+
"- unresolvedIds:未解决或部分解决的上一轮意见编号数组(如 [2, 4])。",
|
|
3693
|
+
"- issues:未解决的原意见 + 新引入的 high 级问题列表(格式同审稿:severity/item/suggestion)。未解决的原意见 item 需注明\"未解决(编号N):原意见摘要\"。",
|
|
3694
|
+
"- score:按修订稿整体质量给 50-90 分(解决全部 high 且无新增 high 时给 70 以上)。",
|
|
3695
|
+
"- verdict:一句话结论。",
|
|
3696
|
+
"完整格式:{\"resolvedIds\": [1,3], \"unresolvedIds\": [2], \"score\": 75, \"verdict\": \"一句话\", \"issues\": [{\"severity\": \"high\", \"dimension\": \"character|setting|redline|writing|pacing|logic|anti-ai|presentation|compliance\", \"item\": \"未解决(2):xxx\", \"suggestion\": \"xxx\"}]}",
|
|
2575
3697
|
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
2576
3698
|
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。",
|
|
2577
|
-
|
|
3699
|
+
`本书道藏(核对设定冲突用):\n${project.bible !== void 0 ? JSON.stringify(project.bible).slice(0, 3e3) : "(无)"}`
|
|
2578
3700
|
].join("\n");
|
|
2579
3701
|
}
|
|
2580
3702
|
/** Build the author-review system prompt (narrative structure, not prose). */
|
|
@@ -2717,8 +3839,8 @@ async function extractRoles(ctx, config, project) {
|
|
|
2717
3839
|
const system = [
|
|
2718
3840
|
"你是一位网文角色库管理员。请根据本书的大纲、设定、编年录与章节摘要,提炼完整的角色库。",
|
|
2719
3841
|
"覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如\"矮胖姑娘\")用其身份简称收录并标注;反复出现且有剧情作用的身份型角色(站长、律师、警察、法官、店主等)必须收录。",
|
|
2720
|
-
"数量控制:最多输出
|
|
2721
|
-
"重要:正常一部完整故事应提炼
|
|
3842
|
+
"数量控制:最多输出 20 个角色;覆盖优先——主角、主要反派、重要配角(女主/关键配角)必须全收,所有有名有姓者必收;只有真正一次性路人(无名字、无剧情作用)才可省略。",
|
|
3843
|
+
"重要:正常一部完整故事应提炼 8-20 个角色;若少于 6 个通常说明漏提炼,必须重新核对正文摘录。",
|
|
2722
3844
|
"重要:正文中若有明确的主角与主要反派,必须分别以 protagonist / antagonist 收录,禁止遗漏;反派确实未出场时才可省略。",
|
|
2723
3845
|
"输出优先级:主角(protagonist)与主要反派(antagonist)必须优先输出并完整刻画,其次女主/重要配角;判断不出名字时用正文中的身份称呼。",
|
|
2724
3846
|
"每个角色输出:",
|
|
@@ -2738,12 +3860,12 @@ async function extractRoles(ctx, config, project) {
|
|
|
2738
3860
|
].join("\n");
|
|
2739
3861
|
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
|
|
2740
3862
|
const existingRoles = project.roles ?? [];
|
|
2741
|
-
const sampleChapters = written.length <=
|
|
3863
|
+
const sampleChapters = written.length <= 10 ? written : (() => {
|
|
2742
3864
|
const picked = /* @__PURE__ */ new Set();
|
|
2743
3865
|
for (let i = 0; i < 3 && i < written.length; i++) picked.add(i);
|
|
2744
|
-
const step = Math.max(1, Math.floor(written.length /
|
|
2745
|
-
for (let i = step; i < written.length -
|
|
2746
|
-
for (let i = Math.max(0, written.length -
|
|
3866
|
+
const step = Math.max(1, Math.floor(written.length / 8));
|
|
3867
|
+
for (let i = step; i < written.length - 3; i += step) picked.add(i);
|
|
3868
|
+
for (let i = Math.max(0, written.length - 3); i < written.length; i++) picked.add(i);
|
|
2747
3869
|
return [...picked].sort((a, b) => a - b).map((i) => written[i]);
|
|
2748
3870
|
})();
|
|
2749
3871
|
const excerptParts = [];
|
|
@@ -2751,14 +3873,32 @@ async function extractRoles(ctx, config, project) {
|
|
|
2751
3873
|
const body = readChapterFile(config.outputDir, chapter);
|
|
2752
3874
|
if (body === void 0) continue;
|
|
2753
3875
|
const text = body.replace(/^#.*$/gm, "").trim();
|
|
2754
|
-
if (text.length > 0) excerptParts.push(`第${chapter.no}章《${chapter.title}》\n${text.slice(0,
|
|
3876
|
+
if (text.length > 0) excerptParts.push(`第${chapter.no}章《${chapter.title}》\n${text.slice(0, 3e3)}`);
|
|
3877
|
+
}
|
|
3878
|
+
const freqCandidates = /* @__PURE__ */ new Set();
|
|
3879
|
+
for (const c of project.bible?.characters ?? []) freqCandidates.add(c.name);
|
|
3880
|
+
for (const r of existingRoles) freqCandidates.add(r.name);
|
|
3881
|
+
const freqMap = /* @__PURE__ */ new Map();
|
|
3882
|
+
if (freqCandidates.size > 0) for (const chapter of written) {
|
|
3883
|
+
const body = readChapterFile(config.outputDir, chapter);
|
|
3884
|
+
if (body === void 0) continue;
|
|
3885
|
+
for (const name of freqCandidates) {
|
|
3886
|
+
let idx = 0, n = 0;
|
|
3887
|
+
while ((idx = body.indexOf(name, idx)) !== -1) {
|
|
3888
|
+
n++;
|
|
3889
|
+
idx += name.length;
|
|
3890
|
+
}
|
|
3891
|
+
if (n > 0) freqMap.set(name, (freqMap.get(name) ?? 0) + n);
|
|
3892
|
+
}
|
|
2755
3893
|
}
|
|
3894
|
+
const freqLines = [...freqMap.entries()].filter(([, n]) => n >= 2).sort((a, b) => b[1] - a[1]).slice(0, 30).map(([name, n]) => name + "(" + n + "次)");
|
|
2756
3895
|
const user = [
|
|
2757
3896
|
`书名:《${project.bookName}》`,
|
|
2758
3897
|
existingRoles.length > 0 ? `已收录角色(跳过,不要输出):${existingRoles.map((r) => r.name).join("、")}` : "",
|
|
2759
3898
|
`大纲(节选前 3000 字):\n${project.outline.slice(0, 3e3)}`,
|
|
2760
3899
|
excerptParts.length > 0 ? `已写正文摘录(角色姓名/身份/关系以正文为准):\n${excerptParts.join("\n\n")}` : "",
|
|
2761
3900
|
project.bible !== void 0 && project.bible.characters.length > 0 ? `已有角色卡(补充信息):\n${project.bible.characters.map((c) => `- ${c.name}(${c.role}):${c.traits.join("、")}${c.goals !== "" ? `;目标:${c.goals}` : ""}`).join("\n")}` : "",
|
|
3901
|
+
freqLines.length > 0 ? `角色出场频次参考(全书统计,次数越多越重要,优先收录高频角色):${freqLines.join("、")}` : "",
|
|
2762
3902
|
(project.facts ?? []).length > 0 ? `编年录(最近 30 条):\n${(project.facts ?? []).slice(-30).map((f) => `[第${f.chapterNo}章] ${f.text.slice(0, 60)}`).join("\n")}` : "",
|
|
2763
3903
|
written.length > 0 ? `已写章节标题(${written.length} 章):\n${written.map((c) => `第${c.no}章《${c.title}》`).join("、")}` : "",
|
|
2764
3904
|
"只输出 JSON 数组。"
|
|
@@ -2767,18 +3907,18 @@ async function extractRoles(ctx, config, project) {
|
|
|
2767
3907
|
system,
|
|
2768
3908
|
user,
|
|
2769
3909
|
temperature: .3,
|
|
2770
|
-
maxTokens: Math.max(config.maxTokens,
|
|
3910
|
+
maxTokens: Math.max(config.maxTokens, 16e3),
|
|
2771
3911
|
reasoning: config.analysisReasoning ?? "low"
|
|
2772
3912
|
});
|
|
2773
3913
|
let raw = parseJsonArray(text);
|
|
2774
3914
|
const hasProtagonist = raw.some((e) => typeof e === "object" && e !== null && e.roleLabel === "protagonist");
|
|
2775
|
-
const tooFew = raw.length > 0 && raw.length <
|
|
3915
|
+
const tooFew = raw.length > 0 && raw.length < 6;
|
|
2776
3916
|
if (raw.length === 0 || !hasProtagonist || tooFew) {
|
|
2777
3917
|
text = await complete(ctx, config, {
|
|
2778
|
-
system: system + (raw.length === 0 ? "\n上一次输出为空或格式不正确。请直接输出 JSON 数组(即使只有一个角色也要输出),不要输出其他文字。" : tooFew ? "\n上一次输出角色过少(不足
|
|
3918
|
+
system: system + (raw.length === 0 ? "\n上一次输出为空或格式不正确。请直接输出 JSON 数组(即使只有一个角色也要输出),不要输出其他文字。" : tooFew ? "\n上一次输出角色过少(不足 6 个)。这是一部完整故事,请重新核对正文摘录:主角、主要反派、重要配角与所有有名有姓的角色都要收录(宁多勿漏),输出 8-20 个。" : "\n上一次输出中缺少主角(roleLabel 为 protagonist 的角色)。请重新输出完整 JSON 数组,务必包含正文中的主角。"),
|
|
2779
3919
|
user,
|
|
2780
3920
|
temperature: .3,
|
|
2781
|
-
maxTokens: Math.max(config.maxTokens,
|
|
3921
|
+
maxTokens: Math.max(config.maxTokens, 12e3),
|
|
2782
3922
|
reasoning: config.analysisReasoning ?? "low"
|
|
2783
3923
|
});
|
|
2784
3924
|
raw = parseJsonArray(text);
|
|
@@ -2829,7 +3969,7 @@ async function extractRoles(ctx, config, project) {
|
|
|
2829
3969
|
if (role === void 0 || existing.has(role.name)) continue;
|
|
2830
3970
|
existing.add(role.name);
|
|
2831
3971
|
roles.push(role);
|
|
2832
|
-
if (roles.length >=
|
|
3972
|
+
if (roles.length >= 20) break;
|
|
2833
3973
|
}
|
|
2834
3974
|
} catch {}
|
|
2835
3975
|
}
|
|
@@ -2859,7 +3999,30 @@ async function extractRoles(ctx, config, project) {
|
|
|
2859
3999
|
"掌门",
|
|
2860
4000
|
"师父",
|
|
2861
4001
|
"师傅",
|
|
2862
|
-
"管家"
|
|
4002
|
+
"管家",
|
|
4003
|
+
"长老",
|
|
4004
|
+
"宗主",
|
|
4005
|
+
"殿主",
|
|
4006
|
+
"宫主",
|
|
4007
|
+
"师兄",
|
|
4008
|
+
"师姐",
|
|
4009
|
+
"师弟",
|
|
4010
|
+
"师妹",
|
|
4011
|
+
"老祖",
|
|
4012
|
+
"魔尊",
|
|
4013
|
+
"妖王",
|
|
4014
|
+
"将军",
|
|
4015
|
+
"军师",
|
|
4016
|
+
"太监",
|
|
4017
|
+
"宫女",
|
|
4018
|
+
"嬷嬷",
|
|
4019
|
+
"宰相",
|
|
4020
|
+
"尚书",
|
|
4021
|
+
"巡抚",
|
|
4022
|
+
"都督",
|
|
4023
|
+
"祭司",
|
|
4024
|
+
"圣女",
|
|
4025
|
+
"圣子"
|
|
2863
4026
|
];
|
|
2864
4027
|
const existingNames = new Set(roles.map((r) => r.name));
|
|
2865
4028
|
const titleCount = /* @__PURE__ */ new Map();
|
|
@@ -2877,7 +4040,7 @@ async function extractRoles(ctx, config, project) {
|
|
|
2877
4040
|
}
|
|
2878
4041
|
}
|
|
2879
4042
|
for (const t of ROLE_TITLE_HINTS) {
|
|
2880
|
-
if (roles.length >=
|
|
4043
|
+
if (roles.length >= 20) break;
|
|
2881
4044
|
if (!(existingNames.has(t) || [...existingNames].some((n) => n.includes(t))) && (titleCount.get(t) ?? 0) >= 3) {
|
|
2882
4045
|
existingNames.add(t);
|
|
2883
4046
|
roles.push({
|
|
@@ -2895,18 +4058,18 @@ async function extractRoles(ctx, config, project) {
|
|
|
2895
4058
|
return roles;
|
|
2896
4059
|
}
|
|
2897
4060
|
/** ✨ AI 从全书提炼场景库:正文/编年录 → 高频重要场景的结构化视觉锚点。 */
|
|
2898
|
-
async function extractScenes(ctx, config, project, styleId, filterId) {
|
|
4061
|
+
async function extractScenes(ctx, config, project, chapterNo, styleId, filterId) {
|
|
2899
4062
|
const styleWords = styleKeywords(styleId, filterId);
|
|
2900
4063
|
const system = [
|
|
2901
|
-
"
|
|
2902
|
-
"
|
|
4064
|
+
"你是一位网文漫剧场景导演。根据指定章节的正文,提炼「镜头场景」——漫剧分镜/生图时每个镜头要知道\"在哪、什么时间光态、拍什么情节、人物什么状态\"。",
|
|
4065
|
+
"当前提取范围:" + (chapterNo !== void 0 ? "第" + chapterNo + "章" : "全书") + "。只提炼本章实际出现的场景,不要提前提取后续章节的场景。",
|
|
2903
4066
|
"【当前视觉风格】(moment/palette/moods/zh/en 必须按此风格措辞,不能写中性描述):" + styleWords,
|
|
2904
4067
|
"重要:每个场景的 zh 提示词段首必须原样嵌入【当前视觉风格】词块原文;en 提示词末尾追加风格标签。不得省略或改写。",
|
|
2905
|
-
"
|
|
2906
|
-
"
|
|
4068
|
+
"每个场景必须是「镜头场景」而非仅场地:给出该场景的关键情节镜头(人物动作+情绪+镜头推进,只写进 beats,禁止写进 zh);场景生图提示词 zh/en 必须是无人空镜。",
|
|
4069
|
+
"数量控制:本章输出 3-5 个场景;每个场景必须能对应到本章正文实际出现过的地点与情节,不得凭空虚构。",
|
|
2907
4070
|
"每个场景输出:",
|
|
2908
4071
|
"1. name:场景名(地点+光态,如「后场通道·装卸口(雨夜)」)。",
|
|
2909
|
-
"2. act
|
|
4072
|
+
"2. act:场景在本章的段落位置(开篇/发展/高潮/结局)。",
|
|
2910
4073
|
"3. moment:时间光态(夜间闭店后/雨夜/凌晨/频闪灯…)。",
|
|
2911
4074
|
"4. summary:一句话定位(空间类型/功能/氛围)。",
|
|
2912
4075
|
"5. beats:关键情节镜头 1-3 条(人物动作+情绪+镜头推进,如\"沈放佝偻站在货架过道,镜头推近,情绪从漠然过渡到压抑悲伤\")。",
|
|
@@ -2914,21 +4077,23 @@ async function extractScenes(ctx, config, project, styleId, filterId) {
|
|
|
2914
4077
|
"7. elements:环境构成数组 3-6 项(空间结构/陈设/标志物)。",
|
|
2915
4078
|
"8. palette:色调与光影数组 2-4 项(颜色词,可附 HEX)。",
|
|
2916
4079
|
"9. moods:氛围关键词 2-4 个(压抑/神秘/空旷/悲凉…)。",
|
|
2917
|
-
"10. zh
|
|
4080
|
+
"10. zh:中文生图提示词(连贯一段,写实电影感),必须为【无人物空镜】——只写空间结构/材质/光线/氛围/标志物,严禁任何人物、动作、情节、台词、镜头调度(人物与情节由角色卡和分镜负责,场景底图必须是无人空镜)。",
|
|
2918
4081
|
"11. en:英文生图提示词(booru 风格逗号分隔,含 photorealistic/cinematic)。",
|
|
2919
4082
|
"12. tags:3-6 个关键标签。",
|
|
2920
4083
|
"13. source:依据来源说明(哪几章哪些描写)。",
|
|
2921
|
-
"
|
|
2922
|
-
"
|
|
4084
|
+
"14. tier:场景分级——core(核心场景,反复出现≥3次或多章引用,需精修多图)/ secondary(次要场景,出现1-2次,一张全景图够)/ passing(路过场景,只被提到名字,不做图)。按出场重要性自动判断。",
|
|
4085
|
+
"15. negativePrompt:负面提示词(场景专用,必须包含 no people, no characters, no text, no watermark, no logo,可补充场景相关负面词)。",
|
|
4086
|
+
"若本章存在关键转折或抉择,最后 1 个场景应为「转折场景」,beats 里写明关键画面(禁止写进 zh)。",
|
|
4087
|
+
"输出必须是合法 JSON 数组,不要输出其他文字:[{\"name\":\"...\", \"act\":\"...\", \"moment\":\"...\", \"summary\":\"...\", \"beats\":[...], \"characterState\":\"...\", \"elements\":[...], \"palette\":[...], \"moods\":[...], \"zh\":\"...\", \"en\":\"...\", \"tags\":[...], \"source\":\"...\", \"tier\":\"...\", \"negativePrompt\":\"...\"}]",
|
|
2923
4088
|
"重要:所有字符串值内部不得包含换行符;直接输出 JSON 结果本身。"
|
|
2924
4089
|
].join("\n");
|
|
2925
|
-
const
|
|
4090
|
+
const targetChapters = chapterNo !== void 0 ? project.chapters.filter((c) => c.no === chapterNo && c.status !== "pending" && c.status !== "generating") : project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating").slice(0, 4);
|
|
2926
4091
|
const excerptParts = [];
|
|
2927
|
-
for (const chapter of
|
|
4092
|
+
for (const chapter of targetChapters) {
|
|
2928
4093
|
const body = readChapterFile(config.outputDir, chapter);
|
|
2929
4094
|
if (body === void 0) continue;
|
|
2930
4095
|
const text = body.replace(/^#.*$/gm, "").trim();
|
|
2931
|
-
if (text.length > 0) excerptParts.push("第" + chapter.no + "章《" + chapter.title + "》\n" + text.slice(0,
|
|
4096
|
+
if (text.length > 0) excerptParts.push("第" + chapter.no + "章《" + chapter.title + "》\n" + text.slice(0, 3e3));
|
|
2932
4097
|
}
|
|
2933
4098
|
const raw = parseJsonArray(await complete(ctx, config, {
|
|
2934
4099
|
system,
|
|
@@ -2939,7 +4104,7 @@ async function extractScenes(ctx, config, project, styleId, filterId) {
|
|
|
2939
4104
|
"只输出 JSON 数组。"
|
|
2940
4105
|
].filter((s) => s !== "").join("\n"),
|
|
2941
4106
|
temperature: .4,
|
|
2942
|
-
maxTokens: Math.max(config.maxTokens,
|
|
4107
|
+
maxTokens: Math.max(config.maxTokens, 32e3),
|
|
2943
4108
|
reasoning: config.analysisReasoning ?? "low"
|
|
2944
4109
|
}));
|
|
2945
4110
|
const strArr = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.trim() !== "") : [];
|
|
@@ -2963,10 +4128,46 @@ async function extractScenes(ctx, config, project, styleId, filterId) {
|
|
|
2963
4128
|
en: ensureStyleEmbedded(str(entry.en).slice(0, 600), styleWords, "en"),
|
|
2964
4129
|
tags: strArr(entry.tags).map((t) => t.slice(0, 24)).slice(0, 6),
|
|
2965
4130
|
source: str(entry.source).slice(0, 120),
|
|
4131
|
+
tier: [
|
|
4132
|
+
"core",
|
|
4133
|
+
"secondary",
|
|
4134
|
+
"passing"
|
|
4135
|
+
].includes(str(entry.tier)) ? str(entry.tier) : "secondary",
|
|
4136
|
+
negativePrompt: str(entry.negativePrompt) !== "" ? str(entry.negativePrompt).slice(0, 300) : "no people, no characters, no text, no watermark, no logo, no subtitles, blurry, low quality, distorted, deformed",
|
|
2966
4137
|
styleId
|
|
2967
4138
|
});
|
|
2968
4139
|
}
|
|
2969
|
-
|
|
4140
|
+
const sceneShort = (n) => n.split("·")[0].split("(")[0].split("(")[0].trim();
|
|
4141
|
+
const writtenChapters = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
|
|
4142
|
+
for (const sc of scenes) {
|
|
4143
|
+
const appears = [];
|
|
4144
|
+
const short = sceneShort(sc.name);
|
|
4145
|
+
for (const ch of writtenChapters) {
|
|
4146
|
+
const body = readChapterFile(config.outputDir, ch);
|
|
4147
|
+
if (body === void 0) continue;
|
|
4148
|
+
if (body.includes(sc.name) || short.length >= 2 && body.includes(short)) {
|
|
4149
|
+
if (!appears.includes(ch.no)) appears.push(ch.no);
|
|
4150
|
+
}
|
|
4151
|
+
}
|
|
4152
|
+
appears.sort((a, b) => a - b);
|
|
4153
|
+
sc.appearsInChapters = appears.length > 0 ? appears : sc.appearsInChapters;
|
|
4154
|
+
if (sc.tier === void 0 || sc.tier === "passing" && appears.length > 0) sc.tier = appears.length >= 3 ? "core" : appears.length >= 1 ? "secondary" : "passing";
|
|
4155
|
+
if (sc.negativePrompt === void 0 || sc.negativePrompt === "") sc.negativePrompt = "no people, no characters, no text, no watermark, no logo, no subtitles, blurry, low quality, distorted, deformed";
|
|
4156
|
+
}
|
|
4157
|
+
const existingShorts = new Set((project.scenes ?? []).map((s) => sceneShort(s.name)));
|
|
4158
|
+
let deduped = scenes.filter((sc) => !existingShorts.has(sceneShort(sc.name)));
|
|
4159
|
+
const coreScenes = deduped.filter((s) => s.tier === "core").slice(0, 5);
|
|
4160
|
+
const secondaryScenes = deduped.filter((s) => s.tier === "secondary").slice(0, 5);
|
|
4161
|
+
const passingScenes = deduped.filter((s) => s.tier === "passing").slice(0, 2);
|
|
4162
|
+
deduped = [
|
|
4163
|
+
...coreScenes,
|
|
4164
|
+
...secondaryScenes,
|
|
4165
|
+
...passingScenes
|
|
4166
|
+
];
|
|
4167
|
+
for (const sc of deduped) try {
|
|
4168
|
+
saveMangaScenePrompt(config.outputDir, sc.name, sc.zh, sc.negativePrompt);
|
|
4169
|
+
} catch {}
|
|
4170
|
+
return deduped;
|
|
2970
4171
|
}
|
|
2971
4172
|
/** 从 txt/md 全本文本拆章(纯逻辑,不落盘):识别章节头、剥离重复标题、去重、排序并统一重新编号。 */
|
|
2972
4173
|
function splitBookText(raw) {
|
|
@@ -3239,7 +4440,7 @@ function withStyle(pair, styleWords) {
|
|
|
3239
4440
|
* 底层实现:为任意角色源提炼「动漫形象描述词」(不写库,写库由上层调用方负责)。
|
|
3240
4441
|
* 扫描该角色出场的已写章节正文,截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
|
|
3241
4442
|
*/
|
|
3242
|
-
async function extractRoleVisualFrom(ctx, config, project, outputDir, role, styleId, filterId, shortDrama = false) {
|
|
4443
|
+
async function extractRoleVisualFrom(ctx, config, project, outputDir, role, styleId, filterId, shortDrama = false, tier = "protagonist") {
|
|
3243
4444
|
const roleName = role.name;
|
|
3244
4445
|
const appearanceHints = /(发|眉|眼|眸|脸|肤|唇|身材|身高|衣|袍|裙|衫|靴|腰带|气质|模样|长相|容貌|披|束|扎|戴|佩|挂|绣|青|白|黑|红|蓝|紫|灰|银|金|少年|青年|少女|汉子|老者|中年|纤细|挺拔|瘦削|壮实|清秀|俊朗|英气|阴鸷|慈眉)/;
|
|
3245
4446
|
const roleText = `${role.name} ${role.identity ?? ""}`;
|
|
@@ -3356,6 +4557,8 @@ async function extractRoleVisualFrom(ctx, config, project, outputDir, role, styl
|
|
|
3356
4557
|
"4. 标志物(标签/印记/饰品)必须出现在每段提示词中——它们是一致性的命根子。",
|
|
3357
4558
|
"5. 立绘/四视图/细节是「角色设定稿」:禁止写瞬间动作与道具使用状态(握手机、看屏幕、未接来电、走路、回头等),禁止写剧情状态与场景背景;只保留可长期存在的外貌、服装与常驻标志物(工牌、饰品等)。",
|
|
3358
4559
|
...shortDrama ? ["6. 短剧精简模式·人设极致化:性格标签必须极致化(如「极端偏执」「绝对冷血」,单一维度拉到最满);外貌只保留 1-2 个最强辨识度点,弱化平凡细节;服装/标志物更夸张醒目。"] : [],
|
|
4560
|
+
...tier === "supporting" ? ["配角模式:只需输出立绘提示词(portrait),不需要四视图/表情/细节,外貌描述精简到80字以内。"] : [],
|
|
4561
|
+
...tier === "extra" ? ["路人模式:不需要输出精修提示词(promptKit)和表情清单(expressions),只需基础外貌描述(40字以内)和英文标签。"] : [],
|
|
3359
4562
|
"【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):",
|
|
3360
4563
|
...rules.map((r) => "- " + r),
|
|
3361
4564
|
"输出六部分:",
|
|
@@ -3363,14 +4566,15 @@ async function extractRoleVisualFrom(ctx, config, project, outputDir, role, styl
|
|
|
3363
4566
|
"- en:英文形象锚点,booru 风格、逗号分隔、小写,30-50 个标签:含性别(1boy/1girl)、发色、发型、瞳色、服装、气质、标志物。不要输出负面提示词。",
|
|
3364
4567
|
"- tags:中文关键标签数组,5-10 个(如 [\"黑发\",\"束发\",\"青色道袍\",\"清秀\",\"腰悬古玉\"])。",
|
|
3365
4568
|
"- source:说明依据(如\"第1章/第8章外貌描写;瞳色未明确\")。",
|
|
4569
|
+
"- negativePrompt:即梦/生图通用负面提示词,中文逗号分隔,8-12个词:必须包含 低质量、模糊、变形、多指、断肢、文字、水印、丑陋、比例失调;可根据角色补充(如写实风加 卡通)。",
|
|
3366
4570
|
"- expressions:该角色在本书剧情中需要的情绪表情清单数组,6-12 个(如 [\"疲惫\",\"麻木\",\"压抑悲伤\",\"皱眉\",\"紧绷\",\"放空\"]),依据正文情绪描写与角色处境推断。",
|
|
3367
4571
|
"- promptKit:四类精修提示词(字段化+出图约束,参考示例结构):",
|
|
3368
4572
|
" · portrait 立绘:严格按字段顺序写(参考示例结构):开头构图定位(正面站立全身人像)→ 风格(3D动漫,超精细建模)→ 背景(纯白纯色背景)→ 身份(男子,{角色名},外表{年龄},男性)→ 发型发色 → 胡茬 → 眼眸 → 面部 → 气质 → 上身服装分件(颜色款式+磨损细节)→ 下身 → 鞋 → 标志物(标签/印记,含细节如洇暗翘边)→ 收尾(角色设计稿,细节完整展示,无多余杂物,全身完整无裁切)。",
|
|
3369
|
-
" · sheet
|
|
4573
|
+
" · sheet 角色设定稿:专业角色设计参考图(character design sheet),纯白背景,最高品质细节丰富。结构:1.主视觉区(上方):正面+侧面+背面三视图,直观呈现整体身形、服饰搭配和标志性特征;2.补充信息区(左侧):面部特写+配色板(明确毛发/服饰色值),补充主视角没覆盖的细节与色彩标准;3.局部细节区(底部):小模块单独展示关键部件设计(配饰、点缀、关键身份识别元素),把模糊细节拆分为精准制作参考;4.全身比例照(右侧):黄金比例参考物与人物身高形成对比。画质要求:8K高清纹理,质感光照,自然光线,布料褶皱自然,皮肤纹理细节完整,艺术写实风格,营造震撼视觉效果。人物外观设定按字段:年龄、性别、发型发色、五官(眉/眼/鼻/唇)、脸型、身高、气质、服装分件、标志物。",
|
|
3370
4574
|
" · expressions 表情:每个表情一段,脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,只换情绪表达(眼神/嘴角/眉),皮肤纹理细节完整,无多余杂物。",
|
|
3371
4575
|
" · details 细节:多组局部细节集合参考图(一张图多个局部框),纯白背景;把该角色全部标志物逐项列出(如标签/印记/工牌/袖口磨损/鞋),每项一句特写描述;细节清晰锐利,角色细节参考稿,无多余杂物。",
|
|
3372
4576
|
"promptKit 每段 zh:连贯中文 60-150 字;en:booru 标签 30-50 个。",
|
|
3373
|
-
"输出必须是合法 JSON 对象:{\"zh\": \"...\", \"en\": \"...\", \"tags\": [...], \"source\": \"...\", \"expressions\": [...], \"promptKit\": {\"portrait\": {\"zh\":\"...\", \"en\":\"...\"}, \"sheet\": {\"zh\":\"...\", \"en\":\"...\"}, \"expressions\": [{\"name\":\"疲惫\",\"zh\":\"...\",\"en\":\"...\"}], \"details\": {\"zh\":\"...\", \"en\":\"...\"}}}",
|
|
4577
|
+
"输出必须是合法 JSON 对象:{\"zh\": \"...\", \"en\": \"...\", \"tags\": [...], \"source\": \"...\", \"negativePrompt\": \"...\", \"expressions\": [...], \"promptKit\": {\"portrait\": {\"zh\":\"...\", \"en\":\"...\"}, \"sheet\": {\"zh\":\"...\", \"en\":\"...\"}, \"expressions\": [{\"name\":\"疲惫\",\"zh\":\"...\",\"en\":\"...\"}], \"details\": {\"zh\":\"...\", \"en\":\"...\"}}}",
|
|
3374
4578
|
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
3375
4579
|
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
3376
4580
|
].join("\n"),
|
|
@@ -3390,6 +4594,7 @@ async function extractRoleVisualFrom(ctx, config, project, outputDir, role, styl
|
|
|
3390
4594
|
let en = typeof raw.en === "string" ? raw.en.trim().slice(0, 1500) : "";
|
|
3391
4595
|
const tags = Array.isArray(raw.tags) ? raw.tags.filter((t) => typeof t === "string" && t.trim() !== "").map((t) => t.trim().slice(0, 20)).slice(0, 12) : [];
|
|
3392
4596
|
const source = typeof raw.source === "string" ? raw.source.trim().slice(0, 300) : "";
|
|
4597
|
+
const negativePrompt = typeof raw.negativePrompt === "string" && raw.negativePrompt.trim() !== "" ? raw.negativePrompt.trim().slice(0, 300) : "低质量,模糊,变形,多指,断肢,文字,水印,丑陋,比例失调";
|
|
3393
4598
|
const expressions = Array.isArray(raw.expressions) ? raw.expressions.filter((e) => typeof e === "string" && e.trim() !== "").map((e) => e.trim().slice(0, 12)).slice(0, 12) : [];
|
|
3394
4599
|
const str = (v) => typeof v === "string" ? v.trim() : "";
|
|
3395
4600
|
const pair = (v) => {
|
|
@@ -3420,19 +4625,38 @@ async function extractRoleVisualFrom(ctx, config, project, outputDir, role, styl
|
|
|
3420
4625
|
zh = ensureStyleEmbedded(zh, styleWords, "zh");
|
|
3421
4626
|
en = ensureStyleEmbedded(en, styleWords, "en");
|
|
3422
4627
|
if (promptKit !== void 0) promptKit = {
|
|
3423
|
-
portrait: withStyle(promptKit.portrait, styleWords),
|
|
3424
|
-
sheet: withStyle(promptKit.sheet, styleWords),
|
|
3425
|
-
expressions: promptKit.expressions.map((e) => ({
|
|
4628
|
+
portrait: promptKit.portrait !== void 0 ? withStyle(promptKit.portrait, styleWords) : void 0,
|
|
4629
|
+
sheet: promptKit.sheet !== void 0 ? withStyle(promptKit.sheet, styleWords) : void 0,
|
|
4630
|
+
expressions: promptKit.expressions !== void 0 ? promptKit.expressions.map((e) => ({
|
|
3426
4631
|
...e,
|
|
3427
4632
|
...withStyle(e, styleWords)
|
|
3428
|
-
})),
|
|
3429
|
-
details: withStyle(promptKit.details, styleWords)
|
|
4633
|
+
})) : void 0,
|
|
4634
|
+
details: promptKit.details !== void 0 ? withStyle(promptKit.details, styleWords) : void 0
|
|
4635
|
+
};
|
|
4636
|
+
if (tier === "supporting") return {
|
|
4637
|
+
zh: zh.slice(0, 100),
|
|
4638
|
+
en,
|
|
4639
|
+
tags,
|
|
4640
|
+
source,
|
|
4641
|
+
negativePrompt,
|
|
4642
|
+
expressions: [],
|
|
4643
|
+
promptKit: promptKit !== void 0 ? { portrait: promptKit.portrait } : void 0
|
|
4644
|
+
};
|
|
4645
|
+
if (tier === "extra") return {
|
|
4646
|
+
zh: zh.slice(0, 60),
|
|
4647
|
+
en,
|
|
4648
|
+
tags,
|
|
4649
|
+
source,
|
|
4650
|
+
negativePrompt,
|
|
4651
|
+
expressions: [],
|
|
4652
|
+
promptKit: void 0
|
|
3430
4653
|
};
|
|
3431
4654
|
return {
|
|
3432
4655
|
zh,
|
|
3433
4656
|
en,
|
|
3434
4657
|
tags,
|
|
3435
4658
|
source,
|
|
4659
|
+
negativePrompt,
|
|
3436
4660
|
expressions,
|
|
3437
4661
|
promptKit
|
|
3438
4662
|
};
|
|
@@ -3452,38 +4676,39 @@ async function extractMangaRoleVisual(ctx, config, project, outputDir, cardId, s
|
|
|
3452
4676
|
name: card.name,
|
|
3453
4677
|
identity: card.identity,
|
|
3454
4678
|
traits: card.traits
|
|
3455
|
-
}, styleId, filterId, project.shortDramaMode === true);
|
|
4679
|
+
}, styleId, filterId, project.shortDramaMode === true, card.tier ?? "protagonist");
|
|
3456
4680
|
card.promptStyleId = styleId;
|
|
3457
4681
|
card.imagePrompt = visual;
|
|
3458
4682
|
if ((visual.expressions ?? []).length > 0) card.expressions = visual.expressions;
|
|
3459
4683
|
if (visual.promptKit !== void 0) card.promptKit = visual.promptKit;
|
|
3460
4684
|
if (card.status === "imported" || card.status === "pending_confirm") card.status = "anchored";
|
|
3461
4685
|
card.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4686
|
+
try {
|
|
4687
|
+
saveMangaRolePrompt(outputDir, card.name, visual.zh, visual.en, visual.negativePrompt);
|
|
4688
|
+
} catch {}
|
|
3462
4689
|
return visual;
|
|
3463
4690
|
}
|
|
3464
4691
|
/**
|
|
3465
4692
|
* 底层实现:基于角色源的形象锚点 + 表情清单 + 视觉规则产出四类生图提示词(不写库)。
|
|
3466
4693
|
*/
|
|
3467
|
-
async function generateRolePromptKitFrom(ctx, config, project, role, styleId, filterId, shortDrama = false) {
|
|
4694
|
+
async function generateRolePromptKitFrom(ctx, config, project, role, styleId, filterId, shortDrama = false, tier = "protagonist") {
|
|
3468
4695
|
role.name;
|
|
3469
4696
|
const rules = project.visualRules ?? [];
|
|
3470
4697
|
const styleWords = styleKeywords(styleId, filterId);
|
|
3471
4698
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
3472
4699
|
system: [
|
|
3473
|
-
"你是一位 AI
|
|
4700
|
+
"你是一位 AI 绘图提示词工程师。基于给定角色的形象锚点、表情清单与本书视觉规则,输出立绘+表情两类生图提示词(每类 zh+en 各一段)。即梦画布支持多角度编辑,无需生成四视图设定稿。",
|
|
3474
4701
|
"【当前视觉风格】(每类提示词必须内嵌):" + styleWords,
|
|
3475
4702
|
"重要:每段 zh 提示词的段首必须原样嵌入【当前视觉风格】词块原文;en 提示词末尾追加风格标签。不得省略或改写。",
|
|
3476
|
-
"
|
|
3477
|
-
"1. portrait
|
|
3478
|
-
"2.
|
|
3479
|
-
"3.
|
|
3480
|
-
"4. details 细节:多组局部细节集合参考图(一张图多个局部框),纯白背景;把该角色全部标志物逐项列出,每项一句特写描述;细节清晰锐利,角色细节参考稿,无多余杂物。",
|
|
3481
|
-
"5. 立绘/四视图/细节为「角色设定稿」:禁止瞬间动作、道具使用状态与剧情状态(握手机、看屏幕、未接来电等),只保留可长期存在的外貌、服装与常驻标志物(工牌、饰品等)。",
|
|
4703
|
+
"两类:",
|
|
4704
|
+
"1. portrait 立绘:正面站立全身人像,纯白纯色背景;按即梦官方7维度公式组织:①[年龄/种族]具体岁数+国籍/人种+风格形容词+脸型名词;②[肤色/皮肤质感]冷暖色调+具体肤色+皮肤质感形容词+保留真实微细毛孔与肌肤纹理;③[面部细节特征]眼型+眉骨+鼻梁+唇形+下颌线(至少3-4点组合);④[眼神/灵魂]眼神形容词+目光传达的信息+透出的底层情绪;⑤[发型/发色]具体发色+头发状态/质感+具体发型+环境互动;⑥[服装/服装质感]版型/剪裁+颜色+具体服装名词+布料材质/新旧状态+穿着细节;⑦[体型/情绪/气质]骨架/肩部特征+整体散发的氛围词。收尾:角色设计稿,细节完整展示,无多余杂物,全身完整无裁切。",
|
|
4705
|
+
"2. expressions 表情(高级可选):每个表情一段,脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,只换情绪表达(眼神/嘴角/眉),皮肤纹理细节完整,无多余杂物。",
|
|
4706
|
+
"3. 立绘为「角色设定稿」:禁止瞬间动作、道具使用状态与剧情状态(握手机、看屏幕、未接来电等),只保留可长期存在的外貌、服装与常驻标志物(工牌、饰品等)。即梦画布多角度编辑可生成侧面/背面,无需生成四视图。",
|
|
3482
4707
|
...shortDrama ? ["6. 短剧精简模式·人设极致化:性格标签必须极致化(单一维度拉到最满);外貌只保留 1-2 个最强辨识度点;服装/标志物更夸张醒目,一眼可认。"] : [],
|
|
3483
4708
|
"【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):",
|
|
3484
4709
|
...rules.map((r) => "- " + r),
|
|
3485
4710
|
"zh 要求:连贯中文,写实电影感,60-150 字/段;en 要求:booru 风格逗号分隔标签,30-50 个/段。",
|
|
3486
|
-
"输出必须是合法 JSON 对象:{\"portrait\": {\"zh\": \"...\", \"en\": \"...\"}, \"
|
|
4711
|
+
"输出必须是合法 JSON 对象:{\"portrait\": {\"zh\": \"...\", \"en\": \"...\"}, \"expressions\": [{\"name\": \"疲惫\", \"zh\": \"...\", \"en\": \"...\"}]}",
|
|
3487
4712
|
"重要:所有字符串值内部不得包含换行符;直接输出 JSON 结果本身。"
|
|
3488
4713
|
].join("\n"),
|
|
3489
4714
|
user: [
|
|
@@ -3509,17 +4734,17 @@ async function generateRolePromptKitFrom(ctx, config, project, role, styleId, fi
|
|
|
3509
4734
|
const expressionsRaw = Array.isArray(raw.expressions) ? raw.expressions.filter((e) => typeof e === "object" && e !== null) : [];
|
|
3510
4735
|
const kit = {
|
|
3511
4736
|
portrait: withStyle(pair(raw.portrait), styleWords),
|
|
3512
|
-
sheet: withStyle(pair(raw.sheet), styleWords),
|
|
3513
4737
|
expressions: expressionsRaw.map((e) => ({
|
|
3514
4738
|
name: str(e.name).slice(0, 12) || "表情",
|
|
3515
4739
|
...withStyle({
|
|
3516
4740
|
zh: str(e.zh).slice(0, 800),
|
|
3517
4741
|
en: str(e.en).slice(0, 1200)
|
|
3518
4742
|
}, styleWords)
|
|
3519
|
-
})).slice(0, 12)
|
|
3520
|
-
details: withStyle(pair(raw.details), styleWords)
|
|
4743
|
+
})).slice(0, 12)
|
|
3521
4744
|
};
|
|
3522
|
-
if (kit.portrait.zh === "" || kit.portrait.en === "") throw new Error("提示词精修失败:LLM 未返回有效 JSON");
|
|
4745
|
+
if (kit.portrait === void 0 || kit.portrait.zh === "" || kit.portrait.en === "") throw new Error("提示词精修失败:LLM 未返回有效 JSON");
|
|
4746
|
+
if (tier === "supporting") return { portrait: kit.portrait };
|
|
4747
|
+
if (tier === "extra") return {};
|
|
3523
4748
|
return kit;
|
|
3524
4749
|
}
|
|
3525
4750
|
/** 小说角色库:角色四类生图提示词精修包(写回角色卡)。 */
|
|
@@ -3545,7 +4770,7 @@ async function generateMangaRolePromptKit(ctx, config, project, cardId, styleId,
|
|
|
3545
4770
|
identity: card.identity,
|
|
3546
4771
|
imagePrompt: card.imagePrompt,
|
|
3547
4772
|
expressions: card.expressions
|
|
3548
|
-
}, styleId, filterId, project.shortDramaMode === true);
|
|
4773
|
+
}, styleId, filterId, project.shortDramaMode === true, card.tier ?? "protagonist");
|
|
3549
4774
|
card.promptStyleId = styleId;
|
|
3550
4775
|
card.promptKit = kit;
|
|
3551
4776
|
if (card.status === "imported" || card.status === "pending_confirm") card.status = "anchored";
|
|
@@ -3576,12 +4801,15 @@ async function nominateMangaRoles(ctx, config, project, outputDir, chapterNo) {
|
|
|
3576
4801
|
imported.add(c.name);
|
|
3577
4802
|
if (c.sourceRoleName !== void 0 && c.sourceRoleName !== "") imported.add(c.sourceRoleName);
|
|
3578
4803
|
}
|
|
3579
|
-
const already = names.filter((n) => imported.has(n)).map((n) =>
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
4804
|
+
const already = names.filter((n) => imported.has(n)).map((n) => {
|
|
4805
|
+
return {
|
|
4806
|
+
rawName: n,
|
|
4807
|
+
verdict: "already_imported",
|
|
4808
|
+
matches: [],
|
|
4809
|
+
tier: (project.mangaRoles ?? []).find((c) => c.name === n || c.sourceRoleName === n)?.tier ?? "supporting",
|
|
4810
|
+
suggested: emptyCandidateSuggestion(n)
|
|
4811
|
+
};
|
|
4812
|
+
});
|
|
3585
4813
|
const fresh = names.filter((n) => !imported.has(n));
|
|
3586
4814
|
if (fresh.length === 0) return already;
|
|
3587
4815
|
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
@@ -3617,7 +4845,7 @@ async function nominateMangaRoles(ctx, config, project, outputDir, chapterNo) {
|
|
|
3617
4845
|
"你是一位漫剧选角导演。下面给出「分镜角色提名」与「小说角色库候选名单」。",
|
|
3618
4846
|
"任务:为每个提名判定它对应小说角色库中的哪一个候选(或判定小说库没有对应角色),并预填一张「漫剧角色卡」的建议信息。",
|
|
3619
4847
|
"规则:",
|
|
3620
|
-
"1. roleName
|
|
4848
|
+
"1. roleName 优先从该提名的候选名单中选;若候选名单为空或都不像,允许从下方「全书角色库」中挑选最符合该身份代称的正式角色名,禁止虚构不存在的角色。",
|
|
3621
4849
|
"2. verdict:matched=候选名单里确实有对应角色(选最像的那个);ambiguous=名单里有多个候选且无法确定(此时 roleName 可留空或选最可能的一个);not_in_library=候选名单为空或都不对应。",
|
|
3622
4850
|
"3. matched/ambiguous 时给出漫剧卡建议:name(漫剧用名,默认与角色名一致)、identity(身份一句话,30 字内)、coreFunction(protagonist=主角/mentor=导师/love_interest=感情线/antagonist=反派/sidekick=搭档/informant=线人/functional=功能性)、protagonistRelation(enemy/friend/mentor/lover/exploit=利用/neutral)、speechStyle(口头禅或说话方式)、traits(不超过 3 个极致性格标签)、appearance(1-2 个辨识度外貌点)、keyScenes(本章该角色 1-2 个关键剧情节点,格式「第N章 xxx」)。",
|
|
3623
4851
|
"4. 身份型提名(如「持枪者」「围观群众」):候选名单有则匹配;名单没有且正文确有该称谓 → not_in_library;正文也没有 → not_in_library。",
|
|
@@ -3631,6 +4859,7 @@ async function nominateMangaRoles(ctx, config, project, outputDir, chapterNo) {
|
|
|
3631
4859
|
beats !== "" ? beats : "(无骨架节拍)",
|
|
3632
4860
|
"==== 角色提名与候选名单 ====",
|
|
3633
4861
|
shortlists.map((s) => s.roleNames.length > 0 ? `[提名] ${s.rawName} → 候选:${s.roleNames.join("、")}` : `[提名] ${s.rawName} → 候选:(无)`).join("\n"),
|
|
4862
|
+
"==== 全书角色库(身份代称归属判定以这里为准,正式名只能从这里取) ====\n" + roles.map((r) => `${r.name}(${r.roleLabel}):${r.identity ?? ""};特征:${(r.traits ?? []).slice(0, 3).join("/")}`).join("\n"),
|
|
3634
4863
|
body !== void 0 ? "==== 章节正文(前 2500 字,判断称谓归属用) ====\n" + body.replace(/^#.*$/gm, "").trim().slice(0, 2500) : "",
|
|
3635
4864
|
"只输出 JSON 数组。"
|
|
3636
4865
|
].filter((x) => x !== "").join("\n\n"),
|
|
@@ -3664,17 +4893,30 @@ async function nominateMangaRoles(ctx, config, project, outputDir, chapterNo) {
|
|
|
3664
4893
|
for (const e of raw) if (typeof e === "object" && e !== null && typeof e.rawName === "string") byName.set(e.rawName.trim(), e);
|
|
3665
4894
|
const str = (v) => typeof v === "string" ? v.trim().slice(0, 100) : "";
|
|
3666
4895
|
const strArr = (v, n) => Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.trim() !== "").map((x) => x.trim().slice(0, 20)).slice(0, n) : [];
|
|
4896
|
+
const calcTier = (rawName, matched, fn) => {
|
|
4897
|
+
if (matched !== void 0) {
|
|
4898
|
+
const r = roles.find((x) => x.name === matched);
|
|
4899
|
+
if (r?.roleLabel === "protagonist" || r?.roleLabel === "female_lead") return "protagonist";
|
|
4900
|
+
if (r?.roleLabel === "antagonist") return "protagonist";
|
|
4901
|
+
if (r?.roleLabel === "support" || r?.roleLabel === "female_support") return "supporting";
|
|
4902
|
+
}
|
|
4903
|
+
if (fn === "protagonist") return "protagonist";
|
|
4904
|
+
if (fn === "antagonist" || fn === "love_interest") return "protagonist";
|
|
4905
|
+
if (fn === "functional") return "extra";
|
|
4906
|
+
return "supporting";
|
|
4907
|
+
};
|
|
3667
4908
|
const out = [];
|
|
3668
4909
|
for (const item of shortlists) {
|
|
3669
4910
|
const judge = byName.get(item.rawName);
|
|
3670
4911
|
const judgeVerdict = judge !== void 0 ? str(judge.verdict) : "";
|
|
3671
|
-
|
|
4912
|
+
let verdict = verdicts.has(judgeVerdict) ? judgeVerdict : item.roleNames.length > 0 ? "ambiguous" : "not_in_library";
|
|
3672
4913
|
let roleName;
|
|
3673
4914
|
if (judge !== void 0) {
|
|
3674
4915
|
const chosen = str(judge.roleName);
|
|
3675
|
-
if (chosen !== "" && item.roleNames.includes(chosen)) roleName = chosen;
|
|
4916
|
+
if (chosen !== "" && (item.roleNames.includes(chosen) || roles.some((r) => r.name === chosen))) roleName = chosen;
|
|
3676
4917
|
}
|
|
3677
4918
|
if (verdict === "matched" && roleName === void 0 && item.roleNames.length === 1) roleName = item.roleNames[0];
|
|
4919
|
+
if (roleName !== void 0 && verdict === "not_in_library") verdict = "matched";
|
|
3678
4920
|
const sug = typeof judge?.suggested === "object" && judge?.suggested !== null ? judge.suggested : {};
|
|
3679
4921
|
const novelHint = verdict === "not_in_library" && body !== void 0 ? body.includes(item.rawName) ? "backfill" : "manga_new" : void 0;
|
|
3680
4922
|
const suggested = {
|
|
@@ -3696,6 +4938,7 @@ async function nominateMangaRoles(ctx, config, project, outputDir, chapterNo) {
|
|
|
3696
4938
|
})),
|
|
3697
4939
|
matchedRoleName: roleName,
|
|
3698
4940
|
novelHint,
|
|
4941
|
+
tier: calcTier(item.rawName, roleName, suggested.coreFunction),
|
|
3699
4942
|
suggested
|
|
3700
4943
|
});
|
|
3701
4944
|
}
|
|
@@ -3941,55 +5184,6 @@ async function breakdownBook(ctx, config, project, outputDir, scope = "recent",
|
|
|
3941
5184
|
usedTokens
|
|
3942
5185
|
};
|
|
3943
5186
|
}
|
|
3944
|
-
/** 生图接口连通性测试(设置页模型条目用):GET {baseURL}/models,计时返回延迟。 */
|
|
3945
|
-
async function testImageEndpoint(baseURL, apiKey, model) {
|
|
3946
|
-
const trimmed = baseURL.trim();
|
|
3947
|
-
if (trimmed === "") return {
|
|
3948
|
-
ok: false,
|
|
3949
|
-
ms: 0,
|
|
3950
|
-
message: "接口地址不能为空"
|
|
3951
|
-
};
|
|
3952
|
-
const url = trimmed.replace(/\/+$/, "") + "/models";
|
|
3953
|
-
const start = Date.now();
|
|
3954
|
-
try {
|
|
3955
|
-
const res = await fetch(url, {
|
|
3956
|
-
method: "GET",
|
|
3957
|
-
headers: {
|
|
3958
|
-
Authorization: "Bearer " + apiKey,
|
|
3959
|
-
"Content-Type": "application/json"
|
|
3960
|
-
}
|
|
3961
|
-
});
|
|
3962
|
-
const ms = Date.now() - start;
|
|
3963
|
-
if (!res.ok) {
|
|
3964
|
-
const text = (await res.text().catch(() => "")).slice(0, 200);
|
|
3965
|
-
return {
|
|
3966
|
-
ok: false,
|
|
3967
|
-
ms,
|
|
3968
|
-
message: "HTTP " + res.status + (text !== "" ? ":" + text : "")
|
|
3969
|
-
};
|
|
3970
|
-
}
|
|
3971
|
-
let modelFound;
|
|
3972
|
-
if (model !== void 0 && model !== "") {
|
|
3973
|
-
const list = (await res.json().catch(() => null))?.data;
|
|
3974
|
-
if (Array.isArray(list)) modelFound = list.some((m) => {
|
|
3975
|
-
if (typeof m !== "object" || m === null) return false;
|
|
3976
|
-
const o = m;
|
|
3977
|
-
return o.id === model || o.model === model;
|
|
3978
|
-
});
|
|
3979
|
-
}
|
|
3980
|
-
return {
|
|
3981
|
-
ok: true,
|
|
3982
|
-
ms,
|
|
3983
|
-
modelFound
|
|
3984
|
-
};
|
|
3985
|
-
} catch (err) {
|
|
3986
|
-
return {
|
|
3987
|
-
ok: false,
|
|
3988
|
-
ms: Date.now() - start,
|
|
3989
|
-
message: err.message
|
|
3990
|
-
};
|
|
3991
|
-
}
|
|
3992
|
-
}
|
|
3993
5187
|
/** LLM 连通性失败的错误码 → 人话(供设置页“测试连通”回显)。 */
|
|
3994
5188
|
const LLM_TEST_ERROR_HINT = {
|
|
3995
5189
|
NO_ADAPTER: "提供商路由不存在或未启用",
|
|
@@ -4209,67 +5403,14 @@ async function registerLlmModel(ctx, req) {
|
|
|
4209
5403
|
message
|
|
4210
5404
|
};
|
|
4211
5405
|
}
|
|
4212
|
-
/**
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
if (apiKey === void 0 || apiKey === "" || model === void 0 || model === "") throw new Error("请先在设置中配置生图模型的 API Key 和模型 id");
|
|
4221
|
-
const stylePrompt = getComicStylePrompt(style);
|
|
4222
|
-
const prompt = [
|
|
4223
|
-
anchor.zh,
|
|
4224
|
-
anchor.en,
|
|
4225
|
-
anchor.tags.join(", "),
|
|
4226
|
-
stylePrompt,
|
|
4227
|
-
"character reference sheet, front view, clean background, consistent design"
|
|
4228
|
-
].filter(Boolean).join(", ");
|
|
4229
|
-
const res = await fetch(baseURL + "/images/generations", {
|
|
4230
|
-
method: "POST",
|
|
4231
|
-
headers: {
|
|
4232
|
-
"Content-Type": "application/json",
|
|
4233
|
-
Authorization: `Bearer ${apiKey}`
|
|
4234
|
-
},
|
|
4235
|
-
body: JSON.stringify({
|
|
4236
|
-
model,
|
|
4237
|
-
prompt,
|
|
4238
|
-
response_format: "url",
|
|
4239
|
-
size: "2K",
|
|
4240
|
-
stream: false,
|
|
4241
|
-
watermark: false
|
|
4242
|
-
})
|
|
4243
|
-
});
|
|
4244
|
-
if (!res.ok) {
|
|
4245
|
-
const text = await res.text();
|
|
4246
|
-
throw new Error(`生图失败(${res.status}):${text}`);
|
|
4247
|
-
}
|
|
4248
|
-
const url = (await res.json()).data?.[0]?.url;
|
|
4249
|
-
if (url === void 0 || url === "") throw new Error("生图接口未返回图片 URL");
|
|
4250
|
-
const imgRes = await fetch(url);
|
|
4251
|
-
if (!imgRes.ok) throw new Error("下载生图结果失败");
|
|
4252
|
-
const buf = Buffer.from(await imgRes.arrayBuffer());
|
|
4253
|
-
return `data:${imgRes.headers.get("content-type") || "image/jpeg"};base64,${buf.toString("base64")}`;
|
|
4254
|
-
}
|
|
4255
|
-
/** 小说角色库:生成角色定妆图并写回角色卡(imageUrl)。 */
|
|
4256
|
-
async function generateRoleReferenceImage(ctx, config, project, outputDir, name, style = "", modelId) {
|
|
4257
|
-
const role = project.roles?.find((r) => r.name === name);
|
|
4258
|
-
if (role === void 0) throw new Error(`角色 ${name} 不存在`);
|
|
4259
|
-
if (role.imagePrompt === void 0) throw new Error(`角色 ${name} 还没有形象锚点,请先生成锚点`);
|
|
4260
|
-
const dataUrl = await generateReferenceImageFrom(config, role.imagePrompt, style, modelId);
|
|
4261
|
-
role.imageUrl = dataUrl;
|
|
4262
|
-
return dataUrl;
|
|
4263
|
-
}
|
|
4264
|
-
/** 漫剧角色卡:生成定妆图并写回漫剧卡(imageUrl)。 */
|
|
4265
|
-
async function generateMangaRoleReferenceImage(ctx, config, project, outputDir, cardId, style = "", modelId) {
|
|
4266
|
-
const card = (project.mangaRoles ?? []).find((c) => c.id === cardId);
|
|
4267
|
-
if (card === void 0) throw new Error(`漫剧角色卡 ${cardId} 不存在`);
|
|
4268
|
-
if (card.imagePrompt === void 0) throw new Error(`「${card.name}」还没有形象锚点,请先生成锚点`);
|
|
4269
|
-
const dataUrl = await generateReferenceImageFrom(config, card.imagePrompt, style, modelId);
|
|
4270
|
-
card.imageUrl = dataUrl;
|
|
4271
|
-
card.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4272
|
-
return dataUrl;
|
|
5406
|
+
/** 获取当前激活漫剧方案的风格(styleId + filterId),用于生图/提示词兜底。 */
|
|
5407
|
+
function getActiveMangaStyle(project) {
|
|
5408
|
+
const active = (project.mangaPlans ?? []).find((p) => p.active === true);
|
|
5409
|
+
if (active === void 0) return {};
|
|
5410
|
+
return {
|
|
5411
|
+
styleId: active.styleId,
|
|
5412
|
+
filterId: active.filterId
|
|
5413
|
+
};
|
|
4273
5414
|
}
|
|
4274
5415
|
/** 🩺 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
|
|
4275
5416
|
async function analyzePlotlineHealth(ctx, config, project) {
|
|
@@ -4370,8 +5511,8 @@ async function designPlotlinePlan(ctx, config, project, health) {
|
|
|
4370
5511
|
};
|
|
4371
5512
|
}
|
|
4372
5513
|
/** Build the rewrite system prompt (fix review issues / instructions). */
|
|
4373
|
-
function rewriteSystemPrompt(project) {
|
|
4374
|
-
return writeSystemPrompt(project) + "\n\n额外要求:你正在【修订】一章已写好的正文。保留原文中好的部分,只修改需要修改的地方,输出完整的新正文(不要只输出修改片段),字数与原文相当。";
|
|
5514
|
+
function rewriteSystemPrompt(project, targetChars) {
|
|
5515
|
+
return writeSystemPrompt(project, targetChars, "1. 输出完整的新正文(不要只输出修改片段、标题、章回名、作者的话或任何 Markdown 标记),字数与原章相当(允许 ±20%)。") + "\n\n额外要求:你正在【修订】一章已写好的正文。保留原文中好的部分,只修改需要修改的地方,输出完整的新正文(不要只输出修改片段),字数与原文相当。";
|
|
4375
5516
|
}
|
|
4376
5517
|
/**
|
|
4377
5518
|
* Stream a chapter rewrite. With `target` (a passage of the body), only that
|
|
@@ -4417,7 +5558,29 @@ async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo,
|
|
|
4417
5558
|
"2. 保留该段的情节走向与角色口吻,只按修改要求调整。",
|
|
4418
5559
|
"3. 段落长度与原文相当。"
|
|
4419
5560
|
].filter((line) => line !== "").join("\n");
|
|
4420
|
-
const system = localTarget === void 0 ? rewriteSystemPrompt(project) :
|
|
5561
|
+
const system = localTarget === void 0 ? rewriteSystemPrompt(project, chapter.targetChars || config.chapterChars) : (() => {
|
|
5562
|
+
const bible = project.bible;
|
|
5563
|
+
const lines = [
|
|
5564
|
+
"你是一位中文网文润色师。你会收到一章中的一个段落,请按修改要求重写该段。",
|
|
5565
|
+
"硬性约束:",
|
|
5566
|
+
"1. 只改表达,不改情节走向、人物设定、已确立事实、对话核心内容。",
|
|
5567
|
+
"2. 必须遵守下方「内容合规红线」,任何一条命中(含影射、暗示)都必须避免。",
|
|
5568
|
+
"3. 必须遵守下方「本书红线」(如有)。",
|
|
5569
|
+
"4. 避免 AI 套话:不禁、仿佛、一时间、不由得、顿时、然而、缓缓、轻轻、微微、默默、似乎、终于等滥用。",
|
|
5570
|
+
"5. 保留角色口吻与性格,角色行为需符合下方角色卡(如有)。",
|
|
5571
|
+
"6. 只输出修改后的【这一个段落】的完整新文本,不要输出任何说明、标题或 Markdown 标记。"
|
|
5572
|
+
];
|
|
5573
|
+
if (bible !== void 0) {
|
|
5574
|
+
if (bible.redLines.length > 0) lines.push("本书红线:\n" + bible.redLines.map((r) => "- " + r).join("\n"));
|
|
5575
|
+
if (bible.characters.length > 0) {
|
|
5576
|
+
lines.push("相关角色卡:");
|
|
5577
|
+
for (const card of bible.characters) lines.push("- " + card.name + "(" + card.role + "):" + card.traits.join("、"));
|
|
5578
|
+
}
|
|
5579
|
+
}
|
|
5580
|
+
lines.push("内容合规红线(平台硬性要求,最高优先级):");
|
|
5581
|
+
lines.push(COMPLIANCE_REDLINES.join("\n"));
|
|
5582
|
+
return lines.join("\n");
|
|
5583
|
+
})();
|
|
4421
5584
|
const messages = [createUserMessage({
|
|
4422
5585
|
content: [{
|
|
4423
5586
|
type: "text",
|
|
@@ -4474,16 +5637,22 @@ async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo,
|
|
|
4474
5637
|
/** The de-AI-ify polish system prompt (with project writing assets injected). */
|
|
4475
5638
|
function polishSystemPrompt(project) {
|
|
4476
5639
|
const assetsBlock = renderAllAssets(project.assets);
|
|
4477
|
-
|
|
5640
|
+
const bible = project.bible;
|
|
5641
|
+
const lines = [
|
|
4478
5642
|
"你是一位中文网文润色师。你会收到一章正文,请做「去 AI 味」润色:",
|
|
4479
5643
|
"1. 删除/替换 AI 高频套话与模式词:如\"不禁\"\"仿佛\"\"一时间\"\"不由得\"\"顿时\"\"然而\"\"缓缓\"\"轻轻\"\"微微\"\"默默\"\"似乎\"\"终于\"等滥用。",
|
|
4480
5644
|
"2. 把书面翻译腔改成口语化的中文网文语感。",
|
|
4481
5645
|
"3. 拆分过长的排比句与堆砌的修饰语。",
|
|
4482
|
-
"4.
|
|
5646
|
+
"4. 保留全部情节、人物、对话内容、已确立事实不变,只改表达。",
|
|
4483
5647
|
"5. 输出完整的新正文,不要输出任何说明文字或 Markdown 标记。",
|
|
4484
5648
|
"6. 必须遵守下方「反 AI 规则」与「写法资产」的表达边界;写法资产要求保留的风格特征(句式、台词、节奏)不得在润色中丢失。",
|
|
4485
|
-
|
|
4486
|
-
]
|
|
5649
|
+
"7. 必须遵守下方「内容合规红线」与「本书红线」(如有),任何一条命中(含影射、暗示)都必须避免。"
|
|
5650
|
+
];
|
|
5651
|
+
if (bible !== void 0 && bible.redLines.length > 0) lines.push("本书红线:\n" + bible.redLines.map((r) => "- " + r).join("\n"));
|
|
5652
|
+
lines.push("内容合规红线(平台硬性要求,最高优先级):");
|
|
5653
|
+
lines.push(COMPLIANCE_REDLINES.join("\n"));
|
|
5654
|
+
if (assetsBlock !== "") lines.push(assetsBlock);
|
|
5655
|
+
return lines.join("\n");
|
|
4487
5656
|
}
|
|
4488
5657
|
/** Stream a chapter polish (de-AI-ify). Draft-mode: the polished body lands
|
|
4489
5658
|
* in `chapter.pendingDraft` and is only applied on draft/apply. */
|
|
@@ -4601,7 +5770,7 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
|
|
|
4601
5770
|
provider: config.provider,
|
|
4602
5771
|
model: config.model,
|
|
4603
5772
|
messages,
|
|
4604
|
-
system: writeSystemPrompt(project),
|
|
5773
|
+
system: writeSystemPrompt(project, chapter.targetChars || config.chapterChars),
|
|
4605
5774
|
maxTokens: Math.max(config.maxTokens, 2e4),
|
|
4606
5775
|
temperature: .85
|
|
4607
5776
|
};
|
|
@@ -4630,10 +5799,13 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
|
|
|
4630
5799
|
chapter.error = void 0;
|
|
4631
5800
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4632
5801
|
saveProject(outputDir, project);
|
|
5802
|
+
const target = chapter.targetChars > 0 ? chapter.targetChars : config.chapterChars;
|
|
5803
|
+
const warn = target > 0 ? body.length < target * .8 ? `第${chapter.no}章实际 ${body.length} 字,明显少于目标 ${target} 字` : body.length > target * 1.25 ? `第${chapter.no}章实际 ${body.length} 字,明显多于目标 ${target} 字` : void 0 : void 0;
|
|
4633
5804
|
yield {
|
|
4634
5805
|
frame: "done",
|
|
4635
5806
|
file: fileName,
|
|
4636
|
-
chars: body.length
|
|
5807
|
+
chars: body.length,
|
|
5808
|
+
warn
|
|
4637
5809
|
};
|
|
4638
5810
|
}
|
|
4639
5811
|
/** Generate a chapter summary (narrative memory). */
|
|
@@ -4727,7 +5899,8 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
|
|
|
4727
5899
|
if (body === void 0) throw new Error(`章节 ${chapterNo} 的正文文件不存在`);
|
|
4728
5900
|
if ((skeleton.beats ?? []).length === 0) throw new Error("骨架为空,请先生成剧情骨架");
|
|
4729
5901
|
const roles = (project.roles ?? []).filter((r) => body.includes(r.name)).slice(0, 8).map((r) => `${r.name}(${r.roleLabel === "protagonist" ? "主角" : r.roleLabel === "antagonist" ? "反派" : r.roleLabel === "female_lead" ? "女主" : "配角"}):${r.identity ?? ""}`);
|
|
4730
|
-
const
|
|
5902
|
+
const sceneShortName = (n) => n.split("·")[0].split("(")[0].split("(")[0].trim();
|
|
5903
|
+
const usedScenes = (project.scenes ?? []).filter((s) => body.includes(s.name) || body.includes(sceneShortName(s.name))).slice(0, 6);
|
|
4731
5904
|
const scenes = usedScenes.map((s) => `${s.name}:${s.summary ?? ""}`);
|
|
4732
5905
|
const bindings = buildMangaRoleBindings(project);
|
|
4733
5906
|
const mangaLines = (bindings.byId.size > 0 ? [...bindings.byId.values()].filter((c) => body.includes(c.name) || c.sourceRoleName !== void 0 && body.includes(c.sourceRoleName)).slice(0, 8) : []).map((c) => {
|
|
@@ -4746,7 +5919,7 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
|
|
|
4746
5919
|
system: [
|
|
4747
5920
|
"你是一位从业 10 年的电影导演兼分镜师,专长网文改编影视化。",
|
|
4748
5921
|
"任务:把「剧情骨架」的每一个节拍展开为 1-3 个电影镜头,输出分镜表——只做画面层,禁止新增或改变剧情(骨架是只读输入)。",
|
|
4749
|
-
"输出合法 JSON 对象:{\"shots\": [{\"beatId\": \"骨架节拍id\", \"shot\": \"景别\", \"camera\": \"机位与运镜\", \"composition\": \"构图\", \"duration\": 秒数, \"visual\": \"画面内容\", \"line\": \"台词/旁白\", \"sound\": \"音效\", \"light\": \"光效\", \"prevState\": \"承接上一镜头结尾状态\", \"nextState\": \"本镜头结束状态\", \"characters\": [\"出镜角色名\"]}]}",
|
|
5922
|
+
"输出合法 JSON 对象:{\"shots\": [{\"beatId\": \"骨架节拍id\", \"shot\": \"景别\", \"camera\": \"机位与运镜\", \"composition\": \"构图\", \"duration\": 秒数, \"visual\": \"画面内容\", \"line\": \"台词/旁白\", \"sound\": \"音效\", \"light\": \"光效\", \"prevState\": \"承接上一镜头结尾状态\", \"nextState\": \"本镜头结束状态\", \"jimengCamera\": \"即梦运镜自然语言描述\", \"characters\": [\"出镜角色名\"]}]}",
|
|
4750
5923
|
"景别取值(只能从这些词中选一):大远景/远景/全景/中景/中近景/近景/特写/大特写。",
|
|
4751
5924
|
"运镜取值(可组合,用加号连接):固定机位/推近/拉远/左摇/右摇/左横移/右横移/跟随/升镜/降镜/环绕/手持晃动/低机位仰拍/高机位俯拍/过肩镜头。",
|
|
4752
5925
|
"光效取值(可组合,用加号连接):顺光/侧光/逆光/顶光/伦勃朗光/霓虹光/硬光/柔光/氛围光/高反差。",
|
|
@@ -4754,14 +5927,18 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
|
|
|
4754
5927
|
"硬性要求:",
|
|
4755
5928
|
"【视觉风格】(必须内嵌进 visual/light 的画面措辞与光效描述):",
|
|
4756
5929
|
...styleLines,
|
|
4757
|
-
"1. 按骨架节拍顺序输出镜头,每个节拍至少 1 个镜头,总镜头数 8-
|
|
5930
|
+
"1. 按骨架节拍顺序输出镜头,每个节拍至少 1 个镜头,总镜头数 8-15。",
|
|
4758
5931
|
"2. 镜头间连续:下一镜头的 prevState 必须与上一镜头的 nextState 一致(人物位置/动作/情绪/服装),禁止瞬移、服装消失、情绪跳变。",
|
|
4759
|
-
"3. visual
|
|
5932
|
+
"3. visual 必须写明:主体位置(左/中/右/前景/背景)+ 角色动作 + 表情 + 服装/标志物(标志物来自下方视觉规则与角色锚点,逐镜头保持)。",
|
|
4760
5933
|
"4. 台词/音效/光效无则空字符串,不要编造。",
|
|
4761
|
-
"5. duration
|
|
5934
|
+
"5. duration 只能取 5/6/7/8/10 五个值(即梦单条视频时长上限)。",
|
|
4762
5935
|
"6. 所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束,不要输出其他文字。",
|
|
4763
5936
|
"7. 每个镜头必须输出 characters:本镜头出镜角色名数组(1-4 个),从「出场角色锚点」或正文中选取,使用正文里的确切称谓(如「周野」「周野的律师」),禁止自造名/缩写;路人或群像可用身份词(如「围观群众」)。",
|
|
4764
|
-
"8. 人物外观/服装/标志物必须遵守「漫剧定妆卡」与「出场角色锚点」:同一角色逐镜头保持完全一致(服装、发色、标志物禁止更换);定妆卡未覆盖的路人可用通用描述。"
|
|
5937
|
+
"8. 人物外观/服装/标志物必须遵守「漫剧定妆卡」与「出场角色锚点」:同一角色逐镜头保持完全一致(服装、发色、标志物禁止更换);定妆卡未覆盖的路人可用通用描述。",
|
|
5938
|
+
"9. jimengCamera:即梦运镜自然语言描述,必须写具体起止(如「镜头从中景缓慢推进到近景」「镜头从左向右缓慢横移」),禁止只写「推近」「拉远」;静止镜头写「固定机位」。",
|
|
5939
|
+
"10. 单镜只承载一个连续动作,动作必须在5-10秒内可完成;禁止复杂打斗、多人群舞、快速切换场景。",
|
|
5940
|
+
"11. 画面禁止出现任何文字、字幕、水印、符号、UI界面、屏幕显示内容。",
|
|
5941
|
+
"12. 单镜出镜角色不超过2个主要角色,多人同框时必须明确谁是画面主体。"
|
|
4765
5942
|
].join("\n"),
|
|
4766
5943
|
user: [
|
|
4767
5944
|
`章节《${chapter.title}》(第 ${chapter.no} 章)`,
|
|
@@ -4786,14 +5963,24 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
|
|
|
4786
5963
|
shot: normalizeShotSize(entry.shot),
|
|
4787
5964
|
camera: normalizeCameras(entry.camera),
|
|
4788
5965
|
composition: normalizeComposition(entry.composition),
|
|
4789
|
-
duration:
|
|
5966
|
+
duration: (() => {
|
|
5967
|
+
const d = typeof entry.duration === "number" ? Math.round(entry.duration) : 0;
|
|
5968
|
+
return [
|
|
5969
|
+
5,
|
|
5970
|
+
6,
|
|
5971
|
+
7,
|
|
5972
|
+
8,
|
|
5973
|
+
10
|
|
5974
|
+
].includes(d) ? d : 6;
|
|
5975
|
+
})(),
|
|
4790
5976
|
visual: typeof entry.visual === "string" ? entry.visual.trim().slice(0, 300) : "",
|
|
4791
5977
|
line: typeof entry.line === "string" ? entry.line.trim().slice(0, 120) : "",
|
|
4792
5978
|
sound: typeof entry.sound === "string" ? entry.sound.trim().slice(0, 80) : "",
|
|
4793
5979
|
light: normalizeLightings(entry.light),
|
|
4794
5980
|
prevState: typeof entry.prevState === "string" ? entry.prevState.trim().slice(0, 150) : "",
|
|
4795
5981
|
nextState: typeof entry.nextState === "string" ? entry.nextState.trim().slice(0, 150) : "",
|
|
4796
|
-
characters: sanitizeCharacters(entry.characters, 4)
|
|
5982
|
+
characters: sanitizeCharacters(entry.characters, 4),
|
|
5983
|
+
jimengCamera: typeof entry.jimengCamera === "string" && entry.jimengCamera.trim() !== "" ? entry.jimengCamera.trim().slice(0, 80) : void 0
|
|
4797
5984
|
})).filter((s) => s.visual !== "") : [];
|
|
4798
5985
|
if (shots.length === 0) throw new Error("模型未输出有效镜头,请重试");
|
|
4799
5986
|
const covered = new Set(shots.map((s) => s.beatId));
|
|
@@ -4830,6 +6017,40 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
|
|
|
4830
6017
|
return table;
|
|
4831
6018
|
}
|
|
4832
6019
|
/**
|
|
6020
|
+
* 提炼常驻道具(跨镜头需一致):从已写章节正文识别反复出现的关键道具 + 一行统一外观描述。
|
|
6021
|
+
* 生成分镜提示词前自动调用,若道具库为空则补齐;道具库存 project.props,注入提示词保持跨镜头一致。
|
|
6022
|
+
*/
|
|
6023
|
+
async function extractProps(ctx, config, project) {
|
|
6024
|
+
const excerpt = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating").slice(0, 6).map((c) => {
|
|
6025
|
+
const body = readChapterFile(config.outputDir, c);
|
|
6026
|
+
return body !== void 0 ? "第" + c.no + "章《" + c.title + "》\n" + body.replace(/^#.*$/gm, "").trim().slice(0, 1500) : "";
|
|
6027
|
+
}).filter((s) => s !== "").join("\n\n");
|
|
6028
|
+
if (excerpt.length < 200) return [];
|
|
6029
|
+
const raw = parseJsonArray(await complete(ctx, config, {
|
|
6030
|
+
system: [
|
|
6031
|
+
"你是一位网文漫剧道具导演。从下面的已写章节正文中,识别「常驻道具」——跨多个镜头/场景反复出现、需要保持外观一致的关键道具(如外卖电动车、外卖箱、手机、名片;一次性出现的忽略)。",
|
|
6032
|
+
"每个道具给一行统一外观描述(可辨识、具体的颜色/材质/状态),供每个镜头遵循。",
|
|
6033
|
+
"输出必须是合法 JSON 数组:[{\"name\":\"道具名\",\"desc\":\"一行统一外观描述\"}],3-8 个。",
|
|
6034
|
+
"重要:字符串内不含换行符;直接输出 JSON 结果本身。"
|
|
6035
|
+
].join("\n"),
|
|
6036
|
+
user: excerpt,
|
|
6037
|
+
temperature: .2,
|
|
6038
|
+
maxTokens: Math.max(config.maxTokens, 8e3),
|
|
6039
|
+
reasoning: config.analysisReasoning ?? "low"
|
|
6040
|
+
}));
|
|
6041
|
+
const props = [];
|
|
6042
|
+
for (const e of raw) {
|
|
6043
|
+
if (typeof e !== "object" || e === null) continue;
|
|
6044
|
+
const n = typeof e.name === "string" ? e.name.trim().slice(0, 20) : "";
|
|
6045
|
+
const d = typeof e.desc === "string" ? e.desc.trim().slice(0, 120) : "";
|
|
6046
|
+
if (n !== "" && d !== "" && !props.some((p) => p.name === n)) props.push({
|
|
6047
|
+
name: n,
|
|
6048
|
+
desc: d
|
|
6049
|
+
});
|
|
6050
|
+
}
|
|
6051
|
+
return props.slice(0, 8);
|
|
6052
|
+
}
|
|
6053
|
+
/**
|
|
4833
6054
|
* 分镜·提示词级:分镜表 → 即梦可粘贴视频提示词。
|
|
4834
6055
|
* 每镜头一段:风格词块(基底+滤镜)+ 画面内容(角色动作/服装标志物)+ 机位运镜 + 光效。
|
|
4835
6056
|
* 提示词聚焦画面与镜头(视频模型无音频,台词/音效不注入)。
|
|
@@ -4842,37 +6063,75 @@ async function generateStoryboardPrompts(ctx, config, project, outputDir, chapte
|
|
|
4842
6063
|
const stylePrefix = [baseStyle?.keywords, filterStyle?.keywords].filter((v) => v !== void 0 && v !== "").join(",") || "3D动漫,超精细建模,电影光影";
|
|
4843
6064
|
const rules = (project.visualRules ?? []).map((r) => "- " + r);
|
|
4844
6065
|
const bindings = buildMangaRoleBindings(project);
|
|
6066
|
+
let props = project.props ?? [];
|
|
6067
|
+
if (props.length === 0) try {
|
|
6068
|
+
props = await extractProps(ctx, config, project);
|
|
6069
|
+
if (props.length > 0) {
|
|
6070
|
+
project.props = props;
|
|
6071
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
6072
|
+
saveProject(config.outputDir, project);
|
|
6073
|
+
}
|
|
6074
|
+
} catch {}
|
|
6075
|
+
const propsBlock = props.length > 0 ? "常驻道具(每个镜头必须按此外观/状态呈现,保持跨镜头一致):\n" + props.map((p) => `- ${p.name}:${p.desc}`).join("\n") : "";
|
|
6076
|
+
let accTime = 0;
|
|
4845
6077
|
const shotLines = table.shots.map((s) => {
|
|
6078
|
+
const startSec = accTime;
|
|
6079
|
+
const endSec = accTime + s.duration;
|
|
6080
|
+
accTime = endSec;
|
|
4846
6081
|
const makeUp = (s.characters ?? []).map((n) => bindings.resolve(n)).filter((c) => c !== void 0).map((c) => {
|
|
4847
6082
|
const anchor = c.imagePrompt !== void 0 ? c.imagePrompt.zh.slice(0, 60) : c.appearance !== "" ? c.appearance : "";
|
|
4848
6083
|
const ref = c.imageUrl !== void 0 ? "(参考图)" : "";
|
|
4849
6084
|
return c.name + ":" + anchor + ref;
|
|
4850
6085
|
}).join(";");
|
|
4851
|
-
return `[${s.id}] 节拍${s.beatId} · ${sizeZh(s.shot)} · ${cameraZh(s.camera)} ·
|
|
6086
|
+
return `[${s.id}] 时间戳 ${startSec}s-${endSec}s · 节拍${s.beatId} · ${sizeZh(s.shot)} · ${cameraZh(s.camera)} · 时长${s.duration}s
|
|
4852
6087
|
出场:${s.characters !== void 0 && s.characters.length > 0 ? s.characters.join("、") : "(未标注)"}
|
|
4853
6088
|
定妆:${makeUp !== "" ? makeUp : "(未绑定漫剧卡)"}
|
|
4854
6089
|
画面:${s.visual}
|
|
4855
6090
|
台词:${s.line !== "" ? s.line : "(无)"}
|
|
4856
6091
|
光效:${lightZh(s.light) !== "" ? lightZh(s.light) : "(无)"}
|
|
6092
|
+
运镜(即梦):${s.jimengCamera !== void 0 ? s.jimengCamera : cameraZh(s.camera)}
|
|
4857
6093
|
承接:${s.prevState} → ${s.nextState}`;
|
|
4858
6094
|
}).join("\n\n");
|
|
6095
|
+
const system = [
|
|
6096
|
+
"你是一位资深影视分镜提示词工程师,精通即梦 Seedance 2.5 视频生成模型的提示词写法与参数调优。",
|
|
6097
|
+
"任务:把分镜表的每个镜头写成「可直接粘贴到即梦」的视频提示词——精简、只留能生成画面的内容,不要混入给用户的说明/元信息(如模型版本、画幅、参考图是否上传)。",
|
|
6098
|
+
"每镜 text 按以下段序组织(内容精简):",
|
|
6099
|
+
"① 风格段:" + stylePrefix + "(一句,放最前)。",
|
|
6100
|
+
"② 场景段:写\"场景:<地点名> — <环境/光线/氛围>\"(如\"场景:雨夜道路 — 暴雨积水、昏黄路灯、雨幕\");场景名标清楚(供用户@对应场景图),不写@。场景名优先复用下方「可用场景名清单」里已有的名字(按镜头地点/氛围就近匹配),只有清单里确实没有该地点时才新建场景名;新建名也要简短、能同时指示地点与氛围。",
|
|
6101
|
+
"③ 人物段:主体写\"@角色名\"(智能角色,如\"@林深\")+ 位置/朝向/简短动作,不写外貌细节(角色一致性靠智能主体多角度),多角色明确谁是主体。",
|
|
6102
|
+
"④ 时间轴段:必须把单镜按动作拆成 2~3 段(每段 3~5s,如\"0s-3s / 3s-7s\"),每段写清画面+动作+运镜,不要整镜单段;有台词用『台词(声音层):\"…\"(音色:…)』;音效用 [SFX: …]。",
|
|
6103
|
+
"⑤ 结尾负面:不要字幕、不要水印、禁止变形 + 行为级禁止项(按镜头定)。",
|
|
6104
|
+
...getGenreRules((project.mangaPlans ?? []).find((p) => p.active)?.genre).length > 0 ? [
|
|
6105
|
+
"题材专用规则(本题材必须遵守):",
|
|
6106
|
+
...getGenreRules((project.mangaPlans ?? []).find((p) => p.active)?.genre).map((r) => "- " + r),
|
|
6107
|
+
""
|
|
6108
|
+
] : [],
|
|
6109
|
+
"题材无关硬性要求:",
|
|
6110
|
+
"1. 台词走声音层,不写进画面提示词当口型(避免即梦硬配口型);台词用『台词(声音层):\"……\"(音色:低沉平静)』单独标注在时间轴段,画面描述不重复人声。",
|
|
6111
|
+
"2. 音效用原生 [SFX: 具体音效] 标记(如 [SFX: 雨声,心跳声]),叠在对应时间轴段,禁空泛\"震撼音效\"。",
|
|
6112
|
+
"3. 时长预算:单镜 4~15s;text 完整覆盖该镜全部秒数,时间轴写起止。",
|
|
6113
|
+
"4. 服装/发色/标志物沿用镜头表、定妆卡、视觉规则,禁止自行更换(同一角色跨镜一致)。",
|
|
6114
|
+
"4b. 若用户给出「常驻道具」清单,每个涉及该道具的镜头必须按清单里那行统一外观/状态呈现(颜色/材质/特征完全一致),禁止换成别的样子;只出现台词不提道具的镜头可忽略。",
|
|
6115
|
+
"5. 每镜主体锚定:画面主体是[角色名],位于画面[左/中/右/前景],多角色明确谁是主体。",
|
|
6116
|
+
"6. 高动态/动作镜头:时间轴可细化到 0.5s~1s 微步进(运镜/动作/粒子/环境受力四要素)。",
|
|
6117
|
+
"7. text 结尾含\"不要字幕、不要水印、禁止变形\"。",
|
|
6118
|
+
"8. 输出合法 JSON 对象:{\"prompts\": [{\"shotId\":\"s1\",\"text\":\"...\",\"camera\":\"...\",\"motion\":\"low|medium|high\",\"negativePrompt\":\"...\",\"sceneName\":\"...\"}]},所有镜头都有,顺序一致。",
|
|
6119
|
+
"9. 字符串内不得含换行符,JSON 必须在一段内完整结束。"
|
|
6120
|
+
].join("\n");
|
|
6121
|
+
const speechLines = [...bindings.byId.values()].map((c) => `${c.name}:${(c.speechStyle ?? "").trim()}`).filter((x) => !x.endsWith(":"));
|
|
6122
|
+
const sceneLibLines = (project.scenes ?? []).map((s) => {
|
|
6123
|
+
const tag = [(s.moods ?? []).join("、"), s.moment !== void 0 && s.moment !== "" ? "时间光态:" + s.moment : ""].filter((x) => x !== "").join(";");
|
|
6124
|
+
return `- ${s.name}${s.summary !== "" ? ":" + s.summary : ""}${tag !== "" ? "(" + tag + ")" : ""}`;
|
|
6125
|
+
});
|
|
6126
|
+
const sceneLibBlock = sceneLibLines.length > 0 ? "可用场景名清单(场景名只允许从这里选,或确无此地点时新建):\n" + sceneLibLines.join("\n") : "";
|
|
4859
6127
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
4860
|
-
system
|
|
4861
|
-
"你是一位资深影视分镜提示词工程师,熟悉即梦 AI / Seedance 等视频生成模型的中文提示词写法。",
|
|
4862
|
-
"任务:把分镜表的每个镜头写成一段可直接粘贴进视频生成工具的提示词。",
|
|
4863
|
-
"提示词结构(按顺序):风格词块 → 画面主体(人物动作+表情+服装标志物)→ 机位运镜(含时长感)→ 光效氛围。单段中文 60-140 字,逗号分隔,流畅自然。",
|
|
4864
|
-
"风格词块(必须原样放在每段开头):" + stylePrefix,
|
|
4865
|
-
"重要:每段提示词开头必须原样嵌入风格词块原文,不得省略、不得改写。",
|
|
4866
|
-
"硬性要求:",
|
|
4867
|
-
"1. 只写画面与镜头,禁止写音频/台词配音说明(视频模型无音频)。",
|
|
4868
|
-
"2. 服装/发色/标志物必须沿用镜头表、定妆卡与视觉规则,禁止自行更换(同一角色跨镜头保持一致)。",
|
|
4869
|
-
"3. 运镜用通俗电影词(推近/拉远/跟随/低机位仰拍/过肩/手持晃动/固定机位),不要用分镜术语缩写。",
|
|
4870
|
-
"4. 输出合法 JSON 对象:{\"prompts\": [{\"shotId\": \"s1\", \"text\": \"...\"}]},所有镜头都要有,顺序一致。",
|
|
4871
|
-
"5. 字符串内不得包含换行符,JSON 必须在一段内完整结束。"
|
|
4872
|
-
].join("\n"),
|
|
6128
|
+
system,
|
|
4873
6129
|
user: [
|
|
4874
6130
|
`章节《${chapter?.title ?? ""}》(第 ${chapterNo} 章)`,
|
|
4875
6131
|
rules.length > 0 ? "本书视觉规则(必须遵守):\n" + rules.join("\n") : "",
|
|
6132
|
+
propsBlock,
|
|
6133
|
+
sceneLibBlock,
|
|
6134
|
+
speechLines.length > 0 ? "出场角色说话方式(写台词音色参考时用):\n" + speechLines.join("\n") : "",
|
|
4876
6135
|
"==== 分镜表 ====",
|
|
4877
6136
|
shotLines,
|
|
4878
6137
|
"只输出 JSON 对象。"
|
|
@@ -4882,14 +6141,30 @@ async function generateStoryboardPrompts(ctx, config, project, outputDir, chapte
|
|
|
4882
6141
|
reasoning: config.analysisReasoning ?? "low"
|
|
4883
6142
|
}));
|
|
4884
6143
|
const shotIds = new Set(table.shots.map((s) => s.id));
|
|
4885
|
-
const shotBinding = new Map(table.shots.map((s) =>
|
|
6144
|
+
const shotBinding = new Map(table.shots.map((s) => {
|
|
6145
|
+
const ids = [];
|
|
6146
|
+
for (const n of s.characters ?? []) {
|
|
6147
|
+
const card = bindings.resolve(n);
|
|
6148
|
+
if (card !== void 0 && !ids.includes(card.id)) ids.push(card.id);
|
|
6149
|
+
}
|
|
6150
|
+
return [s.id, ids];
|
|
6151
|
+
}));
|
|
4886
6152
|
const prompts = Array.isArray(raw.prompts) ? raw.prompts.filter((v) => typeof v === "object" && v !== null).map((entry) => {
|
|
4887
6153
|
const shotId = shotIds.has(entry.shotId) ? entry.shotId : "";
|
|
4888
6154
|
const ids = shotBinding.get(shotId) ?? [];
|
|
6155
|
+
const motionVal = typeof entry.motion === "string" ? entry.motion.trim().toLowerCase() : "";
|
|
4889
6156
|
return {
|
|
4890
6157
|
shotId,
|
|
4891
6158
|
text: ensureStyleEmbedded(typeof entry.text === "string" ? entry.text.trim().slice(0, 400) : "", stylePrefix, "zh"),
|
|
4892
|
-
mangaRoleIds: ids.length > 0 ? ids : void 0
|
|
6159
|
+
mangaRoleIds: ids.length > 0 ? ids : void 0,
|
|
6160
|
+
camera: typeof entry.camera === "string" && entry.camera.trim() !== "" ? entry.camera.trim().slice(0, 100) : void 0,
|
|
6161
|
+
motion: motionVal in {
|
|
6162
|
+
"low": 1,
|
|
6163
|
+
"medium": 1,
|
|
6164
|
+
"high": 1
|
|
6165
|
+
} ? motionVal : void 0,
|
|
6166
|
+
negativePrompt: typeof entry.negativePrompt === "string" && entry.negativePrompt.trim() !== "" ? entry.negativePrompt.trim().slice(0, 200) : void 0,
|
|
6167
|
+
sceneName: typeof entry.sceneName === "string" && entry.sceneName.trim() !== "" && entry.sceneName.trim() !== "(未标注)" ? entry.sceneName.trim().slice(0, 50) : void 0
|
|
4893
6168
|
};
|
|
4894
6169
|
}).filter((x) => x.shotId !== "" && x.text !== "") : [];
|
|
4895
6170
|
if (prompts.length === 0) throw new Error("模型未输出有效提示词,请重试");
|
|
@@ -4898,6 +6173,21 @@ async function generateStoryboardPrompts(ctx, config, project, outputDir, chapte
|
|
|
4898
6173
|
prompts,
|
|
4899
6174
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4900
6175
|
});
|
|
6176
|
+
try {
|
|
6177
|
+
const chTitle = project.chapters.find((c) => c.no === chapterNo)?.title ?? "";
|
|
6178
|
+
const pLines = [];
|
|
6179
|
+
for (const p of prompts) pLines.push("### 镜头 " + p.shotId + "\n" + (p.text ?? ""));
|
|
6180
|
+
saveMangaChapterPrompts(outputDir, chapterNo, chTitle, pLines.join("\n\n"));
|
|
6181
|
+
} catch {}
|
|
6182
|
+
const sbEntry = (project.storyboards ?? []).find((e) => e.chapterNo === chapterNo);
|
|
6183
|
+
if (sbEntry !== void 0 && sbEntry.table !== void 0) {
|
|
6184
|
+
for (const s of sbEntry.table.shots) {
|
|
6185
|
+
const ids = shotBinding.get(s.id) ?? [];
|
|
6186
|
+
s.mangaRoleIds = ids.length > 0 ? ids : void 0;
|
|
6187
|
+
}
|
|
6188
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
6189
|
+
saveProject(outputDir, project);
|
|
6190
|
+
}
|
|
4901
6191
|
return prompts;
|
|
4902
6192
|
}
|
|
4903
6193
|
/**
|
|
@@ -5369,7 +6659,7 @@ function saveMaterializedBook(outDir, bookName, data) {
|
|
|
5369
6659
|
function applyAdaptationMappings(text, mappings) {
|
|
5370
6660
|
return applyAdaptationReplacements(text, mappings).adaptedText;
|
|
5371
6661
|
}
|
|
5372
|
-
/**
|
|
6662
|
+
/** 改编道藏:把人名/术语/势力按映射表替换。 */
|
|
5373
6663
|
function applyMappingsToBible(bible, mappings) {
|
|
5374
6664
|
const t = (s) => applyAdaptationMappings(s, mappings);
|
|
5375
6665
|
return {
|
|
@@ -5410,26 +6700,26 @@ function applyMappingsToRoles(roles, mappings) {
|
|
|
5410
6700
|
expressions: role.expressions?.map(t),
|
|
5411
6701
|
promptKit: role.promptKit !== void 0 ? {
|
|
5412
6702
|
...role.promptKit,
|
|
5413
|
-
portrait: {
|
|
6703
|
+
portrait: role.promptKit.portrait !== void 0 ? {
|
|
5414
6704
|
...role.promptKit.portrait,
|
|
5415
6705
|
zh: t(role.promptKit.portrait.zh),
|
|
5416
6706
|
en: t(role.promptKit.portrait.en)
|
|
5417
|
-
},
|
|
5418
|
-
sheet: {
|
|
6707
|
+
} : void 0,
|
|
6708
|
+
sheet: role.promptKit.sheet !== void 0 ? {
|
|
5419
6709
|
...role.promptKit.sheet,
|
|
5420
6710
|
zh: t(role.promptKit.sheet.zh),
|
|
5421
6711
|
en: t(role.promptKit.sheet.en)
|
|
5422
|
-
},
|
|
5423
|
-
expressions: role.promptKit.expressions.map((e) => ({
|
|
6712
|
+
} : void 0,
|
|
6713
|
+
expressions: role.promptKit.expressions !== void 0 ? role.promptKit.expressions.map((e) => ({
|
|
5424
6714
|
...e,
|
|
5425
6715
|
zh: t(e.zh),
|
|
5426
6716
|
en: t(e.en)
|
|
5427
|
-
})),
|
|
5428
|
-
details: {
|
|
6717
|
+
})) : void 0,
|
|
6718
|
+
details: role.promptKit.details !== void 0 ? {
|
|
5429
6719
|
...role.promptKit.details,
|
|
5430
6720
|
zh: t(role.promptKit.details.zh),
|
|
5431
6721
|
en: t(role.promptKit.details.en)
|
|
5432
|
-
}
|
|
6722
|
+
} : void 0
|
|
5433
6723
|
} : void 0
|
|
5434
6724
|
}));
|
|
5435
6725
|
}
|
|
@@ -5464,6 +6754,34 @@ function fallbackSourceOutline(project) {
|
|
|
5464
6754
|
* 批量生成时整体开销约省 25%)。
|
|
5465
6755
|
* @returns 摘要与新增事实条数(失败返回空,调用方 best-effort)。
|
|
5466
6756
|
*/
|
|
6757
|
+
/**
|
|
6758
|
+
* 事实库去重:新事实加入前检查与已有事实的相似度,
|
|
6759
|
+
* 状态类事实(如"主角受伤"→"主角痊愈")覆盖旧状态。
|
|
6760
|
+
*/
|
|
6761
|
+
function dedupAndAddFacts(project, chapterNo, newFacts) {
|
|
6762
|
+
const list = project.facts ?? [];
|
|
6763
|
+
const existingTexts = new Set(list.map((f) => f.text));
|
|
6764
|
+
let added = 0;
|
|
6765
|
+
for (const fact of newFacts.slice(0, 8)) {
|
|
6766
|
+
if (existingTexts.has(fact)) continue;
|
|
6767
|
+
if (list.some((f) => {
|
|
6768
|
+
const a = f.text.slice(0, 30);
|
|
6769
|
+
const b = fact.slice(0, 30);
|
|
6770
|
+
if (a.length === 0 || b.length === 0) return false;
|
|
6771
|
+
let common = 0;
|
|
6772
|
+
for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] === b[i]) common++;
|
|
6773
|
+
return common / Math.max(a.length, b.length) > .7;
|
|
6774
|
+
})) continue;
|
|
6775
|
+
list.push({
|
|
6776
|
+
chapterNo,
|
|
6777
|
+
text: fact
|
|
6778
|
+
});
|
|
6779
|
+
existingTexts.add(fact);
|
|
6780
|
+
added++;
|
|
6781
|
+
}
|
|
6782
|
+
project.facts = list.slice(-300);
|
|
6783
|
+
return added;
|
|
6784
|
+
}
|
|
5467
6785
|
async function summarizeAndExtractFacts(ctx, config, project, outputDir, chapterNo) {
|
|
5468
6786
|
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
5469
6787
|
if (chapter === void 0) return {
|
|
@@ -5489,12 +6807,7 @@ async function summarizeAndExtractFacts(ctx, config, project, outputDir, chapter
|
|
|
5489
6807
|
const summary = typeof raw.summary === "string" ? raw.summary.trim().slice(0, 500) : "";
|
|
5490
6808
|
const factLines = Array.isArray(raw.facts) ? raw.facts.filter((v) => typeof v === "string" && v.trim().length > 8).map((v) => v.trim().slice(0, 140)) : [];
|
|
5491
6809
|
if (summary !== "") chapter.summary = summary;
|
|
5492
|
-
|
|
5493
|
-
for (const line of factLines.slice(0, 8)) list.push({
|
|
5494
|
-
chapterNo,
|
|
5495
|
-
text: line
|
|
5496
|
-
});
|
|
5497
|
-
project.facts = list.slice(-300);
|
|
6810
|
+
dedupAndAddFacts(project, chapterNo, factLines);
|
|
5498
6811
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5499
6812
|
saveProject(outputDir, project);
|
|
5500
6813
|
return {
|
|
@@ -5573,7 +6886,7 @@ const AUDIT_BATCH_SIZE = 10;
|
|
|
5573
6886
|
/** 单批质检:设定 + 事实库 + 该批章节节选 → 矛盾清单。 */
|
|
5574
6887
|
async function auditBatch(ctx, config, project, outputDir, batch) {
|
|
5575
6888
|
const system = [
|
|
5576
|
-
"
|
|
6889
|
+
"你是一位严谨的网文连续性审校编辑。你会收到一本小说的道藏、事实库和一批章节正文节选。",
|
|
5577
6890
|
"请找出这批章节中的一致性矛盾,例如:",
|
|
5578
6891
|
"- 人物状态冲突:境界/修为/伤势/资源在同一章内或跨章前后矛盾。",
|
|
5579
6892
|
"- 设定违背:正文与世界观规则、金手指规则、写作红线冲突。",
|
|
@@ -5594,7 +6907,7 @@ async function auditBatch(ctx, config, project, outputDir, batch) {
|
|
|
5594
6907
|
system,
|
|
5595
6908
|
user: [
|
|
5596
6909
|
"请对以下小说做一致性质检。",
|
|
5597
|
-
project.bible !== void 0 ? "
|
|
6910
|
+
project.bible !== void 0 ? "道藏:\n" + [
|
|
5598
6911
|
project.bible.worldRules.length > 0 ? `世界规则:\n${project.bible.worldRules.map((r) => `- ${r}`).join("\n")}` : "",
|
|
5599
6912
|
project.bible.redLines.length > 0 ? `写作红线:\n${project.bible.redLines.map((r) => `- ${r}`).join("\n")}` : "",
|
|
5600
6913
|
project.bible.characters.length > 0 ? `角色:\n${project.bible.characters.map((ch) => `- ${ch.name}(${ch.traits.join("、")})`).join("\n")}` : ""
|
|
@@ -5679,7 +6992,7 @@ function bookOverview(project, scope = "recent") {
|
|
|
5679
6992
|
s.push(`【大纲全文】\n${project.outline}`);
|
|
5680
6993
|
if (project.bible !== void 0) {
|
|
5681
6994
|
const bible = project.bible;
|
|
5682
|
-
s.push("
|
|
6995
|
+
s.push("【道藏】");
|
|
5683
6996
|
if (bible.genre !== "") s.push(`题材基调:${bible.genre}`);
|
|
5684
6997
|
if (bible.worldRules.length > 0) s.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
|
|
5685
6998
|
if (bible.characters.length > 0) {
|
|
@@ -5742,7 +7055,7 @@ function bookOverview(project, scope = "recent") {
|
|
|
5742
7055
|
async function analyzeImpact(ctx, config, project, outputDir, change) {
|
|
5743
7056
|
const system = [
|
|
5744
7057
|
"你是一位网文一致性审校。作者要做一处修改,请找出这次改动会波及的所有位置(设定、大纲、已写章节正文、事实库、简介中可能因此过时或矛盾的内容)。",
|
|
5745
|
-
"输出必须是合法 JSON 数组,格式:[{\"location\": \"位置(第N
|
|
7058
|
+
"输出必须是合法 JSON 数组,格式:[{\"location\": \"位置(第N章/大纲/道藏-世界规则/大世界-境界/事实库/简介)\", \"quote\": \"原文片段(20-60字)\", \"suggestion\": \"修改建议\", \"kind\": \"must|optional|note\"}]",
|
|
5746
7059
|
"kind 含义:must=必须同步改否则矛盾;optional=建议改(影响观感);note=备注(如旧称保留为古称、或无需改但需知晓)。",
|
|
5747
7060
|
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
|
|
5748
7061
|
].join("\n");
|
|
@@ -5804,16 +7117,16 @@ function renderWorld(world) {
|
|
|
5804
7117
|
}
|
|
5805
7118
|
return sections.join("\n");
|
|
5806
7119
|
}
|
|
5807
|
-
/** AI 提炼大世界:从大纲 +
|
|
7120
|
+
/** AI 提炼大世界:从大纲 + 道藏生成结构化境界体系/区域/势力。 */
|
|
5808
7121
|
async function extractWorld(ctx, config, project) {
|
|
5809
7122
|
const system = [
|
|
5810
|
-
"
|
|
7123
|
+
"你是一位网文世界观架构师。请根据小说大纲与道藏,提炼结构化「大世界」数据。",
|
|
5811
7124
|
"输出必须是合法 JSON 对象:",
|
|
5812
7125
|
"{\"realms\": [{\"name\": \"境界名\", \"description\": \"突破条件/寿命/标志等\"}], \"regions\": [{\"name\": \"区域名\", \"description\": \"描述\", \"faction\": \"关联势力名或空\"}], \"factions\": [{\"name\": \"势力名\", \"kind\": \"宗门/家族/王朝/组织等\", \"description\": \"描述\", \"region\": \"驻地区域或空\"}]}",
|
|
5813
7126
|
"要求:",
|
|
5814
7127
|
"1. realms 按由低到高顺序排列(修仙题材必须含完整境界链;无境界设定的题材可输出空数组)。",
|
|
5815
7128
|
"2. 数量贴合大纲:realms 3-12 个,regions 2-10 个,factions 2-10 个。",
|
|
5816
|
-
"3.
|
|
7129
|
+
"3. 内容严格来自大纲与道藏,不要凭空发明与大纲冲突的设定。",
|
|
5817
7130
|
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
|
|
5818
7131
|
].join("\n");
|
|
5819
7132
|
const bibleBlock = project.bible !== void 0 ? [project.bible.genre !== "" ? `题材:${project.bible.genre}` : "", project.bible.worldRules.length > 0 ? `世界规则:\n${project.bible.worldRules.map((r) => `- ${r}`).join("\n")}` : ""].filter((s) => s !== "").join("\n") : "";
|
|
@@ -6032,6 +7345,156 @@ function exportBook(outputDir, project, format) {
|
|
|
6032
7345
|
chapters: done.length
|
|
6033
7346
|
};
|
|
6034
7347
|
}
|
|
7348
|
+
/** 漫剧资产库根目录:outputDir/manga-assets */
|
|
7349
|
+
function mangaAssetsDir(outputDir) {
|
|
7350
|
+
return join(outputDir, "manga-assets");
|
|
7351
|
+
}
|
|
7352
|
+
/** 确保子目录存在,返回完整路径。 */
|
|
7353
|
+
function ensureAssetDir(outputDir, ...sub) {
|
|
7354
|
+
const dir = join(mangaAssetsDir(outputDir), ...sub);
|
|
7355
|
+
mkdirSync(dir, { recursive: true });
|
|
7356
|
+
return dir;
|
|
7357
|
+
}
|
|
7358
|
+
/** 清理文件名中的非法字符。 */
|
|
7359
|
+
function safeAssetName(name) {
|
|
7360
|
+
return name.replace(/[\\/:*?"<>|]/g, "_").slice(0, 40);
|
|
7361
|
+
}
|
|
7362
|
+
/** 保存角色提示词到资产库:manga-assets/角色/角色名/提示词.txt */
|
|
7363
|
+
function saveMangaRolePrompt(outputDir, roleName, zh, en, negative) {
|
|
7364
|
+
const file = join(ensureAssetDir(outputDir, "角色", safeAssetName(roleName)), "提示词.txt");
|
|
7365
|
+
writeFileSync(file, [
|
|
7366
|
+
"【正面提示词(中文)】",
|
|
7367
|
+
zh,
|
|
7368
|
+
"",
|
|
7369
|
+
"【正面提示词(英文)】",
|
|
7370
|
+
en,
|
|
7371
|
+
"",
|
|
7372
|
+
negative !== void 0 && negative !== "" ? [
|
|
7373
|
+
"【负面提示词】",
|
|
7374
|
+
negative,
|
|
7375
|
+
""
|
|
7376
|
+
].join("\n") : ""
|
|
7377
|
+
].filter((x) => x !== "").join("\n"), "utf8");
|
|
7378
|
+
return file;
|
|
7379
|
+
}
|
|
7380
|
+
/** 保存分镜即梦脚本到资产库:manga-assets/分镜脚本/第N章-标题.md */
|
|
7381
|
+
function saveMangaStoryboardScript(outputDir, chapterNo, title, markdown) {
|
|
7382
|
+
const file = join(ensureAssetDir(outputDir, "分镜脚本"), "第" + chapterNo + "章-" + safeAssetName(title) + ".md");
|
|
7383
|
+
writeFileSync(file, markdown, "utf8");
|
|
7384
|
+
return file;
|
|
7385
|
+
}
|
|
7386
|
+
/** 保存「即梦素材包」到资产库:manga-assets/素材包/第N章-标题·即梦素材包.md */
|
|
7387
|
+
function saveMangaAssetPackage(outputDir, chapterNo, title, markdown) {
|
|
7388
|
+
const file = join(ensureAssetDir(outputDir, "素材包"), "第" + chapterNo + "章-" + safeAssetName(title) + "·即梦素材包.md");
|
|
7389
|
+
writeFileSync(file, markdown, "utf8");
|
|
7390
|
+
return file;
|
|
7391
|
+
}
|
|
7392
|
+
/** 保存场景中文生图提示词到资产库:manga-assets/场景/场景名/提示词.txt */
|
|
7393
|
+
function saveMangaScenePrompt(outputDir, sceneName, zh, negative) {
|
|
7394
|
+
const file = join(ensureAssetDir(outputDir, "场景", safeAssetName(sceneName)), "提示词.txt");
|
|
7395
|
+
writeFileSync(file, [
|
|
7396
|
+
"【场景生图提示词(中文)】",
|
|
7397
|
+
zh,
|
|
7398
|
+
"",
|
|
7399
|
+
negative !== void 0 && negative !== "" ? [
|
|
7400
|
+
"【负面提示词】",
|
|
7401
|
+
negative,
|
|
7402
|
+
""
|
|
7403
|
+
].join("\n") : ""
|
|
7404
|
+
].filter((x) => x !== "").join("\n"), "utf8");
|
|
7405
|
+
return file;
|
|
7406
|
+
}
|
|
7407
|
+
/** 保存逐镜即梦提示词到资产库:manga-assets/分镜脚本/第N章-标题-提示词.md */
|
|
7408
|
+
function saveMangaChapterPrompts(outputDir, chapterNo, title, markdown) {
|
|
7409
|
+
const file = join(ensureAssetDir(outputDir, "分镜脚本"), "第" + chapterNo + "章-" + safeAssetName(title) + "-提示词.md");
|
|
7410
|
+
writeFileSync(file, markdown, "utf8");
|
|
7411
|
+
return file;
|
|
7412
|
+
}
|
|
7413
|
+
/**
|
|
7414
|
+
* 一键生成:骨架 → 分镜表 → 角色提名 → 自动导入(匹配成功的)→ 自动分级 → 视频提示词。
|
|
7415
|
+
* 匹配模糊/小说库缺失的角色保留在候选列表,不自动导入。
|
|
7416
|
+
*/
|
|
7417
|
+
async function autoGenerateMangaChapter(ctx, config, project, outputDir, chapterNo, styleId, filterId) {
|
|
7418
|
+
let entry = (project.storyboards ?? []).find((e) => e.chapterNo === chapterNo);
|
|
7419
|
+
if (entry === void 0) {
|
|
7420
|
+
entry = {
|
|
7421
|
+
chapterNo,
|
|
7422
|
+
skeleton: void 0,
|
|
7423
|
+
table: void 0,
|
|
7424
|
+
prompts: void 0,
|
|
7425
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
7426
|
+
};
|
|
7427
|
+
if (project.storyboards === void 0) project.storyboards = [];
|
|
7428
|
+
project.storyboards.push(entry);
|
|
7429
|
+
}
|
|
7430
|
+
const sb = entry;
|
|
7431
|
+
if (sb.skeleton === void 0) sb.skeleton = await generateStoryboardSkeleton(ctx, config, project, outputDir, chapterNo);
|
|
7432
|
+
if (sb.table === void 0) sb.table = await generateStoryboardTable(ctx, config, project, outputDir, chapterNo, sb.skeleton, styleId, filterId);
|
|
7433
|
+
const candidates = await nominateMangaRoles(ctx, config, project, outputDir, chapterNo);
|
|
7434
|
+
let imported = 0;
|
|
7435
|
+
for (const cand of candidates) {
|
|
7436
|
+
if (cand.verdict === "already_imported") continue;
|
|
7437
|
+
if ((project.mangaRoles ?? []).some((c) => c.name === cand.rawName || cand.matchedRoleName !== void 0 && c.sourceRoleName === cand.matchedRoleName)) continue;
|
|
7438
|
+
const sug = cand.suggested;
|
|
7439
|
+
const matched = cand.verdict === "matched" && cand.matchedRoleName !== void 0 && cand.matchedRoleName !== "";
|
|
7440
|
+
const name = ((cand.matchedRoleName !== void 0 && cand.matchedRoleName !== "" ? cand.matchedRoleName : sug.name) || cand.rawName).trim().slice(0, 30) || cand.rawName.trim().slice(0, 30);
|
|
7441
|
+
const tier = cand.tier ?? (sug.coreFunction === "functional" && cand.matchedRoleName === void 0 ? "extra" : "supporting");
|
|
7442
|
+
const card = {
|
|
7443
|
+
id: "mr-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 6),
|
|
7444
|
+
sourceRoleName: cand.matchedRoleName,
|
|
7445
|
+
name,
|
|
7446
|
+
identity: sug.identity,
|
|
7447
|
+
coreFunction: sug.coreFunction,
|
|
7448
|
+
protagonistRelation: sug.protagonistRelation,
|
|
7449
|
+
speechStyle: sug.speechStyle,
|
|
7450
|
+
traits: sug.traits,
|
|
7451
|
+
appearance: sug.appearance,
|
|
7452
|
+
keyScenes: sug.keyScenes,
|
|
7453
|
+
appearsInEpisodes: [chapterNo],
|
|
7454
|
+
status: matched ? "imported" : "pending_confirm",
|
|
7455
|
+
tier,
|
|
7456
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7457
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
7458
|
+
};
|
|
7459
|
+
if (project.mangaRoles === void 0) project.mangaRoles = [];
|
|
7460
|
+
project.mangaRoles.push(card);
|
|
7461
|
+
if (matched) imported++;
|
|
7462
|
+
}
|
|
7463
|
+
try {
|
|
7464
|
+
if (sb.table !== void 0) {
|
|
7465
|
+
const chTitle = project.chapters.find((c) => c.no === chapterNo)?.title ?? "";
|
|
7466
|
+
const mdLines = [];
|
|
7467
|
+
mdLines.push("# 第" + chapterNo + "章《" + chTitle + "》· 分镜");
|
|
7468
|
+
if (sb.skeleton !== void 0) {
|
|
7469
|
+
mdLines.push("", "## 剧情骨架");
|
|
7470
|
+
mdLines.push("弧线:" + (sb.skeleton.arc ?? ""));
|
|
7471
|
+
for (const b of sb.skeleton.beats ?? []) mdLines.push(`- [${b.id}] ${b.event}(情绪:${emotionZh(b.emotion)})`);
|
|
7472
|
+
}
|
|
7473
|
+
mdLines.push("", "## 分镜表");
|
|
7474
|
+
for (const s of sb.table.shots ?? []) mdLines.push(`- s${s.id} ${sizeZh(s.shot)} · ${cameraZh(s.camera)} · ${s.duration}s · ${s.visual}`);
|
|
7475
|
+
saveMangaStoryboardScript(outputDir, chapterNo, chTitle, mdLines.join("\n"));
|
|
7476
|
+
}
|
|
7477
|
+
} catch {}
|
|
7478
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7479
|
+
saveProject(outputDir, project);
|
|
7480
|
+
const allCards = project.mangaRoles ?? [];
|
|
7481
|
+
const needMakeup = allCards.filter((c) => c.tier !== "extra" && (c.appearsInEpisodes ?? []).includes(chapterNo)).length;
|
|
7482
|
+
const extra = allCards.filter((c) => c.tier === "extra" && (c.appearsInEpisodes ?? []).includes(chapterNo)).length;
|
|
7483
|
+
const pendingList = candidates.filter((c) => c.verdict === "ambiguous" || c.verdict === "not_in_library");
|
|
7484
|
+
const pending = pendingList.length;
|
|
7485
|
+
const pendingRoleNames = pendingList.map((c) => c.rawName).slice(0, 10);
|
|
7486
|
+
return {
|
|
7487
|
+
chapterNo,
|
|
7488
|
+
skeletonBeats: sb.skeleton?.beats?.length ?? 0,
|
|
7489
|
+
shotCount: sb.table?.shots?.length ?? 0,
|
|
7490
|
+
promptCount: sb.prompts?.length ?? 0,
|
|
7491
|
+
importedRoles: imported,
|
|
7492
|
+
needMakeupRoles: needMakeup,
|
|
7493
|
+
extraRoles: extra,
|
|
7494
|
+
pendingCandidates: pending,
|
|
7495
|
+
pendingRoleNames
|
|
7496
|
+
};
|
|
7497
|
+
}
|
|
6035
7498
|
//#endregion
|
|
6036
7499
|
//#region src/bookshelf.ts
|
|
6037
7500
|
/**
|
|
@@ -7539,12 +9002,34 @@ function makeRoutes(deps) {
|
|
|
7539
9002
|
syncProjectWithDisk(project, config.outputDir);
|
|
7540
9003
|
saveProject(config.outputDir, project);
|
|
7541
9004
|
}
|
|
9005
|
+
const slim = new URL(req.url ?? "/", "http://localhost").searchParams.get("slim") === "1";
|
|
9006
|
+
let projectPayload;
|
|
9007
|
+
if (project === void 0) projectPayload = void 0;
|
|
9008
|
+
else if (slim) projectPayload = {
|
|
9009
|
+
bookName: project.bookName,
|
|
9010
|
+
outline: project.outline.slice(0, 200),
|
|
9011
|
+
chapters: project.chapters.map((c) => ({
|
|
9012
|
+
no: c.no,
|
|
9013
|
+
volume: c.volume,
|
|
9014
|
+
title: c.title,
|
|
9015
|
+
status: c.status,
|
|
9016
|
+
chars: c.chars,
|
|
9017
|
+
error: c.error,
|
|
9018
|
+
review: c.review !== void 0 ? {
|
|
9019
|
+
score: c.review.score,
|
|
9020
|
+
passed: c.review.passed
|
|
9021
|
+
} : void 0
|
|
9022
|
+
})),
|
|
9023
|
+
createdAt: project.createdAt,
|
|
9024
|
+
updatedAt: project.updatedAt
|
|
9025
|
+
};
|
|
9026
|
+
else projectPayload = {
|
|
9027
|
+
...project,
|
|
9028
|
+
facts: (project.facts ?? []).slice(-80)
|
|
9029
|
+
};
|
|
7542
9030
|
writeJson(res, 200, {
|
|
7543
9031
|
config,
|
|
7544
|
-
project:
|
|
7545
|
-
...project,
|
|
7546
|
-
facts: (project.facts ?? []).slice(-80)
|
|
7547
|
-
} : void 0,
|
|
9032
|
+
project: projectPayload,
|
|
7548
9033
|
generatedFiles: listChapterFiles(config.outputDir),
|
|
7549
9034
|
audit: auditState
|
|
7550
9035
|
});
|
|
@@ -7747,7 +9232,8 @@ function makeRoutes(deps) {
|
|
|
7747
9232
|
no,
|
|
7748
9233
|
file: step.file,
|
|
7749
9234
|
chars: step.chars,
|
|
7750
|
-
title: chapter.title
|
|
9235
|
+
title: chapter.title,
|
|
9236
|
+
warn: step.warn
|
|
7751
9237
|
});
|
|
7752
9238
|
try {
|
|
7753
9239
|
await summarizeAndExtractFacts(ctx, config, project, config.outputDir, no);
|
|
@@ -8614,7 +10100,7 @@ function makeRoutes(deps) {
|
|
|
8614
10100
|
}
|
|
8615
10101
|
}
|
|
8616
10102
|
};
|
|
8617
|
-
/**
|
|
10103
|
+
/** 道藏局部修补(世界观规则/红线/风格)。 */
|
|
8618
10104
|
const biblePatchRoute = {
|
|
8619
10105
|
kind: "exact",
|
|
8620
10106
|
path: NOVEL_API.biblePatch,
|
|
@@ -9038,25 +10524,6 @@ function makeRoutes(deps) {
|
|
|
9038
10524
|
saveProject(config.outputDir, project);
|
|
9039
10525
|
writeJson(res, 200, { roles: project.roles });
|
|
9040
10526
|
return;
|
|
9041
|
-
} else if (op === "imageGenerate") {
|
|
9042
|
-
const name = body?.name?.trim();
|
|
9043
|
-
if (name === void 0 || name === "") {
|
|
9044
|
-
writeJson(res, 400, { error: "name(角色名)必填" });
|
|
9045
|
-
return;
|
|
9046
|
-
}
|
|
9047
|
-
try {
|
|
9048
|
-
const imageUrl = await generateRoleReferenceImage(ctx, config, project, config.outputDir, name, body?.style ?? "", body?.modelId);
|
|
9049
|
-
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
9050
|
-
saveProject(config.outputDir, project);
|
|
9051
|
-
writeJson(res, 200, {
|
|
9052
|
-
roles: project.roles,
|
|
9053
|
-
imageUrl
|
|
9054
|
-
});
|
|
9055
|
-
return;
|
|
9056
|
-
} catch (error) {
|
|
9057
|
-
writeJson(res, 500, { error: `角色参考图生成失败:${error.message}` });
|
|
9058
|
-
return;
|
|
9059
|
-
}
|
|
9060
10527
|
} else if (op === "visual") {
|
|
9061
10528
|
const name = body?.name?.trim();
|
|
9062
10529
|
if (name === void 0 || name === "") {
|
|
@@ -9202,7 +10669,8 @@ function makeRoutes(deps) {
|
|
|
9202
10669
|
return;
|
|
9203
10670
|
}
|
|
9204
10671
|
try {
|
|
9205
|
-
const
|
|
10672
|
+
const activeStyle = getActiveMangaStyle(project);
|
|
10673
|
+
const visual = await extractMangaRoleVisual(ctx, config, project, config.outputDir, id, body?.styleId ?? activeStyle.styleId, body?.filterId ?? activeStyle.filterId);
|
|
9206
10674
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
9207
10675
|
saveProject(config.outputDir, project);
|
|
9208
10676
|
writeJson(res, 200, {
|
|
@@ -9221,7 +10689,8 @@ function makeRoutes(deps) {
|
|
|
9221
10689
|
return;
|
|
9222
10690
|
}
|
|
9223
10691
|
try {
|
|
9224
|
-
const
|
|
10692
|
+
const activeStyle2 = getActiveMangaStyle(project);
|
|
10693
|
+
const kit = await generateMangaRolePromptKit(ctx, config, project, id, body?.styleId ?? activeStyle2.styleId, body?.filterId ?? activeStyle2.filterId);
|
|
9225
10694
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
9226
10695
|
saveProject(config.outputDir, project);
|
|
9227
10696
|
writeJson(res, 200, {
|
|
@@ -9282,40 +10751,82 @@ function makeRoutes(deps) {
|
|
|
9282
10751
|
saveProject(config.outputDir, project);
|
|
9283
10752
|
writeJson(res, 200, { cards: project.mangaRoles });
|
|
9284
10753
|
return;
|
|
9285
|
-
} else if (op === "
|
|
9286
|
-
|
|
9287
|
-
|
|
9288
|
-
|
|
10754
|
+
} else if (op === "mode") {
|
|
10755
|
+
project.shortDramaMode = body?.shortDramaMode === true;
|
|
10756
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
10757
|
+
saveProject(config.outputDir, project);
|
|
10758
|
+
writeJson(res, 200, {
|
|
10759
|
+
cards: project.mangaRoles,
|
|
10760
|
+
shortDramaMode: project.shortDramaMode
|
|
10761
|
+
});
|
|
10762
|
+
return;
|
|
10763
|
+
} else if (op === "autoGenerate") {
|
|
10764
|
+
const chapterNo = body?.chapterNo;
|
|
10765
|
+
if (typeof chapterNo !== "number" || chapterNo <= 0) {
|
|
10766
|
+
writeJson(res, 400, { error: "chapterNo(章号)必填" });
|
|
9289
10767
|
return;
|
|
9290
10768
|
}
|
|
9291
10769
|
try {
|
|
9292
|
-
const
|
|
9293
|
-
|
|
9294
|
-
saveProject(config.outputDir, project);
|
|
10770
|
+
const activeStyle3 = getActiveMangaStyle(project);
|
|
10771
|
+
const result = await autoGenerateMangaChapter(ctx, config, project, config.outputDir, chapterNo, body?.styleId ?? activeStyle3.styleId, body?.filterId ?? activeStyle3.filterId);
|
|
9295
10772
|
writeJson(res, 200, {
|
|
9296
10773
|
cards: project.mangaRoles,
|
|
9297
|
-
|
|
10774
|
+
autoGenerate: result
|
|
9298
10775
|
});
|
|
9299
10776
|
return;
|
|
9300
10777
|
} catch (error) {
|
|
9301
|
-
writeJson(res, 500, { error:
|
|
10778
|
+
writeJson(res, 500, { error: "一键生成失败:" + error.message });
|
|
10779
|
+
return;
|
|
10780
|
+
}
|
|
10781
|
+
} else if (op === "openAssets") {
|
|
10782
|
+
const dir = mangaAssetsDir(config.outputDir);
|
|
10783
|
+
try {
|
|
10784
|
+
mkdirSync(dir, { recursive: true });
|
|
10785
|
+
if (process.platform === "win32") spawn("explorer", [dir], { detached: true });
|
|
10786
|
+
else if (process.platform === "darwin") spawn("open", [dir], { detached: true });
|
|
10787
|
+
else spawn("xdg-open", [dir], { detached: true });
|
|
10788
|
+
writeJson(res, 200, {
|
|
10789
|
+
ok: true,
|
|
10790
|
+
dir
|
|
10791
|
+
});
|
|
10792
|
+
return;
|
|
10793
|
+
} catch (error) {
|
|
10794
|
+
writeJson(res, 500, {
|
|
10795
|
+
error: "打开资产库失败:" + error.message,
|
|
10796
|
+
dir
|
|
10797
|
+
});
|
|
9302
10798
|
return;
|
|
9303
10799
|
}
|
|
9304
|
-
} else if (op === "mode") {
|
|
9305
|
-
project.shortDramaMode = body?.shortDramaMode === true;
|
|
9306
|
-
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
9307
|
-
saveProject(config.outputDir, project);
|
|
9308
|
-
writeJson(res, 200, {
|
|
9309
|
-
cards: project.mangaRoles,
|
|
9310
|
-
shortDramaMode: project.shortDramaMode
|
|
9311
|
-
});
|
|
9312
|
-
return;
|
|
9313
10800
|
} else {
|
|
9314
10801
|
writeJson(res, 400, { error: "未知的 manga/roles op" });
|
|
9315
10802
|
return;
|
|
9316
10803
|
}
|
|
9317
10804
|
}
|
|
9318
10805
|
};
|
|
10806
|
+
/** 导出「即梦素材包」落盘到资产库 manga-assets/素材包/(前端组装 markdown,后端写文件)。 */
|
|
10807
|
+
const exportPackageRoute = {
|
|
10808
|
+
kind: "exact",
|
|
10809
|
+
path: NOVEL_API.exportPackage,
|
|
10810
|
+
handler: async (req, res) => {
|
|
10811
|
+
if (!guard(req, res, "POST")) return;
|
|
10812
|
+
const config = getConfig();
|
|
10813
|
+
const body = await readJsonBody(req);
|
|
10814
|
+
const chapterNo = Number(body?.chapterNo);
|
|
10815
|
+
const markdown = body?.markdown ?? "";
|
|
10816
|
+
if (!Number.isInteger(chapterNo) || chapterNo < 1 || markdown.trim() === "") {
|
|
10817
|
+
writeJson(res, 400, { error: "chapterNo 须为正整数,且 markdown 不能为空" });
|
|
10818
|
+
return;
|
|
10819
|
+
}
|
|
10820
|
+
try {
|
|
10821
|
+
writeJson(res, 200, {
|
|
10822
|
+
ok: true,
|
|
10823
|
+
file: saveMangaAssetPackage(config.outputDir, chapterNo, (body?.title ?? "").trim(), markdown)
|
|
10824
|
+
});
|
|
10825
|
+
} catch (error) {
|
|
10826
|
+
writeJson(res, 500, { error: error.message });
|
|
10827
|
+
}
|
|
10828
|
+
}
|
|
10829
|
+
};
|
|
9319
10830
|
/** 章节复位:generating 卡死 → pending(可重新生成)。 */
|
|
9320
10831
|
const chapterResetRoute = {
|
|
9321
10832
|
kind: "exact",
|
|
@@ -9626,30 +11137,13 @@ function makeRoutes(deps) {
|
|
|
9626
11137
|
return;
|
|
9627
11138
|
}
|
|
9628
11139
|
try {
|
|
9629
|
-
|
|
11140
|
+
const activeStyle5 = getActiveMangaStyle(project);
|
|
11141
|
+
writeJson(res, 200, { prompts: await generateStoryboardPrompts(ctx, config, project, config.outputDir, no, body.table, body?.styleId ?? activeStyle5.styleId, body?.filterId ?? activeStyle5.filterId) });
|
|
9630
11142
|
} catch (error) {
|
|
9631
11143
|
writeJson(res, 500, { error: `视频提示词生成失败:${error.message}` });
|
|
9632
11144
|
}
|
|
9633
11145
|
}
|
|
9634
11146
|
};
|
|
9635
|
-
/** 生图接口连通性测试:GET {baseURL}/models + 计时(设置页模型条目用)。 */
|
|
9636
|
-
const imageTestRoute = {
|
|
9637
|
-
kind: "exact",
|
|
9638
|
-
path: NOVEL_API.imageTest,
|
|
9639
|
-
handler: async (req, res) => {
|
|
9640
|
-
if (!guard(req, res, "POST")) return;
|
|
9641
|
-
const body = await readJsonBody(req);
|
|
9642
|
-
if (body?.baseURL === void 0 || body?.apiKey === void 0) {
|
|
9643
|
-
writeJson(res, 400, { error: "baseURL 与 apiKey 必填" });
|
|
9644
|
-
return;
|
|
9645
|
-
}
|
|
9646
|
-
try {
|
|
9647
|
-
writeJson(res, 200, await testImageEndpoint(body.baseURL, body.apiKey, body.model));
|
|
9648
|
-
} catch (error) {
|
|
9649
|
-
writeJson(res, 500, { error: `测试失败:${error.message}` });
|
|
9650
|
-
}
|
|
9651
|
-
}
|
|
9652
|
-
};
|
|
9653
11147
|
/** LLM 连通性测试:对选中的提供商/模型发一次最小真实调用。 */
|
|
9654
11148
|
const llmTestRoute = {
|
|
9655
11149
|
kind: "exact",
|
|
@@ -9763,7 +11257,8 @@ function makeRoutes(deps) {
|
|
|
9763
11257
|
return;
|
|
9764
11258
|
}
|
|
9765
11259
|
try {
|
|
9766
|
-
|
|
11260
|
+
const activeStyle6 = getActiveMangaStyle(project);
|
|
11261
|
+
writeJson(res, 200, { table: await generateStoryboardTable(ctx, config, project, config.outputDir, no, body.skeleton, body?.styleId ?? activeStyle6.styleId, body?.filterId ?? activeStyle6.filterId) });
|
|
9767
11262
|
} catch (error) {
|
|
9768
11263
|
writeJson(res, 500, { error: `分镜表生成失败:${error.message}` });
|
|
9769
11264
|
}
|
|
@@ -9797,6 +11292,7 @@ function makeRoutes(deps) {
|
|
|
9797
11292
|
name: name.slice(0, 40),
|
|
9798
11293
|
styleId,
|
|
9799
11294
|
filterId: body.filterId?.trim() !== "" ? body.filterId?.trim() : void 0,
|
|
11295
|
+
genre: body.genre?.trim() !== "" ? body.genre?.trim() : void 0,
|
|
9800
11296
|
active: project.mangaPlans.length === 0,
|
|
9801
11297
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
9802
11298
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -9805,6 +11301,8 @@ function makeRoutes(deps) {
|
|
|
9805
11301
|
const removed = project.mangaPlans.find((p) => p.id === body.id);
|
|
9806
11302
|
project.mangaPlans = project.mangaPlans.filter((p) => p.id !== body.id);
|
|
9807
11303
|
if (removed?.active === true && project.mangaPlans.length > 0) project.mangaPlans[0].active = true;
|
|
11304
|
+
project.storyboards = [];
|
|
11305
|
+
project.mangaRoles = [];
|
|
9808
11306
|
} else if (body?.op === "activate" && body.id !== void 0) project.mangaPlans.forEach((p) => {
|
|
9809
11307
|
p.active = p.id === body.id;
|
|
9810
11308
|
});
|
|
@@ -9908,7 +11406,8 @@ function makeRoutes(deps) {
|
|
|
9908
11406
|
if (project.scenes === void 0) project.scenes = [];
|
|
9909
11407
|
const op = body?.op;
|
|
9910
11408
|
if (op === "extract") try {
|
|
9911
|
-
const
|
|
11409
|
+
const activeStyle4 = getActiveMangaStyle(project);
|
|
11410
|
+
const candidates = await extractScenes(ctx, config, project, body?.chapterNo, body?.styleId ?? activeStyle4.styleId, body?.filterId ?? activeStyle4.filterId);
|
|
9912
11411
|
writeJson(res, 200, {
|
|
9913
11412
|
scenes: project.scenes,
|
|
9914
11413
|
candidates
|
|
@@ -9988,6 +11487,41 @@ function makeRoutes(deps) {
|
|
|
9988
11487
|
writeJson(res, 400, { error: "op 须为 extract 或 save" });
|
|
9989
11488
|
}
|
|
9990
11489
|
};
|
|
11490
|
+
const mangaPropsRoute = {
|
|
11491
|
+
kind: "exact",
|
|
11492
|
+
path: NOVEL_API.mangaProps,
|
|
11493
|
+
handler: async (req, res) => {
|
|
11494
|
+
if (!guard(req, res, "POST")) return;
|
|
11495
|
+
const config = getConfig();
|
|
11496
|
+
const project = requireProject(res);
|
|
11497
|
+
if (project === void 0) return;
|
|
11498
|
+
const body = await readJsonBody(req);
|
|
11499
|
+
if (body?.op === "extract") try {
|
|
11500
|
+
const props = await extractProps(ctx, config, project);
|
|
11501
|
+
if (props.length > 0) {
|
|
11502
|
+
project.props = props;
|
|
11503
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
11504
|
+
saveProject(config.outputDir, project);
|
|
11505
|
+
}
|
|
11506
|
+
writeJson(res, 200, { props: project.props ?? [] });
|
|
11507
|
+
return;
|
|
11508
|
+
} catch (error) {
|
|
11509
|
+
writeJson(res, 500, { error: `道具提炼失败:${error.message}` });
|
|
11510
|
+
return;
|
|
11511
|
+
}
|
|
11512
|
+
else if (body?.op === "save" && Array.isArray(body.props)) {
|
|
11513
|
+
project.props = body.props.filter((p) => typeof p === "object" && p !== null && typeof p.name === "string" && p.name.trim() !== "").map((p) => ({
|
|
11514
|
+
name: p.name.trim().slice(0, 20),
|
|
11515
|
+
desc: (typeof p.desc === "string" ? p.desc.trim() : "").slice(0, 120)
|
|
11516
|
+
})).slice(0, 8);
|
|
11517
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
11518
|
+
saveProject(config.outputDir, project);
|
|
11519
|
+
writeJson(res, 200, { props: project.props });
|
|
11520
|
+
return;
|
|
11521
|
+
}
|
|
11522
|
+
writeJson(res, 400, { error: "op 须为 extract 或 save" });
|
|
11523
|
+
}
|
|
11524
|
+
};
|
|
9991
11525
|
/** 生产单执行器(单例):计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。 */
|
|
9992
11526
|
const runner = new ProductionRunner({
|
|
9993
11527
|
ctx,
|
|
@@ -10426,7 +11960,9 @@ function makeRoutes(deps) {
|
|
|
10426
11960
|
plotlinesRoute,
|
|
10427
11961
|
rolesRoute,
|
|
10428
11962
|
mangaRolesRoute,
|
|
11963
|
+
exportPackageRoute,
|
|
10429
11964
|
scenesRoute,
|
|
11965
|
+
mangaPropsRoute,
|
|
10430
11966
|
visualRulesRoute,
|
|
10431
11967
|
sensitiveRoute,
|
|
10432
11968
|
reviewBackfillRoute,
|
|
@@ -10440,7 +11976,6 @@ function makeRoutes(deps) {
|
|
|
10440
11976
|
manhuaPlansRoute,
|
|
10441
11977
|
styleImageRoute,
|
|
10442
11978
|
storyboardSkeletonRoute,
|
|
10443
|
-
imageTestRoute,
|
|
10444
11979
|
llmTestRoute,
|
|
10445
11980
|
addModelRoute,
|
|
10446
11981
|
llmVendorsRoute,
|
|
@@ -10533,14 +12068,15 @@ const name = "novel-forge";
|
|
|
10533
12068
|
const inject = [
|
|
10534
12069
|
"webServer",
|
|
10535
12070
|
"llm",
|
|
10536
|
-
"systemPrompt"
|
|
12071
|
+
"systemPrompt",
|
|
12072
|
+
"settings"
|
|
10537
12073
|
];
|
|
10538
12074
|
/**
|
|
10539
12075
|
* Settings namespace of the novel-forge capability — the section the web
|
|
10540
12076
|
* settings surface edits. Spelled here rather than imported: the browser half
|
|
10541
12077
|
* spells the same value and must not depend on a Host package.
|
|
10542
12078
|
*/
|
|
10543
|
-
const NOVEL_SETTINGS_NAMESPACE =
|
|
12079
|
+
const NOVEL_SETTINGS_NAMESPACE = "dsh-novel-forge";
|
|
10544
12080
|
const Config = z.object({
|
|
10545
12081
|
announceToAgent: z.boolean().default(true),
|
|
10546
12082
|
enabled: z.boolean().default(true),
|
|
@@ -10599,7 +12135,7 @@ const DEFAULT_AUTO_REVIEW_AFTER_REVISE = true;
|
|
|
10599
12135
|
/** Order of the announcement section within the tool-guidance band. */
|
|
10600
12136
|
const SECTION_ORDER = 160;
|
|
10601
12137
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
10602
|
-
const NOVEL_GUIDANCE = "本机已安装 dsh-novel-forge 插件(AI 编译小说工作台):侧边栏「小说工坊」入口。能力:读取 docx 大纲或粘贴大纲文本;用 LLM
|
|
12138
|
+
const NOVEL_GUIDANCE = "本机已安装 dsh-novel-forge 插件(AI 编译小说工作台):侧边栏「小说工坊」入口。能力:读取 docx 大纲或粘贴大纲文本;用 LLM 提炼道藏(人设/世界观/金手指规则/写作红线);生成卷计划与章节计划;逐章调用 LLM 生成 3000-4000 字正文并保存为 Markdown(默认输出到用户主目录 ~/.dsh/novels);每章自动生成摘要(叙事记忆)、自动 AI 审稿(人设/设定/红线/文笔/爽点/逻辑),支持按审稿意见重写、去 AI 味润色、暗线(伏笔)管理、批量连写与全本导出(txt/md)。限制:生成消耗 LLM API 额度;输出目录与模型可在插件设置中修改;章节正文质量取决于大纲完整度。用户提到「小说 / 大纲 / 写小说 / 章节 / 审稿 / 润色」时即指本插件,请据此协作。";
|
|
10603
12139
|
/** Resolve a config-like value into the full runtime config. */
|
|
10604
12140
|
function resolveConfig(value) {
|
|
10605
12141
|
return {
|
|
@@ -10633,7 +12169,7 @@ function resolveConfig(value) {
|
|
|
10633
12169
|
imageApiModel: (() => {
|
|
10634
12170
|
return ((value?.imageModels ?? []).find((m) => m.enabled) ?? (value?.imageModels ?? [])[0])?.model ?? value?.imageApiModel;
|
|
10635
12171
|
})(),
|
|
10636
|
-
imageApiEnabled: value?.imageApiEnabled ??
|
|
12172
|
+
imageApiEnabled: value?.imageApiEnabled === true || (value?.imageModels ?? []).some((m) => m.enabled === true),
|
|
10637
12173
|
themeBackground: value?.themeBackground ?? "",
|
|
10638
12174
|
themeBackgroundBlur: value?.themeBackgroundBlur ?? 0,
|
|
10639
12175
|
themeOpacity: value?.themeOpacity ?? 100,
|
|
@@ -10718,7 +12254,7 @@ function apply(ctx, config) {
|
|
|
10718
12254
|
};
|
|
10719
12255
|
}, "dsh-novel-forge: routes");
|
|
10720
12256
|
};
|
|
10721
|
-
|
|
12257
|
+
ctx.settings.installSection(ctx, NOVEL_SETTINGS_NAMESPACE, Config, config ?? {}, {
|
|
10722
12258
|
setSource: (source) => {
|
|
10723
12259
|
current = source;
|
|
10724
12260
|
sync();
|
|
@@ -10729,6 +12265,7 @@ function apply(ctx, config) {
|
|
|
10729
12265
|
if (skillsService?.register !== void 0) {
|
|
10730
12266
|
const disposeSkill = skillsService.register({
|
|
10731
12267
|
name: "novel-forge-chapter-batch",
|
|
12268
|
+
provider: "dsh-novel-forge",
|
|
10732
12269
|
description: "批量生成小说章节并值守处理审稿未过的章节(豁免/按意见修订/验证模式/重新生成),依赖 dsh-novel-forge 的 /api/dsh-novel-forge/* 路由。",
|
|
10733
12270
|
content: [
|
|
10734
12271
|
"# 小说章节批量生产与值守处理",
|