@waterwx/dsh-novel-forge 1.10.1 → 2.0.0
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 +287 -281
- package/lib/client.js +19572 -18127
- package/lib/index.js +12171 -6932
- package/lib/types/action-guard.d.ts +61 -0
- package/lib/types/ai-scan.d.ts +30 -0
- package/lib/types/assets.d.ts +20 -9
- package/lib/types/assistant.d.ts +29 -0
- package/lib/types/author-assets.d.ts +1 -3
- package/lib/types/bookshelf.d.ts +13 -3
- package/lib/types/client/api.d.ts +137 -21
- package/lib/types/client/chip-prefs.d.ts +36 -0
- package/lib/types/client/docx.d.ts +2 -2
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/llmLive.d.ts +31 -0
- package/lib/types/client/locales.d.ts +15 -70
- package/lib/types/client/mount.d.ts +0 -2
- package/lib/types/client/panel/AiTag.d.ts +1 -0
- package/lib/types/client/panel/AssistantTab.d.ts +4 -1
- package/lib/types/client/panel/AuthorHome.d.ts +13 -9
- package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
- package/lib/types/client/panel/CmdPalette.d.ts +12 -0
- package/lib/types/client/panel/CreateBookView.d.ts +5 -1
- package/lib/types/client/panel/DirectorView.d.ts +7 -0
- package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
- package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
- package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
- package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
- package/lib/types/client/panel/ModelManager.d.ts +2 -2
- package/lib/types/client/panel/NovelPanel.d.ts +6 -1
- package/lib/types/client/panel/PageHeader.d.ts +18 -0
- package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
- package/lib/types/client/panel/SettingsView.d.ts +12 -7
- package/lib/types/client/panel/ShelfView.d.ts +10 -1
- package/lib/types/client/panel/SubPage.d.ts +27 -0
- package/lib/types/client/panel/TensionView.d.ts +18 -0
- package/lib/types/client/panel/TimelineView.d.ts +18 -0
- package/lib/types/client/panel/helpers.d.ts +5 -3
- package/lib/types/client/panel/market-radar-state.d.ts +56 -0
- package/lib/types/client/panel/toast.d.ts +2 -0
- package/lib/types/client/panel/views.d.ts +21 -35
- package/lib/types/client/run-status.d.ts +21 -0
- package/lib/types/client/sidebar-entry.d.ts +0 -2
- package/lib/types/docx-core.d.ts +16 -0
- package/lib/types/docx.d.ts +2 -3
- package/lib/types/engine.d.ts +83 -79
- package/lib/types/global-assets.d.ts +6 -0
- package/lib/types/home.d.ts +11 -0
- package/lib/types/index.d.ts +27 -13
- package/lib/types/llm-live.d.ts +70 -0
- package/lib/types/llm-retry.d.ts +38 -0
- package/lib/types/market-radar-scan.d.ts +18 -0
- package/lib/types/market-radar-sources.d.ts +31 -0
- package/lib/types/migrate-output.d.ts +25 -0
- package/lib/types/model-capability.d.ts +93 -0
- package/lib/types/novel-context.d.ts +80 -0
- package/lib/types/output-budget.d.ts +86 -0
- package/lib/types/prompt-slots.d.ts +57 -0
- package/lib/types/prompting.d.ts +10 -0
- package/lib/types/protocol.d.ts +829 -522
- package/lib/types/revision.d.ts +76 -0
- package/lib/types/routes.d.ts +9 -3
- package/lib/types/run.d.ts +23 -1
- package/lib/types/settings-persist.d.ts +59 -0
- package/lib/types/snapshots.d.ts +46 -0
- package/lib/types/stage-contract.d.ts +58 -0
- package/lib/types/tension.d.ts +53 -0
- package/lib/types/timeline.d.ts +37 -0
- package/package.json +50 -28
- package/src/action-guard.ts +127 -0
- package/src/ai-scan.ts +155 -0
- package/src/assets.ts +1801 -535
- package/src/assistant.ts +1098 -770
- package/src/author-assets.ts +131 -147
- package/src/bookshelf.ts +193 -167
- package/src/client/api.ts +767 -607
- package/src/client/chip-prefs.ts +121 -0
- package/src/client/docx.ts +32 -69
- package/src/client/index.ts +3 -1
- package/src/client/llmLive.ts +111 -0
- package/src/client/locales.ts +15 -274
- package/src/client/mount.tsx +4 -3
- package/src/client/panel/AdaptModeView.tsx +535 -520
- package/src/client/panel/AiTag.tsx +7 -0
- package/src/client/panel/AssetsTab.tsx +448 -458
- package/src/client/panel/AssistantTab.tsx +355 -301
- package/src/client/panel/AuthorAssetsView.tsx +205 -207
- package/src/client/panel/AuthorHome.tsx +309 -202
- package/src/client/panel/BookAnalysisView.tsx +63 -0
- package/src/client/panel/CmdPalette.tsx +102 -0
- package/src/client/panel/CreateBookView.tsx +366 -352
- package/src/client/panel/DirectorView.tsx +200 -0
- package/src/client/panel/GlobalAssetLibraryView.tsx +168 -172
- package/src/client/panel/IdeaInspirationView.tsx +76 -0
- package/src/client/panel/ImportModal.tsx +302 -302
- package/src/client/panel/KnowledgeBaseView.tsx +125 -0
- package/src/client/panel/LiveFeedLog.tsx +204 -0
- package/src/client/panel/MarketRadarView.tsx +415 -0
- package/src/client/panel/ModelManager.tsx +205 -205
- package/src/client/panel/NovelPanel.tsx +5559 -4655
- package/src/client/panel/PageHeader.tsx +37 -0
- package/src/client/panel/ProgressConsole.tsx +63 -64
- package/src/client/panel/PromptSlotsView.tsx +97 -0
- package/src/client/panel/ReaderView.tsx +306 -306
- package/src/client/panel/RunPanel.tsx +56 -24
- package/src/client/panel/SettingsView.tsx +175 -112
- package/src/client/panel/ShelfView.tsx +63 -39
- package/src/client/panel/SubPage.tsx +42 -0
- package/src/client/panel/TensionView.tsx +212 -0
- package/src/client/panel/TimelineView.tsx +208 -0
- package/src/client/panel/WorldTab.tsx +202 -202
- package/src/client/panel/helpers.ts +7 -5
- package/src/client/panel/market-radar-state.ts +79 -0
- package/src/client/panel/panel.module.css +5767 -4199
- package/src/client/panel/toast.ts +51 -0
- package/src/client/panel/views.tsx +316 -314
- package/src/client/run-status.ts +171 -0
- package/src/client/sidebar-entry.ts +3 -3
- package/src/docx-core.ts +53 -0
- package/src/docx.ts +44 -83
- package/src/engine.ts +1610 -1541
- package/src/global-assets.ts +58 -0
- package/src/home.ts +33 -0
- package/src/index.ts +184 -103
- package/src/llm-live.ts +245 -0
- package/src/llm-retry.ts +77 -0
- package/src/market-radar-scan.ts +35 -0
- package/src/market-radar-sources.ts +173 -0
- package/src/migrate-output.ts +76 -0
- package/src/model-capability.ts +215 -0
- package/src/novel-context.ts +286 -0
- package/src/output-budget.ts +147 -0
- package/src/prompt-slots.ts +125 -0
- package/src/prompting.ts +26 -0
- package/src/protocol.ts +2454 -2148
- package/src/revision.ts +283 -0
- package/src/routes.ts +3795 -3359
- package/src/run.ts +113 -17
- package/src/settings-persist.ts +114 -0
- package/src/snapshots.ts +174 -0
- package/src/stage-contract.ts +186 -0
- package/src/tension.ts +251 -0
- package/src/timeline.ts +159 -0
- package/assets/styles/arcane-thick-3d.webp +0 -0
- package/assets/styles/chinese-ink.webp +0 -0
- package/assets/styles/clay-stopmotion-3d.webp +0 -0
- package/assets/styles/dark-ghibli.webp +0 -0
- package/assets/styles/dark-gothic-3d.webp +0 -0
- package/assets/styles/film-photo.webp +0 -0
- package/assets/styles/guofeng-heavy.webp +0 -0
- package/assets/styles/horror-game-3d.webp +0 -0
- package/assets/styles/japan-cel-dark.webp +0 -0
- package/assets/styles/korean-webtoon.webp +0 -0
- package/assets/styles/low-cyberpunk-3d.webp +0 -0
- package/assets/styles/nextgen-cg.webp +0 -0
- package/assets/styles/noir-film.webp +0 -0
- package/assets/styles/openworld-survival.webp +0 -0
- package/assets/styles/paper-cut-3d.webp +0 -0
- package/assets/styles/pixar-adult-3d.webp +0 -0
- package/assets/styles/pop-marvel-2d.webp +0 -0
- package/assets/styles/shinkai-anime.webp +0 -0
- package/assets/styles/tactical-stealth.webp +0 -0
- package/assets/styles/vaporwave-3d.webp +0 -0
- package/assets/styles/vhs90.webp +0 -0
- package/lib/types/client/panel/BookshelfBar.d.ts +0 -11
- package/lib/types/client/panel/FlowGuide.d.ts +0 -21
- package/lib/types/client/panel/MangaRoleLibrary.d.ts +0 -18
- package/lib/types/client/panel/MangaWorkspace.d.ts +0 -15
- package/lib/types/client/panel/ModelPicker.d.ts +0 -11
- package/lib/types/client/panel/ProgressHomeView.d.ts +0 -5
- package/lib/types/client/panel/SceneLibrary.d.ts +0 -14
- package/lib/types/client/panel/StoryboardTab.d.ts +0 -19
- package/lib/types/client/panel/StyleCard.d.ts +0 -8
- package/lib/types/client/panel/StyleGallery.d.ts +0 -1
- package/lib/types/comic-presets.d.ts +0 -12
- package/lib/types/shot-language.d.ts +0 -46
- package/lib/types/story-beat-language.d.ts +0 -35
- package/lib/types/style-library.d.ts +0 -33
- package/skills/chapter-batch/SKILL.md +0 -81
- package/src/client/mount.tsx._new +0 -97
- package/src/client/mount.tsx._nf +0 -97
- package/src/client/mount.tsx._tmp +0 -97
- package/src/client/panel/BookshelfBar.tsx +0 -170
- package/src/client/panel/FlowGuide.tsx +0 -83
- package/src/client/panel/MangaRoleLibrary.tsx +0 -810
- package/src/client/panel/MangaWorkspace.tsx +0 -359
- package/src/client/panel/ProgressHomeView.tsx +0 -87
- package/src/client/panel/SceneLibrary.tsx +0 -396
- package/src/client/panel/StoryboardTab.tsx +0 -466
- package/src/client/panel/StyleCard.tsx +0 -90
- package/src/client/panel/StyleGallery.tsx +0 -58
- package/src/comic-presets.ts +0 -67
- package/src/shot-language.ts +0 -181
- package/src/story-beat-language.ts +0 -130
- package/src/style-library.ts +0 -78
package/src/engine.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* 内容合规红线(平台硬性要求):所有书籍、所有章节无条件生效,
|
|
11
|
-
*
|
|
11
|
+
* 优先级高于单书大纲/道藏中的任何设定与作者自定义红线。
|
|
12
12
|
* 注入点:章节生成系统提示 + 审稿系统提示(命中即 high)。
|
|
13
13
|
*/
|
|
14
14
|
export const COMPLIANCE_REDLINES: ReadonlyArray<string> = [
|
|
@@ -23,39 +23,38 @@ export const COMPLIANCE_REDLINES: ReadonlyArray<string> = [
|
|
|
23
23
|
'9. 不得出现法律法规禁止的其他内容。',
|
|
24
24
|
]
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
/** 审稿维度取值:归一化模型输出的 dimension 字段。维度清单以 reviewSystemPrompt 的九条为准。 */
|
|
27
|
+
const REVIEW_DIMENSION_IDS = new Set(['character', 'setting', 'redline', 'writing', 'pacing', 'logic', 'anti-ai', 'presentation', 'compliance'])
|
|
28
|
+
|
|
29
|
+
import { mkdirSync, readFileSync, writeFileSync, readdirSync, existsSync, rmSync, renameSync } from 'node:fs'
|
|
30
|
+
import { join, basename, extname } from 'node:path'
|
|
31
|
+
import { tmpdir } from 'node:os'
|
|
29
32
|
import { createUserMessage, BlockAssembler, ReasoningEffortId, type GenerateOptions, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
30
33
|
import type { Context } from '@deepseek-ai/cordis'
|
|
31
|
-
import { emptyProjectAssets, renderAllAssets, styleEngineSystemPrompt } from './assets.ts'
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
} from './
|
|
43
|
-
import {
|
|
44
|
-
normalizeStoryFunction,
|
|
45
|
-
normalizeEmotions,
|
|
46
|
-
functionZh,
|
|
47
|
-
emotionZh,
|
|
48
|
-
} from './story-beat-language.ts'
|
|
34
|
+
import { BUILTIN_GENRE_LIBRARY, BUILTIN_PROGRESSION_MODES, emptyProjectAssets, recommendStylePreset, renderAllAssets, styleEngineSystemPrompt, styleFormulaSystemPrompt } from './assets.ts'
|
|
35
|
+
import { scanAiFlavor } from './ai-scan.ts'
|
|
36
|
+
import { beginLiveCall, endLiveCall, markFirstToken } from './llm-live.ts'
|
|
37
|
+
import { shouldSwitchModel, withModelFallback } from './llm-retry.ts'
|
|
38
|
+
import { convergeRequestParams } from './model-capability.ts'
|
|
39
|
+
import { renderLengthContract, resolveOutputBudget, STRUCTURED_FIELD_LIMITS } from './output-budget.ts'
|
|
40
|
+
import { renderTensionBlock } from './tension.ts'
|
|
41
|
+
import { renderPromptSlots } from './prompt-slots.ts'
|
|
42
|
+
import { normalizeTimelineEvent, renderTimelineBlock, sortTimeline } from './timeline.ts'
|
|
43
|
+
import { createSnapshot } from './snapshots.ts'
|
|
44
|
+
import { renderChapterWriterSkeleton } from './prompting.ts'
|
|
45
|
+
import { NovelActionError } from './action-guard.ts'
|
|
46
|
+
import { buildChapterContext, renderContextBlocks } from './novel-context.ts'
|
|
49
47
|
import type {
|
|
50
48
|
AdaptationDimension,
|
|
51
49
|
AdaptAnalyzeResponse,
|
|
52
50
|
AdaptationMapping,
|
|
53
|
-
AdaptationProposal,
|
|
54
51
|
AdaptationRules,
|
|
55
52
|
AdaptProposeResponse,
|
|
56
|
-
AdaptExecuteResponse,
|
|
57
53
|
AuditIssue,
|
|
58
54
|
AuthorReview,
|
|
55
|
+
ExportScope,
|
|
56
|
+
TimelineEvent,
|
|
57
|
+
TimelineIssue,
|
|
59
58
|
BreakdownResponse,
|
|
60
59
|
ChapterPlan,
|
|
61
60
|
Foreshadow,
|
|
@@ -65,37 +64,39 @@ import type {
|
|
|
65
64
|
PlotlineHealthReport,
|
|
66
65
|
PlotlinePlan,
|
|
67
66
|
ProjectState,
|
|
67
|
+
ReviewDimension,
|
|
68
68
|
ReviewReport,
|
|
69
69
|
RoleRecord,
|
|
70
70
|
RoleStatusCard,
|
|
71
|
-
SceneCard,
|
|
72
71
|
StoryBible,
|
|
73
|
-
StoryboardSkeleton,
|
|
74
|
-
StoryboardBeat,
|
|
75
|
-
StoryboardTable,
|
|
76
|
-
StoryboardShot,
|
|
77
|
-
StoryboardPrompt,
|
|
78
|
-
ChapterStoryboard,
|
|
79
72
|
AddModelRequest,
|
|
80
73
|
AddModelResponse,
|
|
81
74
|
SavedModel,
|
|
82
|
-
LlmModelOption,
|
|
83
75
|
LlmModelsResponse,
|
|
84
76
|
LlmVendorOption,
|
|
85
77
|
LlmVendorsResponse,
|
|
86
78
|
LlmProvidersResponse,
|
|
87
79
|
RemoveProviderRequest,
|
|
88
80
|
RemoveProviderResponse,
|
|
89
|
-
LlmTestRequest,
|
|
90
81
|
LlmTestResponse,
|
|
91
|
-
MangaRoleCandidate,
|
|
92
|
-
MangaRoleCard,
|
|
93
82
|
Volume,
|
|
94
83
|
WorldState,
|
|
95
84
|
AdaptMaterializeRequest,
|
|
96
85
|
AdaptMaterializeResponse,
|
|
97
86
|
AdaptMaterializeSaveRequest,
|
|
98
87
|
AdaptMaterializeSaveResponse,
|
|
88
|
+
MarketRadarRequest,
|
|
89
|
+
MarketRadarResult,
|
|
90
|
+
MarketRadarSignal,
|
|
91
|
+
MarketRadarBriefRequest,
|
|
92
|
+
ProductionFoundation,
|
|
93
|
+
MarketCreativeBrief,
|
|
94
|
+
BookAnalysisRequest,
|
|
95
|
+
BookAnalysisResult,
|
|
96
|
+
IdeaInspirationRequest,
|
|
97
|
+
IdeaInspirationResult,
|
|
98
|
+
DirectorRequest,
|
|
99
|
+
DirectorAdvice,
|
|
99
100
|
} from './protocol.ts'
|
|
100
101
|
import { LLM_VENDORS } from './protocol.ts'
|
|
101
102
|
|
|
@@ -170,7 +171,9 @@ export function loadProject(outputDir: string): ProjectState | undefined {
|
|
|
170
171
|
if (!Array.isArray(raw.facts)) raw.facts = []
|
|
171
172
|
if (!Array.isArray(raw.plotlines)) raw.plotlines = []
|
|
172
173
|
return raw
|
|
173
|
-
} catch {
|
|
174
|
+
} catch (error) {
|
|
175
|
+
// 项目文件损坏被静默当"无项目"会遮蔽数据丢失,留痕到服务端日志。
|
|
176
|
+
console.error(`[novel-forge] project file unreadable/corrupt: ${file} — ${(error as Error).message}`)
|
|
174
177
|
return undefined
|
|
175
178
|
}
|
|
176
179
|
}
|
|
@@ -178,9 +181,20 @@ export function loadProject(outputDir: string): ProjectState | undefined {
|
|
|
178
181
|
/** Persist the project state next to the chapters. */
|
|
179
182
|
export function saveProject(outputDir: string, project: ProjectState): void {
|
|
180
183
|
mkdirSync(outputDir, { recursive: true })
|
|
181
|
-
|
|
184
|
+
const target = join(outputDir, PROJECT_FILE)
|
|
185
|
+
const data = JSON.stringify(project, null, 2)
|
|
186
|
+
// no-op 检测:内容未变化则跳过写盘,减少连续保存的 I/O
|
|
187
|
+
try {
|
|
188
|
+
if (existsSync(target)) {
|
|
189
|
+
const existing = readFileSync(target, 'utf8')
|
|
190
|
+
if (existing === data) return
|
|
191
|
+
}
|
|
192
|
+
} catch { /* 读取失败时正常写入 */ }
|
|
193
|
+
// 原子写:先写临时文件再 rename,降低中途崩溃导致项目文件损坏的风险
|
|
194
|
+
const tmp = target + '.tmp'
|
|
195
|
+
writeFileSync(tmp, data, 'utf8')
|
|
196
|
+
renameSync(tmp, target)
|
|
182
197
|
}
|
|
183
|
-
|
|
184
198
|
/**
|
|
185
199
|
* 并发保护:长任务(章节计划生成/正文生成)在内存中持有旧快照,
|
|
186
200
|
* 期间其他请求可能修改了「易变字段」(道藏/角色库/剧情线/人物志存档/简介/封面)。
|
|
@@ -193,7 +207,6 @@ export function mergeVolatileFromDisk(outputDir: string, project: ProjectState):
|
|
|
193
207
|
if (disk === undefined) return
|
|
194
208
|
project.bible = disk.bible
|
|
195
209
|
project.roles = disk.roles
|
|
196
|
-
project.mangaRoles = disk.mangaRoles
|
|
197
210
|
project.plotlines = disk.plotlines
|
|
198
211
|
project.roleStatus = disk.roleStatus
|
|
199
212
|
project.blurb = disk.blurb
|
|
@@ -336,59 +349,158 @@ export function createProject(outline: string, outlinePath?: string): ProjectSta
|
|
|
336
349
|
|
|
337
350
|
// ------------------------------------------------------------------- llm
|
|
338
351
|
|
|
352
|
+
/** 一次非流式 LLM 调用的入参。 */
|
|
353
|
+
interface CompleteOptions {
|
|
354
|
+
system: string
|
|
355
|
+
user: string
|
|
356
|
+
temperature?: number
|
|
357
|
+
maxTokens?: number
|
|
358
|
+
reasoning?: 'off' | 'low' | 'high' | 'max'
|
|
359
|
+
model?: string
|
|
360
|
+
liveLabel?: string
|
|
361
|
+
/**
|
|
362
|
+
* 该调用是否要求严格结构化输出(JSON)。为 true 时,模型参数能力层可以在
|
|
363
|
+
* 结构化稳定性优先的前提下覆盖用户的思考档位。
|
|
364
|
+
*/
|
|
365
|
+
structured?: boolean
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* 把已经拼好的请求过一遍模型参数能力层。
|
|
370
|
+
*
|
|
371
|
+
* **凡是自己构造 `GenerateOptions`、不走 {@link complete} 的调用点都必须经过它**,
|
|
372
|
+
* 否则那条路径会绕过厂商参数约束(固定温度、不支持的 reasoning 等),对 Kimi
|
|
373
|
+
* 这类模型直接报参数错误 —— 而"测试连通"恰恰是用户用来判断配置对不对的地方。
|
|
374
|
+
*
|
|
375
|
+
* 抽成函数而不是在每个调用点内联:流式调用点有 5 处,还要各自处理备用模型切换,
|
|
376
|
+
* 内联五遍就是同一件事写五份,必然漂移。
|
|
377
|
+
* @param base - 已拼好的请求(调用方已确定 temperature / reasoningEffort)。
|
|
378
|
+
* @param model - **本次实际要调用的模型**(切换备用模型后必须重算,故在循环内调用)。
|
|
379
|
+
* @param options - `structured` 表示严格结构化输出任务。
|
|
380
|
+
* @returns 收敛后的请求(固定温度的模型会省略 temperature / 不支持的参数会被移除)。
|
|
381
|
+
*/
|
|
382
|
+
function applyModelCapability(
|
|
383
|
+
base: GenerateOptions,
|
|
384
|
+
model: string,
|
|
385
|
+
options: { structured?: boolean } = {},
|
|
386
|
+
): GenerateOptions {
|
|
387
|
+
const converged = convergeRequestParams(
|
|
388
|
+
{
|
|
389
|
+
temperature: base.temperature ?? 0.7,
|
|
390
|
+
reasoningEffort: base.reasoningEffort === undefined
|
|
391
|
+
? undefined
|
|
392
|
+
: String(base.reasoningEffort) as 'off' | 'low' | 'high' | 'max',
|
|
393
|
+
},
|
|
394
|
+
model,
|
|
395
|
+
{ structured: options.structured === true, omitFixedTemperature: true },
|
|
396
|
+
)
|
|
397
|
+
return {
|
|
398
|
+
...base,
|
|
399
|
+
model,
|
|
400
|
+
...(converged.temperature === undefined ? { temperature: undefined } : { temperature: converged.temperature }),
|
|
401
|
+
...(converged.reasoningEffort === undefined ? { reasoningEffort: undefined } : { reasoningEffort: ReasoningEffortId(converged.reasoningEffort) }),
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
339
405
|
/** One complete non-streaming LLM call. */
|
|
340
406
|
async function complete(
|
|
341
407
|
ctx: Context,
|
|
342
408
|
config: NovelConfig,
|
|
343
|
-
options:
|
|
409
|
+
options: CompleteOptions,
|
|
344
410
|
): Promise<string> {
|
|
411
|
+
const liveLabel = options.liveLabel ?? 'LLM 调用'
|
|
345
412
|
const messages: Message[] = [createUserMessage({
|
|
346
413
|
content: [{ type: 'text', text: options.user }],
|
|
347
414
|
source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
|
|
348
415
|
})]
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
model:
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
416
|
+
/** 用指定模型跑一次完整调用(含实况打点、token 记账与诊断日志)。 */
|
|
417
|
+
const runOnce = async (model: string): Promise<string> => {
|
|
418
|
+
const live = beginLiveCall({ label: liveLabel, model, system: options.system, user: options.user })
|
|
419
|
+
// 用户偏好先成型,再经能力层按**实际模型**收敛:同一模型走不同 provider/中转
|
|
420
|
+
// 时约束不变,换备用模型后约束随新模型重算。
|
|
421
|
+
const converged = convergeRequestParams(
|
|
422
|
+
{
|
|
423
|
+
temperature: options.temperature ?? 0.7,
|
|
424
|
+
reasoningEffort: options.reasoning ?? config.reasoningEffort ?? 'off',
|
|
425
|
+
},
|
|
426
|
+
model,
|
|
427
|
+
{
|
|
428
|
+
structured: options.structured === true,
|
|
429
|
+
// 厂商建议固定温度的模型不要显式传入,优先省略,省得多余参数被拒。
|
|
430
|
+
omitFixedTemperature: true,
|
|
431
|
+
},
|
|
432
|
+
)
|
|
433
|
+
if (converged.adjustments.length > 0) {
|
|
434
|
+
ctx.logger('novel-forge').info('模型参数收敛(%s @ %s):%s', liveLabel, model, converged.adjustments.join(';'))
|
|
435
|
+
}
|
|
436
|
+
const request: GenerateOptions = {
|
|
437
|
+
provider: config.provider,
|
|
438
|
+
model,
|
|
439
|
+
messages,
|
|
440
|
+
system: options.system,
|
|
441
|
+
maxTokens: options.maxTokens ?? config.maxTokens,
|
|
442
|
+
...(converged.temperature === undefined ? {} : { temperature: converged.temperature }),
|
|
443
|
+
...(converged.reasoningEffort === undefined ? {} : { reasoningEffort: ReasoningEffortId(converged.reasoningEffort) }),
|
|
444
|
+
}
|
|
445
|
+
const assembler = new BlockAssembler()
|
|
446
|
+
for await (const chunk of ctx.llm.stream(request)) {
|
|
447
|
+
if (chunk.type === 'text-delta') markFirstToken(live)
|
|
448
|
+
assembler.push(chunk)
|
|
449
|
+
}
|
|
450
|
+
const finish = assembler.finish
|
|
451
|
+
if (finish.kind === 'error' || finish.kind === 'aborted') {
|
|
452
|
+
const message = `LLM 调用失败(${finish.kind}): ${finish.failure.message}`
|
|
453
|
+
endLiveCall(live, assembler, { phase: 'failed', error: message })
|
|
454
|
+
throw new Error(message)
|
|
455
|
+
}
|
|
456
|
+
if (finish.kind === 'max-tokens') {
|
|
457
|
+
const message = 'LLM 输出达到 maxTokens 上限,请增大配置后重试'
|
|
458
|
+
endLiveCall(live, assembler, { phase: 'failed', error: message })
|
|
459
|
+
throw new Error(message)
|
|
460
|
+
}
|
|
461
|
+
// Diagnostics: log the assembled block shape (reasoning-only turns yield no
|
|
462
|
+
// text blocks — some models answer entirely in the reasoning channel).
|
|
463
|
+
if (process.env.DSH_NOVEL_DEBUG === '1') {
|
|
464
|
+
console.error('[dsh-novel-forge] complete: finish=%j blocks=%j', JSON.stringify(finish), assembler.blocks().map(b => `${b.type}:${'text' in b ? b.text.length : '?'}`))
|
|
465
|
+
}
|
|
466
|
+
const text = textFromAssembler(assembler)
|
|
467
|
+
endLiveCall(live, assembler, { chars: text.length, preview: text.slice(0, 320), phase: text === '' ? 'failed' : 'completed' })
|
|
468
|
+
return text
|
|
368
469
|
}
|
|
470
|
+
// 主模型失败(网络/限流/额度类)且配置了备用模型时,自动换模型重试一次。
|
|
471
|
+
return withModelFallback({ model: options.model || config.model, fallbackModel: config.fallbackModel }, runOnce)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* 从 assembler 取模型文本;无文本块时回落到 reasoning 通道
|
|
476
|
+
* (部分模型会把答案整个放在思考通道,适配器以 reasoning 块呈现)。
|
|
477
|
+
*/
|
|
478
|
+
function textFromAssembler(assembler: BlockAssembler): string {
|
|
369
479
|
const blocks = assembler.blocks()
|
|
370
|
-
// Diagnostics: log the assembled block shape (reasoning-only turns yield no
|
|
371
|
-
// text blocks — the v4-flash model can answer entirely in the reasoning
|
|
372
|
-
// channel, which the adapter surfaces as a reasoning block).
|
|
373
|
-
if (process.env.DSH_NOVEL_DEBUG === '1') {
|
|
374
|
-
console.error('[dsh-novel-forge] complete: finish=%j blocks=%j', JSON.stringify(finish), blocks.map(b => `${b.type}:${'text' in b ? b.text.length : '?'}`))
|
|
375
|
-
}
|
|
376
480
|
const textBlocks = blocks
|
|
377
481
|
.filter((block): block is Extract<StreamChunk, { type: 'block-end' }>['block'] & { type: 'text' } => block.type === 'text')
|
|
378
482
|
.map(block => block.text)
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
483
|
+
const text = textBlocks.join('\n').trim()
|
|
484
|
+
if (text !== '') return text
|
|
485
|
+
return blocks
|
|
486
|
+
.filter((block): block is { type: 'reasoning'; text: string } => block.type === 'reasoning')
|
|
487
|
+
.map(block => block.text)
|
|
488
|
+
.join('\n')
|
|
489
|
+
.trim()
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** 解析 JSON 数组;失败或为空时给模型一次修复重试(对齐上游 structuredInvokeRepair 精神)。 */
|
|
493
|
+
async function completeJsonArray(
|
|
494
|
+
ctx: Context,
|
|
495
|
+
config: NovelConfig,
|
|
496
|
+
options: CompleteOptions,
|
|
497
|
+
parse: (text: string) => unknown[],
|
|
498
|
+
): Promise<unknown[]> {
|
|
499
|
+
let parsed = parse(await complete(ctx, config, options))
|
|
500
|
+
if (Array.isArray(parsed) && parsed.length > 0) return parsed
|
|
501
|
+
const repairOptions = { ...options, user: options.user + '\n\n注意:你上一次输出不是合法且非空的 JSON 数组。请重新输出:只输出一个合法 JSON 数组(不要 Markdown、不要解释、不要思考过程、不要代码块标记)。' }
|
|
502
|
+
const parsed2 = parse(await complete(ctx, config, repairOptions))
|
|
503
|
+
return Array.isArray(parsed2) ? parsed2 : []
|
|
392
504
|
}
|
|
393
505
|
|
|
394
506
|
/**
|
|
@@ -419,6 +531,44 @@ function parseJson<T>(text: string, wantArray: boolean): T {
|
|
|
419
531
|
const start2 = trimmed.indexOf(opener)
|
|
420
532
|
if (start2 !== -1) push(trimmed.slice(start2))
|
|
421
533
|
|
|
534
|
+
// Repair truncated JSON: find the last complete object/array element, close brackets.
|
|
535
|
+
const repairTruncated = (value: string): string => {
|
|
536
|
+
const firstOpen = value.indexOf(opener)
|
|
537
|
+
if (firstOpen === -1) return value
|
|
538
|
+
const body = value.slice(firstOpen)
|
|
539
|
+
let inStr = false
|
|
540
|
+
let depth = 0
|
|
541
|
+
let lastComplete = -1
|
|
542
|
+
for (let i = 0; i < body.length; i++) {
|
|
543
|
+
const ch = body[i]!
|
|
544
|
+
if (inStr) {
|
|
545
|
+
if (ch === '\\') { i++; continue }
|
|
546
|
+
if (ch === '"') inStr = false
|
|
547
|
+
continue
|
|
548
|
+
}
|
|
549
|
+
if (ch === '"') { inStr = true; continue }
|
|
550
|
+
if (ch === '{' || ch === '[') depth++
|
|
551
|
+
if (ch === '}' || ch === ']') {
|
|
552
|
+
depth--
|
|
553
|
+
if (depth === 1) lastComplete = i
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (lastComplete === -1 || depth <= 0) return value
|
|
557
|
+
const truncated = body.slice(0, lastComplete + 1)
|
|
558
|
+
let result = truncated
|
|
559
|
+
let d = 0
|
|
560
|
+
let inS = false
|
|
561
|
+
for (let i = 0; i < result.length; i++) {
|
|
562
|
+
const c = result[i]!
|
|
563
|
+
if (inS) { if (c === '\\') { i++; continue } if (c === '"') inS = false; continue }
|
|
564
|
+
if (c === '"') { inS = true; continue }
|
|
565
|
+
if (c === '{' || c === '[') d++
|
|
566
|
+
if (c === '}' || c === ']') d--
|
|
567
|
+
}
|
|
568
|
+
while (d > 0) { result += closer; d-- }
|
|
569
|
+
return result
|
|
570
|
+
}
|
|
571
|
+
|
|
422
572
|
// Repair: models love raw newlines inside string values, which JSON forbids.
|
|
423
573
|
const repair = (value: string): string => {
|
|
424
574
|
let out = ''
|
|
@@ -450,7 +600,7 @@ function parseJson<T>(text: string, wantArray: boolean): T {
|
|
|
450
600
|
}
|
|
451
601
|
|
|
452
602
|
for (const candidate of candidates) {
|
|
453
|
-
for (const attempt of [candidate, repair(candidate)]) {
|
|
603
|
+
for (const attempt of [candidate, repair(candidate), repairTruncated(candidate)]) {
|
|
454
604
|
try {
|
|
455
605
|
const value = JSON.parse(attempt) as unknown
|
|
456
606
|
if (!wantArray || Array.isArray(value)) return value as T
|
|
@@ -490,7 +640,7 @@ function parseJsonObject<T>(text: string): T {
|
|
|
490
640
|
/** System prompt for story-bible extraction. */
|
|
491
641
|
function bibleSystemPrompt(): string {
|
|
492
642
|
return [
|
|
493
|
-
'
|
|
643
|
+
'你是一位资深网文编辑兼设定架构师。你会收到一份小说大纲,请把它提炼成结构化的「道藏」,供后续写作时严格引用。',
|
|
494
644
|
'要求:',
|
|
495
645
|
'1. 忠于大纲,不自行发明大纲之外的设定。',
|
|
496
646
|
'2. 角色卡覆盖大纲明确出现的角色(主角必含),每个角色给出性格标签、目标、关键关系。',
|
|
@@ -519,7 +669,7 @@ export async function extractBible(ctx: Context, config: NovelConfig, outline: s
|
|
|
519
669
|
}
|
|
520
670
|
const facts = (project?.facts ?? []).slice(-40)
|
|
521
671
|
const user = [
|
|
522
|
-
|
|
672
|
+
`请为下面这部小说提炼道藏:\n\n${outline}`,
|
|
523
673
|
facts.length > 0
|
|
524
674
|
? `\n\n【已写章节事实(编年录)】用于确认真实角色姓名与已确立设定;忠于大纲,不要新增大纲外设定:\n${facts.map(f => `[第${f.chapterNo}章] ${f.text.slice(0, 100)}`).join('\n')}`
|
|
525
675
|
: '',
|
|
@@ -531,7 +681,7 @@ export async function extractBible(ctx: Context, config: NovelConfig, outline: s
|
|
|
531
681
|
system: bibleSystemPrompt(),
|
|
532
682
|
user,
|
|
533
683
|
temperature: 0.4,
|
|
534
|
-
maxTokens: Math.max(config.maxTokens,
|
|
684
|
+
maxTokens: Math.max(config.maxTokens, 8000),
|
|
535
685
|
reasoning: config.analysisReasoning ?? 'low',
|
|
536
686
|
})
|
|
537
687
|
const raw = parseJsonObject<{
|
|
@@ -593,13 +743,21 @@ function volumeSystemPrompt(): string {
|
|
|
593
743
|
'[{"no": 1, "title": "卷名", "summary": "卷定位与剧情重心", "chapterStart": 1, "chapterEnd": 80}]',
|
|
594
744
|
'重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
595
745
|
'重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。',
|
|
746
|
+
// 长度合同与 resolve 端的截断口径共用一份声明,避免 prompt 与解析漂移。
|
|
747
|
+
renderLengthContract([
|
|
748
|
+
['卷名', STRUCTURED_FIELD_LIMITS.chapterTitle],
|
|
749
|
+
['每卷定位', STRUCTURED_FIELD_LIMITS.chapterSummary],
|
|
750
|
+
]),
|
|
596
751
|
].join('\n')
|
|
597
752
|
}
|
|
598
753
|
|
|
599
754
|
/** Plan volumes from an outline. */
|
|
600
755
|
export async function planVolumes(ctx: Context, config: NovelConfig, outline: string): Promise<Volume[]> {
|
|
601
756
|
const user = `请为下面这部小说划分卷:\n\n${outline}`
|
|
602
|
-
|
|
757
|
+
// 卷数由大纲决定、此处未知,按「大纲暗示的规模」估算:约每 1200 字大纲对应一卷,
|
|
758
|
+
// 夹在 3-24 卷之间(与卷规划自身的 3-8 卷建议不冲突,只是给预算一个量级)。
|
|
759
|
+
const expectedVolumes = Math.min(24, Math.max(3, Math.round(outline.length / 1_200)))
|
|
760
|
+
const text = await complete(ctx, config, { system: volumeSystemPrompt(), user, temperature: 0.4, maxTokens: resolveOutputBudget('volume-strategy', { volumes: expectedVolumes }) })
|
|
603
761
|
const parsed = parseJsonArray<Record<string, unknown>>(text)
|
|
604
762
|
const volumes: Volume[] = []
|
|
605
763
|
for (let i = 0; i < parsed.length; i++) {
|
|
@@ -612,8 +770,8 @@ export async function planVolumes(ctx: Context, config: NovelConfig, outline: st
|
|
|
612
770
|
const end = typeof entry.chapterEnd === 'number' ? entry.chapterEnd : undefined
|
|
613
771
|
volumes.push({
|
|
614
772
|
no,
|
|
615
|
-
title: title.slice(0,
|
|
616
|
-
summary: summary.slice(0,
|
|
773
|
+
title: title.slice(0, STRUCTURED_FIELD_LIMITS.chapterTitle),
|
|
774
|
+
summary: summary.slice(0, STRUCTURED_FIELD_LIMITS.chapterSummary),
|
|
617
775
|
chapterStart: start ?? 1,
|
|
618
776
|
chapterEnd: end ?? 1,
|
|
619
777
|
})
|
|
@@ -637,7 +795,9 @@ function volumeOf(chapterNo: number, volumes: Volume[] | undefined): number {
|
|
|
637
795
|
function planSystemPrompt(volumes: Volume[] | undefined): string {
|
|
638
796
|
const volumeBlock = volumes !== undefined && volumes.length > 0
|
|
639
797
|
? ['\n全书分卷结构(规划章节时需落在对应卷内):']
|
|
640
|
-
.concat(volumes.map(v => `第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})`
|
|
798
|
+
.concat(volumes.map(v => `第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})` +
|
|
799
|
+
(v.strategy !== undefined && v.strategy !== '' ? `\n 卷战略:${v.strategy}` : '') +
|
|
800
|
+
(v.pacing !== undefined && v.pacing !== '' ? `\n 卷节奏板:${v.pacing}` : '')))
|
|
641
801
|
.join('\n')
|
|
642
802
|
: ''
|
|
643
803
|
return [
|
|
@@ -649,39 +809,53 @@ function planSystemPrompt(volumes: Volume[] | undefined): string {
|
|
|
649
809
|
'3. 严格遵循大纲的人设、金手指规则、战力体系与世界观设定,不得自行发明冲突设定。',
|
|
650
810
|
'4. 输出必须是合法的 JSON 数组,不要输出任何其他文字或 Markdown 代码块标记。',
|
|
651
811
|
'5. 数组每个元素格式:{"title": "章节标题(10字以内,有网文感)", "beats": "结构化剧情要点(150-250字,必须包含四段,段间用换行分隔):\\n本章目标:本章要完成的核心推进;\\n剧情要点:主要情节的起承转合(2-4 句);\\n爽点/钩子:本章的爽点兑现或情绪钩子;\\n结尾钩子:本章结尾为下一章埋下的悬念"}',
|
|
812
|
+
'6. 每个章节对象可额外包含以下可选字段(尽量给出,缺失则跳过):tension(0-100 整数:本章张力目标,对抗/揭示/抉择/代价的强度;铺垫与日常章明显偏低,与相邻章节形成起伏)、mustAdvance(数组,本章必须推进的局面/关系/信息/风险/决策变化);mustPreserve(数组,本章必须保持不破坏的项);characterHardFacts(数组,本章不可违背的人物硬事实:身份/阵营/境界/当前位置/知情度);endingHook(字符串,本章结尾钩子要求);obligation(字符串,本章义务合约一句话)。',
|
|
652
813
|
'重要:beats 字段内部必须使用 \\n 转义表示换行(JSON 字符串内不得有真实换行符),其余字符串值也不得包含真实换行符,JSON 必须在一段内完整结束。',
|
|
653
814
|
'重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。',
|
|
654
815
|
volumeBlock,
|
|
655
816
|
].join('\n')
|
|
656
817
|
}
|
|
657
818
|
|
|
658
|
-
/** Build the writing system prompt (bible + outline + active foreshadows).
|
|
659
|
-
|
|
819
|
+
/** Build the writing system prompt (bible + outline + active foreshadows).
|
|
820
|
+
* `targetChars` 来自每章计划(规划时快照,= 设置的每章目标字数);无则退回默认 3500。
|
|
821
|
+
* 字数区间按目标动态生成(±15%,取整到百位),避免系统提示词与设置互相冲突。
|
|
822
|
+
* `lengthRule` 可覆盖第 1 条字数要求(整章修订/改编时按原文长度为准)。 */
|
|
823
|
+
function writeSystemPrompt(project: ProjectState, targetChars?: number, lengthRule?: string, beforeChapter?: number): string {
|
|
660
824
|
const bible = project.bible
|
|
661
825
|
const sections: string[] = []
|
|
662
826
|
if (bible !== undefined) {
|
|
663
|
-
sections.push('====================
|
|
827
|
+
sections.push('==================== 道藏(写作时严格遵守) ====================')
|
|
664
828
|
if (bible.genre !== '') sections.push(`题材基调:${bible.genre}`)
|
|
665
829
|
if (bible.worldRules.length > 0) sections.push('世界规则:\n' + bible.worldRules.map(r => `- ${r}`).join('\n'))
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
830
|
+
// 角色上下文:角色库(主表)与道藏角色卡合并去重,避免两份重复注入、互相打架。
|
|
831
|
+
const roleLib = project.roles ?? []
|
|
832
|
+
const labelName = { protagonist: '主角', female_lead: '女主', female_support: '女配', support: '配角', antagonist: '反派', extra: '路人' }
|
|
833
|
+
const seenRole = new Set<string>()
|
|
834
|
+
const mergedRoles: string[] = []
|
|
835
|
+
for (const r of roleLib) {
|
|
836
|
+
seenRole.add(r.name)
|
|
837
|
+
const card = bible.characters.find(c => c.name === r.name)
|
|
838
|
+
const traits = card !== undefined ? card.traits : (Array.isArray(r.traits) ? r.traits : [])
|
|
839
|
+
const goals = card !== undefined && card.goals !== '' ? card.goals : r.goals
|
|
840
|
+
const relations = card !== undefined && card.relations !== '' ? card.relations : (Array.isArray(r.relations) && r.relations.length > 0 ? r.relations.join('、') : '')
|
|
841
|
+
mergedRoles.push(`- ${r.name}(${labelName[r.roleLabel]}):${r.identity}${traits.length > 0 ? `;性格:${traits.join('、')}` : ''}${goals !== '' ? `;目标:${goals}` : ''}${relations !== '' ? `;关系:${relations}` : ''}`)
|
|
842
|
+
if (card !== undefined && Array.isArray(card.knowledge) && card.knowledge.length > 0) {
|
|
843
|
+
mergedRoles.push(` 已知信息(该角色知道的:${card.knowledge.join(';')};未列出的信息该角色一律不知道,不得写其知晓或提及)`)
|
|
674
844
|
}
|
|
675
845
|
}
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
846
|
+
for (const card of bible.characters) {
|
|
847
|
+
if (seenRole.has(card.name)) continue
|
|
848
|
+
seenRole.add(card.name)
|
|
849
|
+
const roleName = { protagonist: '主角', supporting: '配角', antagonist: '反派', other: '其他' }[card.role]
|
|
850
|
+
mergedRoles.push(`- ${card.name}(${roleName}):${card.traits.join('、')}${card.goals !== '' ? `;目标:${card.goals}` : ''}${card.relations !== '' ? `;关系:${card.relations}` : ''}`)
|
|
851
|
+
if (Array.isArray(card.knowledge) && card.knowledge.length > 0) {
|
|
852
|
+
mergedRoles.push(` 已知信息(该角色知道的:${card.knowledge.join(';')};未列出的信息该角色一律不知道,不得写其知晓或提及)`)
|
|
683
853
|
}
|
|
684
854
|
}
|
|
855
|
+
if (mergedRoles.length > 0) {
|
|
856
|
+
sections.push('角色卡(角色库与道藏已合并去重):')
|
|
857
|
+
sections.push(...mergedRoles)
|
|
858
|
+
}
|
|
685
859
|
if (bible.redLines.length > 0) sections.push('写作红线(违反即失败):\n' + bible.redLines.map(r => `- ${r}`).join('\n'))
|
|
686
860
|
if (bible.style.length > 0) sections.push('风格要求:\n' + bible.style.map(r => `- ${r}`).join('\n'))
|
|
687
861
|
}
|
|
@@ -713,9 +887,12 @@ function writeSystemPrompt(project: ProjectState): string {
|
|
|
713
887
|
}
|
|
714
888
|
sections.push('')
|
|
715
889
|
sections.push('写作硬性要求:')
|
|
716
|
-
|
|
890
|
+
const target = targetChars !== undefined && targetChars > 0 ? targetChars : 3500
|
|
891
|
+
const lo = Math.max(1000, Math.round((target * 0.85) / 100) * 100)
|
|
892
|
+
const hi = Math.max(lo + 100, Math.round((target * 1.15) / 100) * 100)
|
|
893
|
+
sections.push(lengthRule ?? `1. 每章 ${lo}-${hi} 字(目标 ${target} 字,按中文字符计),只输出章节正文,不要输出标题、章回名、作者的话或任何 Markdown 标记。`)
|
|
717
894
|
sections.push('2. 以主角视角展开,动作、对话、心理描写交替推进,禁止大段设定说明。')
|
|
718
|
-
sections.push('3.
|
|
895
|
+
sections.push('3. 尊重大纲与道藏:人设不崩、金手指规则不自相矛盾、战力不随意膨胀。')
|
|
719
896
|
sections.push('4. 章末留一个钩子(悬念、反转或新线索),吸引读者读下一章。')
|
|
720
897
|
sections.push('5. 语言流畅自然,符合中文网文语感,避免翻译腔与病句。')
|
|
721
898
|
sections.push('6. 对话与冲突密度:每章至少 1 处实质对话或正面对抗/交锋场面;推理与心理活动必须用动作、环境细节、微表情、对话呈现,禁止整章纯内心独白铺陈(禁止"解说式"交代线索)。')
|
|
@@ -726,6 +903,26 @@ function writeSystemPrompt(project: ProjectState): string {
|
|
|
726
903
|
sections.push('==================== 内容合规红线(平台硬性要求,最高优先级,违反即失败) ====================')
|
|
727
904
|
sections.push(COMPLIANCE_REDLINES.join('\n'))
|
|
728
905
|
sections.push('以上九条为硬性底线,任何情况下不得以任何形式出现或影射;若剧情确需涉及(如批判、反讽),只能以明确否定、揭露、批判的立场呈现,且不得展开细节。')
|
|
906
|
+
sections.push(renderChapterWriterSkeleton({
|
|
907
|
+
targetChars: target,
|
|
908
|
+
minChars: lo,
|
|
909
|
+
maxChars: hi,
|
|
910
|
+
pov: '第三人称有限视角,严格跟随主角所见所知。',
|
|
911
|
+
endingHookPreference: '章末留一个明确钩子(新信息、新风险或未闭合选择)。',
|
|
912
|
+
tonePreference: '动作、对话、心理交替推进;重大信息用对话/动作/发现呈现。',
|
|
913
|
+
antiAiRules: '严格遵循上方「反 AI 规则」与写法资产,避免套话与 AI 腔。',
|
|
914
|
+
}))
|
|
915
|
+
// 故事时间线锚点:只注入本章之前的事件(回写旧章时不被后续章节剧透)。
|
|
916
|
+
const timelineBlock = renderTimelineBlock(project, beforeChapter ?? Number.MAX_SAFE_INTEGER)
|
|
917
|
+
if (timelineBlock !== '') sections.push(timelineBlock)
|
|
918
|
+
// 本章张力(目标值 + 参考曲线位置):写作时把握松紧。
|
|
919
|
+
if (beforeChapter !== undefined) {
|
|
920
|
+
const tensionBlock = renderTensionBlock(project, beforeChapter)
|
|
921
|
+
if (tensionBlock !== '') sections.push(tensionBlock)
|
|
922
|
+
}
|
|
923
|
+
// 作者自定义槽位(只影响表达方式,不覆盖道藏/红线/合规)。
|
|
924
|
+
const slotBlock = renderPromptSlots(project)
|
|
925
|
+
if (slotBlock !== '') sections.push(slotBlock)
|
|
729
926
|
return sections.join('\n')
|
|
730
927
|
}
|
|
731
928
|
|
|
@@ -745,8 +942,14 @@ export async function planChapters(
|
|
|
745
942
|
const startNo = existing.length === 0 ? 1 : Math.max(...existing.map(c => c.no)) + 1
|
|
746
943
|
const NL = String.fromCharCode(10)
|
|
747
944
|
const continuation = existing.length > 0
|
|
748
|
-
|
|
749
|
-
|
|
945
|
+
// 截断显式化:注入给模型的编年录必须自带条数声明,否则模型会把"注入的 15 条"
|
|
946
|
+
// 当成"全部事实",从而在续写规划时漏掉更早的设定锚点。
|
|
947
|
+
const allFacts = Array.isArray(project.facts) ? project.facts : []
|
|
948
|
+
const latestFacts = continuation && allFacts.length > 0
|
|
949
|
+
? (allFacts.length > 15
|
|
950
|
+
? `(本书编年录共 ${allFacts.length} 条,此处只注入最近 15 条,较早 ${allFacts.length - 15} 条未注入)\n`
|
|
951
|
+
: '')
|
|
952
|
+
+ allFacts.slice(-15).map(f => `[第${f.chapterNo}章] ${f.text.slice(0, 150)}`).join('\n')
|
|
750
953
|
: ''
|
|
751
954
|
// 上一章(已写章节中章号最大者)结尾原文,作为续写剧情起点。
|
|
752
955
|
let prevTail = ''
|
|
@@ -762,14 +965,24 @@ export async function planChapters(
|
|
|
762
965
|
}
|
|
763
966
|
// 续写模式大纲注入:精简大纲(≤2 万字)直接全量,避免误伤「分卷收官规划」(如第五卷双结局);
|
|
764
967
|
// 超大 docx 大纲才节选——截到「记忆梗」之前,保留分卷大纲与红线,去掉后续冗余。
|
|
968
|
+
// 续写模式大纲注入:精简大纲(≤2 万字)直接全量;超大大纲按优先级裁剪——
|
|
969
|
+
// 优先保留当前卷/红线/道藏规则,不绑定任何特定书的章节标题。
|
|
765
970
|
const outlineBlock = continuation
|
|
766
971
|
? (() => {
|
|
767
972
|
if (project.outline.length <= 20000) return project.outline
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
973
|
+
// 找最后一个卷标记(支持「第X卷」「卷X」),截到该位置
|
|
974
|
+
const volMarkers = ['第' + (volume?.no ?? '') + '卷', '卷' + (volume?.no ?? '')]
|
|
975
|
+
let cut = -1
|
|
976
|
+
for (const marker of volMarkers) {
|
|
977
|
+
const idx = project.outline.lastIndexOf(marker)
|
|
978
|
+
if (idx > cut) cut = idx
|
|
979
|
+
}
|
|
980
|
+
// 没找到卷标记时,截取前 15000 字(保留足够设定,去掉末尾冗余)
|
|
981
|
+
if (cut < 2000) cut = Math.min(15000, project.outline.length)
|
|
982
|
+
return project.outline.slice(0, cut).trimEnd() + '\n…(大纲过长,已保留当前卷及之前内容,后续从略)'
|
|
771
983
|
})()
|
|
772
984
|
: project.outline
|
|
985
|
+
|
|
773
986
|
const user = [
|
|
774
987
|
'请为下面这部小说规划章节。',
|
|
775
988
|
volume !== undefined
|
|
@@ -777,8 +990,54 @@ export async function planChapters(
|
|
|
777
990
|
: continuation
|
|
778
991
|
? `本书已有 ${existing.length} 章已规划/已写作(见下方「已有章节」)。请规划**后续**章节:从第 ${startNo} 章开始。`
|
|
779
992
|
: '请规划全书开篇章节。',
|
|
993
|
+
(() => {
|
|
994
|
+
const c = project.bookContract
|
|
995
|
+
const primary = project.assets?.primaryProgression
|
|
996
|
+
const aux = project.assets?.auxiliaryProgressions ?? []
|
|
997
|
+
const genre = project.assets?.genre
|
|
998
|
+
const parts: string[] = []
|
|
999
|
+
if (c?.promise !== undefined && c.promise !== '') parts.push(`书籍承诺:${c.promise}`)
|
|
1000
|
+
if ((c?.primaryModeName ?? primary?.name) !== undefined) parts.push(`主推进模式:${c?.primaryModeName ?? primary!.name}`)
|
|
1001
|
+
if (aux.length > 0) parts.push(`辅助推进模式:${aux.map(a => a.name).join('、')}`)
|
|
1002
|
+
if ((c?.tone ?? genre?.template) !== undefined) parts.push(`文风基调:${c?.tone ?? genre!.template}`)
|
|
1003
|
+
if (c?.targetPlatform !== undefined && c.targetPlatform !== '') parts.push(`目标平台:${c.targetPlatform}`)
|
|
1004
|
+
return parts.length > 0 ? '【开书定盘】(章节规划须符合此定位)\n' + parts.join('\n') : ''
|
|
1005
|
+
})(),
|
|
780
1006
|
continuation
|
|
781
|
-
?
|
|
1007
|
+
? (() => {
|
|
1008
|
+
// 从最近章节摘要动态生成「已发生事件禁令」,不绑定任何特定书
|
|
1009
|
+
const recentChapters = existing.slice(-20)
|
|
1010
|
+
const eventLines = recentChapters
|
|
1011
|
+
.filter(c => c.summary !== undefined && c.summary.trim() !== '')
|
|
1012
|
+
.map(c => '第' + c.no + '章《' + c.title + '》:' + c.summary!.slice(0, 80))
|
|
1013
|
+
// 截断显式化:只列最近 20 章时说明全书规模,避免模型据此认为"前面没有内容"。
|
|
1014
|
+
const scopeNote = existing.length > recentChapters.length
|
|
1015
|
+
? `(全书已规划 ${existing.length} 章,此处只列最近 ${recentChapters.length} 章)`
|
|
1016
|
+
: ''
|
|
1017
|
+
const eventsText = eventLines.length > 0
|
|
1018
|
+
? '以下情节已在已有章节中发生过(最近 ' + eventLines.length + ' 章摘要)' + scopeNote + ',后续章节**绝对不得重写或重复**:\n' + eventLines.join('\n')
|
|
1019
|
+
: '已有章节的剧情不得重写或重复(无章节摘要时以编年录为准)。'
|
|
1020
|
+
// 位置感知:把「全书总章数 / 当前所处卷 / 距收尾还有多远」算清楚直接喂给模型。
|
|
1021
|
+
// 原先只模糊地说「接近全书规划总章数」,模型看到近期剧情是高光就自己收尾了
|
|
1022
|
+
// (实测:600 章规划的书在第 81-100 章被规划成大结局)。
|
|
1023
|
+
const vols = (project.volumes ?? []).slice().sort((a, b) => a.no - b.no)
|
|
1024
|
+
const totalCh = vols.length > 0 ? Math.max(...vols.map(v => v.chapterEnd ?? 0)) : 0
|
|
1025
|
+
const curVol = vols.find(v => startNo >= (v.chapterStart ?? 0) && startNo <= (v.chapterEnd ?? 0))
|
|
1026
|
+
const lastVol = vols[vols.length - 1]
|
|
1027
|
+
const remaining = totalCh > 0 ? totalCh - startNo + 1 : 0
|
|
1028
|
+
const inEndgame = totalCh > 0 && lastVol !== undefined &&
|
|
1029
|
+
startNo >= (lastVol.chapterStart ?? 0) + Math.floor(((lastVol.chapterEnd ?? 0) - (lastVol.chapterStart ?? 0)) * 0.8)
|
|
1030
|
+
const posText = totalCh > 0 && curVol !== undefined
|
|
1031
|
+
? '【全书体量与当前进度(硬约束,优先级高于一切"感觉像结尾"的判断)】' + NL
|
|
1032
|
+
+ `全书规划共 ${totalCh} 章、${vols.length} 卷(` + vols.map(v => `第${v.no}卷《${v.title}》${v.chapterStart}-${v.chapterEnd}章`).join(';') + ')。' + NL
|
|
1033
|
+
+ `本次从第 ${startNo} 章开始规划,目前处于第${curVol.no}卷《${curVol.title}》(第${curVol.chapterStart}-${curVol.chapterEnd}章)。` + NL
|
|
1034
|
+
+ `距全书收尾尚余约 ${remaining} 章。` + NL
|
|
1035
|
+
+ (inEndgame
|
|
1036
|
+
? '当前已进入最后一卷末段,可以按大纲推进大结局。'
|
|
1037
|
+
: `**当前处于全书前段,距离收尾还有约 ${remaining} 章——本次规划绝对禁止出现大结局、系统消失、真相揭开后完结、主角退隐、"全剧终"式收束。** 必须按第${curVol.no}卷的卷战略正常推进剧情,只完成本卷内的一段推进,并为后续留下足够的上升空间。近期章节即使处于高潮(打斗/揭秘/对抗),也只是本卷的中段高潮,不是全书高潮。`)
|
|
1038
|
+
: ''
|
|
1039
|
+
return '【续写硬性要求】已有章节的剧情不得重写或重复,章节标题也不得与已有章节重复。' + NL + eventsText + NL + posText
|
|
1040
|
+
})()
|
|
782
1041
|
: '',
|
|
783
1042
|
prevTail !== ''
|
|
784
1043
|
? `【上一章(第 ${startNo - 1} 章)结尾原文】第 ${startNo} 章必须紧接此状态继续,从新的事件写起,不得回顾重述:\n${prevTail}`
|
|
@@ -800,11 +1059,16 @@ export async function planChapters(
|
|
|
800
1059
|
const system = planSystemPrompt(project.volumes) + (continuation
|
|
801
1060
|
? '\n重要:本次是**续写规划**——已有章节的剧情不得重写或重复,新章节标题不得与已有章节标题相同,新章节的剧情必须从上一章结尾自然接续(人物状态、时间线、地点衔接一致)。'
|
|
802
1061
|
: '')
|
|
803
|
-
const
|
|
804
|
-
|
|
1062
|
+
const parsed = (await completeJsonArray(
|
|
1063
|
+
ctx, config,
|
|
1064
|
+
{ system, user, temperature: 0.7, maxTokens: resolveOutputBudget('chapter-list', { chapters: chapterCount }), liveLabel: '章节规划' },
|
|
1065
|
+
t => parseJsonArray<Record<string, unknown>>(t),
|
|
1066
|
+
)) as Record<string, unknown>[]
|
|
805
1067
|
const chapters: ChapterPlan[] = []
|
|
806
1068
|
const existingNos = new Set(existing.map(c => c.no))
|
|
807
1069
|
const existingTitles = new Set(existing.map(c => c.title))
|
|
1070
|
+
const strArr = (v: unknown): string[] => Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '') : []
|
|
1071
|
+
const str = (v: unknown): string => typeof v === 'string' ? v.trim() : ''
|
|
808
1072
|
let cursor = startNo
|
|
809
1073
|
for (const item of parsed) {
|
|
810
1074
|
if (chapters.length >= chapterCount) break
|
|
@@ -817,6 +1081,19 @@ export async function planChapters(
|
|
|
817
1081
|
if (title !== '' && existingTitles.has(title)) continue
|
|
818
1082
|
while (existingNos.has(cursor)) cursor++
|
|
819
1083
|
const no = cursor++
|
|
1084
|
+
const pd: ChapterPlan['payoffDirectives'] = Array.isArray(entry.payoffDirectives)
|
|
1085
|
+
? entry.payoffDirectives
|
|
1086
|
+
.filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
|
|
1087
|
+
.map(p => ({
|
|
1088
|
+
no: typeof p.no === 'number' ? p.no : undefined,
|
|
1089
|
+
operation: (['seed', 'touch', 'pressure', 'partial_reveal', 'payoff', 'forbid'] as const).includes(p.operation as never)
|
|
1090
|
+
? p.operation as 'seed' | 'touch' | 'pressure' | 'partial_reveal' | 'payoff' | 'forbid'
|
|
1091
|
+
: undefined,
|
|
1092
|
+
text: str(p.text).slice(0, 120),
|
|
1093
|
+
}))
|
|
1094
|
+
.filter(p => p.text !== '' || p.operation !== undefined)
|
|
1095
|
+
.slice(0, 4)
|
|
1096
|
+
: undefined
|
|
820
1097
|
chapters.push({
|
|
821
1098
|
no,
|
|
822
1099
|
volume: volumeOf(no, project.volumes),
|
|
@@ -824,6 +1101,15 @@ export async function planChapters(
|
|
|
824
1101
|
beats,
|
|
825
1102
|
targetChars: config.chapterChars,
|
|
826
1103
|
status: 'pending',
|
|
1104
|
+
tension: typeof entry.tension === 'number' && Number.isFinite(entry.tension)
|
|
1105
|
+
? Math.max(0, Math.min(100, Math.round(entry.tension)))
|
|
1106
|
+
: undefined,
|
|
1107
|
+
mustAdvance: strArr(entry.mustAdvance).slice(0, 4),
|
|
1108
|
+
mustPreserve: strArr(entry.mustPreserve).slice(0, 4),
|
|
1109
|
+
characterHardFacts: strArr(entry.characterHardFacts).slice(0, 6),
|
|
1110
|
+
payoffDirectives: pd,
|
|
1111
|
+
endingHook: str(entry.endingHook).slice(0, 120) || undefined,
|
|
1112
|
+
obligation: str(entry.obligation).slice(0, 200) || undefined,
|
|
827
1113
|
})
|
|
828
1114
|
}
|
|
829
1115
|
if (chapters.length === 0) {
|
|
@@ -832,32 +1118,58 @@ export async function planChapters(
|
|
|
832
1118
|
return chapters
|
|
833
1119
|
}
|
|
834
1120
|
|
|
1121
|
+
/** 书内知识库检索:按查询词匹配文档标题/内容,返回相关片段(供生成/规划注入)。 */
|
|
1122
|
+
function retrieveKnowledge(project: ProjectState, query: string, topN = 3): string {
|
|
1123
|
+
const docs = project.knowledgeDocs ?? []
|
|
1124
|
+
if (docs.length === 0) return ''
|
|
1125
|
+
const terms = (query ?? '').split(/[\s,,。;;、/]+/).map(t => t.trim()).filter(t => t.length >= 2)
|
|
1126
|
+
if (terms.length === 0) return ''
|
|
1127
|
+
const scored = docs.map(d => {
|
|
1128
|
+
let score = 0
|
|
1129
|
+
for (const t of terms) {
|
|
1130
|
+
if (d.title.includes(t)) score += 4
|
|
1131
|
+
if (d.content.includes(t)) score += 1
|
|
1132
|
+
}
|
|
1133
|
+
return { d, score }
|
|
1134
|
+
})
|
|
1135
|
+
const hits = scored.filter(x => x.score > 0).sort((a, b) => b.score - a.score)
|
|
1136
|
+
const top = hits.slice(0, topN)
|
|
1137
|
+
if (top.length === 0) return ''
|
|
1138
|
+
// 截断显式化:命中多于注入条数时说明总量,避免模型以为知识库只有这几条。
|
|
1139
|
+
const scopeNote = hits.length > top.length ? `(命中 ${hits.length} 条,此处只注入最相关的 ${top.length} 条)` : ''
|
|
1140
|
+
return '【书内知识库参考(与本章相关,写作时须遵守/可引用)】' + scopeNote + '\n'
|
|
1141
|
+
+ top.map(x => `- 《${x.d.title}》:${x.d.content.slice(0, 300)}`).join('\n')
|
|
1142
|
+
}
|
|
1143
|
+
|
|
835
1144
|
// ------------------------------------------------------------------ writing
|
|
836
1145
|
|
|
837
1146
|
/** The review system prompt. */
|
|
838
1147
|
function reviewSystemPrompt(project: ProjectState): string {
|
|
839
1148
|
const bible = project.bible
|
|
840
1149
|
const sections: string[] = [
|
|
841
|
-
'
|
|
1150
|
+
'你是一位严格的网文审稿编辑。你会收到一章正文以及本书的道藏与红线。',
|
|
842
1151
|
'请从以下维度审查本章:',
|
|
843
|
-
'1.
|
|
844
|
-
'2.
|
|
845
|
-
'3.
|
|
1152
|
+
'1. 人设一致性:角色行为是否符合下方角色卡的设定(性格/目标/知情度/说话方式)。',
|
|
1153
|
+
'2. 设定一致性:金手指规则、战力体系、世界观是否与道藏冲突。',
|
|
1154
|
+
'3. 红线检查:是否触犯下方「本书红线」与「内容合规红线」。',
|
|
846
1155
|
'4. 文笔质量:语病、翻译腔、AI 套话("不禁""仿佛""一时间"等高频词滥用)、流水账。',
|
|
847
1156
|
'5. 节奏与爽点:本章是否有推进、有钩子,是否拖沓灌水。',
|
|
848
1157
|
'6. 逻辑漏洞:前后矛盾、时间线错误、对话失真。',
|
|
849
|
-
'7. 反 AI 规则:逐条核对下方「反 AI
|
|
1158
|
+
'7. 反 AI 规则:逐条核对下方「反 AI 规则」清单——禁止类命中即列为问题,鼓励类只作低优先级建议、不阻塞通过。',
|
|
850
1159
|
'8. 呈现方式:整章是否纯内心推理铺陈(无对话/无对抗,推理全靠解说);反派是否纯背景板无行动;重要配角是否无名标签化(瘦高个/灰衣人全程代称)——命中即列为问题。',
|
|
851
1160
|
'9. 内容合规(最高优先级):逐条核对下方「内容合规红线」,任何一条命中(含影射、暗示、详细描写)必须列为 high,并给出改写建议。',
|
|
852
1161
|
'输出必须是合法 JSON 对象,不要输出任何其他文字:',
|
|
853
|
-
'{"score": 0-100的整数, "verdict": "一句话总评", "issues": [{"severity": "high|medium|low", "item": "问题描述", "suggestion": "修改建议"}]}',
|
|
1162
|
+
'{"score": 0-100的整数, "riskScore": 0-100的整数(越高越需人工处理,可结合本地AI味指数), "tension": 0-100的整数(本章张力强度:对抗/揭示/抉择/代价;铺垫与日常章应明显偏低), "verdict": "一句话总评", "issues": [{"severity": "high|medium|low", "dimension": "character|setting|redline|writing|pacing|logic|anti-ai|presentation|compliance", "item": "问题描述", "suggestion": "修改建议", "ruleName": "命中的反AI规则名(见反AI规则清单)", "ruleType": "forbidden|risk|encourage", "category": "套话|句式|段落|心理|设定|节奏|对话|其他", "excerpt": "命中的原文摘录(不超过50字)", "reason": "判定理由", "canAutoRewrite": true|false}]}',
|
|
1163
|
+
'tension 是本章张力强度评分(不是质量分):对决/揭秘/生死抉择偏高,铺垫章、日常章、过渡章偏低;同一本书内保持同一尺度。',
|
|
1164
|
+
'反 AI 类 issue 尽量给出 ruleName/ruleType/category/excerpt/reason/canAutoRewrite,便于统计与自动改写。',
|
|
1165
|
+
'AI 套话高频模板词示例(集中出现必须整体降密度):仿佛、似乎、极其、完美、深不见底、形成了、莫名、无法形容、难以言喻、精心雕琢、肤光胜雪、眉目如画、歌舞升平、觥筹交错、妙语连珠、不可名状、另一层真相、命运、真相。',
|
|
854
1166
|
'重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
855
1167
|
'重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。',
|
|
856
1168
|
]
|
|
857
1169
|
const assetsBlock = renderAllAssets(project.assets)
|
|
858
1170
|
if (assetsBlock !== '') sections.push('\n' + assetsBlock)
|
|
859
1171
|
if (bible !== undefined) {
|
|
860
|
-
sections.push('\n====================
|
|
1172
|
+
sections.push('\n==================== 道藏 ====================')
|
|
861
1173
|
if (bible.worldRules.length > 0) sections.push('世界规则:\n' + bible.worldRules.map(r => `- ${r}`).join('\n'))
|
|
862
1174
|
if (bible.characters.length > 0) {
|
|
863
1175
|
sections.push('角色卡:')
|
|
@@ -885,17 +1197,26 @@ export async function reviewChapter(
|
|
|
885
1197
|
chapterNo: number,
|
|
886
1198
|
): Promise<ReviewReport> {
|
|
887
1199
|
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
888
|
-
if (chapter === undefined) throw new
|
|
1200
|
+
if (chapter === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 不在计划中`)
|
|
889
1201
|
const body = readChapterFile(outputDir, chapter)
|
|
890
|
-
if (body === undefined) throw new
|
|
1202
|
+
if (body === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 的正文文件不存在`)
|
|
1203
|
+
const bodyText = body.replace(/^#\s+.*$/m, '').trim()
|
|
1204
|
+
// 本地 AI 味扫描(事实锚点,让 LLM 复核判断而非逐字统计)
|
|
1205
|
+
const aiScan = scanAiFlavor(bodyText)
|
|
1206
|
+
// 跨章上下文:上一章结尾 + 最近/相关事实 + 活跃剧情线/伏笔(审稿不再只看本章内部)
|
|
1207
|
+
const chapterCtx = buildChapterContext(project, chapter, outputDir, { stage: 'review' })
|
|
1208
|
+
const blocks = renderContextBlocks(chapterCtx)
|
|
1209
|
+
const crossChapter = [blocks.continuityBlock, blocks.factsBlock, blocks.plotlinesBlock, blocks.foreshadowsBlock].filter(b => b !== '').join('\n')
|
|
891
1210
|
const user = [
|
|
892
1211
|
`本章标题:《${chapter.title}》`,
|
|
893
1212
|
`本章剧情要点:${chapter.beats}`,
|
|
1213
|
+
`==================== 本地 AI 味扫描(事实锚点,你只需复核判断,不必再逐字统计) ====================\n${aiScan.summary}`,
|
|
1214
|
+
crossChapter,
|
|
894
1215
|
'==================== 章节正文 ====================',
|
|
895
|
-
|
|
896
|
-
].join('\n')
|
|
897
|
-
const text = await complete(ctx, config, { system: reviewSystemPrompt(project), user, temperature: 0.3, maxTokens: Math.max(config.maxTokens,
|
|
898
|
-
const raw = parseJsonObject<{ score?: unknown; verdict?: unknown; issues?: unknown }>(text)
|
|
1216
|
+
bodyText,
|
|
1217
|
+
].filter(line => line !== '').join('\n')
|
|
1218
|
+
const text = await complete(ctx, config, { system: reviewSystemPrompt(project), user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 8000), reasoning: config.analysisReasoning ?? 'low', model: config.reviewModel, liveLabel: '审稿' })
|
|
1219
|
+
const raw = parseJsonObject<{ score?: unknown; riskScore?: unknown; tension?: unknown; verdict?: unknown; issues?: unknown; resolvedIds?: unknown; unresolvedIds?: unknown }>(text)
|
|
899
1220
|
const issues = Array.isArray(raw.issues)
|
|
900
1221
|
? raw.issues
|
|
901
1222
|
.filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
|
|
@@ -903,19 +1224,39 @@ export async function reviewChapter(
|
|
|
903
1224
|
severity: (['high', 'medium', 'low'] as const).includes(entry.severity as never)
|
|
904
1225
|
? entry.severity as 'high' | 'medium' | 'low'
|
|
905
1226
|
: 'medium',
|
|
1227
|
+
dimension: (typeof entry.dimension === 'string' && REVIEW_DIMENSION_IDS.has(entry.dimension))
|
|
1228
|
+
? entry.dimension as ReviewDimension
|
|
1229
|
+
: undefined,
|
|
906
1230
|
item: typeof entry.item === 'string' ? entry.item : '',
|
|
907
1231
|
suggestion: typeof entry.suggestion === 'string' ? entry.suggestion : '',
|
|
1232
|
+
ruleName: typeof entry.ruleName === 'string' ? entry.ruleName : undefined,
|
|
1233
|
+
ruleType: (['forbidden', 'risk', 'encourage'] as const).includes(entry.ruleType as never)
|
|
1234
|
+
? entry.ruleType as 'forbidden' | 'risk' | 'encourage'
|
|
1235
|
+
: undefined,
|
|
1236
|
+
category: typeof entry.category === 'string' ? entry.category : undefined,
|
|
1237
|
+
excerpt: typeof entry.excerpt === 'string' ? entry.excerpt.slice(0, 80) : undefined,
|
|
1238
|
+
reason: typeof entry.reason === 'string' ? entry.reason.slice(0, 200) : undefined,
|
|
1239
|
+
canAutoRewrite: typeof entry.canAutoRewrite === 'boolean' ? entry.canAutoRewrite : undefined,
|
|
908
1240
|
}))
|
|
909
1241
|
.filter(issue => issue.item !== '')
|
|
910
1242
|
: []
|
|
911
1243
|
const score = typeof raw.score === 'number' ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60
|
|
912
|
-
|
|
913
|
-
|
|
1244
|
+
const riskScore = typeof raw.riskScore === 'number' ? Math.max(0, Math.min(100, Math.round(raw.riskScore))) : undefined
|
|
1245
|
+
// 通过条件:有 high 必须 ≥ reviewPassScore;无 high 可降 5 分但最低 65(避免低分稿直接放行)
|
|
1246
|
+
const hasHigh = issues.some(i => i.severity === 'high')
|
|
1247
|
+
const softThreshold = Math.max(65, config.reviewPassScore - 5)
|
|
1248
|
+
const passed = hasHigh ? score >= config.reviewPassScore : score >= softThreshold
|
|
914
1249
|
const report: ReviewReport = {
|
|
915
1250
|
score,
|
|
916
1251
|
passed,
|
|
1252
|
+
tension: typeof raw.tension === 'number' && Number.isFinite(raw.tension)
|
|
1253
|
+
? Math.max(0, Math.min(100, Math.round(raw.tension)))
|
|
1254
|
+
: undefined,
|
|
917
1255
|
verdict: typeof raw.verdict === 'string' ? raw.verdict.slice(0, 200) : '',
|
|
918
1256
|
issues,
|
|
1257
|
+
riskScore,
|
|
1258
|
+
aiFlavor: aiScan.aiScore,
|
|
1259
|
+
aiPhrases: aiScan.clicheHits.slice(0, 8),
|
|
919
1260
|
reviewedAt: new Date().toISOString(),
|
|
920
1261
|
}
|
|
921
1262
|
chapter.review = report
|
|
@@ -936,6 +1277,8 @@ export async function reviewChapterText(
|
|
|
936
1277
|
text: string,
|
|
937
1278
|
previousReport?: ReviewReport,
|
|
938
1279
|
): Promise<ReviewReport> {
|
|
1280
|
+
const bodyText = text.slice(0, 20000)
|
|
1281
|
+
const aiScan = scanAiFlavor(bodyText)
|
|
939
1282
|
const user = [
|
|
940
1283
|
`书名:《${project.bookName}》`,
|
|
941
1284
|
previousReport !== undefined
|
|
@@ -945,12 +1288,13 @@ export async function reviewChapterText(
|
|
|
945
1288
|
previousReport !== undefined
|
|
946
1289
|
? '==================== 修订稿(上一轮审稿后按意见修订的正文) ===================='
|
|
947
1290
|
: '==================== 待审查正文 ====================',
|
|
948
|
-
|
|
1291
|
+
`==================== 本地 AI 味扫描(事实锚点,你只需复核判断,不必再逐字统计) ====================\n${aiScan.summary}`,
|
|
1292
|
+
bodyText,
|
|
949
1293
|
].join('\n')
|
|
950
1294
|
// 验证模式:携带上一轮报告时,逐条核对原意见是否解决 + 只挑新增 high,不再全新找茬。
|
|
951
1295
|
const system = previousReport !== undefined ? verifySystemPrompt(project) : reviewSystemPrompt(project)
|
|
952
|
-
const raw = parseJsonObject<{ score?: unknown; verdict?: unknown; issues?: unknown }>(
|
|
953
|
-
await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens,
|
|
1296
|
+
const raw = parseJsonObject<{ score?: unknown; riskScore?: unknown; verdict?: unknown; issues?: unknown; resolvedIds?: unknown; unresolvedIds?: unknown }>(
|
|
1297
|
+
await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 8000) }),
|
|
954
1298
|
)
|
|
955
1299
|
const issues = Array.isArray(raw.issues)
|
|
956
1300
|
? raw.issues
|
|
@@ -959,18 +1303,39 @@ export async function reviewChapterText(
|
|
|
959
1303
|
severity: (['high', 'medium', 'low'] as const).includes(entry.severity as never)
|
|
960
1304
|
? entry.severity as 'high' | 'medium' | 'low'
|
|
961
1305
|
: 'medium',
|
|
1306
|
+
dimension: (typeof entry.dimension === 'string' && REVIEW_DIMENSION_IDS.has(entry.dimension))
|
|
1307
|
+
? entry.dimension as ReviewDimension
|
|
1308
|
+
: undefined,
|
|
962
1309
|
item: typeof entry.item === 'string' ? entry.item : '',
|
|
963
1310
|
suggestion: typeof entry.suggestion === 'string' ? entry.suggestion : '',
|
|
1311
|
+
ruleName: typeof entry.ruleName === 'string' ? entry.ruleName : undefined,
|
|
1312
|
+
ruleType: (['forbidden', 'risk', 'encourage'] as const).includes(entry.ruleType as never)
|
|
1313
|
+
? entry.ruleType as 'forbidden' | 'risk' | 'encourage'
|
|
1314
|
+
: undefined,
|
|
1315
|
+
category: typeof entry.category === 'string' ? entry.category : undefined,
|
|
1316
|
+
excerpt: typeof entry.excerpt === 'string' ? entry.excerpt.slice(0, 80) : undefined,
|
|
1317
|
+
reason: typeof entry.reason === 'string' ? entry.reason.slice(0, 200) : undefined,
|
|
1318
|
+
canAutoRewrite: typeof entry.canAutoRewrite === 'boolean' ? entry.canAutoRewrite : undefined,
|
|
964
1319
|
}))
|
|
965
1320
|
.filter(issue => issue.item !== '')
|
|
966
1321
|
: []
|
|
967
1322
|
const score = typeof raw.score === 'number' ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60
|
|
968
|
-
|
|
969
|
-
|
|
1323
|
+
const riskScore = typeof raw.riskScore === 'number' ? Math.max(0, Math.min(100, Math.round(raw.riskScore))) : undefined
|
|
1324
|
+
// 非验证模式:有 high 必须 ≥ reviewPassScore;无 high 可降 5 分但最低 65
|
|
1325
|
+
const hasHighAny = issues.some(i => i.severity === 'high')
|
|
1326
|
+
let passed = hasHighAny ? score >= config.reviewPassScore : score >= Math.max(65, config.reviewPassScore - 5)
|
|
970
1327
|
if (previousReport !== undefined) {
|
|
971
1328
|
const hasHigh = issues.some(i => i.severity === 'high')
|
|
972
1329
|
const prevHigh = previousReport.issues.filter(i => i.severity === 'high')
|
|
973
|
-
|
|
1330
|
+
// 优先用模型输出的 unresolvedIds 精确判定(按编号),兼容旧报告回退到增强字符串匹配
|
|
1331
|
+
const unresolvedIds = Array.isArray(raw.unresolvedIds) ? raw.unresolvedIds.filter((v: unknown): v is number => typeof v === 'number') : []
|
|
1332
|
+
const resolvedIds = Array.isArray(raw.resolvedIds) ? raw.resolvedIds.filter((v: unknown): v is number => typeof v === 'number') : []
|
|
1333
|
+
let prevHighResolved: boolean
|
|
1334
|
+
if (unresolvedIds.length > 0 || resolvedIds.length > 0) {
|
|
1335
|
+
prevHighResolved = prevHigh.every((_, idx) => !unresolvedIds.includes(idx + 1) || resolvedIds.includes(idx + 1))
|
|
1336
|
+
} else {
|
|
1337
|
+
prevHighResolved = prevHigh.every(p => !issues.some(i => i.item.replace(/^未解决\(\d+\):/, '').includes(p.item.replace(/^未解决\(\d+\):/, '').slice(0, 20))))
|
|
1338
|
+
}
|
|
974
1339
|
passed = !hasHigh && prevHighResolved
|
|
975
1340
|
}
|
|
976
1341
|
return {
|
|
@@ -978,6 +1343,9 @@ export async function reviewChapterText(
|
|
|
978
1343
|
passed,
|
|
979
1344
|
verdict: typeof raw.verdict === 'string' ? raw.verdict.slice(0, 200) : '',
|
|
980
1345
|
issues,
|
|
1346
|
+
riskScore,
|
|
1347
|
+
aiFlavor: aiScan.aiScore,
|
|
1348
|
+
aiPhrases: aiScan.clicheHits.slice(0, 8),
|
|
981
1349
|
reviewedAt: new Date().toISOString(),
|
|
982
1350
|
}
|
|
983
1351
|
}
|
|
@@ -987,16 +1355,20 @@ function verifySystemPrompt(project: ProjectState): string {
|
|
|
987
1355
|
return [
|
|
988
1356
|
'你是一位网文审稿验证员。作者已按上一轮审稿意见修订了本章,你需要验证修订效果。',
|
|
989
1357
|
'你的任务(严格按此执行):',
|
|
990
|
-
'1.
|
|
1358
|
+
'1. 逐条核对「上一轮意见」中的每一条(按编号 1、2、3...)是否已在修订稿中解决。',
|
|
991
1359
|
'2. 只挑修订【新引入】的 high 级问题(设定矛盾/逻辑硬伤/事实错误)——新引入的 medium/low 主观项(文笔/套话/节奏)不要列。',
|
|
992
1360
|
'3. 禁止重复挑剔上一轮已指出且本次已解决的主观项(如"缓缓/微微"等套话、错别字)——即使换个说法再提也不行。',
|
|
993
|
-
'4. 严禁为了显得专业而新增"换一批毛病"
|
|
994
|
-
'
|
|
995
|
-
'
|
|
996
|
-
'
|
|
1361
|
+
'4. 严禁为了显得专业而新增"换一批毛病"式的意见。',
|
|
1362
|
+
'输出必须是合法 JSON 对象,包含以下字段:',
|
|
1363
|
+
'- resolvedIds:已解决的上一轮意见编号数组(如 [1, 3, 5])。',
|
|
1364
|
+
'- unresolvedIds:未解决或部分解决的上一轮意见编号数组(如 [2, 4])。',
|
|
1365
|
+
'- issues:未解决的原意见 + 新引入的 high 级问题列表(格式同审稿:severity/item/suggestion)。未解决的原意见 item 需注明"未解决(编号N):原意见摘要"。',
|
|
1366
|
+
'- score:按修订稿整体质量给 50-90 分(解决全部 high 且无新增 high 时给 70 以上)。',
|
|
1367
|
+
'- verdict:一句话结论。',
|
|
1368
|
+
'完整格式:{"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"}]}',
|
|
997
1369
|
'重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
998
1370
|
'重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。',
|
|
999
|
-
|
|
1371
|
+
`本书道藏(核对设定冲突用):\n${project.bible !== undefined ? JSON.stringify(project.bible).slice(0, 3000) : '(无)'}`,
|
|
1000
1372
|
].join('\n')
|
|
1001
1373
|
}
|
|
1002
1374
|
|
|
@@ -1012,8 +1384,12 @@ function authorReviewSystemPrompt(): string {
|
|
|
1012
1384
|
'5. advancedLines:本章实际推进的剧情线名称数组——从「活跃剧情线」清单中选出推进了的线(名称必须与清单中的线名一字不差;没推进任何线则输出空数组)。',
|
|
1013
1385
|
'6. continuity:与上一章结尾的衔接检查(人物位置/时间/伤势/资源/对话状态),发现问题要指出。',
|
|
1014
1386
|
'7. trend:结合上一章复盘看近期节奏趋势(是否连续拖沓、爽点密度是否下降、是否需要调整)。',
|
|
1387
|
+
'8. stateChanges:本章发生的关键状态变化数组(人物状态/世界局面/关系/资源,每条约 20 字,最多 6 条),供整本与事实库回灌。',
|
|
1388
|
+
'9. newConflicts:本章新引入或明显升级的冲突数组(最多 4 条)。',
|
|
1389
|
+
'10. clues:本章埋下或推进的新线索/伏笔数组(最多 4 条)。',
|
|
1390
|
+
'11. absentRisks:本章缺席但按卷级职责应出场/该推进关系的重要角色,及其缺席带来的风险(最多 4 条)。',
|
|
1015
1391
|
'输出必须是合法 JSON 对象,不要输出任何其他文字:',
|
|
1016
|
-
'{"hookHonored": true或false, "hookNote": "一句话", "endingHook": 0-10整数, "plotlineProgress": "一句话", "advancedLines": ["线名"], "continuity": "一句话", "trend": "一句话"}',
|
|
1392
|
+
'{"hookHonored": true或false, "hookNote": "一句话", "endingHook": 0-10整数, "plotlineProgress": "一句话", "advancedLines": ["线名"], "continuity": "一句话", "trend": "一句话", "stateChanges": ["状态变化"], "newConflicts": ["新冲突"], "clues": ["新线索"], "absentRisks": ["本章缺席但值得注意的角色及风险(卷级职责/缺席风险,最多4条)"]}',
|
|
1017
1393
|
'重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
1018
1394
|
'重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。',
|
|
1019
1395
|
].join('\n')
|
|
@@ -1060,6 +1436,10 @@ export async function authorReviewChapter(
|
|
|
1060
1436
|
advancedLines?: unknown
|
|
1061
1437
|
continuity?: unknown
|
|
1062
1438
|
trend?: unknown
|
|
1439
|
+
stateChanges?: unknown
|
|
1440
|
+
newConflicts?: unknown
|
|
1441
|
+
clues?: unknown
|
|
1442
|
+
absentRisks?: unknown
|
|
1063
1443
|
}>(
|
|
1064
1444
|
await complete(ctx, config, { system: authorReviewSystemPrompt(), user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 4000) }),
|
|
1065
1445
|
)
|
|
@@ -1068,6 +1448,7 @@ export async function authorReviewChapter(
|
|
|
1068
1448
|
const advancedLines = Array.isArray(raw.advancedLines)
|
|
1069
1449
|
? raw.advancedLines.filter((n): n is string => typeof n === 'string' && n.trim() !== '' && knownLineNames.has(n.trim())).map(n => n.trim())
|
|
1070
1450
|
: []
|
|
1451
|
+
const strArr = (v: unknown): string[] => Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '').map(x => x.slice(0, 120)) : []
|
|
1071
1452
|
return {
|
|
1072
1453
|
hookHonored: raw.hookHonored === true,
|
|
1073
1454
|
hookNote: typeof raw.hookNote === 'string' ? raw.hookNote.slice(0, 200) : '',
|
|
@@ -1076,6 +1457,10 @@ export async function authorReviewChapter(
|
|
|
1076
1457
|
advancedLines,
|
|
1077
1458
|
continuity: typeof raw.continuity === 'string' ? raw.continuity.slice(0, 200) : '',
|
|
1078
1459
|
trend: typeof raw.trend === 'string' ? raw.trend.slice(0, 200) : '',
|
|
1460
|
+
stateChanges: strArr(raw.stateChanges).slice(0, 6),
|
|
1461
|
+
newConflicts: strArr(raw.newConflicts).slice(0, 4),
|
|
1462
|
+
clues: strArr(raw.clues).slice(0, 4),
|
|
1463
|
+
absentRisks: strArr(raw.absentRisks).slice(0, 4),
|
|
1079
1464
|
reviewedAt: new Date().toISOString(),
|
|
1080
1465
|
}
|
|
1081
1466
|
}
|
|
@@ -1090,7 +1475,7 @@ export function autoLinkPlotlines(project: ProjectState, chapterNo: number, adva
|
|
|
1090
1475
|
}
|
|
1091
1476
|
}
|
|
1092
1477
|
|
|
1093
|
-
/**
|
|
1478
|
+
/** 建议剧情线:基于大纲/卷计划/已写章节/编年录,提炼候选线。 */
|
|
1094
1479
|
export async function suggestPlotlines(ctx: Context, config: NovelConfig, project: ProjectState): Promise<Plotline[]> {
|
|
1095
1480
|
const system = [
|
|
1096
1481
|
'你是一位网文剧情架构师。根据本书的大纲、卷计划、已写章节标题与编年录,为作者提炼建议的剧情线(主线/支线/人物线/悬念线)。',
|
|
@@ -1137,7 +1522,7 @@ export async function suggestPlotlines(ctx: Context, config: NovelConfig, projec
|
|
|
1137
1522
|
return lines
|
|
1138
1523
|
}
|
|
1139
1524
|
|
|
1140
|
-
/**
|
|
1525
|
+
/** 刷新单条剧情线的进度:结合编年录与各章摘要分析该线推进到哪。 */
|
|
1141
1526
|
export async function refreshPlotlineProgress(
|
|
1142
1527
|
ctx: Context,
|
|
1143
1528
|
config: NovelConfig,
|
|
@@ -1168,7 +1553,7 @@ export async function refreshPlotlineProgress(
|
|
|
1168
1553
|
return typeof raw.progress === 'string' ? raw.progress.trim().slice(0, 300) : ''
|
|
1169
1554
|
}
|
|
1170
1555
|
|
|
1171
|
-
/**
|
|
1556
|
+
/** AI 从全书提炼角色库:大纲 + 道藏 + 编年录 + 章节摘要 → 结构化角色清单。 */
|
|
1172
1557
|
export async function extractRoles(
|
|
1173
1558
|
ctx: Context,
|
|
1174
1559
|
config: NovelConfig,
|
|
@@ -1177,8 +1562,8 @@ export async function extractRoles(
|
|
|
1177
1562
|
const system = [
|
|
1178
1563
|
'你是一位网文角色库管理员。请根据本书的大纲、设定、编年录与章节摘要,提炼完整的角色库。',
|
|
1179
1564
|
'覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如"矮胖姑娘")用其身份简称收录并标注;反复出现且有剧情作用的身份型角色(站长、律师、警察、法官、店主等)必须收录。',
|
|
1180
|
-
'数量控制:最多输出
|
|
1181
|
-
'重要:正常一部完整故事应提炼
|
|
1565
|
+
'数量控制:最多输出 20 个角色;覆盖优先——主角、主要反派、重要配角(女主/关键配角)必须全收,所有有名有姓者必收;只有真正一次性路人(无名字、无剧情作用)才可省略。',
|
|
1566
|
+
'重要:正常一部完整故事应提炼 8-20 个角色;若少于 6 个通常说明漏提炼,必须重新核对正文摘录。',
|
|
1182
1567
|
'重要:正文中若有明确的主角与主要反派,必须分别以 protagonist / antagonist 收录,禁止遗漏;反派确实未出场时才可省略。',
|
|
1183
1568
|
'输出优先级:主角(protagonist)与主要反派(antagonist)必须优先输出并完整刻画,其次女主/重要配角;判断不出名字时用正文中的身份称呼。',
|
|
1184
1569
|
'每个角色输出:',
|
|
@@ -1199,14 +1584,14 @@ export async function extractRoles(
|
|
|
1199
1584
|
const written = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating')
|
|
1200
1585
|
const existingRoles = project.roles ?? []
|
|
1201
1586
|
// 正文摘录:均匀采样覆盖全书(开头/中间/结尾),避免只取前几章漏掉后期才出场的重要角色。
|
|
1202
|
-
const sampleChapters: ChapterPlan[] = written.length <=
|
|
1587
|
+
const sampleChapters: ChapterPlan[] = written.length <= 10
|
|
1203
1588
|
? written
|
|
1204
1589
|
: (() => {
|
|
1205
1590
|
const picked = new Set<number>()
|
|
1206
1591
|
for (let i = 0; i < 3 && i < written.length; i++) picked.add(i)
|
|
1207
|
-
const step = Math.max(1, Math.floor(written.length /
|
|
1208
|
-
for (let i = step; i < written.length -
|
|
1209
|
-
for (let i = Math.max(0, written.length -
|
|
1592
|
+
const step = Math.max(1, Math.floor(written.length / 8))
|
|
1593
|
+
for (let i = step; i < written.length - 3; i += step) picked.add(i)
|
|
1594
|
+
for (let i = Math.max(0, written.length - 3); i < written.length; i++) picked.add(i)
|
|
1210
1595
|
return [...picked].sort((a, b) => a - b).map(i => written[i])
|
|
1211
1596
|
})()
|
|
1212
1597
|
const excerptParts: string[] = []
|
|
@@ -1214,8 +1599,25 @@ export async function extractRoles(
|
|
|
1214
1599
|
const body = readChapterFile(config.outputDir, chapter)
|
|
1215
1600
|
if (body === undefined) continue
|
|
1216
1601
|
const text = body.replace(/^#.*$/gm, '').trim()
|
|
1217
|
-
if (text.length > 0) excerptParts.push(`第${chapter.no}章《${chapter.title}》\n${text.slice(0,
|
|
1602
|
+
if (text.length > 0) excerptParts.push(`第${chapter.no}章《${chapter.title}》\n${text.slice(0, 3000)}`)
|
|
1603
|
+
}
|
|
1604
|
+
// 出场频次统计:道藏角色名 + 已收录角色,扫全书统计出现次数,传给 LLM 做重要性判断参考
|
|
1605
|
+
const freqCandidates = new Set<string>()
|
|
1606
|
+
for (const c of project.bible?.characters ?? []) freqCandidates.add(c.name)
|
|
1607
|
+
for (const r of existingRoles) freqCandidates.add(r.name)
|
|
1608
|
+
const freqMap = new Map<string, number>()
|
|
1609
|
+
if (freqCandidates.size > 0) {
|
|
1610
|
+
for (const chapter of written) {
|
|
1611
|
+
const body = readChapterFile(config.outputDir, chapter)
|
|
1612
|
+
if (body === undefined) continue
|
|
1613
|
+
for (const name of freqCandidates) {
|
|
1614
|
+
let idx = 0, n = 0
|
|
1615
|
+
while ((idx = body.indexOf(name, idx)) !== -1) { n++; idx += name.length }
|
|
1616
|
+
if (n > 0) freqMap.set(name, (freqMap.get(name) ?? 0) + n)
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1218
1619
|
}
|
|
1620
|
+
const freqLines = [...freqMap.entries()].filter(([, n]) => n >= 2).sort((a, b) => b[1] - a[1]).slice(0, 30).map(([name, n]) => name + '(' + n + '次)')
|
|
1219
1621
|
const user = [
|
|
1220
1622
|
`书名:《${project.bookName}》`,
|
|
1221
1623
|
existingRoles.length > 0
|
|
@@ -1228,6 +1630,9 @@ export async function extractRoles(
|
|
|
1228
1630
|
project.bible !== undefined && project.bible.characters.length > 0
|
|
1229
1631
|
? `已有角色卡(补充信息):\n${project.bible.characters.map(c => `- ${c.name}(${c.role}):${c.traits.join('、')}${c.goals !== '' ? `;目标:${c.goals}` : ''}`).join('\n')}`
|
|
1230
1632
|
: '',
|
|
1633
|
+
freqLines.length > 0
|
|
1634
|
+
? `角色出场频次参考(全书统计,次数越多越重要,优先收录高频角色):${freqLines.join('、')}`
|
|
1635
|
+
: '',
|
|
1231
1636
|
(project.facts ?? []).length > 0
|
|
1232
1637
|
? `编年录(最近 30 条):\n${(project.facts ?? []).slice(-30).map(f => `[第${f.chapterNo}章] ${f.text.slice(0, 60)}`).join('\n')}`
|
|
1233
1638
|
: '',
|
|
@@ -1236,18 +1641,19 @@ export async function extractRoles(
|
|
|
1236
1641
|
: '',
|
|
1237
1642
|
'只输出 JSON 数组。',
|
|
1238
1643
|
].join('\n\n')
|
|
1239
|
-
|
|
1644
|
+
// 角色阵容:一次要产出 8-20 个角色,预算按预期角色数推算。
|
|
1645
|
+
let text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: resolveOutputBudget('character-cast', { characters: 20 }), structured: true, reasoning: config.analysisReasoning ?? 'low' })
|
|
1240
1646
|
let raw = parseJsonArray<Record<string, unknown>>(text)
|
|
1241
1647
|
const hasProtagonist = raw.some(e => typeof e === 'object' && e !== null && e.roleLabel === 'protagonist')
|
|
1242
|
-
const tooFew = raw.length > 0 && raw.length <
|
|
1648
|
+
const tooFew = raw.length > 0 && raw.length < 6
|
|
1243
1649
|
if (raw.length === 0 || !hasProtagonist || tooFew) {
|
|
1244
1650
|
// LLM 偶发输出非 JSON / 空数组 / 漏主角 / 角色过少:重试一次(追加明确指令),避免静默返回空或缺角色的候选。
|
|
1245
1651
|
const hint = raw.length === 0
|
|
1246
1652
|
? '\n上一次输出为空或格式不正确。请直接输出 JSON 数组(即使只有一个角色也要输出),不要输出其他文字。'
|
|
1247
1653
|
: tooFew
|
|
1248
|
-
? '\n上一次输出角色过少(不足
|
|
1654
|
+
? '\n上一次输出角色过少(不足 6 个)。这是一部完整故事,请重新核对正文摘录:主角、主要反派、重要配角与所有有名有姓的角色都要收录(宁多勿漏),输出 8-20 个。'
|
|
1249
1655
|
: '\n上一次输出中缺少主角(roleLabel 为 protagonist 的角色)。请重新输出完整 JSON 数组,务必包含正文中的主角。'
|
|
1250
|
-
text = await complete(ctx, config, { system: system + hint, user, temperature: 0.3, maxTokens:
|
|
1656
|
+
text = await complete(ctx, config, { system: system + hint, user, temperature: 0.3, maxTokens: resolveOutputBudget('character-cast', { characters: 24 }), structured: true, reasoning: config.analysisReasoning ?? 'low' })
|
|
1251
1657
|
raw = parseJsonArray<Record<string, unknown>>(text)
|
|
1252
1658
|
}
|
|
1253
1659
|
const labels = new Set(['protagonist', 'female_lead', 'female_support', 'support', 'antagonist', 'extra'])
|
|
@@ -1285,12 +1691,12 @@ export async function extractRoles(
|
|
|
1285
1691
|
if (role === undefined || existing.has(role.name)) continue
|
|
1286
1692
|
existing.add(role.name)
|
|
1287
1693
|
roles.push(role)
|
|
1288
|
-
if (roles.length >=
|
|
1694
|
+
if (roles.length >= 20) break
|
|
1289
1695
|
}
|
|
1290
1696
|
} catch { /* 补漏失败不阻塞主结果 */ }
|
|
1291
1697
|
}
|
|
1292
1698
|
// 确定性兜底:正文中反复出现的身份型称呼(站长/律师/警察等)若 LLM 仍漏掉,按出现次数直接补条(通用、不依赖名单)。
|
|
1293
|
-
const ROLE_TITLE_HINTS = ['站长', '律师', '检察官', '法官', '警察', '店主', '老板', '经理', '局长', '医生', '老师', '护士', '房东', '司机', '保安', '主管', '队长', '厂长', '董事长', '总裁', '市长', '道长', '掌门', '师父', '师傅', '管家']
|
|
1699
|
+
const ROLE_TITLE_HINTS = ['站长', '律师', '检察官', '法官', '警察', '店主', '老板', '经理', '局长', '医生', '老师', '护士', '房东', '司机', '保安', '主管', '队长', '厂长', '董事长', '总裁', '市长', '道长', '掌门', '师父', '师傅', '管家', '长老', '宗主', '殿主', '宫主', '师兄', '师姐', '师弟', '师妹', '老祖', '魔尊', '妖王', '将军', '军师', '太监', '宫女', '嬷嬷', '宰相', '尚书', '巡抚', '都督', '祭司', '圣女', '圣子']
|
|
1294
1700
|
const existingNames = new Set(roles.map(r => r.name))
|
|
1295
1701
|
const titleCount = new Map<string, number>()
|
|
1296
1702
|
for (const chapter of written) {
|
|
@@ -1307,7 +1713,7 @@ export async function extractRoles(
|
|
|
1307
1713
|
}
|
|
1308
1714
|
}
|
|
1309
1715
|
for (const t of ROLE_TITLE_HINTS) {
|
|
1310
|
-
if (roles.length >=
|
|
1716
|
+
if (roles.length >= 20) break
|
|
1311
1717
|
const covered = existingNames.has(t) || [...existingNames].some(n => n.includes(t))
|
|
1312
1718
|
if (!covered && (titleCount.get(t) ?? 0) >= 3) {
|
|
1313
1719
|
existingNames.add(t)
|
|
@@ -1317,92 +1723,7 @@ export async function extractRoles(
|
|
|
1317
1723
|
return roles
|
|
1318
1724
|
}
|
|
1319
1725
|
|
|
1320
|
-
/**
|
|
1321
|
-
export async function extractScenes(
|
|
1322
|
-
ctx: Context,
|
|
1323
|
-
config: NovelConfig,
|
|
1324
|
-
project: ProjectState,
|
|
1325
|
-
styleId?: string,
|
|
1326
|
-
filterId?: string,
|
|
1327
|
-
): Promise<SceneCard[]> {
|
|
1328
|
-
const styleWords = styleKeywords(styleId, filterId)
|
|
1329
|
-
const system = [
|
|
1330
|
-
'你是一位网文漫剧场景导演。根据本书的大纲、编年录与已写章节摘录,提炼「镜头场景」——漫剧分镜/生图时每个镜头要知道"在哪、什么时间光态、拍什么情节、人物什么状态"。',
|
|
1331
|
-
'按五幕结构覆盖全书核心场景(短篇 10 章内可少于五幕,按实际剧情组织):第一幕后场/核心主场、第二幕对外卖场/营业区、第三幕禁忌区域/地下深仓、第四幕外部人间世界(短暂出逃)、第五幕回归与抉择。',
|
|
1332
|
-
'【当前视觉风格】(moment/palette/moods/zh/en 必须按此风格措辞,不能写中性描述):' + styleWords,
|
|
1333
|
-
'重要:每个场景的 zh 提示词段首必须原样嵌入【当前视觉风格】词块原文;en 提示词末尾追加风格标签。不得省略或改写。',
|
|
1334
|
-
'每个场景必须是「镜头场景」而非仅场地:给出该场景的关键情节镜头(人物动作+情绪+镜头推进)。',
|
|
1335
|
-
'数量控制:最多输出 8 个场景;每个场景必须能对应到正文实际出现过的地点与情节,不得凭空虚构。',
|
|
1336
|
-
'每个场景输出:',
|
|
1337
|
-
'1. name:场景名(地点+光态,如「后场通道·装卸口(雨夜)」)。',
|
|
1338
|
-
'2. act:幕归属(第一幕后场/第二幕卖场/第三幕深仓/第四幕外界/第五幕抉择)。',
|
|
1339
|
-
'3. moment:时间光态(夜间闭店后/雨夜/凌晨/频闪灯…)。',
|
|
1340
|
-
'4. summary:一句话定位(空间类型/功能/氛围)。',
|
|
1341
|
-
'5. beats:关键情节镜头 1-3 条(人物动作+情绪+镜头推进,如"沈放佝偻站在货架过道,镜头推近,情绪从漠然过渡到压抑悲伤")。',
|
|
1342
|
-
'6. characterState:主角/关键角色在该场景的状态一句话(含标志物细节如标签磨损、手腕红痕)。',
|
|
1343
|
-
'7. elements:环境构成数组 3-6 项(空间结构/陈设/标志物)。',
|
|
1344
|
-
'8. palette:色调与光影数组 2-4 项(颜色词,可附 HEX)。',
|
|
1345
|
-
'9. moods:氛围关键词 2-4 个(压抑/神秘/空旷/悲凉…)。',
|
|
1346
|
-
'10. zh:中文生图提示词(连贯一段,写实电影感,含环境/光线/氛围/人物状态)。',
|
|
1347
|
-
'11. en:英文生图提示词(booru 风格逗号分隔,含 photorealistic/cinematic)。',
|
|
1348
|
-
'12. tags:3-6 个关键标签。',
|
|
1349
|
-
'13. source:依据来源说明(哪几章哪些描写)。',
|
|
1350
|
-
'若故事存在结局抉择(如双分支),最后 1-2 个场景应为「抉择场景」,zh 里写明分支 A/B 两种画面。',
|
|
1351
|
-
'输出必须是合法 JSON 数组,不要输出其他文字:[{"name":"...", "act":"...", "moment":"...", "summary":"...", "beats":[...], "characterState":"...", "elements":[...], "palette":[...], "moods":[...], "zh":"...", "en":"...", "tags":[...], "source":"..."}]',
|
|
1352
|
-
'重要:所有字符串值内部不得包含换行符;直接输出 JSON 结果本身。',
|
|
1353
|
-
].join('\n')
|
|
1354
|
-
const written = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating')
|
|
1355
|
-
const excerptParts: string[] = []
|
|
1356
|
-
for (const chapter of written.slice(0, 4)) {
|
|
1357
|
-
const body = readChapterFile(config.outputDir, chapter)
|
|
1358
|
-
if (body === undefined) continue
|
|
1359
|
-
const text = body.replace(/^#.*$/gm, '').trim()
|
|
1360
|
-
if (text.length > 0) excerptParts.push('第' + chapter.no + '章《' + chapter.title + '》' + '\n' + text.slice(0, 1800))
|
|
1361
|
-
}
|
|
1362
|
-
const user = [
|
|
1363
|
-
'书名:《' + project.bookName + '》',
|
|
1364
|
-
(project.facts ?? []).length > 0
|
|
1365
|
-
? '编年录(最近 80 条,场景地点以这里为准):' + '\n' + (project.facts ?? []).slice(-80).map(f => '[第' + f.chapterNo + '章] ' + f.text.slice(0, 80)).join('\n')
|
|
1366
|
-
: '',
|
|
1367
|
-
excerptParts.length > 0
|
|
1368
|
-
? '已写正文摘录(场景描写以正文为准):' + '\n' + excerptParts.join('\n')
|
|
1369
|
-
: '',
|
|
1370
|
-
'只输出 JSON 数组。',
|
|
1371
|
-
].filter(s => s !== '').join('\n')
|
|
1372
|
-
const text = await complete(ctx, config, { system, user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low' })
|
|
1373
|
-
const raw = parseJsonArray<Record<string, unknown>>(text)
|
|
1374
|
-
const strArr = (v: unknown): string[] => Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '') : []
|
|
1375
|
-
const str = (v: unknown): string => typeof v === 'string' ? v.trim() : ''
|
|
1376
|
-
const scenes: SceneCard[] = []
|
|
1377
|
-
for (const entry of raw) {
|
|
1378
|
-
if (typeof entry !== 'object' || entry === null) continue
|
|
1379
|
-
const name = str(entry.name).slice(0, 40)
|
|
1380
|
-
if (name === '') continue
|
|
1381
|
-
scenes.push({
|
|
1382
|
-
name,
|
|
1383
|
-
act: str(entry.act).slice(0, 40),
|
|
1384
|
-
moment: str(entry.moment).slice(0, 60),
|
|
1385
|
-
summary: str(entry.summary).slice(0, 120),
|
|
1386
|
-
beats: strArr(entry.beats).map(t => t.slice(0, 120)).slice(0, 3),
|
|
1387
|
-
characterState: str(entry.characterState).slice(0, 160),
|
|
1388
|
-
elements: strArr(entry.elements).map(t => t.slice(0, 60)).slice(0, 6),
|
|
1389
|
-
palette: strArr(entry.palette).map(t => t.slice(0, 40)).slice(0, 4),
|
|
1390
|
-
moods: strArr(entry.moods).map(t => t.slice(0, 20)).slice(0, 4),
|
|
1391
|
-
zh: ensureStyleEmbedded(str(entry.zh).slice(0, 800), styleWords, 'zh'),
|
|
1392
|
-
en: ensureStyleEmbedded(str(entry.en).slice(0, 600), styleWords, 'en'),
|
|
1393
|
-
tags: strArr(entry.tags).map(t => t.slice(0, 24)).slice(0, 6),
|
|
1394
|
-
source: str(entry.source).slice(0, 120),
|
|
1395
|
-
styleId,
|
|
1396
|
-
})
|
|
1397
|
-
}
|
|
1398
|
-
return scenes
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
/** 从 txt/md 全本文本拆章(纯逻辑,不落盘):识别章节头、剥离重复标题、去重、排序并统一重新编号。 */
|
|
1726
|
+
/** AI 从全书提炼场景库:正文/编年录 → 高频重要场景的结构化视觉锚点。 */
|
|
1406
1727
|
export function splitBookText(raw: string): Array<{ no: number; title: string; body: string }> {
|
|
1407
1728
|
const lines = raw.split(/\r?\n/)
|
|
1408
1729
|
// 拆章:中文「第X章/回/节/卷」、中文数字章节(一、二、三…)、特殊章节(序章/楔子/尾声/番外…)、英文 Chapter N(均可带 # 前缀)。
|
|
@@ -1514,7 +1835,7 @@ export function importBookTextFromText(
|
|
|
1514
1835
|
}
|
|
1515
1836
|
chapter.file = chapterFileName(chapter)
|
|
1516
1837
|
writeFileSync(join(outputDir, chapter.file), '# 第' + c.no + '章 ' + chapter.title + '\n\n' + body + '\n', 'utf8')
|
|
1517
|
-
chapter.chars = body
|
|
1838
|
+
chapter.chars = countHanzi(body)
|
|
1518
1839
|
project.chapters.push(chapter)
|
|
1519
1840
|
}
|
|
1520
1841
|
project.updatedAt = new Date().toISOString()
|
|
@@ -1532,674 +1853,104 @@ export function importBookText(
|
|
|
1532
1853
|
return importBookTextFromText(raw, outputDir, bookName)
|
|
1533
1854
|
}
|
|
1534
1855
|
|
|
1535
|
-
/** 常见职业/身份尾缀:角色名如「周野律师」正文可能只写「周野」或「周野的律师」。 */
|
|
1536
|
-
const ROLE_NAME_SUFFIXES = ['律师', '辩护律师', '医生', '老师', '教授', '先生', '女士', '小姐', '警官', '警察', '局长', '总经理', '经理', '老板', '师父', '师傅', '道长', '老祖', '长老', '掌门', '少主', '公主', '王子', '王妃', '皇后', '皇帝', '王爷', '公子', '姑娘', '夫人', '太太', '大人', '将军', '丞相', '尚书', '员外']
|
|
1537
|
-
|
|
1538
|
-
/**
|
|
1539
|
-
* 从角色名/身份拆出正文可能使用的检索词:
|
|
1540
|
-
* - specific:具体称谓(含职业尾缀或 3 字以上身份片段),如「周野的律师」「辩护律师」「律师」——优先用,避免误抓到同名主干(周野)的段落;
|
|
1541
|
-
* - stems:名字主干(如「周野」),最后兜底。
|
|
1542
|
-
*/
|
|
1543
|
-
function roleFallbackTokens(name: string, identity: string | undefined): { specific: string[]; stems: string[] } {
|
|
1544
|
-
const specific = new Set<string>()
|
|
1545
|
-
const stems = new Set<string>()
|
|
1546
|
-
const addName = (s: string): void => {
|
|
1547
|
-
const t = s.trim()
|
|
1548
|
-
if (t.length < 2) return
|
|
1549
|
-
specific.add(t)
|
|
1550
|
-
for (const q of ['辩护', '助理', '高级', '首席', '御用', '御前', '专职']) {
|
|
1551
|
-
if (t.includes(q)) specific.add(t.replace(q, ''))
|
|
1552
|
-
}
|
|
1553
|
-
for (const suf of ROLE_NAME_SUFFIXES) {
|
|
1554
|
-
if (t.endsWith(suf) && t.length > suf.length + 1) {
|
|
1555
|
-
const stem = t.slice(0, -suf.length)
|
|
1556
|
-
// 「周野律师」→「周野的律师」(正文常见写法)
|
|
1557
|
-
specific.add(stem + '的' + suf)
|
|
1558
|
-
specific.add(suf)
|
|
1559
|
-
if (stem.length >= 2) stems.add(stem)
|
|
1560
|
-
}
|
|
1561
|
-
}
|
|
1562
|
-
}
|
|
1563
|
-
addName(name)
|
|
1564
|
-
for (const part of (identity ?? '').split(/[的,,。\s]+/)) {
|
|
1565
|
-
const p = part.trim()
|
|
1566
|
-
if (p.length < 2) continue
|
|
1567
|
-
if (p.length >= 3 || ROLE_NAME_SUFFIXES.some(s => p.endsWith(s))) {
|
|
1568
|
-
specific.add(p)
|
|
1569
|
-
for (const suf of ROLE_NAME_SUFFIXES) {
|
|
1570
|
-
if (p.endsWith(suf) && p.length > suf.length + 1) {
|
|
1571
|
-
specific.add(suf)
|
|
1572
|
-
stems.add(p.slice(0, -suf.length))
|
|
1573
|
-
}
|
|
1574
|
-
}
|
|
1575
|
-
} else {
|
|
1576
|
-
stems.add(p)
|
|
1577
|
-
}
|
|
1578
|
-
}
|
|
1579
|
-
return { specific: [...specific].filter(t => t.length >= 2), stems: [...stems].filter(t => t.length >= 2) }
|
|
1580
|
-
}
|
|
1581
|
-
|
|
1582
1856
|
/** 保证风格词块已内嵌(LLM 偶发漏嵌时兜底):zh 段首前缀,en 末尾追加。 */
|
|
1583
|
-
function
|
|
1584
|
-
const t = text.trim()
|
|
1585
|
-
if (styleWords === '' || t === '') return t
|
|
1586
|
-
if (t.includes(styleWords)) return t
|
|
1587
|
-
return lang === 'zh' ? styleWords + ',' + t : t + ',' + styleWords
|
|
1588
|
-
}
|
|
1589
|
-
|
|
1590
|
-
/** 对 zh/en 一对提示词统一补风格词块。 */
|
|
1591
|
-
function withStyle(pair: { zh: string; en: string }, styleWords: string): { zh: string; en: string } {
|
|
1592
|
-
return { zh: ensureStyleEmbedded(pair.zh, styleWords, 'zh'), en: ensureStyleEmbedded(pair.en, styleWords, 'en') }
|
|
1593
|
-
}
|
|
1594
|
-
|
|
1595
|
-
/** 动漫形象描述词(中文描述 + 英文 booru 标签 + 关键外貌标签)。 */
|
|
1596
|
-
export interface RoleVisualPrompt {
|
|
1597
|
-
zh: string
|
|
1598
|
-
en: string
|
|
1599
|
-
tags: string[]
|
|
1600
|
-
source: string
|
|
1601
|
-
/** 该角色所需情绪表情清单(6-12 个,如 疲惫/麻木/压抑悲伤)。 */
|
|
1602
|
-
expressions?: string[]
|
|
1603
|
-
/** 四类精修提示词(立绘/四视图/表情/细节,一次提炼直接产出)。 */
|
|
1604
|
-
promptKit?: RoleRecord['promptKit']
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
/** 可提炼形象的「角色源」:小说角色卡或漫剧角色卡的最小公共面。 */
|
|
1608
|
-
interface RoleVisualSource {
|
|
1609
|
-
name: string
|
|
1610
|
-
identity?: string
|
|
1611
|
-
traits?: string[]
|
|
1612
|
-
}
|
|
1613
|
-
|
|
1614
|
-
/**
|
|
1615
|
-
* 底层实现:为任意角色源提炼「动漫形象描述词」(不写库,写库由上层调用方负责)。
|
|
1616
|
-
* 扫描该角色出场的已写章节正文,截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
|
|
1617
|
-
*/
|
|
1618
|
-
async function extractRoleVisualFrom(
|
|
1857
|
+
export async function suggestOutlines(
|
|
1619
1858
|
ctx: Context,
|
|
1620
1859
|
config: NovelConfig,
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
shortDrama = false,
|
|
1627
|
-
): Promise<RoleVisualPrompt> {
|
|
1628
|
-
const roleName = role.name
|
|
1629
|
-
|
|
1630
|
-
// 1. 扫描正文:收集该角色出场且可能含外貌描写的段落(最近 60 章内,每章最多 2 段,共 12 段)。
|
|
1631
|
-
// 支持“描述型角色名”(如「灰蓝工装女人」):先精确匹配,匹配不到时用角色名拆分出的关键词兜底。
|
|
1632
|
-
const appearanceHints = /(发|眉|眼|眸|脸|肤|唇|身材|身高|衣|袍|裙|衫|靴|腰带|气质|模样|长相|容貌|披|束|扎|戴|佩|挂|绣|青|白|黑|红|蓝|紫|灰|银|金|少年|青年|少女|汉子|老者|中年|纤细|挺拔|瘦削|壮实|清秀|俊朗|英气|阴鸷|慈眉)/
|
|
1633
|
-
// 从角色名 + 身份描述中提取可检索关键词,支持“描述型角色名”(灰蓝工装女人)和“身份型角色名”(富商)。
|
|
1634
|
-
const roleText = `${role.name} ${role.identity ?? ''}`
|
|
1635
|
-
const colorWords = ['深灰', '灰蓝', '灰', '蓝', '白', '黑', '红', '青', '紫', '银', '金', '黄', '绿', '粉', '棕']
|
|
1636
|
-
const garmentWords = ['工装', '西装', '定制西装', '袍', '裙', '衫', '衣', '裤', '靴', '鞋', '帽', '腰带', '制服', '外套', '马甲', '夹克']
|
|
1637
|
-
const roleWords = ['女人', '男人', '老者', '老人', '中年', '青年', '少女', '姑娘', '男子', '女子', '工人', '路人', '购买者']
|
|
1638
|
-
const locationWords = ['别墅', '小区', '街区']
|
|
1639
|
-
const identityTokens = (role.identity ?? '').split(/[,,。\s]+/).filter(t => t.length >= 2)
|
|
1640
|
-
const searchTokens = Array.from(new Set([
|
|
1641
|
-
roleName,
|
|
1642
|
-
...identityTokens,
|
|
1643
|
-
...colorWords.filter(w => roleText.includes(w)),
|
|
1644
|
-
...garmentWords.filter(w => roleText.includes(w)),
|
|
1645
|
-
...roleWords.filter(w => roleText.includes(w)),
|
|
1646
|
-
...locationWords.filter(w => roleText.includes(w)),
|
|
1647
|
-
])).filter(t => t.length >= 2)
|
|
1648
|
-
const matchesRole = (para: string, tokens: string[]): boolean => para.includes(roleName) || tokens.some(tok => para.includes(tok))
|
|
1649
|
-
const excerpts: Array<{ no: number; text: string }> = []
|
|
1650
|
-
const written = project.chapters
|
|
1651
|
-
.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.file !== undefined)
|
|
1652
|
-
/** 扫描一批章节,收集该角色出场且可能含外貌描写的段落(每章最多 2 段,共 12 段)。 */
|
|
1653
|
-
const scanChapters = (list: ChapterPlan[], tokens: string[]): void => {
|
|
1654
|
-
for (const chapter of list) {
|
|
1655
|
-
if (excerpts.length >= 12) break
|
|
1656
|
-
const body = readChapterFile(outputDir, chapter)
|
|
1657
|
-
if (body === undefined) continue
|
|
1658
|
-
const paras = body.split(/\n{2,}/).map(p => p.trim()).filter(p => p.length > 0)
|
|
1659
|
-
let perChapter = 0
|
|
1660
|
-
for (const para of paras) {
|
|
1661
|
-
if (perChapter >= 2 || excerpts.length >= 12) break
|
|
1662
|
-
if (!matchesRole(para, tokens)) continue
|
|
1663
|
-
// 优先外貌描写段(含外貌关键词),否则纯动作段也收(LLM 自己判断)。
|
|
1664
|
-
if (appearanceHints.test(para) || excerpts.length < 4) {
|
|
1665
|
-
excerpts.push({ no: chapter.no, text: para.slice(0, 220) })
|
|
1666
|
-
perChapter++
|
|
1667
|
-
}
|
|
1668
|
-
}
|
|
1669
|
-
}
|
|
1670
|
-
}
|
|
1671
|
-
// 第 1 优先:最近 60 章(快路径)。
|
|
1672
|
-
scanChapters(written.slice(-60), searchTokens)
|
|
1673
|
-
// 第 2 优先:角色可能只在早期出场(全书 >60 章时),全书回扫。
|
|
1674
|
-
if (excerpts.length === 0) scanChapters(written, searchTokens)
|
|
1675
|
-
// 第 3 优先:角色名/身份带职业尾缀(如「周野律师」→ 正文写「周野的律师/律师」),用具体称谓扫。
|
|
1676
|
-
if (excerpts.length === 0) {
|
|
1677
|
-
const fb = roleFallbackTokens(role.name, role.identity)
|
|
1678
|
-
if (fb.specific.length > 0) scanChapters(written, fb.specific)
|
|
1679
|
-
}
|
|
1680
|
-
// 第 4 兜底:具体称谓也没找到时,退回名字主干(如「周野」),交给 LLM 判断是否目标角色。
|
|
1681
|
-
if (excerpts.length === 0) {
|
|
1682
|
-
const fb = roleFallbackTokens(role.name, role.identity)
|
|
1683
|
-
if (fb.stems.length > 0) scanChapters(written, fb.stems)
|
|
1684
|
-
}
|
|
1685
|
-
if (excerpts.length === 0) {
|
|
1686
|
-
throw new Error(`正文中未找到「${roleName}」的出场描写(已扫描全书及角色名拆分词),请确认角色名与正文一致,或该角色尚未在正文出场`)
|
|
1687
|
-
}
|
|
1688
|
-
|
|
1689
|
-
// 2. LLM 提炼:一次输出 锚点 + 表情清单 + 四类精修提示词。
|
|
1690
|
-
const rules = project.visualRules ?? []
|
|
1691
|
-
const styleWords = styleKeywords(styleId, filterId)
|
|
1860
|
+
idea: string,
|
|
1861
|
+
count = 3,
|
|
1862
|
+
exclude: string[] = [],
|
|
1863
|
+
): Promise<OutlineCandidate[]> {
|
|
1864
|
+
const n = Math.max(1, Math.min(3, Math.floor(count)))
|
|
1692
1865
|
const system = [
|
|
1693
|
-
'
|
|
1694
|
-
'
|
|
1695
|
-
'
|
|
1696
|
-
'
|
|
1697
|
-
'
|
|
1698
|
-
'
|
|
1699
|
-
'
|
|
1700
|
-
'
|
|
1701
|
-
'
|
|
1702
|
-
|
|
1703
|
-
'
|
|
1704
|
-
...
|
|
1705
|
-
|
|
1706
|
-
'
|
|
1707
|
-
'- en:英文形象锚点,booru 风格、逗号分隔、小写,30-50 个标签:含性别(1boy/1girl)、发色、发型、瞳色、服装、气质、标志物。不要输出负面提示词。',
|
|
1708
|
-
'- tags:中文关键标签数组,5-10 个(如 ["黑发","束发","青色道袍","清秀","腰悬古玉"])。',
|
|
1709
|
-
'- source:说明依据(如"第1章/第8章外貌描写;瞳色未明确")。',
|
|
1710
|
-
'- expressions:该角色在本书剧情中需要的情绪表情清单数组,6-12 个(如 ["疲惫","麻木","压抑悲伤","皱眉","紧绷","放空"]),依据正文情绪描写与角色处境推断。',
|
|
1711
|
-
'- promptKit:四类精修提示词(字段化+出图约束,参考示例结构):',
|
|
1712
|
-
' · portrait 立绘:严格按字段顺序写(参考示例结构):开头构图定位(正面站立全身人像)→ 风格(3D动漫,超精细建模)→ 背景(纯白纯色背景)→ 身份(男子,{角色名},外表{年龄},男性)→ 发型发色 → 胡茬 → 眼眸 → 面部 → 气质 → 上身服装分件(颜色款式+磨损细节)→ 下身 → 鞋 → 标志物(标签/印记,含细节如洇暗翘边)→ 收尾(角色设计稿,细节完整展示,无多余杂物,全身完整无裁切)。',
|
|
1713
|
-
' · sheet 四视图:同一角色的 正面/左侧面/右侧面/背面 四视图设定表(character sheet),纯白背景,全身完整,四个视角分别描述,服装与标志物细节完整展示。',
|
|
1714
|
-
' · expressions 表情:每个表情一段,脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,只换情绪表达(眼神/嘴角/眉),皮肤纹理细节完整,无多余杂物。',
|
|
1715
|
-
' · details 细节:多组局部细节集合参考图(一张图多个局部框),纯白背景;把该角色全部标志物逐项列出(如标签/印记/工牌/袖口磨损/鞋),每项一句特写描述;细节清晰锐利,角色细节参考稿,无多余杂物。',
|
|
1716
|
-
'promptKit 每段 zh:连贯中文 60-150 字;en:booru 标签 30-50 个。',
|
|
1717
|
-
'输出必须是合法 JSON 对象:{"zh": "...", "en": "...", "tags": [...], "source": "...", "expressions": [...], "promptKit": {"portrait": {"zh":"...", "en":"..."}, "sheet": {"zh":"...", "en":"..."}, "expressions": [{"name":"疲惫","zh":"...","en":"..."}], "details": {"zh":"...", "en":"..."}}}',
|
|
1718
|
-
'重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
1866
|
+
'你是一位资深网文策划。作者只给了一句「想法」,你需要把它扩展成 2-3 个【方向差异明显】的完整小说大纲方案,供作者挑选。',
|
|
1867
|
+
'每个方案必须满足:',
|
|
1868
|
+
'1. bookName:书名(6 字以内,抓眼球、点题)。',
|
|
1869
|
+
'2. genre:题材(如 仙侠修真 / 都市异能 / 玄幻 / 悬疑)。',
|
|
1870
|
+
'3. sellingPoint:核心卖点一句话(金手指/爽点/差异化,40 字内)。',
|
|
1871
|
+
'4. outline:完整大纲文本(至少 800 字,可直接作为开书大纲),结构包含:书名与题材、金手指/核心设定、主角人设与动机、主线剧情走向(至少 5 个阶段)、关键配角与势力、卖点与爽点设计、预计分卷(3-5 卷)。',
|
|
1872
|
+
'方向差异要求:',
|
|
1873
|
+
'- 方案之间的金手指/剧情走向必须明显不同(如:苟道发育流 vs 随身老爷爷流 vs 群像争霸流),不能只是换书名。',
|
|
1874
|
+
'- 忠实于作者想法的核心要素,但允许在不同方向上进行合理演绎。',
|
|
1875
|
+
'- 不输出任何与已列「需避开的方向」雷同的方案。',
|
|
1876
|
+
'输出必须是合法 JSON 数组,只输出数组本身:',
|
|
1877
|
+
'[{"id": "唯一id", "bookName": "...", "genre": "...", "sellingPoint": "...", "outline": "..."}]',
|
|
1878
|
+
`本次只输出 ${n} 个方案。`,
|
|
1879
|
+
'重要:所有字符串值内部不得包含换行符(大纲内部分段请用「。\n」或「;」自然断句),JSON 必须在一段内完整结束。',
|
|
1719
1880
|
'重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。',
|
|
1720
1881
|
].join('\n')
|
|
1721
1882
|
const user = [
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1883
|
+
`作者的想法:${idea}`,
|
|
1884
|
+
idea.trim().length < 40
|
|
1885
|
+
? '作者的想法非常简短(可能只有一句)。请基于通用网文套路合理扩展补全:为每个方案自洽地设计金手指/核心设定、主角人设与动机、主线走向,使其成为完整可开书的大纲;不同方案的方向仍须明显差异。'
|
|
1886
|
+
: '',
|
|
1887
|
+
exclude.length > 0
|
|
1888
|
+
? `需避开的已暂留方案方向(新方案不得与之雷同):\n${exclude.map((e, i) => `${i + 1}. ${e}`).join('\n')}`
|
|
1889
|
+
: '',
|
|
1890
|
+
`请生成 ${n} 个大纲方案。`,
|
|
1891
|
+
'只输出 JSON 数组。',
|
|
1728
1892
|
].join('\n\n')
|
|
1729
|
-
const text = await complete(ctx, config, { system, user, temperature: 0.
|
|
1730
|
-
const
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
:
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
return { zh: str(o.zh).slice(0, 800), en: str(o.en).slice(0, 1200) }
|
|
1893
|
+
const text = await complete(ctx, config, { system, user, temperature: 0.85, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low' })
|
|
1894
|
+
const parsed = parseJsonArray<Record<string, unknown>>(text)
|
|
1895
|
+
const candidates: OutlineCandidate[] = []
|
|
1896
|
+
for (const entry of parsed) {
|
|
1897
|
+
if (typeof entry !== 'object' || entry === null) continue
|
|
1898
|
+
const bookName = typeof entry.bookName === 'string' ? entry.bookName.trim().slice(0, 30) : ''
|
|
1899
|
+
const outline = typeof entry.outline === 'string' ? entry.outline.trim() : ''
|
|
1900
|
+
if (bookName === '' || outline.length < 300) continue
|
|
1901
|
+
candidates.push({
|
|
1902
|
+
id: typeof entry.id === 'string' && entry.id !== '' ? entry.id : `oc-${Date.now().toString(36)}-${candidates.length}`,
|
|
1903
|
+
bookName,
|
|
1904
|
+
genre: typeof entry.genre === 'string' ? entry.genre.trim().slice(0, 20) : '',
|
|
1905
|
+
sellingPoint: typeof entry.sellingPoint === 'string' ? entry.sellingPoint.trim().slice(0, 120) : '',
|
|
1906
|
+
outline,
|
|
1907
|
+
})
|
|
1745
1908
|
}
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
let promptKit: RoleRecord['promptKit']
|
|
1749
|
-
try {
|
|
1750
|
-
promptKit = {
|
|
1751
|
-
portrait: pair(kitRaw.portrait),
|
|
1752
|
-
sheet: pair(kitRaw.sheet),
|
|
1753
|
-
expressions: kitExpr.map(e => ({
|
|
1754
|
-
name: str(e.name).slice(0, 12) || '表情',
|
|
1755
|
-
zh: str(e.zh).slice(0, 800),
|
|
1756
|
-
en: str(e.en).slice(0, 1200),
|
|
1757
|
-
})).slice(0, 12),
|
|
1758
|
-
details: pair(kitRaw.details),
|
|
1759
|
-
}
|
|
1760
|
-
} catch {
|
|
1761
|
-
promptKit = undefined
|
|
1762
|
-
}
|
|
1763
|
-
if (zh === '' || en === '') {
|
|
1764
|
-
throw new Error('形象描述提炼失败:LLM 未返回有效 JSON')
|
|
1765
|
-
}
|
|
1766
|
-
// 风格兜底:LLM 漏嵌时强制补上(zh 段首 / en 末尾),保证出图风格与当前方案一致。
|
|
1767
|
-
zh = ensureStyleEmbedded(zh, styleWords, 'zh')
|
|
1768
|
-
en = ensureStyleEmbedded(en, styleWords, 'en')
|
|
1769
|
-
if (promptKit !== undefined) {
|
|
1770
|
-
promptKit = {
|
|
1771
|
-
portrait: withStyle(promptKit.portrait, styleWords),
|
|
1772
|
-
sheet: withStyle(promptKit.sheet, styleWords),
|
|
1773
|
-
expressions: promptKit.expressions.map(e => ({ ...e, ...withStyle(e, styleWords) })),
|
|
1774
|
-
details: withStyle(promptKit.details, styleWords),
|
|
1775
|
-
}
|
|
1909
|
+
if (candidates.length === 0) {
|
|
1910
|
+
throw new Error('大纲方案生成失败:LLM 未返回有效 JSON(可重试)')
|
|
1776
1911
|
}
|
|
1777
|
-
return
|
|
1778
|
-
}
|
|
1779
|
-
|
|
1780
|
-
/** 小说角色库:提炼单个角色的形象锚点并写回角色卡。 */
|
|
1781
|
-
export async function extractRoleVisual(
|
|
1782
|
-
ctx: Context,
|
|
1783
|
-
config: NovelConfig,
|
|
1784
|
-
project: ProjectState,
|
|
1785
|
-
outputDir: string,
|
|
1786
|
-
roleName: string,
|
|
1787
|
-
styleId?: string,
|
|
1788
|
-
filterId?: string,
|
|
1789
|
-
): Promise<RoleVisualPrompt> {
|
|
1790
|
-
const role = (project.roles ?? []).find(r => r.name === roleName)
|
|
1791
|
-
if (role === undefined) throw new Error(`角色「${roleName}」不在角色库中`)
|
|
1792
|
-
role.promptStyleId = styleId
|
|
1793
|
-
return extractRoleVisualFrom(ctx, config, project, outputDir, role, styleId, filterId)
|
|
1912
|
+
return candidates.slice(0, n)
|
|
1794
1913
|
}
|
|
1795
1914
|
|
|
1796
|
-
/**
|
|
1797
|
-
|
|
1915
|
+
/** 拆书分析:对已写章节做结构/人物/文风/卖点四维体检。
|
|
1916
|
+
* 两阶段管道(先抽取候选、再落库):
|
|
1917
|
+
* ① 源片段笔记:每章抽取结构化笔记(剧情/人物/设定/写法/卖点/短板信号)
|
|
1918
|
+
* ② 分节分析:按维度各跑一次 LLM,输出可读分析稿 + 结构化数据 + 证据链。
|
|
1919
|
+
* @param scope 'recent'(默认最近20章) | 'volume:N' | 'all'
|
|
1920
|
+
* @param preset 'quick'(总览/剧情/人物/文风) | 'standard'(+卖点)
|
|
1921
|
+
* @param budgetTokens token 预算上限(超过即截断章节取样)。
|
|
1922
|
+
*/
|
|
1923
|
+
export async function breakdownBook(
|
|
1798
1924
|
ctx: Context,
|
|
1799
1925
|
config: NovelConfig,
|
|
1800
1926
|
project: ProjectState,
|
|
1801
1927
|
outputDir: string,
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
): Promise<
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
return visual
|
|
1816
|
-
}
|
|
1928
|
+
scope = 'recent',
|
|
1929
|
+
preset: 'quick' | 'standard' = 'quick',
|
|
1930
|
+
budgetTokens = 50000,
|
|
1931
|
+
): Promise<BreakdownResponse> {
|
|
1932
|
+
// 1. 选章节范围。
|
|
1933
|
+
const written = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.summary !== undefined && c.summary !== '')
|
|
1934
|
+
let selected = written
|
|
1935
|
+
if (scope === 'recent') selected = written.slice(-20)
|
|
1936
|
+
else if (/^volume:\d+$/.test(scope)) {
|
|
1937
|
+
const v = Number(scope.slice(7))
|
|
1938
|
+
selected = written.filter(c => c.volume === v)
|
|
1939
|
+
}
|
|
1940
|
+
if (selected.length === 0) throw new NovelActionError('contract', '没有可分析的已写章节(需要已生成并带摘要)')
|
|
1817
1941
|
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
ctx: Context,
|
|
1831
|
-
config: NovelConfig,
|
|
1832
|
-
project: ProjectState,
|
|
1833
|
-
role: RoleKitSource,
|
|
1834
|
-
styleId?: string,
|
|
1835
|
-
filterId?: string,
|
|
1836
|
-
shortDrama = false,
|
|
1837
|
-
): Promise<RoleRecord['promptKit']> {
|
|
1838
|
-
const roleName = role.name
|
|
1839
|
-
const rules = project.visualRules ?? []
|
|
1840
|
-
const styleWords = styleKeywords(styleId, filterId)
|
|
1841
|
-
const system = [
|
|
1842
|
-
'你是一位 AI 绘图提示词工程师。基于给定角色的形象锚点、表情清单与本书视觉规则,输出四类生图提示词(每类 zh+en 各一段)。',
|
|
1843
|
-
'【当前视觉风格】(每类提示词必须内嵌):' + styleWords,
|
|
1844
|
-
'重要:每段 zh 提示词的段首必须原样嵌入【当前视觉风格】词块原文;en 提示词末尾追加风格标签。不得省略或改写。',
|
|
1845
|
-
'四类:',
|
|
1846
|
-
'1. portrait 立绘:正面站立全身人像,3D动漫超精细建模,纯白纯色背景;按字段流组织:身份(男子,角色名,外表年龄)→ 发型发色 → 胡茬 → 眼眸 → 面部 → 气质 → 上身服装分件(含磨损)→ 下身 → 鞋 → 标志物(含细节)→ 收尾(角色设计稿,细节完整展示,无多余杂物,全身完整无裁切)。',
|
|
1847
|
-
'2. sheet 四视图:同一角色的 正面/左侧面/右侧面/背面 四视图设定表(character sheet),纯白背景,四个视角分别描述。',
|
|
1848
|
-
'3. expressions 表情:每个表情一段,脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,只换情绪表达(眼神/嘴角/眉),皮肤纹理细节完整,无多余杂物。',
|
|
1849
|
-
'4. details 细节:多组局部细节集合参考图(一张图多个局部框),纯白背景;把该角色全部标志物逐项列出,每项一句特写描述;细节清晰锐利,角色细节参考稿,无多余杂物。',
|
|
1850
|
-
'5. 立绘/四视图/细节为「角色设定稿」:禁止瞬间动作、道具使用状态与剧情状态(握手机、看屏幕、未接来电等),只保留可长期存在的外貌、服装与常驻标志物(工牌、饰品等)。',
|
|
1851
|
-
...(shortDrama ? ['6. 短剧精简模式·人设极致化:性格标签必须极致化(单一维度拉到最满);外貌只保留 1-2 个最强辨识度点;服装/标志物更夸张醒目,一眼可认。'] : []),
|
|
1852
|
-
'【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):',
|
|
1853
|
-
...rules.map(r => '- ' + r),
|
|
1854
|
-
'zh 要求:连贯中文,写实电影感,60-150 字/段;en 要求:booru 风格逗号分隔标签,30-50 个/段。',
|
|
1855
|
-
'输出必须是合法 JSON 对象:{"portrait": {"zh": "...", "en": "..."}, "sheet": {"zh": "...", "en": "..."}, "expressions": [{"name": "疲惫", "zh": "...", "en": "..."}], "details": {"zh": "...", "en": "..."}}',
|
|
1856
|
-
'重要:所有字符串值内部不得包含换行符;直接输出 JSON 结果本身。',
|
|
1857
|
-
].join('\n')
|
|
1858
|
-
const user = [
|
|
1859
|
-
`角色:${role.name}(${role.identity ?? ''})`,
|
|
1860
|
-
`中文锚点:${role.imagePrompt.zh}`,
|
|
1861
|
-
`英文锚点:${role.imagePrompt.en}`,
|
|
1862
|
-
`关键标签:${role.imagePrompt.tags.join('、')}`,
|
|
1863
|
-
`表情清单:${(role.expressions ?? []).join('、') || '(未提供,按角色气质推断 6 个)'}`,
|
|
1864
|
-
'只输出 JSON 对象。',
|
|
1865
|
-
].join('\n\n')
|
|
1866
|
-
const text = await complete(ctx, config, { system, user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low' })
|
|
1867
|
-
const raw = parseJsonObject<Record<string, unknown>>(text)
|
|
1868
|
-
const str = (v: unknown): string => typeof v === 'string' ? v.trim() : ''
|
|
1869
|
-
const pair = (v: unknown): { zh: string; en: string } => {
|
|
1870
|
-
const o = (typeof v === 'object' && v !== null) ? v as Record<string, unknown> : {}
|
|
1871
|
-
return { zh: str(o.zh).slice(0, 800), en: str(o.en).slice(0, 1200) }
|
|
1872
|
-
}
|
|
1873
|
-
const expressionsRaw = Array.isArray(raw.expressions) ? raw.expressions.filter((e): e is Record<string, unknown> => typeof e === 'object' && e !== null) : []
|
|
1874
|
-
const kit: RoleRecord['promptKit'] = {
|
|
1875
|
-
portrait: withStyle(pair(raw.portrait), styleWords),
|
|
1876
|
-
sheet: withStyle(pair(raw.sheet), styleWords),
|
|
1877
|
-
expressions: expressionsRaw.map(e => ({
|
|
1878
|
-
name: str(e.name).slice(0, 12) || '表情',
|
|
1879
|
-
...withStyle({ zh: str(e.zh).slice(0, 800), en: str(e.en).slice(0, 1200) }, styleWords),
|
|
1880
|
-
})).slice(0, 12),
|
|
1881
|
-
details: withStyle(pair(raw.details), styleWords),
|
|
1882
|
-
}
|
|
1883
|
-
if (kit.portrait.zh === '' || kit.portrait.en === '') {
|
|
1884
|
-
throw new Error('提示词精修失败:LLM 未返回有效 JSON')
|
|
1885
|
-
}
|
|
1886
|
-
return kit
|
|
1887
|
-
}
|
|
1888
|
-
|
|
1889
|
-
/** 小说角色库:角色四类生图提示词精修包(写回角色卡)。 */
|
|
1890
|
-
export async function generateRolePromptKit(
|
|
1891
|
-
ctx: Context,
|
|
1892
|
-
config: NovelConfig,
|
|
1893
|
-
project: ProjectState,
|
|
1894
|
-
roleName: string,
|
|
1895
|
-
styleId?: string,
|
|
1896
|
-
filterId?: string,
|
|
1897
|
-
): Promise<RoleRecord['promptKit']> {
|
|
1898
|
-
const role = (project.roles ?? []).find(r => r.name === roleName)
|
|
1899
|
-
if (role === undefined) throw new Error(`角色「${roleName}」不在角色库中`)
|
|
1900
|
-
if (role.imagePrompt === undefined) throw new Error(`角色「${roleName}」还没有形象锚点,请先生成锚点`)
|
|
1901
|
-
role.promptStyleId = styleId
|
|
1902
|
-
return generateRolePromptKitFrom(ctx, config, project, { name: role.name, identity: role.identity, imagePrompt: role.imagePrompt, expressions: role.expressions }, styleId, filterId)
|
|
1903
|
-
}
|
|
1904
|
-
|
|
1905
|
-
/** 漫剧角色卡:四类生图提示词精修包(写回漫剧卡,status → anchored)。 */
|
|
1906
|
-
export async function generateMangaRolePromptKit(
|
|
1907
|
-
ctx: Context,
|
|
1908
|
-
config: NovelConfig,
|
|
1909
|
-
project: ProjectState,
|
|
1910
|
-
cardId: string,
|
|
1911
|
-
styleId?: string,
|
|
1912
|
-
filterId?: string,
|
|
1913
|
-
): Promise<RoleRecord['promptKit']> {
|
|
1914
|
-
const card = (project.mangaRoles ?? []).find(c => c.id === cardId)
|
|
1915
|
-
if (card === undefined) throw new Error(`漫剧角色卡 ${cardId} 不存在`)
|
|
1916
|
-
if (card.imagePrompt === undefined) throw new Error(`「${card.name}」还没有形象锚点,请先生成锚点`)
|
|
1917
|
-
const kit = await generateRolePromptKitFrom(ctx, config, project, { name: card.name, identity: card.identity, imagePrompt: card.imagePrompt, expressions: card.expressions }, styleId, filterId, project.shortDramaMode === true)
|
|
1918
|
-
card.promptStyleId = styleId
|
|
1919
|
-
card.promptKit = kit
|
|
1920
|
-
if (card.status === 'imported' || card.status === 'pending_confirm') card.status = 'anchored'
|
|
1921
|
-
card.updatedAt = new Date().toISOString()
|
|
1922
|
-
return kit
|
|
1923
|
-
}
|
|
1924
|
-
|
|
1925
|
-
/**
|
|
1926
|
-
* 漫剧角色库·提名(两段式):从某章分镜的 characters 提名候选角色名 →
|
|
1927
|
-
* 规则过滤(精确名 + 身份/简称匹配,短名单 ≤5)→ LLM 确认(是/否 + 选哪个,不做开放检索)→
|
|
1928
|
-
* 返回带漫剧卡建议的候选(未匹配时给出「回小说库补提炼 / 漫剧直接创建」判定)。
|
|
1929
|
-
*/
|
|
1930
|
-
export async function nominateMangaRoles(
|
|
1931
|
-
ctx: Context,
|
|
1932
|
-
config: NovelConfig,
|
|
1933
|
-
project: ProjectState,
|
|
1934
|
-
outputDir: string,
|
|
1935
|
-
chapterNo: number,
|
|
1936
|
-
): Promise<MangaRoleCandidate[]> {
|
|
1937
|
-
const entry = (project.storyboards ?? []).find(e => e.chapterNo === chapterNo)
|
|
1938
|
-
if (entry === undefined) throw new Error(`第 ${chapterNo} 章还没有分镜产出,请先在「分镜」页生成剧情骨架/分镜表`)
|
|
1939
|
-
|
|
1940
|
-
// 收集该章分镜的结构化角色引用(骨架 + 表级 + 镜头级,去重)。
|
|
1941
|
-
const names: string[] = []
|
|
1942
|
-
const pushNames = (list: string[] | undefined): void => {
|
|
1943
|
-
for (const n of list ?? []) {
|
|
1944
|
-
const t = n.trim().slice(0, 20)
|
|
1945
|
-
if (t !== '' && !names.includes(t)) names.push(t)
|
|
1946
|
-
}
|
|
1947
|
-
}
|
|
1948
|
-
pushNames(entry.skeleton?.characters)
|
|
1949
|
-
pushNames(entry.table?.characters)
|
|
1950
|
-
for (const s of entry.table?.shots ?? []) pushNames(s.characters)
|
|
1951
|
-
if (names.length === 0) throw new Error(`第 ${chapterNo} 章的分镜还没有结构化角色(characters 为空),请重新生成剧情骨架/分镜表`)
|
|
1952
|
-
|
|
1953
|
-
// 已建漫剧卡的直接标 already_imported(按漫剧名或来源名匹配)。
|
|
1954
|
-
const imported = new Set<string>()
|
|
1955
|
-
for (const c of project.mangaRoles ?? []) {
|
|
1956
|
-
imported.add(c.name)
|
|
1957
|
-
if (c.sourceRoleName !== undefined && c.sourceRoleName !== '') imported.add(c.sourceRoleName)
|
|
1958
|
-
}
|
|
1959
|
-
const already: MangaRoleCandidate[] = names.filter(n => imported.has(n)).map(n => ({
|
|
1960
|
-
rawName: n,
|
|
1961
|
-
verdict: 'already_imported' as const,
|
|
1962
|
-
matches: [],
|
|
1963
|
-
suggested: emptyCandidateSuggestion(n),
|
|
1964
|
-
}))
|
|
1965
|
-
const fresh = names.filter(n => !imported.has(n))
|
|
1966
|
-
if (fresh.length === 0) return already
|
|
1967
|
-
|
|
1968
|
-
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
1969
|
-
const body = chapter !== undefined ? readChapterFile(outputDir, chapter) : undefined
|
|
1970
|
-
const roles = project.roles ?? []
|
|
1971
|
-
|
|
1972
|
-
// 规则阶段:精确名 + 身份/简称匹配,短名单 ≤5。
|
|
1973
|
-
const shortlists: Array<{ rawName: string; roleNames: string[] }> = fresh.map(rawName => {
|
|
1974
|
-
const shortlist: string[] = []
|
|
1975
|
-
const push = (n: string): void => { if (!shortlist.includes(n)) shortlist.push(n) }
|
|
1976
|
-
for (const r of roles) {
|
|
1977
|
-
if (shortlist.length >= 5) break
|
|
1978
|
-
const n = r.name
|
|
1979
|
-
if (n === rawName) { push(n); continue }
|
|
1980
|
-
if (n.includes(rawName) || rawName.includes(n)) { push(n); continue }
|
|
1981
|
-
const ident = r.identity ?? ''
|
|
1982
|
-
if (ident !== '' && (ident === rawName || ident.includes(rawName) || rawName.includes(ident))) push(n)
|
|
1983
|
-
}
|
|
1984
|
-
return { rawName, roleNames: shortlist }
|
|
1985
|
-
})
|
|
1986
|
-
|
|
1987
|
-
// LLM 阶段:确认是/否 + 选哪个 + 漫剧卡建议(只能从短名单里选,不做开放检索)。
|
|
1988
|
-
const beats = (entry.skeleton?.beats ?? []).map(b => `[${b.id}] ${b.event}`).join('\n')
|
|
1989
|
-
const system = [
|
|
1990
|
-
'你是一位漫剧选角导演。下面给出「分镜角色提名」与「小说角色库候选名单」。',
|
|
1991
|
-
'任务:为每个提名判定它对应小说角色库中的哪一个候选(或判定小说库没有对应角色),并预填一张「漫剧角色卡」的建议信息。',
|
|
1992
|
-
'规则:',
|
|
1993
|
-
'1. roleName 只能从该提名的候选名单中选,禁止输出名单之外的名字(不做开放检索)。',
|
|
1994
|
-
'2. verdict:matched=候选名单里确实有对应角色(选最像的那个);ambiguous=名单里有多个候选且无法确定(此时 roleName 可留空或选最可能的一个);not_in_library=候选名单为空或都不对应。',
|
|
1995
|
-
'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」)。',
|
|
1996
|
-
'4. 身份型提名(如「持枪者」「围观群众」):候选名单有则匹配;名单没有且正文确有该称谓 → not_in_library;正文也没有 → not_in_library。',
|
|
1997
|
-
...(project.shortDramaMode === true
|
|
1998
|
-
? ['5. 短剧精简模式(本书已开启):只保留 5-8 个上镜角色——主角/反派/感情线/关键配角;功能性路人不上卡(此类提名直接 not_in_library)。每个角色必须给出明确的 coreFunction 与 protagonistRelation;性格标签极致化;候选超过 8 个时按戏份重要性裁剪到最核心 8 个。']
|
|
1999
|
-
: []),
|
|
2000
|
-
'输出必须是合法 JSON 数组:[{"rawName": "...", "verdict": "...", "roleName": "...或省略", "suggested": {...}}],每个提名都要有,数组长度必须等于提名数。',
|
|
2001
|
-
'重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
2002
|
-
].join('\n')
|
|
2003
|
-
const user = [
|
|
2004
|
-
`章节:第 ${chapterNo} 章《${chapter?.title ?? ''}》`,
|
|
2005
|
-
'==== 剧情骨架(节拍) ====',
|
|
2006
|
-
beats !== '' ? beats : '(无骨架节拍)',
|
|
2007
|
-
'==== 角色提名与候选名单 ====',
|
|
2008
|
-
shortlists.map(s => s.roleNames.length > 0 ? `[提名] ${s.rawName} → 候选:${s.roleNames.join('、')}` : `[提名] ${s.rawName} → 候选:(无)`).join('\n'),
|
|
2009
|
-
body !== undefined ? '==== 章节正文(前 2500 字,判断称谓归属用) ====\n' + body.replace(/^#.*$/gm, '').trim().slice(0, 2500) : '',
|
|
2010
|
-
'只输出 JSON 数组。',
|
|
2011
|
-
].filter(x => x !== '').join('\n\n')
|
|
2012
|
-
const text = await complete(ctx, config, { system, user, temperature: 0.2, maxTokens: Math.max(config.maxTokens, 8000), reasoning: config.analysisReasoning ?? 'low' })
|
|
2013
|
-
const raw = parseJsonArray<Record<string, unknown>>(text)
|
|
2014
|
-
|
|
2015
|
-
const verdicts = new Set(['matched', 'ambiguous', 'not_in_library'])
|
|
2016
|
-
const fnMap: Record<string, MangaRoleCard['coreFunction']> = { protagonist: 'protagonist', mentor: 'mentor', love_interest: 'love_interest', antagonist: 'antagonist', sidekick: 'sidekick', informant: 'informant', functional: 'functional' }
|
|
2017
|
-
const relMap: Record<string, MangaRoleCard['protagonistRelation']> = { enemy: 'enemy', friend: 'friend', mentor: 'mentor', lover: 'lover', exploit: 'exploit', neutral: 'neutral' }
|
|
2018
|
-
const byName = new Map<string, Record<string, unknown>>()
|
|
2019
|
-
for (const e of raw) {
|
|
2020
|
-
if (typeof e === 'object' && e !== null && typeof e.rawName === 'string') byName.set(e.rawName.trim(), e)
|
|
2021
|
-
}
|
|
2022
|
-
const str = (v: unknown): string => typeof v === 'string' ? v.trim().slice(0, 100) : ''
|
|
2023
|
-
const strArr = (v: unknown, n: number): string[] => Array.isArray(v)
|
|
2024
|
-
? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '').map(x => x.trim().slice(0, 20)).slice(0, n)
|
|
2025
|
-
: []
|
|
2026
|
-
|
|
2027
|
-
const out: MangaRoleCandidate[] = []
|
|
2028
|
-
for (const item of shortlists) {
|
|
2029
|
-
const judge = byName.get(item.rawName)
|
|
2030
|
-
const judgeVerdict = judge !== undefined ? str(judge.verdict) : ''
|
|
2031
|
-
const verdict = verdicts.has(judgeVerdict) ? judgeVerdict as MangaRoleCandidate['verdict'] : (item.roleNames.length > 0 ? 'ambiguous' : 'not_in_library')
|
|
2032
|
-
let roleName: string | undefined
|
|
2033
|
-
if (judge !== undefined) {
|
|
2034
|
-
const chosen = str(judge.roleName)
|
|
2035
|
-
if (chosen !== '' && item.roleNames.includes(chosen)) roleName = chosen
|
|
2036
|
-
}
|
|
2037
|
-
if (verdict === 'matched' && roleName === undefined && item.roleNames.length === 1) roleName = item.roleNames[0]
|
|
2038
|
-
const sug = (typeof judge?.suggested === 'object' && judge?.suggested !== null) ? judge.suggested as Record<string, unknown> : {}
|
|
2039
|
-
const novelHint: MangaRoleCandidate['novelHint'] = verdict === 'not_in_library' && body !== undefined
|
|
2040
|
-
? (body.includes(item.rawName) ? 'backfill' : 'manga_new')
|
|
2041
|
-
: undefined
|
|
2042
|
-
const suggested = {
|
|
2043
|
-
name: (str(sug.name) !== '' ? str(sug.name) : (roleName ?? item.rawName)).slice(0, 30),
|
|
2044
|
-
identity: str(sug.identity).slice(0, 60),
|
|
2045
|
-
coreFunction: fnMap[str(sug.coreFunction)] ?? 'functional',
|
|
2046
|
-
protagonistRelation: relMap[str(sug.protagonistRelation)] ?? 'neutral',
|
|
2047
|
-
speechStyle: str(sug.speechStyle).slice(0, 60),
|
|
2048
|
-
traits: strArr(sug.traits, 3),
|
|
2049
|
-
appearance: str(sug.appearance).slice(0, 100),
|
|
2050
|
-
keyScenes: strArr(sug.keyScenes, 3),
|
|
2051
|
-
}
|
|
2052
|
-
out.push({
|
|
2053
|
-
rawName: item.rawName,
|
|
2054
|
-
verdict,
|
|
2055
|
-
matches: item.roleNames.map(n => ({ roleName: n, reason: roleName === n ? 'LLM 确认' : '规则候选' })),
|
|
2056
|
-
matchedRoleName: roleName,
|
|
2057
|
-
novelHint,
|
|
2058
|
-
suggested,
|
|
2059
|
-
})
|
|
2060
|
-
}
|
|
2061
|
-
return [...already, ...out]
|
|
2062
|
-
}
|
|
2063
|
-
|
|
2064
|
-
/** 空建议(already_imported 等无需 LLM 的候选用)。 */
|
|
2065
|
-
function emptyCandidateSuggestion(name: string): MangaRoleCandidate['suggested'] {
|
|
2066
|
-
return { name, identity: '', coreFunction: 'functional', protagonistRelation: 'neutral', speechStyle: '', traits: [], appearance: '', keyScenes: [] }
|
|
2067
|
-
}
|
|
2068
|
-
|
|
2069
|
-
/** ✨ 从道藏/红线提炼「视觉世界观规则」:生图/生视频必须遵守的设定纠偏(如"商品=人,禁止常规超市商品")。 */
|
|
2070
|
-
export async function extractVisualRules(
|
|
2071
|
-
ctx: Context,
|
|
2072
|
-
config: NovelConfig,
|
|
2073
|
-
project: ProjectState,
|
|
2074
|
-
): Promise<string[]> {
|
|
2075
|
-
const system = [
|
|
2076
|
-
'你是一位 AI 绘图的视觉规则设计师。本书的设定是"反常识"的,生图/生视频模型默认会画成现实世界的样子,你需要提炼 3-6 条「视觉规则」钉住本书的视觉世界观。',
|
|
2077
|
-
'规则要求:',
|
|
2078
|
-
'1. 每条必须可执行:明确"画面里必须出现什么/禁止出现什么"(如"货架上的一切商品都是活人,禁止画成罐头/饮料/日用品")。',
|
|
2079
|
-
'2. 覆盖本书最容易被模型画错的 2-4 个核心反常识点。',
|
|
2080
|
-
'3. 每条 40 字内,禁止泛泛而谈。',
|
|
2081
|
-
'输出必须是合法 JSON 数组:["规则1", "规则2", ...],不要输出其他文字。',
|
|
2082
|
-
].join('\n')
|
|
2083
|
-
const bible = project.bible
|
|
2084
|
-
const user = [
|
|
2085
|
-
`书名:《${project.bookName}》`,
|
|
2086
|
-
`题材:${bible?.genre ?? ''}`,
|
|
2087
|
-
bible !== undefined && bible.worldRules.length > 0
|
|
2088
|
-
? `世界规则:\n${bible.worldRules.map(r => '- ' + r).join('\n')}`
|
|
2089
|
-
: '',
|
|
2090
|
-
bible !== undefined && bible.redLines.length > 0
|
|
2091
|
-
? `红线:\n${bible.redLines.map(r => '- ' + r).join('\n')}`
|
|
2092
|
-
: '',
|
|
2093
|
-
'只输出 JSON 数组。',
|
|
2094
|
-
].filter(s => s !== '').join('\n\n')
|
|
2095
|
-
const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 4000), reasoning: config.analysisReasoning ?? 'low' })
|
|
2096
|
-
const raw = parseJsonArray<string>(text)
|
|
2097
|
-
return raw.map(r => typeof r === 'string' ? r.trim().slice(0, 80) : '').filter(r => r !== '').slice(0, 8)
|
|
2098
|
-
}
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
/**
|
|
2102
|
-
* 开书想法 → AI 大纲:输入一句话想法,生成 2-3 个方向不同、可直接开书的完整大纲方案。
|
|
2103
|
-
* @param count 本次生成几个(默认 3,最多 3)
|
|
2104
|
-
* @param exclude 已暂留方案的剧情方向/卖点摘要(换批时避开,防止重复)
|
|
2105
|
-
*/
|
|
2106
|
-
export async function suggestOutlines(
|
|
2107
|
-
ctx: Context,
|
|
2108
|
-
config: NovelConfig,
|
|
2109
|
-
idea: string,
|
|
2110
|
-
count = 3,
|
|
2111
|
-
exclude: string[] = [],
|
|
2112
|
-
): Promise<OutlineCandidate[]> {
|
|
2113
|
-
const n = Math.max(1, Math.min(3, Math.floor(count)))
|
|
2114
|
-
const system = [
|
|
2115
|
-
'你是一位资深网文策划。作者只给了一句「想法」,你需要把它扩展成 2-3 个【方向差异明显】的完整小说大纲方案,供作者挑选。',
|
|
2116
|
-
'每个方案必须满足:',
|
|
2117
|
-
'1. bookName:书名(6 字以内,抓眼球、点题)。',
|
|
2118
|
-
'2. genre:题材(如 仙侠修真 / 都市异能 / 玄幻 / 悬疑)。',
|
|
2119
|
-
'3. sellingPoint:核心卖点一句话(金手指/爽点/差异化,40 字内)。',
|
|
2120
|
-
'4. outline:完整大纲文本(至少 800 字,可直接作为开书大纲),结构包含:书名与题材、金手指/核心设定、主角人设与动机、主线剧情走向(至少 5 个阶段)、关键配角与势力、卖点与爽点设计、预计分卷(3-5 卷)。',
|
|
2121
|
-
'方向差异要求:',
|
|
2122
|
-
'- 方案之间的金手指/剧情走向必须明显不同(如:苟道发育流 vs 随身老爷爷流 vs 群像争霸流),不能只是换书名。',
|
|
2123
|
-
'- 忠实于作者想法的核心要素,但允许在不同方向上进行合理演绎。',
|
|
2124
|
-
'- 不输出任何与已列「需避开的方向」雷同的方案。',
|
|
2125
|
-
'输出必须是合法 JSON 数组,只输出数组本身:',
|
|
2126
|
-
'[{"id": "唯一id", "bookName": "...", "genre": "...", "sellingPoint": "...", "outline": "..."}]',
|
|
2127
|
-
`本次只输出 ${n} 个方案。`,
|
|
2128
|
-
'重要:所有字符串值内部不得包含换行符(大纲内部分段请用「。\n」或「;」自然断句),JSON 必须在一段内完整结束。',
|
|
2129
|
-
'重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。',
|
|
2130
|
-
].join('\n')
|
|
2131
|
-
const user = [
|
|
2132
|
-
`作者的想法:${idea}`,
|
|
2133
|
-
idea.trim().length < 40
|
|
2134
|
-
? '作者的想法非常简短(可能只有一句)。请基于通用网文套路合理扩展补全:为每个方案自洽地设计金手指/核心设定、主角人设与动机、主线走向,使其成为完整可开书的大纲;不同方案的方向仍须明显差异。'
|
|
2135
|
-
: '',
|
|
2136
|
-
exclude.length > 0
|
|
2137
|
-
? `需避开的已暂留方案方向(新方案不得与之雷同):\n${exclude.map((e, i) => `${i + 1}. ${e}`).join('\n')}`
|
|
2138
|
-
: '',
|
|
2139
|
-
`请生成 ${n} 个大纲方案。`,
|
|
2140
|
-
'只输出 JSON 数组。',
|
|
2141
|
-
].join('\n\n')
|
|
2142
|
-
const text = await complete(ctx, config, { system, user, temperature: 0.85, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low' })
|
|
2143
|
-
const parsed = parseJsonArray<Record<string, unknown>>(text)
|
|
2144
|
-
const candidates: OutlineCandidate[] = []
|
|
2145
|
-
for (const entry of parsed) {
|
|
2146
|
-
if (typeof entry !== 'object' || entry === null) continue
|
|
2147
|
-
const bookName = typeof entry.bookName === 'string' ? entry.bookName.trim().slice(0, 30) : ''
|
|
2148
|
-
const outline = typeof entry.outline === 'string' ? entry.outline.trim() : ''
|
|
2149
|
-
if (bookName === '' || outline.length < 300) continue
|
|
2150
|
-
candidates.push({
|
|
2151
|
-
id: typeof entry.id === 'string' && entry.id !== '' ? entry.id : `oc-${Date.now().toString(36)}-${candidates.length}`,
|
|
2152
|
-
bookName,
|
|
2153
|
-
genre: typeof entry.genre === 'string' ? entry.genre.trim().slice(0, 20) : '',
|
|
2154
|
-
sellingPoint: typeof entry.sellingPoint === 'string' ? entry.sellingPoint.trim().slice(0, 120) : '',
|
|
2155
|
-
outline,
|
|
2156
|
-
})
|
|
2157
|
-
}
|
|
2158
|
-
if (candidates.length === 0) {
|
|
2159
|
-
throw new Error('大纲方案生成失败:LLM 未返回有效 JSON(可重试)')
|
|
2160
|
-
}
|
|
2161
|
-
return candidates.slice(0, n)
|
|
2162
|
-
}
|
|
2163
|
-
|
|
2164
|
-
/** 拆书分析:对已写章节做结构/人物/文风/卖点四维体检。
|
|
2165
|
-
* 两阶段管道(借鉴 AI-Novel-Writing-Assistant):
|
|
2166
|
-
* ① 源片段笔记:每章抽取结构化笔记(剧情/人物/设定/写法/卖点/短板信号)
|
|
2167
|
-
* ② 分节分析:按维度各跑一次 LLM,输出可读分析稿 + 结构化数据 + 证据链。
|
|
2168
|
-
* @param scope 'recent'(默认最近20章) | 'volume:N' | 'all'
|
|
2169
|
-
* @param preset 'quick'(总览/剧情/人物/文风) | 'standard'(+卖点)
|
|
2170
|
-
* @param budgetTokens token 预算上限(超过即截断章节取样)。
|
|
2171
|
-
*/
|
|
2172
|
-
export async function breakdownBook(
|
|
2173
|
-
ctx: Context,
|
|
2174
|
-
config: NovelConfig,
|
|
2175
|
-
project: ProjectState,
|
|
2176
|
-
outputDir: string,
|
|
2177
|
-
scope = 'recent',
|
|
2178
|
-
preset: 'quick' | 'standard' = 'quick',
|
|
2179
|
-
budgetTokens = 50000,
|
|
2180
|
-
): Promise<BreakdownResponse> {
|
|
2181
|
-
// 1. 选章节范围。
|
|
2182
|
-
const written = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.summary !== undefined && c.summary !== '')
|
|
2183
|
-
let selected = written
|
|
2184
|
-
if (scope === 'recent') selected = written.slice(-20)
|
|
2185
|
-
else if (/^volume:\d+$/.test(scope)) {
|
|
2186
|
-
const v = Number(scope.slice(7))
|
|
2187
|
-
selected = written.filter(c => c.volume === v)
|
|
2188
|
-
}
|
|
2189
|
-
if (selected.length === 0) throw new Error('没有可分析的已写章节(需要已生成并带摘要)')
|
|
2190
|
-
|
|
2191
|
-
// 2. token 预算:估算每章正文+摘要成本,超预算则只取最近的章节。
|
|
2192
|
-
let budget = budgetTokens
|
|
2193
|
-
const chunks: Array<{ no: number; title: string; summary: string; body: string }> = []
|
|
2194
|
-
for (const c of selected.slice().reverse()) {
|
|
2195
|
-
const body = readChapterFile(outputDir, c) ?? ''
|
|
2196
|
-
// 粗估:每 4 字符 ≈ 1 token(中文),章节正文截 4000 字上限。
|
|
2197
|
-
const bodySlice = body.replace(/^#\s+.*$/m, '').trim().slice(0, 4000)
|
|
2198
|
-
const est = Math.ceil((bodySlice.length + (c.summary?.length ?? 0)) / 4) + 400
|
|
2199
|
-
if (est > budget && chunks.length > 0) break
|
|
2200
|
-
chunks.unshift({ no: c.no, title: c.title, summary: c.summary ?? '', body: bodySlice })
|
|
2201
|
-
budget -= est
|
|
2202
|
-
}
|
|
1942
|
+
// 2. token 预算:估算每章正文+摘要成本,超预算则只取最近的章节。
|
|
1943
|
+
let budget = budgetTokens
|
|
1944
|
+
const chunks: Array<{ no: number; title: string; summary: string; body: string }> = []
|
|
1945
|
+
for (const c of selected.slice().reverse()) {
|
|
1946
|
+
const body = readChapterFile(outputDir, c) ?? ''
|
|
1947
|
+
// 粗估:每 4 字符 ≈ 1 token(中文),章节正文截 4000 字上限。
|
|
1948
|
+
const bodySlice = body.replace(/^#\s+.*$/m, '').trim().slice(0, 4000)
|
|
1949
|
+
const est = Math.ceil((bodySlice.length + (c.summary?.length ?? 0)) / 4) + 400
|
|
1950
|
+
if (est > budget && chunks.length > 0) break
|
|
1951
|
+
chunks.unshift({ no: c.no, title: c.title, summary: c.summary ?? '', body: bodySlice })
|
|
1952
|
+
budget -= est
|
|
1953
|
+
}
|
|
2203
1954
|
|
|
2204
1955
|
// 3. 阶段一:源片段笔记(每章一次 LLM,串行控制 token)。
|
|
2205
1956
|
const notes: string[] = []
|
|
@@ -2327,40 +2078,6 @@ export async function breakdownBook(
|
|
|
2327
2078
|
}
|
|
2328
2079
|
}
|
|
2329
2080
|
|
|
2330
|
-
/** 生图接口连通性测试(设置页模型条目用):GET {baseURL}/models,计时返回延迟。 */
|
|
2331
|
-
export async function testImageEndpoint(baseURL: string, apiKey: string, model?: string): Promise<{ ok: boolean; ms: number; message?: string; modelFound?: boolean }> {
|
|
2332
|
-
const trimmed = baseURL.trim()
|
|
2333
|
-
if (trimmed === '') return { ok: false, ms: 0, message: '接口地址不能为空' }
|
|
2334
|
-
const url = trimmed.replace(/\/+$/, '') + '/models'
|
|
2335
|
-
const start = Date.now()
|
|
2336
|
-
try {
|
|
2337
|
-
const res = await fetch(url, {
|
|
2338
|
-
method: 'GET',
|
|
2339
|
-
headers: { Authorization: 'Bearer ' + apiKey, 'Content-Type': 'application/json' },
|
|
2340
|
-
})
|
|
2341
|
-
const ms = Date.now() - start
|
|
2342
|
-
if (!res.ok) {
|
|
2343
|
-
const text = (await res.text().catch(() => '')).slice(0, 200)
|
|
2344
|
-
return { ok: false, ms, message: 'HTTP ' + res.status + (text !== '' ? ':' + text : '') }
|
|
2345
|
-
}
|
|
2346
|
-
let modelFound: boolean | undefined
|
|
2347
|
-
if (model !== undefined && model !== '') {
|
|
2348
|
-
const json = await res.json().catch(() => null)
|
|
2349
|
-
const list = (json as { data?: unknown } | null)?.data
|
|
2350
|
-
if (Array.isArray(list)) {
|
|
2351
|
-
modelFound = list.some(m => {
|
|
2352
|
-
if (typeof m !== 'object' || m === null) return false
|
|
2353
|
-
const o = m as Record<string, unknown>
|
|
2354
|
-
return o.id === model || o.model === model
|
|
2355
|
-
})
|
|
2356
|
-
}
|
|
2357
|
-
}
|
|
2358
|
-
return { ok: true, ms, modelFound }
|
|
2359
|
-
} catch (err) {
|
|
2360
|
-
return { ok: false, ms: Date.now() - start, message: (err as Error).message }
|
|
2361
|
-
}
|
|
2362
|
-
}
|
|
2363
|
-
|
|
2364
2081
|
// ---------------------------------------------------------- llm catalog/test
|
|
2365
2082
|
|
|
2366
2083
|
/** LLM 连通性失败的错误码 → 人话(供设置页“测试连通”回显)。 */
|
|
@@ -2393,13 +2110,16 @@ export async function testLlmModel(ctx: Context, provider: string, model: string
|
|
|
2393
2110
|
content: [{ type: 'text', text: '只回复两个字:OK' }],
|
|
2394
2111
|
source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
|
|
2395
2112
|
})]
|
|
2396
|
-
|
|
2113
|
+
// 这里自己构造请求(为省 token 拿到首块即停),所以**必须显式过一遍能力层**:
|
|
2114
|
+
// 否则「测试连通」会原样下发参数,对固定温度的模型必然报错 —— 而测试连通
|
|
2115
|
+
// 正是用户用来判断配置对不对的地方。
|
|
2116
|
+
const request = applyModelCapability({
|
|
2397
2117
|
provider,
|
|
2398
2118
|
model,
|
|
2399
2119
|
messages,
|
|
2400
2120
|
maxTokens: 16,
|
|
2401
2121
|
temperature: 0,
|
|
2402
|
-
}
|
|
2122
|
+
}, model)
|
|
2403
2123
|
// 真实流式调用 + 30 秒超时(GenerateOptions.signal 由适配器响应并中止)。
|
|
2404
2124
|
const controller = new AbortController()
|
|
2405
2125
|
const timer = setTimeout(() => controller.abort(), 30_000)
|
|
@@ -2454,11 +2174,22 @@ export async function listLlmVendors(ctx: Context): Promise<LlmVendorsResponse>
|
|
|
2454
2174
|
}
|
|
2455
2175
|
}
|
|
2456
2176
|
} catch { /* 单个目录读取失败跳过 */ }
|
|
2457
|
-
// 已注册的适配器路由(如 deepseek-official
|
|
2177
|
+
// 已注册的适配器路由(如 deepseek-official):以 DSH 运行时目录为准刷新模型列表,
|
|
2178
|
+
// 否则 LLM_VENDORS 里的预置模型一旦过期(模型改名/下线),下拉里就会挂着不存在的模型。
|
|
2458
2179
|
try {
|
|
2459
2180
|
for (const p of ctx.llm.listProviders()) {
|
|
2460
|
-
|
|
2461
|
-
|
|
2181
|
+
const name = p.name !== '' && p.name !== p.id ? p.name : p.id
|
|
2182
|
+
let models: string[] = []
|
|
2183
|
+
try {
|
|
2184
|
+
models = (await ctx.llm.listModels(p.id)).map(m => m.id).filter(id => id !== '')
|
|
2185
|
+
} catch { /* 该 provider 目录不可用 → 保留预置兜底 */ }
|
|
2186
|
+
const existing = map.get(p.id)
|
|
2187
|
+
if (existing === undefined) {
|
|
2188
|
+
map.set(p.id, { id: p.id, name, models, builtin: true })
|
|
2189
|
+
} else if (models.length > 0) {
|
|
2190
|
+
// 运行时目录优先:预置列表只作为目录不可用时的兜底
|
|
2191
|
+
existing.models = models
|
|
2192
|
+
if (existing.name === '' || existing.name === p.id) existing.name = name
|
|
2462
2193
|
}
|
|
2463
2194
|
}
|
|
2464
2195
|
} catch { /* ignore */ }
|
|
@@ -2569,101 +2300,7 @@ export async function registerLlmModel(ctx: Context, req: AddModelRequest): Prom
|
|
|
2569
2300
|
return { ok: true, saved, provider: route, message }
|
|
2570
2301
|
}
|
|
2571
2302
|
|
|
2572
|
-
/**
|
|
2573
|
-
async function generateReferenceImageFrom(
|
|
2574
|
-
config: NovelConfig,
|
|
2575
|
-
anchor: NonNullable<RoleRecord['imagePrompt']>,
|
|
2576
|
-
style = '',
|
|
2577
|
-
modelId?: string,
|
|
2578
|
-
): Promise<string> {
|
|
2579
|
-
if (config.imageApiEnabled !== true) throw new Error('生图未启用,请在设置中启用一个生图模型')
|
|
2580
|
-
const activeImage = (modelId !== undefined && modelId !== '' ? (config.imageModels ?? []).find(m => m.id === modelId) : undefined)
|
|
2581
|
-
?? (config.imageModels ?? []).find(m => m.enabled)
|
|
2582
|
-
?? (config.imageModels ?? [])[0]
|
|
2583
|
-
const apiKey = activeImage?.apiKey ?? config.imageApiKey
|
|
2584
|
-
const model = activeImage?.model ?? config.imageApiModel
|
|
2585
|
-
const baseURL = (activeImage?.baseURL ?? config.imageBaseUrl ?? '').replace(/\/+$/, '')
|
|
2586
|
-
if (baseURL === '') throw new Error('请先在设置中填写生图接口地址')
|
|
2587
|
-
if (apiKey === undefined || apiKey === '' || model === undefined || model === '') {
|
|
2588
|
-
throw new Error('请先在设置中配置生图模型的 API Key 和模型 id')
|
|
2589
|
-
}
|
|
2590
|
-
const stylePrompt = getComicStylePrompt(style)
|
|
2591
|
-
const prompt = [
|
|
2592
|
-
anchor.zh,
|
|
2593
|
-
anchor.en,
|
|
2594
|
-
anchor.tags.join(', '),
|
|
2595
|
-
stylePrompt,
|
|
2596
|
-
'character reference sheet, front view, clean background, consistent design',
|
|
2597
|
-
].filter(Boolean).join(', ')
|
|
2598
|
-
|
|
2599
|
-
const res = await fetch(baseURL + '/images/generations', {
|
|
2600
|
-
method: 'POST',
|
|
2601
|
-
headers: {
|
|
2602
|
-
'Content-Type': 'application/json',
|
|
2603
|
-
Authorization: `Bearer ${apiKey}`,
|
|
2604
|
-
},
|
|
2605
|
-
body: JSON.stringify({
|
|
2606
|
-
model,
|
|
2607
|
-
prompt,
|
|
2608
|
-
response_format: 'url',
|
|
2609
|
-
size: '2K',
|
|
2610
|
-
stream: false,
|
|
2611
|
-
watermark: false,
|
|
2612
|
-
}),
|
|
2613
|
-
})
|
|
2614
|
-
if (!res.ok) {
|
|
2615
|
-
const text = await res.text()
|
|
2616
|
-
throw new Error(`生图失败(${res.status}):${text}`)
|
|
2617
|
-
}
|
|
2618
|
-
const json = (await res.json()) as { data?: Array<{ url?: string }> }
|
|
2619
|
-
const url = json.data?.[0]?.url
|
|
2620
|
-
if (url === undefined || url === '') throw new Error('生图接口未返回图片 URL')
|
|
2621
|
-
|
|
2622
|
-
const imgRes = await fetch(url)
|
|
2623
|
-
if (!imgRes.ok) throw new Error('下载生图结果失败')
|
|
2624
|
-
const buf = Buffer.from(await imgRes.arrayBuffer())
|
|
2625
|
-
const contentType = imgRes.headers.get('content-type') || 'image/jpeg'
|
|
2626
|
-
return `data:${contentType};base64,${buf.toString('base64')}`
|
|
2627
|
-
}
|
|
2628
|
-
|
|
2629
|
-
/** 小说角色库:生成角色定妆图并写回角色卡(imageUrl)。 */
|
|
2630
|
-
export async function generateRoleReferenceImage(
|
|
2631
|
-
ctx: Context,
|
|
2632
|
-
config: NovelConfig,
|
|
2633
|
-
project: ProjectState,
|
|
2634
|
-
outputDir: string,
|
|
2635
|
-
name: string,
|
|
2636
|
-
style = '',
|
|
2637
|
-
modelId?: string,
|
|
2638
|
-
): Promise<string> {
|
|
2639
|
-
const role = project.roles?.find(r => r.name === name)
|
|
2640
|
-
if (role === undefined) throw new Error(`角色 ${name} 不存在`)
|
|
2641
|
-
if (role.imagePrompt === undefined) throw new Error(`角色 ${name} 还没有形象锚点,请先生成锚点`)
|
|
2642
|
-
const dataUrl = await generateReferenceImageFrom(config, role.imagePrompt, style, modelId)
|
|
2643
|
-
role.imageUrl = dataUrl
|
|
2644
|
-
return dataUrl
|
|
2645
|
-
}
|
|
2646
|
-
|
|
2647
|
-
/** 漫剧角色卡:生成定妆图并写回漫剧卡(imageUrl)。 */
|
|
2648
|
-
export async function generateMangaRoleReferenceImage(
|
|
2649
|
-
ctx: Context,
|
|
2650
|
-
config: NovelConfig,
|
|
2651
|
-
project: ProjectState,
|
|
2652
|
-
outputDir: string,
|
|
2653
|
-
cardId: string,
|
|
2654
|
-
style = '',
|
|
2655
|
-
modelId?: string,
|
|
2656
|
-
): Promise<string> {
|
|
2657
|
-
const card = (project.mangaRoles ?? []).find(c => c.id === cardId)
|
|
2658
|
-
if (card === undefined) throw new Error(`漫剧角色卡 ${cardId} 不存在`)
|
|
2659
|
-
if (card.imagePrompt === undefined) throw new Error(`「${card.name}」还没有形象锚点,请先生成锚点`)
|
|
2660
|
-
const dataUrl = await generateReferenceImageFrom(config, card.imagePrompt, style, modelId)
|
|
2661
|
-
card.imageUrl = dataUrl
|
|
2662
|
-
card.updatedAt = new Date().toISOString()
|
|
2663
|
-
return dataUrl
|
|
2664
|
-
}
|
|
2665
|
-
|
|
2666
|
-
/** 🩺 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
|
|
2303
|
+
/** 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
|
|
2667
2304
|
export async function analyzePlotlineHealth(
|
|
2668
2305
|
ctx: Context,
|
|
2669
2306
|
config: NovelConfig,
|
|
@@ -2722,7 +2359,7 @@ export async function analyzePlotlineHealth(
|
|
|
2722
2359
|
}
|
|
2723
2360
|
}
|
|
2724
2361
|
|
|
2725
|
-
/**
|
|
2362
|
+
/** 剧情方案:基于健康检查结果设计下一阶段方向与建议新线。 */
|
|
2726
2363
|
export async function designPlotlinePlan(
|
|
2727
2364
|
ctx: Context,
|
|
2728
2365
|
config: NovelConfig,
|
|
@@ -2782,8 +2419,9 @@ export async function designPlotlinePlan(
|
|
|
2782
2419
|
}
|
|
2783
2420
|
|
|
2784
2421
|
/** Build the rewrite system prompt (fix review issues / instructions). */
|
|
2785
|
-
function rewriteSystemPrompt(project: ProjectState): string {
|
|
2786
|
-
|
|
2422
|
+
function rewriteSystemPrompt(project: ProjectState, targetChars?: number, beforeChapter?: number): string {
|
|
2423
|
+
// 整章修订以「与原文相当」为准,不套用目标字数区间(避免与原文长度冲突)。
|
|
2424
|
+
const base = writeSystemPrompt(project, targetChars, '1. 输出完整的新正文(不要只输出修改片段、标题、章回名、作者的话或任何 Markdown 标记),字数与原章相当(允许 ±20%)。', beforeChapter)
|
|
2787
2425
|
return base + '\n\n额外要求:你正在【修订】一章已写好的正文。保留原文中好的部分,只修改需要修改的地方,输出完整的新正文(不要只输出修改片段),字数与原文相当。'
|
|
2788
2426
|
}
|
|
2789
2427
|
|
|
@@ -2803,9 +2441,9 @@ export async function* rewriteChapterStream(
|
|
|
2803
2441
|
target?: string,
|
|
2804
2442
|
): AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'drafted'; chars: number; draft: string }, void, unknown> {
|
|
2805
2443
|
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
2806
|
-
if (chapter === undefined) throw new
|
|
2444
|
+
if (chapter === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 不在计划中`)
|
|
2807
2445
|
const body = readChapterFile(outputDir, chapter)
|
|
2808
|
-
if (body === undefined) throw new
|
|
2446
|
+
if (body === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 的正文文件不存在`)
|
|
2809
2447
|
|
|
2810
2448
|
const reviewBlock = chapter.review !== undefined
|
|
2811
2449
|
? '审稿意见:\n' + chapter.review.issues.map(i => `[${i.severity}] ${i.item} → ${i.suggestion}`).join('\n')
|
|
@@ -2824,7 +2462,7 @@ export async function* rewriteChapterStream(
|
|
|
2824
2462
|
const paragraphs = bodyText.split(/\n{2,}/)
|
|
2825
2463
|
const idx = paragraphs.findIndex(p => normalize(p).includes(wantedFlat))
|
|
2826
2464
|
if (idx === -1) {
|
|
2827
|
-
throw new
|
|
2465
|
+
throw new NovelActionError('contract', `在正文中未找到要修改的片段:「${wanted.slice(0, 40)}…」。请从正文中复制原文片段(无需整段,取片段即可)。`)
|
|
2828
2466
|
}
|
|
2829
2467
|
localTarget = {
|
|
2830
2468
|
paragraph: paragraphs[idx]!,
|
|
@@ -2854,8 +2492,33 @@ export async function* rewriteChapterStream(
|
|
|
2854
2492
|
].filter(line => line !== '').join('\n')
|
|
2855
2493
|
|
|
2856
2494
|
const system = localTarget === undefined
|
|
2857
|
-
? rewriteSystemPrompt(project)
|
|
2858
|
-
:
|
|
2495
|
+
? rewriteSystemPrompt(project, chapter.targetChars || config.chapterChars, chapter.no)
|
|
2496
|
+
: (() => {
|
|
2497
|
+
// 局部修订:补齐合规红线/本书红线/反AI规则/角色卡,加「只改表达不改情节」约束
|
|
2498
|
+
const bible = project.bible
|
|
2499
|
+
const lines = [
|
|
2500
|
+
'你是一位中文网文润色师。你会收到一章中的一个段落,请按修改要求重写该段。',
|
|
2501
|
+
'硬性约束:',
|
|
2502
|
+
'1. 只改表达,不改情节走向、人物设定、已确立事实、对话核心内容。',
|
|
2503
|
+
'2. 必须遵守下方「内容合规红线」,任何一条命中(含影射、暗示)都必须避免。',
|
|
2504
|
+
'3. 必须遵守下方「本书红线」(如有)。',
|
|
2505
|
+
'4. 避免 AI 套话:不禁、仿佛、一时间、不由得、顿时、然而、缓缓、轻轻、微微、默默、似乎、终于等滥用。',
|
|
2506
|
+
'5. 保留角色口吻与性格,角色行为需符合下方角色卡(如有)。',
|
|
2507
|
+
'6. 只输出修改后的【这一个段落】的完整新文本,不要输出任何说明、标题或 Markdown 标记。',
|
|
2508
|
+
]
|
|
2509
|
+
if (bible !== undefined) {
|
|
2510
|
+
if (bible.redLines.length > 0) lines.push('本书红线:\n' + bible.redLines.map(r => '- ' + r).join('\n'))
|
|
2511
|
+
if (bible.characters.length > 0) {
|
|
2512
|
+
lines.push('相关角色卡:')
|
|
2513
|
+
for (const card of bible.characters) {
|
|
2514
|
+
lines.push('- ' + card.name + '(' + card.role + '):' + card.traits.join('、'))
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
lines.push('内容合规红线(平台硬性要求,最高优先级):')
|
|
2519
|
+
lines.push(COMPLIANCE_REDLINES.join('\n'))
|
|
2520
|
+
return lines.join('\n')
|
|
2521
|
+
})()
|
|
2859
2522
|
|
|
2860
2523
|
const messages: Message[] = [createUserMessage({
|
|
2861
2524
|
content: [{ type: 'text', text: user }],
|
|
@@ -2874,24 +2537,43 @@ export async function* rewriteChapterStream(
|
|
|
2874
2537
|
}
|
|
2875
2538
|
|
|
2876
2539
|
yield { frame: 'start' }
|
|
2877
|
-
const
|
|
2540
|
+
const primaryModel = request.model ?? config.model
|
|
2541
|
+
let rewritten = ''
|
|
2878
2542
|
let streamError: Error | undefined
|
|
2879
|
-
for
|
|
2880
|
-
|
|
2881
|
-
if (
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
.
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
.
|
|
2543
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
2544
|
+
const model = attempt === 0 ? primaryModel : (config.fallbackModel ?? '').trim()
|
|
2545
|
+
if (model === '') break
|
|
2546
|
+
const live = beginLiveCall({
|
|
2547
|
+
label: `修订 · 第${chapterNo}章`,
|
|
2548
|
+
model,
|
|
2549
|
+
system: request.system,
|
|
2550
|
+
user: messages[0]?.content !== undefined ? JSON.stringify(messages[0].content).slice(0, 2000) : undefined,
|
|
2551
|
+
})
|
|
2552
|
+
const assembler = new BlockAssembler()
|
|
2553
|
+
let produced = 0
|
|
2554
|
+
for await (const chunk of ctx.llm.stream(applyModelCapability(request, model))) {
|
|
2555
|
+
assembler.push(chunk)
|
|
2556
|
+
if (chunk.type === 'text-delta') { produced += chunk.text.length; markFirstToken(live); yield { frame: 'delta', text: chunk.text } }
|
|
2557
|
+
}
|
|
2558
|
+
const finish = assembler.finish
|
|
2559
|
+
streamError = undefined
|
|
2560
|
+
if (finish.kind === 'error' || finish.kind === 'aborted') {
|
|
2561
|
+
streamError = new Error(`修订失败(${finish.kind}): ${finish.failure.message}`)
|
|
2562
|
+
} else if (finish.kind === 'max-tokens') {
|
|
2563
|
+
streamError = new Error('修订输出达到 maxTokens 上限,请增大配置后重试')
|
|
2564
|
+
}
|
|
2565
|
+
rewritten = assembler
|
|
2566
|
+
.blocks()
|
|
2567
|
+
.filter((block): block is Extract<StreamChunk, { type: 'block-end' }>['block'] & { type: 'text' } => block.type === 'text')
|
|
2568
|
+
.map(block => block.text)
|
|
2569
|
+
.join('\n')
|
|
2570
|
+
.trim()
|
|
2571
|
+
endLiveCall(live, assembler, streamError === undefined
|
|
2572
|
+
? { chars: rewritten.length, preview: rewritten.slice(0, 320), phase: rewritten === '' ? 'failed' : 'completed' }
|
|
2573
|
+
: { chars: rewritten.length, phase: 'failed', error: streamError.message })
|
|
2574
|
+
if (streamError === undefined) break
|
|
2575
|
+
if (produced > 0 || !shouldSwitchModel(config.fallbackModel, model, streamError)) break
|
|
2576
|
+
}
|
|
2895
2577
|
if (streamError !== undefined) throw streamError
|
|
2896
2578
|
if (rewritten.length < 20) throw new Error('修订结果过短,可能失败,请重试')
|
|
2897
2579
|
|
|
@@ -2917,16 +2599,24 @@ export async function* rewriteChapterStream(
|
|
|
2917
2599
|
/** The de-AI-ify polish system prompt (with project writing assets injected). */
|
|
2918
2600
|
function polishSystemPrompt(project: ProjectState): string {
|
|
2919
2601
|
const assetsBlock = renderAllAssets(project.assets)
|
|
2920
|
-
|
|
2602
|
+
const bible = project.bible
|
|
2603
|
+
const lines = [
|
|
2921
2604
|
'你是一位中文网文润色师。你会收到一章正文,请做「去 AI 味」润色:',
|
|
2922
2605
|
'1. 删除/替换 AI 高频套话与模式词:如"不禁""仿佛""一时间""不由得""顿时""然而""缓缓""轻轻""微微""默默""似乎""终于"等滥用。',
|
|
2923
2606
|
'2. 把书面翻译腔改成口语化的中文网文语感。',
|
|
2924
2607
|
'3. 拆分过长的排比句与堆砌的修饰语。',
|
|
2925
|
-
'4.
|
|
2608
|
+
'4. 保留全部情节、人物、对话内容、已确立事实不变,只改表达。',
|
|
2926
2609
|
'5. 输出完整的新正文,不要输出任何说明文字或 Markdown 标记。',
|
|
2927
2610
|
'6. 必须遵守下方「反 AI 规则」与「写法资产」的表达边界;写法资产要求保留的风格特征(句式、台词、节奏)不得在润色中丢失。',
|
|
2928
|
-
|
|
2929
|
-
]
|
|
2611
|
+
'7. 必须遵守下方「内容合规红线」与「本书红线」(如有),任何一条命中(含影射、暗示)都必须避免。',
|
|
2612
|
+
]
|
|
2613
|
+
if (bible !== undefined && bible.redLines.length > 0) {
|
|
2614
|
+
lines.push('本书红线:\n' + bible.redLines.map(r => '- ' + r).join('\n'))
|
|
2615
|
+
}
|
|
2616
|
+
lines.push('内容合规红线(平台硬性要求,最高优先级):')
|
|
2617
|
+
lines.push(COMPLIANCE_REDLINES.join('\n'))
|
|
2618
|
+
if (assetsBlock !== '') lines.push(assetsBlock)
|
|
2619
|
+
return lines.join('\n')
|
|
2930
2620
|
}
|
|
2931
2621
|
|
|
2932
2622
|
/** Stream a chapter polish (de-AI-ify). Draft-mode: the polished body lands
|
|
@@ -2939,9 +2629,9 @@ export async function* polishChapterStream(
|
|
|
2939
2629
|
chapterNo: number,
|
|
2940
2630
|
): AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'drafted'; chars: number; draft: string }, void, unknown> {
|
|
2941
2631
|
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
2942
|
-
if (chapter === undefined) throw new
|
|
2632
|
+
if (chapter === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 不在计划中`)
|
|
2943
2633
|
const body = readChapterFile(outputDir, chapter)
|
|
2944
|
-
if (body === undefined) throw new
|
|
2634
|
+
if (body === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 的正文文件不存在`)
|
|
2945
2635
|
const messages: Message[] = [createUserMessage({
|
|
2946
2636
|
content: [{ type: 'text', text: body.replace(/^#\s+.*$/m, '').trim() }],
|
|
2947
2637
|
source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
|
|
@@ -2958,24 +2648,43 @@ export async function* polishChapterStream(
|
|
|
2958
2648
|
reasoningEffort: ReasoningEffortId('off'),
|
|
2959
2649
|
}
|
|
2960
2650
|
yield { frame: 'start' }
|
|
2961
|
-
const
|
|
2651
|
+
const primaryModel = request.model ?? config.model
|
|
2652
|
+
let newBody = ''
|
|
2962
2653
|
let streamError: Error | undefined
|
|
2963
|
-
for
|
|
2964
|
-
|
|
2965
|
-
if (
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
.
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
.
|
|
2654
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
2655
|
+
const model = attempt === 0 ? primaryModel : (config.fallbackModel ?? '').trim()
|
|
2656
|
+
if (model === '') break
|
|
2657
|
+
const live = beginLiveCall({
|
|
2658
|
+
label: `去 AI 味润色 · 第${chapterNo}章`,
|
|
2659
|
+
model,
|
|
2660
|
+
system: request.system,
|
|
2661
|
+
user: body.slice(0, 2000),
|
|
2662
|
+
})
|
|
2663
|
+
const assembler = new BlockAssembler()
|
|
2664
|
+
let produced = 0
|
|
2665
|
+
for await (const chunk of ctx.llm.stream(applyModelCapability(request, model))) {
|
|
2666
|
+
assembler.push(chunk)
|
|
2667
|
+
if (chunk.type === 'text-delta') { produced += chunk.text.length; markFirstToken(live); yield { frame: 'delta', text: chunk.text } }
|
|
2668
|
+
}
|
|
2669
|
+
const finish = assembler.finish
|
|
2670
|
+
streamError = undefined
|
|
2671
|
+
if (finish.kind === 'error' || finish.kind === 'aborted') {
|
|
2672
|
+
streamError = new Error(`润色失败(${finish.kind}): ${finish.failure.message}`)
|
|
2673
|
+
} else if (finish.kind === 'max-tokens') {
|
|
2674
|
+
streamError = new Error('润色输出达到 maxTokens 上限')
|
|
2675
|
+
}
|
|
2676
|
+
newBody = assembler
|
|
2677
|
+
.blocks()
|
|
2678
|
+
.filter((block): block is Extract<StreamChunk, { type: 'block-end' }>['block'] & { type: 'text' } => block.type === 'text')
|
|
2679
|
+
.map(block => block.text)
|
|
2680
|
+
.join('\n')
|
|
2681
|
+
.trim()
|
|
2682
|
+
endLiveCall(live, assembler, streamError === undefined
|
|
2683
|
+
? { chars: newBody.length, preview: newBody.slice(0, 320), phase: newBody === '' ? 'failed' : 'completed' }
|
|
2684
|
+
: { chars: newBody.length, phase: 'failed', error: streamError.message })
|
|
2685
|
+
if (streamError === undefined) break
|
|
2686
|
+
if (produced > 0 || !shouldSwitchModel(config.fallbackModel, model, streamError)) break
|
|
2687
|
+
}
|
|
2979
2688
|
if (streamError !== undefined) throw streamError
|
|
2980
2689
|
if (newBody.length < 100) throw new Error('润色结果过短,可能失败,请重试')
|
|
2981
2690
|
|
|
@@ -2986,6 +2695,12 @@ export async function* polishChapterStream(
|
|
|
2986
2695
|
yield { frame: 'drafted', chars: newBody.length, draft: newBody }
|
|
2987
2696
|
}
|
|
2988
2697
|
|
|
2698
|
+
/** 统计中文字符数(汉字),与「每章字数」红线口径一致(不含标点/换行)。 */
|
|
2699
|
+
export function countHanzi(text: string): number {
|
|
2700
|
+
const m = text.match(/[\u4e00-\u9fff]/g)
|
|
2701
|
+
return m === null ? 0 : m.length
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2989
2704
|
/** Generate one chapter (streaming). Yields progress frames; persists when done. */
|
|
2990
2705
|
export async function* generateChapterStream(
|
|
2991
2706
|
ctx: Context,
|
|
@@ -2993,9 +2708,9 @@ export async function* generateChapterStream(
|
|
|
2993
2708
|
project: ProjectState,
|
|
2994
2709
|
outputDir: string,
|
|
2995
2710
|
chapterNo: number,
|
|
2996
|
-
): AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'done'; file: string; chars: number }, void, unknown> {
|
|
2711
|
+
): AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'done'; file: string; chars: number; warn?: string }, void, unknown> {
|
|
2997
2712
|
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
2998
|
-
if (chapter === undefined) throw new
|
|
2713
|
+
if (chapter === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 不在计划中`)
|
|
2999
2714
|
// Note: the route layer owns the 'generating' status + concurrency guard;
|
|
3000
2715
|
// this function must not refuse when status is 'generating' (the route sets
|
|
3001
2716
|
// it before calling us).
|
|
@@ -3055,10 +2770,22 @@ export async function* generateChapterStream(
|
|
|
3055
2770
|
.filter(f => f.status === 'planned' && f.targetChapter !== undefined && f.targetChapter > 0)
|
|
3056
2771
|
.filter(f => Math.abs((f.targetChapter as number) - chapterNo) <= 12)
|
|
3057
2772
|
.map(f => `- ${f.description.slice(0, 120)}${f.targetChapter !== undefined ? `(计划回收于第 ${f.targetChapter} 章)` : ''}`)
|
|
3058
|
-
|
|
2773
|
+
const contractBlock = (() => {
|
|
2774
|
+
const parts: string[] = []
|
|
2775
|
+
if ((chapter.mustAdvance?.length ?? 0) > 0) parts.push(`本章必达(必须推进):${chapter.mustAdvance!.join(';')}`)
|
|
2776
|
+
if ((chapter.mustPreserve?.length ?? 0) > 0) parts.push(`本章保持(不得破坏):${chapter.mustPreserve!.join(';')}`)
|
|
2777
|
+
if ((chapter.characterHardFacts?.length ?? 0) > 0) parts.push(`人物硬事实(不得违背):${chapter.characterHardFacts!.join(';')}`)
|
|
2778
|
+
if ((chapter.payoffDirectives?.length ?? 0) > 0) parts.push(`伏笔指令:${chapter.payoffDirectives!.map(p => `${p.operation ?? 'touch'}${p.no !== undefined ? `(第${p.no}章)` : ''}${p.text !== undefined && p.text !== '' ? ':' + p.text : ''}`).join(';')}`)
|
|
2779
|
+
if (chapter.endingHook !== undefined && chapter.endingHook !== '') parts.push(`章末钩子要求:${chapter.endingHook}`)
|
|
2780
|
+
if (chapter.obligation !== undefined && chapter.obligation !== '') parts.push(`本章义务合约:${chapter.obligation}`)
|
|
2781
|
+
return parts.length > 0 ? '==================== 本章合同(硬约束,必须满足) ====================\n' + parts.join('\n') : ''
|
|
2782
|
+
})()
|
|
2783
|
+
const knowledgeBlock = retrieveKnowledge(project, `${chapter.title} ${chapter.beats}`)
|
|
3059
2784
|
const user = [
|
|
3060
2785
|
`现在写第 ${chapter.no} 章,标题《${chapter.title}》。`,
|
|
3061
2786
|
`本章剧情要点:${chapter.beats}`,
|
|
2787
|
+
contractBlock,
|
|
2788
|
+
knowledgeBlock,
|
|
3062
2789
|
'',
|
|
3063
2790
|
foreshadowHints.length > 0
|
|
3064
2791
|
? `本章附近需顺势埋下以下暗线(自然带过,不喧宾夺主,1-2 句即可,但细节要可辨识、与描述吻合):\n${foreshadowHints.join('\n')}`
|
|
@@ -3081,446 +2808,140 @@ export async function* generateChapterStream(
|
|
|
3081
2808
|
|
|
3082
2809
|
const messages: Message[] = [createUserMessage({
|
|
3083
2810
|
content: [{ type: 'text', text: user }],
|
|
3084
|
-
source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
|
|
3085
|
-
})]
|
|
3086
|
-
const request: GenerateOptions = {
|
|
3087
|
-
provider: config.provider,
|
|
3088
|
-
model: config.model,
|
|
3089
|
-
messages,
|
|
3090
|
-
system: writeSystemPrompt(project),
|
|
3091
|
-
// Full-chapter output: budget generously (4000 chars ≈ 8-12k tokens,
|
|
3092
|
-
// plus the model's reasoning channel).
|
|
3093
|
-
maxTokens: Math.max(config.maxTokens, 20000),
|
|
3094
|
-
temperature: 0.85,
|
|
3095
|
-
}
|
|
3096
|
-
|
|
3097
|
-
yield { frame: 'start' }
|
|
3098
|
-
|
|
3099
|
-
const assembler = new BlockAssembler()
|
|
3100
|
-
let streamError: Error | undefined
|
|
3101
|
-
for await (const chunk of ctx.llm.stream(request)) {
|
|
3102
|
-
assembler.push(chunk)
|
|
3103
|
-
if (chunk.type === 'text-delta') {
|
|
3104
|
-
yield { frame: 'delta', text: chunk.text }
|
|
3105
|
-
}
|
|
3106
|
-
}
|
|
3107
|
-
const finish = assembler.finish
|
|
3108
|
-
if (finish.kind === 'error' || finish.kind === 'aborted') {
|
|
3109
|
-
streamError = new Error(`生成失败(${finish.kind}): ${finish.failure.message}`)
|
|
3110
|
-
} else if (finish.kind === 'max-tokens') {
|
|
3111
|
-
streamError = new Error('达到 maxTokens 上限,正文可能不完整,请增大 maxTokens 后重试')
|
|
3112
|
-
}
|
|
3113
|
-
const body = assembler
|
|
3114
|
-
.blocks()
|
|
3115
|
-
.filter((block): block is Extract<StreamChunk, { type: 'block-end' }>['block'] & { type: 'text' } => block.type === 'text')
|
|
3116
|
-
.map(block => block.text)
|
|
3117
|
-
.join('\n')
|
|
3118
|
-
.trim()
|
|
3119
|
-
if (streamError !== undefined) throw streamError
|
|
3120
|
-
if (body.length < 100) throw new Error('生成内容过短,可能失败,请重试')
|
|
3121
|
-
|
|
3122
|
-
// Write the chapter file.
|
|
3123
|
-
const fileName = chapterFileName(chapter)
|
|
3124
|
-
mkdirSync(outputDir, { recursive: true })
|
|
3125
|
-
writeFileSync(join(outputDir, fileName), `# 第${chapter.no}章 ${chapter.title}\n\n${body}\n`, 'utf8')
|
|
3126
|
-
|
|
3127
|
-
chapter.status = 'written'
|
|
3128
|
-
chapter.chars = body.length
|
|
3129
|
-
chapter.file = fileName
|
|
3130
|
-
chapter.error = undefined
|
|
3131
|
-
project.updatedAt = new Date().toISOString()
|
|
3132
|
-
saveProject(outputDir, project)
|
|
3133
|
-
|
|
3134
|
-
yield { frame: 'done', file: fileName, chars: body.length }
|
|
3135
|
-
}
|
|
3136
|
-
|
|
3137
|
-
/** Generate a chapter summary (narrative memory). */
|
|
3138
|
-
export async function summarizeChapter(
|
|
3139
|
-
ctx: Context,
|
|
3140
|
-
config: NovelConfig,
|
|
3141
|
-
project: ProjectState,
|
|
3142
|
-
outputDir: string,
|
|
3143
|
-
chapterNo: number,
|
|
3144
|
-
): Promise<string> {
|
|
3145
|
-
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
3146
|
-
if (chapter === undefined) throw new Error(`章节 ${chapterNo} 不在计划中`)
|
|
3147
|
-
const body = readChapterFile(outputDir, chapter)
|
|
3148
|
-
if (body === undefined) throw new Error(`章节 ${chapterNo} 的正文文件不存在`)
|
|
3149
|
-
const system = [
|
|
3150
|
-
'你是一位网文编辑。请为下面一章写一段 120-200 字的摘要,供后续章节写作时保持连贯性。',
|
|
3151
|
-
'摘要必须包含:本章发生的关键事件、主角状态变化(境界/资源/伤势/心境)、新增的伏笔或线索、角色关系变化。',
|
|
3152
|
-
'用客观陈述句,不要评价,不要剧透式感叹。只输出摘要正文。',
|
|
3153
|
-
].join('\n')
|
|
3154
|
-
const user = body.replace(/^#\s+.*$/m, '').trim()
|
|
3155
|
-
const summary = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 4000) })
|
|
3156
|
-
chapter.summary = summary.slice(0, 500)
|
|
3157
|
-
project.updatedAt = new Date().toISOString()
|
|
3158
|
-
saveProject(outputDir, project)
|
|
3159
|
-
return chapter.summary
|
|
3160
|
-
}
|
|
3161
|
-
|
|
3162
|
-
/** 把分镜产出写入项目持久化(按章 upsert)。重新生成上游时级联清掉下游旧产物:
|
|
3163
|
-
* 新骨架 → 清分镜表+提示词;新分镜表 → 清提示词;新提示词不动上游。 */
|
|
3164
|
-
function saveChapterStoryboard(project: ProjectState, outputDir: string, entry: ChapterStoryboard): void {
|
|
3165
|
-
if (project.storyboards === undefined) project.storyboards = []
|
|
3166
|
-
const idx = project.storyboards.findIndex(e => e.chapterNo === entry.chapterNo)
|
|
3167
|
-
const prev = idx === -1 ? undefined : project.storyboards[idx]
|
|
3168
|
-
const next: ChapterStoryboard = { ...(prev ?? {}), ...entry }
|
|
3169
|
-
if (entry.skeleton !== undefined) {
|
|
3170
|
-
next.table = undefined
|
|
3171
|
-
next.prompts = undefined
|
|
3172
|
-
}
|
|
3173
|
-
if (entry.table !== undefined) {
|
|
3174
|
-
next.prompts = undefined
|
|
3175
|
-
}
|
|
3176
|
-
if (idx === -1) project.storyboards.push(next)
|
|
3177
|
-
else project.storyboards[idx] = next
|
|
3178
|
-
project.updatedAt = new Date().toISOString()
|
|
3179
|
-
saveProject(outputDir, project)
|
|
3180
|
-
}
|
|
3181
|
-
|
|
3182
|
-
/** 确定性兜底:正文中出现的角色库角色名(LLM 漏填 characters 时使用)。 */
|
|
3183
|
-
function guessCharactersFromRoles(project: ProjectState, body: string, limit = 12): string[] {
|
|
3184
|
-
const out: string[] = []
|
|
3185
|
-
for (const r of project.roles ?? []) {
|
|
3186
|
-
if (out.length >= limit) break
|
|
3187
|
-
if (r.name !== '' && !out.includes(r.name) && body.includes(r.name)) out.push(r.name)
|
|
3188
|
-
}
|
|
3189
|
-
return out
|
|
3190
|
-
}
|
|
3191
|
-
|
|
3192
|
-
/** 清洗 LLM 输出的角色名数组:去空 / 去重 / 限长 / 限字数。 */
|
|
3193
|
-
function sanitizeCharacters(raw: unknown, limit: number): string[] {
|
|
3194
|
-
if (!Array.isArray(raw)) return []
|
|
3195
|
-
const out: string[] = []
|
|
3196
|
-
for (const v of raw) {
|
|
3197
|
-
if (typeof v !== 'string') continue
|
|
3198
|
-
const name = v.trim().slice(0, 20)
|
|
3199
|
-
if (name === '' || out.includes(name)) continue
|
|
3200
|
-
out.push(name)
|
|
3201
|
-
if (out.length >= limit) break
|
|
3202
|
-
}
|
|
3203
|
-
return out
|
|
3204
|
-
}
|
|
3205
|
-
|
|
3206
|
-
/** 漫剧卡绑定索引:按分镜称谓(正文确切名)解析漫剧卡(卡名/来源名精确优先,包含兜底)。 */
|
|
3207
|
-
interface MangaRoleBindingIndex {
|
|
3208
|
-
byId: Map<string, MangaRoleCard>
|
|
3209
|
-
resolve: (name: string) => MangaRoleCard | undefined
|
|
3210
|
-
}
|
|
3211
|
-
function buildMangaRoleBindings(project: ProjectState): MangaRoleBindingIndex {
|
|
3212
|
-
const cards = project.mangaRoles ?? []
|
|
3213
|
-
const byName = new Map<string, MangaRoleCard>()
|
|
3214
|
-
for (const c of cards) {
|
|
3215
|
-
if (c.name !== '') byName.set(c.name, c)
|
|
3216
|
-
if (c.sourceRoleName !== undefined && c.sourceRoleName !== '') byName.set(c.sourceRoleName, c)
|
|
3217
|
-
}
|
|
3218
|
-
const byId = new Map<string, MangaRoleCard>(cards.map(c => [c.id, c]))
|
|
3219
|
-
return {
|
|
3220
|
-
byId,
|
|
3221
|
-
resolve: (name: string): MangaRoleCard | undefined => {
|
|
3222
|
-
const exact = byName.get(name)
|
|
3223
|
-
if (exact !== undefined) return exact
|
|
3224
|
-
for (const c of cards) {
|
|
3225
|
-
if (c.name !== '' && (c.name.includes(name) || name.includes(c.name))) return c
|
|
3226
|
-
if (c.sourceRoleName !== undefined && c.sourceRoleName !== '' && (c.sourceRoleName.includes(name) || name.includes(c.sourceRoleName))) return c
|
|
3227
|
-
}
|
|
3228
|
-
return undefined
|
|
3229
|
-
},
|
|
3230
|
-
}
|
|
3231
|
-
}
|
|
3232
|
-
|
|
3233
|
-
/**
|
|
3234
|
-
* 分镜·导演级:剧情骨架 → 分镜表(镜头级)。
|
|
3235
|
-
* 只做画面层:景别/机位运镜/时长/画面/台词/音效/光效 + 状态连续;禁止改剧情(骨架只读)。
|
|
3236
|
-
*/
|
|
3237
|
-
export async function generateStoryboardTable(
|
|
3238
|
-
ctx: Context,
|
|
3239
|
-
config: NovelConfig,
|
|
3240
|
-
project: ProjectState,
|
|
3241
|
-
outputDir: string,
|
|
3242
|
-
chapterNo: number,
|
|
3243
|
-
skeleton: StoryboardSkeleton,
|
|
3244
|
-
styleId?: string,
|
|
3245
|
-
filterId?: string,
|
|
3246
|
-
): Promise<StoryboardTable> {
|
|
3247
|
-
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
3248
|
-
if (chapter === undefined) throw new Error(`章节 ${chapterNo} 不在计划中`)
|
|
3249
|
-
const body = readChapterFile(outputDir, chapter)
|
|
3250
|
-
if (body === undefined) throw new Error(`章节 ${chapterNo} 的正文文件不存在`)
|
|
3251
|
-
if ((skeleton.beats ?? []).length === 0) throw new Error('骨架为空,请先生成剧情骨架')
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
// 出场角色锚点:正文中出现过的角色名 + 身份(简短)。
|
|
3255
|
-
const roles = (project.roles ?? [])
|
|
3256
|
-
.filter(r => body.includes(r.name))
|
|
3257
|
-
.slice(0, 8)
|
|
3258
|
-
.map(r => `${r.name}(${r.roleLabel === 'protagonist' ? '主角' : r.roleLabel === 'antagonist' ? '反派' : r.roleLabel === 'female_lead' ? '女主' : '配角'}):${r.identity ?? ''}`)
|
|
3259
|
-
// 场景卡:仅列名称与定位(正文命中优先,至多 6 个)。
|
|
3260
|
-
const usedScenes = (project.scenes ?? []).filter(s => body.includes(s.name)).slice(0, 6)
|
|
3261
|
-
const scenes = usedScenes.map(s => `${s.name}:${s.summary ?? ''}`)
|
|
3262
|
-
// 漫剧定妆卡:本章正文命中漫剧卡(卡名或来源名),注入定妆锚点供画面遵守。
|
|
3263
|
-
const bindings = buildMangaRoleBindings(project)
|
|
3264
|
-
const mangaCardsInBody = bindings.byId.size > 0
|
|
3265
|
-
? [...bindings.byId.values()].filter(c => body.includes(c.name) || (c.sourceRoleName !== undefined && body.includes(c.sourceRoleName))).slice(0, 8)
|
|
3266
|
-
: []
|
|
3267
|
-
const mangaLines = mangaCardsInBody.map(c => {
|
|
3268
|
-
const anchor = c.imagePrompt !== undefined ? c.imagePrompt.zh.slice(0, 90) : (c.appearance !== '' ? c.appearance : '')
|
|
3269
|
-
const ref = c.imageUrl !== undefined ? ';参考图:有' : (c.gallery ?? []).some(g => g.label.includes('立绘')) ? ';参考图:有(立绘)' : ''
|
|
3270
|
-
return `${c.name}(${c.identity ?? ''}):定妆${anchor !== '' ? '「' + anchor + '」' : '(锚点未生成)'}${ref}`
|
|
3271
|
-
})
|
|
3272
|
-
const rules = (project.visualRules ?? []).map(r => '- ' + r)
|
|
2811
|
+
source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
|
|
2812
|
+
})]
|
|
2813
|
+
const request: GenerateOptions = {
|
|
2814
|
+
provider: config.provider,
|
|
2815
|
+
model: config.generateModel || config.model,
|
|
2816
|
+
messages,
|
|
2817
|
+
system: writeSystemPrompt(project, chapter.targetChars || config.chapterChars, undefined, chapter.no),
|
|
2818
|
+
// Full-chapter output: budget generously (4000 chars ≈ 8-12k tokens,
|
|
2819
|
+
// plus the model's reasoning channel).
|
|
2820
|
+
maxTokens: Math.max(config.maxTokens, 20000),
|
|
2821
|
+
temperature: 0.85,
|
|
2822
|
+
}
|
|
3273
2823
|
|
|
3274
|
-
|
|
3275
|
-
const filterStyle = filterId !== undefined ? findStyle(filterId) : undefined
|
|
3276
|
-
const styleLines: string[] = []
|
|
3277
|
-
if (baseStyle !== undefined) styleLines.push('- 基底风格「' + baseStyle.name + '」:' + baseStyle.keywords)
|
|
3278
|
-
if (filterStyle !== undefined) styleLines.push('- 叠加滤镜「' + filterStyle.name + '」:' + filterStyle.keywords)
|
|
3279
|
-
if (styleLines.length === 0) styleLines.push('- (未选择风格,默认 3D 动漫超精细建模质感)')
|
|
2824
|
+
yield { frame: 'start' }
|
|
3280
2825
|
|
|
3281
|
-
const
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
'
|
|
3287
|
-
'
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
'==== 剧情骨架(只读,禁止改动) ====',
|
|
3304
|
-
`弧线:${skeleton.arc}`,
|
|
3305
|
-
skeleton.beats.map(b => `[${b.id}] [${b.function}] ${b.event}(情绪:${b.emotion}${b.cause !== undefined ? ';承接:' + b.cause : ''})`).join('\n'),
|
|
3306
|
-
roles.length > 0 ? '==== 出场角色锚点 ====\n' + roles.join('\n') : '',
|
|
3307
|
-
mangaLines.length > 0 ? '==== 漫剧定妆卡(角色外观以此为准,禁止改换服装/发色/标志物) ====\n' + mangaLines.join('\n') : '',
|
|
3308
|
-
scenes.length > 0 ? '==== 相关场景 ====\n' + scenes.join('\n') : '',
|
|
3309
|
-
rules.length > 0 ? '==== 本书视觉规则(必须内嵌进 visual) ====\n' + rules.join('\n') : '',
|
|
3310
|
-
'==== 章节正文(画面细节以此为准) ====',
|
|
3311
|
-
body.replace(/^#\s+.*$/m, '').trim().slice(0, 3000),
|
|
3312
|
-
].filter(s => s !== '').join('\n\n')
|
|
3313
|
-
const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 16000), reasoning: config.analysisReasoning ?? 'low' })
|
|
3314
|
-
const raw = parseJsonObject<{ shots?: unknown }>(text)
|
|
3315
|
-
const beatIds = new Set(skeleton.beats.map(b => b.id))
|
|
3316
|
-
const shots: StoryboardShot[] = Array.isArray(raw.shots)
|
|
3317
|
-
? raw.shots
|
|
3318
|
-
.filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
|
|
3319
|
-
.map((entry, i) => ({
|
|
3320
|
-
id: 's' + (i + 1),
|
|
3321
|
-
beatId: beatIds.has(entry.beatId as string) ? entry.beatId as string : skeleton.beats[0]!.id,
|
|
3322
|
-
shot: normalizeShotSize(entry.shot as string | undefined),
|
|
3323
|
-
camera: normalizeCameras(entry.camera as string | undefined),
|
|
3324
|
-
composition: normalizeComposition(entry.composition as string | undefined),
|
|
3325
|
-
duration: typeof entry.duration === 'number' && entry.duration >= 1 && entry.duration <= 12 ? Math.round(entry.duration) : 4,
|
|
3326
|
-
visual: typeof entry.visual === 'string' ? entry.visual.trim().slice(0, 300) : '',
|
|
3327
|
-
line: typeof entry.line === 'string' ? entry.line.trim().slice(0, 120) : '',
|
|
3328
|
-
sound: typeof entry.sound === 'string' ? entry.sound.trim().slice(0, 80) : '',
|
|
3329
|
-
light: normalizeLightings(entry.light as string | undefined),
|
|
3330
|
-
prevState: typeof entry.prevState === 'string' ? entry.prevState.trim().slice(0, 150) : '',
|
|
3331
|
-
nextState: typeof entry.nextState === 'string' ? entry.nextState.trim().slice(0, 150) : '',
|
|
3332
|
-
characters: sanitizeCharacters(entry.characters, 4),
|
|
3333
|
-
}))
|
|
3334
|
-
.filter(s => s.visual !== '')
|
|
3335
|
-
: []
|
|
3336
|
-
if (shots.length === 0) throw new Error('模型未输出有效镜头,请重试')
|
|
3337
|
-
// 覆盖自检:每个节拍至少 1 个镜头(缺失节拍告警)。
|
|
3338
|
-
const covered = new Set(shots.map(s => s.beatId))
|
|
3339
|
-
const missing = skeleton.beats.filter(b => !covered.has(b.id)).map(b => b.id)
|
|
3340
|
-
if (missing.length > 0) {
|
|
3341
|
-
console.warn(`[dsh-novel-forge] storyboard: beat ${missing.join(',')} 无镜头覆盖`)
|
|
3342
|
-
}
|
|
3343
|
-
// 表级 characters:各镜头去重汇总;LLM 漏标时用骨架/角色库兜底。
|
|
3344
|
-
const tableChars: string[] = []
|
|
3345
|
-
const tableRoleIds: string[] = []
|
|
3346
|
-
for (const s of shots) {
|
|
3347
|
-
// 定妆绑定:镜头 characters 称谓 → 漫剧卡 id(供出图/生视频时按卡取定妆图)。
|
|
3348
|
-
const ids: string[] = []
|
|
3349
|
-
for (const n of s.characters ?? []) {
|
|
3350
|
-
const card = bindings.resolve(n)
|
|
3351
|
-
if (card !== undefined && !ids.includes(card.id)) ids.push(card.id)
|
|
3352
|
-
}
|
|
3353
|
-
s.mangaRoleIds = ids.length > 0 ? ids : undefined
|
|
3354
|
-
for (const c of s.characters ?? []) {
|
|
3355
|
-
if (!tableChars.includes(c)) tableChars.push(c)
|
|
3356
|
-
if (tableChars.length >= 12) break
|
|
2826
|
+
const primaryModel = request.model ?? config.model
|
|
2827
|
+
let body = ''
|
|
2828
|
+
let streamError: Error | undefined
|
|
2829
|
+
// 最多两次尝试:主模型 → (未产出任何文字且失败可重试时)备用模型。
|
|
2830
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
2831
|
+
const model = attempt === 0 ? primaryModel : (config.fallbackModel ?? '').trim()
|
|
2832
|
+
if (model === '') break
|
|
2833
|
+
const live = beginLiveCall({
|
|
2834
|
+
label: `正文生成 · 第${chapter.no}章`,
|
|
2835
|
+
model,
|
|
2836
|
+
system: request.system,
|
|
2837
|
+
user,
|
|
2838
|
+
})
|
|
2839
|
+
const assembler = new BlockAssembler()
|
|
2840
|
+
let produced = 0
|
|
2841
|
+
for await (const chunk of ctx.llm.stream(applyModelCapability(request, model))) {
|
|
2842
|
+
assembler.push(chunk)
|
|
2843
|
+
if (chunk.type === 'text-delta') {
|
|
2844
|
+
produced += chunk.text.length
|
|
2845
|
+
markFirstToken(live)
|
|
2846
|
+
yield { frame: 'delta', text: chunk.text }
|
|
2847
|
+
}
|
|
3357
2848
|
}
|
|
3358
|
-
|
|
3359
|
-
|
|
2849
|
+
const finish = assembler.finish
|
|
2850
|
+
streamError = undefined
|
|
2851
|
+
if (finish.kind === 'error' || finish.kind === 'aborted') {
|
|
2852
|
+
streamError = new Error(`生成失败(${finish.kind}): ${finish.failure.message}`)
|
|
2853
|
+
} else if (finish.kind === 'max-tokens') {
|
|
2854
|
+
streamError = new Error('达到 maxTokens 上限,正文可能不完整,请增大 maxTokens 后重试')
|
|
3360
2855
|
}
|
|
2856
|
+
body = assembler
|
|
2857
|
+
.blocks()
|
|
2858
|
+
.filter((block): block is Extract<StreamChunk, { type: 'block-end' }>['block'] & { type: 'text' } => block.type === 'text')
|
|
2859
|
+
.map(block => block.text)
|
|
2860
|
+
.join('\n')
|
|
2861
|
+
.trim()
|
|
2862
|
+
endLiveCall(live, assembler, streamError === undefined
|
|
2863
|
+
? { chars: body.length, preview: body.slice(0, 320), phase: body === '' ? 'failed' : 'completed' }
|
|
2864
|
+
: { chars: body.length, phase: 'failed', error: streamError.message })
|
|
2865
|
+
if (streamError === undefined) break
|
|
2866
|
+
// 只有「一个字都没产出」时才换模型重试——已有正文绝不能重复生成。
|
|
2867
|
+
if (produced > 0 || !shouldSwitchModel(config.fallbackModel, model, streamError)) break
|
|
3361
2868
|
}
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
2869
|
+
if (streamError !== undefined) throw streamError
|
|
2870
|
+
if (body.length < 100) throw new Error('生成内容过短,可能失败,请重试')
|
|
2871
|
+
|
|
2872
|
+
// 字数口径统一为「汉字数」(与每章字数红线一致)。不足目标 90% 时自动续写补齐(最多 2 轮)。
|
|
2873
|
+
const target = chapter.targetChars > 0 ? chapter.targetChars : config.chapterChars
|
|
2874
|
+
let hanzi = countHanzi(body)
|
|
2875
|
+
for (let round = 0; round < 2 && target > 0 && hanzi < target * 0.9; round++) {
|
|
2876
|
+
const need = Math.max(400, Math.round(target * 0.95) - hanzi)
|
|
2877
|
+
const contUser = [
|
|
2878
|
+
`第 ${chapter.no} 章《${chapter.title}》已写正文末尾如下:`,
|
|
2879
|
+
body.slice(-2500),
|
|
2880
|
+
'',
|
|
2881
|
+
`请承接上文继续写约 ${need} 字,不要重复已有内容,不要重写开头,不要总结前文,直接续写新情节,结尾留一个章末钩子。只输出续写的正文,不要输出标题或任何说明。`,
|
|
2882
|
+
].join('\n')
|
|
2883
|
+
const cont = await complete(ctx, config, {
|
|
2884
|
+
system: writeSystemPrompt(project, target, undefined, chapter.no) + '\n\n【续写任务】严格承接已有正文往下写,不要重复已有内容、不要重写开头、不要总结前文,直接续写新情节,并在结尾留一个章末钩子。',
|
|
2885
|
+
user: contUser,
|
|
2886
|
+
temperature: 0.85,
|
|
2887
|
+
maxTokens: Math.max(config.maxTokens, 12000),
|
|
2888
|
+
model: config.generateModel || config.model,
|
|
2889
|
+
liveLabel: '续写补齐',
|
|
2890
|
+
})
|
|
2891
|
+
const add = cont.trim()
|
|
2892
|
+
if (add.length < 100) break
|
|
2893
|
+
body = `${body}\n\n${add}`
|
|
2894
|
+
hanzi = countHanzi(body)
|
|
3369
2895
|
}
|
|
3370
|
-
saveChapterStoryboard(project, outputDir, { chapterNo, table, updatedAt: new Date().toISOString() })
|
|
3371
|
-
return table
|
|
3372
|
-
}
|
|
3373
2896
|
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
ctx: Context,
|
|
3381
|
-
config: NovelConfig,
|
|
3382
|
-
project: ProjectState,
|
|
3383
|
-
outputDir: string,
|
|
3384
|
-
chapterNo: number,
|
|
3385
|
-
table: StoryboardTable,
|
|
3386
|
-
styleId?: string,
|
|
3387
|
-
filterId?: string,
|
|
3388
|
-
): Promise<StoryboardPrompt[]> {
|
|
3389
|
-
if ((table.shots ?? []).length === 0) throw new Error('分镜表为空,请先生成分镜表')
|
|
3390
|
-
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
3391
|
-
const baseStyle = styleId !== undefined ? findStyle(styleId) : undefined
|
|
3392
|
-
const filterStyle = filterId !== undefined ? findStyle(filterId) : undefined
|
|
3393
|
-
const stylePrefix = [
|
|
3394
|
-
baseStyle?.keywords,
|
|
3395
|
-
filterStyle?.keywords,
|
|
3396
|
-
].filter((v): v is string => v !== undefined && v !== '').join(',') || '3D动漫,超精细建模,电影光影'
|
|
3397
|
-
const rules = (project.visualRules ?? []).map(r => '- ' + r)
|
|
3398
|
-
const bindings = buildMangaRoleBindings(project)
|
|
3399
|
-
const shotLines = table.shots.map(s => {
|
|
3400
|
-
const bound = (s.characters ?? []).map(n => bindings.resolve(n)).filter((c): c is MangaRoleCard => c !== undefined)
|
|
3401
|
-
const makeUp = bound.map(c => {
|
|
3402
|
-
const anchor = c.imagePrompt !== undefined ? c.imagePrompt.zh.slice(0, 60) : (c.appearance !== '' ? c.appearance : '')
|
|
3403
|
-
const ref = c.imageUrl !== undefined ? '(参考图)' : ''
|
|
3404
|
-
return c.name + ':' + anchor + ref
|
|
3405
|
-
}).join(';')
|
|
3406
|
-
return `[${s.id}] 节拍${s.beatId} · ${sizeZh(s.shot)} · ${cameraZh(s.camera)} · ${s.duration}s
|
|
3407
|
-
出场:${s.characters !== undefined && s.characters.length > 0 ? s.characters.join('、') : '(未标注)'}
|
|
3408
|
-
定妆:${makeUp !== '' ? makeUp : '(未绑定漫剧卡)'}
|
|
3409
|
-
画面:${s.visual}
|
|
3410
|
-
台词:${s.line !== '' ? s.line : '(无)'}
|
|
3411
|
-
光效:${lightZh(s.light) !== '' ? lightZh(s.light) : '(无)'}
|
|
3412
|
-
承接:${s.prevState} → ${s.nextState}`
|
|
3413
|
-
}).join('\n\n')
|
|
3414
|
-
const system = [
|
|
3415
|
-
'你是一位资深影视分镜提示词工程师,熟悉即梦 AI / Seedance 等视频生成模型的中文提示词写法。',
|
|
3416
|
-
'任务:把分镜表的每个镜头写成一段可直接粘贴进视频生成工具的提示词。',
|
|
3417
|
-
'提示词结构(按顺序):风格词块 → 画面主体(人物动作+表情+服装标志物)→ 机位运镜(含时长感)→ 光效氛围。单段中文 60-140 字,逗号分隔,流畅自然。',
|
|
3418
|
-
'风格词块(必须原样放在每段开头):' + stylePrefix,
|
|
3419
|
-
'重要:每段提示词开头必须原样嵌入风格词块原文,不得省略、不得改写。',
|
|
3420
|
-
'硬性要求:',
|
|
3421
|
-
'1. 只写画面与镜头,禁止写音频/台词配音说明(视频模型无音频)。',
|
|
3422
|
-
'2. 服装/发色/标志物必须沿用镜头表、定妆卡与视觉规则,禁止自行更换(同一角色跨镜头保持一致)。',
|
|
3423
|
-
'3. 运镜用通俗电影词(推近/拉远/跟随/低机位仰拍/过肩/手持晃动/固定机位),不要用分镜术语缩写。',
|
|
3424
|
-
'4. 输出合法 JSON 对象:{"prompts": [{"shotId": "s1", "text": "..."}]},所有镜头都要有,顺序一致。',
|
|
3425
|
-
'5. 字符串内不得包含换行符,JSON 必须在一段内完整结束。',
|
|
3426
|
-
].join('\n')
|
|
3427
|
-
const user = [
|
|
3428
|
-
`章节《${chapter?.title ?? ''}》(第 ${chapterNo} 章)`,
|
|
3429
|
-
rules.length > 0 ? '本书视觉规则(必须遵守):\n' + rules.join('\n') : '',
|
|
3430
|
-
'==== 分镜表 ====',
|
|
3431
|
-
shotLines,
|
|
3432
|
-
'只输出 JSON 对象。',
|
|
3433
|
-
].filter(x => x !== '').join('\n\n')
|
|
3434
|
-
const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 16000), reasoning: config.analysisReasoning ?? 'low' })
|
|
3435
|
-
const raw = parseJsonObject<{ prompts?: unknown }>(text)
|
|
3436
|
-
const shotIds = new Set(table.shots.map(s => s.id))
|
|
3437
|
-
const shotBinding = new Map<string, string[]>(table.shots.map(s => [s.id, s.mangaRoleIds ?? []]))
|
|
3438
|
-
const prompts: StoryboardPrompt[] = Array.isArray(raw.prompts)
|
|
3439
|
-
? raw.prompts
|
|
3440
|
-
.filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
|
|
3441
|
-
.map(entry => {
|
|
3442
|
-
const shotId = shotIds.has(entry.shotId as string) ? entry.shotId as string : ''
|
|
3443
|
-
const ids = shotBinding.get(shotId) ?? []
|
|
3444
|
-
return {
|
|
3445
|
-
shotId,
|
|
3446
|
-
text: ensureStyleEmbedded(typeof entry.text === 'string' ? entry.text.trim().slice(0, 400) : '', stylePrefix, 'zh'),
|
|
3447
|
-
mangaRoleIds: ids.length > 0 ? ids : undefined,
|
|
3448
|
-
}
|
|
3449
|
-
})
|
|
3450
|
-
.filter(x => x.shotId !== '' && x.text !== '')
|
|
3451
|
-
: []
|
|
3452
|
-
if (prompts.length === 0) throw new Error('模型未输出有效提示词,请重试')
|
|
3453
|
-
saveChapterStoryboard(project, outputDir, { chapterNo, prompts, updatedAt: new Date().toISOString() })
|
|
3454
|
-
return prompts
|
|
3455
|
-
}
|
|
2897
|
+
// 覆盖前留存历史版本:重新生成把好稿改坏时可以回滚(首次生成无旧稿,不产生空快照)。
|
|
2898
|
+
if (chapter.file !== undefined && chapter.file !== '') createSnapshot(outputDir, chapter, '重新生成前')
|
|
2899
|
+
// Write the chapter file.
|
|
2900
|
+
const fileName = chapterFileName(chapter)
|
|
2901
|
+
mkdirSync(outputDir, { recursive: true })
|
|
2902
|
+
writeFileSync(join(outputDir, fileName), `# 第${chapter.no}章 ${chapter.title}\n\n${body}\n`, 'utf8')
|
|
3456
2903
|
|
|
2904
|
+
chapter.status = 'written'
|
|
2905
|
+
chapter.chars = hanzi
|
|
2906
|
+
chapter.file = fileName
|
|
2907
|
+
chapter.error = undefined
|
|
2908
|
+
project.updatedAt = new Date().toISOString()
|
|
2909
|
+
saveProject(outputDir, project)
|
|
3457
2910
|
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
2911
|
+
const warn = target > 0
|
|
2912
|
+
? (hanzi < target * 0.85
|
|
2913
|
+
? `第${chapter.no}章实际 ${hanzi} 汉字,少于目标 ${target} 汉字`
|
|
2914
|
+
: hanzi > target * 1.25
|
|
2915
|
+
? `第${chapter.no}章实际 ${hanzi} 汉字,多于目标 ${target} 汉字`
|
|
2916
|
+
: undefined)
|
|
2917
|
+
: undefined
|
|
2918
|
+
yield { frame: 'done', file: fileName, chars: hanzi, warn }
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
/** Generate a chapter summary (narrative memory). */
|
|
2922
|
+
export async function summarizeChapter(
|
|
3463
2923
|
ctx: Context,
|
|
3464
2924
|
config: NovelConfig,
|
|
3465
2925
|
project: ProjectState,
|
|
3466
2926
|
outputDir: string,
|
|
3467
2927
|
chapterNo: number,
|
|
3468
|
-
): Promise<
|
|
2928
|
+
): Promise<string> {
|
|
3469
2929
|
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
3470
|
-
if (chapter === undefined) throw new
|
|
2930
|
+
if (chapter === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 不在计划中`)
|
|
3471
2931
|
const body = readChapterFile(outputDir, chapter)
|
|
3472
|
-
if (body === undefined) throw new
|
|
2932
|
+
if (body === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 的正文文件不存在`)
|
|
3473
2933
|
const system = [
|
|
3474
|
-
'
|
|
3475
|
-
'
|
|
3476
|
-
'
|
|
3477
|
-
'情绪词取值(从这些词中选 1-3 个,用数组输出,按情绪发展顺序):平静/淡然/期待/好奇/警觉/压抑/隐忍/担忧/焦躁/不安/惊惧/愤怒/崩溃/决绝/痛心/释然/悲凉/得意/重生/麻木。',
|
|
3478
|
-
'功能取值(只能从这些词中选一):铺垫/冲突/转折/高潮/收束/伏笔/人物塑造。',
|
|
3479
|
-
'硬性要求:',
|
|
3480
|
-
'1. beats 数量 4-9 个,严格按时间顺序,因果链完整:前一个 beat 的结果是后一个 beat 的原因。',
|
|
3481
|
-
'2. 必须覆盖本章全部剧情要点与正文关键事件,遗漏关键事件视为失败。',
|
|
3482
|
-
'3. 只输出剧情骨架,禁止写画面描写、机位运镜、台词细节、音效(那是导演阶段的事)。',
|
|
3483
|
-
'4. 所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
3484
|
-
'5. 直接输出 JSON 本身,不要输出思考过程或其他文字。',
|
|
3485
|
-
'6. 额外输出 characters:本章真正出镜(说话/行动/被正面描写)的角色名数组,全部使用正文中的确切称谓(如「周野」「周野的律师」,不要改名或拼接),去重,3-10 个;路人或群像可用身份词(如「围观群众」)。',
|
|
3486
|
-
].join('\n')
|
|
3487
|
-
const user = [
|
|
3488
|
-
`章节《${chapter.title}》(第 ${chapter.no} 章)`,
|
|
3489
|
-
`剧情要点:${chapter.beats !== undefined && chapter.beats !== '' ? chapter.beats : '(未填写)'}`,
|
|
3490
|
-
'==================== 章节正文 ====================',
|
|
3491
|
-
body.replace(/^#\s+.*$/m, '').trim(),
|
|
2934
|
+
'你是一位网文编辑。请为下面一章写一段 120-200 字的摘要,供后续章节写作时保持连贯性。',
|
|
2935
|
+
'摘要必须包含:本章发生的关键事件、主角状态变化(境界/资源/伤势/心境)、新增的伏笔或线索、角色关系变化。',
|
|
2936
|
+
'用客观陈述句,不要评价,不要剧透式感叹。只输出摘要正文。',
|
|
3492
2937
|
].join('\n')
|
|
3493
|
-
const
|
|
3494
|
-
const
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
.map((entry, i) => ({
|
|
3500
|
-
id: 'b' + (i + 1),
|
|
3501
|
-
event: typeof entry.event === 'string' ? entry.event.trim().slice(0, 200) : '',
|
|
3502
|
-
emotion: Array.isArray(entry.emotion)
|
|
3503
|
-
? normalizeEmotions(entry.emotion.filter((x): x is string => typeof x === 'string').join('→'))
|
|
3504
|
-
: normalizeEmotions(typeof entry.emotion === 'string' ? entry.emotion : undefined),
|
|
3505
|
-
function: normalizeStoryFunction(typeof entry.function === 'string' ? entry.function : undefined),
|
|
3506
|
-
cause: typeof entry.cause === 'string' && entry.cause.trim() !== '' ? entry.cause.trim().slice(0, 150) : undefined,
|
|
3507
|
-
}))
|
|
3508
|
-
.filter(b => b.event !== '')
|
|
3509
|
-
: []
|
|
3510
|
-
if (beats.length === 0) throw new Error('模型未输出有效节拍链,请重试')
|
|
3511
|
-
// 出场角色:LLM 结构化输出优先,漏填时用角色库命中正文兜底。
|
|
3512
|
-
const skeletonChars = sanitizeCharacters(raw.characters, 12)
|
|
3513
|
-
const chars = skeletonChars.length > 0 ? skeletonChars : guessCharactersFromRoles(project, body, 12)
|
|
3514
|
-
const skeleton: StoryboardSkeleton = {
|
|
3515
|
-
chapterNo,
|
|
3516
|
-
arc: arc !== '' ? arc : '(本章弧线未生成)',
|
|
3517
|
-
beats,
|
|
3518
|
-
characters: chars.length > 0 ? chars : undefined,
|
|
3519
|
-
}
|
|
3520
|
-
saveChapterStoryboard(project, outputDir, { chapterNo, skeleton, updatedAt: new Date().toISOString() })
|
|
3521
|
-
return skeleton
|
|
2938
|
+
const user = body.replace(/^#\s+.*$/m, '').trim()
|
|
2939
|
+
const summary = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 4000) })
|
|
2940
|
+
chapter.summary = summary.slice(0, 500)
|
|
2941
|
+
project.updatedAt = new Date().toISOString()
|
|
2942
|
+
saveProject(outputDir, project)
|
|
2943
|
+
return chapter.summary
|
|
3522
2944
|
}
|
|
3523
|
-
|
|
3524
2945
|
/**
|
|
3525
2946
|
* 反向推大纲:从已写章节正文反推出全书总纲(分卷 + 章节要点 + 主线/人物弧线/伏笔清单)。
|
|
3526
2947
|
* 两阶段:分批提取章节事件摘要 → 汇总生成大纲。不修改章节/设定,只返回大纲文本。
|
|
@@ -3536,7 +2957,7 @@ export async function reverseOutlineFromChapters(
|
|
|
3536
2957
|
.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error')
|
|
3537
2958
|
.filter(c => readChapterFile(outputDir, c) !== undefined)
|
|
3538
2959
|
.sort((a, b) => a.no - b.no)
|
|
3539
|
-
if (written.length === 0) throw new
|
|
2960
|
+
if (written.length === 0) throw new NovelActionError('contract', '本书还没有已写章节,无法反推大纲')
|
|
3540
2961
|
|
|
3541
2962
|
// 阶段 1:分批提取章节事件摘要(每批 10 章,正文各取前 1000 字控制成本)。
|
|
3542
2963
|
const BATCH = 10
|
|
@@ -3597,7 +3018,7 @@ export async function analyzeAdaptation(
|
|
|
3597
3018
|
const user = [
|
|
3598
3019
|
'你是一位资深网文编辑兼改编策划。下面给你一部已完结/连载小说的若干章正文节选。请通读并输出该书的「原文设定卡片」与「可改范围矩阵」。',
|
|
3599
3020
|
'输出合法 JSON 对象:',
|
|
3600
|
-
'{"bookName": "书名", "outline": "一句话主线梗概(100字内)", "dimensions": [{"key":"realm","title":"大世界","mutability":"big|small|free|locked|visual","current":"当前值","evidence":"证据(出现章节/频次)","candidates":["
|
|
3021
|
+
'{"bookName": "书名", "outline": "一句话主线梗概(100字内)", "dimensions": [{"key":"realm","title":"大世界","mutability":"big|small|free|locked|visual","current":"当前值","evidence":"证据(出现章节/频次)","candidates":[{"name":"候选体系名","desc":"该体系一句话说明"}],"impact":"改了会影响什么","risk":"high|medium|low"}]}',
|
|
3601
3022
|
'dimensions 至少覆盖以下维度(key/title):realm 大世界、cultivation 修为体系、protagonist 主角、goldenFinger 金手指、supporting 配角与势力人物名、faction 势力/组织、style 文风与叙事、ending 结局走向、timeline 时间线/编年、foreshadow 伏笔/暗线。',
|
|
3602
3023
|
'mutability 取值:locked=建议保留、big=可改影响大、small=可改影响小、free=可自由改、visual=仅视觉包装。',
|
|
3603
3024
|
'每个 dimension.current 必须忠于文本,能引用原文就用原文(尤其角色名/境界名/势力名/金手指名)。',
|
|
@@ -3645,13 +3066,24 @@ function normalizeAdaptationDimension(d: Record<string, unknown>): AdaptationDim
|
|
|
3645
3066
|
const risk = (['high', 'medium', 'low'] as const).includes(d.risk as AdaptationDimension['risk'])
|
|
3646
3067
|
? d.risk as AdaptationDimension['risk']
|
|
3647
3068
|
: 'medium'
|
|
3069
|
+
const rawCands = Array.isArray(d.candidates) ? d.candidates : []
|
|
3070
|
+
const candidates = rawCands.map((x): { name: string; desc?: string } | null => {
|
|
3071
|
+
if (typeof x === 'string') return x.trim() !== '' ? { name: x.trim() } : null
|
|
3072
|
+
if (typeof x === 'object' && x !== null) {
|
|
3073
|
+
const o = x as { name?: unknown; desc?: unknown }
|
|
3074
|
+
const name = typeof o.name === 'string' ? o.name.trim() : ''
|
|
3075
|
+
const desc = typeof o.desc === 'string' ? o.desc.trim() : undefined
|
|
3076
|
+
return name !== '' ? { name, desc } : null
|
|
3077
|
+
}
|
|
3078
|
+
return null
|
|
3079
|
+
}).filter((x): x is { name: string; desc?: string } => x !== null)
|
|
3648
3080
|
return {
|
|
3649
3081
|
key,
|
|
3650
3082
|
title,
|
|
3651
3083
|
mutability,
|
|
3652
3084
|
current: typeof d.current === 'string' ? d.current : '',
|
|
3653
3085
|
evidence: typeof d.evidence === 'string' ? d.evidence : undefined,
|
|
3654
|
-
candidates
|
|
3086
|
+
candidates,
|
|
3655
3087
|
impact: typeof d.impact === 'string' ? d.impact : '',
|
|
3656
3088
|
risk,
|
|
3657
3089
|
}
|
|
@@ -3691,7 +3123,7 @@ async function reverseOutlineFromAdaptationText(
|
|
|
3691
3123
|
export async function proposeAdaptation(
|
|
3692
3124
|
ctx: Context,
|
|
3693
3125
|
config: NovelConfig,
|
|
3694
|
-
|
|
3126
|
+
_text: string,
|
|
3695
3127
|
selections: Array<{ key: string; title: string; current: string; target: string; mutability: string }>,
|
|
3696
3128
|
dimensions?: AdaptationDimension[],
|
|
3697
3129
|
): Promise<AdaptProposeResponse> {
|
|
@@ -3834,7 +3266,7 @@ export async function rewriteAdaptationBook(
|
|
|
3834
3266
|
adaptedParts.push('# 第' + c.no + '章 ' + title + '\n\n' + c.body.trim() + '\n')
|
|
3835
3267
|
continue
|
|
3836
3268
|
}
|
|
3837
|
-
|
|
3269
|
+
const user = [
|
|
3838
3270
|
mappingBlock,
|
|
3839
3271
|
ruleBlock,
|
|
3840
3272
|
'第 ' + c.no + ' 章《' + c.title + '》:',
|
|
@@ -3884,7 +3316,7 @@ export async function materializeAdaptedBook(
|
|
|
3884
3316
|
): Promise<Omit<AdaptMaterializeResponse, 'book'>> {
|
|
3885
3317
|
const bookName = (args.bookName ?? '').trim().slice(0, 40) || '改编新书'
|
|
3886
3318
|
const outDir = (args.outputDir ?? '').trim()
|
|
3887
|
-
if (outDir === '') throw new
|
|
3319
|
+
if (outDir === '') throw new NovelActionError('contract', '未指定新书输出目录')
|
|
3888
3320
|
const mappings = args.proposal?.mappings ?? []
|
|
3889
3321
|
// 源文导入临时项目(复用角色/道藏/世界提炼:这些函数读取 config.outputDir 下的章节文件)。
|
|
3890
3322
|
const tmpDir = join(tmpdir(), 'dsh-novel-forge-adapt-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8))
|
|
@@ -3894,7 +3326,7 @@ export async function materializeAdaptedBook(
|
|
|
3894
3326
|
if (sourceProject === undefined) throw new Error('临时项目创建失败')
|
|
3895
3327
|
const sourceConfig: NovelConfig = { ...config, outputDir: tmpDir }
|
|
3896
3328
|
|
|
3897
|
-
//
|
|
3329
|
+
// 源书道藏 + 角色库 + 大世界。
|
|
3898
3330
|
// 注意:extractWorld/extractRoles 内部读取 project.outline,所以要先把源文大纲写进临时项目。
|
|
3899
3331
|
const srcOutline = (args.outline ?? '').trim() !== '' ? (args.outline ?? '').trim() : fallbackSourceOutline(sourceProject)
|
|
3900
3332
|
sourceProject.outline = srcOutline
|
|
@@ -3959,7 +3391,7 @@ function applyAdaptationMappings(text: string, mappings: AdaptationMapping[]): s
|
|
|
3959
3391
|
return applyAdaptationReplacements(text, mappings).adaptedText
|
|
3960
3392
|
}
|
|
3961
3393
|
|
|
3962
|
-
/**
|
|
3394
|
+
/** 改编道藏:把人名/术语/势力按映射表替换。 */
|
|
3963
3395
|
function applyMappingsToBible(bible: StoryBible, mappings: AdaptationMapping[]): StoryBible {
|
|
3964
3396
|
const t = (s: string): string => applyAdaptationMappings(s, mappings)
|
|
3965
3397
|
return {
|
|
@@ -3984,13 +3416,6 @@ function applyMappingsToRoles(roles: RoleRecord[], mappings: AdaptationMapping[]
|
|
|
3984
3416
|
relations: role.relations.map(t),
|
|
3985
3417
|
arc: role.arc.map(t),
|
|
3986
3418
|
knowledge: role.knowledge.map(t),
|
|
3987
|
-
imagePrompt: role.imagePrompt !== undefined
|
|
3988
|
-
? { ...role.imagePrompt, zh: t(role.imagePrompt.zh), en: t(role.imagePrompt.en), tags: role.imagePrompt.tags.map(t), source: role.imagePrompt.source !== undefined ? t(role.imagePrompt.source) : undefined }
|
|
3989
|
-
: undefined,
|
|
3990
|
-
expressions: role.expressions?.map(t),
|
|
3991
|
-
promptKit: role.promptKit !== undefined
|
|
3992
|
-
? { ...role.promptKit, portrait: { ...role.promptKit.portrait, zh: t(role.promptKit.portrait.zh), en: t(role.promptKit.portrait.en) }, sheet: { ...role.promptKit.sheet, zh: t(role.promptKit.sheet.zh), en: t(role.promptKit.sheet.en) }, expressions: role.promptKit.expressions.map(e => ({ ...e, zh: t(e.zh), en: t(e.en) })), details: { ...role.promptKit.details, zh: t(role.promptKit.details.zh), en: t(role.promptKit.details.en) } }
|
|
3993
|
-
: undefined,
|
|
3994
3419
|
}))
|
|
3995
3420
|
}
|
|
3996
3421
|
|
|
@@ -4015,6 +3440,33 @@ function fallbackSourceOutline(project: ProjectState): string {
|
|
|
4015
3440
|
* 批量生成时整体开销约省 25%)。
|
|
4016
3441
|
* @returns 摘要与新增事实条数(失败返回空,调用方 best-effort)。
|
|
4017
3442
|
*/
|
|
3443
|
+
|
|
3444
|
+
/**
|
|
3445
|
+
* 事实库去重:新事实加入前检查与已有事实的相似度,
|
|
3446
|
+
* 状态类事实(如"主角受伤"→"主角痊愈")覆盖旧状态。
|
|
3447
|
+
*/
|
|
3448
|
+
function dedupAndAddFacts(project: ProjectState, chapterNo: number, newFacts: string[]): number {
|
|
3449
|
+
const list = project.facts ?? []
|
|
3450
|
+
const existingTexts = new Set(list.map(f => f.text))
|
|
3451
|
+
let added = 0
|
|
3452
|
+
for (const fact of newFacts.slice(0, 8)) {
|
|
3453
|
+
if (existingTexts.has(fact)) continue
|
|
3454
|
+
const isDuplicate = list.some(f => {
|
|
3455
|
+
const a = f.text.slice(0, 30)
|
|
3456
|
+
const b = fact.slice(0, 30)
|
|
3457
|
+
if (a.length === 0 || b.length === 0) return false
|
|
3458
|
+
let common = 0
|
|
3459
|
+
for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] === b[i]) common++
|
|
3460
|
+
return common / Math.max(a.length, b.length) > 0.7
|
|
3461
|
+
})
|
|
3462
|
+
if (isDuplicate) continue
|
|
3463
|
+
list.push({ chapterNo, text: fact })
|
|
3464
|
+
existingTexts.add(fact)
|
|
3465
|
+
added++
|
|
3466
|
+
}
|
|
3467
|
+
project.facts = list.slice(-300)
|
|
3468
|
+
return added
|
|
3469
|
+
}
|
|
4018
3470
|
export async function summarizeAndExtractFacts(
|
|
4019
3471
|
ctx: Context,
|
|
4020
3472
|
config: NovelConfig,
|
|
@@ -4042,9 +3494,7 @@ export async function summarizeAndExtractFacts(
|
|
|
4042
3494
|
.map(v => v.trim().slice(0, 140))
|
|
4043
3495
|
: []
|
|
4044
3496
|
if (summary !== '') chapter.summary = summary
|
|
4045
|
-
|
|
4046
|
-
for (const line of factLines.slice(0, 8)) list.push({ chapterNo, text: line })
|
|
4047
|
-
project.facts = list.slice(-300)
|
|
3497
|
+
dedupAndAddFacts(project, chapterNo, factLines)
|
|
4048
3498
|
project.updatedAt = new Date().toISOString()
|
|
4049
3499
|
saveProject(outputDir, project)
|
|
4050
3500
|
return { summary, factCount: factLines.length }
|
|
@@ -4148,7 +3598,7 @@ async function auditBatch(
|
|
|
4148
3598
|
batch: ChapterPlan[],
|
|
4149
3599
|
): Promise<AuditIssue[]> {
|
|
4150
3600
|
const system = [
|
|
4151
|
-
'
|
|
3601
|
+
'你是一位严谨的网文连续性审校编辑。你会收到一本小说的道藏、事实库和一批章节正文节选。',
|
|
4152
3602
|
'请找出这批章节中的一致性矛盾,例如:',
|
|
4153
3603
|
'- 人物状态冲突:境界/修为/伤势/资源在同一章内或跨章前后矛盾。',
|
|
4154
3604
|
'- 设定违背:正文与世界观规则、金手指规则、写作红线冲突。',
|
|
@@ -4169,7 +3619,7 @@ async function auditBatch(
|
|
|
4169
3619
|
const user = [
|
|
4170
3620
|
'请对以下小说做一致性质检。',
|
|
4171
3621
|
project.bible !== undefined
|
|
4172
|
-
? '
|
|
3622
|
+
? '道藏:\n' + [
|
|
4173
3623
|
project.bible.worldRules.length > 0 ? `世界规则:\n${project.bible.worldRules.map(r => `- ${r}`).join('\n')}` : '',
|
|
4174
3624
|
project.bible.redLines.length > 0 ? `写作红线:\n${project.bible.redLines.map(r => `- ${r}`).join('\n')}` : '',
|
|
4175
3625
|
project.bible.characters.length > 0 ? `角色:\n${project.bible.characters.map(ch => `- ${ch.name}(${ch.traits.join('、')})`).join('\n')}` : '',
|
|
@@ -4179,7 +3629,7 @@ async function auditBatch(
|
|
|
4179
3629
|
`正文节选(每章前 700 字):\n${chapterBlocks}`,
|
|
4180
3630
|
'只输出 JSON 数组。',
|
|
4181
3631
|
].filter(s => s !== '').join('\n\n')
|
|
4182
|
-
const text = await complete(ctx, config, { system, user, temperature: 0.2, maxTokens: Math.max(config.maxTokens, 12000) })
|
|
3632
|
+
const text = await complete(ctx, config, { system, user, temperature: 0.2, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low', model: config.auditModel, liveLabel: '质检查询' })
|
|
4183
3633
|
const parsed = parseJsonArray<Record<string, unknown>>(text)
|
|
4184
3634
|
const issues: AuditIssue[] = []
|
|
4185
3635
|
for (const entry of parsed) {
|
|
@@ -4224,7 +3674,7 @@ export async function auditBook(
|
|
|
4224
3674
|
return all.slice(0, 50)
|
|
4225
3675
|
}
|
|
4226
3676
|
|
|
4227
|
-
/**
|
|
3677
|
+
/** 小说简介:生成或按已写开头补全(面向读者的作品门面)。 */
|
|
4228
3678
|
export async function generateBlurb(
|
|
4229
3679
|
ctx: Context,
|
|
4230
3680
|
config: NovelConfig,
|
|
@@ -4268,7 +3718,7 @@ export function bookOverview(project: ProjectState, scope: 'recent' | 'full' | n
|
|
|
4268
3718
|
s.push(`【大纲全文】\n${project.outline}`)
|
|
4269
3719
|
if (project.bible !== undefined) {
|
|
4270
3720
|
const bible = project.bible
|
|
4271
|
-
s.push('
|
|
3721
|
+
s.push('【道藏】')
|
|
4272
3722
|
if (bible.genre !== '') s.push(`题材基调:${bible.genre}`)
|
|
4273
3723
|
if (bible.worldRules.length > 0) s.push('世界规则:\n' + bible.worldRules.map(r => `- ${r}`).join('\n'))
|
|
4274
3724
|
if (bible.characters.length > 0) {
|
|
@@ -4325,7 +3775,7 @@ export function bookOverview(project: ProjectState, scope: 'recent' | 'full' | n
|
|
|
4325
3775
|
|
|
4326
3776
|
/** 一条影响分析结果(改动波及处)。 */
|
|
4327
3777
|
export interface ImpactItem {
|
|
4328
|
-
/** 位置:章节号 / 大纲 /
|
|
3778
|
+
/** 位置:章节号 / 大纲 / 道藏 / 大世界 / 事实库 / 简介。 */
|
|
4329
3779
|
location: string
|
|
4330
3780
|
/** 原文片段(定位用)。 */
|
|
4331
3781
|
quote: string
|
|
@@ -4348,7 +3798,7 @@ export async function analyzeImpact(
|
|
|
4348
3798
|
): Promise<ImpactItem[]> {
|
|
4349
3799
|
const system = [
|
|
4350
3800
|
'你是一位网文一致性审校。作者要做一处修改,请找出这次改动会波及的所有位置(设定、大纲、已写章节正文、事实库、简介中可能因此过时或矛盾的内容)。',
|
|
4351
|
-
'输出必须是合法 JSON 数组,格式:[{"location": "位置(第N
|
|
3801
|
+
'输出必须是合法 JSON 数组,格式:[{"location": "位置(第N章/大纲/道藏-世界规则/大世界-境界/事实库/简介)", "quote": "原文片段(20-60字)", "suggestion": "修改建议", "kind": "must|optional|note"}]',
|
|
4352
3802
|
'kind 含义:must=必须同步改否则矛盾;optional=建议改(影响观感);note=备注(如旧称保留为古称、或无需改但需知晓)。',
|
|
4353
3803
|
'重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
4354
3804
|
].join('\n')
|
|
@@ -4419,20 +3869,20 @@ export function renderWorld(world: WorldState | undefined): string {
|
|
|
4419
3869
|
return sections.join('\n')
|
|
4420
3870
|
}
|
|
4421
3871
|
|
|
4422
|
-
/**
|
|
3872
|
+
/** 提炼大世界:从大纲 + 道藏生成结构化境界体系/区域/势力。 */
|
|
4423
3873
|
export async function extractWorld(
|
|
4424
3874
|
ctx: Context,
|
|
4425
3875
|
config: NovelConfig,
|
|
4426
3876
|
project: ProjectState,
|
|
4427
3877
|
): Promise<WorldState> {
|
|
4428
3878
|
const system = [
|
|
4429
|
-
'
|
|
3879
|
+
'你是一位网文世界观架构师。请根据小说大纲与道藏,提炼结构化「大世界」数据。',
|
|
4430
3880
|
'输出必须是合法 JSON 对象:',
|
|
4431
3881
|
'{"realms": [{"name": "境界名", "description": "突破条件/寿命/标志等"}], "regions": [{"name": "区域名", "description": "描述", "faction": "关联势力名或空"}], "factions": [{"name": "势力名", "kind": "宗门/家族/王朝/组织等", "description": "描述", "region": "驻地区域或空"}]}',
|
|
4432
3882
|
'要求:',
|
|
4433
3883
|
'1. realms 按由低到高顺序排列(修仙题材必须含完整境界链;无境界设定的题材可输出空数组)。',
|
|
4434
3884
|
'2. 数量贴合大纲:realms 3-12 个,regions 2-10 个,factions 2-10 个。',
|
|
4435
|
-
'3.
|
|
3885
|
+
'3. 内容严格来自大纲与道藏,不要凭空发明与大纲冲突的设定。',
|
|
4436
3886
|
'重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
4437
3887
|
].join('\n')
|
|
4438
3888
|
const bibleBlock = project.bible !== undefined
|
|
@@ -4544,7 +3994,7 @@ export async function refreshCharacters(
|
|
|
4544
3994
|
].join('\n')
|
|
4545
3995
|
const rosterBlock = roster.map(ch => `- ${ch.name}(${ch.traits.join('、')})`).join('\n')
|
|
4546
3996
|
const factsBlock = facts.map(f => `[第${f.chapterNo}章] ${f.text}`).join('\n')
|
|
4547
|
-
|
|
3997
|
+
const user = [
|
|
4548
3998
|
`角色名单:\n${rosterBlock}`,
|
|
4549
3999
|
`已确立事实库(${facts.length} 条):\n${factsBlock.slice(-6000)}`,
|
|
4550
4000
|
'只输出 JSON 数组。',
|
|
@@ -4571,7 +4021,7 @@ export async function refreshCharacters(
|
|
|
4571
4021
|
appearances: entry?.chapters.size ?? 0,
|
|
4572
4022
|
})
|
|
4573
4023
|
}
|
|
4574
|
-
//
|
|
4024
|
+
// 名单外的角色(从事实库中识别到但不在道藏名单)仅当有出场统计时补充。
|
|
4575
4025
|
for (const [name, entry] of stat) {
|
|
4576
4026
|
if (!cards.some(c => c.name === name)) {
|
|
4577
4027
|
cards.push({
|
|
@@ -4613,7 +4063,7 @@ export async function suggestForeshadows(
|
|
|
4613
4063
|
`大纲:\n${project.outline}`,
|
|
4614
4064
|
`已规划章节数:${project.chapters.length}`,
|
|
4615
4065
|
].join('\n')
|
|
4616
|
-
const text = await complete(ctx, config, { system: foreshadowSystemPrompt(), user, temperature: 0.5, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low' })
|
|
4066
|
+
const text = await complete(ctx, config, { system: foreshadowSystemPrompt(), user, temperature: 0.5, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low', liveLabel: '伏笔建议' })
|
|
4617
4067
|
const parsed = parseJsonArray<Record<string, unknown>>(text)
|
|
4618
4068
|
const existing = new Set(project.foreshadows.map(f => f.description))
|
|
4619
4069
|
const created: Foreshadow[] = []
|
|
@@ -4645,17 +4095,34 @@ export async function extractStyleAsset(
|
|
|
4645
4095
|
ctx: Context,
|
|
4646
4096
|
config: NovelConfig,
|
|
4647
4097
|
sampleText: string,
|
|
4648
|
-
): Promise<{
|
|
4098
|
+
): Promise<{
|
|
4099
|
+
proseRules: string[]; dialogueRules: string[]; descriptionRules: string[]; boundaries: string[];
|
|
4100
|
+
preset?: 'imitate' | 'balanced' | 'transfer'; fingerprintRisk?: 'low' | 'medium' | 'high';
|
|
4101
|
+
writingGuidance?: string[]; forbiddenEntities?: string[];
|
|
4102
|
+
}> {
|
|
4649
4103
|
const user = `请分析下面这段样本文本,提炼其叙事风格规则:\n\n${sampleText}`
|
|
4650
|
-
const text = await complete(ctx, config, { system: styleEngineSystemPrompt(), user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 12000) })
|
|
4651
|
-
const raw = parseJsonObject<{
|
|
4104
|
+
const text = await complete(ctx, config, { system: styleEngineSystemPrompt(), user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 12000), liveLabel: '写法提取' })
|
|
4105
|
+
const raw = parseJsonObject<{
|
|
4106
|
+
proseRules?: unknown; dialogueRules?: unknown; descriptionRules?: unknown; boundaries?: unknown;
|
|
4107
|
+
preset?: unknown; fingerprintRisk?: unknown; writingGuidance?: unknown; forbiddenEntities?: unknown;
|
|
4108
|
+
}>(text)
|
|
4652
4109
|
const strArray = (value: unknown): string[] =>
|
|
4653
4110
|
Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string' && v.trim() !== '') : []
|
|
4111
|
+
// 结构化输出校验(轻量 schema):归一 enum、截断、去空;preset 缺失时按指纹风险自动推荐。
|
|
4112
|
+
const fingerprintRisk = (['low', 'medium', 'high'] as const).includes(raw.fingerprintRisk as never)
|
|
4113
|
+
? raw.fingerprintRisk as 'low' | 'medium' | 'high'
|
|
4114
|
+
: 'medium'
|
|
4654
4115
|
const result = {
|
|
4655
|
-
proseRules: strArray(raw.proseRules),
|
|
4656
|
-
dialogueRules: strArray(raw.dialogueRules),
|
|
4657
|
-
descriptionRules: strArray(raw.descriptionRules),
|
|
4658
|
-
boundaries: strArray(raw.boundaries),
|
|
4116
|
+
proseRules: strArray(raw.proseRules).slice(0, 40),
|
|
4117
|
+
dialogueRules: strArray(raw.dialogueRules).slice(0, 40),
|
|
4118
|
+
descriptionRules: strArray(raw.descriptionRules).slice(0, 40),
|
|
4119
|
+
boundaries: strArray(raw.boundaries).slice(0, 30),
|
|
4120
|
+
preset: (['imitate', 'balanced', 'transfer'] as const).includes(raw.preset as never)
|
|
4121
|
+
? raw.preset as 'imitate' | 'balanced' | 'transfer'
|
|
4122
|
+
: recommendStylePreset(fingerprintRisk),
|
|
4123
|
+
fingerprintRisk,
|
|
4124
|
+
writingGuidance: strArray(raw.writingGuidance).slice(0, 20),
|
|
4125
|
+
forbiddenEntities: strArray(raw.forbiddenEntities).slice(0, 30),
|
|
4659
4126
|
}
|
|
4660
4127
|
if (result.proseRules.length + result.dialogueRules.length + result.descriptionRules.length + result.boundaries.length === 0) {
|
|
4661
4128
|
throw new Error('写法提取失败:模型没有返回有效规则')
|
|
@@ -4663,6 +4130,404 @@ export async function extractStyleAsset(
|
|
|
4663
4130
|
return result
|
|
4664
4131
|
}
|
|
4665
4132
|
|
|
4133
|
+
/** 分层提取写作公式(basic/standard/deep)。 */
|
|
4134
|
+
export async function extractStyleFormula(
|
|
4135
|
+
ctx: Context,
|
|
4136
|
+
config: NovelConfig,
|
|
4137
|
+
sampleText: string,
|
|
4138
|
+
depth: 'basic' | 'standard' | 'deep',
|
|
4139
|
+
): Promise<{ name: string; focusAreas: string[]; formula: string; applyGuidance: string }> {
|
|
4140
|
+
const user = `请提炼下面这段样本文本的写作公式:\n\n${sampleText}`
|
|
4141
|
+
const text = await complete(ctx, config, { system: styleFormulaSystemPrompt(depth), user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 8000), liveLabel: '公式提取' })
|
|
4142
|
+
const raw = parseJsonObject<{ name?: unknown; focusAreas?: unknown; formula?: unknown; applyGuidance?: unknown }>(text)
|
|
4143
|
+
const formula = typeof raw.formula === 'string' ? raw.formula.trim() : ''
|
|
4144
|
+
if (formula === '') throw new Error('公式提取失败:模型没有返回有效 formula')
|
|
4145
|
+
return {
|
|
4146
|
+
name: typeof raw.name === 'string' ? raw.name.trim().slice(0, 40) : `公式 ${Date.now().toString(36)}`,
|
|
4147
|
+
focusAreas: Array.isArray(raw.focusAreas) ? raw.focusAreas.filter((x): x is string => typeof x === 'string' && x.trim() !== '').map(x => x.slice(0, 20)).slice(0, 6) : [],
|
|
4148
|
+
formula,
|
|
4149
|
+
applyGuidance: typeof raw.applyGuidance === 'string' ? raw.applyGuidance.trim().slice(0, 300) : '',
|
|
4150
|
+
}
|
|
4151
|
+
}
|
|
4152
|
+
|
|
4153
|
+
/** 市场雷达系统提示(对齐上游 marketRadar 分析师角色)。 */
|
|
4154
|
+
function marketRadarSystemPrompt(): string {
|
|
4155
|
+
return [
|
|
4156
|
+
'你是中文网络文学市场分析师。只分析输入中的公开榜单元数据或用户给出的市场线索,不补写作品正文,不假装知道未提供的信息。',
|
|
4157
|
+
'只根据输入归纳热门题材和市场信号;对未提供的信息不要臆测。',
|
|
4158
|
+
'重点分析:热门题材组合、主角身份、金手指机制、开篇危机、关系卖点、标题句式、拥挤套路和差异化机会。',
|
|
4159
|
+
'kind 只能使用 genre、protagonist、advantage、opening、relationship、title_pattern、opportunity、crowding,不得创造近义枚举值。',
|
|
4160
|
+
'榜单高频不等于适合照搬。机会建议必须说明读者满足点,同时避开直接复制具体作品。',
|
|
4161
|
+
'productionFoundation 的题材/推进模式优先引用「资源库」中已有的 existingId(名称一字不差);只有确无合适资产时才给出新资产(existingId 为 null)。',
|
|
4162
|
+
'creativeBrief 严禁复用榜单作品的人名、专有设定、简介句子和完整书名;只能提炼读者需求、爽点机制和结构机会,不得输出任何具体人名/作品名,一律使用身份或通用场景称谓。',
|
|
4163
|
+
].join('\n')
|
|
4164
|
+
}
|
|
4165
|
+
|
|
4166
|
+
/** 题材雷达:输入平台/题材/榜单文本 → 信号 + 生产底座 + 开书创意。 */
|
|
4167
|
+
export async function runMarketRadar(
|
|
4168
|
+
ctx: Context,
|
|
4169
|
+
config: NovelConfig,
|
|
4170
|
+
req: MarketRadarRequest,
|
|
4171
|
+
): Promise<MarketRadarResult> {
|
|
4172
|
+
const genreCatalog = BUILTIN_GENRE_LIBRARY.map((g, i) => `- [${i}] ${g.name}:${g.description}${g.template !== undefined ? `(写法:${g.template})` : ''}`).join('\n')
|
|
4173
|
+
const modeCatalog = BUILTIN_PROGRESSION_MODES.map((m, i) => `- [${i}] ${m.name}:${m.driver} / 期待:${m.readerExpectation}`).join('\n')
|
|
4174
|
+
const user = [
|
|
4175
|
+
req.platform !== undefined && req.platform !== '' ? `目标平台:${req.platform}` : '',
|
|
4176
|
+
req.genre !== undefined && req.genre !== '' ? `目标题材:${req.genre}` : '',
|
|
4177
|
+
req.keywords !== undefined && req.keywords !== '' ? `关键词:${req.keywords}` : '',
|
|
4178
|
+
(req.candidates !== undefined && req.candidates.length > 0)
|
|
4179
|
+
? '【上榜记录(真实榜单)】\n' + req.candidates.map((c, i) => `- ${i + 1}.《${c.title}》${c.author !== undefined && c.author !== '' ? `(${c.author})` : ''}${(c.tags?.length ?? 0) > 0 ? ` [${c.tags!.join('、')}]` : ''}${c.category !== undefined && c.category !== '' ? ` ${c.category}` : ''}${c.synopsis !== undefined && c.synopsis !== '' ? `:${c.synopsis.slice(0, 140)}` : ''}`).join('\n')
|
|
4180
|
+
: (req.feedText !== undefined && req.feedText !== '')
|
|
4181
|
+
? '【榜单/市场线索】\n' + req.feedText.slice(0, 6000)
|
|
4182
|
+
: '(无榜单数据,请基于平台/题材/关键词与你的市场知识归纳)',
|
|
4183
|
+
'',
|
|
4184
|
+
'现有题材基底库:\n' + (genreCatalog || '空'),
|
|
4185
|
+
'',
|
|
4186
|
+
'现有推进模式库:\n' + (modeCatalog || '空'),
|
|
4187
|
+
'',
|
|
4188
|
+
'输出必须是合法 JSON 对象,不要输出任何其他文字。',
|
|
4189
|
+
'JSON 结构:{"signals":[{"id":"短横线稳定id","kind":"genre|protagonist|advantage|opening|relationship|title_pattern|opportunity|crowding","title":"一句话","detail":"说明(含读者满足点)","direction":"current|rising|stable|falling","recommended":true|false}], "productionFoundation":{"genre":{"existingId":null或资源名,"name":"题材名","description":"说明","template":"写法指引"},"primaryStoryMode":{"existingId":null或资源名,"name":"模式名","driver":"驱动力","readerExpectation":"读者期待"},"secondaryStoryMode":{同primary,可null或省略}}}',
|
|
4190
|
+
].filter(s => s !== '').join('\n\n')
|
|
4191
|
+
const text = await complete(ctx, config, { system: marketRadarSystemPrompt(), user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 8000), liveLabel: '题材雷达' })
|
|
4192
|
+
const raw = parseJsonObject<Record<string, unknown>>(text)
|
|
4193
|
+
const signalAs = (v: unknown): MarketRadarSignal[] => Array.isArray(v)
|
|
4194
|
+
? v.filter((x): x is Record<string, unknown> => typeof x === 'object' && x !== null)
|
|
4195
|
+
.map(x => ({
|
|
4196
|
+
id: typeof x.id === 'string' ? x.id.slice(0, 40) : `sig-${Math.random().toString(36).slice(2, 7)}`,
|
|
4197
|
+
kind: (['genre', 'protagonist', 'advantage', 'opening', 'relationship', 'title_pattern', 'opportunity', 'crowding'] as const).includes(x.kind as never)
|
|
4198
|
+
? x.kind as MarketRadarSignal['kind']
|
|
4199
|
+
: 'genre',
|
|
4200
|
+
title: typeof x.title === 'string' ? x.title.slice(0, 60) : '未命名信号',
|
|
4201
|
+
detail: typeof x.detail === 'string' ? x.detail.slice(0, 300) : '',
|
|
4202
|
+
direction: (['current', 'rising', 'stable', 'falling'] as const).includes(x.direction as never)
|
|
4203
|
+
? x.direction as MarketRadarSignal['direction']
|
|
4204
|
+
: undefined,
|
|
4205
|
+
recommended: typeof x.recommended === 'boolean' ? x.recommended : undefined,
|
|
4206
|
+
}))
|
|
4207
|
+
: []
|
|
4208
|
+
const pf = (raw.productionFoundation ?? {}) as Record<string, unknown>
|
|
4209
|
+
const genre = (pf.genre ?? {}) as Record<string, unknown>
|
|
4210
|
+
const primary = (pf.primaryStoryMode ?? {}) as Record<string, unknown>
|
|
4211
|
+
const secondary = (pf.secondaryStoryMode ?? {}) as Record<string, unknown>
|
|
4212
|
+
const genreIds = new Set(BUILTIN_GENRE_LIBRARY.map(g => g.name))
|
|
4213
|
+
const modeIds = new Set(BUILTIN_PROGRESSION_MODES.map(m => m.name))
|
|
4214
|
+
const productionFoundation: ProductionFoundation = {
|
|
4215
|
+
genre: {
|
|
4216
|
+
existingId: typeof genre.existingId === 'string' && genre.existingId !== '' && genreIds.has(genre.existingId) ? genre.existingId : undefined,
|
|
4217
|
+
name: typeof genre.name === 'string' ? genre.name.slice(0, 40) : '未定题材',
|
|
4218
|
+
description: typeof genre.description === 'string' ? genre.description.slice(0, 300) : '',
|
|
4219
|
+
template: typeof genre.template === 'string' ? genre.template.slice(0, 200) : undefined,
|
|
4220
|
+
},
|
|
4221
|
+
primaryStoryMode: {
|
|
4222
|
+
existingId: typeof primary.existingId === 'string' && primary.existingId !== '' && modeIds.has(primary.existingId) ? primary.existingId : undefined,
|
|
4223
|
+
name: typeof primary.name === 'string' ? primary.name.slice(0, 40) : '未定模式',
|
|
4224
|
+
driver: typeof primary.driver === 'string' ? primary.driver.slice(0, 200) : '',
|
|
4225
|
+
readerExpectation: typeof primary.readerExpectation === 'string' ? primary.readerExpectation.slice(0, 200) : '',
|
|
4226
|
+
},
|
|
4227
|
+
secondaryStoryMode: Object.keys(secondary).length > 0 && typeof secondary.name === 'string' && secondary.name !== ''
|
|
4228
|
+
? {
|
|
4229
|
+
existingId: typeof secondary.existingId === 'string' && secondary.existingId !== '' && modeIds.has(secondary.existingId) ? secondary.existingId : undefined,
|
|
4230
|
+
name: secondary.name.slice(0, 40),
|
|
4231
|
+
driver: typeof secondary.driver === 'string' ? secondary.driver.slice(0, 200) : '',
|
|
4232
|
+
readerExpectation: typeof secondary.readerExpectation === 'string' ? secondary.readerExpectation.slice(0, 200) : '',
|
|
4233
|
+
}
|
|
4234
|
+
: undefined,
|
|
4235
|
+
}
|
|
4236
|
+
return {
|
|
4237
|
+
signals: signalAs(raw.signals),
|
|
4238
|
+
productionFoundation,
|
|
4239
|
+
// 报告 id 让前端能区分「同一份报告的重渲染」与「换了新报告」:只有后者才重置
|
|
4240
|
+
// 作者手选的信号。用短随机 id 即可,无需持久化。
|
|
4241
|
+
reportId: `mr-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
4242
|
+
}
|
|
4243
|
+
}
|
|
4244
|
+
|
|
4245
|
+
const MARKET_MODE_HINT: Record<string, string> = {
|
|
4246
|
+
follow_hot: '优先贴合当前热门满足点,但仍禁止复制具体作品。',
|
|
4247
|
+
differentiate: '保留热门读者满足点,同时至少替换主角身份、舞台或金手指机制中的一项。',
|
|
4248
|
+
light: '市场信号只作次要参考,用户自身想法和已选题材优先。',
|
|
4249
|
+
}
|
|
4250
|
+
|
|
4251
|
+
/** 开书创意简报:用选中的市场信号 + 影响模式生成可执行 constraint / creative seed。 */
|
|
4252
|
+
export async function runMarketCreativeBrief(ctx: Context, config: NovelConfig, req: MarketRadarBriefRequest): Promise<MarketCreativeBrief> {
|
|
4253
|
+
const user = [
|
|
4254
|
+
`影响模式:${req.influenceMode}`,
|
|
4255
|
+
MARKET_MODE_HINT[req.influenceMode] ?? '',
|
|
4256
|
+
req.signals.length > 0
|
|
4257
|
+
? '选中的市场信号:\n' + req.signals.map(s => `- [${s.kind}] ${s.title}:${s.detail}`).join('\n')
|
|
4258
|
+
: '(无选中信号,请结合题材与推进模式补齐)',
|
|
4259
|
+
'',
|
|
4260
|
+
'输出必须是合法 JSON 对象,不要输出任何其他文字。',
|
|
4261
|
+
'JSON 结构:{"promptBlock":"可直接指导题材/金手指/首章爆点/整书方向的约束","openingIdea":"一段可直接开书的中文起始想法(主角身份、金手指或核心优势、开局事件、近期目标;不输出标题/大纲/Markdown)","coreAdvantage":"主角能做什么(含触发条件/使用边界/成长方向或代价至少一项)","bookSellingPoint":"读者持续追读的核心满足点","first30ChapterPromise":"前30章必须兑现的阶段结果/关系变化/能力成长"}',
|
|
4262
|
+
'严禁复用榜单作品的人名、专有设定、简介句子和完整书名;一律使用身份或通用场景称谓,不得输出具体人名/作品名。',
|
|
4263
|
+
].filter(s => s !== '').join('\n\n')
|
|
4264
|
+
const text = await complete(ctx, config, { system: marketRadarSystemPrompt(), user, temperature: 0.5, maxTokens: Math.max(config.maxTokens, 4000), liveLabel: '开书创意' })
|
|
4265
|
+
const cb = parseJsonObject<Record<string, unknown>>(text)
|
|
4266
|
+
return {
|
|
4267
|
+
promptBlock: typeof cb.promptBlock === 'string' ? cb.promptBlock.slice(0, 800) : '',
|
|
4268
|
+
openingIdea: typeof cb.openingIdea === 'string' ? cb.openingIdea.slice(0, 800) : '',
|
|
4269
|
+
coreAdvantage: typeof cb.coreAdvantage === 'string' ? cb.coreAdvantage.slice(0, 600) : '',
|
|
4270
|
+
bookSellingPoint: typeof cb.bookSellingPoint === 'string' ? cb.bookSellingPoint.slice(0, 400) : '',
|
|
4271
|
+
first30ChapterPromise: typeof cb.first30ChapterPromise === 'string' ? cb.first30ChapterPromise.slice(0, 400) : '',
|
|
4272
|
+
}
|
|
4273
|
+
}
|
|
4274
|
+
|
|
4275
|
+
/** 书分析/拆书:输入一本书/章节文本 → 卖点/结构/可借鉴点/风险。 */
|
|
4276
|
+
export async function runBookAnalysis(ctx: Context, config: NovelConfig, req: BookAnalysisRequest): Promise<BookAnalysisResult> {
|
|
4277
|
+
const system = [
|
|
4278
|
+
'你是资深中文网文拆书编辑。分析输入的一本书或章节文本,提炼可复用的创作思路。',
|
|
4279
|
+
'严禁照搬具体作品的人名/专有设定/简介句子;只能提炼读者需求、结构手法和风险。',
|
|
4280
|
+
].join('\n')
|
|
4281
|
+
const user = [
|
|
4282
|
+
'请分析下面文本:\n' + (req.text ?? '').slice(0, 8000),
|
|
4283
|
+
'输出必须是合法 JSON 对象。',
|
|
4284
|
+
'JSON 结构:{"sellingPoints":["读者为什么追(卖点)"],"structure":["可复用叙事结构/节奏/单元"],"lessons":["可借鉴手法/套路"],"risks":["易踩坑/风险"]}',
|
|
4285
|
+
].filter(s => s !== '').join('\n\n')
|
|
4286
|
+
const text = await complete(ctx, config, { system, user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 4000), liveLabel: '书分析' })
|
|
4287
|
+
const raw = parseJsonObject<Record<string, unknown>>(text)
|
|
4288
|
+
const arr = (v: unknown): string[] => Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '').map(x => x.slice(0, 200)) : []
|
|
4289
|
+
return {
|
|
4290
|
+
sellingPoints: arr(raw.sellingPoints),
|
|
4291
|
+
structure: arr(raw.structure),
|
|
4292
|
+
lessons: arr(raw.lessons),
|
|
4293
|
+
risks: arr(raw.risks),
|
|
4294
|
+
}
|
|
4295
|
+
}
|
|
4296
|
+
|
|
4297
|
+
/** 创意灵感:一句话/题材 → 多方向开书灵感。 */
|
|
4298
|
+
export async function runIdeaInspiration(ctx: Context, config: NovelConfig, req: IdeaInspirationRequest): Promise<IdeaInspirationResult> {
|
|
4299
|
+
const count = Math.max(1, Math.min(10, req.count ?? 5))
|
|
4300
|
+
const system = [
|
|
4301
|
+
'你是中文网文开书灵感策划。根据用户一句话/题材方向,给出多个可开书的差异化创意。',
|
|
4302
|
+
'严禁输出具体人名/作品名;用身份/通用称谓。每个创意都要有明确钩子、题材、视角、长期兑现。',
|
|
4303
|
+
].join('\n')
|
|
4304
|
+
const user = [
|
|
4305
|
+
`我的方向:${req.idea ?? ''}`,
|
|
4306
|
+
`请给 ${count} 个开书创意。`,
|
|
4307
|
+
'输出必须是合法 JSON 对象。',
|
|
4308
|
+
'JSON 结构:{"ideas":[{"title":"书名/门面","hook":"一句话钩子/开局爆点","genre":"题材","pov":"主角视角/身份","payoff":"长期追读兑现"}]}',
|
|
4309
|
+
].filter(s => s !== '').join('\n\n')
|
|
4310
|
+
const text = await complete(ctx, config, { system, user, temperature: 0.8, maxTokens: Math.max(config.maxTokens, 4000), liveLabel: '创意灵感' })
|
|
4311
|
+
const raw = parseJsonObject<Record<string, unknown>>(text)
|
|
4312
|
+
const str = (v: unknown): string => typeof v === 'string' ? v.trim().slice(0, 200) : ''
|
|
4313
|
+
const arr = (v: unknown): Array<{ title: string; hook: string; genre: string; pov: string; payoff: string }> => Array.isArray(v)
|
|
4314
|
+
? v.filter((x): x is Record<string, unknown> => typeof x === 'object' && x !== null).map(x => ({ title: str(x.title), hook: str(x.hook), genre: str(x.genre), pov: str(x.pov), payoff: str(x.payoff) }))
|
|
4315
|
+
: []
|
|
4316
|
+
return { ideas: arr(raw.ideas).slice(0, count) }
|
|
4317
|
+
}
|
|
4318
|
+
|
|
4319
|
+
/** 雷达→灵感:基于市场信号/生产底座/创意简报,生成贴合市场的开书灵感。 */
|
|
4320
|
+
export async function runMarketIdeaInspiration(
|
|
4321
|
+
ctx: Context,
|
|
4322
|
+
config: NovelConfig,
|
|
4323
|
+
req: { signals?: MarketRadarSignal[]; foundation?: ProductionFoundation; brief?: MarketCreativeBrief; count?: number },
|
|
4324
|
+
): Promise<IdeaInspirationResult> {
|
|
4325
|
+
const count = Math.max(1, Math.min(10, req.count ?? 5))
|
|
4326
|
+
const system = [
|
|
4327
|
+
'你是中文网文开书灵感策划,擅长从市场榜单分析中提炼可开书方向。',
|
|
4328
|
+
'严禁输出具体人名/作品名;用身份/通用称谓。每个创意都要有明确钩子、题材、视角、长期兑现,且要与市场分析呼应但做差异化。',
|
|
4329
|
+
].join('\n')
|
|
4330
|
+
const signalBlock = (req.signals ?? []).map(s => `- [${s.kind}] ${s.title}:${s.detail}`).join('\n') || '(无)'
|
|
4331
|
+
const f = req.foundation
|
|
4332
|
+
const foundationBlock = f !== undefined
|
|
4333
|
+
? `题材:${f.genre.name}(${f.genre.description})\n主推进:${f.primaryStoryMode.name}(${f.primaryStoryMode.driver})${f.secondaryStoryMode !== undefined ? `\n副推进:${f.secondaryStoryMode.name}` : ''}`
|
|
4334
|
+
: '(无)'
|
|
4335
|
+
const b = req.brief
|
|
4336
|
+
const briefBlock = b !== undefined
|
|
4337
|
+
? `创作约束:${b.promptBlock}\n开篇想法:${b.openingIdea}\n核心优势:${b.coreAdvantage}\n追读卖点:${b.bookSellingPoint}\n前30章承诺:${b.first30ChapterPromise}`
|
|
4338
|
+
: '(无)'
|
|
4339
|
+
const user = [
|
|
4340
|
+
'基于以下市场分析,给出多个【差异化、可开书】的灵感:',
|
|
4341
|
+
`市场信号:\n${signalBlock}`,
|
|
4342
|
+
`生产底座:\n${foundationBlock}`,
|
|
4343
|
+
`创意简报:\n${briefBlock}`,
|
|
4344
|
+
`请给 ${count} 个开书灵感。`,
|
|
4345
|
+
'输出必须是合法 JSON 对象。',
|
|
4346
|
+
'JSON 结构:{"ideas":[{"title":"书名/门面","hook":"一句话钩子/开局爆点","genre":"题材","pov":"主角视角/身份","payoff":"长期追读兑现"}]}',
|
|
4347
|
+
].filter(s => s !== '').join('\n\n')
|
|
4348
|
+
const text = await complete(ctx, config, { system, user, temperature: 0.8, maxTokens: Math.max(config.maxTokens, 4000), liveLabel: '市场灵感' })
|
|
4349
|
+
const raw = parseJsonObject<Record<string, unknown>>(text)
|
|
4350
|
+
const str = (v: unknown): string => typeof v === 'string' ? v.trim().slice(0, 200) : ''
|
|
4351
|
+
const arr = (v: unknown): Array<{ title: string; hook: string; genre: string; pov: string; payoff: string }> => Array.isArray(v)
|
|
4352
|
+
? v.filter((x): x is Record<string, unknown> => typeof x === 'object' && x !== null).map(x => ({ title: str(x.title), hook: str(x.hook), genre: str(x.genre), pov: str(x.pov), payoff: str(x.payoff) }))
|
|
4353
|
+
: []
|
|
4354
|
+
return { ideas: arr(raw.ideas).slice(0, count) }
|
|
4355
|
+
}
|
|
4356
|
+
|
|
4357
|
+
/** 自动导演编排建议:基于全书上下文,给出下一卷/阶段编排 + 修复再平衡。 */
|
|
4358
|
+
export async function runDirectorAdvice(ctx: Context, config: NovelConfig, project: ProjectState, req: DirectorRequest): Promise<DirectorAdvice> {
|
|
4359
|
+
const done = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating')
|
|
4360
|
+
const last = done[done.length - 1]
|
|
4361
|
+
const volumesBlock = (project.volumes ?? []).map(v => `第${v.no}卷《${v.title}》:${v.summary}`).join('\n') || '无'
|
|
4362
|
+
const plotlinesBlock = (project.plotlines ?? []).filter(l => l.status === 'active' || l.status === 'paused').map(l => `[${l.kind}] ${l.name}:${l.goal}${l.progress ? `(${l.progress})` : ''}`).join('\n') || '无'
|
|
4363
|
+
const foreBlock = (project.foreshadows ?? []).filter(f => f.status === 'planted' || f.status === 'progressing').map(f => `- ${f.description}${f.targetChapter ? `(约${f.targetChapter}章回收)` : ''}`).join('\n') || '无'
|
|
4364
|
+
const recentFacts = (project.facts ?? []).slice(-8).map(f => `[第${f.chapterNo}章] ${f.text}`).join('\n')
|
|
4365
|
+
const user = [
|
|
4366
|
+
`书名:《${project.bookName}》`,
|
|
4367
|
+
req.focus !== undefined && req.focus !== '' ? `聚焦:${req.focus}` : '',
|
|
4368
|
+
`当前进度:已写 ${done.length} 章,最后一章《${last?.title ?? '无'}》摘要:${last?.summary ?? ''}`,
|
|
4369
|
+
'',
|
|
4370
|
+
`分卷:\n${volumesBlock}`,
|
|
4371
|
+
`剧情线:\n${plotlinesBlock}`,
|
|
4372
|
+
`活跃伏笔:\n${foreBlock}`,
|
|
4373
|
+
`最近事实:\n${recentFacts}`,
|
|
4374
|
+
'',
|
|
4375
|
+
'请作为本书的自动导演,给出下一卷/下一阶段的编排建议:续写方向、阶段弧光、节奏板、风险提示、需要修复/再平衡的点。',
|
|
4376
|
+
'输出必须是合法 JSON 对象。',
|
|
4377
|
+
'JSON 结构:{"summary":"总体判断一句话","nextArc":["下一阶段关键剧情节点(每条约一句话)"],"pacing":"节奏板(起承转合/爽点密度/章节节奏)","risks":["风险/跑偏提示"],"fixes":["需要修复/再平衡的点"]}',
|
|
4378
|
+
].filter(s => s !== '').join('\n\n')
|
|
4379
|
+
const text = await complete(ctx, config, { system: '你是一位长篇网文自动导演,负责整本编排、节奏、风险与再平衡。只给出可执行建议,不输出正文。', user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 4000), liveLabel: '自动导演' })
|
|
4380
|
+
const raw = parseJsonObject<Record<string, unknown>>(text)
|
|
4381
|
+
const arr = (v: unknown): string[] => Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '').map(x => x.slice(0, 200)) : []
|
|
4382
|
+
return {
|
|
4383
|
+
summary: typeof raw.summary === 'string' ? raw.summary.slice(0, 300) : '',
|
|
4384
|
+
nextArc: arr(raw.nextArc),
|
|
4385
|
+
pacing: typeof raw.pacing === 'string' ? raw.pacing.slice(0, 400) : '',
|
|
4386
|
+
risks: arr(raw.risks),
|
|
4387
|
+
fixes: arr(raw.fixes),
|
|
4388
|
+
}
|
|
4389
|
+
}
|
|
4390
|
+
|
|
4391
|
+
// ---------------------------------------------------------------- timeline
|
|
4392
|
+
|
|
4393
|
+
/**
|
|
4394
|
+
* 从一章正文抽取时间线事件(时间点 / 地点 / 参与角色 / 事件)。
|
|
4395
|
+
*
|
|
4396
|
+
* 会**替换**该章已有事件(重复抽取幂等);抽取结果按 order 递增排序后写回项目。
|
|
4397
|
+
* 失败时抛错,由调用方决定是否降级(自动流程用 try/catch 包住,不阻断出章)。
|
|
4398
|
+
*
|
|
4399
|
+
* @param ctx 宿主上下文。
|
|
4400
|
+
* @param config 运行时配置。
|
|
4401
|
+
* @param project 项目状态(就地更新 `project.timeline`)。
|
|
4402
|
+
* @param outputDir 书目录。
|
|
4403
|
+
* @param chapterNo 章节号。
|
|
4404
|
+
* @returns 本次抽取到的事件(已写入 project)。
|
|
4405
|
+
*/
|
|
4406
|
+
export async function extractTimelineForChapter(
|
|
4407
|
+
ctx: Context,
|
|
4408
|
+
config: NovelConfig,
|
|
4409
|
+
project: ProjectState,
|
|
4410
|
+
outputDir: string,
|
|
4411
|
+
chapterNo: number,
|
|
4412
|
+
): Promise<TimelineEvent[]> {
|
|
4413
|
+
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
4414
|
+
if (chapter === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 不在计划中`)
|
|
4415
|
+
const body = readChapterFile(outputDir, chapter)
|
|
4416
|
+
if (body === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 的正文文件不存在`)
|
|
4417
|
+
const NL = String.fromCharCode(10)
|
|
4418
|
+
const system = [
|
|
4419
|
+
'你是小说连续性编辑。任务:把一章正文压缩成「故事时间线」事件,供后续章节保持时间与地点的连续性。',
|
|
4420
|
+
'要求:',
|
|
4421
|
+
'1. 只抽取**正文里真实发生**的场景节点(3-8 条),不要抽取预告、回忆性的泛泛陈述;纯粹的回忆/插叙请在 event 里注明「(回忆)」并给出其故事内时间。',
|
|
4422
|
+
'2. time 用正文里的说法(如「第三日黄昏」「入宗三个月后」「同日夜」);正文没写时间就填相对说法(如「紧接上一场景」)。',
|
|
4423
|
+
'3. order 是本章内的先后序号,从 1 递增;跨章我会用章号排序,你只需要保证章内顺序正确。',
|
|
4424
|
+
'4. place 是场景地点(一处一个词,如「青云宗外门」);characters 只列**在场**的角色名(用正文里的名字,不要写「主角」)。',
|
|
4425
|
+
'5. event 一句话说清发生了什么(不超过 40 字)。',
|
|
4426
|
+
'输出必须是合法 JSON 数组,不要输出任何其他文字或 Markdown 代码块标记:',
|
|
4427
|
+
'[{"time":"第三日黄昏","order":1,"place":"青云宗外门","characters":["沈青"],"event":"沈青被外门弟子当众羞辱"}]',
|
|
4428
|
+
'重要:字符串值内部不得包含换行符;JSON 必须在一段内完整结束。',
|
|
4429
|
+
].join('@NL@').replace('@NL@', NL)
|
|
4430
|
+
const text = await complete(ctx, config, {
|
|
4431
|
+
system,
|
|
4432
|
+
user: `第 ${chapterNo} 章《${chapter.title}》正文:@NL@@NL@${body.replace(/^#\s+.*$/m, '').trim().slice(0, 24000)}`.replace('@NL@', NL),
|
|
4433
|
+
temperature: 0.2,
|
|
4434
|
+
maxTokens: Math.max(config.maxTokens, 4000),
|
|
4435
|
+
model: config.auditModel || config.model,
|
|
4436
|
+
liveLabel: `时间线抽取 · 第${chapterNo}章`,
|
|
4437
|
+
})
|
|
4438
|
+
let parsed: unknown = []
|
|
4439
|
+
try {
|
|
4440
|
+
parsed = parseJson<unknown>(text, true)
|
|
4441
|
+
} catch {
|
|
4442
|
+
parsed = []
|
|
4443
|
+
}
|
|
4444
|
+
const rawList = Array.isArray(parsed) ? parsed : []
|
|
4445
|
+
const now = new Date().toISOString()
|
|
4446
|
+
const extracted = rawList
|
|
4447
|
+
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
|
|
4448
|
+
.map((item, index) => normalizeTimelineEvent({
|
|
4449
|
+
time: typeof item.time === 'string' ? item.time : '',
|
|
4450
|
+
order: typeof item.order === 'number' ? item.order : index + 1,
|
|
4451
|
+
place: typeof item.place === 'string' ? item.place : '',
|
|
4452
|
+
characters: Array.isArray(item.characters) ? item.characters.filter((c): c is string => typeof c === 'string') : [],
|
|
4453
|
+
event: typeof item.event === 'string' ? item.event : '',
|
|
4454
|
+
}, chapterNo, index, now))
|
|
4455
|
+
.filter(e => e.event !== '')
|
|
4456
|
+
// 保留模型给出的**叙述顺序**(不按 order 重排):时间线检查要靠这个顺序发现
|
|
4457
|
+
// 「自报 order 与叙述顺序打架」;渲染/注入时再用 sortTimeline 按 order 排。
|
|
4458
|
+
const kept = (project.timeline ?? []).filter(e => e.chapterNo !== chapterNo)
|
|
4459
|
+
project.timeline = [...kept, ...extracted]
|
|
4460
|
+
project.updatedAt = now
|
|
4461
|
+
return extracted
|
|
4462
|
+
}
|
|
4463
|
+
|
|
4464
|
+
/**
|
|
4465
|
+
* AI 复核时间线:在规则初筛之外,让模型找规则抓不到的时序问题
|
|
4466
|
+
* (年龄/季节错位、回忆与当前时间混淆、跨章跨度不合理等)。
|
|
4467
|
+
*
|
|
4468
|
+
* @param ctx 宿主上下文。
|
|
4469
|
+
* @param config 运行时配置。
|
|
4470
|
+
* @param project 项目状态。
|
|
4471
|
+
* @returns 模型给出的问题清单(与规则初筛结果合并由调用方决定)。
|
|
4472
|
+
*/
|
|
4473
|
+
export async function reviewTimeline(
|
|
4474
|
+
ctx: Context,
|
|
4475
|
+
config: NovelConfig,
|
|
4476
|
+
project: ProjectState,
|
|
4477
|
+
): Promise<TimelineIssue[]> {
|
|
4478
|
+
const events = sortTimeline(project.timeline ?? [])
|
|
4479
|
+
if (events.length === 0) return []
|
|
4480
|
+
const NL = String.fromCharCode(10)
|
|
4481
|
+
const lines = events.map(e => {
|
|
4482
|
+
const parts = [`第${e.chapterNo}章`]
|
|
4483
|
+
if (e.order !== undefined) parts.push(`#${e.order}`)
|
|
4484
|
+
if (e.time !== '') parts.push(e.time)
|
|
4485
|
+
if (e.place !== '') parts.push(`@${e.place}`)
|
|
4486
|
+
if (e.characters.length > 0) parts.push(`(${e.characters.join('、')})`)
|
|
4487
|
+
parts.push(e.event)
|
|
4488
|
+
return '- ' + parts.join(' ')
|
|
4489
|
+
})
|
|
4490
|
+
const system = [
|
|
4491
|
+
'你是小说连续性编辑,只做一件事:找**时间线**上的矛盾。',
|
|
4492
|
+
'检查项:1) 时间倒流(后一章早于前一章且没有明确插叙标注);2) 地点瞬移(同一人物无移动交代就换场景);',
|
|
4493
|
+
'3) 时间跨度与事件量明显不匹配(如「一日之内」发生跨越数月的剧情);4) 季节/年龄/节日前后矛盾;5) 回忆与现实混淆。',
|
|
4494
|
+
'只报**能在给定事件表里指认**的问题,不要臆测;没有问题就返回空数组。',
|
|
4495
|
+
'输出必须是合法 JSON 数组,不要任何其他文字:',
|
|
4496
|
+
'[{"severity":"high|medium|low","chapters":[3,7],"item":"问题一句话","suggestion":"怎么修"}]',
|
|
4497
|
+
'重要:字符串值内部不得包含换行符。',
|
|
4498
|
+
].join(NL)
|
|
4499
|
+
const text = await complete(ctx, config, {
|
|
4500
|
+
system,
|
|
4501
|
+
user: `故事时间线(按章节顺序):@NL@${lines.join(NL)}`.replace('@NL@', NL),
|
|
4502
|
+
temperature: 0.2,
|
|
4503
|
+
maxTokens: Math.max(config.maxTokens, 4000),
|
|
4504
|
+
model: config.auditModel || config.model,
|
|
4505
|
+
liveLabel: '时间线复核',
|
|
4506
|
+
})
|
|
4507
|
+
let parsed: unknown = []
|
|
4508
|
+
try {
|
|
4509
|
+
parsed = parseJson<unknown>(text, true)
|
|
4510
|
+
} catch {
|
|
4511
|
+
parsed = []
|
|
4512
|
+
}
|
|
4513
|
+
if (!Array.isArray(parsed)) return []
|
|
4514
|
+
return parsed
|
|
4515
|
+
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
|
|
4516
|
+
.map(item => {
|
|
4517
|
+
const severity = item.severity === 'high' || item.severity === 'low' ? item.severity : 'medium'
|
|
4518
|
+
const chapters = Array.isArray(item.chapters)
|
|
4519
|
+
? item.chapters.filter((c): c is number => typeof c === 'number' && Number.isFinite(c))
|
|
4520
|
+
: []
|
|
4521
|
+
return {
|
|
4522
|
+
severity,
|
|
4523
|
+
chapters,
|
|
4524
|
+
item: typeof item.item === 'string' ? item.item.slice(0, 300) : '',
|
|
4525
|
+
suggestion: typeof item.suggestion === 'string' ? item.suggestion.slice(0, 300) : '',
|
|
4526
|
+
} satisfies TimelineIssue
|
|
4527
|
+
})
|
|
4528
|
+
.filter(issue => issue.item !== '')
|
|
4529
|
+
}
|
|
4530
|
+
|
|
4666
4531
|
// ------------------------------------------------------------------ export
|
|
4667
4532
|
|
|
4668
4533
|
/** Export the whole book as one txt/md file. */
|
|
@@ -4688,3 +4553,207 @@ export function exportBook(outputDir: string, project: ProjectState, format: 'tx
|
|
|
4688
4553
|
writeFileSync(join(outputDir, file), content, 'utf8')
|
|
4689
4554
|
return { file, chars: content.length, chapters: done.length }
|
|
4690
4555
|
}
|
|
4556
|
+
|
|
4557
|
+
/** 逐行渲染「- 名称:值」清单,跳过空值。 */
|
|
4558
|
+
function renderPairs(pairs: Array<[string, string | undefined]>): string {
|
|
4559
|
+
return pairs
|
|
4560
|
+
.filter(([, v]) => v !== undefined && v !== '')
|
|
4561
|
+
.map(([k, v]) => `- ${k}:${v}`)
|
|
4562
|
+
.join('\n')
|
|
4563
|
+
}
|
|
4564
|
+
|
|
4565
|
+
/** 渲染项目设定(卷首语 / 开书定盘 / 总纲 / 道藏 / 大世界 / 写作资产)。 */
|
|
4566
|
+
function renderSettings(project: ProjectState): string {
|
|
4567
|
+
const out: string[] = [`# ${project.bookName} · 项目设定`, '']
|
|
4568
|
+
out.push('## 卷首语 / 简介', '', project.blurb !== undefined && project.blurb !== '' ? project.blurb : '(未填写)', '')
|
|
4569
|
+
const c = project.bookContract
|
|
4570
|
+
if (c !== undefined) {
|
|
4571
|
+
out.push('## 开书定盘', '', renderPairs([
|
|
4572
|
+
['书籍承诺', c.promise],
|
|
4573
|
+
['主推进模式', c.primaryModeName],
|
|
4574
|
+
['辅助推进模式', (c.secondaryModeNames ?? []).join('、')],
|
|
4575
|
+
['文风基调', c.tone],
|
|
4576
|
+
['目标平台', c.targetPlatform],
|
|
4577
|
+
]), '')
|
|
4578
|
+
}
|
|
4579
|
+
out.push('## 总纲', '', project.outline.trim() === '' ? '(未导入)' : project.outline.trim(), '')
|
|
4580
|
+
const bible = project.bible
|
|
4581
|
+
if (bible !== undefined) {
|
|
4582
|
+
out.push('## 道藏', '')
|
|
4583
|
+
if (bible.worldRules.length > 0) out.push('### 世界规则', '', bible.worldRules.map((r, i) => `${i + 1}. ${r}`).join('\n'), '')
|
|
4584
|
+
if (bible.redLines.length > 0) out.push('### 写作红线', '', bible.redLines.map(r => `- ${r}`).join('\n'), '')
|
|
4585
|
+
if (bible.style.length > 0) out.push('### 文风', '', bible.style.map(x => `- ${x}`).join('\n'), '')
|
|
4586
|
+
if (bible.characters.length > 0) {
|
|
4587
|
+
out.push('### 角色(道藏)', '')
|
|
4588
|
+
for (const ch of bible.characters) {
|
|
4589
|
+
out.push(`- **${ch.name}**(${ch.role})`)
|
|
4590
|
+
if (ch.traits.length > 0) out.push(` - 标签:${ch.traits.join('、')}`)
|
|
4591
|
+
if (ch.goals !== '') out.push(` - 目标:${ch.goals}`)
|
|
4592
|
+
if (ch.relations !== '') out.push(` - 关系:${ch.relations}`)
|
|
4593
|
+
if ((ch.knowledge ?? []).length > 0) out.push(` - 知情:${(ch.knowledge ?? []).join(';')}`)
|
|
4594
|
+
}
|
|
4595
|
+
out.push('')
|
|
4596
|
+
}
|
|
4597
|
+
}
|
|
4598
|
+
const world = project.world
|
|
4599
|
+
if (world !== undefined) {
|
|
4600
|
+
out.push('## 大世界', '')
|
|
4601
|
+
if (world.realms.length > 0) out.push('### 境界体系', '', world.realms.map((r, i) => `${i + 1}. ${r.name}${r.description !== '' ? `:${r.description}` : ''}`).join('\n'), '')
|
|
4602
|
+
if (world.regions.length > 0) out.push('### 区域', '', world.regions.map(r => `- ${r.name}${r.description !== '' ? `:${r.description}` : ''}`).join('\n'), '')
|
|
4603
|
+
if (world.factions.length > 0) out.push('### 势力', '', world.factions.map(f => `- ${f.name}(${f.kind})${f.description !== '' ? `:${f.description}` : ''}`).join('\n'), '')
|
|
4604
|
+
}
|
|
4605
|
+
const assets = project.assets
|
|
4606
|
+
if (assets !== undefined) {
|
|
4607
|
+
const head: Array<[string, string | undefined]> = []
|
|
4608
|
+
if (assets.genre !== undefined) head.push(['题材', assets.genre.name])
|
|
4609
|
+
if (assets.primaryProgression !== undefined) head.push(['主推进', assets.primaryProgression.name])
|
|
4610
|
+
if ((assets.auxiliaryProgressions ?? []).length > 0) head.push(['辅助推进', assets.auxiliaryProgressions.map(a => a.name).join('、')])
|
|
4611
|
+
if (head.length > 0) out.push('## 写作资产', '', renderPairs(head), '')
|
|
4612
|
+
if ((assets.antiAiRules ?? []).length > 0) {
|
|
4613
|
+
out.push('### 反 AI 规则', '')
|
|
4614
|
+
out.push(assets.antiAiRules.map(r => `- ${r.name}:避免「${r.avoid}」→ ${r.fix}`).join('\n'), '')
|
|
4615
|
+
}
|
|
4616
|
+
if ((assets.styleAssets ?? []).length > 0) {
|
|
4617
|
+
out.push('### 笔法帖 / 写法资产', '')
|
|
4618
|
+
out.push(assets.styleAssets.map(x => `- ${x.name}`).join('\n'), '')
|
|
4619
|
+
}
|
|
4620
|
+
}
|
|
4621
|
+
return out.join('\n').replace(/\n(\n)+/g, '\n\n')
|
|
4622
|
+
}
|
|
4623
|
+
|
|
4624
|
+
/** 渲染规划(卷计划 + 章节计划)。 */
|
|
4625
|
+
function renderPlan(project: ProjectState): string {
|
|
4626
|
+
const out: string[] = [`# ${project.bookName} · 规划`, '']
|
|
4627
|
+
const volumes = (project.volumes ?? []).slice().sort((a, b) => a.no - b.no)
|
|
4628
|
+
if (volumes.length > 0) {
|
|
4629
|
+
out.push('## 卷计划', '')
|
|
4630
|
+
for (const v of volumes) {
|
|
4631
|
+
out.push(`### 第${v.no}卷 ${v.title}`, '')
|
|
4632
|
+
if (v.summary !== '') out.push(v.summary, '')
|
|
4633
|
+
}
|
|
4634
|
+
}
|
|
4635
|
+
out.push(`## 章节计划(共 ${project.chapters.length} 章)`, '')
|
|
4636
|
+
for (const c of project.chapters.slice().sort((a, b) => a.no - b.no)) {
|
|
4637
|
+
out.push(`### 第${c.no}章 ${c.title} [${c.status}]`, '')
|
|
4638
|
+
if (c.beats !== '') out.push(c.beats.trim(), '')
|
|
4639
|
+
const extra = renderPairs([
|
|
4640
|
+
['本章必达', (c.mustAdvance ?? []).join(';')],
|
|
4641
|
+
['不可破坏', (c.mustPreserve ?? []).join(';')],
|
|
4642
|
+
['人物硬事实', (c.characterHardFacts ?? []).join(';')],
|
|
4643
|
+
['结尾钩子', c.endingHook],
|
|
4644
|
+
['义务合约', c.obligation],
|
|
4645
|
+
])
|
|
4646
|
+
if (extra !== '') out.push(extra, '')
|
|
4647
|
+
if (c.summary !== undefined && c.summary !== '') out.push(`- 摘要:${c.summary}`, '')
|
|
4648
|
+
}
|
|
4649
|
+
return out.join('\n')
|
|
4650
|
+
}
|
|
4651
|
+
|
|
4652
|
+
/** 渲染角色(角色库 + 人物志)。 */
|
|
4653
|
+
function renderCharacters(project: ProjectState): string {
|
|
4654
|
+
const out: string[] = [`# ${project.bookName} · 角色`, '']
|
|
4655
|
+
const roles = project.roles ?? []
|
|
4656
|
+
out.push(`## 角色库(${roles.length})`, '')
|
|
4657
|
+
if (roles.length === 0) out.push('(空)', '')
|
|
4658
|
+
for (const r of roles) {
|
|
4659
|
+
out.push(`### ${r.name}(${r.roleLabel})`, '')
|
|
4660
|
+
const info = renderPairs([
|
|
4661
|
+
['身份', r.identity],
|
|
4662
|
+
['性格', r.traits.join('、')],
|
|
4663
|
+
['目标', r.goals],
|
|
4664
|
+
['关系网', r.relations.join(';')],
|
|
4665
|
+
['成长线', r.arc.join(' → ')],
|
|
4666
|
+
['知情度', r.knowledge.join(';')],
|
|
4667
|
+
['首次出场', r.firstChapter !== undefined ? `第${r.firstChapter}章` : undefined],
|
|
4668
|
+
])
|
|
4669
|
+
if (info !== '') out.push(info, '')
|
|
4670
|
+
}
|
|
4671
|
+
const status = project.roleStatus ?? []
|
|
4672
|
+
if (status.length > 0) {
|
|
4673
|
+
out.push('## 人物志(当前状态)', '')
|
|
4674
|
+
for (const card of status) {
|
|
4675
|
+
out.push(`- **${card.name}**(${card.role})${card.status} — 最近出场第 ${card.lastChapter} 章,累计 ${card.appearances} 次`)
|
|
4676
|
+
}
|
|
4677
|
+
}
|
|
4678
|
+
return out.join('\n')
|
|
4679
|
+
}
|
|
4680
|
+
|
|
4681
|
+
/** 渲染质检记录(逐章审稿 + 作者复盘 + 暗线 + 剧情线 + 待办)。 */
|
|
4682
|
+
function renderReview(project: ProjectState): string {
|
|
4683
|
+
const out: string[] = [`# ${project.bookName} · 质检记录`, '']
|
|
4684
|
+
const reviewed = project.chapters.filter(c => c.review !== undefined).slice().sort((a, b) => a.no - b.no)
|
|
4685
|
+
out.push(`## 审稿(${reviewed.length} / ${project.chapters.length} 章)`, '')
|
|
4686
|
+
for (const c of reviewed) {
|
|
4687
|
+
const r = c.review!
|
|
4688
|
+
out.push(`### 第${c.no}章 ${c.title} ${r.score} 分 · ${r.passed ? '通过' : '未过'}`, '')
|
|
4689
|
+
if (r.verdict !== '') out.push(r.verdict, '')
|
|
4690
|
+
if (r.issues.length > 0) {
|
|
4691
|
+
out.push(r.issues.map(i => `- [${i.severity}] ${i.item}${i.suggestion !== '' ? ` → ${i.suggestion}` : ''}`).join('\n'), '')
|
|
4692
|
+
}
|
|
4693
|
+
}
|
|
4694
|
+
const author = project.chapters.filter(c => c.authorReview !== undefined).slice().sort((a, b) => a.no - b.no)
|
|
4695
|
+
if (author.length > 0) {
|
|
4696
|
+
out.push('## 作者复盘', '')
|
|
4697
|
+
for (const c of author) {
|
|
4698
|
+
const a = c.authorReview!
|
|
4699
|
+
out.push(`### 第${c.no}章 ${c.title}`, '')
|
|
4700
|
+
out.push(renderPairs([
|
|
4701
|
+
['上一章钩子兑现', a.hookHonored ? '是' : '否'],
|
|
4702
|
+
['钩子说明', a.hookNote],
|
|
4703
|
+
['结尾钩子强度', String(a.endingHook)],
|
|
4704
|
+
['剧情线推进', a.plotlineProgress],
|
|
4705
|
+
['连续性', a.continuity],
|
|
4706
|
+
]), '')
|
|
4707
|
+
}
|
|
4708
|
+
}
|
|
4709
|
+
const fores = project.foreshadows ?? []
|
|
4710
|
+
if (fores.length > 0) {
|
|
4711
|
+
out.push('## 暗线 / 伏笔', '')
|
|
4712
|
+
out.push(fores.map(f => `- [${f.status}] ${f.description}${f.targetChapter !== undefined ? `(预计第 ${f.targetChapter} 章回收)` : ''}`).join('\n'), '')
|
|
4713
|
+
}
|
|
4714
|
+
const plotlines = project.plotlines ?? []
|
|
4715
|
+
if (plotlines.length > 0) {
|
|
4716
|
+
out.push('## 剧情线', '')
|
|
4717
|
+
out.push(plotlines.map(l => `- [${l.status}] ${l.name}:${l.goal}(${l.progress})`).join('\n'), '')
|
|
4718
|
+
}
|
|
4719
|
+
const todos = project.todos ?? []
|
|
4720
|
+
if (todos.length > 0) {
|
|
4721
|
+
out.push('## 编辑待办', '')
|
|
4722
|
+
out.push(todos.map(t => `- [${t.done ? 'x' : ' '}] ${t.text}`).join('\n'), '')
|
|
4723
|
+
}
|
|
4724
|
+
return out.join('\n')
|
|
4725
|
+
}
|
|
4726
|
+
|
|
4727
|
+
/**
|
|
4728
|
+
* 按范围导出:整本正文 / 设定 / 规划 / 角色 / 质检记录 / 项目 JSON。
|
|
4729
|
+
*
|
|
4730
|
+
* @param outputDir 书目录。
|
|
4731
|
+
* @param project 项目状态。
|
|
4732
|
+
* @param scope 导出范围。
|
|
4733
|
+
* @param format txt / md / json(json 只对 `project` 范围有独立含义,其余按 md 落盘)。
|
|
4734
|
+
* @returns 文件名、字符数与章节数。
|
|
4735
|
+
*/
|
|
4736
|
+
export function exportScope(
|
|
4737
|
+
outputDir: string,
|
|
4738
|
+
project: ProjectState,
|
|
4739
|
+
scope: ExportScope,
|
|
4740
|
+
format: 'txt' | 'md' | 'json',
|
|
4741
|
+
): { file: string; chars: number; chapters: number } {
|
|
4742
|
+
if (scope === 'book') return exportBook(outputDir, project, format === 'json' ? 'md' : format)
|
|
4743
|
+
if (scope === 'project') {
|
|
4744
|
+
const payload = { schema: 'dsh-novel-forge/project@1', exportedAt: new Date().toISOString(), project }
|
|
4745
|
+
const content = JSON.stringify(payload, null, 2)
|
|
4746
|
+
const file = `《${safeFileName(project.bookName)}》项目备份.json`
|
|
4747
|
+
writeFileSync(join(outputDir, file), content, 'utf8')
|
|
4748
|
+
return { file, chars: content.length, chapters: project.chapters.length }
|
|
4749
|
+
}
|
|
4750
|
+
const body = scope === 'settings' ? renderSettings(project)
|
|
4751
|
+
: scope === 'plan' ? renderPlan(project)
|
|
4752
|
+
: scope === 'characters' ? renderCharacters(project)
|
|
4753
|
+
: renderReview(project)
|
|
4754
|
+
const ext = format === 'json' ? 'md' : format
|
|
4755
|
+
const label = scope === 'settings' ? '项目设定' : scope === 'plan' ? '规划' : scope === 'characters' ? '角色' : '质检记录'
|
|
4756
|
+
const file = `《${safeFileName(project.bookName)}》${label}.${ext}`
|
|
4757
|
+
writeFileSync(join(outputDir, file), body, 'utf8')
|
|
4758
|
+
return { file, chars: body.length, chapters: 0 }
|
|
4759
|
+
}
|