@waterwx/dsh-novel-forge 2.0.1-alpha → 2.1.0-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -2
- package/lib/client.js +3291 -752
- package/lib/index.js +3557 -237
- package/lib/types/assets.d.ts +16 -3
- package/lib/types/assistant.d.ts +4 -0
- package/lib/types/client/api.d.ts +89 -0
- package/lib/types/client/llmLive.d.ts +33 -0
- package/lib/types/client/locales.d.ts +3 -3
- package/lib/types/client/panel/AdaptModeView.d.ts +1 -1
- package/lib/types/client/panel/AssetsTab.d.ts +1 -1
- package/lib/types/client/panel/AssistantTab.d.ts +1 -1
- package/lib/types/client/panel/AuthorAssetsView.d.ts +1 -1
- package/lib/types/client/panel/AuthorHome.d.ts +4 -2
- package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
- package/lib/types/client/panel/BookshelfBar.d.ts +1 -1
- package/lib/types/client/panel/CreateBookView.d.ts +6 -2
- package/lib/types/client/panel/DirectorView.d.ts +7 -0
- package/lib/types/client/panel/FlowGuide.d.ts +1 -1
- package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +1 -1
- package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
- package/lib/types/client/panel/ImportModal.d.ts +1 -1
- package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
- package/lib/types/client/panel/LiveFeedLog.d.ts +5 -0
- package/lib/types/client/panel/MangaRoleLibrary.d.ts +1 -1
- package/lib/types/client/panel/MangaWorkspace.d.ts +1 -1
- package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
- package/lib/types/client/panel/NovelPanel.d.ts +2 -2
- package/lib/types/client/panel/ProgressConsole.d.ts +1 -1
- package/lib/types/client/panel/ProgressHomeView.d.ts +1 -1
- package/lib/types/client/panel/PropLibrary.d.ts +1 -1
- package/lib/types/client/panel/ReaderView.d.ts +1 -1
- package/lib/types/client/panel/RunPanel.d.ts +1 -1
- package/lib/types/client/panel/SceneLibrary.d.ts +1 -1
- package/lib/types/client/panel/SettingsView.d.ts +1 -1
- package/lib/types/client/panel/ShelfView.d.ts +1 -1
- package/lib/types/client/panel/StoryboardTab.d.ts +1 -1
- package/lib/types/client/panel/StyleCard.d.ts +1 -1
- package/lib/types/client/panel/StyleGallery.d.ts +1 -1
- package/lib/types/client/panel/WorldTab.d.ts +1 -1
- package/lib/types/engine.d.ts +29 -1
- package/lib/types/global-assets.d.ts +6 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/llm-live.d.ts +44 -0
- package/lib/types/market-radar-scan.d.ts +19 -0
- package/lib/types/market-radar-sources.d.ts +31 -0
- package/lib/types/prompting.d.ts +35 -0
- package/lib/types/protocol.d.ts +364 -8
- package/lib/types/run.d.ts +1 -1
- package/lib/types/src/ai-scan.d.ts +36 -0
- package/lib/types/src/assets.d.ts +37 -0
- package/lib/types/src/assistant.d.ts +45 -0
- package/lib/types/src/author-assets.d.ts +25 -0
- package/lib/types/src/bookshelf.d.ts +42 -0
- package/lib/types/src/client/api.d.ts +234 -0
- package/lib/types/src/client/docx.d.ts +15 -0
- package/lib/types/src/client/index.d.ts +14 -0
- package/lib/types/src/client/locales.d.ts +212 -0
- package/lib/types/src/client/mount.d.ts +9 -0
- package/lib/types/src/client/panel/AdaptModeView.d.ts +5 -0
- package/lib/types/src/client/panel/AssetsTab.d.ts +12 -0
- package/lib/types/src/client/panel/AssistantTab.d.ts +7 -0
- package/lib/types/src/client/panel/AuthorAssetsView.d.ts +4 -0
- package/lib/types/src/client/panel/AuthorHome.d.ts +32 -0
- package/lib/types/src/client/panel/BookshelfBar.d.ts +11 -0
- package/lib/types/src/client/panel/CreateBookView.d.ts +9 -0
- package/lib/types/src/client/panel/FlowGuide.d.ts +22 -0
- package/lib/types/src/client/panel/GlobalAssetLibraryView.d.ts +4 -0
- package/lib/types/src/client/panel/ImportModal.d.ts +7 -0
- package/lib/types/src/client/panel/MangaRoleLibrary.d.ts +20 -0
- package/lib/types/src/client/panel/MangaWorkspace.d.ts +12 -0
- package/lib/types/src/client/panel/ModelManager.d.ts +14 -0
- package/lib/types/src/client/panel/NovelPanel.d.ts +13 -0
- package/lib/types/src/client/panel/ProgressConsole.d.ts +17 -0
- package/lib/types/src/client/panel/ProgressHomeView.d.ts +5 -0
- package/lib/types/src/client/panel/PropLibrary.d.ts +8 -0
- package/lib/types/src/client/panel/ReaderView.d.ts +8 -0
- package/lib/types/src/client/panel/ReasoningSection.d.ts +10 -0
- package/lib/types/src/client/panel/RunPanel.d.ts +5 -0
- package/lib/types/src/client/panel/SceneLibrary.d.ts +16 -0
- package/lib/types/src/client/panel/SettingsView.d.ts +16 -0
- package/lib/types/src/client/panel/ShelfView.d.ts +15 -0
- package/lib/types/src/client/panel/StoryboardTab.d.ts +19 -0
- package/lib/types/src/client/panel/StyleCard.d.ts +8 -0
- package/lib/types/src/client/panel/StyleGallery.d.ts +1 -0
- package/lib/types/src/client/panel/WorldTab.d.ts +10 -0
- package/lib/types/src/client/panel/controller.d.ts +19 -0
- package/lib/types/src/client/panel/helpers.d.ts +18 -0
- package/lib/types/src/client/panel/views.d.ts +77 -0
- package/lib/types/src/client/sidebar-entry.d.ts +13 -0
- package/lib/types/src/client/text.d.ts +9 -0
- package/lib/types/src/comic-presets.d.ts +12 -0
- package/lib/types/src/docx.d.ts +19 -0
- package/lib/types/src/engine.d.ts +402 -0
- package/lib/types/src/index.d.ts +79 -0
- package/lib/types/src/manga-genre-rules.d.ts +22 -0
- package/lib/types/src/novel-context.d.ts +80 -0
- package/lib/types/src/prompt-utils.d.ts +35 -0
- package/lib/types/src/protocol.d.ts +2176 -0
- package/lib/types/src/review-policy.d.ts +97 -0
- package/lib/types/src/routes.d.ts +29 -0
- package/lib/types/src/run.d.ts +32 -0
- package/lib/types/src/shot-language.d.ts +46 -0
- package/lib/types/src/story-beat-language.d.ts +35 -0
- package/lib/types/src/style-library.d.ts +33 -0
- package/package.json +1 -1
- package/src/assets.ts +1774 -879
- package/src/assistant.ts +154 -9
- package/src/client/api.ts +105 -6
- package/src/client/llmLive.ts +101 -0
- package/src/client/locales.ts +4 -4
- package/src/client/panel/AdaptModeView.tsx +18 -3
- package/src/client/panel/AssistantTab.tsx +41 -3
- package/src/client/panel/AuthorHome.tsx +51 -20
- package/src/client/panel/BookAnalysisView.tsx +60 -0
- package/src/client/panel/CreateBookView.tsx +22 -3
- package/src/client/panel/DirectorView.tsx +200 -0
- package/src/client/panel/IdeaInspirationView.tsx +71 -0
- package/src/client/panel/KnowledgeBaseView.tsx +73 -0
- package/src/client/panel/LiveFeedLog.tsx +76 -0
- package/src/client/panel/MarketRadarView.tsx +374 -0
- package/src/client/panel/NovelPanel.tsx +117 -16
- package/src/client/panel/ProgressConsole.tsx +5 -6
- package/src/client/panel/SettingsView.tsx +3 -0
- package/src/client/panel/panel.module.css +2 -3
- package/src/engine.ts +459 -23
- package/src/global-assets.ts +59 -0
- package/src/index.ts +17 -1
- package/src/llm-live.ts +68 -0
- package/src/market-radar-scan.ts +41 -0
- package/src/market-radar-sources.ts +173 -0
- package/src/prompting.ts +159 -0
- package/src/protocol.ts +340 -8
- package/src/routes.ts +579 -59
- package/src/run.ts +15 -8
- package/skills/chapter-batch/SKILL.md +0 -81
package/src/engine.ts
CHANGED
|
@@ -31,11 +31,13 @@ import { join, basename, extname, dirname } from 'node:path'
|
|
|
31
31
|
import { homedir, 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'
|
|
34
|
+
import { BUILTIN_GENRE_LIBRARY, BUILTIN_PROGRESSION_MODES, emptyProjectAssets, recommendStylePreset, renderAllAssets, styleEngineSystemPrompt, styleFormulaSystemPrompt } from './assets.ts'
|
|
35
35
|
import { getComicStylePrompt } from './comic-presets.ts'
|
|
36
36
|
import { getGenreRules } from './manga-genre-rules.ts'
|
|
37
37
|
import { findStyle, styleKeywords } from './style-library.ts'
|
|
38
38
|
import { scanAiFlavor } from './ai-scan.ts'
|
|
39
|
+
import { emitLive, nextSessionId } from './llm-live.ts'
|
|
40
|
+
import { renderOfficialChapterWriterSkeleton } from './prompting.ts'
|
|
39
41
|
import { buildChapterContext, renderContextBlocks } from './novel-context.ts'
|
|
40
42
|
import {
|
|
41
43
|
normalizeShotSize,
|
|
@@ -105,6 +107,18 @@ import type {
|
|
|
105
107
|
AdaptMaterializeResponse,
|
|
106
108
|
AdaptMaterializeSaveRequest,
|
|
107
109
|
AdaptMaterializeSaveResponse,
|
|
110
|
+
MarketRadarRequest,
|
|
111
|
+
MarketRadarResult,
|
|
112
|
+
MarketRadarSignal,
|
|
113
|
+
MarketRadarBriefRequest,
|
|
114
|
+
ProductionFoundation,
|
|
115
|
+
MarketCreativeBrief,
|
|
116
|
+
BookAnalysisRequest,
|
|
117
|
+
BookAnalysisResult,
|
|
118
|
+
IdeaInspirationRequest,
|
|
119
|
+
IdeaInspirationResult,
|
|
120
|
+
DirectorRequest,
|
|
121
|
+
DirectorAdvice,
|
|
108
122
|
} from './protocol.ts'
|
|
109
123
|
import { LLM_VENDORS } from './protocol.ts'
|
|
110
124
|
|
|
@@ -360,15 +374,20 @@ export function createProject(outline: string, outlinePath?: string): ProjectSta
|
|
|
360
374
|
async function complete(
|
|
361
375
|
ctx: Context,
|
|
362
376
|
config: NovelConfig,
|
|
363
|
-
options: { system: string; user: string; temperature?: number; maxTokens?: number; reasoning?: 'off' | 'low' | 'high' | 'max' },
|
|
377
|
+
options: { system: string; user: string; temperature?: number; maxTokens?: number; reasoning?: 'off' | 'low' | 'high' | 'max'; model?: string; liveLabel?: string },
|
|
364
378
|
): Promise<string> {
|
|
379
|
+
const liveLabel = options.liveLabel ?? 'LLM 调用'
|
|
380
|
+
const sessionId = nextSessionId()
|
|
381
|
+
const effModel = options.model || config.model
|
|
382
|
+
emitLive({ type: 'session_started', sessionId, label: liveLabel, model: effModel, at: new Date().toISOString(), context: { interactionId: sessionId } })
|
|
383
|
+
emitLive({ type: 'phase_changed', sessionId, phase: 'streaming', phaseMessage: '模型正在返回内容', at: new Date().toISOString() })
|
|
365
384
|
const messages: Message[] = [createUserMessage({
|
|
366
385
|
content: [{ type: 'text', text: options.user }],
|
|
367
386
|
source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
|
|
368
387
|
})]
|
|
369
388
|
const request: GenerateOptions = {
|
|
370
389
|
provider: config.provider,
|
|
371
|
-
model: config.model,
|
|
390
|
+
model: options.model || config.model,
|
|
372
391
|
messages,
|
|
373
392
|
system: options.system,
|
|
374
393
|
maxTokens: options.maxTokens ?? config.maxTokens,
|
|
@@ -408,9 +427,24 @@ async function complete(
|
|
|
408
427
|
.trim()
|
|
409
428
|
if (reasoning !== '') text = reasoning
|
|
410
429
|
}
|
|
430
|
+
emitLive({ type: 'session_completed', sessionId, totalChars: text.length, preview: text.slice(0, 320), at: new Date().toISOString(), phase: text === '' ? 'failed' : 'completed' })
|
|
411
431
|
return text
|
|
412
432
|
}
|
|
413
433
|
|
|
434
|
+
/** 解析 JSON 数组;失败或为空时给模型一次修复重试(对齐上游 structuredInvokeRepair 精神)。 */
|
|
435
|
+
async function completeJsonArray(
|
|
436
|
+
ctx: Context,
|
|
437
|
+
config: NovelConfig,
|
|
438
|
+
options: { system: string; user: string; temperature?: number; maxTokens?: number; reasoning?: 'off' | 'low' | 'high' | 'max'; model?: string; liveLabel?: string },
|
|
439
|
+
parse: (text: string) => unknown[],
|
|
440
|
+
): Promise<unknown[]> {
|
|
441
|
+
let parsed = parse(await complete(ctx, config, options))
|
|
442
|
+
if (Array.isArray(parsed) && parsed.length > 0) return parsed
|
|
443
|
+
const repairOptions = { ...options, user: options.user + '\n\n注意:你上一次输出不是合法且非空的 JSON 数组。请重新输出:只输出一个合法 JSON 数组(不要 Markdown、不要解释、不要思考过程、不要代码块标记)。' }
|
|
444
|
+
const parsed2 = parse(await complete(ctx, config, repairOptions))
|
|
445
|
+
return Array.isArray(parsed2) ? parsed2 : []
|
|
446
|
+
}
|
|
447
|
+
|
|
414
448
|
/**
|
|
415
449
|
* Parse a JSON value out of a model response. Multi-level tolerance because
|
|
416
450
|
* models are sloppy: prose around the JSON, ```json fences, a truncated tail,
|
|
@@ -695,7 +729,9 @@ function volumeOf(chapterNo: number, volumes: Volume[] | undefined): number {
|
|
|
695
729
|
function planSystemPrompt(volumes: Volume[] | undefined): string {
|
|
696
730
|
const volumeBlock = volumes !== undefined && volumes.length > 0
|
|
697
731
|
? ['\n全书分卷结构(规划章节时需落在对应卷内):']
|
|
698
|
-
.concat(volumes.map(v => `第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})`
|
|
732
|
+
.concat(volumes.map(v => `第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})` +
|
|
733
|
+
(v.strategy !== undefined && v.strategy !== '' ? `\n 卷战略:${v.strategy}` : '') +
|
|
734
|
+
(v.pacing !== undefined && v.pacing !== '' ? `\n 卷节奏板:${v.pacing}` : '')))
|
|
699
735
|
.join('\n')
|
|
700
736
|
: ''
|
|
701
737
|
return [
|
|
@@ -707,6 +743,7 @@ function planSystemPrompt(volumes: Volume[] | undefined): string {
|
|
|
707
743
|
'3. 严格遵循大纲的人设、金手指规则、战力体系与世界观设定,不得自行发明冲突设定。',
|
|
708
744
|
'4. 输出必须是合法的 JSON 数组,不要输出任何其他文字或 Markdown 代码块标记。',
|
|
709
745
|
'5. 数组每个元素格式:{"title": "章节标题(10字以内,有网文感)", "beats": "结构化剧情要点(150-250字,必须包含四段,段间用换行分隔):\\n本章目标:本章要完成的核心推进;\\n剧情要点:主要情节的起承转合(2-4 句);\\n爽点/钩子:本章的爽点兑现或情绪钩子;\\n结尾钩子:本章结尾为下一章埋下的悬念"}',
|
|
746
|
+
'6. 每个章节对象可额外包含以下可选字段(尽量给出,缺失则跳过):mustAdvance(数组,本章必须推进的局面/关系/信息/风险/决策变化);mustPreserve(数组,本章必须保持不破坏的项);characterHardFacts(数组,本章不可违背的人物硬事实:身份/阵营/境界/当前位置/知情度);endingHook(字符串,本章结尾钩子要求);obligation(字符串,本章义务合约一句话)。',
|
|
710
747
|
'重要:beats 字段内部必须使用 \\n 转义表示换行(JSON 字符串内不得有真实换行符),其余字符串值也不得包含真实换行符,JSON 必须在一段内完整结束。',
|
|
711
748
|
'重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。',
|
|
712
749
|
volumeBlock,
|
|
@@ -800,6 +837,15 @@ function writeSystemPrompt(project: ProjectState, targetChars?: number, lengthRu
|
|
|
800
837
|
sections.push('==================== 内容合规红线(平台硬性要求,最高优先级,违反即失败) ====================')
|
|
801
838
|
sections.push(COMPLIANCE_REDLINES.join('\n'))
|
|
802
839
|
sections.push('以上九条为硬性底线,任何情况下不得以任何形式出现或影射;若剧情确需涉及(如批判、反讽),只能以明确否定、揭露、批判的立场呈现,且不得展开细节。')
|
|
840
|
+
sections.push(renderOfficialChapterWriterSkeleton({
|
|
841
|
+
targetChars: target,
|
|
842
|
+
minChars: lo,
|
|
843
|
+
maxChars: hi,
|
|
844
|
+
pov: '第三人称有限视角,严格跟随主角所见所知。',
|
|
845
|
+
endingHookPreference: '章末留一个明确钩子(新信息、新风险或未闭合选择)。',
|
|
846
|
+
tonePreference: '动作、对话、心理交替推进;重大信息用对话/动作/发现呈现。',
|
|
847
|
+
antiAiRules: '严格遵循上方「反 AI 规则」与写法资产,避免套话与 AI 腔。',
|
|
848
|
+
}))
|
|
803
849
|
return sections.join('\n')
|
|
804
850
|
}
|
|
805
851
|
|
|
@@ -861,6 +907,19 @@ export async function planChapters(
|
|
|
861
907
|
: continuation
|
|
862
908
|
? `本书已有 ${existing.length} 章已规划/已写作(见下方「已有章节」)。请规划**后续**章节:从第 ${startNo} 章开始。`
|
|
863
909
|
: '请规划全书开篇章节。',
|
|
910
|
+
(() => {
|
|
911
|
+
const c = project.bookContract
|
|
912
|
+
const primary = project.assets?.primaryProgression
|
|
913
|
+
const aux = project.assets?.auxiliaryProgressions ?? []
|
|
914
|
+
const genre = project.assets?.genre
|
|
915
|
+
const parts: string[] = []
|
|
916
|
+
if (c?.promise !== undefined && c.promise !== '') parts.push(`书籍承诺:${c.promise}`)
|
|
917
|
+
if ((c?.primaryModeName ?? primary?.name) !== undefined) parts.push(`主推进模式:${c?.primaryModeName ?? primary!.name}`)
|
|
918
|
+
if (aux.length > 0) parts.push(`辅助推进模式:${aux.map(a => a.name).join('、')}`)
|
|
919
|
+
if ((c?.tone ?? genre?.template) !== undefined) parts.push(`文风基调:${c?.tone ?? genre!.template}`)
|
|
920
|
+
if (c?.targetPlatform !== undefined && c.targetPlatform !== '') parts.push(`目标平台:${c.targetPlatform}`)
|
|
921
|
+
return parts.length > 0 ? '【开书定盘】(章节规划须符合此定位)\n' + parts.join('\n') : ''
|
|
922
|
+
})(),
|
|
864
923
|
continuation
|
|
865
924
|
? (() => {
|
|
866
925
|
// 从最近章节摘要动态生成「已发生事件禁令」,不绑定任何特定书
|
|
@@ -894,11 +953,16 @@ export async function planChapters(
|
|
|
894
953
|
const system = planSystemPrompt(project.volumes) + (continuation
|
|
895
954
|
? '\n重要:本次是**续写规划**——已有章节的剧情不得重写或重复,新章节标题不得与已有章节标题相同,新章节的剧情必须从上一章结尾自然接续(人物状态、时间线、地点衔接一致)。'
|
|
896
955
|
: '')
|
|
897
|
-
const
|
|
898
|
-
|
|
956
|
+
const parsed = (await completeJsonArray(
|
|
957
|
+
ctx, config,
|
|
958
|
+
{ system, user, temperature: 0.7, maxTokens: Math.max(config.maxTokens, 40000), liveLabel: '章节规划' },
|
|
959
|
+
t => parseJsonArray<Record<string, unknown>>(t),
|
|
960
|
+
)) as Record<string, unknown>[]
|
|
899
961
|
const chapters: ChapterPlan[] = []
|
|
900
962
|
const existingNos = new Set(existing.map(c => c.no))
|
|
901
963
|
const existingTitles = new Set(existing.map(c => c.title))
|
|
964
|
+
const strArr = (v: unknown): string[] => Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '') : []
|
|
965
|
+
const str = (v: unknown): string => typeof v === 'string' ? v.trim() : ''
|
|
902
966
|
let cursor = startNo
|
|
903
967
|
for (const item of parsed) {
|
|
904
968
|
if (chapters.length >= chapterCount) break
|
|
@@ -911,6 +975,19 @@ export async function planChapters(
|
|
|
911
975
|
if (title !== '' && existingTitles.has(title)) continue
|
|
912
976
|
while (existingNos.has(cursor)) cursor++
|
|
913
977
|
const no = cursor++
|
|
978
|
+
const pd: ChapterPlan['payoffDirectives'] = Array.isArray(entry.payoffDirectives)
|
|
979
|
+
? entry.payoffDirectives
|
|
980
|
+
.filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
|
|
981
|
+
.map(p => ({
|
|
982
|
+
no: typeof p.no === 'number' ? p.no : undefined,
|
|
983
|
+
operation: (['seed', 'touch', 'pressure', 'partial_reveal', 'payoff', 'forbid'] as const).includes(p.operation as never)
|
|
984
|
+
? p.operation as 'seed' | 'touch' | 'pressure' | 'partial_reveal' | 'payoff' | 'forbid'
|
|
985
|
+
: undefined,
|
|
986
|
+
text: str(p.text).slice(0, 120),
|
|
987
|
+
}))
|
|
988
|
+
.filter(p => p.text !== '' || p.operation !== undefined)
|
|
989
|
+
.slice(0, 4)
|
|
990
|
+
: undefined
|
|
914
991
|
chapters.push({
|
|
915
992
|
no,
|
|
916
993
|
volume: volumeOf(no, project.volumes),
|
|
@@ -918,6 +995,12 @@ export async function planChapters(
|
|
|
918
995
|
beats,
|
|
919
996
|
targetChars: config.chapterChars,
|
|
920
997
|
status: 'pending',
|
|
998
|
+
mustAdvance: strArr(entry.mustAdvance).slice(0, 4),
|
|
999
|
+
mustPreserve: strArr(entry.mustPreserve).slice(0, 4),
|
|
1000
|
+
characterHardFacts: strArr(entry.characterHardFacts).slice(0, 6),
|
|
1001
|
+
payoffDirectives: pd,
|
|
1002
|
+
endingHook: str(entry.endingHook).slice(0, 120) || undefined,
|
|
1003
|
+
obligation: str(entry.obligation).slice(0, 200) || undefined,
|
|
921
1004
|
})
|
|
922
1005
|
}
|
|
923
1006
|
if (chapters.length === 0) {
|
|
@@ -926,6 +1009,24 @@ export async function planChapters(
|
|
|
926
1009
|
return chapters
|
|
927
1010
|
}
|
|
928
1011
|
|
|
1012
|
+
/** 书内知识库检索:按查询词匹配文档标题/内容,返回相关片段(供生成/规划注入)。 */
|
|
1013
|
+
function retrieveKnowledge(project: ProjectState, query: string, topN = 3): string {
|
|
1014
|
+
const docs = project.knowledgeDocs ?? []
|
|
1015
|
+
if (docs.length === 0) return ''
|
|
1016
|
+
const terms = (query ?? '').split(/[\s,,。;;、/]+/).map(t => t.trim()).filter(t => t.length >= 2)
|
|
1017
|
+
if (terms.length === 0) return ''
|
|
1018
|
+
const scored = docs.map(d => {
|
|
1019
|
+
let score = 0
|
|
1020
|
+
for (const t of terms) {
|
|
1021
|
+
if (d.title.includes(t)) score += 4
|
|
1022
|
+
if (d.content.includes(t)) score += 1
|
|
1023
|
+
}
|
|
1024
|
+
return { d, score }
|
|
1025
|
+
}).filter(x => x.score > 0).sort((a, b) => b.score - a.score).slice(0, topN)
|
|
1026
|
+
if (scored.length === 0) return ''
|
|
1027
|
+
return '【书内知识库参考(与本章相关,写作时须遵守/可引用)】\n' + scored.map(x => `- 《${x.d.title}》:${x.d.content.slice(0, 300)}`).join('\n')
|
|
1028
|
+
}
|
|
1029
|
+
|
|
929
1030
|
// ------------------------------------------------------------------ writing
|
|
930
1031
|
|
|
931
1032
|
/** The review system prompt. */
|
|
@@ -944,8 +1045,10 @@ function reviewSystemPrompt(project: ProjectState): string {
|
|
|
944
1045
|
'8. 呈现方式:整章是否纯内心推理铺陈(无对话/无对抗,推理全靠解说);反派是否纯背景板无行动;重要配角是否无名标签化(瘦高个/灰衣人全程代称)——命中即列为问题。',
|
|
945
1046
|
'9. 内容合规(最高优先级):逐条核对下方「内容合规红线」,任何一条命中(含影射、暗示、详细描写)必须列为 high,并给出改写建议。',
|
|
946
1047
|
'输出必须是合法 JSON 对象,不要输出任何其他文字:',
|
|
947
|
-
'{"score": 0-100的整数, "verdict": "一句话总评", "issues": [{"severity": "high|medium|low", "dimension": "character|setting|redline|writing|pacing|logic|anti-ai|presentation|compliance", "item": "问题描述", "suggestion": "修改建议"}]}',
|
|
1048
|
+
'{"score": 0-100的整数, "riskScore": 0-100的整数(越高越需人工处理,可结合本地AI味指数), "verdict": "一句话总评", "issues": [{"severity": "high|medium|low", "dimension": "character|setting|redline|writing|pacing|logic|anti-ai|presentation|compliance", "item": "问题描述", "suggestion": "修改建议", "ruleName": "命中的反AI规则名(见反AI规则清单)", "ruleType": "forbidden|risk|encourage", "category": "套话|句式|段落|心理|设定|节奏|对话|其他", "excerpt": "命中的原文摘录(不超过50字)", "reason": "判定理由", "canAutoRewrite": true|false}]}',
|
|
948
1049
|
'维度 dimension 与上方 9 个审查维度一一对应:人设=character、设定=setting、红线=redline、文笔=writing、节奏=pacing、逻辑=logic、反AI=anti-ai、呈现=presentation、合规=compliance。每条 issue 都必须填 dimension。',
|
|
1050
|
+
'反 AI 类 issue 尽量给出 ruleName/ruleType/category/excerpt/reason/canAutoRewrite,便于统计与自动改写。',
|
|
1051
|
+
'AI 套话高频模板词示例(集中出现必须整体降密度):仿佛、似乎、极其、完美、深不见底、形成了、莫名、无法形容、难以言喻、精心雕琢、肤光胜雪、眉目如画、歌舞升平、觥筹交错、妙语连珠、不可名状、另一层真相、命运、真相。',
|
|
949
1052
|
'重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
950
1053
|
'重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。',
|
|
951
1054
|
]
|
|
@@ -998,8 +1101,8 @@ export async function reviewChapter(
|
|
|
998
1101
|
'==================== 章节正文 ====================',
|
|
999
1102
|
bodyText,
|
|
1000
1103
|
].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)
|
|
1104
|
+
const text = await complete(ctx, config, { system: reviewSystemPrompt(project), user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 8000), model: config.reviewModel, liveLabel: '审稿' })
|
|
1105
|
+
const raw = parseJsonObject<{ score?: unknown; riskScore?: unknown; verdict?: unknown; issues?: unknown; resolvedIds?: unknown; unresolvedIds?: unknown }>(text)
|
|
1003
1106
|
const issues = Array.isArray(raw.issues)
|
|
1004
1107
|
? raw.issues
|
|
1005
1108
|
.filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
|
|
@@ -1012,10 +1115,19 @@ export async function reviewChapter(
|
|
|
1012
1115
|
: undefined,
|
|
1013
1116
|
item: typeof entry.item === 'string' ? entry.item : '',
|
|
1014
1117
|
suggestion: typeof entry.suggestion === 'string' ? entry.suggestion : '',
|
|
1118
|
+
ruleName: typeof entry.ruleName === 'string' ? entry.ruleName : undefined,
|
|
1119
|
+
ruleType: (['forbidden', 'risk', 'encourage'] as const).includes(entry.ruleType as never)
|
|
1120
|
+
? entry.ruleType as 'forbidden' | 'risk' | 'encourage'
|
|
1121
|
+
: undefined,
|
|
1122
|
+
category: typeof entry.category === 'string' ? entry.category : undefined,
|
|
1123
|
+
excerpt: typeof entry.excerpt === 'string' ? entry.excerpt.slice(0, 80) : undefined,
|
|
1124
|
+
reason: typeof entry.reason === 'string' ? entry.reason.slice(0, 200) : undefined,
|
|
1125
|
+
canAutoRewrite: typeof entry.canAutoRewrite === 'boolean' ? entry.canAutoRewrite : undefined,
|
|
1015
1126
|
}))
|
|
1016
1127
|
.filter(issue => issue.item !== '')
|
|
1017
1128
|
: []
|
|
1018
1129
|
const score = typeof raw.score === 'number' ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60
|
|
1130
|
+
const riskScore = typeof raw.riskScore === 'number' ? Math.max(0, Math.min(100, Math.round(raw.riskScore))) : undefined
|
|
1019
1131
|
// 通过条件:有 high 必须 ≥ reviewPassScore;无 high 可降 5 分但最低 65(避免低分稿直接放行)
|
|
1020
1132
|
const hasHigh = issues.some(i => i.severity === 'high')
|
|
1021
1133
|
const softThreshold = Math.max(65, config.reviewPassScore - 5)
|
|
@@ -1025,6 +1137,9 @@ export async function reviewChapter(
|
|
|
1025
1137
|
passed,
|
|
1026
1138
|
verdict: typeof raw.verdict === 'string' ? raw.verdict.slice(0, 200) : '',
|
|
1027
1139
|
issues,
|
|
1140
|
+
riskScore,
|
|
1141
|
+
aiFlavor: aiScan.aiScore,
|
|
1142
|
+
aiPhrases: aiScan.clicheHits.slice(0, 8),
|
|
1028
1143
|
reviewedAt: new Date().toISOString(),
|
|
1029
1144
|
}
|
|
1030
1145
|
chapter.review = report
|
|
@@ -1061,7 +1176,7 @@ export async function reviewChapterText(
|
|
|
1061
1176
|
].join('\n')
|
|
1062
1177
|
// 验证模式:携带上一轮报告时,逐条核对原意见是否解决 + 只挑新增 high,不再全新找茬。
|
|
1063
1178
|
const system = previousReport !== undefined ? verifySystemPrompt(project) : reviewSystemPrompt(project)
|
|
1064
|
-
const raw = parseJsonObject<{ score?: unknown; verdict?: unknown; issues?: unknown; resolvedIds?: unknown; unresolvedIds?: unknown }>(
|
|
1179
|
+
const raw = parseJsonObject<{ score?: unknown; riskScore?: unknown; verdict?: unknown; issues?: unknown; resolvedIds?: unknown; unresolvedIds?: unknown }>(
|
|
1065
1180
|
await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 8000) }),
|
|
1066
1181
|
)
|
|
1067
1182
|
const issues = Array.isArray(raw.issues)
|
|
@@ -1076,10 +1191,19 @@ export async function reviewChapterText(
|
|
|
1076
1191
|
: undefined,
|
|
1077
1192
|
item: typeof entry.item === 'string' ? entry.item : '',
|
|
1078
1193
|
suggestion: typeof entry.suggestion === 'string' ? entry.suggestion : '',
|
|
1194
|
+
ruleName: typeof entry.ruleName === 'string' ? entry.ruleName : undefined,
|
|
1195
|
+
ruleType: (['forbidden', 'risk', 'encourage'] as const).includes(entry.ruleType as never)
|
|
1196
|
+
? entry.ruleType as 'forbidden' | 'risk' | 'encourage'
|
|
1197
|
+
: undefined,
|
|
1198
|
+
category: typeof entry.category === 'string' ? entry.category : undefined,
|
|
1199
|
+
excerpt: typeof entry.excerpt === 'string' ? entry.excerpt.slice(0, 80) : undefined,
|
|
1200
|
+
reason: typeof entry.reason === 'string' ? entry.reason.slice(0, 200) : undefined,
|
|
1201
|
+
canAutoRewrite: typeof entry.canAutoRewrite === 'boolean' ? entry.canAutoRewrite : undefined,
|
|
1079
1202
|
}))
|
|
1080
1203
|
.filter(issue => issue.item !== '')
|
|
1081
1204
|
: []
|
|
1082
1205
|
const score = typeof raw.score === 'number' ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60
|
|
1206
|
+
const riskScore = typeof raw.riskScore === 'number' ? Math.max(0, Math.min(100, Math.round(raw.riskScore))) : undefined
|
|
1083
1207
|
// 非验证模式:有 high 必须 ≥ reviewPassScore;无 high 可降 5 分但最低 65
|
|
1084
1208
|
const hasHighAny = issues.some(i => i.severity === 'high')
|
|
1085
1209
|
let passed = hasHighAny ? score >= config.reviewPassScore : score >= Math.max(65, config.reviewPassScore - 5)
|
|
@@ -1102,6 +1226,9 @@ export async function reviewChapterText(
|
|
|
1102
1226
|
passed,
|
|
1103
1227
|
verdict: typeof raw.verdict === 'string' ? raw.verdict.slice(0, 200) : '',
|
|
1104
1228
|
issues,
|
|
1229
|
+
riskScore,
|
|
1230
|
+
aiFlavor: aiScan.aiScore,
|
|
1231
|
+
aiPhrases: aiScan.clicheHits.slice(0, 8),
|
|
1105
1232
|
reviewedAt: new Date().toISOString(),
|
|
1106
1233
|
}
|
|
1107
1234
|
}
|
|
@@ -1140,8 +1267,12 @@ function authorReviewSystemPrompt(): string {
|
|
|
1140
1267
|
'5. advancedLines:本章实际推进的剧情线名称数组——从「活跃剧情线」清单中选出推进了的线(名称必须与清单中的线名一字不差;没推进任何线则输出空数组)。',
|
|
1141
1268
|
'6. continuity:与上一章结尾的衔接检查(人物位置/时间/伤势/资源/对话状态),发现问题要指出。',
|
|
1142
1269
|
'7. trend:结合上一章复盘看近期节奏趋势(是否连续拖沓、爽点密度是否下降、是否需要调整)。',
|
|
1270
|
+
'8. stateChanges:本章发生的关键状态变化数组(人物状态/世界局面/关系/资源,每条约 20 字,最多 6 条),供整本与事实库回灌。',
|
|
1271
|
+
'9. newConflicts:本章新引入或明显升级的冲突数组(最多 4 条)。',
|
|
1272
|
+
'10. clues:本章埋下或推进的新线索/伏笔数组(最多 4 条)。',
|
|
1273
|
+
'11. absentRisks:本章缺席但按卷级职责应出场/该推进关系的重要角色,及其缺席带来的风险(最多 4 条)。',
|
|
1143
1274
|
'输出必须是合法 JSON 对象,不要输出任何其他文字:',
|
|
1144
|
-
'{"hookHonored": true或false, "hookNote": "一句话", "endingHook": 0-10整数, "plotlineProgress": "一句话", "advancedLines": ["线名"], "continuity": "一句话", "trend": "一句话"}',
|
|
1275
|
+
'{"hookHonored": true或false, "hookNote": "一句话", "endingHook": 0-10整数, "plotlineProgress": "一句话", "advancedLines": ["线名"], "continuity": "一句话", "trend": "一句话", "stateChanges": ["状态变化"], "newConflicts": ["新冲突"], "clues": ["新线索"], "absentRisks": ["本章缺席但值得注意的角色及风险(卷级职责/缺席风险,最多4条)"]}',
|
|
1145
1276
|
'重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
1146
1277
|
'重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。',
|
|
1147
1278
|
].join('\n')
|
|
@@ -1188,6 +1319,10 @@ export async function authorReviewChapter(
|
|
|
1188
1319
|
advancedLines?: unknown
|
|
1189
1320
|
continuity?: unknown
|
|
1190
1321
|
trend?: unknown
|
|
1322
|
+
stateChanges?: unknown
|
|
1323
|
+
newConflicts?: unknown
|
|
1324
|
+
clues?: unknown
|
|
1325
|
+
absentRisks?: unknown
|
|
1191
1326
|
}>(
|
|
1192
1327
|
await complete(ctx, config, { system: authorReviewSystemPrompt(), user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 4000) }),
|
|
1193
1328
|
)
|
|
@@ -1196,6 +1331,7 @@ export async function authorReviewChapter(
|
|
|
1196
1331
|
const advancedLines = Array.isArray(raw.advancedLines)
|
|
1197
1332
|
? raw.advancedLines.filter((n): n is string => typeof n === 'string' && n.trim() !== '' && knownLineNames.has(n.trim())).map(n => n.trim())
|
|
1198
1333
|
: []
|
|
1334
|
+
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
1335
|
return {
|
|
1200
1336
|
hookHonored: raw.hookHonored === true,
|
|
1201
1337
|
hookNote: typeof raw.hookNote === 'string' ? raw.hookNote.slice(0, 200) : '',
|
|
@@ -1204,6 +1340,10 @@ export async function authorReviewChapter(
|
|
|
1204
1340
|
advancedLines,
|
|
1205
1341
|
continuity: typeof raw.continuity === 'string' ? raw.continuity.slice(0, 200) : '',
|
|
1206
1342
|
trend: typeof raw.trend === 'string' ? raw.trend.slice(0, 200) : '',
|
|
1343
|
+
stateChanges: strArr(raw.stateChanges).slice(0, 6),
|
|
1344
|
+
newConflicts: strArr(raw.newConflicts).slice(0, 4),
|
|
1345
|
+
clues: strArr(raw.clues).slice(0, 4),
|
|
1346
|
+
absentRisks: strArr(raw.absentRisks).slice(0, 4),
|
|
1207
1347
|
reviewedAt: new Date().toISOString(),
|
|
1208
1348
|
}
|
|
1209
1349
|
}
|
|
@@ -3199,9 +3339,22 @@ export async function* generateChapterStream(
|
|
|
3199
3339
|
.filter(f => f.status === 'planned' && f.targetChapter !== undefined && f.targetChapter > 0)
|
|
3200
3340
|
.filter(f => Math.abs((f.targetChapter as number) - chapterNo) <= 12)
|
|
3201
3341
|
.map(f => `- ${f.description.slice(0, 120)}${f.targetChapter !== undefined ? `(计划回收于第 ${f.targetChapter} 章)` : ''}`)
|
|
3342
|
+
const contractBlock = (() => {
|
|
3343
|
+
const parts: string[] = []
|
|
3344
|
+
if ((chapter.mustAdvance?.length ?? 0) > 0) parts.push(`本章必达(必须推进):${chapter.mustAdvance!.join(';')}`)
|
|
3345
|
+
if ((chapter.mustPreserve?.length ?? 0) > 0) parts.push(`本章保持(不得破坏):${chapter.mustPreserve!.join(';')}`)
|
|
3346
|
+
if ((chapter.characterHardFacts?.length ?? 0) > 0) parts.push(`人物硬事实(不得违背):${chapter.characterHardFacts!.join(';')}`)
|
|
3347
|
+
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(';')}`)
|
|
3348
|
+
if (chapter.endingHook !== undefined && chapter.endingHook !== '') parts.push(`章末钩子要求:${chapter.endingHook}`)
|
|
3349
|
+
if (chapter.obligation !== undefined && chapter.obligation !== '') parts.push(`本章义务合约:${chapter.obligation}`)
|
|
3350
|
+
return parts.length > 0 ? '==================== 本章合同(硬约束,必须满足) ====================\n' + parts.join('\n') : ''
|
|
3351
|
+
})()
|
|
3352
|
+
const knowledgeBlock = retrieveKnowledge(project, `${chapter.title} ${chapter.beats}`)
|
|
3202
3353
|
const user = [
|
|
3203
3354
|
`现在写第 ${chapter.no} 章,标题《${chapter.title}》。`,
|
|
3204
3355
|
`本章剧情要点:${chapter.beats}`,
|
|
3356
|
+
contractBlock,
|
|
3357
|
+
knowledgeBlock,
|
|
3205
3358
|
'',
|
|
3206
3359
|
foreshadowHints.length > 0
|
|
3207
3360
|
? `本章附近需顺势埋下以下暗线(自然带过,不喧宾夺主,1-2 句即可,但细节要可辨识、与描述吻合):\n${foreshadowHints.join('\n')}`
|
|
@@ -3228,7 +3381,7 @@ export async function* generateChapterStream(
|
|
|
3228
3381
|
})]
|
|
3229
3382
|
const request: GenerateOptions = {
|
|
3230
3383
|
provider: config.provider,
|
|
3231
|
-
model: config.model,
|
|
3384
|
+
model: config.generateModel || config.model,
|
|
3232
3385
|
messages,
|
|
3233
3386
|
system: writeSystemPrompt(project, chapter.targetChars || config.chapterChars),
|
|
3234
3387
|
// Full-chapter output: budget generously (4000 chars ≈ 8-12k tokens,
|
|
@@ -3863,7 +4016,7 @@ export async function analyzeAdaptation(
|
|
|
3863
4016
|
const user = [
|
|
3864
4017
|
'你是一位资深网文编辑兼改编策划。下面给你一部已完结/连载小说的若干章正文节选。请通读并输出该书的「原文设定卡片」与「可改范围矩阵」。',
|
|
3865
4018
|
'输出合法 JSON 对象:',
|
|
3866
|
-
'{"bookName": "书名", "outline": "一句话主线梗概(100字内)", "dimensions": [{"key":"realm","title":"大世界","mutability":"big|small|free|locked|visual","current":"当前值","evidence":"证据(出现章节/频次)","candidates":["
|
|
4019
|
+
'{"bookName": "书名", "outline": "一句话主线梗概(100字内)", "dimensions": [{"key":"realm","title":"大世界","mutability":"big|small|free|locked|visual","current":"当前值","evidence":"证据(出现章节/频次)","candidates":[{"name":"候选体系名","desc":"该体系一句话说明"}],"impact":"改了会影响什么","risk":"high|medium|low"}]}',
|
|
3867
4020
|
'dimensions 至少覆盖以下维度(key/title):realm 大世界、cultivation 修为体系、protagonist 主角、goldenFinger 金手指、supporting 配角与势力人物名、faction 势力/组织、style 文风与叙事、ending 结局走向、timeline 时间线/编年、foreshadow 伏笔/暗线。',
|
|
3868
4021
|
'mutability 取值:locked=建议保留、big=可改影响大、small=可改影响小、free=可自由改、visual=仅视觉包装。',
|
|
3869
4022
|
'每个 dimension.current 必须忠于文本,能引用原文就用原文(尤其角色名/境界名/势力名/金手指名)。',
|
|
@@ -3911,13 +4064,24 @@ function normalizeAdaptationDimension(d: Record<string, unknown>): AdaptationDim
|
|
|
3911
4064
|
const risk = (['high', 'medium', 'low'] as const).includes(d.risk as AdaptationDimension['risk'])
|
|
3912
4065
|
? d.risk as AdaptationDimension['risk']
|
|
3913
4066
|
: 'medium'
|
|
4067
|
+
const rawCands = Array.isArray(d.candidates) ? d.candidates : []
|
|
4068
|
+
const candidates = rawCands.map((x): { name: string; desc?: string } | null => {
|
|
4069
|
+
if (typeof x === 'string') return x.trim() !== '' ? { name: x.trim() } : null
|
|
4070
|
+
if (typeof x === 'object' && x !== null) {
|
|
4071
|
+
const o = x as { name?: unknown; desc?: unknown }
|
|
4072
|
+
const name = typeof o.name === 'string' ? o.name.trim() : ''
|
|
4073
|
+
const desc = typeof o.desc === 'string' ? o.desc.trim() : undefined
|
|
4074
|
+
return name !== '' ? { name, desc } : null
|
|
4075
|
+
}
|
|
4076
|
+
return null
|
|
4077
|
+
}).filter((x): x is { name: string; desc?: string } => x !== null)
|
|
3914
4078
|
return {
|
|
3915
4079
|
key,
|
|
3916
4080
|
title,
|
|
3917
4081
|
mutability,
|
|
3918
4082
|
current: typeof d.current === 'string' ? d.current : '',
|
|
3919
4083
|
evidence: typeof d.evidence === 'string' ? d.evidence : undefined,
|
|
3920
|
-
candidates
|
|
4084
|
+
candidates,
|
|
3921
4085
|
impact: typeof d.impact === 'string' ? d.impact : '',
|
|
3922
4086
|
risk,
|
|
3923
4087
|
}
|
|
@@ -4476,7 +4640,7 @@ async function auditBatch(
|
|
|
4476
4640
|
`正文节选(每章前 700 字):\n${chapterBlocks}`,
|
|
4477
4641
|
'只输出 JSON 数组。',
|
|
4478
4642
|
].filter(s => s !== '').join('\n\n')
|
|
4479
|
-
const text = await complete(ctx, config, { system, user, temperature: 0.2, maxTokens: Math.max(config.maxTokens, 12000) })
|
|
4643
|
+
const text = await complete(ctx, config, { system, user, temperature: 0.2, maxTokens: Math.max(config.maxTokens, 12000), model: config.auditModel, liveLabel: '质检查询' })
|
|
4480
4644
|
const parsed = parseJsonArray<Record<string, unknown>>(text)
|
|
4481
4645
|
const issues: AuditIssue[] = []
|
|
4482
4646
|
for (const entry of parsed) {
|
|
@@ -4910,7 +5074,7 @@ export async function suggestForeshadows(
|
|
|
4910
5074
|
`大纲:\n${project.outline}`,
|
|
4911
5075
|
`已规划章节数:${project.chapters.length}`,
|
|
4912
5076
|
].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' })
|
|
5077
|
+
const text = await complete(ctx, config, { system: foreshadowSystemPrompt(), user, temperature: 0.5, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low', liveLabel: '伏笔建议' })
|
|
4914
5078
|
const parsed = parseJsonArray<Record<string, unknown>>(text)
|
|
4915
5079
|
const existing = new Set(project.foreshadows.map(f => f.description))
|
|
4916
5080
|
const created: Foreshadow[] = []
|
|
@@ -4942,17 +5106,34 @@ export async function extractStyleAsset(
|
|
|
4942
5106
|
ctx: Context,
|
|
4943
5107
|
config: NovelConfig,
|
|
4944
5108
|
sampleText: string,
|
|
4945
|
-
): Promise<{
|
|
5109
|
+
): Promise<{
|
|
5110
|
+
proseRules: string[]; dialogueRules: string[]; descriptionRules: string[]; boundaries: string[];
|
|
5111
|
+
preset?: 'imitate' | 'balanced' | 'transfer'; fingerprintRisk?: 'low' | 'medium' | 'high';
|
|
5112
|
+
writingGuidance?: string[]; forbiddenEntities?: string[];
|
|
5113
|
+
}> {
|
|
4946
5114
|
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<{
|
|
5115
|
+
const text = await complete(ctx, config, { system: styleEngineSystemPrompt(), user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 12000), liveLabel: '写法提取' })
|
|
5116
|
+
const raw = parseJsonObject<{
|
|
5117
|
+
proseRules?: unknown; dialogueRules?: unknown; descriptionRules?: unknown; boundaries?: unknown;
|
|
5118
|
+
preset?: unknown; fingerprintRisk?: unknown; writingGuidance?: unknown; forbiddenEntities?: unknown;
|
|
5119
|
+
}>(text)
|
|
4949
5120
|
const strArray = (value: unknown): string[] =>
|
|
4950
5121
|
Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string' && v.trim() !== '') : []
|
|
5122
|
+
// 结构化输出校验(轻量 schema):归一 enum、截断、去空;preset 缺失时按指纹风险自动推荐。
|
|
5123
|
+
const fingerprintRisk = (['low', 'medium', 'high'] as const).includes(raw.fingerprintRisk as never)
|
|
5124
|
+
? raw.fingerprintRisk as 'low' | 'medium' | 'high'
|
|
5125
|
+
: 'medium'
|
|
4951
5126
|
const result = {
|
|
4952
|
-
proseRules: strArray(raw.proseRules),
|
|
4953
|
-
dialogueRules: strArray(raw.dialogueRules),
|
|
4954
|
-
descriptionRules: strArray(raw.descriptionRules),
|
|
4955
|
-
boundaries: strArray(raw.boundaries),
|
|
5127
|
+
proseRules: strArray(raw.proseRules).slice(0, 40),
|
|
5128
|
+
dialogueRules: strArray(raw.dialogueRules).slice(0, 40),
|
|
5129
|
+
descriptionRules: strArray(raw.descriptionRules).slice(0, 40),
|
|
5130
|
+
boundaries: strArray(raw.boundaries).slice(0, 30),
|
|
5131
|
+
preset: (['imitate', 'balanced', 'transfer'] as const).includes(raw.preset as never)
|
|
5132
|
+
? raw.preset as 'imitate' | 'balanced' | 'transfer'
|
|
5133
|
+
: recommendStylePreset(fingerprintRisk),
|
|
5134
|
+
fingerprintRisk,
|
|
5135
|
+
writingGuidance: strArray(raw.writingGuidance).slice(0, 20),
|
|
5136
|
+
forbiddenEntities: strArray(raw.forbiddenEntities).slice(0, 30),
|
|
4956
5137
|
}
|
|
4957
5138
|
if (result.proseRules.length + result.dialogueRules.length + result.descriptionRules.length + result.boundaries.length === 0) {
|
|
4958
5139
|
throw new Error('写法提取失败:模型没有返回有效规则')
|
|
@@ -4960,6 +5141,261 @@ export async function extractStyleAsset(
|
|
|
4960
5141
|
return result
|
|
4961
5142
|
}
|
|
4962
5143
|
|
|
5144
|
+
/** 分层提取写作公式(basic/standard/deep)。 */
|
|
5145
|
+
export async function extractStyleFormula(
|
|
5146
|
+
ctx: Context,
|
|
5147
|
+
config: NovelConfig,
|
|
5148
|
+
sampleText: string,
|
|
5149
|
+
depth: 'basic' | 'standard' | 'deep',
|
|
5150
|
+
): Promise<{ name: string; focusAreas: string[]; formula: string; applyGuidance: string }> {
|
|
5151
|
+
const user = `请提炼下面这段样本文本的写作公式:\n\n${sampleText}`
|
|
5152
|
+
const text = await complete(ctx, config, { system: styleFormulaSystemPrompt(depth), user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 8000), liveLabel: '公式提取' })
|
|
5153
|
+
const raw = parseJsonObject<{ name?: unknown; focusAreas?: unknown; formula?: unknown; applyGuidance?: unknown }>(text)
|
|
5154
|
+
const formula = typeof raw.formula === 'string' ? raw.formula.trim() : ''
|
|
5155
|
+
if (formula === '') throw new Error('公式提取失败:模型没有返回有效 formula')
|
|
5156
|
+
return {
|
|
5157
|
+
name: typeof raw.name === 'string' ? raw.name.trim().slice(0, 40) : `公式 ${Date.now().toString(36)}`,
|
|
5158
|
+
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) : [],
|
|
5159
|
+
formula,
|
|
5160
|
+
applyGuidance: typeof raw.applyGuidance === 'string' ? raw.applyGuidance.trim().slice(0, 300) : '',
|
|
5161
|
+
}
|
|
5162
|
+
}
|
|
5163
|
+
|
|
5164
|
+
/** 市场雷达系统提示(对齐上游 marketRadar 分析师角色)。 */
|
|
5165
|
+
function marketRadarSystemPrompt(): string {
|
|
5166
|
+
return [
|
|
5167
|
+
'你是中文网络文学市场分析师。只分析输入中的公开榜单元数据或用户给出的市场线索,不补写作品正文,不假装知道未提供的信息。',
|
|
5168
|
+
'只根据输入归纳热门题材和市场信号;对未提供的信息不要臆测。',
|
|
5169
|
+
'重点分析:热门题材组合、主角身份、金手指机制、开篇危机、关系卖点、标题句式、拥挤套路和差异化机会。',
|
|
5170
|
+
'kind 只能使用 genre、protagonist、advantage、opening、relationship、title_pattern、opportunity、crowding,不得创造近义枚举值。',
|
|
5171
|
+
'榜单高频不等于适合照搬。机会建议必须说明读者满足点,同时避开直接复制具体作品。',
|
|
5172
|
+
'productionFoundation 的题材/推进模式优先引用「资源库」中已有的 existingId(名称一字不差);只有确无合适资产时才给出新资产(existingId 为 null)。',
|
|
5173
|
+
'creativeBrief 严禁复用榜单作品的人名、专有设定、简介句子和完整书名;只能提炼读者需求、爽点机制和结构机会,不得输出任何具体人名/作品名,一律使用身份或通用场景称谓。',
|
|
5174
|
+
].join('\n')
|
|
5175
|
+
}
|
|
5176
|
+
|
|
5177
|
+
/** 题材雷达:输入平台/题材/榜单文本 → 信号 + 生产底座 + 开书创意。 */
|
|
5178
|
+
export async function runMarketRadar(
|
|
5179
|
+
ctx: Context,
|
|
5180
|
+
config: NovelConfig,
|
|
5181
|
+
req: MarketRadarRequest,
|
|
5182
|
+
): Promise<MarketRadarResult> {
|
|
5183
|
+
const genreCatalog = BUILTIN_GENRE_LIBRARY.map((g, i) => `- [${i}] ${g.name}:${g.description}${g.template !== undefined ? `(写法:${g.template})` : ''}`).join('\n')
|
|
5184
|
+
const modeCatalog = BUILTIN_PROGRESSION_MODES.map((m, i) => `- [${i}] ${m.name}:${m.driver} / 期待:${m.readerExpectation}`).join('\n')
|
|
5185
|
+
const user = [
|
|
5186
|
+
req.platform !== undefined && req.platform !== '' ? `目标平台:${req.platform}` : '',
|
|
5187
|
+
req.genre !== undefined && req.genre !== '' ? `目标题材:${req.genre}` : '',
|
|
5188
|
+
req.keywords !== undefined && req.keywords !== '' ? `关键词:${req.keywords}` : '',
|
|
5189
|
+
(req.candidates !== undefined && req.candidates.length > 0)
|
|
5190
|
+
? '【上榜记录(真实榜单)】\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')
|
|
5191
|
+
: (req.feedText !== undefined && req.feedText !== '')
|
|
5192
|
+
? '【榜单/市场线索】\n' + req.feedText.slice(0, 6000)
|
|
5193
|
+
: '(无榜单数据,请基于平台/题材/关键词与你的市场知识归纳)',
|
|
5194
|
+
'',
|
|
5195
|
+
'现有题材基底库:\n' + (genreCatalog || '空'),
|
|
5196
|
+
'',
|
|
5197
|
+
'现有推进模式库:\n' + (modeCatalog || '空'),
|
|
5198
|
+
'',
|
|
5199
|
+
'输出必须是合法 JSON 对象,不要输出任何其他文字。',
|
|
5200
|
+
'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或省略}}}',
|
|
5201
|
+
].filter(s => s !== '').join('\n\n')
|
|
5202
|
+
const text = await complete(ctx, config, { system: marketRadarSystemPrompt(), user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 8000), liveLabel: '题材雷达' })
|
|
5203
|
+
const raw = parseJsonObject<Record<string, unknown>>(text)
|
|
5204
|
+
const signalAs = (v: unknown): MarketRadarSignal[] => Array.isArray(v)
|
|
5205
|
+
? v.filter((x): x is Record<string, unknown> => typeof x === 'object' && x !== null)
|
|
5206
|
+
.map(x => ({
|
|
5207
|
+
id: typeof x.id === 'string' ? x.id.slice(0, 40) : `sig-${Math.random().toString(36).slice(2, 7)}`,
|
|
5208
|
+
kind: (['genre', 'protagonist', 'advantage', 'opening', 'relationship', 'title_pattern', 'opportunity', 'crowding'] as const).includes(x.kind as never)
|
|
5209
|
+
? x.kind as MarketRadarSignal['kind']
|
|
5210
|
+
: 'genre',
|
|
5211
|
+
title: typeof x.title === 'string' ? x.title.slice(0, 60) : '未命名信号',
|
|
5212
|
+
detail: typeof x.detail === 'string' ? x.detail.slice(0, 300) : '',
|
|
5213
|
+
direction: (['current', 'rising', 'stable', 'falling'] as const).includes(x.direction as never)
|
|
5214
|
+
? x.direction as MarketRadarSignal['direction']
|
|
5215
|
+
: undefined,
|
|
5216
|
+
recommended: typeof x.recommended === 'boolean' ? x.recommended : undefined,
|
|
5217
|
+
}))
|
|
5218
|
+
: []
|
|
5219
|
+
const pf = (raw.productionFoundation ?? {}) as Record<string, unknown>
|
|
5220
|
+
const genre = (pf.genre ?? {}) as Record<string, unknown>
|
|
5221
|
+
const primary = (pf.primaryStoryMode ?? {}) as Record<string, unknown>
|
|
5222
|
+
const secondary = (pf.secondaryStoryMode ?? {}) as Record<string, unknown>
|
|
5223
|
+
const genreIds = new Set(BUILTIN_GENRE_LIBRARY.map(g => g.name))
|
|
5224
|
+
const modeIds = new Set(BUILTIN_PROGRESSION_MODES.map(m => m.name))
|
|
5225
|
+
const productionFoundation: ProductionFoundation = {
|
|
5226
|
+
genre: {
|
|
5227
|
+
existingId: typeof genre.existingId === 'string' && genre.existingId !== '' && genreIds.has(genre.existingId) ? genre.existingId : undefined,
|
|
5228
|
+
name: typeof genre.name === 'string' ? genre.name.slice(0, 40) : '未定题材',
|
|
5229
|
+
description: typeof genre.description === 'string' ? genre.description.slice(0, 300) : '',
|
|
5230
|
+
template: typeof genre.template === 'string' ? genre.template.slice(0, 200) : undefined,
|
|
5231
|
+
},
|
|
5232
|
+
primaryStoryMode: {
|
|
5233
|
+
existingId: typeof primary.existingId === 'string' && primary.existingId !== '' && modeIds.has(primary.existingId) ? primary.existingId : undefined,
|
|
5234
|
+
name: typeof primary.name === 'string' ? primary.name.slice(0, 40) : '未定模式',
|
|
5235
|
+
driver: typeof primary.driver === 'string' ? primary.driver.slice(0, 200) : '',
|
|
5236
|
+
readerExpectation: typeof primary.readerExpectation === 'string' ? primary.readerExpectation.slice(0, 200) : '',
|
|
5237
|
+
},
|
|
5238
|
+
secondaryStoryMode: Object.keys(secondary).length > 0 && typeof secondary.name === 'string' && secondary.name !== ''
|
|
5239
|
+
? {
|
|
5240
|
+
existingId: typeof secondary.existingId === 'string' && secondary.existingId !== '' && modeIds.has(secondary.existingId) ? secondary.existingId : undefined,
|
|
5241
|
+
name: secondary.name.slice(0, 40),
|
|
5242
|
+
driver: typeof secondary.driver === 'string' ? secondary.driver.slice(0, 200) : '',
|
|
5243
|
+
readerExpectation: typeof secondary.readerExpectation === 'string' ? secondary.readerExpectation.slice(0, 200) : '',
|
|
5244
|
+
}
|
|
5245
|
+
: undefined,
|
|
5246
|
+
}
|
|
5247
|
+
return {
|
|
5248
|
+
signals: signalAs(raw.signals),
|
|
5249
|
+
productionFoundation,
|
|
5250
|
+
}
|
|
5251
|
+
}
|
|
5252
|
+
|
|
5253
|
+
const MARKET_MODE_HINT: Record<string, string> = {
|
|
5254
|
+
follow_hot: '优先贴合当前热门满足点,但仍禁止复制具体作品。',
|
|
5255
|
+
differentiate: '保留热门读者满足点,同时至少替换主角身份、舞台或金手指机制中的一项。',
|
|
5256
|
+
light: '市场信号只作次要参考,用户自身想法和已选题材优先。',
|
|
5257
|
+
}
|
|
5258
|
+
|
|
5259
|
+
/** 开书创意简报:用选中的市场信号 + 影响模式生成可执行 constraint / creative seed。 */
|
|
5260
|
+
export async function runMarketCreativeBrief(ctx: Context, config: NovelConfig, req: MarketRadarBriefRequest): Promise<MarketCreativeBrief> {
|
|
5261
|
+
const user = [
|
|
5262
|
+
`影响模式:${req.influenceMode}`,
|
|
5263
|
+
MARKET_MODE_HINT[req.influenceMode] ?? '',
|
|
5264
|
+
req.signals.length > 0
|
|
5265
|
+
? '选中的市场信号:\n' + req.signals.map(s => `- [${s.kind}] ${s.title}:${s.detail}`).join('\n')
|
|
5266
|
+
: '(无选中信号,请结合题材与推进模式补齐)',
|
|
5267
|
+
'',
|
|
5268
|
+
'输出必须是合法 JSON 对象,不要输出任何其他文字。',
|
|
5269
|
+
'JSON 结构:{"promptBlock":"可直接指导题材/金手指/首章爆点/整书方向的约束","openingIdea":"一段可直接开书的中文起始想法(主角身份、金手指或核心优势、开局事件、近期目标;不输出标题/大纲/Markdown)","coreAdvantage":"主角能做什么(含触发条件/使用边界/成长方向或代价至少一项)","bookSellingPoint":"读者持续追读的核心满足点","first30ChapterPromise":"前30章必须兑现的阶段结果/关系变化/能力成长"}',
|
|
5270
|
+
'严禁复用榜单作品的人名、专有设定、简介句子和完整书名;一律使用身份或通用场景称谓,不得输出具体人名/作品名。',
|
|
5271
|
+
].filter(s => s !== '').join('\n\n')
|
|
5272
|
+
const text = await complete(ctx, config, { system: marketRadarSystemPrompt(), user, temperature: 0.5, maxTokens: Math.max(config.maxTokens, 4000), liveLabel: '开书创意' })
|
|
5273
|
+
const cb = parseJsonObject<Record<string, unknown>>(text)
|
|
5274
|
+
return {
|
|
5275
|
+
promptBlock: typeof cb.promptBlock === 'string' ? cb.promptBlock.slice(0, 800) : '',
|
|
5276
|
+
openingIdea: typeof cb.openingIdea === 'string' ? cb.openingIdea.slice(0, 800) : '',
|
|
5277
|
+
coreAdvantage: typeof cb.coreAdvantage === 'string' ? cb.coreAdvantage.slice(0, 600) : '',
|
|
5278
|
+
bookSellingPoint: typeof cb.bookSellingPoint === 'string' ? cb.bookSellingPoint.slice(0, 400) : '',
|
|
5279
|
+
first30ChapterPromise: typeof cb.first30ChapterPromise === 'string' ? cb.first30ChapterPromise.slice(0, 400) : '',
|
|
5280
|
+
}
|
|
5281
|
+
}
|
|
5282
|
+
|
|
5283
|
+
/** 书分析/拆书:输入一本书/章节文本 → 卖点/结构/可借鉴点/风险。 */
|
|
5284
|
+
export async function runBookAnalysis(ctx: Context, config: NovelConfig, req: BookAnalysisRequest): Promise<BookAnalysisResult> {
|
|
5285
|
+
const system = [
|
|
5286
|
+
'你是资深中文网文拆书编辑。分析输入的一本书或章节文本,提炼可复用的创作思路。',
|
|
5287
|
+
'严禁照搬具体作品的人名/专有设定/简介句子;只能提炼读者需求、结构手法和风险。',
|
|
5288
|
+
].join('\n')
|
|
5289
|
+
const user = [
|
|
5290
|
+
'请分析下面文本:\n' + (req.text ?? '').slice(0, 8000),
|
|
5291
|
+
'输出必须是合法 JSON 对象。',
|
|
5292
|
+
'JSON 结构:{"sellingPoints":["读者为什么追(卖点)"],"structure":["可复用叙事结构/节奏/单元"],"lessons":["可借鉴手法/套路"],"risks":["易踩坑/风险"]}',
|
|
5293
|
+
].filter(s => s !== '').join('\n\n')
|
|
5294
|
+
const text = await complete(ctx, config, { system, user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 4000), liveLabel: '书分析' })
|
|
5295
|
+
const raw = parseJsonObject<Record<string, unknown>>(text)
|
|
5296
|
+
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)) : []
|
|
5297
|
+
return {
|
|
5298
|
+
sellingPoints: arr(raw.sellingPoints),
|
|
5299
|
+
structure: arr(raw.structure),
|
|
5300
|
+
lessons: arr(raw.lessons),
|
|
5301
|
+
risks: arr(raw.risks),
|
|
5302
|
+
}
|
|
5303
|
+
}
|
|
5304
|
+
|
|
5305
|
+
/** 创意灵感:一句话/题材 → 多方向开书灵感。 */
|
|
5306
|
+
export async function runIdeaInspiration(ctx: Context, config: NovelConfig, req: IdeaInspirationRequest): Promise<IdeaInspirationResult> {
|
|
5307
|
+
const count = Math.max(1, Math.min(10, req.count ?? 5))
|
|
5308
|
+
const system = [
|
|
5309
|
+
'你是中文网文开书灵感策划。根据用户一句话/题材方向,给出多个可开书的差异化创意。',
|
|
5310
|
+
'严禁输出具体人名/作品名;用身份/通用称谓。每个创意都要有明确钩子、题材、视角、长期兑现。',
|
|
5311
|
+
].join('\n')
|
|
5312
|
+
const user = [
|
|
5313
|
+
`我的方向:${req.idea ?? ''}`,
|
|
5314
|
+
`请给 ${count} 个开书创意。`,
|
|
5315
|
+
'输出必须是合法 JSON 对象。',
|
|
5316
|
+
'JSON 结构:{"ideas":[{"title":"书名/门面","hook":"一句话钩子/开局爆点","genre":"题材","pov":"主角视角/身份","payoff":"长期追读兑现"}]}',
|
|
5317
|
+
].filter(s => s !== '').join('\n\n')
|
|
5318
|
+
const text = await complete(ctx, config, { system, user, temperature: 0.8, maxTokens: Math.max(config.maxTokens, 4000), liveLabel: '创意灵感' })
|
|
5319
|
+
const raw = parseJsonObject<Record<string, unknown>>(text)
|
|
5320
|
+
const str = (v: unknown): string => typeof v === 'string' ? v.trim().slice(0, 200) : ''
|
|
5321
|
+
const arr = (v: unknown): Array<{ title: string; hook: string; genre: string; pov: string; payoff: string }> => Array.isArray(v)
|
|
5322
|
+
? 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) }))
|
|
5323
|
+
: []
|
|
5324
|
+
return { ideas: arr(raw.ideas).slice(0, count) }
|
|
5325
|
+
}
|
|
5326
|
+
|
|
5327
|
+
/** 雷达→灵感:基于市场信号/生产底座/创意简报,生成贴合市场的开书灵感。 */
|
|
5328
|
+
export async function runMarketIdeaInspiration(
|
|
5329
|
+
ctx: Context,
|
|
5330
|
+
config: NovelConfig,
|
|
5331
|
+
req: { signals?: MarketRadarSignal[]; foundation?: ProductionFoundation; brief?: MarketCreativeBrief; count?: number },
|
|
5332
|
+
): Promise<IdeaInspirationResult> {
|
|
5333
|
+
const count = Math.max(1, Math.min(10, req.count ?? 5))
|
|
5334
|
+
const system = [
|
|
5335
|
+
'你是中文网文开书灵感策划,擅长从市场榜单分析中提炼可开书方向。',
|
|
5336
|
+
'严禁输出具体人名/作品名;用身份/通用称谓。每个创意都要有明确钩子、题材、视角、长期兑现,且要与市场分析呼应但做差异化。',
|
|
5337
|
+
].join('\n')
|
|
5338
|
+
const signalBlock = (req.signals ?? []).map(s => `- [${s.kind}] ${s.title}:${s.detail}`).join('\n') || '(无)'
|
|
5339
|
+
const f = req.foundation
|
|
5340
|
+
const foundationBlock = f !== undefined
|
|
5341
|
+
? `题材:${f.genre.name}(${f.genre.description})\n主推进:${f.primaryStoryMode.name}(${f.primaryStoryMode.driver})${f.secondaryStoryMode !== undefined ? `\n副推进:${f.secondaryStoryMode.name}` : ''}`
|
|
5342
|
+
: '(无)'
|
|
5343
|
+
const b = req.brief
|
|
5344
|
+
const briefBlock = b !== undefined
|
|
5345
|
+
? `创作约束:${b.promptBlock}\n开篇想法:${b.openingIdea}\n核心优势:${b.coreAdvantage}\n追读卖点:${b.bookSellingPoint}\n前30章承诺:${b.first30ChapterPromise}`
|
|
5346
|
+
: '(无)'
|
|
5347
|
+
const user = [
|
|
5348
|
+
'基于以下市场分析,给出多个【差异化、可开书】的灵感:',
|
|
5349
|
+
`市场信号:\n${signalBlock}`,
|
|
5350
|
+
`生产底座:\n${foundationBlock}`,
|
|
5351
|
+
`创意简报:\n${briefBlock}`,
|
|
5352
|
+
`请给 ${count} 个开书灵感。`,
|
|
5353
|
+
'输出必须是合法 JSON 对象。',
|
|
5354
|
+
'JSON 结构:{"ideas":[{"title":"书名/门面","hook":"一句话钩子/开局爆点","genre":"题材","pov":"主角视角/身份","payoff":"长期追读兑现"}]}',
|
|
5355
|
+
].filter(s => s !== '').join('\n\n')
|
|
5356
|
+
const text = await complete(ctx, config, { system, user, temperature: 0.8, maxTokens: Math.max(config.maxTokens, 4000), liveLabel: '市场灵感' })
|
|
5357
|
+
const raw = parseJsonObject<Record<string, unknown>>(text)
|
|
5358
|
+
const str = (v: unknown): string => typeof v === 'string' ? v.trim().slice(0, 200) : ''
|
|
5359
|
+
const arr = (v: unknown): Array<{ title: string; hook: string; genre: string; pov: string; payoff: string }> => Array.isArray(v)
|
|
5360
|
+
? 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) }))
|
|
5361
|
+
: []
|
|
5362
|
+
return { ideas: arr(raw.ideas).slice(0, count) }
|
|
5363
|
+
}
|
|
5364
|
+
|
|
5365
|
+
/** 自动导演编排建议:基于全书上下文,给出下一卷/阶段编排 + 修复再平衡。 */
|
|
5366
|
+
export async function runDirectorAdvice(ctx: Context, config: NovelConfig, project: ProjectState, req: DirectorRequest): Promise<DirectorAdvice> {
|
|
5367
|
+
const done = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating')
|
|
5368
|
+
const last = done[done.length - 1]
|
|
5369
|
+
const volumesBlock = (project.volumes ?? []).map(v => `第${v.no}卷《${v.title}》:${v.summary}`).join('\n') || '无'
|
|
5370
|
+
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') || '无'
|
|
5371
|
+
const foreBlock = (project.foreshadows ?? []).filter(f => f.status === 'planted' || f.status === 'progressing').map(f => `- ${f.description}${f.targetChapter ? `(约${f.targetChapter}章回收)` : ''}`).join('\n') || '无'
|
|
5372
|
+
const recentFacts = (project.facts ?? []).slice(-8).map(f => `[第${f.chapterNo}章] ${f.text}`).join('\n')
|
|
5373
|
+
const user = [
|
|
5374
|
+
`书名:《${project.bookName}》`,
|
|
5375
|
+
req.focus !== undefined && req.focus !== '' ? `聚焦:${req.focus}` : '',
|
|
5376
|
+
`当前进度:已写 ${done.length} 章,最后一章《${last?.title ?? '无'}》摘要:${last?.summary ?? ''}`,
|
|
5377
|
+
'',
|
|
5378
|
+
`分卷:\n${volumesBlock}`,
|
|
5379
|
+
`剧情线:\n${plotlinesBlock}`,
|
|
5380
|
+
`活跃伏笔:\n${foreBlock}`,
|
|
5381
|
+
`最近事实:\n${recentFacts}`,
|
|
5382
|
+
'',
|
|
5383
|
+
'请作为本书的自动导演,给出下一卷/下一阶段的编排建议:续写方向、阶段弧光、节奏板、风险提示、需要修复/再平衡的点。',
|
|
5384
|
+
'输出必须是合法 JSON 对象。',
|
|
5385
|
+
'JSON 结构:{"summary":"总体判断一句话","nextArc":["下一阶段关键剧情节点(每条约一句话)"],"pacing":"节奏板(起承转合/爽点密度/章节节奏)","risks":["风险/跑偏提示"],"fixes":["需要修复/再平衡的点"]}',
|
|
5386
|
+
].filter(s => s !== '').join('\n\n')
|
|
5387
|
+
const text = await complete(ctx, config, { system: '你是一位长篇网文自动导演,负责整本编排、节奏、风险与再平衡。只给出可执行建议,不输出正文。', user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 4000), liveLabel: '自动导演' })
|
|
5388
|
+
const raw = parseJsonObject<Record<string, unknown>>(text)
|
|
5389
|
+
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)) : []
|
|
5390
|
+
return {
|
|
5391
|
+
summary: typeof raw.summary === 'string' ? raw.summary.slice(0, 300) : '',
|
|
5392
|
+
nextArc: arr(raw.nextArc),
|
|
5393
|
+
pacing: typeof raw.pacing === 'string' ? raw.pacing.slice(0, 400) : '',
|
|
5394
|
+
risks: arr(raw.risks),
|
|
5395
|
+
fixes: arr(raw.fixes),
|
|
5396
|
+
}
|
|
5397
|
+
}
|
|
5398
|
+
|
|
4963
5399
|
// ------------------------------------------------------------------ export
|
|
4964
5400
|
|
|
4965
5401
|
/** Export the whole book as one txt/md file. */
|