@waterwx/dsh-novel-forge 2.0.0-alpha.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 +286 -285
- package/lib/client.js +19561 -19439
- package/lib/index.js +8159 -4456
- package/lib/types/action-guard.d.ts +61 -0
- package/lib/types/ai-scan.d.ts +0 -6
- 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 +136 -27
- package/lib/types/client/chip-prefs.d.ts +36 -0
- package/lib/types/client/docx.d.ts +2 -2
- 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 -11
- 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 -9
- 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 +78 -103
- package/lib/types/global-assets.d.ts +6 -0
- package/lib/types/home.d.ts +11 -0
- package/lib/types/index.d.ts +26 -12
- 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/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 +816 -601
- 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 -25
- package/src/action-guard.ts +127 -0
- package/src/ai-scan.ts +0 -24
- package/src/assets.ts +1801 -879
- 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 -617
- package/src/client/chip-prefs.ts +121 -0
- package/src/client/docx.ts +32 -69
- package/src/client/index.ts +2 -0
- package/src/client/llmLive.ts +111 -0
- package/src/client/locales.ts +15 -274
- package/src/client/mount.tsx +3 -2
- 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 -204
- 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 +3144 -2205
- 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 -106
- 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 -4360
- 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 +1197 -1638
- package/src/global-assets.ts +58 -0
- package/src/home.ts +33 -0
- package/src/index.ts +181 -101
- 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 -287
- package/src/output-budget.ts +147 -0
- package/src/prompt-slots.ts +125 -0
- package/src/prompting.ts +26 -0
- package/src/protocol.ts +835 -614
- package/src/revision.ts +283 -0
- package/src/routes.ts +1286 -910
- 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/guoman-2d-gufeng.webp +0 -0
- package/assets/styles/guoman-3d-tianyuan.webp +0 -0
- package/assets/styles/guoman-3d-xuanhuan.webp +0 -0
- package/assets/styles/horror-game-3d.webp +0 -0
- package/assets/styles/hyperreal-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/sandiao-qban.webp +0 -0
- package/assets/styles/shinkai-anime.webp +0 -0
- package/assets/styles/tactical-stealth.webp +0 -0
- package/assets/styles/urban-lightreal-3d.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 -22
- package/lib/types/client/panel/MangaRoleLibrary.d.ts +0 -20
- package/lib/types/client/panel/MangaWorkspace.d.ts +0 -12
- 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/PropLibrary.d.ts +0 -8
- package/lib/types/client/panel/SceneLibrary.d.ts +0 -16
- 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/manga-genre-rules.d.ts +0 -22
- package/lib/types/prompt-utils.d.ts +0 -35
- package/lib/types/review-policy.d.ts +0 -97
- 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 -84
- package/src/client/panel/MangaRoleLibrary.tsx +0 -1002
- package/src/client/panel/MangaWorkspace.tsx +0 -715
- package/src/client/panel/ProgressHomeView.tsx +0 -87
- package/src/client/panel/PropLibrary.tsx +0 -175
- package/src/client/panel/SceneLibrary.tsx +0 -453
- package/src/client/panel/StoryboardTab.tsx +0 -472
- 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/manga-genre-rules.ts +0 -87
- package/src/prompt-utils.ts +0 -73
- package/src/review-policy.ts +0 -161
- package/src/shot-language.ts +0 -181
- package/src/story-beat-language.ts +0 -130
- package/src/style-library.ts +0 -84
package/src/engine.ts
CHANGED
|
@@ -23,45 +23,38 @@ export const COMPLIANCE_REDLINES: ReadonlyArray<string> = [
|
|
|
23
23
|
'9. 不得出现法律法规禁止的其他内容。',
|
|
24
24
|
]
|
|
25
25
|
|
|
26
|
-
/**
|
|
26
|
+
/** 审稿维度取值:归一化模型输出的 dimension 字段。维度清单以 reviewSystemPrompt 的九条为准。 */
|
|
27
27
|
const REVIEW_DIMENSION_IDS = new Set(['character', 'setting', 'redline', 'writing', 'pacing', 'logic', 'anti-ai', 'presentation', 'compliance'])
|
|
28
28
|
|
|
29
29
|
import { mkdirSync, readFileSync, writeFileSync, readdirSync, existsSync, rmSync, renameSync } from 'node:fs'
|
|
30
|
-
import { join, basename, extname
|
|
31
|
-
import {
|
|
30
|
+
import { join, basename, extname } from 'node:path'
|
|
31
|
+
import { tmpdir } from 'node:os'
|
|
32
32
|
import { createUserMessage, BlockAssembler, ReasoningEffortId, type GenerateOptions, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
33
33
|
import type { Context } from '@deepseek-ai/cordis'
|
|
34
|
-
import { emptyProjectAssets, renderAllAssets, styleEngineSystemPrompt } from './assets.ts'
|
|
35
|
-
import { getComicStylePrompt } from './comic-presets.ts'
|
|
36
|
-
import { getGenreRules } from './manga-genre-rules.ts'
|
|
37
|
-
import { findStyle, styleKeywords } from './style-library.ts'
|
|
34
|
+
import { BUILTIN_GENRE_LIBRARY, BUILTIN_PROGRESSION_MODES, emptyProjectAssets, recommendStylePreset, renderAllAssets, styleEngineSystemPrompt, styleFormulaSystemPrompt } from './assets.ts'
|
|
38
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'
|
|
39
46
|
import { buildChapterContext, renderContextBlocks } from './novel-context.ts'
|
|
40
|
-
import {
|
|
41
|
-
normalizeShotSize,
|
|
42
|
-
normalizeCameras,
|
|
43
|
-
normalizeComposition,
|
|
44
|
-
normalizeLightings,
|
|
45
|
-
sizeZh,
|
|
46
|
-
cameraZh,
|
|
47
|
-
lightZh,
|
|
48
|
-
} from './shot-language.ts'
|
|
49
|
-
import {
|
|
50
|
-
normalizeStoryFunction,
|
|
51
|
-
normalizeEmotions,
|
|
52
|
-
functionZh,
|
|
53
|
-
emotionZh,
|
|
54
|
-
} from './story-beat-language.ts'
|
|
55
47
|
import type {
|
|
56
48
|
AdaptationDimension,
|
|
57
49
|
AdaptAnalyzeResponse,
|
|
58
50
|
AdaptationMapping,
|
|
59
|
-
AdaptationProposal,
|
|
60
51
|
AdaptationRules,
|
|
61
52
|
AdaptProposeResponse,
|
|
62
|
-
AdaptExecuteResponse,
|
|
63
53
|
AuditIssue,
|
|
64
54
|
AuthorReview,
|
|
55
|
+
ExportScope,
|
|
56
|
+
TimelineEvent,
|
|
57
|
+
TimelineIssue,
|
|
65
58
|
BreakdownResponse,
|
|
66
59
|
ChapterPlan,
|
|
67
60
|
Foreshadow,
|
|
@@ -72,39 +65,38 @@ import type {
|
|
|
72
65
|
PlotlinePlan,
|
|
73
66
|
ProjectState,
|
|
74
67
|
ReviewDimension,
|
|
75
|
-
ReviewIssue,
|
|
76
68
|
ReviewReport,
|
|
77
69
|
RoleRecord,
|
|
78
70
|
RoleStatusCard,
|
|
79
|
-
SceneCard,
|
|
80
71
|
StoryBible,
|
|
81
|
-
StoryboardSkeleton,
|
|
82
|
-
StoryboardBeat,
|
|
83
|
-
StoryboardTable,
|
|
84
|
-
StoryboardShot,
|
|
85
|
-
StoryboardPrompt,
|
|
86
|
-
ChapterStoryboard,
|
|
87
72
|
AddModelRequest,
|
|
88
73
|
AddModelResponse,
|
|
89
74
|
SavedModel,
|
|
90
|
-
LlmModelOption,
|
|
91
75
|
LlmModelsResponse,
|
|
92
76
|
LlmVendorOption,
|
|
93
77
|
LlmVendorsResponse,
|
|
94
78
|
LlmProvidersResponse,
|
|
95
79
|
RemoveProviderRequest,
|
|
96
80
|
RemoveProviderResponse,
|
|
97
|
-
LlmTestRequest,
|
|
98
81
|
LlmTestResponse,
|
|
99
|
-
MangaRoleCandidate,
|
|
100
|
-
MangaRoleCard,
|
|
101
|
-
Prop,
|
|
102
82
|
Volume,
|
|
103
83
|
WorldState,
|
|
104
84
|
AdaptMaterializeRequest,
|
|
105
85
|
AdaptMaterializeResponse,
|
|
106
86
|
AdaptMaterializeSaveRequest,
|
|
107
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,
|
|
108
100
|
} from './protocol.ts'
|
|
109
101
|
import { LLM_VENDORS } from './protocol.ts'
|
|
110
102
|
|
|
@@ -179,7 +171,9 @@ export function loadProject(outputDir: string): ProjectState | undefined {
|
|
|
179
171
|
if (!Array.isArray(raw.facts)) raw.facts = []
|
|
180
172
|
if (!Array.isArray(raw.plotlines)) raw.plotlines = []
|
|
181
173
|
return raw
|
|
182
|
-
} catch {
|
|
174
|
+
} catch (error) {
|
|
175
|
+
// 项目文件损坏被静默当"无项目"会遮蔽数据丢失,留痕到服务端日志。
|
|
176
|
+
console.error(`[novel-forge] project file unreadable/corrupt: ${file} — ${(error as Error).message}`)
|
|
183
177
|
return undefined
|
|
184
178
|
}
|
|
185
179
|
}
|
|
@@ -213,7 +207,6 @@ export function mergeVolatileFromDisk(outputDir: string, project: ProjectState):
|
|
|
213
207
|
if (disk === undefined) return
|
|
214
208
|
project.bible = disk.bible
|
|
215
209
|
project.roles = disk.roles
|
|
216
|
-
project.mangaRoles = disk.mangaRoles
|
|
217
210
|
project.plotlines = disk.plotlines
|
|
218
211
|
project.roleStatus = disk.roleStatus
|
|
219
212
|
project.blurb = disk.blurb
|
|
@@ -356,59 +349,158 @@ export function createProject(outline: string, outlinePath?: string): ProjectSta
|
|
|
356
349
|
|
|
357
350
|
// ------------------------------------------------------------------- llm
|
|
358
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
|
+
|
|
359
405
|
/** One complete non-streaming LLM call. */
|
|
360
406
|
async function complete(
|
|
361
407
|
ctx: Context,
|
|
362
408
|
config: NovelConfig,
|
|
363
|
-
options:
|
|
409
|
+
options: CompleteOptions,
|
|
364
410
|
): Promise<string> {
|
|
411
|
+
const liveLabel = options.liveLabel ?? 'LLM 调用'
|
|
365
412
|
const messages: Message[] = [createUserMessage({
|
|
366
413
|
content: [{ type: 'text', text: options.user }],
|
|
367
414
|
source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
|
|
368
415
|
})]
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
model:
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
|
388
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 {
|
|
389
479
|
const blocks = assembler.blocks()
|
|
390
|
-
// Diagnostics: log the assembled block shape (reasoning-only turns yield no
|
|
391
|
-
// text blocks — the v4-flash model can answer entirely in the reasoning
|
|
392
|
-
// channel, which the adapter surfaces as a reasoning block).
|
|
393
|
-
if (process.env.DSH_NOVEL_DEBUG === '1') {
|
|
394
|
-
console.error('[dsh-novel-forge] complete: finish=%j blocks=%j', JSON.stringify(finish), blocks.map(b => `${b.type}:${'text' in b ? b.text.length : '?'}`))
|
|
395
|
-
}
|
|
396
480
|
const textBlocks = blocks
|
|
397
481
|
.filter((block): block is Extract<StreamChunk, { type: 'block-end' }>['block'] & { type: 'text' } => block.type === 'text')
|
|
398
482
|
.map(block => block.text)
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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 : []
|
|
412
504
|
}
|
|
413
505
|
|
|
414
506
|
/**
|
|
@@ -651,13 +743,21 @@ function volumeSystemPrompt(): string {
|
|
|
651
743
|
'[{"no": 1, "title": "卷名", "summary": "卷定位与剧情重心", "chapterStart": 1, "chapterEnd": 80}]',
|
|
652
744
|
'重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
653
745
|
'重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。',
|
|
746
|
+
// 长度合同与 resolve 端的截断口径共用一份声明,避免 prompt 与解析漂移。
|
|
747
|
+
renderLengthContract([
|
|
748
|
+
['卷名', STRUCTURED_FIELD_LIMITS.chapterTitle],
|
|
749
|
+
['每卷定位', STRUCTURED_FIELD_LIMITS.chapterSummary],
|
|
750
|
+
]),
|
|
654
751
|
].join('\n')
|
|
655
752
|
}
|
|
656
753
|
|
|
657
754
|
/** Plan volumes from an outline. */
|
|
658
755
|
export async function planVolumes(ctx: Context, config: NovelConfig, outline: string): Promise<Volume[]> {
|
|
659
756
|
const user = `请为下面这部小说划分卷:\n\n${outline}`
|
|
660
|
-
|
|
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 }) })
|
|
661
761
|
const parsed = parseJsonArray<Record<string, unknown>>(text)
|
|
662
762
|
const volumes: Volume[] = []
|
|
663
763
|
for (let i = 0; i < parsed.length; i++) {
|
|
@@ -670,8 +770,8 @@ export async function planVolumes(ctx: Context, config: NovelConfig, outline: st
|
|
|
670
770
|
const end = typeof entry.chapterEnd === 'number' ? entry.chapterEnd : undefined
|
|
671
771
|
volumes.push({
|
|
672
772
|
no,
|
|
673
|
-
title: title.slice(0,
|
|
674
|
-
summary: summary.slice(0,
|
|
773
|
+
title: title.slice(0, STRUCTURED_FIELD_LIMITS.chapterTitle),
|
|
774
|
+
summary: summary.slice(0, STRUCTURED_FIELD_LIMITS.chapterSummary),
|
|
675
775
|
chapterStart: start ?? 1,
|
|
676
776
|
chapterEnd: end ?? 1,
|
|
677
777
|
})
|
|
@@ -695,7 +795,9 @@ function volumeOf(chapterNo: number, volumes: Volume[] | undefined): number {
|
|
|
695
795
|
function planSystemPrompt(volumes: Volume[] | undefined): string {
|
|
696
796
|
const volumeBlock = volumes !== undefined && volumes.length > 0
|
|
697
797
|
? ['\n全书分卷结构(规划章节时需落在对应卷内):']
|
|
698
|
-
.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}` : '')))
|
|
699
801
|
.join('\n')
|
|
700
802
|
: ''
|
|
701
803
|
return [
|
|
@@ -707,6 +809,7 @@ function planSystemPrompt(volumes: Volume[] | undefined): string {
|
|
|
707
809
|
'3. 严格遵循大纲的人设、金手指规则、战力体系与世界观设定,不得自行发明冲突设定。',
|
|
708
810
|
'4. 输出必须是合法的 JSON 数组,不要输出任何其他文字或 Markdown 代码块标记。',
|
|
709
811
|
'5. 数组每个元素格式:{"title": "章节标题(10字以内,有网文感)", "beats": "结构化剧情要点(150-250字,必须包含四段,段间用换行分隔):\\n本章目标:本章要完成的核心推进;\\n剧情要点:主要情节的起承转合(2-4 句);\\n爽点/钩子:本章的爽点兑现或情绪钩子;\\n结尾钩子:本章结尾为下一章埋下的悬念"}',
|
|
812
|
+
'6. 每个章节对象可额外包含以下可选字段(尽量给出,缺失则跳过):tension(0-100 整数:本章张力目标,对抗/揭示/抉择/代价的强度;铺垫与日常章明显偏低,与相邻章节形成起伏)、mustAdvance(数组,本章必须推进的局面/关系/信息/风险/决策变化);mustPreserve(数组,本章必须保持不破坏的项);characterHardFacts(数组,本章不可违背的人物硬事实:身份/阵营/境界/当前位置/知情度);endingHook(字符串,本章结尾钩子要求);obligation(字符串,本章义务合约一句话)。',
|
|
710
813
|
'重要:beats 字段内部必须使用 \\n 转义表示换行(JSON 字符串内不得有真实换行符),其余字符串值也不得包含真实换行符,JSON 必须在一段内完整结束。',
|
|
711
814
|
'重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。',
|
|
712
815
|
volumeBlock,
|
|
@@ -717,7 +820,7 @@ function planSystemPrompt(volumes: Volume[] | undefined): string {
|
|
|
717
820
|
* `targetChars` 来自每章计划(规划时快照,= 设置的每章目标字数);无则退回默认 3500。
|
|
718
821
|
* 字数区间按目标动态生成(±15%,取整到百位),避免系统提示词与设置互相冲突。
|
|
719
822
|
* `lengthRule` 可覆盖第 1 条字数要求(整章修订/改编时按原文长度为准)。 */
|
|
720
|
-
function writeSystemPrompt(project: ProjectState, targetChars?: number, lengthRule?: string): string {
|
|
823
|
+
function writeSystemPrompt(project: ProjectState, targetChars?: number, lengthRule?: string, beforeChapter?: number): string {
|
|
721
824
|
const bible = project.bible
|
|
722
825
|
const sections: string[] = []
|
|
723
826
|
if (bible !== undefined) {
|
|
@@ -800,6 +903,26 @@ function writeSystemPrompt(project: ProjectState, targetChars?: number, lengthRu
|
|
|
800
903
|
sections.push('==================== 内容合规红线(平台硬性要求,最高优先级,违反即失败) ====================')
|
|
801
904
|
sections.push(COMPLIANCE_REDLINES.join('\n'))
|
|
802
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)
|
|
803
926
|
return sections.join('\n')
|
|
804
927
|
}
|
|
805
928
|
|
|
@@ -819,8 +942,14 @@ export async function planChapters(
|
|
|
819
942
|
const startNo = existing.length === 0 ? 1 : Math.max(...existing.map(c => c.no)) + 1
|
|
820
943
|
const NL = String.fromCharCode(10)
|
|
821
944
|
const continuation = existing.length > 0
|
|
822
|
-
|
|
823
|
-
|
|
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')
|
|
824
953
|
: ''
|
|
825
954
|
// 上一章(已写章节中章号最大者)结尾原文,作为续写剧情起点。
|
|
826
955
|
let prevTail = ''
|
|
@@ -861,6 +990,19 @@ export async function planChapters(
|
|
|
861
990
|
: continuation
|
|
862
991
|
? `本书已有 ${existing.length} 章已规划/已写作(见下方「已有章节」)。请规划**后续**章节:从第 ${startNo} 章开始。`
|
|
863
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
|
+
})(),
|
|
864
1006
|
continuation
|
|
865
1007
|
? (() => {
|
|
866
1008
|
// 从最近章节摘要动态生成「已发生事件禁令」,不绑定任何特定书
|
|
@@ -868,10 +1010,33 @@ export async function planChapters(
|
|
|
868
1010
|
const eventLines = recentChapters
|
|
869
1011
|
.filter(c => c.summary !== undefined && c.summary.trim() !== '')
|
|
870
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
|
+
: ''
|
|
871
1017
|
const eventsText = eventLines.length > 0
|
|
872
|
-
? '以下情节已在已有章节中发生过(最近 ' + eventLines.length + '
|
|
1018
|
+
? '以下情节已在已有章节中发生过(最近 ' + eventLines.length + ' 章摘要)' + scopeNote + ',后续章节**绝对不得重写或重复**:\n' + eventLines.join('\n')
|
|
873
1019
|
: '已有章节的剧情不得重写或重复(无章节摘要时以编年录为准)。'
|
|
874
|
-
|
|
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
|
|
875
1040
|
})()
|
|
876
1041
|
: '',
|
|
877
1042
|
prevTail !== ''
|
|
@@ -894,11 +1059,16 @@ export async function planChapters(
|
|
|
894
1059
|
const system = planSystemPrompt(project.volumes) + (continuation
|
|
895
1060
|
? '\n重要:本次是**续写规划**——已有章节的剧情不得重写或重复,新章节标题不得与已有章节标题相同,新章节的剧情必须从上一章结尾自然接续(人物状态、时间线、地点衔接一致)。'
|
|
896
1061
|
: '')
|
|
897
|
-
const
|
|
898
|
-
|
|
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>[]
|
|
899
1067
|
const chapters: ChapterPlan[] = []
|
|
900
1068
|
const existingNos = new Set(existing.map(c => c.no))
|
|
901
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() : ''
|
|
902
1072
|
let cursor = startNo
|
|
903
1073
|
for (const item of parsed) {
|
|
904
1074
|
if (chapters.length >= chapterCount) break
|
|
@@ -911,6 +1081,19 @@ export async function planChapters(
|
|
|
911
1081
|
if (title !== '' && existingTitles.has(title)) continue
|
|
912
1082
|
while (existingNos.has(cursor)) cursor++
|
|
913
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
|
|
914
1097
|
chapters.push({
|
|
915
1098
|
no,
|
|
916
1099
|
volume: volumeOf(no, project.volumes),
|
|
@@ -918,6 +1101,15 @@ export async function planChapters(
|
|
|
918
1101
|
beats,
|
|
919
1102
|
targetChars: config.chapterChars,
|
|
920
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,
|
|
921
1113
|
})
|
|
922
1114
|
}
|
|
923
1115
|
if (chapters.length === 0) {
|
|
@@ -926,6 +1118,29 @@ export async function planChapters(
|
|
|
926
1118
|
return chapters
|
|
927
1119
|
}
|
|
928
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
|
+
|
|
929
1144
|
// ------------------------------------------------------------------ writing
|
|
930
1145
|
|
|
931
1146
|
/** The review system prompt. */
|
|
@@ -944,8 +1159,10 @@ function reviewSystemPrompt(project: ProjectState): string {
|
|
|
944
1159
|
'8. 呈现方式:整章是否纯内心推理铺陈(无对话/无对抗,推理全靠解说);反派是否纯背景板无行动;重要配角是否无名标签化(瘦高个/灰衣人全程代称)——命中即列为问题。',
|
|
945
1160
|
'9. 内容合规(最高优先级):逐条核对下方「内容合规红线」,任何一条命中(含影射、暗示、详细描写)必须列为 high,并给出改写建议。',
|
|
946
1161
|
'输出必须是合法 JSON 对象,不要输出任何其他文字:',
|
|
947
|
-
'{"score": 0-100的整数, "verdict": "一句话总评", "issues": [{"severity": "high|medium|low", "dimension": "character|setting|redline|writing|pacing|logic|anti-ai|presentation|compliance", "item": "问题描述", "suggestion": "修改建议"}]}',
|
|
948
|
-
'
|
|
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 套话高频模板词示例(集中出现必须整体降密度):仿佛、似乎、极其、完美、深不见底、形成了、莫名、无法形容、难以言喻、精心雕琢、肤光胜雪、眉目如画、歌舞升平、觥筹交错、妙语连珠、不可名状、另一层真相、命运、真相。',
|
|
949
1166
|
'重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
950
1167
|
'重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。',
|
|
951
1168
|
]
|
|
@@ -980,9 +1197,9 @@ export async function reviewChapter(
|
|
|
980
1197
|
chapterNo: number,
|
|
981
1198
|
): Promise<ReviewReport> {
|
|
982
1199
|
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
983
|
-
if (chapter === undefined) throw new
|
|
1200
|
+
if (chapter === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 不在计划中`)
|
|
984
1201
|
const body = readChapterFile(outputDir, chapter)
|
|
985
|
-
if (body === undefined) throw new
|
|
1202
|
+
if (body === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 的正文文件不存在`)
|
|
986
1203
|
const bodyText = body.replace(/^#\s+.*$/m, '').trim()
|
|
987
1204
|
// 本地 AI 味扫描(事实锚点,让 LLM 复核判断而非逐字统计)
|
|
988
1205
|
const aiScan = scanAiFlavor(bodyText)
|
|
@@ -998,8 +1215,8 @@ export async function reviewChapter(
|
|
|
998
1215
|
'==================== 章节正文 ====================',
|
|
999
1216
|
bodyText,
|
|
1000
1217
|
].filter(line => line !== '').join('\n')
|
|
1001
|
-
const text = await complete(ctx, config, { system: reviewSystemPrompt(project), user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 8000) })
|
|
1002
|
-
const raw = parseJsonObject<{ score?: unknown; verdict?: unknown; issues?: unknown; resolvedIds?: unknown; unresolvedIds?: unknown }>(text)
|
|
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)
|
|
1003
1220
|
const issues = Array.isArray(raw.issues)
|
|
1004
1221
|
? raw.issues
|
|
1005
1222
|
.filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
|
|
@@ -1012,10 +1229,19 @@ export async function reviewChapter(
|
|
|
1012
1229
|
: undefined,
|
|
1013
1230
|
item: typeof entry.item === 'string' ? entry.item : '',
|
|
1014
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,
|
|
1015
1240
|
}))
|
|
1016
1241
|
.filter(issue => issue.item !== '')
|
|
1017
1242
|
: []
|
|
1018
1243
|
const score = typeof raw.score === 'number' ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60
|
|
1244
|
+
const riskScore = typeof raw.riskScore === 'number' ? Math.max(0, Math.min(100, Math.round(raw.riskScore))) : undefined
|
|
1019
1245
|
// 通过条件:有 high 必须 ≥ reviewPassScore;无 high 可降 5 分但最低 65(避免低分稿直接放行)
|
|
1020
1246
|
const hasHigh = issues.some(i => i.severity === 'high')
|
|
1021
1247
|
const softThreshold = Math.max(65, config.reviewPassScore - 5)
|
|
@@ -1023,8 +1249,14 @@ export async function reviewChapter(
|
|
|
1023
1249
|
const report: ReviewReport = {
|
|
1024
1250
|
score,
|
|
1025
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,
|
|
1026
1255
|
verdict: typeof raw.verdict === 'string' ? raw.verdict.slice(0, 200) : '',
|
|
1027
1256
|
issues,
|
|
1257
|
+
riskScore,
|
|
1258
|
+
aiFlavor: aiScan.aiScore,
|
|
1259
|
+
aiPhrases: aiScan.clicheHits.slice(0, 8),
|
|
1028
1260
|
reviewedAt: new Date().toISOString(),
|
|
1029
1261
|
}
|
|
1030
1262
|
chapter.review = report
|
|
@@ -1061,7 +1293,7 @@ export async function reviewChapterText(
|
|
|
1061
1293
|
].join('\n')
|
|
1062
1294
|
// 验证模式:携带上一轮报告时,逐条核对原意见是否解决 + 只挑新增 high,不再全新找茬。
|
|
1063
1295
|
const system = previousReport !== undefined ? verifySystemPrompt(project) : reviewSystemPrompt(project)
|
|
1064
|
-
const raw = parseJsonObject<{ score?: unknown; verdict?: unknown; issues?: unknown; resolvedIds?: unknown; unresolvedIds?: unknown }>(
|
|
1296
|
+
const raw = parseJsonObject<{ score?: unknown; riskScore?: unknown; verdict?: unknown; issues?: unknown; resolvedIds?: unknown; unresolvedIds?: unknown }>(
|
|
1065
1297
|
await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 8000) }),
|
|
1066
1298
|
)
|
|
1067
1299
|
const issues = Array.isArray(raw.issues)
|
|
@@ -1076,10 +1308,19 @@ export async function reviewChapterText(
|
|
|
1076
1308
|
: undefined,
|
|
1077
1309
|
item: typeof entry.item === 'string' ? entry.item : '',
|
|
1078
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,
|
|
1079
1319
|
}))
|
|
1080
1320
|
.filter(issue => issue.item !== '')
|
|
1081
1321
|
: []
|
|
1082
1322
|
const score = typeof raw.score === 'number' ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60
|
|
1323
|
+
const riskScore = typeof raw.riskScore === 'number' ? Math.max(0, Math.min(100, Math.round(raw.riskScore))) : undefined
|
|
1083
1324
|
// 非验证模式:有 high 必须 ≥ reviewPassScore;无 high 可降 5 分但最低 65
|
|
1084
1325
|
const hasHighAny = issues.some(i => i.severity === 'high')
|
|
1085
1326
|
let passed = hasHighAny ? score >= config.reviewPassScore : score >= Math.max(65, config.reviewPassScore - 5)
|
|
@@ -1102,6 +1343,9 @@ export async function reviewChapterText(
|
|
|
1102
1343
|
passed,
|
|
1103
1344
|
verdict: typeof raw.verdict === 'string' ? raw.verdict.slice(0, 200) : '',
|
|
1104
1345
|
issues,
|
|
1346
|
+
riskScore,
|
|
1347
|
+
aiFlavor: aiScan.aiScore,
|
|
1348
|
+
aiPhrases: aiScan.clicheHits.slice(0, 8),
|
|
1105
1349
|
reviewedAt: new Date().toISOString(),
|
|
1106
1350
|
}
|
|
1107
1351
|
}
|
|
@@ -1140,8 +1384,12 @@ function authorReviewSystemPrompt(): string {
|
|
|
1140
1384
|
'5. advancedLines:本章实际推进的剧情线名称数组——从「活跃剧情线」清单中选出推进了的线(名称必须与清单中的线名一字不差;没推进任何线则输出空数组)。',
|
|
1141
1385
|
'6. continuity:与上一章结尾的衔接检查(人物位置/时间/伤势/资源/对话状态),发现问题要指出。',
|
|
1142
1386
|
'7. trend:结合上一章复盘看近期节奏趋势(是否连续拖沓、爽点密度是否下降、是否需要调整)。',
|
|
1387
|
+
'8. stateChanges:本章发生的关键状态变化数组(人物状态/世界局面/关系/资源,每条约 20 字,最多 6 条),供整本与事实库回灌。',
|
|
1388
|
+
'9. newConflicts:本章新引入或明显升级的冲突数组(最多 4 条)。',
|
|
1389
|
+
'10. clues:本章埋下或推进的新线索/伏笔数组(最多 4 条)。',
|
|
1390
|
+
'11. absentRisks:本章缺席但按卷级职责应出场/该推进关系的重要角色,及其缺席带来的风险(最多 4 条)。',
|
|
1143
1391
|
'输出必须是合法 JSON 对象,不要输出任何其他文字:',
|
|
1144
|
-
'{"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条)"]}',
|
|
1145
1393
|
'重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
1146
1394
|
'重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。',
|
|
1147
1395
|
].join('\n')
|
|
@@ -1188,6 +1436,10 @@ export async function authorReviewChapter(
|
|
|
1188
1436
|
advancedLines?: unknown
|
|
1189
1437
|
continuity?: unknown
|
|
1190
1438
|
trend?: unknown
|
|
1439
|
+
stateChanges?: unknown
|
|
1440
|
+
newConflicts?: unknown
|
|
1441
|
+
clues?: unknown
|
|
1442
|
+
absentRisks?: unknown
|
|
1191
1443
|
}>(
|
|
1192
1444
|
await complete(ctx, config, { system: authorReviewSystemPrompt(), user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 4000) }),
|
|
1193
1445
|
)
|
|
@@ -1196,6 +1448,7 @@ export async function authorReviewChapter(
|
|
|
1196
1448
|
const advancedLines = Array.isArray(raw.advancedLines)
|
|
1197
1449
|
? raw.advancedLines.filter((n): n is string => typeof n === 'string' && n.trim() !== '' && knownLineNames.has(n.trim())).map(n => n.trim())
|
|
1198
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)) : []
|
|
1199
1452
|
return {
|
|
1200
1453
|
hookHonored: raw.hookHonored === true,
|
|
1201
1454
|
hookNote: typeof raw.hookNote === 'string' ? raw.hookNote.slice(0, 200) : '',
|
|
@@ -1204,6 +1457,10 @@ export async function authorReviewChapter(
|
|
|
1204
1457
|
advancedLines,
|
|
1205
1458
|
continuity: typeof raw.continuity === 'string' ? raw.continuity.slice(0, 200) : '',
|
|
1206
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),
|
|
1207
1464
|
reviewedAt: new Date().toISOString(),
|
|
1208
1465
|
}
|
|
1209
1466
|
}
|
|
@@ -1218,7 +1475,7 @@ export function autoLinkPlotlines(project: ProjectState, chapterNo: number, adva
|
|
|
1218
1475
|
}
|
|
1219
1476
|
}
|
|
1220
1477
|
|
|
1221
|
-
/**
|
|
1478
|
+
/** 建议剧情线:基于大纲/卷计划/已写章节/编年录,提炼候选线。 */
|
|
1222
1479
|
export async function suggestPlotlines(ctx: Context, config: NovelConfig, project: ProjectState): Promise<Plotline[]> {
|
|
1223
1480
|
const system = [
|
|
1224
1481
|
'你是一位网文剧情架构师。根据本书的大纲、卷计划、已写章节标题与编年录,为作者提炼建议的剧情线(主线/支线/人物线/悬念线)。',
|
|
@@ -1265,7 +1522,7 @@ export async function suggestPlotlines(ctx: Context, config: NovelConfig, projec
|
|
|
1265
1522
|
return lines
|
|
1266
1523
|
}
|
|
1267
1524
|
|
|
1268
|
-
/**
|
|
1525
|
+
/** 刷新单条剧情线的进度:结合编年录与各章摘要分析该线推进到哪。 */
|
|
1269
1526
|
export async function refreshPlotlineProgress(
|
|
1270
1527
|
ctx: Context,
|
|
1271
1528
|
config: NovelConfig,
|
|
@@ -1296,7 +1553,7 @@ export async function refreshPlotlineProgress(
|
|
|
1296
1553
|
return typeof raw.progress === 'string' ? raw.progress.trim().slice(0, 300) : ''
|
|
1297
1554
|
}
|
|
1298
1555
|
|
|
1299
|
-
/**
|
|
1556
|
+
/** AI 从全书提炼角色库:大纲 + 道藏 + 编年录 + 章节摘要 → 结构化角色清单。 */
|
|
1300
1557
|
export async function extractRoles(
|
|
1301
1558
|
ctx: Context,
|
|
1302
1559
|
config: NovelConfig,
|
|
@@ -1384,7 +1641,8 @@ export async function extractRoles(
|
|
|
1384
1641
|
: '',
|
|
1385
1642
|
'只输出 JSON 数组。',
|
|
1386
1643
|
].join('\n\n')
|
|
1387
|
-
|
|
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' })
|
|
1388
1646
|
let raw = parseJsonArray<Record<string, unknown>>(text)
|
|
1389
1647
|
const hasProtagonist = raw.some(e => typeof e === 'object' && e !== null && e.roleLabel === 'protagonist')
|
|
1390
1648
|
const tooFew = raw.length > 0 && raw.length < 6
|
|
@@ -1395,7 +1653,7 @@ export async function extractRoles(
|
|
|
1395
1653
|
: tooFew
|
|
1396
1654
|
? '\n上一次输出角色过少(不足 6 个)。这是一部完整故事,请重新核对正文摘录:主角、主要反派、重要配角与所有有名有姓的角色都要收录(宁多勿漏),输出 8-20 个。'
|
|
1397
1655
|
: '\n上一次输出中缺少主角(roleLabel 为 protagonist 的角色)。请重新输出完整 JSON 数组,务必包含正文中的主角。'
|
|
1398
|
-
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' })
|
|
1399
1657
|
raw = parseJsonArray<Record<string, unknown>>(text)
|
|
1400
1658
|
}
|
|
1401
1659
|
const labels = new Set(['protagonist', 'female_lead', 'female_support', 'support', 'antagonist', 'extra'])
|
|
@@ -1465,135 +1723,7 @@ export async function extractRoles(
|
|
|
1465
1723
|
return roles
|
|
1466
1724
|
}
|
|
1467
1725
|
|
|
1468
|
-
/**
|
|
1469
|
-
export async function extractScenes(
|
|
1470
|
-
ctx: Context,
|
|
1471
|
-
config: NovelConfig,
|
|
1472
|
-
project: ProjectState,
|
|
1473
|
-
chapterNo?: number,
|
|
1474
|
-
styleId?: string,
|
|
1475
|
-
filterId?: string,
|
|
1476
|
-
): Promise<SceneCard[]> {
|
|
1477
|
-
const styleWords = styleKeywords(styleId, filterId)
|
|
1478
|
-
const chapterLabel = chapterNo !== undefined ? '第' + chapterNo + '章' : '全书'
|
|
1479
|
-
const system = [
|
|
1480
|
-
'你是一位网文漫剧场景导演。根据指定章节的正文,提炼「镜头场景」——漫剧分镜/生图时每个镜头要知道"在哪、什么时间光态、拍什么情节、人物什么状态"。',
|
|
1481
|
-
'当前提取范围:' + chapterLabel + '。只提炼本章实际出现的场景,不要提前提取后续章节的场景。',
|
|
1482
|
-
'【当前视觉风格】(moment/palette/moods/zh/en 必须按此风格措辞,不能写中性描述):' + styleWords,
|
|
1483
|
-
'重要:每个场景的 zh 提示词段首必须原样嵌入【当前视觉风格】词块原文;en 提示词末尾追加风格标签。不得省略或改写。',
|
|
1484
|
-
'每个场景必须是「镜头场景」而非仅场地:给出该场景的关键情节镜头(人物动作+情绪+镜头推进,只写进 beats,禁止写进 zh);场景生图提示词 zh/en 必须是无人空镜。',
|
|
1485
|
-
'数量控制:本章输出 3-5 个场景;每个场景必须能对应到本章正文实际出现过的地点与情节,不得凭空虚构。',
|
|
1486
|
-
'每个场景输出:',
|
|
1487
|
-
'1. name:场景名(地点+光态,如「后场通道·装卸口(雨夜)」)。',
|
|
1488
|
-
'2. act:场景在本章的段落位置(开篇/发展/高潮/结局)。',
|
|
1489
|
-
'3. moment:时间光态(夜间闭店后/雨夜/凌晨/频闪灯…)。',
|
|
1490
|
-
'4. summary:一句话定位(空间类型/功能/氛围)。',
|
|
1491
|
-
'5. beats:关键情节镜头 1-3 条(人物动作+情绪+镜头推进,如"沈放佝偻站在货架过道,镜头推近,情绪从漠然过渡到压抑悲伤")。',
|
|
1492
|
-
'6. characterState:主角/关键角色在该场景的状态一句话(含标志物细节如标签磨损、手腕红痕)。',
|
|
1493
|
-
'7. elements:环境构成数组 3-6 项(空间结构/陈设/标志物)。',
|
|
1494
|
-
'8. palette:色调与光影数组 2-4 项(颜色词,可附 HEX)。',
|
|
1495
|
-
'9. moods:氛围关键词 2-4 个(压抑/神秘/空旷/悲凉…)。',
|
|
1496
|
-
'10. zh:中文生图提示词(连贯一段,写实电影感),必须为【无人物空镜】——只写空间结构/材质/光线/氛围/标志物,严禁任何人物、动作、情节、台词、镜头调度(人物与情节由角色卡和分镜负责,场景底图必须是无人空镜)。',
|
|
1497
|
-
'11. en:英文生图提示词(booru 风格逗号分隔,含 photorealistic/cinematic)。',
|
|
1498
|
-
'12. tags:3-6 个关键标签。',
|
|
1499
|
-
'13. source:依据来源说明(哪几章哪些描写)。',
|
|
1500
|
-
|
|
1501
|
-
'14. tier:场景分级——core(核心场景,反复出现≥3次或多章引用,需精修多图)/ secondary(次要场景,出现1-2次,一张全景图够)/ passing(路过场景,只被提到名字,不做图)。按出场重要性自动判断。',
|
|
1502
|
-
'15. negativePrompt:负面提示词(场景专用,必须包含 no people, no characters, no text, no watermark, no logo,可补充场景相关负面词)。', '若本章存在关键转折或抉择,最后 1 个场景应为「转折场景」,beats 里写明关键画面(禁止写进 zh)。',
|
|
1503
|
-
'输出必须是合法 JSON 数组,不要输出其他文字:[{"name":"...", "act":"...", "moment":"...", "summary":"...", "beats":[...], "characterState":"...", "elements":[...], "palette":[...], "moods":[...], "zh":"...", "en":"...", "tags":[...], "source":"...", "tier":"...", "negativePrompt":"..."}]',
|
|
1504
|
-
'重要:所有字符串值内部不得包含换行符;直接输出 JSON 结果本身。',
|
|
1505
|
-
].join('\n')
|
|
1506
|
-
const targetChapters = chapterNo !== undefined
|
|
1507
|
-
? project.chapters.filter(c => c.no === chapterNo && c.status !== 'pending' && c.status !== 'generating')
|
|
1508
|
-
: project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating').slice(0, 4)
|
|
1509
|
-
const excerptParts: string[] = []
|
|
1510
|
-
for (const chapter of targetChapters) {
|
|
1511
|
-
const body = readChapterFile(config.outputDir, chapter)
|
|
1512
|
-
if (body === undefined) continue
|
|
1513
|
-
const text = body.replace(/^#.*$/gm, '').trim()
|
|
1514
|
-
if (text.length > 0) excerptParts.push('第' + chapter.no + '章《' + chapter.title + '》' + '\n' + text.slice(0, 3000))
|
|
1515
|
-
}
|
|
1516
|
-
const user = [
|
|
1517
|
-
'书名:《' + project.bookName + '》',
|
|
1518
|
-
(project.facts ?? []).length > 0
|
|
1519
|
-
? '编年录(最近 80 条,场景地点以这里为准):' + '\n' + (project.facts ?? []).slice(-80).map(f => '[第' + f.chapterNo + '章] ' + f.text.slice(0, 80)).join('\n')
|
|
1520
|
-
: '',
|
|
1521
|
-
excerptParts.length > 0
|
|
1522
|
-
? '已写正文摘录(场景描写以正文为准):' + '\n' + excerptParts.join('\n')
|
|
1523
|
-
: '',
|
|
1524
|
-
'只输出 JSON 数组。',
|
|
1525
|
-
].filter(s => s !== '').join('\n')
|
|
1526
|
-
const text = await complete(ctx, config, { system, user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 32000), reasoning: config.analysisReasoning ?? 'low' })
|
|
1527
|
-
const raw = parseJsonArray<Record<string, unknown>>(text)
|
|
1528
|
-
const strArr = (v: unknown): string[] => Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '') : []
|
|
1529
|
-
const str = (v: unknown): string => typeof v === 'string' ? v.trim() : ''
|
|
1530
|
-
const scenes: SceneCard[] = []
|
|
1531
|
-
for (const entry of raw) {
|
|
1532
|
-
if (typeof entry !== 'object' || entry === null) continue
|
|
1533
|
-
const name = str(entry.name).slice(0, 40)
|
|
1534
|
-
if (name === '') continue
|
|
1535
|
-
scenes.push({
|
|
1536
|
-
name,
|
|
1537
|
-
act: str(entry.act).slice(0, 40),
|
|
1538
|
-
moment: str(entry.moment).slice(0, 60),
|
|
1539
|
-
summary: str(entry.summary).slice(0, 120),
|
|
1540
|
-
beats: strArr(entry.beats).map(t => t.slice(0, 120)).slice(0, 3),
|
|
1541
|
-
characterState: str(entry.characterState).slice(0, 160),
|
|
1542
|
-
elements: strArr(entry.elements).map(t => t.slice(0, 60)).slice(0, 6),
|
|
1543
|
-
palette: strArr(entry.palette).map(t => t.slice(0, 40)).slice(0, 4),
|
|
1544
|
-
moods: strArr(entry.moods).map(t => t.slice(0, 20)).slice(0, 4),
|
|
1545
|
-
zh: ensureStyleEmbedded(str(entry.zh).slice(0, 800), styleWords, 'zh'),
|
|
1546
|
-
en: ensureStyleEmbedded(str(entry.en).slice(0, 600), styleWords, 'en'),
|
|
1547
|
-
tags: strArr(entry.tags).map(t => t.slice(0, 24)).slice(0, 6),
|
|
1548
|
-
source: str(entry.source).slice(0, 120),
|
|
1549
|
-
|
|
1550
|
-
tier: (['core', 'secondary', 'passing'] as const).includes(str(entry.tier) as any) ? (str(entry.tier) as 'core' | 'secondary' | 'passing') : 'secondary',
|
|
1551
|
-
negativePrompt: str(entry.negativePrompt) !== '' ? str(entry.negativePrompt).slice(0, 300) : 'no people, no characters, no text, no watermark, no logo, no subtitles, blurry, low quality, distorted, deformed', styleId,
|
|
1552
|
-
})
|
|
1553
|
-
}
|
|
1554
|
-
// 本地统计出场章节:自动补 tier 和 appearsInChapters(不依赖 LLM 判断,更准确)
|
|
1555
|
-
const sceneShort = (n: string): string => n.split('·')[0].split('(')[0].split('(')[0].trim()
|
|
1556
|
-
const writtenChapters = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating')
|
|
1557
|
-
for (const sc of scenes) {
|
|
1558
|
-
const appears: number[] = []
|
|
1559
|
-
const short = sceneShort(sc.name)
|
|
1560
|
-
for (const ch of writtenChapters) {
|
|
1561
|
-
const body = readChapterFile(config.outputDir, ch)
|
|
1562
|
-
if (body === undefined) continue
|
|
1563
|
-
if (body.includes(sc.name) || (short.length >= 2 && body.includes(short))) {
|
|
1564
|
-
if (!appears.includes(ch.no)) appears.push(ch.no)
|
|
1565
|
-
}
|
|
1566
|
-
}
|
|
1567
|
-
appears.sort((a, b) => a - b)
|
|
1568
|
-
sc.appearsInChapters = appears.length > 0 ? appears : sc.appearsInChapters
|
|
1569
|
-
// tier 兜底:LLM 没给或给了 passing 但实际有出场时,按出场数重算
|
|
1570
|
-
if (sc.tier === undefined || (sc.tier === 'passing' && appears.length > 0)) {
|
|
1571
|
-
sc.tier = appears.length >= 3 ? 'core' : appears.length >= 1 ? 'secondary' : 'passing'
|
|
1572
|
-
}
|
|
1573
|
-
// negativePrompt 兜底
|
|
1574
|
-
if (sc.negativePrompt === undefined || sc.negativePrompt === '') {
|
|
1575
|
-
sc.negativePrompt = 'no people, no characters, no text, no watermark, no logo, no subtitles, blurry, low quality, distorted, deformed'
|
|
1576
|
-
}
|
|
1577
|
-
}
|
|
1578
|
-
// 去重:与已有场景短名重复的 candidate 不返回(避免多次提取后累积重复)
|
|
1579
|
-
const existingShorts = new Set((project.scenes ?? []).map(s => sceneShort(s.name)))
|
|
1580
|
-
let deduped = scenes.filter(sc => !existingShorts.has(sceneShort(sc.name)))
|
|
1581
|
-
// 数量控制:core≤5, secondary≤5, passing≤2,总计≤10
|
|
1582
|
-
const coreScenes = deduped.filter(s => s.tier === 'core').slice(0, 5)
|
|
1583
|
-
const secondaryScenes = deduped.filter(s => s.tier === 'secondary').slice(0, 5)
|
|
1584
|
-
const passingScenes = deduped.filter(s => s.tier === 'passing').slice(0, 2)
|
|
1585
|
-
deduped = [...coreScenes, ...secondaryScenes, ...passingScenes]
|
|
1586
|
-
// 落盘:把每个场景的中文生图提示词写入资产库 manga-assets/场景/场景名/提示词.txt
|
|
1587
|
-
for (const sc of deduped) {
|
|
1588
|
-
try { saveMangaScenePrompt(config.outputDir, sc.name, sc.zh, sc.negativePrompt) } catch { /* 落盘失败不阻塞 */ }
|
|
1589
|
-
}
|
|
1590
|
-
return deduped
|
|
1591
|
-
}
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
/** 从 txt/md 全本文本拆章(纯逻辑,不落盘):识别章节头、剥离重复标题、去重、排序并统一重新编号。 */
|
|
1726
|
+
/** AI 从全书提炼场景库:正文/编年录 → 高频重要场景的结构化视觉锚点。 */
|
|
1597
1727
|
export function splitBookText(raw: string): Array<{ no: number; title: string; body: string }> {
|
|
1598
1728
|
const lines = raw.split(/\r?\n/)
|
|
1599
1729
|
// 拆章:中文「第X章/回/节/卷」、中文数字章节(一、二、三…)、特殊章节(序章/楔子/尾声/番外…)、英文 Chapter N(均可带 # 前缀)。
|
|
@@ -1705,7 +1835,7 @@ export function importBookTextFromText(
|
|
|
1705
1835
|
}
|
|
1706
1836
|
chapter.file = chapterFileName(chapter)
|
|
1707
1837
|
writeFileSync(join(outputDir, chapter.file), '# 第' + c.no + '章 ' + chapter.title + '\n\n' + body + '\n', 'utf8')
|
|
1708
|
-
chapter.chars = body
|
|
1838
|
+
chapter.chars = countHanzi(body)
|
|
1709
1839
|
project.chapters.push(chapter)
|
|
1710
1840
|
}
|
|
1711
1841
|
project.updatedAt = new Date().toISOString()
|
|
@@ -1723,617 +1853,7 @@ export function importBookText(
|
|
|
1723
1853
|
return importBookTextFromText(raw, outputDir, bookName)
|
|
1724
1854
|
}
|
|
1725
1855
|
|
|
1726
|
-
/** 常见职业/身份尾缀:角色名如「周野律师」正文可能只写「周野」或「周野的律师」。 */
|
|
1727
|
-
const ROLE_NAME_SUFFIXES = ['律师', '辩护律师', '医生', '老师', '教授', '先生', '女士', '小姐', '警官', '警察', '局长', '总经理', '经理', '老板', '师父', '师傅', '道长', '老祖', '长老', '掌门', '少主', '公主', '王子', '王妃', '皇后', '皇帝', '王爷', '公子', '姑娘', '夫人', '太太', '大人', '将军', '丞相', '尚书', '员外']
|
|
1728
|
-
|
|
1729
|
-
/**
|
|
1730
|
-
* 从角色名/身份拆出正文可能使用的检索词:
|
|
1731
|
-
* - specific:具体称谓(含职业尾缀或 3 字以上身份片段),如「周野的律师」「辩护律师」「律师」——优先用,避免误抓到同名主干(周野)的段落;
|
|
1732
|
-
* - stems:名字主干(如「周野」),最后兜底。
|
|
1733
|
-
*/
|
|
1734
|
-
function roleFallbackTokens(name: string, identity: string | undefined): { specific: string[]; stems: string[] } {
|
|
1735
|
-
const specific = new Set<string>()
|
|
1736
|
-
const stems = new Set<string>()
|
|
1737
|
-
const addName = (s: string): void => {
|
|
1738
|
-
const t = s.trim()
|
|
1739
|
-
if (t.length < 2) return
|
|
1740
|
-
specific.add(t)
|
|
1741
|
-
for (const q of ['辩护', '助理', '高级', '首席', '御用', '御前', '专职']) {
|
|
1742
|
-
if (t.includes(q)) specific.add(t.replace(q, ''))
|
|
1743
|
-
}
|
|
1744
|
-
for (const suf of ROLE_NAME_SUFFIXES) {
|
|
1745
|
-
if (t.endsWith(suf) && t.length > suf.length + 1) {
|
|
1746
|
-
const stem = t.slice(0, -suf.length)
|
|
1747
|
-
// 「周野律师」→「周野的律师」(正文常见写法)
|
|
1748
|
-
specific.add(stem + '的' + suf)
|
|
1749
|
-
specific.add(suf)
|
|
1750
|
-
if (stem.length >= 2) stems.add(stem)
|
|
1751
|
-
}
|
|
1752
|
-
}
|
|
1753
|
-
}
|
|
1754
|
-
addName(name)
|
|
1755
|
-
for (const part of (identity ?? '').split(/[的,,。\s]+/)) {
|
|
1756
|
-
const p = part.trim()
|
|
1757
|
-
if (p.length < 2) continue
|
|
1758
|
-
if (p.length >= 3 || ROLE_NAME_SUFFIXES.some(s => p.endsWith(s))) {
|
|
1759
|
-
specific.add(p)
|
|
1760
|
-
for (const suf of ROLE_NAME_SUFFIXES) {
|
|
1761
|
-
if (p.endsWith(suf) && p.length > suf.length + 1) {
|
|
1762
|
-
specific.add(suf)
|
|
1763
|
-
stems.add(p.slice(0, -suf.length))
|
|
1764
|
-
}
|
|
1765
|
-
}
|
|
1766
|
-
} else {
|
|
1767
|
-
stems.add(p)
|
|
1768
|
-
}
|
|
1769
|
-
}
|
|
1770
|
-
return { specific: [...specific].filter(t => t.length >= 2), stems: [...stems].filter(t => t.length >= 2) }
|
|
1771
|
-
}
|
|
1772
|
-
|
|
1773
1856
|
/** 保证风格词块已内嵌(LLM 偶发漏嵌时兜底):zh 段首前缀,en 末尾追加。 */
|
|
1774
|
-
function ensureStyleEmbedded(text: string, styleWords: string, lang: 'zh' | 'en'): string {
|
|
1775
|
-
const t = text.trim()
|
|
1776
|
-
if (styleWords === '' || t === '') return t
|
|
1777
|
-
if (t.includes(styleWords)) return t
|
|
1778
|
-
return lang === 'zh' ? styleWords + ',' + t : t + ',' + styleWords
|
|
1779
|
-
}
|
|
1780
|
-
|
|
1781
|
-
/** 对 zh/en 一对提示词统一补风格词块。 */
|
|
1782
|
-
function withStyle(pair: { zh: string; en: string }, styleWords: string): { zh: string; en: string } {
|
|
1783
|
-
return { zh: ensureStyleEmbedded(pair.zh, styleWords, 'zh'), en: ensureStyleEmbedded(pair.en, styleWords, 'en') }
|
|
1784
|
-
}
|
|
1785
|
-
|
|
1786
|
-
/** 动漫形象描述词(中文描述 + 英文 booru 标签 + 关键外貌标签)。 */
|
|
1787
|
-
export interface RoleVisualPrompt {
|
|
1788
|
-
zh: string
|
|
1789
|
-
en: string
|
|
1790
|
-
tags: string[]
|
|
1791
|
-
source: string
|
|
1792
|
-
/** 即梦/生图通用负面提示词。 */
|
|
1793
|
-
negativePrompt?: string
|
|
1794
|
-
/** 该角色所需情绪表情清单(6-12 个,如 疲惫/麻木/压抑悲伤)。 */
|
|
1795
|
-
expressions?: string[]
|
|
1796
|
-
/** 四类精修提示词(立绘/四视图/表情/细节,一次提炼直接产出)。 */
|
|
1797
|
-
promptKit?: RoleRecord['promptKit']
|
|
1798
|
-
}
|
|
1799
|
-
|
|
1800
|
-
/** 可提炼形象的「角色源」:小说角色卡或漫剧角色卡的最小公共面。 */
|
|
1801
|
-
interface RoleVisualSource {
|
|
1802
|
-
name: string
|
|
1803
|
-
identity?: string
|
|
1804
|
-
traits?: string[]
|
|
1805
|
-
}
|
|
1806
|
-
|
|
1807
|
-
/**
|
|
1808
|
-
* 底层实现:为任意角色源提炼「动漫形象描述词」(不写库,写库由上层调用方负责)。
|
|
1809
|
-
* 扫描该角色出场的已写章节正文,截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
|
|
1810
|
-
*/
|
|
1811
|
-
async function extractRoleVisualFrom(
|
|
1812
|
-
ctx: Context,
|
|
1813
|
-
config: NovelConfig,
|
|
1814
|
-
project: ProjectState,
|
|
1815
|
-
outputDir: string,
|
|
1816
|
-
role: RoleVisualSource,
|
|
1817
|
-
styleId?: string,
|
|
1818
|
-
filterId?: string,
|
|
1819
|
-
shortDrama = false,
|
|
1820
|
-
tier: 'protagonist' | 'supporting' | 'extra' = 'protagonist',
|
|
1821
|
-
): Promise<RoleVisualPrompt> {
|
|
1822
|
-
const roleName = role.name
|
|
1823
|
-
|
|
1824
|
-
// 1. 扫描正文:收集该角色出场且可能含外貌描写的段落(最近 60 章内,每章最多 2 段,共 12 段)。
|
|
1825
|
-
// 支持“描述型角色名”(如「灰蓝工装女人」):先精确匹配,匹配不到时用角色名拆分出的关键词兜底。
|
|
1826
|
-
const appearanceHints = /(发|眉|眼|眸|脸|肤|唇|身材|身高|衣|袍|裙|衫|靴|腰带|气质|模样|长相|容貌|披|束|扎|戴|佩|挂|绣|青|白|黑|红|蓝|紫|灰|银|金|少年|青年|少女|汉子|老者|中年|纤细|挺拔|瘦削|壮实|清秀|俊朗|英气|阴鸷|慈眉)/
|
|
1827
|
-
// 从角色名 + 身份描述中提取可检索关键词,支持“描述型角色名”(灰蓝工装女人)和“身份型角色名”(富商)。
|
|
1828
|
-
const roleText = `${role.name} ${role.identity ?? ''}`
|
|
1829
|
-
const colorWords = ['深灰', '灰蓝', '灰', '蓝', '白', '黑', '红', '青', '紫', '银', '金', '黄', '绿', '粉', '棕']
|
|
1830
|
-
const garmentWords = ['工装', '西装', '定制西装', '袍', '裙', '衫', '衣', '裤', '靴', '鞋', '帽', '腰带', '制服', '外套', '马甲', '夹克']
|
|
1831
|
-
const roleWords = ['女人', '男人', '老者', '老人', '中年', '青年', '少女', '姑娘', '男子', '女子', '工人', '路人', '购买者']
|
|
1832
|
-
const locationWords = ['别墅', '小区', '街区']
|
|
1833
|
-
const identityTokens = (role.identity ?? '').split(/[,,。\s]+/).filter(t => t.length >= 2)
|
|
1834
|
-
const searchTokens = Array.from(new Set([
|
|
1835
|
-
roleName,
|
|
1836
|
-
...identityTokens,
|
|
1837
|
-
...colorWords.filter(w => roleText.includes(w)),
|
|
1838
|
-
...garmentWords.filter(w => roleText.includes(w)),
|
|
1839
|
-
...roleWords.filter(w => roleText.includes(w)),
|
|
1840
|
-
...locationWords.filter(w => roleText.includes(w)),
|
|
1841
|
-
])).filter(t => t.length >= 2)
|
|
1842
|
-
const matchesRole = (para: string, tokens: string[]): boolean => para.includes(roleName) || tokens.some(tok => para.includes(tok))
|
|
1843
|
-
const excerpts: Array<{ no: number; text: string }> = []
|
|
1844
|
-
const written = project.chapters
|
|
1845
|
-
.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.file !== undefined)
|
|
1846
|
-
/** 扫描一批章节,收集该角色出场且可能含外貌描写的段落(每章最多 2 段,共 12 段)。 */
|
|
1847
|
-
const scanChapters = (list: ChapterPlan[], tokens: string[]): void => {
|
|
1848
|
-
for (const chapter of list) {
|
|
1849
|
-
if (excerpts.length >= 12) break
|
|
1850
|
-
const body = readChapterFile(outputDir, chapter)
|
|
1851
|
-
if (body === undefined) continue
|
|
1852
|
-
const paras = body.split(/\n{2,}/).map(p => p.trim()).filter(p => p.length > 0)
|
|
1853
|
-
let perChapter = 0
|
|
1854
|
-
for (const para of paras) {
|
|
1855
|
-
if (perChapter >= 2 || excerpts.length >= 12) break
|
|
1856
|
-
if (!matchesRole(para, tokens)) continue
|
|
1857
|
-
// 优先外貌描写段(含外貌关键词),否则纯动作段也收(LLM 自己判断)。
|
|
1858
|
-
if (appearanceHints.test(para) || excerpts.length < 4) {
|
|
1859
|
-
excerpts.push({ no: chapter.no, text: para.slice(0, 220) })
|
|
1860
|
-
perChapter++
|
|
1861
|
-
}
|
|
1862
|
-
}
|
|
1863
|
-
}
|
|
1864
|
-
}
|
|
1865
|
-
// 第 1 优先:最近 60 章(快路径)。
|
|
1866
|
-
scanChapters(written.slice(-60), searchTokens)
|
|
1867
|
-
// 第 2 优先:角色可能只在早期出场(全书 >60 章时),全书回扫。
|
|
1868
|
-
if (excerpts.length === 0) scanChapters(written, searchTokens)
|
|
1869
|
-
// 第 3 优先:角色名/身份带职业尾缀(如「周野律师」→ 正文写「周野的律师/律师」),用具体称谓扫。
|
|
1870
|
-
if (excerpts.length === 0) {
|
|
1871
|
-
const fb = roleFallbackTokens(role.name, role.identity)
|
|
1872
|
-
if (fb.specific.length > 0) scanChapters(written, fb.specific)
|
|
1873
|
-
}
|
|
1874
|
-
// 第 4 兜底:具体称谓也没找到时,退回名字主干(如「周野」),交给 LLM 判断是否目标角色。
|
|
1875
|
-
if (excerpts.length === 0) {
|
|
1876
|
-
const fb = roleFallbackTokens(role.name, role.identity)
|
|
1877
|
-
if (fb.stems.length > 0) scanChapters(written, fb.stems)
|
|
1878
|
-
}
|
|
1879
|
-
if (excerpts.length === 0) {
|
|
1880
|
-
throw new Error(`正文中未找到「${roleName}」的出场描写(已扫描全书及角色名拆分词),请确认角色名与正文一致,或该角色尚未在正文出场`)
|
|
1881
|
-
}
|
|
1882
|
-
|
|
1883
|
-
// 2. LLM 提炼:一次输出 锚点 + 表情清单 + 四类精修提示词。
|
|
1884
|
-
const rules = project.visualRules ?? []
|
|
1885
|
-
const styleWords = styleKeywords(styleId, filterId)
|
|
1886
|
-
const system = [
|
|
1887
|
-
'你是一位动漫角色设定师与 AI 绘图提示词工程师。根据网文正文中该角色的实际外貌描写,输出「形象锚点」与「四类生图提示词」——一次完成,用于 AI 绘图(NovelAI / Stable Diffusion / Midjourney / 豆包等)生成一致的角色立绘。',
|
|
1888
|
-
'【当前视觉风格】(portrait 立绘的「风格」字段必须原样使用,sheet/expressions/details 同样内嵌):' + styleWords,
|
|
1889
|
-
'重要:每段 zh 提示词的段首必须原样嵌入【当前视觉风格】词块原文;en 提示词末尾追加风格标签。不得省略或改写。',
|
|
1890
|
-
'硬性要求(依据优先):',
|
|
1891
|
-
'1. 发色/发型/瞳色/服装/气质/标志物必须来自提供的正文段落,不得凭空发明。',
|
|
1892
|
-
'2. 正文未明确写到的项目(如瞳色没写、身高没写),用「未定」标注或直接不写,不要编造数值。',
|
|
1893
|
-
'3. 服装优先取正文明确出现的(颜色+款式),多次出现取最常穿的组合;服装按分件组织(上身/下身/鞋/配饰)。',
|
|
1894
|
-
'4. 标志物(标签/印记/饰品)必须出现在每段提示词中——它们是一致性的命根子。',
|
|
1895
|
-
'5. 立绘/四视图/细节是「角色设定稿」:禁止写瞬间动作与道具使用状态(握手机、看屏幕、未接来电、走路、回头等),禁止写剧情状态与场景背景;只保留可长期存在的外貌、服装与常驻标志物(工牌、饰品等)。',
|
|
1896
|
-
...(shortDrama ? ['6. 短剧精简模式·人设极致化:性格标签必须极致化(如「极端偏执」「绝对冷血」,单一维度拉到最满);外貌只保留 1-2 个最强辨识度点,弱化平凡细节;服装/标志物更夸张醒目。'] : []),
|
|
1897
|
-
...(tier === 'supporting' ? ['配角模式:只需输出立绘提示词(portrait),不需要四视图/表情/细节,外貌描述精简到80字以内。'] : []),
|
|
1898
|
-
...(tier === 'extra' ? ['路人模式:不需要输出精修提示词(promptKit)和表情清单(expressions),只需基础外貌描述(40字以内)和英文标签。'] : []),
|
|
1899
|
-
'【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):',
|
|
1900
|
-
...rules.map(r => '- ' + r),
|
|
1901
|
-
'输出六部分:',
|
|
1902
|
-
'- zh:中文形象锚点,一段连贯文字(60-150 字):发色发型、瞳色、脸型气质、服装(颜色款式)、身材、标志性物件。',
|
|
1903
|
-
'- en:英文形象锚点,booru 风格、逗号分隔、小写,30-50 个标签:含性别(1boy/1girl)、发色、发型、瞳色、服装、气质、标志物。不要输出负面提示词。',
|
|
1904
|
-
'- tags:中文关键标签数组,5-10 个(如 ["黑发","束发","青色道袍","清秀","腰悬古玉"])。',
|
|
1905
|
-
'- source:说明依据(如"第1章/第8章外貌描写;瞳色未明确")。',
|
|
1906
|
-
'- negativePrompt:即梦/生图通用负面提示词,中文逗号分隔,8-12个词:必须包含 低质量、模糊、变形、多指、断肢、文字、水印、丑陋、比例失调;可根据角色补充(如写实风加 卡通)。',
|
|
1907
|
-
'- expressions:该角色在本书剧情中需要的情绪表情清单数组,6-12 个(如 ["疲惫","麻木","压抑悲伤","皱眉","紧绷","放空"]),依据正文情绪描写与角色处境推断。',
|
|
1908
|
-
'- promptKit:四类精修提示词(字段化+出图约束,参考示例结构):',
|
|
1909
|
-
' · portrait 立绘:严格按字段顺序写(参考示例结构):开头构图定位(正面站立全身人像)→ 风格(3D动漫,超精细建模)→ 背景(纯白纯色背景)→ 身份(男子,{角色名},外表{年龄},男性)→ 发型发色 → 胡茬 → 眼眸 → 面部 → 气质 → 上身服装分件(颜色款式+磨损细节)→ 下身 → 鞋 → 标志物(标签/印记,含细节如洇暗翘边)→ 收尾(角色设计稿,细节完整展示,无多余杂物,全身完整无裁切)。',
|
|
1910
|
-
' · sheet 角色设定稿:专业角色设计参考图(character design sheet),纯白背景,最高品质细节丰富。结构:1.主视觉区(上方):正面+侧面+背面三视图,直观呈现整体身形、服饰搭配和标志性特征;2.补充信息区(左侧):面部特写+配色板(明确毛发/服饰色值),补充主视角没覆盖的细节与色彩标准;3.局部细节区(底部):小模块单独展示关键部件设计(配饰、点缀、关键身份识别元素),把模糊细节拆分为精准制作参考;4.全身比例照(右侧):黄金比例参考物与人物身高形成对比。画质要求:8K高清纹理,质感光照,自然光线,布料褶皱自然,皮肤纹理细节完整,艺术写实风格,营造震撼视觉效果。人物外观设定按字段:年龄、性别、发型发色、五官(眉/眼/鼻/唇)、脸型、身高、气质、服装分件、标志物。',
|
|
1911
|
-
' · expressions 表情:每个表情一段,脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,只换情绪表达(眼神/嘴角/眉),皮肤纹理细节完整,无多余杂物。',
|
|
1912
|
-
' · details 细节:多组局部细节集合参考图(一张图多个局部框),纯白背景;把该角色全部标志物逐项列出(如标签/印记/工牌/袖口磨损/鞋),每项一句特写描述;细节清晰锐利,角色细节参考稿,无多余杂物。',
|
|
1913
|
-
'promptKit 每段 zh:连贯中文 60-150 字;en:booru 标签 30-50 个。',
|
|
1914
|
-
'输出必须是合法 JSON 对象:{"zh": "...", "en": "...", "tags": [...], "source": "...", "negativePrompt": "...", "expressions": [...], "promptKit": {"portrait": {"zh":"...", "en":"..."}, "sheet": {"zh":"...", "en":"..."}, "expressions": [{"name":"疲惫","zh":"...","en":"..."}], "details": {"zh":"...", "en":"..."}}}',
|
|
1915
|
-
'重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
1916
|
-
'重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。',
|
|
1917
|
-
].join('\n')
|
|
1918
|
-
const user = [
|
|
1919
|
-
`书名:《${project.bookName}》`,
|
|
1920
|
-
`目标角色:${role.name}(${role.identity})`,
|
|
1921
|
-
(role.traits ?? []).length > 0 ? `性格标签:${role.traits!.join('、')}` : '',
|
|
1922
|
-
`正文出场描写(含外貌线索的段落):`,
|
|
1923
|
-
...excerpts.map(e => `[第${e.no}章] ${e.text}`),
|
|
1924
|
-
'只输出 JSON 对象。',
|
|
1925
|
-
].join('\n\n')
|
|
1926
|
-
const text = await complete(ctx, config, { system, user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low' })
|
|
1927
|
-
const raw = parseJsonObject<{ zh?: unknown; en?: unknown; tags?: unknown; source?: unknown; negativePrompt?: unknown; expressions?: unknown; promptKit?: unknown }>(text)
|
|
1928
|
-
let zh = typeof raw.zh === 'string' ? raw.zh.trim().slice(0, 500) : ''
|
|
1929
|
-
let en = typeof raw.en === 'string' ? raw.en.trim().slice(0, 1500) : ''
|
|
1930
|
-
const tags = Array.isArray(raw.tags)
|
|
1931
|
-
? raw.tags.filter((t): t is string => typeof t === 'string' && t.trim() !== '').map(t => t.trim().slice(0, 20)).slice(0, 12)
|
|
1932
|
-
: []
|
|
1933
|
-
const source = typeof raw.source === 'string' ? raw.source.trim().slice(0, 300) : ''
|
|
1934
|
-
const defaultNegative = '低质量,模糊,变形,多指,断肢,文字,水印,丑陋,比例失调'
|
|
1935
|
-
const negativePrompt = (typeof raw.negativePrompt === 'string' && raw.negativePrompt.trim() !== '') ? raw.negativePrompt.trim().slice(0, 300) : defaultNegative
|
|
1936
|
-
const expressions = Array.isArray(raw.expressions)
|
|
1937
|
-
? raw.expressions.filter((e): e is string => typeof e === 'string' && e.trim() !== '').map(e => e.trim().slice(0, 12)).slice(0, 12)
|
|
1938
|
-
: []
|
|
1939
|
-
// 四类精修提示词(一次提炼直接产出)
|
|
1940
|
-
const str = (v: unknown): string => typeof v === 'string' ? v.trim() : ''
|
|
1941
|
-
const pair = (v: unknown): { zh: string; en: string } => {
|
|
1942
|
-
const o = (typeof v === 'object' && v !== null) ? v as Record<string, unknown> : {}
|
|
1943
|
-
return { zh: str(o.zh).slice(0, 800), en: str(o.en).slice(0, 1200) }
|
|
1944
|
-
}
|
|
1945
|
-
const kitRaw = (typeof raw.promptKit === 'object' && raw.promptKit !== null) ? raw.promptKit as Record<string, unknown> : {}
|
|
1946
|
-
const kitExpr = Array.isArray(kitRaw.expressions) ? kitRaw.expressions.filter((e): e is Record<string, unknown> => typeof e === 'object' && e !== null) : []
|
|
1947
|
-
let promptKit: RoleRecord['promptKit']
|
|
1948
|
-
try {
|
|
1949
|
-
promptKit = {
|
|
1950
|
-
portrait: pair(kitRaw.portrait),
|
|
1951
|
-
sheet: pair(kitRaw.sheet),
|
|
1952
|
-
expressions: kitExpr.map(e => ({
|
|
1953
|
-
name: str(e.name).slice(0, 12) || '表情',
|
|
1954
|
-
zh: str(e.zh).slice(0, 800),
|
|
1955
|
-
en: str(e.en).slice(0, 1200),
|
|
1956
|
-
})).slice(0, 12),
|
|
1957
|
-
details: pair(kitRaw.details),
|
|
1958
|
-
}
|
|
1959
|
-
} catch {
|
|
1960
|
-
promptKit = undefined
|
|
1961
|
-
}
|
|
1962
|
-
if (zh === '' || en === '') {
|
|
1963
|
-
throw new Error('形象描述提炼失败:LLM 未返回有效 JSON')
|
|
1964
|
-
}
|
|
1965
|
-
// 风格兜底:LLM 漏嵌时强制补上(zh 段首 / en 末尾),保证出图风格与当前方案一致。
|
|
1966
|
-
zh = ensureStyleEmbedded(zh, styleWords, 'zh')
|
|
1967
|
-
en = ensureStyleEmbedded(en, styleWords, 'en')
|
|
1968
|
-
if (promptKit !== undefined) {
|
|
1969
|
-
promptKit = {
|
|
1970
|
-
portrait: promptKit.portrait !== undefined ? withStyle(promptKit.portrait, styleWords) : undefined,
|
|
1971
|
-
sheet: promptKit.sheet !== undefined ? withStyle(promptKit.sheet, styleWords) : undefined,
|
|
1972
|
-
expressions: promptKit.expressions !== undefined ? promptKit.expressions.map(e => ({ ...e, ...withStyle(e, styleWords) })) : undefined,
|
|
1973
|
-
details: promptKit.details !== undefined ? withStyle(promptKit.details, styleWords) : undefined,
|
|
1974
|
-
}
|
|
1975
|
-
}
|
|
1976
|
-
// 按角色级别过滤输出深度:主角完整 / 配角仅立绘 / 路人仅基础描述
|
|
1977
|
-
if (tier === 'supporting') {
|
|
1978
|
-
return { zh: zh.slice(0, 100), en, tags, source, negativePrompt, expressions: [], promptKit: promptKit !== undefined ? { portrait: promptKit.portrait } : undefined }
|
|
1979
|
-
}
|
|
1980
|
-
if (tier === 'extra') {
|
|
1981
|
-
return { zh: zh.slice(0, 60), en, tags, source, negativePrompt, expressions: [], promptKit: undefined }
|
|
1982
|
-
}
|
|
1983
|
-
return { zh, en, tags, source, negativePrompt, expressions, promptKit }
|
|
1984
|
-
}
|
|
1985
|
-
|
|
1986
|
-
/** 小说角色库:提炼单个角色的形象锚点并写回角色卡。 */
|
|
1987
|
-
export async function extractRoleVisual(
|
|
1988
|
-
ctx: Context,
|
|
1989
|
-
config: NovelConfig,
|
|
1990
|
-
project: ProjectState,
|
|
1991
|
-
outputDir: string,
|
|
1992
|
-
roleName: string,
|
|
1993
|
-
styleId?: string,
|
|
1994
|
-
filterId?: string,
|
|
1995
|
-
): Promise<RoleVisualPrompt> {
|
|
1996
|
-
const role = (project.roles ?? []).find(r => r.name === roleName)
|
|
1997
|
-
if (role === undefined) throw new Error(`角色「${roleName}」不在角色库中`)
|
|
1998
|
-
role.promptStyleId = styleId
|
|
1999
|
-
return extractRoleVisualFrom(ctx, config, project, outputDir, role, styleId, filterId)
|
|
2000
|
-
}
|
|
2001
|
-
|
|
2002
|
-
/** 漫剧角色卡:提炼形象锚点并写回漫剧卡(status → anchored)。 */
|
|
2003
|
-
export async function extractMangaRoleVisual(
|
|
2004
|
-
ctx: Context,
|
|
2005
|
-
config: NovelConfig,
|
|
2006
|
-
project: ProjectState,
|
|
2007
|
-
outputDir: string,
|
|
2008
|
-
cardId: string,
|
|
2009
|
-
styleId?: string,
|
|
2010
|
-
filterId?: string,
|
|
2011
|
-
): Promise<RoleVisualPrompt> {
|
|
2012
|
-
const card = (project.mangaRoles ?? []).find(c => c.id === cardId)
|
|
2013
|
-
if (card === undefined) throw new Error(`漫剧角色卡 ${cardId} 不存在`)
|
|
2014
|
-
const visual = await extractRoleVisualFrom(ctx, config, project, outputDir, { name: card.name, identity: card.identity, traits: card.traits }, styleId, filterId, project.shortDramaMode === true, card.tier ?? 'protagonist')
|
|
2015
|
-
card.promptStyleId = styleId
|
|
2016
|
-
card.imagePrompt = visual
|
|
2017
|
-
if ((visual.expressions ?? []).length > 0) card.expressions = visual.expressions
|
|
2018
|
-
if (visual.promptKit !== undefined) card.promptKit = visual.promptKit
|
|
2019
|
-
if (card.status === 'imported' || card.status === 'pending_confirm') card.status = 'anchored'
|
|
2020
|
-
card.updatedAt = new Date().toISOString()
|
|
2021
|
-
|
|
2022
|
-
try { saveMangaRolePrompt(outputDir, card.name, visual.zh, visual.en, visual.negativePrompt) } catch { /* 资产库保存失败不阻塞 */ }
|
|
2023
|
-
return visual
|
|
2024
|
-
}
|
|
2025
|
-
|
|
2026
|
-
/** 可精修提示词包的「角色源」:小说角色卡或漫剧角色卡的最小公共面。 */
|
|
2027
|
-
interface RoleKitSource {
|
|
2028
|
-
name: string
|
|
2029
|
-
identity?: string
|
|
2030
|
-
imagePrompt: NonNullable<RoleRecord['imagePrompt']>
|
|
2031
|
-
expressions?: string[]
|
|
2032
|
-
}
|
|
2033
|
-
|
|
2034
|
-
/**
|
|
2035
|
-
* 底层实现:基于角色源的形象锚点 + 表情清单 + 视觉规则产出四类生图提示词(不写库)。
|
|
2036
|
-
*/
|
|
2037
|
-
async function generateRolePromptKitFrom(
|
|
2038
|
-
ctx: Context,
|
|
2039
|
-
config: NovelConfig,
|
|
2040
|
-
project: ProjectState,
|
|
2041
|
-
role: RoleKitSource,
|
|
2042
|
-
styleId?: string,
|
|
2043
|
-
filterId?: string,
|
|
2044
|
-
shortDrama = false,
|
|
2045
|
-
tier: 'protagonist' | 'supporting' | 'extra' = 'protagonist',
|
|
2046
|
-
): Promise<RoleRecord['promptKit']> {
|
|
2047
|
-
const roleName = role.name
|
|
2048
|
-
const rules = project.visualRules ?? []
|
|
2049
|
-
const styleWords = styleKeywords(styleId, filterId)
|
|
2050
|
-
const system = [
|
|
2051
|
-
'你是一位 AI 绘图提示词工程师。基于给定角色的形象锚点、表情清单与本书视觉规则,输出立绘+表情两类生图提示词(每类 zh+en 各一段)。即梦画布支持多角度编辑,无需生成四视图设定稿。',
|
|
2052
|
-
'【当前视觉风格】(每类提示词必须内嵌):' + styleWords,
|
|
2053
|
-
'重要:每段 zh 提示词的段首必须原样嵌入【当前视觉风格】词块原文;en 提示词末尾追加风格标签。不得省略或改写。',
|
|
2054
|
-
'两类:',
|
|
2055
|
-
'1. portrait 立绘:正面站立全身人像,纯白纯色背景;按即梦官方7维度公式组织:①[年龄/种族]具体岁数+国籍/人种+风格形容词+脸型名词;②[肤色/皮肤质感]冷暖色调+具体肤色+皮肤质感形容词+保留真实微细毛孔与肌肤纹理;③[面部细节特征]眼型+眉骨+鼻梁+唇形+下颌线(至少3-4点组合);④[眼神/灵魂]眼神形容词+目光传达的信息+透出的底层情绪;⑤[发型/发色]具体发色+头发状态/质感+具体发型+环境互动;⑥[服装/服装质感]版型/剪裁+颜色+具体服装名词+布料材质/新旧状态+穿着细节;⑦[体型/情绪/气质]骨架/肩部特征+整体散发的氛围词。收尾:角色设计稿,细节完整展示,无多余杂物,全身完整无裁切。',
|
|
2056
|
-
'2. expressions 表情(高级可选):每个表情一段,脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,只换情绪表达(眼神/嘴角/眉),皮肤纹理细节完整,无多余杂物。',
|
|
2057
|
-
'3. 立绘为「角色设定稿」:禁止瞬间动作、道具使用状态与剧情状态(握手机、看屏幕、未接来电等),只保留可长期存在的外貌、服装与常驻标志物(工牌、饰品等)。即梦画布多角度编辑可生成侧面/背面,无需生成四视图。',
|
|
2058
|
-
...(shortDrama ? ['6. 短剧精简模式·人设极致化:性格标签必须极致化(单一维度拉到最满);外貌只保留 1-2 个最强辨识度点;服装/标志物更夸张醒目,一眼可认。'] : []),
|
|
2059
|
-
'【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):',
|
|
2060
|
-
...rules.map(r => '- ' + r),
|
|
2061
|
-
'zh 要求:连贯中文,写实电影感,60-150 字/段;en 要求:booru 风格逗号分隔标签,30-50 个/段。',
|
|
2062
|
-
'输出必须是合法 JSON 对象:{"portrait": {"zh": "...", "en": "..."}, "expressions": [{"name": "疲惫", "zh": "...", "en": "..."}]}',
|
|
2063
|
-
'重要:所有字符串值内部不得包含换行符;直接输出 JSON 结果本身。',
|
|
2064
|
-
].join('\n')
|
|
2065
|
-
const user = [
|
|
2066
|
-
`角色:${role.name}(${role.identity ?? ''})`,
|
|
2067
|
-
`中文锚点:${role.imagePrompt.zh}`,
|
|
2068
|
-
`英文锚点:${role.imagePrompt.en}`,
|
|
2069
|
-
`关键标签:${role.imagePrompt.tags.join('、')}`,
|
|
2070
|
-
`表情清单:${(role.expressions ?? []).join('、') || '(未提供,按角色气质推断 6 个)'}`,
|
|
2071
|
-
'只输出 JSON 对象。',
|
|
2072
|
-
].join('\n\n')
|
|
2073
|
-
const text = await complete(ctx, config, { system, user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low' })
|
|
2074
|
-
const raw = parseJsonObject<Record<string, unknown>>(text)
|
|
2075
|
-
const str = (v: unknown): string => typeof v === 'string' ? v.trim() : ''
|
|
2076
|
-
const pair = (v: unknown): { zh: string; en: string } => {
|
|
2077
|
-
const o = (typeof v === 'object' && v !== null) ? v as Record<string, unknown> : {}
|
|
2078
|
-
return { zh: str(o.zh).slice(0, 800), en: str(o.en).slice(0, 1200) }
|
|
2079
|
-
}
|
|
2080
|
-
const expressionsRaw = Array.isArray(raw.expressions) ? raw.expressions.filter((e): e is Record<string, unknown> => typeof e === 'object' && e !== null) : []
|
|
2081
|
-
const kit: RoleRecord['promptKit'] = {
|
|
2082
|
-
portrait: withStyle(pair(raw.portrait), styleWords),
|
|
2083
|
-
expressions: expressionsRaw.map(e => ({
|
|
2084
|
-
name: str(e.name).slice(0, 12) || '表情',
|
|
2085
|
-
...withStyle({ zh: str(e.zh).slice(0, 800), en: str(e.en).slice(0, 1200) }, styleWords),
|
|
2086
|
-
})).slice(0, 12),
|
|
2087
|
-
}
|
|
2088
|
-
if (kit.portrait === undefined || kit.portrait.zh === '' || kit.portrait.en === '') {
|
|
2089
|
-
throw new Error('提示词精修失败:LLM 未返回有效 JSON')
|
|
2090
|
-
}
|
|
2091
|
-
// 按角色级别过滤精修包:主角立绘+表情 / 配角仅立绘 / 路人不输出
|
|
2092
|
-
if (tier === 'supporting') return { portrait: kit.portrait }
|
|
2093
|
-
if (tier === 'extra') return {}
|
|
2094
|
-
return kit
|
|
2095
|
-
}
|
|
2096
|
-
|
|
2097
|
-
/** 小说角色库:角色四类生图提示词精修包(写回角色卡)。 */
|
|
2098
|
-
export async function generateRolePromptKit(
|
|
2099
|
-
ctx: Context,
|
|
2100
|
-
config: NovelConfig,
|
|
2101
|
-
project: ProjectState,
|
|
2102
|
-
roleName: string,
|
|
2103
|
-
styleId?: string,
|
|
2104
|
-
filterId?: string,
|
|
2105
|
-
): Promise<RoleRecord['promptKit']> {
|
|
2106
|
-
const role = (project.roles ?? []).find(r => r.name === roleName)
|
|
2107
|
-
if (role === undefined) throw new Error(`角色「${roleName}」不在角色库中`)
|
|
2108
|
-
if (role.imagePrompt === undefined) throw new Error(`角色「${roleName}」还没有形象锚点,请先生成锚点`)
|
|
2109
|
-
role.promptStyleId = styleId
|
|
2110
|
-
return generateRolePromptKitFrom(ctx, config, project, { name: role.name, identity: role.identity, imagePrompt: role.imagePrompt, expressions: role.expressions }, styleId, filterId)
|
|
2111
|
-
}
|
|
2112
|
-
|
|
2113
|
-
/** 漫剧角色卡:四类生图提示词精修包(写回漫剧卡,status → anchored)。 */
|
|
2114
|
-
export async function generateMangaRolePromptKit(
|
|
2115
|
-
ctx: Context,
|
|
2116
|
-
config: NovelConfig,
|
|
2117
|
-
project: ProjectState,
|
|
2118
|
-
cardId: string,
|
|
2119
|
-
styleId?: string,
|
|
2120
|
-
filterId?: string,
|
|
2121
|
-
): Promise<RoleRecord['promptKit']> {
|
|
2122
|
-
const card = (project.mangaRoles ?? []).find(c => c.id === cardId)
|
|
2123
|
-
if (card === undefined) throw new Error(`漫剧角色卡 ${cardId} 不存在`)
|
|
2124
|
-
if (card.imagePrompt === undefined) throw new Error(`「${card.name}」还没有形象锚点,请先生成锚点`)
|
|
2125
|
-
const kit = await generateRolePromptKitFrom(ctx, config, project, { name: card.name, identity: card.identity, imagePrompt: card.imagePrompt, expressions: card.expressions }, styleId, filterId, project.shortDramaMode === true, card.tier ?? 'protagonist')
|
|
2126
|
-
card.promptStyleId = styleId
|
|
2127
|
-
card.promptKit = kit
|
|
2128
|
-
if (card.status === 'imported' || card.status === 'pending_confirm') card.status = 'anchored'
|
|
2129
|
-
card.updatedAt = new Date().toISOString()
|
|
2130
|
-
return kit
|
|
2131
|
-
}
|
|
2132
|
-
|
|
2133
|
-
/**
|
|
2134
|
-
* 漫剧角色库·提名(两段式):从某章分镜的 characters 提名候选角色名 →
|
|
2135
|
-
* 规则过滤(精确名 + 身份/简称匹配,短名单 ≤5)→ LLM 确认(是/否 + 选哪个,不做开放检索)→
|
|
2136
|
-
* 返回带漫剧卡建议的候选(未匹配时给出「回小说库补提炼 / 漫剧直接创建」判定)。
|
|
2137
|
-
*/
|
|
2138
|
-
export async function nominateMangaRoles(
|
|
2139
|
-
ctx: Context,
|
|
2140
|
-
config: NovelConfig,
|
|
2141
|
-
project: ProjectState,
|
|
2142
|
-
outputDir: string,
|
|
2143
|
-
chapterNo: number,
|
|
2144
|
-
): Promise<MangaRoleCandidate[]> {
|
|
2145
|
-
const entry = (project.storyboards ?? []).find(e => e.chapterNo === chapterNo)
|
|
2146
|
-
if (entry === undefined) throw new Error(`第 ${chapterNo} 章还没有分镜产出,请先在「分镜」页生成剧情骨架/分镜表`)
|
|
2147
|
-
|
|
2148
|
-
// 收集该章分镜的结构化角色引用(骨架 + 表级 + 镜头级,去重)。
|
|
2149
|
-
const names: string[] = []
|
|
2150
|
-
const pushNames = (list: string[] | undefined): void => {
|
|
2151
|
-
for (const n of list ?? []) {
|
|
2152
|
-
const t = n.trim().slice(0, 20)
|
|
2153
|
-
if (t !== '' && !names.includes(t)) names.push(t)
|
|
2154
|
-
}
|
|
2155
|
-
}
|
|
2156
|
-
pushNames(entry.skeleton?.characters)
|
|
2157
|
-
pushNames(entry.table?.characters)
|
|
2158
|
-
for (const s of entry.table?.shots ?? []) pushNames(s.characters)
|
|
2159
|
-
if (names.length === 0) throw new Error(`第 ${chapterNo} 章的分镜还没有结构化角色(characters 为空),请重新生成剧情骨架/分镜表`)
|
|
2160
|
-
|
|
2161
|
-
// 已建漫剧卡的直接标 already_imported(按漫剧名或来源名匹配)。
|
|
2162
|
-
const imported = new Set<string>()
|
|
2163
|
-
for (const c of project.mangaRoles ?? []) {
|
|
2164
|
-
imported.add(c.name)
|
|
2165
|
-
if (c.sourceRoleName !== undefined && c.sourceRoleName !== '') imported.add(c.sourceRoleName)
|
|
2166
|
-
}
|
|
2167
|
-
const already: MangaRoleCandidate[] = names.filter(n => imported.has(n)).map(n => {
|
|
2168
|
-
const card = (project.mangaRoles ?? []).find(c => c.name === n || c.sourceRoleName === n)
|
|
2169
|
-
return {
|
|
2170
|
-
rawName: n,
|
|
2171
|
-
verdict: 'already_imported' as const,
|
|
2172
|
-
matches: [],
|
|
2173
|
-
tier: card?.tier ?? 'supporting',
|
|
2174
|
-
suggested: emptyCandidateSuggestion(n),
|
|
2175
|
-
}})
|
|
2176
|
-
const fresh = names.filter(n => !imported.has(n))
|
|
2177
|
-
if (fresh.length === 0) return already
|
|
2178
|
-
|
|
2179
|
-
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
2180
|
-
const body = chapter !== undefined ? readChapterFile(outputDir, chapter) : undefined
|
|
2181
|
-
const roles = project.roles ?? []
|
|
2182
|
-
|
|
2183
|
-
// 规则阶段:精确名 + 身份/简称匹配,短名单 ≤5。
|
|
2184
|
-
const shortlists: Array<{ rawName: string; roleNames: string[] }> = fresh.map(rawName => {
|
|
2185
|
-
const shortlist: string[] = []
|
|
2186
|
-
const push = (n: string): void => { if (!shortlist.includes(n)) shortlist.push(n) }
|
|
2187
|
-
for (const r of roles) {
|
|
2188
|
-
if (shortlist.length >= 5) break
|
|
2189
|
-
const n = r.name
|
|
2190
|
-
if (n === rawName) { push(n); continue }
|
|
2191
|
-
if (n.includes(rawName) || rawName.includes(n)) { push(n); continue }
|
|
2192
|
-
const ident = r.identity ?? ''
|
|
2193
|
-
if (ident !== '' && (ident === rawName || ident.includes(rawName) || rawName.includes(ident))) push(n)
|
|
2194
|
-
}
|
|
2195
|
-
return { rawName, roleNames: shortlist }
|
|
2196
|
-
})
|
|
2197
|
-
|
|
2198
|
-
// LLM 阶段:确认是/否 + 选哪个 + 漫剧卡建议(只能从短名单里选,不做开放检索)。
|
|
2199
|
-
const beats = (entry.skeleton?.beats ?? []).map(b => `[${b.id}] ${b.event}`).join('\n')
|
|
2200
|
-
const system = [
|
|
2201
|
-
'你是一位漫剧选角导演。下面给出「分镜角色提名」与「小说角色库候选名单」。',
|
|
2202
|
-
'任务:为每个提名判定它对应小说角色库中的哪一个候选(或判定小说库没有对应角色),并预填一张「漫剧角色卡」的建议信息。',
|
|
2203
|
-
'规则:',
|
|
2204
|
-
'1. roleName 优先从该提名的候选名单中选;若候选名单为空或都不像,允许从下方「全书角色库」中挑选最符合该身份代称的正式角色名,禁止虚构不存在的角色。',
|
|
2205
|
-
'2. verdict:matched=候选名单里确实有对应角色(选最像的那个);ambiguous=名单里有多个候选且无法确定(此时 roleName 可留空或选最可能的一个);not_in_library=候选名单为空或都不对应。',
|
|
2206
|
-
'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」)。',
|
|
2207
|
-
'4. 身份型提名(如「持枪者」「围观群众」):候选名单有则匹配;名单没有且正文确有该称谓 → not_in_library;正文也没有 → not_in_library。',
|
|
2208
|
-
...(project.shortDramaMode === true
|
|
2209
|
-
? ['5. 短剧精简模式(本书已开启):只保留 5-8 个上镜角色——主角/反派/感情线/关键配角;功能性路人不上卡(此类提名直接 not_in_library)。每个角色必须给出明确的 coreFunction 与 protagonistRelation;性格标签极致化;候选超过 8 个时按戏份重要性裁剪到最核心 8 个。']
|
|
2210
|
-
: []),
|
|
2211
|
-
'输出必须是合法 JSON 数组:[{"rawName": "...", "verdict": "...", "roleName": "...或省略", "suggested": {...}}],每个提名都要有,数组长度必须等于提名数。',
|
|
2212
|
-
'重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
2213
|
-
].join('\n')
|
|
2214
|
-
const user = [
|
|
2215
|
-
`章节:第 ${chapterNo} 章《${chapter?.title ?? ''}》`,
|
|
2216
|
-
'==== 剧情骨架(节拍) ====',
|
|
2217
|
-
beats !== '' ? beats : '(无骨架节拍)',
|
|
2218
|
-
'==== 角色提名与候选名单 ====',
|
|
2219
|
-
shortlists.map(s => s.roleNames.length > 0 ? `[提名] ${s.rawName} → 候选:${s.roleNames.join('、')}` : `[提名] ${s.rawName} → 候选:(无)`).join('\n'),
|
|
2220
|
-
'==== 全书角色库(身份代称归属判定以这里为准,正式名只能从这里取) ====\n' + roles.map(r => `${r.name}(${r.roleLabel}):${r.identity ?? ''};特征:${(r.traits ?? []).slice(0, 3).join('/')}`).join('\n'),
|
|
2221
|
-
body !== undefined ? '==== 章节正文(前 2500 字,判断称谓归属用) ====\n' + body.replace(/^#.*$/gm, '').trim().slice(0, 2500) : '',
|
|
2222
|
-
'只输出 JSON 数组。',
|
|
2223
|
-
].filter(x => x !== '').join('\n\n')
|
|
2224
|
-
const text = await complete(ctx, config, { system, user, temperature: 0.2, maxTokens: Math.max(config.maxTokens, 8000), reasoning: config.analysisReasoning ?? 'low' })
|
|
2225
|
-
const raw = parseJsonArray<Record<string, unknown>>(text)
|
|
2226
|
-
|
|
2227
|
-
const verdicts = new Set(['matched', 'ambiguous', 'not_in_library'])
|
|
2228
|
-
const fnMap: Record<string, MangaRoleCard['coreFunction']> = { protagonist: 'protagonist', mentor: 'mentor', love_interest: 'love_interest', antagonist: 'antagonist', sidekick: 'sidekick', informant: 'informant', functional: 'functional' }
|
|
2229
|
-
const relMap: Record<string, MangaRoleCard['protagonistRelation']> = { enemy: 'enemy', friend: 'friend', mentor: 'mentor', lover: 'lover', exploit: 'exploit', neutral: 'neutral' }
|
|
2230
|
-
const byName = new Map<string, Record<string, unknown>>()
|
|
2231
|
-
for (const e of raw) {
|
|
2232
|
-
if (typeof e === 'object' && e !== null && typeof e.rawName === 'string') byName.set(e.rawName.trim(), e)
|
|
2233
|
-
}
|
|
2234
|
-
const str = (v: unknown): string => typeof v === 'string' ? v.trim().slice(0, 100) : ''
|
|
2235
|
-
const strArr = (v: unknown, n: number): string[] => Array.isArray(v)
|
|
2236
|
-
? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '').map(x => x.trim().slice(0, 20)).slice(0, n)
|
|
2237
|
-
: []
|
|
2238
|
-
|
|
2239
|
-
// 智能分级:根据小说角色库 roleLabel + LLM 给出的 coreFunction 判断 tier
|
|
2240
|
-
const calcTier = (rawName: string, matched: string | undefined, fn: MangaRoleCard['coreFunction']): 'protagonist' | 'supporting' | 'extra' => {
|
|
2241
|
-
if (matched !== undefined) {
|
|
2242
|
-
const r = roles.find(x => x.name === matched)
|
|
2243
|
-
if (r?.roleLabel === 'protagonist' || r?.roleLabel === 'female_lead') return 'protagonist'
|
|
2244
|
-
if (r?.roleLabel === 'antagonist') return 'protagonist'
|
|
2245
|
-
if (r?.roleLabel === 'support' || r?.roleLabel === 'female_support') return 'supporting'
|
|
2246
|
-
}
|
|
2247
|
-
if (fn === 'protagonist') return 'protagonist'
|
|
2248
|
-
// 核心反派 / 感情线 → 主角级定妆(完整立绘+表情+细节),否则反派容易跨镜头崩脸。
|
|
2249
|
-
if (fn === 'antagonist' || fn === 'love_interest') return 'protagonist'
|
|
2250
|
-
// 功能性 / 出场即死 → 不做立绘。
|
|
2251
|
-
if (fn === 'functional') return 'extra'
|
|
2252
|
-
return 'supporting'
|
|
2253
|
-
}
|
|
2254
|
-
const out: MangaRoleCandidate[] = []
|
|
2255
|
-
for (const item of shortlists) {
|
|
2256
|
-
const judge = byName.get(item.rawName)
|
|
2257
|
-
const judgeVerdict = judge !== undefined ? str(judge.verdict) : ''
|
|
2258
|
-
let verdict = verdicts.has(judgeVerdict) ? judgeVerdict as MangaRoleCandidate['verdict'] : (item.roleNames.length > 0 ? 'ambiguous' : 'not_in_library')
|
|
2259
|
-
let roleName: string | undefined
|
|
2260
|
-
if (judge !== undefined) {
|
|
2261
|
-
const chosen = str(judge.roleName)
|
|
2262
|
-
// 接受范围放宽:候选名单内 或 全书角色库内的正式名均可(身份代称→角色名的语义桥,如「持枪男人」→「周野」)。
|
|
2263
|
-
if (chosen !== '' && (item.roleNames.includes(chosen) || roles.some(r => r.name === chosen))) roleName = chosen
|
|
2264
|
-
}
|
|
2265
|
-
if (verdict === 'matched' && roleName === undefined && item.roleNames.length === 1) roleName = item.roleNames[0]
|
|
2266
|
-
// LLM 从全书库认出代称对应正式角色(如「持枪男人」→「周野」)时,即使候选名单为空也视为 matched,不再漏。
|
|
2267
|
-
if (roleName !== undefined && verdict === 'not_in_library') verdict = 'matched'
|
|
2268
|
-
const sug = (typeof judge?.suggested === 'object' && judge?.suggested !== null) ? judge.suggested as Record<string, unknown> : {}
|
|
2269
|
-
const novelHint: MangaRoleCandidate['novelHint'] = verdict === 'not_in_library' && body !== undefined
|
|
2270
|
-
? (body.includes(item.rawName) ? 'backfill' : 'manga_new')
|
|
2271
|
-
: undefined
|
|
2272
|
-
const suggested = {
|
|
2273
|
-
name: (str(sug.name) !== '' ? str(sug.name) : (roleName ?? item.rawName)).slice(0, 30),
|
|
2274
|
-
identity: str(sug.identity).slice(0, 60),
|
|
2275
|
-
coreFunction: fnMap[str(sug.coreFunction)] ?? 'functional',
|
|
2276
|
-
protagonistRelation: relMap[str(sug.protagonistRelation)] ?? 'neutral',
|
|
2277
|
-
speechStyle: str(sug.speechStyle).slice(0, 60),
|
|
2278
|
-
traits: strArr(sug.traits, 3),
|
|
2279
|
-
appearance: str(sug.appearance).slice(0, 100),
|
|
2280
|
-
keyScenes: strArr(sug.keyScenes, 3),
|
|
2281
|
-
}
|
|
2282
|
-
out.push({
|
|
2283
|
-
rawName: item.rawName,
|
|
2284
|
-
verdict,
|
|
2285
|
-
matches: item.roleNames.map(n => ({ roleName: n, reason: roleName === n ? 'LLM 确认' : '规则候选' })),
|
|
2286
|
-
matchedRoleName: roleName,
|
|
2287
|
-
novelHint,
|
|
2288
|
-
tier: calcTier(item.rawName, roleName, suggested.coreFunction),
|
|
2289
|
-
suggested,
|
|
2290
|
-
})
|
|
2291
|
-
}
|
|
2292
|
-
return [...already, ...out]
|
|
2293
|
-
}
|
|
2294
|
-
|
|
2295
|
-
/** 空建议(already_imported 等无需 LLM 的候选用)。 */
|
|
2296
|
-
function emptyCandidateSuggestion(name: string): MangaRoleCandidate['suggested'] {
|
|
2297
|
-
return { name, identity: '', coreFunction: 'functional', protagonistRelation: 'neutral', speechStyle: '', traits: [], appearance: '', keyScenes: [] }
|
|
2298
|
-
}
|
|
2299
|
-
|
|
2300
|
-
/** ✨ 从道藏/红线提炼「视觉世界观规则」:生图/生视频必须遵守的设定纠偏(如"商品=人,禁止常规超市商品")。 */
|
|
2301
|
-
export async function extractVisualRules(
|
|
2302
|
-
ctx: Context,
|
|
2303
|
-
config: NovelConfig,
|
|
2304
|
-
project: ProjectState,
|
|
2305
|
-
): Promise<string[]> {
|
|
2306
|
-
const system = [
|
|
2307
|
-
'你是一位 AI 绘图的视觉规则设计师。本书的设定是"反常识"的,生图/生视频模型默认会画成现实世界的样子,你需要提炼 3-6 条「视觉规则」钉住本书的视觉世界观。',
|
|
2308
|
-
'规则要求:',
|
|
2309
|
-
'1. 每条必须可执行:明确"画面里必须出现什么/禁止出现什么"(如"货架上的一切商品都是活人,禁止画成罐头/饮料/日用品")。',
|
|
2310
|
-
'2. 覆盖本书最容易被模型画错的 2-4 个核心反常识点。',
|
|
2311
|
-
'3. 每条 40 字内,禁止泛泛而谈。',
|
|
2312
|
-
'输出必须是合法 JSON 数组:["规则1", "规则2", ...],不要输出其他文字。',
|
|
2313
|
-
].join('\n')
|
|
2314
|
-
const bible = project.bible
|
|
2315
|
-
const user = [
|
|
2316
|
-
`书名:《${project.bookName}》`,
|
|
2317
|
-
`题材:${bible?.genre ?? ''}`,
|
|
2318
|
-
bible !== undefined && bible.worldRules.length > 0
|
|
2319
|
-
? `世界规则:\n${bible.worldRules.map(r => '- ' + r).join('\n')}`
|
|
2320
|
-
: '',
|
|
2321
|
-
bible !== undefined && bible.redLines.length > 0
|
|
2322
|
-
? `红线:\n${bible.redLines.map(r => '- ' + r).join('\n')}`
|
|
2323
|
-
: '',
|
|
2324
|
-
'只输出 JSON 数组。',
|
|
2325
|
-
].filter(s => s !== '').join('\n\n')
|
|
2326
|
-
const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 4000), reasoning: config.analysisReasoning ?? 'low' })
|
|
2327
|
-
const raw = parseJsonArray<string>(text)
|
|
2328
|
-
return raw.map(r => typeof r === 'string' ? r.trim().slice(0, 80) : '').filter(r => r !== '').slice(0, 8)
|
|
2329
|
-
}
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
/**
|
|
2333
|
-
* 开书想法 → AI 大纲:输入一句话想法,生成 2-3 个方向不同、可直接开书的完整大纲方案。
|
|
2334
|
-
* @param count 本次生成几个(默认 3,最多 3)
|
|
2335
|
-
* @param exclude 已暂留方案的剧情方向/卖点摘要(换批时避开,防止重复)
|
|
2336
|
-
*/
|
|
2337
1857
|
export async function suggestOutlines(
|
|
2338
1858
|
ctx: Context,
|
|
2339
1859
|
config: NovelConfig,
|
|
@@ -2393,7 +1913,7 @@ export async function suggestOutlines(
|
|
|
2393
1913
|
}
|
|
2394
1914
|
|
|
2395
1915
|
/** 拆书分析:对已写章节做结构/人物/文风/卖点四维体检。
|
|
2396
|
-
*
|
|
1916
|
+
* 两阶段管道(先抽取候选、再落库):
|
|
2397
1917
|
* ① 源片段笔记:每章抽取结构化笔记(剧情/人物/设定/写法/卖点/短板信号)
|
|
2398
1918
|
* ② 分节分析:按维度各跑一次 LLM,输出可读分析稿 + 结构化数据 + 证据链。
|
|
2399
1919
|
* @param scope 'recent'(默认最近20章) | 'volume:N' | 'all'
|
|
@@ -2417,7 +1937,7 @@ export async function breakdownBook(
|
|
|
2417
1937
|
const v = Number(scope.slice(7))
|
|
2418
1938
|
selected = written.filter(c => c.volume === v)
|
|
2419
1939
|
}
|
|
2420
|
-
if (selected.length === 0) throw new
|
|
1940
|
+
if (selected.length === 0) throw new NovelActionError('contract', '没有可分析的已写章节(需要已生成并带摘要)')
|
|
2421
1941
|
|
|
2422
1942
|
// 2. token 预算:估算每章正文+摘要成本,超预算则只取最近的章节。
|
|
2423
1943
|
let budget = budgetTokens
|
|
@@ -2590,13 +2110,16 @@ export async function testLlmModel(ctx: Context, provider: string, model: string
|
|
|
2590
2110
|
content: [{ type: 'text', text: '只回复两个字:OK' }],
|
|
2591
2111
|
source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
|
|
2592
2112
|
})]
|
|
2593
|
-
|
|
2113
|
+
// 这里自己构造请求(为省 token 拿到首块即停),所以**必须显式过一遍能力层**:
|
|
2114
|
+
// 否则「测试连通」会原样下发参数,对固定温度的模型必然报错 —— 而测试连通
|
|
2115
|
+
// 正是用户用来判断配置对不对的地方。
|
|
2116
|
+
const request = applyModelCapability({
|
|
2594
2117
|
provider,
|
|
2595
2118
|
model,
|
|
2596
2119
|
messages,
|
|
2597
2120
|
maxTokens: 16,
|
|
2598
2121
|
temperature: 0,
|
|
2599
|
-
}
|
|
2122
|
+
}, model)
|
|
2600
2123
|
// 真实流式调用 + 30 秒超时(GenerateOptions.signal 由适配器响应并中止)。
|
|
2601
2124
|
const controller = new AbortController()
|
|
2602
2125
|
const timer = setTimeout(() => controller.abort(), 30_000)
|
|
@@ -2651,11 +2174,22 @@ export async function listLlmVendors(ctx: Context): Promise<LlmVendorsResponse>
|
|
|
2651
2174
|
}
|
|
2652
2175
|
}
|
|
2653
2176
|
} catch { /* 单个目录读取失败跳过 */ }
|
|
2654
|
-
// 已注册的适配器路由(如 deepseek-official
|
|
2177
|
+
// 已注册的适配器路由(如 deepseek-official):以 DSH 运行时目录为准刷新模型列表,
|
|
2178
|
+
// 否则 LLM_VENDORS 里的预置模型一旦过期(模型改名/下线),下拉里就会挂着不存在的模型。
|
|
2655
2179
|
try {
|
|
2656
2180
|
for (const p of ctx.llm.listProviders()) {
|
|
2657
|
-
|
|
2658
|
-
|
|
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
|
|
2659
2193
|
}
|
|
2660
2194
|
}
|
|
2661
2195
|
} catch { /* ignore */ }
|
|
@@ -2766,14 +2300,7 @@ export async function registerLlmModel(ctx: Context, req: AddModelRequest): Prom
|
|
|
2766
2300
|
return { ok: true, saved, provider: route, message }
|
|
2767
2301
|
}
|
|
2768
2302
|
|
|
2769
|
-
/**
|
|
2770
|
-
export function getActiveMangaStyle(project: ProjectState): { styleId?: string; filterId?: string } {
|
|
2771
|
-
const active = (project.mangaPlans ?? []).find(p => p.active === true)
|
|
2772
|
-
if (active === undefined) return {}
|
|
2773
|
-
return { styleId: active.styleId, filterId: active.filterId }
|
|
2774
|
-
}
|
|
2775
|
-
|
|
2776
|
-
/** 🩺 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
|
|
2303
|
+
/** 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
|
|
2777
2304
|
export async function analyzePlotlineHealth(
|
|
2778
2305
|
ctx: Context,
|
|
2779
2306
|
config: NovelConfig,
|
|
@@ -2832,7 +2359,7 @@ export async function analyzePlotlineHealth(
|
|
|
2832
2359
|
}
|
|
2833
2360
|
}
|
|
2834
2361
|
|
|
2835
|
-
/**
|
|
2362
|
+
/** 剧情方案:基于健康检查结果设计下一阶段方向与建议新线。 */
|
|
2836
2363
|
export async function designPlotlinePlan(
|
|
2837
2364
|
ctx: Context,
|
|
2838
2365
|
config: NovelConfig,
|
|
@@ -2892,9 +2419,9 @@ export async function designPlotlinePlan(
|
|
|
2892
2419
|
}
|
|
2893
2420
|
|
|
2894
2421
|
/** Build the rewrite system prompt (fix review issues / instructions). */
|
|
2895
|
-
function rewriteSystemPrompt(project: ProjectState, targetChars?: number): string {
|
|
2422
|
+
function rewriteSystemPrompt(project: ProjectState, targetChars?: number, beforeChapter?: number): string {
|
|
2896
2423
|
// 整章修订以「与原文相当」为准,不套用目标字数区间(避免与原文长度冲突)。
|
|
2897
|
-
const base = writeSystemPrompt(project, targetChars, '1. 输出完整的新正文(不要只输出修改片段、标题、章回名、作者的话或任何 Markdown 标记),字数与原章相当(允许 ±20%)。')
|
|
2424
|
+
const base = writeSystemPrompt(project, targetChars, '1. 输出完整的新正文(不要只输出修改片段、标题、章回名、作者的话或任何 Markdown 标记),字数与原章相当(允许 ±20%)。', beforeChapter)
|
|
2898
2425
|
return base + '\n\n额外要求:你正在【修订】一章已写好的正文。保留原文中好的部分,只修改需要修改的地方,输出完整的新正文(不要只输出修改片段),字数与原文相当。'
|
|
2899
2426
|
}
|
|
2900
2427
|
|
|
@@ -2914,9 +2441,9 @@ export async function* rewriteChapterStream(
|
|
|
2914
2441
|
target?: string,
|
|
2915
2442
|
): AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'drafted'; chars: number; draft: string }, void, unknown> {
|
|
2916
2443
|
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
2917
|
-
if (chapter === undefined) throw new
|
|
2444
|
+
if (chapter === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 不在计划中`)
|
|
2918
2445
|
const body = readChapterFile(outputDir, chapter)
|
|
2919
|
-
if (body === undefined) throw new
|
|
2446
|
+
if (body === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 的正文文件不存在`)
|
|
2920
2447
|
|
|
2921
2448
|
const reviewBlock = chapter.review !== undefined
|
|
2922
2449
|
? '审稿意见:\n' + chapter.review.issues.map(i => `[${i.severity}] ${i.item} → ${i.suggestion}`).join('\n')
|
|
@@ -2935,7 +2462,7 @@ export async function* rewriteChapterStream(
|
|
|
2935
2462
|
const paragraphs = bodyText.split(/\n{2,}/)
|
|
2936
2463
|
const idx = paragraphs.findIndex(p => normalize(p).includes(wantedFlat))
|
|
2937
2464
|
if (idx === -1) {
|
|
2938
|
-
throw new
|
|
2465
|
+
throw new NovelActionError('contract', `在正文中未找到要修改的片段:「${wanted.slice(0, 40)}…」。请从正文中复制原文片段(无需整段,取片段即可)。`)
|
|
2939
2466
|
}
|
|
2940
2467
|
localTarget = {
|
|
2941
2468
|
paragraph: paragraphs[idx]!,
|
|
@@ -2965,7 +2492,7 @@ export async function* rewriteChapterStream(
|
|
|
2965
2492
|
].filter(line => line !== '').join('\n')
|
|
2966
2493
|
|
|
2967
2494
|
const system = localTarget === undefined
|
|
2968
|
-
? rewriteSystemPrompt(project, chapter.targetChars || config.chapterChars)
|
|
2495
|
+
? rewriteSystemPrompt(project, chapter.targetChars || config.chapterChars, chapter.no)
|
|
2969
2496
|
: (() => {
|
|
2970
2497
|
// 局部修订:补齐合规红线/本书红线/反AI规则/角色卡,加「只改表达不改情节」约束
|
|
2971
2498
|
const bible = project.bible
|
|
@@ -3010,24 +2537,43 @@ export async function* rewriteChapterStream(
|
|
|
3010
2537
|
}
|
|
3011
2538
|
|
|
3012
2539
|
yield { frame: 'start' }
|
|
3013
|
-
const
|
|
2540
|
+
const primaryModel = request.model ?? config.model
|
|
2541
|
+
let rewritten = ''
|
|
3014
2542
|
let streamError: Error | undefined
|
|
3015
|
-
for
|
|
3016
|
-
|
|
3017
|
-
if (
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
.
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
.
|
|
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
|
+
}
|
|
3031
2577
|
if (streamError !== undefined) throw streamError
|
|
3032
2578
|
if (rewritten.length < 20) throw new Error('修订结果过短,可能失败,请重试')
|
|
3033
2579
|
|
|
@@ -3083,9 +2629,9 @@ export async function* polishChapterStream(
|
|
|
3083
2629
|
chapterNo: number,
|
|
3084
2630
|
): AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'drafted'; chars: number; draft: string }, void, unknown> {
|
|
3085
2631
|
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
3086
|
-
if (chapter === undefined) throw new
|
|
2632
|
+
if (chapter === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 不在计划中`)
|
|
3087
2633
|
const body = readChapterFile(outputDir, chapter)
|
|
3088
|
-
if (body === undefined) throw new
|
|
2634
|
+
if (body === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 的正文文件不存在`)
|
|
3089
2635
|
const messages: Message[] = [createUserMessage({
|
|
3090
2636
|
content: [{ type: 'text', text: body.replace(/^#\s+.*$/m, '').trim() }],
|
|
3091
2637
|
source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
|
|
@@ -3102,24 +2648,43 @@ export async function* polishChapterStream(
|
|
|
3102
2648
|
reasoningEffort: ReasoningEffortId('off'),
|
|
3103
2649
|
}
|
|
3104
2650
|
yield { frame: 'start' }
|
|
3105
|
-
const
|
|
2651
|
+
const primaryModel = request.model ?? config.model
|
|
2652
|
+
let newBody = ''
|
|
3106
2653
|
let streamError: Error | undefined
|
|
3107
|
-
for
|
|
3108
|
-
|
|
3109
|
-
if (
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
.
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
.
|
|
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
|
+
}
|
|
3123
2688
|
if (streamError !== undefined) throw streamError
|
|
3124
2689
|
if (newBody.length < 100) throw new Error('润色结果过短,可能失败,请重试')
|
|
3125
2690
|
|
|
@@ -3130,6 +2695,12 @@ export async function* polishChapterStream(
|
|
|
3130
2695
|
yield { frame: 'drafted', chars: newBody.length, draft: newBody }
|
|
3131
2696
|
}
|
|
3132
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
|
+
|
|
3133
2704
|
/** Generate one chapter (streaming). Yields progress frames; persists when done. */
|
|
3134
2705
|
export async function* generateChapterStream(
|
|
3135
2706
|
ctx: Context,
|
|
@@ -3139,7 +2710,7 @@ export async function* generateChapterStream(
|
|
|
3139
2710
|
chapterNo: number,
|
|
3140
2711
|
): AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'done'; file: string; chars: number; warn?: string }, void, unknown> {
|
|
3141
2712
|
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
3142
|
-
if (chapter === undefined) throw new
|
|
2713
|
+
if (chapter === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 不在计划中`)
|
|
3143
2714
|
// Note: the route layer owns the 'generating' status + concurrency guard;
|
|
3144
2715
|
// this function must not refuse when status is 'generating' (the route sets
|
|
3145
2716
|
// it before calling us).
|
|
@@ -3199,9 +2770,22 @@ export async function* generateChapterStream(
|
|
|
3199
2770
|
.filter(f => f.status === 'planned' && f.targetChapter !== undefined && f.targetChapter > 0)
|
|
3200
2771
|
.filter(f => Math.abs((f.targetChapter as number) - chapterNo) <= 12)
|
|
3201
2772
|
.map(f => `- ${f.description.slice(0, 120)}${f.targetChapter !== undefined ? `(计划回收于第 ${f.targetChapter} 章)` : ''}`)
|
|
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}`)
|
|
3202
2784
|
const user = [
|
|
3203
2785
|
`现在写第 ${chapter.no} 章,标题《${chapter.title}》。`,
|
|
3204
2786
|
`本章剧情要点:${chapter.beats}`,
|
|
2787
|
+
contractBlock,
|
|
2788
|
+
knowledgeBlock,
|
|
3205
2789
|
'',
|
|
3206
2790
|
foreshadowHints.length > 0
|
|
3207
2791
|
? `本章附近需顺势埋下以下暗线(自然带过,不喧宾夺主,1-2 句即可,但细节要可辨识、与描述吻合):\n${foreshadowHints.join('\n')}`
|
|
@@ -3228,9 +2812,9 @@ export async function* generateChapterStream(
|
|
|
3228
2812
|
})]
|
|
3229
2813
|
const request: GenerateOptions = {
|
|
3230
2814
|
provider: config.provider,
|
|
3231
|
-
model: config.model,
|
|
2815
|
+
model: config.generateModel || config.model,
|
|
3232
2816
|
messages,
|
|
3233
|
-
system: writeSystemPrompt(project, chapter.targetChars || config.chapterChars),
|
|
2817
|
+
system: writeSystemPrompt(project, chapter.targetChars || config.chapterChars, undefined, chapter.no),
|
|
3234
2818
|
// Full-chapter output: budget generously (4000 chars ≈ 8-12k tokens,
|
|
3235
2819
|
// plus the model's reasoning channel).
|
|
3236
2820
|
maxTokens: Math.max(config.maxTokens, 20000),
|
|
@@ -3239,50 +2823,99 @@ export async function* generateChapterStream(
|
|
|
3239
2823
|
|
|
3240
2824
|
yield { frame: 'start' }
|
|
3241
2825
|
|
|
3242
|
-
const
|
|
2826
|
+
const primaryModel = request.model ?? config.model
|
|
2827
|
+
let body = ''
|
|
3243
2828
|
let streamError: Error | undefined
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
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
|
+
}
|
|
3248
2848
|
}
|
|
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 后重试')
|
|
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
|
|
3249
2868
|
}
|
|
3250
|
-
const finish = assembler.finish
|
|
3251
|
-
if (finish.kind === 'error' || finish.kind === 'aborted') {
|
|
3252
|
-
streamError = new Error(`生成失败(${finish.kind}): ${finish.failure.message}`)
|
|
3253
|
-
} else if (finish.kind === 'max-tokens') {
|
|
3254
|
-
streamError = new Error('达到 maxTokens 上限,正文可能不完整,请增大 maxTokens 后重试')
|
|
3255
|
-
}
|
|
3256
|
-
const body = assembler
|
|
3257
|
-
.blocks()
|
|
3258
|
-
.filter((block): block is Extract<StreamChunk, { type: 'block-end' }>['block'] & { type: 'text' } => block.type === 'text')
|
|
3259
|
-
.map(block => block.text)
|
|
3260
|
-
.join('\n')
|
|
3261
|
-
.trim()
|
|
3262
2869
|
if (streamError !== undefined) throw streamError
|
|
3263
2870
|
if (body.length < 100) throw new Error('生成内容过短,可能失败,请重试')
|
|
3264
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)
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
// 覆盖前留存历史版本:重新生成把好稿改坏时可以回滚(首次生成无旧稿,不产生空快照)。
|
|
2898
|
+
if (chapter.file !== undefined && chapter.file !== '') createSnapshot(outputDir, chapter, '重新生成前')
|
|
3265
2899
|
// Write the chapter file.
|
|
3266
2900
|
const fileName = chapterFileName(chapter)
|
|
3267
2901
|
mkdirSync(outputDir, { recursive: true })
|
|
3268
2902
|
writeFileSync(join(outputDir, fileName), `# 第${chapter.no}章 ${chapter.title}\n\n${body}\n`, 'utf8')
|
|
3269
2903
|
|
|
3270
2904
|
chapter.status = 'written'
|
|
3271
|
-
chapter.chars =
|
|
2905
|
+
chapter.chars = hanzi
|
|
3272
2906
|
chapter.file = fileName
|
|
3273
2907
|
chapter.error = undefined
|
|
3274
2908
|
project.updatedAt = new Date().toISOString()
|
|
3275
2909
|
saveProject(outputDir, project)
|
|
3276
2910
|
|
|
3277
|
-
const target = chapter.targetChars > 0 ? chapter.targetChars : config.chapterChars
|
|
3278
2911
|
const warn = target > 0
|
|
3279
|
-
? (
|
|
3280
|
-
? `第${chapter.no}章实际 ${
|
|
3281
|
-
:
|
|
3282
|
-
? `第${chapter.no}章实际 ${
|
|
2912
|
+
? (hanzi < target * 0.85
|
|
2913
|
+
? `第${chapter.no}章实际 ${hanzi} 汉字,少于目标 ${target} 汉字`
|
|
2914
|
+
: hanzi > target * 1.25
|
|
2915
|
+
? `第${chapter.no}章实际 ${hanzi} 汉字,多于目标 ${target} 汉字`
|
|
3283
2916
|
: undefined)
|
|
3284
2917
|
: undefined
|
|
3285
|
-
yield { frame: 'done', file: fileName, chars:
|
|
2918
|
+
yield { frame: 'done', file: fileName, chars: hanzi, warn }
|
|
3286
2919
|
}
|
|
3287
2920
|
|
|
3288
2921
|
/** Generate a chapter summary (narrative memory). */
|
|
@@ -3294,9 +2927,9 @@ export async function summarizeChapter(
|
|
|
3294
2927
|
chapterNo: number,
|
|
3295
2928
|
): Promise<string> {
|
|
3296
2929
|
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
3297
|
-
if (chapter === undefined) throw new
|
|
2930
|
+
if (chapter === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 不在计划中`)
|
|
3298
2931
|
const body = readChapterFile(outputDir, chapter)
|
|
3299
|
-
if (body === undefined) throw new
|
|
2932
|
+
if (body === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 的正文文件不存在`)
|
|
3300
2933
|
const system = [
|
|
3301
2934
|
'你是一位网文编辑。请为下面一章写一段 120-200 字的摘要,供后续章节写作时保持连贯性。',
|
|
3302
2935
|
'摘要必须包含:本章发生的关键事件、主角状态变化(境界/资源/伤势/心境)、新增的伏笔或线索、角色关系变化。',
|
|
@@ -3309,484 +2942,6 @@ export async function summarizeChapter(
|
|
|
3309
2942
|
saveProject(outputDir, project)
|
|
3310
2943
|
return chapter.summary
|
|
3311
2944
|
}
|
|
3312
|
-
|
|
3313
|
-
/** 把分镜产出写入项目持久化(按章 upsert)。重新生成上游时级联清掉下游旧产物:
|
|
3314
|
-
* 新骨架 → 清分镜表+提示词;新分镜表 → 清提示词;新提示词不动上游。 */
|
|
3315
|
-
function saveChapterStoryboard(project: ProjectState, outputDir: string, entry: ChapterStoryboard): void {
|
|
3316
|
-
if (project.storyboards === undefined) project.storyboards = []
|
|
3317
|
-
const idx = project.storyboards.findIndex(e => e.chapterNo === entry.chapterNo)
|
|
3318
|
-
const prev = idx === -1 ? undefined : project.storyboards[idx]
|
|
3319
|
-
const next: ChapterStoryboard = { ...(prev ?? {}), ...entry }
|
|
3320
|
-
if (entry.skeleton !== undefined) {
|
|
3321
|
-
next.table = undefined
|
|
3322
|
-
next.prompts = undefined
|
|
3323
|
-
}
|
|
3324
|
-
if (entry.table !== undefined) {
|
|
3325
|
-
next.prompts = undefined
|
|
3326
|
-
}
|
|
3327
|
-
if (idx === -1) project.storyboards.push(next)
|
|
3328
|
-
else project.storyboards[idx] = next
|
|
3329
|
-
project.updatedAt = new Date().toISOString()
|
|
3330
|
-
saveProject(outputDir, project)
|
|
3331
|
-
}
|
|
3332
|
-
|
|
3333
|
-
/** 确定性兜底:正文中出现的角色库角色名(LLM 漏填 characters 时使用)。 */
|
|
3334
|
-
function guessCharactersFromRoles(project: ProjectState, body: string, limit = 12): string[] {
|
|
3335
|
-
const out: string[] = []
|
|
3336
|
-
for (const r of project.roles ?? []) {
|
|
3337
|
-
if (out.length >= limit) break
|
|
3338
|
-
if (r.name !== '' && !out.includes(r.name) && body.includes(r.name)) out.push(r.name)
|
|
3339
|
-
}
|
|
3340
|
-
return out
|
|
3341
|
-
}
|
|
3342
|
-
|
|
3343
|
-
/** 清洗 LLM 输出的角色名数组:去空 / 去重 / 限长 / 限字数。 */
|
|
3344
|
-
function sanitizeCharacters(raw: unknown, limit: number): string[] {
|
|
3345
|
-
if (!Array.isArray(raw)) return []
|
|
3346
|
-
const out: string[] = []
|
|
3347
|
-
for (const v of raw) {
|
|
3348
|
-
if (typeof v !== 'string') continue
|
|
3349
|
-
const name = v.trim().slice(0, 20)
|
|
3350
|
-
if (name === '' || out.includes(name)) continue
|
|
3351
|
-
out.push(name)
|
|
3352
|
-
if (out.length >= limit) break
|
|
3353
|
-
}
|
|
3354
|
-
return out
|
|
3355
|
-
}
|
|
3356
|
-
|
|
3357
|
-
/** 漫剧卡绑定索引:按分镜称谓(正文确切名)解析漫剧卡(卡名/来源名精确优先,包含兜底)。 */
|
|
3358
|
-
interface MangaRoleBindingIndex {
|
|
3359
|
-
byId: Map<string, MangaRoleCard>
|
|
3360
|
-
resolve: (name: string) => MangaRoleCard | undefined
|
|
3361
|
-
}
|
|
3362
|
-
function buildMangaRoleBindings(project: ProjectState): MangaRoleBindingIndex {
|
|
3363
|
-
const cards = project.mangaRoles ?? []
|
|
3364
|
-
const byName = new Map<string, MangaRoleCard>()
|
|
3365
|
-
for (const c of cards) {
|
|
3366
|
-
if (c.name !== '') byName.set(c.name, c)
|
|
3367
|
-
if (c.sourceRoleName !== undefined && c.sourceRoleName !== '') byName.set(c.sourceRoleName, c)
|
|
3368
|
-
}
|
|
3369
|
-
const byId = new Map<string, MangaRoleCard>(cards.map(c => [c.id, c]))
|
|
3370
|
-
return {
|
|
3371
|
-
byId,
|
|
3372
|
-
resolve: (name: string): MangaRoleCard | undefined => {
|
|
3373
|
-
const exact = byName.get(name)
|
|
3374
|
-
if (exact !== undefined) return exact
|
|
3375
|
-
for (const c of cards) {
|
|
3376
|
-
if (c.name !== '' && (c.name.includes(name) || name.includes(c.name))) return c
|
|
3377
|
-
if (c.sourceRoleName !== undefined && c.sourceRoleName !== '' && (c.sourceRoleName.includes(name) || name.includes(c.sourceRoleName))) return c
|
|
3378
|
-
}
|
|
3379
|
-
return undefined
|
|
3380
|
-
},
|
|
3381
|
-
}
|
|
3382
|
-
}
|
|
3383
|
-
|
|
3384
|
-
/**
|
|
3385
|
-
* 分镜·导演级:剧情骨架 → 分镜表(镜头级)。
|
|
3386
|
-
* 只做画面层:景别/机位运镜/时长/画面/台词/音效/光效 + 状态连续;禁止改剧情(骨架只读)。
|
|
3387
|
-
*/
|
|
3388
|
-
export async function generateStoryboardTable(
|
|
3389
|
-
ctx: Context,
|
|
3390
|
-
config: NovelConfig,
|
|
3391
|
-
project: ProjectState,
|
|
3392
|
-
outputDir: string,
|
|
3393
|
-
chapterNo: number,
|
|
3394
|
-
skeleton: StoryboardSkeleton,
|
|
3395
|
-
styleId?: string,
|
|
3396
|
-
filterId?: string,
|
|
3397
|
-
): Promise<StoryboardTable> {
|
|
3398
|
-
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
3399
|
-
if (chapter === undefined) throw new Error(`章节 ${chapterNo} 不在计划中`)
|
|
3400
|
-
const body = readChapterFile(outputDir, chapter)
|
|
3401
|
-
if (body === undefined) throw new Error(`章节 ${chapterNo} 的正文文件不存在`)
|
|
3402
|
-
if ((skeleton.beats ?? []).length === 0) throw new Error('骨架为空,请先生成剧情骨架')
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
// 出场角色锚点:正文中出现过的角色名 + 身份(简短)。
|
|
3406
|
-
const roles = (project.roles ?? [])
|
|
3407
|
-
.filter(r => body.includes(r.name))
|
|
3408
|
-
.slice(0, 8)
|
|
3409
|
-
.map(r => `${r.name}(${r.roleLabel === 'protagonist' ? '主角' : r.roleLabel === 'antagonist' ? '反派' : r.roleLabel === 'female_lead' ? '女主' : '配角'}):${r.identity ?? ''}`)
|
|
3410
|
-
// 场景卡:仅列名称与定位(正文命中优先,至多 6 个)。
|
|
3411
|
-
// 匹配策略:完整场景名 或 短名("·"前的区域名),避免"八折区·货架过道(清晨)"这类长名匹配失败
|
|
3412
|
-
const sceneShortName = (n: string): string => n.split('·')[0].split('(')[0].split('(')[0].trim()
|
|
3413
|
-
const usedScenes = (project.scenes ?? []).filter(s => body.includes(s.name) || body.includes(sceneShortName(s.name))).slice(0, 6)
|
|
3414
|
-
const scenes = usedScenes.map(s => `${s.name}:${s.summary ?? ''}`)
|
|
3415
|
-
// 漫剧定妆卡:本章正文命中漫剧卡(卡名或来源名),注入定妆锚点供画面遵守。
|
|
3416
|
-
const bindings = buildMangaRoleBindings(project)
|
|
3417
|
-
const mangaCardsInBody = bindings.byId.size > 0
|
|
3418
|
-
? [...bindings.byId.values()].filter(c => body.includes(c.name) || (c.sourceRoleName !== undefined && body.includes(c.sourceRoleName))).slice(0, 8)
|
|
3419
|
-
: []
|
|
3420
|
-
const mangaLines = mangaCardsInBody.map(c => {
|
|
3421
|
-
const anchor = c.imagePrompt !== undefined ? c.imagePrompt.zh.slice(0, 90) : (c.appearance !== '' ? c.appearance : '')
|
|
3422
|
-
const ref = c.imageUrl !== undefined ? ';参考图:有' : (c.gallery ?? []).some(g => g.label.includes('立绘')) ? ';参考图:有(立绘)' : ''
|
|
3423
|
-
return `${c.name}(${c.identity ?? ''}):定妆${anchor !== '' ? '「' + anchor + '」' : '(锚点未生成)'}${ref}`
|
|
3424
|
-
})
|
|
3425
|
-
const rules = (project.visualRules ?? []).map(r => '- ' + r)
|
|
3426
|
-
|
|
3427
|
-
const baseStyle = styleId !== undefined ? findStyle(styleId) : undefined
|
|
3428
|
-
const filterStyle = filterId !== undefined ? findStyle(filterId) : undefined
|
|
3429
|
-
const styleLines: string[] = []
|
|
3430
|
-
if (baseStyle !== undefined) styleLines.push('- 基底风格「' + baseStyle.name + '」:' + baseStyle.keywords)
|
|
3431
|
-
if (filterStyle !== undefined) styleLines.push('- 叠加滤镜「' + filterStyle.name + '」:' + filterStyle.keywords)
|
|
3432
|
-
if (styleLines.length === 0) styleLines.push('- (未选择风格,默认 3D 动漫超精细建模质感)')
|
|
3433
|
-
|
|
3434
|
-
const system = [
|
|
3435
|
-
'你是一位从业 10 年的电影导演兼分镜师,专长网文改编影视化。',
|
|
3436
|
-
'任务:把「剧情骨架」的每一个节拍展开为 1-3 个电影镜头,输出分镜表——只做画面层,禁止新增或改变剧情(骨架是只读输入)。',
|
|
3437
|
-
'输出合法 JSON 对象:{"shots": [{"beatId": "骨架节拍id", "shot": "景别", "camera": "机位与运镜", "composition": "构图", "duration": 秒数, "visual": "画面内容", "line": "台词/旁白", "sound": "音效", "light": "光效", "prevState": "承接上一镜头结尾状态", "nextState": "本镜头结束状态", "jimengCamera": "即梦运镜自然语言描述", "characters": ["出镜角色名"]}]}',
|
|
3438
|
-
'景别取值(只能从这些词中选一):大远景/远景/全景/中景/中近景/近景/特写/大特写。',
|
|
3439
|
-
'运镜取值(可组合,用加号连接):固定机位/推近/拉远/左摇/右摇/左横移/右横移/跟随/升镜/降镜/环绕/手持晃动/低机位仰拍/高机位俯拍/过肩镜头。',
|
|
3440
|
-
'光效取值(可组合,用加号连接):顺光/侧光/逆光/顶光/伦勃朗光/霓虹光/硬光/柔光/氛围光/高反差。',
|
|
3441
|
-
'构图取值(可选,只能从这些词中选一):三分法/中心对称/引导线/前景遮挡/低机位/俯拍/对称构图。',
|
|
3442
|
-
'硬性要求:',
|
|
3443
|
-
'【视觉风格】(必须内嵌进 visual/light 的画面措辞与光效描述):',
|
|
3444
|
-
...styleLines,
|
|
3445
|
-
'1. 按骨架节拍顺序输出镜头,每个节拍至少 1 个镜头,总镜头数 8-15。',
|
|
3446
|
-
'2. 镜头间连续:下一镜头的 prevState 必须与上一镜头的 nextState 一致(人物位置/动作/情绪/服装),禁止瞬移、服装消失、情绪跳变。',
|
|
3447
|
-
'3. visual 必须写明:主体位置(左/中/右/前景/背景)+ 角色动作 + 表情 + 服装/标志物(标志物来自下方视觉规则与角色锚点,逐镜头保持)。',
|
|
3448
|
-
'4. 台词/音效/光效无则空字符串,不要编造。',
|
|
3449
|
-
'5. duration 只能取 5/6/7/8/10 五个值(即梦单条视频时长上限)。',
|
|
3450
|
-
'6. 所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束,不要输出其他文字。',
|
|
3451
|
-
'7. 每个镜头必须输出 characters:本镜头出镜角色名数组(1-4 个),从「出场角色锚点」或正文中选取,使用正文里的确切称谓(如「周野」「周野的律师」),禁止自造名/缩写;路人或群像可用身份词(如「围观群众」)。',
|
|
3452
|
-
'8. 人物外观/服装/标志物必须遵守「漫剧定妆卡」与「出场角色锚点」:同一角色逐镜头保持完全一致(服装、发色、标志物禁止更换);定妆卡未覆盖的路人可用通用描述。',
|
|
3453
|
-
'9. jimengCamera:即梦运镜自然语言描述,必须写具体起止(如「镜头从中景缓慢推进到近景」「镜头从左向右缓慢横移」),禁止只写「推近」「拉远」;静止镜头写「固定机位」。',
|
|
3454
|
-
'10. 单镜只承载一个连续动作,动作必须在5-10秒内可完成;禁止复杂打斗、多人群舞、快速切换场景。',
|
|
3455
|
-
'11. 画面禁止出现任何文字、字幕、水印、符号、UI界面、屏幕显示内容。',
|
|
3456
|
-
'12. 单镜出镜角色不超过2个主要角色,多人同框时必须明确谁是画面主体。',
|
|
3457
|
-
].join('\n')
|
|
3458
|
-
const user = [
|
|
3459
|
-
`章节《${chapter.title}》(第 ${chapter.no} 章)`,
|
|
3460
|
-
'==== 剧情骨架(只读,禁止改动) ====',
|
|
3461
|
-
`弧线:${skeleton.arc}`,
|
|
3462
|
-
skeleton.beats.map(b => `[${b.id}] [${b.function}] ${b.event}(情绪:${b.emotion}${b.cause !== undefined ? ';承接:' + b.cause : ''})`).join('\n'),
|
|
3463
|
-
roles.length > 0 ? '==== 出场角色锚点 ====\n' + roles.join('\n') : '',
|
|
3464
|
-
mangaLines.length > 0 ? '==== 漫剧定妆卡(角色外观以此为准,禁止改换服装/发色/标志物) ====\n' + mangaLines.join('\n') : '',
|
|
3465
|
-
scenes.length > 0 ? '==== 相关场景 ====\n' + scenes.join('\n') : '',
|
|
3466
|
-
rules.length > 0 ? '==== 本书视觉规则(必须内嵌进 visual) ====\n' + rules.join('\n') : '',
|
|
3467
|
-
'==== 章节正文(画面细节以此为准) ====',
|
|
3468
|
-
body.replace(/^#\s+.*$/m, '').trim().slice(0, 3000),
|
|
3469
|
-
].filter(s => s !== '').join('\n\n')
|
|
3470
|
-
const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 16000), reasoning: config.analysisReasoning ?? 'low' })
|
|
3471
|
-
const raw = parseJsonObject<{ shots?: unknown }>(text)
|
|
3472
|
-
const beatIds = new Set(skeleton.beats.map(b => b.id))
|
|
3473
|
-
const shots: StoryboardShot[] = Array.isArray(raw.shots)
|
|
3474
|
-
? raw.shots
|
|
3475
|
-
.filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
|
|
3476
|
-
.map((entry, i) => ({
|
|
3477
|
-
id: 's' + (i + 1),
|
|
3478
|
-
beatId: beatIds.has(entry.beatId as string) ? entry.beatId as string : skeleton.beats[0]!.id,
|
|
3479
|
-
shot: normalizeShotSize(entry.shot as string | undefined),
|
|
3480
|
-
camera: normalizeCameras(entry.camera as string | undefined),
|
|
3481
|
-
composition: normalizeComposition(entry.composition as string | undefined),
|
|
3482
|
-
duration: (() => { const d = typeof entry.duration === 'number' ? Math.round(entry.duration) : 0; return [5,6,7,8,10].includes(d) ? d : 6; })(),
|
|
3483
|
-
visual: typeof entry.visual === 'string' ? entry.visual.trim().slice(0, 300) : '',
|
|
3484
|
-
line: typeof entry.line === 'string' ? entry.line.trim().slice(0, 120) : '',
|
|
3485
|
-
sound: typeof entry.sound === 'string' ? entry.sound.trim().slice(0, 80) : '',
|
|
3486
|
-
light: normalizeLightings(entry.light as string | undefined),
|
|
3487
|
-
prevState: typeof entry.prevState === 'string' ? entry.prevState.trim().slice(0, 150) : '',
|
|
3488
|
-
nextState: typeof entry.nextState === 'string' ? entry.nextState.trim().slice(0, 150) : '',
|
|
3489
|
-
characters: sanitizeCharacters(entry.characters, 4),
|
|
3490
|
-
jimengCamera: typeof entry.jimengCamera === 'string' && entry.jimengCamera.trim() !== '' ? entry.jimengCamera.trim().slice(0, 80) : undefined,
|
|
3491
|
-
}))
|
|
3492
|
-
.filter(s => s.visual !== '')
|
|
3493
|
-
: []
|
|
3494
|
-
if (shots.length === 0) throw new Error('模型未输出有效镜头,请重试')
|
|
3495
|
-
// 覆盖自检:每个节拍至少 1 个镜头(缺失节拍告警)。
|
|
3496
|
-
const covered = new Set(shots.map(s => s.beatId))
|
|
3497
|
-
const missing = skeleton.beats.filter(b => !covered.has(b.id)).map(b => b.id)
|
|
3498
|
-
if (missing.length > 0) {
|
|
3499
|
-
console.warn(`[dsh-novel-forge] storyboard: beat ${missing.join(',')} 无镜头覆盖`)
|
|
3500
|
-
}
|
|
3501
|
-
// 表级 characters:各镜头去重汇总;LLM 漏标时用骨架/角色库兜底。
|
|
3502
|
-
const tableChars: string[] = []
|
|
3503
|
-
const tableRoleIds: string[] = []
|
|
3504
|
-
for (const s of shots) {
|
|
3505
|
-
// 定妆绑定:镜头 characters 称谓 → 漫剧卡 id(供出图/生视频时按卡取定妆图)。
|
|
3506
|
-
const ids: string[] = []
|
|
3507
|
-
for (const n of s.characters ?? []) {
|
|
3508
|
-
const card = bindings.resolve(n)
|
|
3509
|
-
if (card !== undefined && !ids.includes(card.id)) ids.push(card.id)
|
|
3510
|
-
}
|
|
3511
|
-
s.mangaRoleIds = ids.length > 0 ? ids : undefined
|
|
3512
|
-
for (const c of s.characters ?? []) {
|
|
3513
|
-
if (!tableChars.includes(c)) tableChars.push(c)
|
|
3514
|
-
if (tableChars.length >= 12) break
|
|
3515
|
-
}
|
|
3516
|
-
for (const id of ids) {
|
|
3517
|
-
if (!tableRoleIds.includes(id)) tableRoleIds.push(id)
|
|
3518
|
-
}
|
|
3519
|
-
}
|
|
3520
|
-
const fallbackChars = (skeleton.characters ?? guessCharactersFromRoles(project, body, 12)).slice(0, 12)
|
|
3521
|
-
const table: StoryboardTable = {
|
|
3522
|
-
chapterNo,
|
|
3523
|
-
shots,
|
|
3524
|
-
usedScenes: usedScenes.map(s => s.name),
|
|
3525
|
-
characters: tableChars.length > 0 ? tableChars : fallbackChars,
|
|
3526
|
-
mangaRoleIds: tableRoleIds.length > 0 ? tableRoleIds : undefined,
|
|
3527
|
-
}
|
|
3528
|
-
saveChapterStoryboard(project, outputDir, { chapterNo, table, updatedAt: new Date().toISOString() })
|
|
3529
|
-
return table
|
|
3530
|
-
}
|
|
3531
|
-
|
|
3532
|
-
/**
|
|
3533
|
-
* 提炼常驻道具(跨镜头需一致):从已写章节正文识别反复出现的关键道具 + 一行统一外观描述。
|
|
3534
|
-
* 生成分镜提示词前自动调用,若道具库为空则补齐;道具库存 project.props,注入提示词保持跨镜头一致。
|
|
3535
|
-
*/
|
|
3536
|
-
export async function extractProps(
|
|
3537
|
-
ctx: Context,
|
|
3538
|
-
config: NovelConfig,
|
|
3539
|
-
project: ProjectState,
|
|
3540
|
-
): Promise<Prop[]> {
|
|
3541
|
-
const written = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating')
|
|
3542
|
-
const excerpt = written.slice(0, 6).map(c => {
|
|
3543
|
-
const body = readChapterFile(config.outputDir, c)
|
|
3544
|
-
return body !== undefined ? '第' + c.no + '章《' + c.title + '》\n' + body.replace(/^#.*$/gm, '').trim().slice(0, 1500) : ''
|
|
3545
|
-
}).filter(s => s !== '').join('\n\n')
|
|
3546
|
-
if (excerpt.length < 200) return []
|
|
3547
|
-
const system = [
|
|
3548
|
-
'你是一位网文漫剧道具导演。从下面的已写章节正文中,识别「常驻道具」——跨多个镜头/场景反复出现、需要保持外观一致的关键道具(如外卖电动车、外卖箱、手机、名片;一次性出现的忽略)。',
|
|
3549
|
-
'每个道具给一行统一外观描述(可辨识、具体的颜色/材质/状态),供每个镜头遵循。',
|
|
3550
|
-
'输出必须是合法 JSON 数组:[{"name":"道具名","desc":"一行统一外观描述"}],3-8 个。',
|
|
3551
|
-
'重要:字符串内不含换行符;直接输出 JSON 结果本身。',
|
|
3552
|
-
].join('\n')
|
|
3553
|
-
const text = await complete(ctx, config, { system, user: excerpt, temperature: 0.2, maxTokens: Math.max(config.maxTokens, 8000), reasoning: config.analysisReasoning ?? 'low' })
|
|
3554
|
-
const raw = parseJsonArray<Record<string, unknown>>(text)
|
|
3555
|
-
const props: Prop[] = []
|
|
3556
|
-
for (const e of raw) {
|
|
3557
|
-
if (typeof e !== 'object' || e === null) continue
|
|
3558
|
-
const n = typeof e.name === 'string' ? e.name.trim().slice(0, 20) : ''
|
|
3559
|
-
const d = typeof e.desc === 'string' ? e.desc.trim().slice(0, 120) : ''
|
|
3560
|
-
if (n !== '' && d !== '' && !props.some(p => p.name === n)) props.push({ name: n, desc: d })
|
|
3561
|
-
}
|
|
3562
|
-
return props.slice(0, 8)
|
|
3563
|
-
}
|
|
3564
|
-
|
|
3565
|
-
/**
|
|
3566
|
-
* 分镜·提示词级:分镜表 → 即梦可粘贴视频提示词。
|
|
3567
|
-
* 每镜头一段:风格词块(基底+滤镜)+ 画面内容(角色动作/服装标志物)+ 机位运镜 + 光效。
|
|
3568
|
-
* 提示词聚焦画面与镜头(视频模型无音频,台词/音效不注入)。
|
|
3569
|
-
*/
|
|
3570
|
-
export async function generateStoryboardPrompts(
|
|
3571
|
-
ctx: Context,
|
|
3572
|
-
config: NovelConfig,
|
|
3573
|
-
project: ProjectState,
|
|
3574
|
-
outputDir: string,
|
|
3575
|
-
chapterNo: number,
|
|
3576
|
-
table: StoryboardTable,
|
|
3577
|
-
styleId?: string,
|
|
3578
|
-
filterId?: string,
|
|
3579
|
-
): Promise<StoryboardPrompt[]> {
|
|
3580
|
-
if ((table.shots ?? []).length === 0) throw new Error('分镜表为空,请先生成分镜表')
|
|
3581
|
-
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
3582
|
-
const baseStyle = styleId !== undefined ? findStyle(styleId) : undefined
|
|
3583
|
-
const filterStyle = filterId !== undefined ? findStyle(filterId) : undefined
|
|
3584
|
-
const stylePrefix = [
|
|
3585
|
-
baseStyle?.keywords,
|
|
3586
|
-
filterStyle?.keywords,
|
|
3587
|
-
].filter((v): v is string => v !== undefined && v !== '').join(',') || '3D动漫,超精细建模,电影光影'
|
|
3588
|
-
const rules = (project.visualRules ?? []).map(r => '- ' + r)
|
|
3589
|
-
const bindings = buildMangaRoleBindings(project)
|
|
3590
|
-
// 常驻道具:道具库为空时从已写章节自动提炼一次并回存,此后跨镜头统一注入
|
|
3591
|
-
let props = project.props ?? []
|
|
3592
|
-
if (props.length === 0) {
|
|
3593
|
-
try {
|
|
3594
|
-
props = await extractProps(ctx, config, project)
|
|
3595
|
-
if (props.length > 0) {
|
|
3596
|
-
project.props = props
|
|
3597
|
-
project.updatedAt = new Date().toISOString()
|
|
3598
|
-
saveProject(config.outputDir, project)
|
|
3599
|
-
}
|
|
3600
|
-
} catch { /* 提炼失败不阻塞生成 */ }
|
|
3601
|
-
}
|
|
3602
|
-
const propsBlock = (props.length > 0)
|
|
3603
|
-
? '常驻道具(每个镜头必须按此外观/状态呈现,保持跨镜头一致):\n' + props.map(p => `- ${p.name}:${p.desc}`).join('\n') : ''
|
|
3604
|
-
let accTime = 0
|
|
3605
|
-
const shotLines = table.shots.map(s => {
|
|
3606
|
-
const startSec = accTime
|
|
3607
|
-
const endSec = accTime + s.duration
|
|
3608
|
-
accTime = endSec
|
|
3609
|
-
const bound = (s.characters ?? []).map(n => bindings.resolve(n)).filter((c): c is MangaRoleCard => c !== undefined)
|
|
3610
|
-
const makeUp = bound.map(c => {
|
|
3611
|
-
const anchor = c.imagePrompt !== undefined ? c.imagePrompt.zh.slice(0, 60) : (c.appearance !== '' ? c.appearance : '')
|
|
3612
|
-
const ref = c.imageUrl !== undefined ? '(参考图)' : ''
|
|
3613
|
-
return c.name + ':' + anchor + ref
|
|
3614
|
-
}).join(';')
|
|
3615
|
-
return `[${s.id}] 时间戳 ${startSec}s-${endSec}s · 节拍${s.beatId} · ${sizeZh(s.shot)} · ${cameraZh(s.camera)} · 时长${s.duration}s
|
|
3616
|
-
出场:${s.characters !== undefined && s.characters.length > 0 ? s.characters.join('、') : '(未标注)'}
|
|
3617
|
-
定妆:${makeUp !== '' ? makeUp : '(未绑定漫剧卡)'}
|
|
3618
|
-
画面:${s.visual}
|
|
3619
|
-
台词:${s.line !== '' ? s.line : '(无)'}
|
|
3620
|
-
光效:${lightZh(s.light) !== '' ? lightZh(s.light) : '(无)'}
|
|
3621
|
-
运镜(即梦):${s.jimengCamera !== undefined ? s.jimengCamera : cameraZh(s.camera)}
|
|
3622
|
-
承接:${s.prevState} → ${s.nextState}`
|
|
3623
|
-
}).join('\n\n')
|
|
3624
|
-
const system = [
|
|
3625
|
-
'你是一位资深影视分镜提示词工程师,精通即梦 Seedance 2.5 视频生成模型的提示词写法与参数调优。',
|
|
3626
|
-
'任务:把分镜表的每个镜头写成「可直接粘贴到即梦」的视频提示词——精简、只留能生成画面的内容,不要混入给用户的说明/元信息(如模型版本、画幅、参考图是否上传)。',
|
|
3627
|
-
'每镜 text 按以下段序组织(内容精简):',
|
|
3628
|
-
'① 风格段:' + stylePrefix + '(一句,放最前)。',
|
|
3629
|
-
'② 场景段:写"场景:<地点名> — <环境/光线/氛围>"(如"场景:雨夜道路 — 暴雨积水、昏黄路灯、雨幕");场景名标清楚(供用户@对应场景图),不写@。场景名优先复用下方「可用场景名清单」里已有的名字(按镜头地点/氛围就近匹配),只有清单里确实没有该地点时才新建场景名;新建名也要简短、能同时指示地点与氛围。',
|
|
3630
|
-
'③ 人物段:主体写"@角色名"(智能角色,如"@林深")+ 位置/朝向/简短动作,不写外貌细节(角色一致性靠智能主体多角度),多角色明确谁是主体。',
|
|
3631
|
-
'④ 时间轴段:必须把单镜按动作拆成 2~3 段(每段 3~5s,如"0s-3s / 3s-7s"),每段写清画面+动作+运镜,不要整镜单段;有台词用『台词(声音层):"…"(音色:…)』;音效用 [SFX: …]。',
|
|
3632
|
-
'⑤ 结尾负面:不要字幕、不要水印、禁止变形 + 行为级禁止项(按镜头定)。',
|
|
3633
|
-
...(getGenreRules((project.mangaPlans ?? []).find(p => p.active)?.genre).length > 0
|
|
3634
|
-
? ['题材专用规则(本题材必须遵守):', ...getGenreRules((project.mangaPlans ?? []).find(p => p.active)?.genre).map(r => '- ' + r), '']
|
|
3635
|
-
: []),
|
|
3636
|
-
'题材无关硬性要求:',
|
|
3637
|
-
'1. 台词走声音层,不写进画面提示词当口型(避免即梦硬配口型);台词用『台词(声音层):"……"(音色:低沉平静)』单独标注在时间轴段,画面描述不重复人声。',
|
|
3638
|
-
'2. 音效用原生 [SFX: 具体音效] 标记(如 [SFX: 雨声,心跳声]),叠在对应时间轴段,禁空泛"震撼音效"。',
|
|
3639
|
-
'3. 时长预算:单镜 4~15s;text 完整覆盖该镜全部秒数,时间轴写起止。',
|
|
3640
|
-
'4. 服装/发色/标志物沿用镜头表、定妆卡、视觉规则,禁止自行更换(同一角色跨镜一致)。',
|
|
3641
|
-
'4b. 若用户给出「常驻道具」清单,每个涉及该道具的镜头必须按清单里那行统一外观/状态呈现(颜色/材质/特征完全一致),禁止换成别的样子;只出现台词不提道具的镜头可忽略。',
|
|
3642
|
-
'5. 每镜主体锚定:画面主体是[角色名],位于画面[左/中/右/前景],多角色明确谁是主体。',
|
|
3643
|
-
'6. 高动态/动作镜头:时间轴可细化到 0.5s~1s 微步进(运镜/动作/粒子/环境受力四要素)。',
|
|
3644
|
-
'7. text 结尾含"不要字幕、不要水印、禁止变形"。',
|
|
3645
|
-
'8. 输出合法 JSON 对象:{"prompts": [{"shotId":"s1","text":"...","camera":"...","motion":"low|medium|high","negativePrompt":"...","sceneName":"..."}]},所有镜头都有,顺序一致。',
|
|
3646
|
-
'9. 字符串内不得含换行符,JSON 必须在一段内完整结束。',
|
|
3647
|
-
].join('\n')
|
|
3648
|
-
const speechLines = [...bindings.byId.values()]
|
|
3649
|
-
.map(c => `${c.name}:${(c.speechStyle ?? '').trim()}`)
|
|
3650
|
-
.filter(x => !x.endsWith(':'))
|
|
3651
|
-
// 场景库:场景名须与场景库对齐(供用户按名字@场景图)
|
|
3652
|
-
const sceneLibLines = (project.scenes ?? []).map(s => {
|
|
3653
|
-
const moods = (s.moods ?? []).join('、')
|
|
3654
|
-
const extra = (s.moment !== undefined && s.moment !== '' ? '时间光态:' + s.moment : '')
|
|
3655
|
-
const tag = [moods, extra].filter(x => x !== '').join(';')
|
|
3656
|
-
return `- ${s.name}${s.summary !== '' ? ':' + s.summary : ''}${tag !== '' ? '(' + tag + ')' : ''}`
|
|
3657
|
-
})
|
|
3658
|
-
const sceneLibBlock = sceneLibLines.length > 0
|
|
3659
|
-
? '可用场景名清单(场景名只允许从这里选,或确无此地点时新建):\n' + sceneLibLines.join('\n') : ''
|
|
3660
|
-
const user = [
|
|
3661
|
-
`章节《${chapter?.title ?? ''}》(第 ${chapterNo} 章)`,
|
|
3662
|
-
rules.length > 0 ? '本书视觉规则(必须遵守):\n' + rules.join('\n') : '',
|
|
3663
|
-
propsBlock,
|
|
3664
|
-
sceneLibBlock,
|
|
3665
|
-
speechLines.length > 0 ? '出场角色说话方式(写台词音色参考时用):\n' + speechLines.join('\n') : '',
|
|
3666
|
-
'==== 分镜表 ====',
|
|
3667
|
-
shotLines,
|
|
3668
|
-
'只输出 JSON 对象。',
|
|
3669
|
-
].filter(x => x !== '').join('\n\n')
|
|
3670
|
-
const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 16000), reasoning: config.analysisReasoning ?? 'low' })
|
|
3671
|
-
const raw = parseJsonObject<{ prompts?: unknown }>(text)
|
|
3672
|
-
const shotIds = new Set(table.shots.map(s => s.id))
|
|
3673
|
-
// 实时绑定:用当前漫剧卡库解析每镜角色,不依赖分镜表预存的 mangaRoleIds(角色可能在分镜表之后才导入)
|
|
3674
|
-
const shotBinding = new Map<string, string[]>(table.shots.map(s => {
|
|
3675
|
-
const ids: string[] = []
|
|
3676
|
-
for (const n of s.characters ?? []) {
|
|
3677
|
-
const card = bindings.resolve(n)
|
|
3678
|
-
if (card !== undefined && !ids.includes(card.id)) ids.push(card.id)
|
|
3679
|
-
}
|
|
3680
|
-
return [s.id, ids]
|
|
3681
|
-
}))
|
|
3682
|
-
const prompts: StoryboardPrompt[] = Array.isArray(raw.prompts)
|
|
3683
|
-
? raw.prompts
|
|
3684
|
-
.filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
|
|
3685
|
-
.map(entry => {
|
|
3686
|
-
const shotId = shotIds.has(entry.shotId as string) ? entry.shotId as string : ''
|
|
3687
|
-
const ids = shotBinding.get(shotId) ?? []
|
|
3688
|
-
const motionVal = typeof entry.motion === 'string' ? entry.motion.trim().toLowerCase() : ''
|
|
3689
|
-
return {
|
|
3690
|
-
shotId,
|
|
3691
|
-
text: ensureStyleEmbedded(typeof entry.text === 'string' ? entry.text.trim().slice(0, 400) : '', stylePrefix, 'zh'),
|
|
3692
|
-
mangaRoleIds: ids.length > 0 ? ids : undefined,
|
|
3693
|
-
camera: typeof entry.camera === 'string' && entry.camera.trim() !== '' ? entry.camera.trim().slice(0, 100) : undefined,
|
|
3694
|
-
motion: motionVal in {'low':1,'medium':1,'high':1} ? motionVal as 'low'|'medium'|'high' : undefined,
|
|
3695
|
-
negativePrompt: typeof entry.negativePrompt === 'string' && entry.negativePrompt.trim() !== '' ? entry.negativePrompt.trim().slice(0, 200) : undefined,
|
|
3696
|
-
sceneName: typeof entry.sceneName === 'string' && entry.sceneName.trim() !== '' && entry.sceneName.trim() !== '(未标注)' ? entry.sceneName.trim().slice(0, 50) : undefined,
|
|
3697
|
-
}
|
|
3698
|
-
})
|
|
3699
|
-
.filter(x => x.shotId !== '' && x.text !== '')
|
|
3700
|
-
: []
|
|
3701
|
-
if (prompts.length === 0) throw new Error('模型未输出有效提示词,请重试')
|
|
3702
|
-
saveChapterStoryboard(project, outputDir, { chapterNo, prompts, updatedAt: new Date().toISOString() })
|
|
3703
|
-
// 落盘:逐镜即梦提示词 → 资产库(manga-assets/分镜脚本/第N章-标题-提示词.md)
|
|
3704
|
-
try {
|
|
3705
|
-
const chTitle = (project.chapters.find(c => c.no === chapterNo)?.title ?? '')
|
|
3706
|
-
const pLines: string[] = []
|
|
3707
|
-
for (const p of prompts) pLines.push('### 镜头 ' + p.shotId + '\n' + (p.text ?? ''))
|
|
3708
|
-
saveMangaChapterPrompts(outputDir, chapterNo, chTitle, pLines.join('\n\n'))
|
|
3709
|
-
} catch { /* 落盘失败不阻塞 */ }
|
|
3710
|
-
// 回写实时绑定到分镜表(直接操作 project 对象,不触发级联清除)
|
|
3711
|
-
const sbEntry = (project.storyboards ?? []).find(e => e.chapterNo === chapterNo)
|
|
3712
|
-
if (sbEntry !== undefined && sbEntry.table !== undefined) {
|
|
3713
|
-
for (const s of sbEntry.table.shots) {
|
|
3714
|
-
const ids = shotBinding.get(s.id) ?? []
|
|
3715
|
-
s.mangaRoleIds = ids.length > 0 ? ids : undefined
|
|
3716
|
-
}
|
|
3717
|
-
project.updatedAt = new Date().toISOString()
|
|
3718
|
-
saveProject(outputDir, project)
|
|
3719
|
-
}
|
|
3720
|
-
return prompts
|
|
3721
|
-
}
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
/**
|
|
3725
|
-
* 分镜·编剧级:单章 → 剧情骨架(节拍链)。
|
|
3726
|
-
* 只做故事层(事件/情绪/功能/因果),不做画面;导演级分镜在其上展开。
|
|
3727
|
-
*/
|
|
3728
|
-
export async function generateStoryboardSkeleton(
|
|
3729
|
-
ctx: Context,
|
|
3730
|
-
config: NovelConfig,
|
|
3731
|
-
project: ProjectState,
|
|
3732
|
-
outputDir: string,
|
|
3733
|
-
chapterNo: number,
|
|
3734
|
-
): Promise<StoryboardSkeleton> {
|
|
3735
|
-
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
3736
|
-
if (chapter === undefined) throw new Error(`章节 ${chapterNo} 不在计划中`)
|
|
3737
|
-
const body = readChapterFile(outputDir, chapter)
|
|
3738
|
-
if (body === undefined) throw new Error(`章节 ${chapterNo} 的正文文件不存在`)
|
|
3739
|
-
const system = [
|
|
3740
|
-
'你是一位从业 15 年的电影编剧,专长网文改编影视化,深谙三幕结构与节拍(beat)写作。',
|
|
3741
|
-
'任务:把这一章改编成影视化「剧情骨架」——只做故事层,不做画面。',
|
|
3742
|
-
'输出合法 JSON 对象:{"arc": "本章弧线一句话(起承转合,20-60字)", "beats": [{"event": "事件一句话(发生了什么)", "emotion": "人物情绪走向(情绪词数组)", "function": "铺垫|冲突|转折|高潮|收束|伏笔|人物塑造", "cause": "承接上一节拍的原因(可省略)"}], "characters": ["出镜角色名1", "出镜角色名2"]}',
|
|
3743
|
-
'情绪词取值(从这些词中选 1-3 个,用数组输出,按情绪发展顺序):平静/淡然/期待/好奇/警觉/压抑/隐忍/担忧/焦躁/不安/惊惧/愤怒/崩溃/决绝/痛心/释然/悲凉/得意/重生/麻木。',
|
|
3744
|
-
'功能取值(只能从这些词中选一):铺垫/冲突/转折/高潮/收束/伏笔/人物塑造。',
|
|
3745
|
-
'硬性要求:',
|
|
3746
|
-
'1. beats 数量 4-9 个,严格按时间顺序,因果链完整:前一个 beat 的结果是后一个 beat 的原因。',
|
|
3747
|
-
'2. 必须覆盖本章全部剧情要点与正文关键事件,遗漏关键事件视为失败。',
|
|
3748
|
-
'3. 只输出剧情骨架,禁止写画面描写、机位运镜、台词细节、音效(那是导演阶段的事)。',
|
|
3749
|
-
'4. 所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
3750
|
-
'5. 直接输出 JSON 本身,不要输出思考过程或其他文字。',
|
|
3751
|
-
'6. 额外输出 characters:本章真正出镜(说话/行动/被正面描写)的角色名数组,全部使用正文中的确切称谓(如「周野」「周野的律师」,不要改名或拼接),去重,3-10 个;路人或群像可用身份词(如「围观群众」)。',
|
|
3752
|
-
].join('\n')
|
|
3753
|
-
const user = [
|
|
3754
|
-
`章节《${chapter.title}》(第 ${chapter.no} 章)`,
|
|
3755
|
-
`剧情要点:${chapter.beats !== undefined && chapter.beats !== '' ? chapter.beats : '(未填写)'}`,
|
|
3756
|
-
'==================== 章节正文 ====================',
|
|
3757
|
-
body.replace(/^#\s+.*$/m, '').trim(),
|
|
3758
|
-
].join('\n')
|
|
3759
|
-
const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 4000), reasoning: config.analysisReasoning ?? 'low' })
|
|
3760
|
-
const raw = parseJsonObject<{ arc?: unknown; beats?: unknown; characters?: unknown }>(text)
|
|
3761
|
-
const arc = typeof raw.arc === 'string' ? raw.arc.trim().slice(0, 200) : ''
|
|
3762
|
-
const beats: StoryboardBeat[] = Array.isArray(raw.beats)
|
|
3763
|
-
? raw.beats
|
|
3764
|
-
.filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
|
|
3765
|
-
.map((entry, i) => ({
|
|
3766
|
-
id: 'b' + (i + 1),
|
|
3767
|
-
event: typeof entry.event === 'string' ? entry.event.trim().slice(0, 200) : '',
|
|
3768
|
-
emotion: Array.isArray(entry.emotion)
|
|
3769
|
-
? normalizeEmotions(entry.emotion.filter((x): x is string => typeof x === 'string').join('→'))
|
|
3770
|
-
: normalizeEmotions(typeof entry.emotion === 'string' ? entry.emotion : undefined),
|
|
3771
|
-
function: normalizeStoryFunction(typeof entry.function === 'string' ? entry.function : undefined),
|
|
3772
|
-
cause: typeof entry.cause === 'string' && entry.cause.trim() !== '' ? entry.cause.trim().slice(0, 150) : undefined,
|
|
3773
|
-
}))
|
|
3774
|
-
.filter(b => b.event !== '')
|
|
3775
|
-
: []
|
|
3776
|
-
if (beats.length === 0) throw new Error('模型未输出有效节拍链,请重试')
|
|
3777
|
-
// 出场角色:LLM 结构化输出优先,漏填时用角色库命中正文兜底。
|
|
3778
|
-
const skeletonChars = sanitizeCharacters(raw.characters, 12)
|
|
3779
|
-
const chars = skeletonChars.length > 0 ? skeletonChars : guessCharactersFromRoles(project, body, 12)
|
|
3780
|
-
const skeleton: StoryboardSkeleton = {
|
|
3781
|
-
chapterNo,
|
|
3782
|
-
arc: arc !== '' ? arc : '(本章弧线未生成)',
|
|
3783
|
-
beats,
|
|
3784
|
-
characters: chars.length > 0 ? chars : undefined,
|
|
3785
|
-
}
|
|
3786
|
-
saveChapterStoryboard(project, outputDir, { chapterNo, skeleton, updatedAt: new Date().toISOString() })
|
|
3787
|
-
return skeleton
|
|
3788
|
-
}
|
|
3789
|
-
|
|
3790
2945
|
/**
|
|
3791
2946
|
* 反向推大纲:从已写章节正文反推出全书总纲(分卷 + 章节要点 + 主线/人物弧线/伏笔清单)。
|
|
3792
2947
|
* 两阶段:分批提取章节事件摘要 → 汇总生成大纲。不修改章节/设定,只返回大纲文本。
|
|
@@ -3802,7 +2957,7 @@ export async function reverseOutlineFromChapters(
|
|
|
3802
2957
|
.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error')
|
|
3803
2958
|
.filter(c => readChapterFile(outputDir, c) !== undefined)
|
|
3804
2959
|
.sort((a, b) => a.no - b.no)
|
|
3805
|
-
if (written.length === 0) throw new
|
|
2960
|
+
if (written.length === 0) throw new NovelActionError('contract', '本书还没有已写章节,无法反推大纲')
|
|
3806
2961
|
|
|
3807
2962
|
// 阶段 1:分批提取章节事件摘要(每批 10 章,正文各取前 1000 字控制成本)。
|
|
3808
2963
|
const BATCH = 10
|
|
@@ -3863,7 +3018,7 @@ export async function analyzeAdaptation(
|
|
|
3863
3018
|
const user = [
|
|
3864
3019
|
'你是一位资深网文编辑兼改编策划。下面给你一部已完结/连载小说的若干章正文节选。请通读并输出该书的「原文设定卡片」与「可改范围矩阵」。',
|
|
3865
3020
|
'输出合法 JSON 对象:',
|
|
3866
|
-
'{"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"}]}',
|
|
3867
3022
|
'dimensions 至少覆盖以下维度(key/title):realm 大世界、cultivation 修为体系、protagonist 主角、goldenFinger 金手指、supporting 配角与势力人物名、faction 势力/组织、style 文风与叙事、ending 结局走向、timeline 时间线/编年、foreshadow 伏笔/暗线。',
|
|
3868
3023
|
'mutability 取值:locked=建议保留、big=可改影响大、small=可改影响小、free=可自由改、visual=仅视觉包装。',
|
|
3869
3024
|
'每个 dimension.current 必须忠于文本,能引用原文就用原文(尤其角色名/境界名/势力名/金手指名)。',
|
|
@@ -3911,13 +3066,24 @@ function normalizeAdaptationDimension(d: Record<string, unknown>): AdaptationDim
|
|
|
3911
3066
|
const risk = (['high', 'medium', 'low'] as const).includes(d.risk as AdaptationDimension['risk'])
|
|
3912
3067
|
? d.risk as AdaptationDimension['risk']
|
|
3913
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)
|
|
3914
3080
|
return {
|
|
3915
3081
|
key,
|
|
3916
3082
|
title,
|
|
3917
3083
|
mutability,
|
|
3918
3084
|
current: typeof d.current === 'string' ? d.current : '',
|
|
3919
3085
|
evidence: typeof d.evidence === 'string' ? d.evidence : undefined,
|
|
3920
|
-
candidates
|
|
3086
|
+
candidates,
|
|
3921
3087
|
impact: typeof d.impact === 'string' ? d.impact : '',
|
|
3922
3088
|
risk,
|
|
3923
3089
|
}
|
|
@@ -3957,7 +3123,7 @@ async function reverseOutlineFromAdaptationText(
|
|
|
3957
3123
|
export async function proposeAdaptation(
|
|
3958
3124
|
ctx: Context,
|
|
3959
3125
|
config: NovelConfig,
|
|
3960
|
-
|
|
3126
|
+
_text: string,
|
|
3961
3127
|
selections: Array<{ key: string; title: string; current: string; target: string; mutability: string }>,
|
|
3962
3128
|
dimensions?: AdaptationDimension[],
|
|
3963
3129
|
): Promise<AdaptProposeResponse> {
|
|
@@ -4150,7 +3316,7 @@ export async function materializeAdaptedBook(
|
|
|
4150
3316
|
): Promise<Omit<AdaptMaterializeResponse, 'book'>> {
|
|
4151
3317
|
const bookName = (args.bookName ?? '').trim().slice(0, 40) || '改编新书'
|
|
4152
3318
|
const outDir = (args.outputDir ?? '').trim()
|
|
4153
|
-
if (outDir === '') throw new
|
|
3319
|
+
if (outDir === '') throw new NovelActionError('contract', '未指定新书输出目录')
|
|
4154
3320
|
const mappings = args.proposal?.mappings ?? []
|
|
4155
3321
|
// 源文导入临时项目(复用角色/道藏/世界提炼:这些函数读取 config.outputDir 下的章节文件)。
|
|
4156
3322
|
const tmpDir = join(tmpdir(), 'dsh-novel-forge-adapt-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8))
|
|
@@ -4250,19 +3416,6 @@ function applyMappingsToRoles(roles: RoleRecord[], mappings: AdaptationMapping[]
|
|
|
4250
3416
|
relations: role.relations.map(t),
|
|
4251
3417
|
arc: role.arc.map(t),
|
|
4252
3418
|
knowledge: role.knowledge.map(t),
|
|
4253
|
-
imagePrompt: role.imagePrompt !== undefined
|
|
4254
|
-
? { ...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 }
|
|
4255
|
-
: undefined,
|
|
4256
|
-
expressions: role.expressions?.map(t),
|
|
4257
|
-
promptKit: role.promptKit !== undefined
|
|
4258
|
-
? {
|
|
4259
|
-
...role.promptKit,
|
|
4260
|
-
portrait: role.promptKit.portrait !== undefined ? { ...role.promptKit.portrait, zh: t(role.promptKit.portrait.zh), en: t(role.promptKit.portrait.en) } : undefined,
|
|
4261
|
-
sheet: role.promptKit.sheet !== undefined ? { ...role.promptKit.sheet, zh: t(role.promptKit.sheet.zh), en: t(role.promptKit.sheet.en) } : undefined,
|
|
4262
|
-
expressions: role.promptKit.expressions !== undefined ? role.promptKit.expressions.map(e => ({ ...e, zh: t(e.zh), en: t(e.en) })) : undefined,
|
|
4263
|
-
details: role.promptKit.details !== undefined ? { ...role.promptKit.details, zh: t(role.promptKit.details.zh), en: t(role.promptKit.details.en) } : undefined,
|
|
4264
|
-
}
|
|
4265
|
-
: undefined,
|
|
4266
3419
|
}))
|
|
4267
3420
|
}
|
|
4268
3421
|
|
|
@@ -4341,7 +3494,7 @@ export async function summarizeAndExtractFacts(
|
|
|
4341
3494
|
.map(v => v.trim().slice(0, 140))
|
|
4342
3495
|
: []
|
|
4343
3496
|
if (summary !== '') chapter.summary = summary
|
|
4344
|
-
|
|
3497
|
+
dedupAndAddFacts(project, chapterNo, factLines)
|
|
4345
3498
|
project.updatedAt = new Date().toISOString()
|
|
4346
3499
|
saveProject(outputDir, project)
|
|
4347
3500
|
return { summary, factCount: factLines.length }
|
|
@@ -4476,7 +3629,7 @@ async function auditBatch(
|
|
|
4476
3629
|
`正文节选(每章前 700 字):\n${chapterBlocks}`,
|
|
4477
3630
|
'只输出 JSON 数组。',
|
|
4478
3631
|
].filter(s => s !== '').join('\n\n')
|
|
4479
|
-
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: '质检查询' })
|
|
4480
3633
|
const parsed = parseJsonArray<Record<string, unknown>>(text)
|
|
4481
3634
|
const issues: AuditIssue[] = []
|
|
4482
3635
|
for (const entry of parsed) {
|
|
@@ -4521,7 +3674,7 @@ export async function auditBook(
|
|
|
4521
3674
|
return all.slice(0, 50)
|
|
4522
3675
|
}
|
|
4523
3676
|
|
|
4524
|
-
/**
|
|
3677
|
+
/** 小说简介:生成或按已写开头补全(面向读者的作品门面)。 */
|
|
4525
3678
|
export async function generateBlurb(
|
|
4526
3679
|
ctx: Context,
|
|
4527
3680
|
config: NovelConfig,
|
|
@@ -4716,7 +3869,7 @@ export function renderWorld(world: WorldState | undefined): string {
|
|
|
4716
3869
|
return sections.join('\n')
|
|
4717
3870
|
}
|
|
4718
3871
|
|
|
4719
|
-
/**
|
|
3872
|
+
/** 提炼大世界:从大纲 + 道藏生成结构化境界体系/区域/势力。 */
|
|
4720
3873
|
export async function extractWorld(
|
|
4721
3874
|
ctx: Context,
|
|
4722
3875
|
config: NovelConfig,
|
|
@@ -4910,7 +4063,7 @@ export async function suggestForeshadows(
|
|
|
4910
4063
|
`大纲:\n${project.outline}`,
|
|
4911
4064
|
`已规划章节数:${project.chapters.length}`,
|
|
4912
4065
|
].join('\n')
|
|
4913
|
-
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: '伏笔建议' })
|
|
4914
4067
|
const parsed = parseJsonArray<Record<string, unknown>>(text)
|
|
4915
4068
|
const existing = new Set(project.foreshadows.map(f => f.description))
|
|
4916
4069
|
const created: Foreshadow[] = []
|
|
@@ -4942,17 +4095,34 @@ export async function extractStyleAsset(
|
|
|
4942
4095
|
ctx: Context,
|
|
4943
4096
|
config: NovelConfig,
|
|
4944
4097
|
sampleText: string,
|
|
4945
|
-
): 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
|
+
}> {
|
|
4946
4103
|
const user = `请分析下面这段样本文本,提炼其叙事风格规则:\n\n${sampleText}`
|
|
4947
|
-
const text = await complete(ctx, config, { system: styleEngineSystemPrompt(), user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 12000) })
|
|
4948
|
-
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)
|
|
4949
4109
|
const strArray = (value: unknown): string[] =>
|
|
4950
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'
|
|
4951
4115
|
const result = {
|
|
4952
|
-
proseRules: strArray(raw.proseRules),
|
|
4953
|
-
dialogueRules: strArray(raw.dialogueRules),
|
|
4954
|
-
descriptionRules: strArray(raw.descriptionRules),
|
|
4955
|
-
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),
|
|
4956
4126
|
}
|
|
4957
4127
|
if (result.proseRules.length + result.dialogueRules.length + result.descriptionRules.length + result.boundaries.length === 0) {
|
|
4958
4128
|
throw new Error('写法提取失败:模型没有返回有效规则')
|
|
@@ -4960,6 +4130,404 @@ export async function extractStyleAsset(
|
|
|
4960
4130
|
return result
|
|
4961
4131
|
}
|
|
4962
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
|
+
|
|
4963
4531
|
// ------------------------------------------------------------------ export
|
|
4964
4532
|
|
|
4965
4533
|
/** Export the whole book as one txt/md file. */
|
|
@@ -4986,215 +4554,206 @@ export function exportBook(outputDir: string, project: ProjectState, format: 'tx
|
|
|
4986
4554
|
return { file, chars: content.length, chapters: done.length }
|
|
4987
4555
|
}
|
|
4988
4556
|
|
|
4989
|
-
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
}
|
|
4996
|
-
|
|
4997
|
-
/** 确保子目录存在,返回完整路径。 */
|
|
4998
|
-
function ensureAssetDir(outputDir: string, ...sub: string[]): string {
|
|
4999
|
-
const dir = join(mangaAssetsDir(outputDir), ...sub)
|
|
5000
|
-
mkdirSync(dir, { recursive: true })
|
|
5001
|
-
return dir
|
|
5002
|
-
}
|
|
5003
|
-
|
|
5004
|
-
/** 清理文件名中的非法字符。 */
|
|
5005
|
-
function safeAssetName(name: string): string {
|
|
5006
|
-
return name.replace(/[\\/:*?"<>|]/g, '_').slice(0, 40)
|
|
5007
|
-
}
|
|
5008
|
-
|
|
5009
|
-
/** 保存角色定妆图到资产库:manga-assets/角色/角色名/标签.png */
|
|
5010
|
-
export function saveMangaRoleImage(outputDir: string, roleName: string, label: string, dataUrl: string): string {
|
|
5011
|
-
const dir = ensureAssetDir(outputDir, '角色', safeAssetName(roleName))
|
|
5012
|
-
const safeLabel = safeAssetName(label !== '' ? label : '定妆图')
|
|
5013
|
-
const file = join(dir, safeLabel + '.png')
|
|
5014
|
-
const base64 = dataUrl.includes(',') ? dataUrl.slice(dataUrl.indexOf(',') + 1) : dataUrl
|
|
5015
|
-
writeFileSync(file, Buffer.from(base64, 'base64'))
|
|
5016
|
-
return file
|
|
5017
|
-
}
|
|
5018
|
-
|
|
5019
|
-
/** 保存角色提示词到资产库:manga-assets/角色/角色名/提示词.txt */
|
|
5020
|
-
export function saveMangaRolePrompt(outputDir: string, roleName: string, zh: string, en: string, negative?: string): string {
|
|
5021
|
-
const dir = ensureAssetDir(outputDir, '角色', safeAssetName(roleName))
|
|
5022
|
-
const file = join(dir, '提示词.txt')
|
|
5023
|
-
const content = [
|
|
5024
|
-
'【正面提示词(中文)】', zh, '',
|
|
5025
|
-
'【正面提示词(英文)】', en, '',
|
|
5026
|
-
negative !== undefined && negative !== '' ? ['【负面提示词】', negative, ''].join('\n') : '',
|
|
5027
|
-
].filter(x => x !== '').join('\n')
|
|
5028
|
-
writeFileSync(file, content, 'utf8')
|
|
5029
|
-
return file
|
|
5030
|
-
}
|
|
5031
|
-
|
|
5032
|
-
/** 保存场景底图到资产库:manga-assets/场景/场景名.png */
|
|
5033
|
-
export function saveMangaSceneImage(outputDir: string, sceneName: string, label: string, dataUrl: string): string {
|
|
5034
|
-
const dir = ensureAssetDir(outputDir, '场景')
|
|
5035
|
-
const file = join(dir, safeAssetName(sceneName + '-' + label) + '.png')
|
|
5036
|
-
const base64 = dataUrl.includes(',') ? dataUrl.slice(dataUrl.indexOf(',') + 1) : dataUrl
|
|
5037
|
-
writeFileSync(file, Buffer.from(base64, 'base64'))
|
|
5038
|
-
return file
|
|
5039
|
-
}
|
|
5040
|
-
|
|
5041
|
-
/** 保存分镜即梦脚本到资产库:manga-assets/分镜脚本/第N章-标题.md */
|
|
5042
|
-
export function saveMangaStoryboardScript(outputDir: string, chapterNo: number, title: string, markdown: string): string {
|
|
5043
|
-
const dir = ensureAssetDir(outputDir, '分镜脚本')
|
|
5044
|
-
const file = join(dir, '第' + chapterNo + '章-' + safeAssetName(title) + '.md')
|
|
5045
|
-
writeFileSync(file, markdown, 'utf8')
|
|
5046
|
-
return file
|
|
5047
|
-
}
|
|
5048
|
-
|
|
5049
|
-
/** 保存「即梦素材包」到资产库:manga-assets/素材包/第N章-标题·即梦素材包.md */
|
|
5050
|
-
export function saveMangaAssetPackage(outputDir: string, chapterNo: number, title: string, markdown: string): string {
|
|
5051
|
-
const dir = ensureAssetDir(outputDir, '素材包')
|
|
5052
|
-
const file = join(dir, '第' + chapterNo + '章-' + safeAssetName(title) + '·即梦素材包.md')
|
|
5053
|
-
writeFileSync(file, markdown, 'utf8')
|
|
5054
|
-
return file
|
|
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')
|
|
5055
4563
|
}
|
|
5056
4564
|
|
|
5057
|
-
/**
|
|
5058
|
-
|
|
5059
|
-
const
|
|
5060
|
-
|
|
5061
|
-
const
|
|
5062
|
-
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
|
|
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')
|
|
5067
4622
|
}
|
|
5068
4623
|
|
|
5069
|
-
/**
|
|
5070
|
-
|
|
5071
|
-
const
|
|
5072
|
-
const
|
|
5073
|
-
|
|
5074
|
-
|
|
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')
|
|
5075
4679
|
}
|
|
5076
4680
|
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
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')
|
|
5089
4725
|
}
|
|
5090
4726
|
|
|
5091
4727
|
/**
|
|
5092
|
-
*
|
|
5093
|
-
*
|
|
4728
|
+
* 按范围导出:整本正文 / 设定 / 规划 / 角色 / 质检记录 / 项目 JSON。
|
|
4729
|
+
*
|
|
4730
|
+
* @param outputDir 书目录。
|
|
4731
|
+
* @param project 项目状态。
|
|
4732
|
+
* @param scope 导出范围。
|
|
4733
|
+
* @param format txt / md / json(json 只对 `project` 范围有独立含义,其余按 md 落盘)。
|
|
4734
|
+
* @returns 文件名、字符数与章节数。
|
|
5094
4735
|
*/
|
|
5095
|
-
export
|
|
5096
|
-
ctx: Context,
|
|
5097
|
-
config: NovelConfig,
|
|
5098
|
-
project: ProjectState,
|
|
4736
|
+
export function exportScope(
|
|
5099
4737
|
outputDir: string,
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
):
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
|
|
5117
|
-
|
|
5118
|
-
|
|
5119
|
-
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
const candidates = await nominateMangaRoles(ctx, config, project, outputDir, chapterNo)
|
|
5123
|
-
|
|
5124
|
-
// 4. 为分镜中每个上镜角色建卡,绝不留白:
|
|
5125
|
-
// matched → 自动定型(imported,用正式角色名);ambiguous/not_in_library → 建「待确认」卡(pending_confirm,用代为名,供改名映射)。
|
|
5126
|
-
let imported = 0
|
|
5127
|
-
for (const cand of candidates) {
|
|
5128
|
-
if (cand.verdict === 'already_imported') continue
|
|
5129
|
-
// 已导入跳过(按名或来源名)
|
|
5130
|
-
if ((project.mangaRoles ?? []).some(c => c.name === cand.rawName || (cand.matchedRoleName !== undefined && c.sourceRoleName === cand.matchedRoleName))) continue
|
|
5131
|
-
const sug = cand.suggested
|
|
5132
|
-
const matched = cand.verdict === 'matched' && cand.matchedRoleName !== undefined && cand.matchedRoleName !== ''
|
|
5133
|
-
const baseName = (cand.matchedRoleName !== undefined && cand.matchedRoleName !== '' ? cand.matchedRoleName : sug.name) || cand.rawName
|
|
5134
|
-
const name = baseName.trim().slice(0, 30) || cand.rawName.trim().slice(0, 30)
|
|
5135
|
-
// 分级:matched 用脚本 tier(来自 nominate 的 calcTier);代称/存疑按功能定级,避免把反派/关键角色误降成 extra。
|
|
5136
|
-
const tier: MangaRoleCard['tier'] =
|
|
5137
|
-
cand.tier ?? (sug.coreFunction === 'functional' && cand.matchedRoleName === undefined ? 'extra' : 'supporting')
|
|
5138
|
-
const card: MangaRoleCard = {
|
|
5139
|
-
id: 'mr-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6),
|
|
5140
|
-
sourceRoleName: cand.matchedRoleName,
|
|
5141
|
-
name,
|
|
5142
|
-
identity: sug.identity,
|
|
5143
|
-
coreFunction: sug.coreFunction,
|
|
5144
|
-
protagonistRelation: sug.protagonistRelation,
|
|
5145
|
-
speechStyle: sug.speechStyle,
|
|
5146
|
-
traits: sug.traits,
|
|
5147
|
-
appearance: sug.appearance,
|
|
5148
|
-
keyScenes: sug.keyScenes,
|
|
5149
|
-
appearsInEpisodes: [chapterNo],
|
|
5150
|
-
status: matched ? 'imported' : 'pending_confirm',
|
|
5151
|
-
tier,
|
|
5152
|
-
createdAt: new Date().toISOString(),
|
|
5153
|
-
updatedAt: new Date().toISOString(),
|
|
5154
|
-
}
|
|
5155
|
-
if (project.mangaRoles === undefined) project.mangaRoles = []
|
|
5156
|
-
project.mangaRoles.push(card)
|
|
5157
|
-
if (matched) imported++
|
|
5158
|
-
}
|
|
5159
|
-
|
|
5160
|
-
// 5. 视频提示词:不在「一键生成」里自动产,留到「分镜·提示词」页生成(角色/场景定妆就绪后再产,@引用才完整)。
|
|
5161
|
-
|
|
5162
|
-
// 落盘:骨架 + 分镜表 → 资产库(manga-assets/分镜脚本/第N章-标题.md)
|
|
5163
|
-
try {
|
|
5164
|
-
if (sb.table !== undefined) {
|
|
5165
|
-
const chTitle = (project.chapters.find(c => c.no === chapterNo)?.title ?? '')
|
|
5166
|
-
const mdLines: string[] = []
|
|
5167
|
-
mdLines.push('# 第' + chapterNo + '章《' + chTitle + '》· 分镜')
|
|
5168
|
-
if (sb.skeleton !== undefined) {
|
|
5169
|
-
mdLines.push('', '## 剧情骨架')
|
|
5170
|
-
mdLines.push('弧线:' + (sb.skeleton.arc ?? ''))
|
|
5171
|
-
for (const b of sb.skeleton.beats ?? []) mdLines.push(`- [${b.id}] ${b.event}(情绪:${emotionZh(b.emotion)})`)
|
|
5172
|
-
}
|
|
5173
|
-
mdLines.push('', '## 分镜表')
|
|
5174
|
-
for (const s of sb.table.shots ?? []) mdLines.push(`- s${s.id} ${sizeZh(s.shot)} · ${cameraZh(s.camera)} · ${s.duration}s · ${s.visual}`)
|
|
5175
|
-
saveMangaStoryboardScript(outputDir, chapterNo, chTitle, mdLines.join('\n'))
|
|
5176
|
-
}
|
|
5177
|
-
} catch { /* 落盘失败不阻塞 */ }
|
|
5178
|
-
|
|
5179
|
-
project.updatedAt = new Date().toISOString()
|
|
5180
|
-
saveProject(outputDir, project)
|
|
5181
|
-
|
|
5182
|
-
const allCards = project.mangaRoles ?? []
|
|
5183
|
-
const needMakeup = allCards.filter(c => c.tier !== 'extra' && (c.appearsInEpisodes ?? []).includes(chapterNo)).length
|
|
5184
|
-
const extra = allCards.filter(c => c.tier === 'extra' && (c.appearsInEpisodes ?? []).includes(chapterNo)).length
|
|
5185
|
-
const pendingList = candidates.filter(c => c.verdict === 'ambiguous' || c.verdict === 'not_in_library')
|
|
5186
|
-
const pending = pendingList.length
|
|
5187
|
-
const pendingRoleNames = pendingList.map(c => c.rawName).slice(0, 10)
|
|
5188
|
-
|
|
5189
|
-
return {
|
|
5190
|
-
chapterNo,
|
|
5191
|
-
skeletonBeats: sb.skeleton?.beats?.length ?? 0,
|
|
5192
|
-
shotCount: sb.table?.shots?.length ?? 0,
|
|
5193
|
-
promptCount: sb.prompts?.length ?? 0,
|
|
5194
|
-
importedRoles: imported,
|
|
5195
|
-
needMakeupRoles: needMakeup,
|
|
5196
|
-
extraRoles: extra,
|
|
5197
|
-
pendingCandidates: pending,
|
|
5198
|
-
pendingRoleNames,
|
|
5199
|
-
}
|
|
5200
|
-
}
|
|
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
|
+
}
|