@waterwx/dsh-novel-forge 1.1.2 → 1.1.4

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/src/engine.ts CHANGED
@@ -13,9 +13,13 @@ import type { Context } from '@deepseek-ai/cordis'
13
13
  import { emptyProjectAssets, renderAllAssets, styleEngineSystemPrompt } from './assets.ts'
14
14
  import type {
15
15
  AuditIssue,
16
+ AuthorReview,
16
17
  ChapterPlan,
17
18
  Foreshadow,
18
19
  NovelConfig,
20
+ Plotline,
21
+ PlotlineHealthReport,
22
+ PlotlinePlan,
19
23
  ProjectState,
20
24
  ReviewReport,
21
25
  RoleStatusCard,
@@ -69,6 +73,7 @@ export function loadProject(outputDir: string): ProjectState | undefined {
69
73
  if (!Array.isArray(raw.assets.auxiliaryProgressions)) raw.assets.auxiliaryProgressions = []
70
74
  if (!Array.isArray(raw.assets.styleAssets)) raw.assets.styleAssets = []
71
75
  if (!Array.isArray(raw.facts)) raw.facts = []
76
+ if (!Array.isArray(raw.plotlines)) raw.plotlines = []
72
77
  return raw
73
78
  } catch {
74
79
  return undefined
@@ -81,6 +86,78 @@ export function saveProject(outputDir: string, project: ProjectState): void {
81
86
  writeFileSync(join(outputDir, PROJECT_FILE), JSON.stringify(project, null, 2), 'utf8')
82
87
  }
83
88
 
89
+ // ------------------------------------------------------------ sensitive words
90
+
91
+ /**
92
+ * 内置违禁词库(网文平台常见审查类别)。只做硬匹配提示,不代替人工判断。
93
+ * 词语刻意保持常见写法;作者可自行判断是否修改。
94
+ */
95
+ const SENSITIVE_WORDS: ReadonlyArray<{ word: string; category: string }> = [
96
+ // 政治敏感
97
+ { word: '共匪', category: '政治' }, { word: '独裁', category: '政治' },
98
+ { word: '法轮', category: '政治' }, { word: '六四', category: '政治' },
99
+ { word: '天安门事件', category: '政治' }, { word: '翻墙', category: '政治' },
100
+ { word: '政治敏感', category: '政治' },
101
+ // 色情擦边
102
+ { word: '乳沟', category: '擦边' }, { word: '酥胸', category: '擦边' },
103
+ { word: '淫荡', category: '擦边' }, { word: '做爱', category: '擦边' },
104
+ { word: '上床', category: '擦边' }, { word: '裸体', category: '擦边' },
105
+ { word: '一丝不挂', category: '擦边' }, { word: '胴体', category: '擦边' },
106
+ { word: '春药', category: '擦边' }, { word: '催情', category: '擦边' },
107
+ { word: '迷奸', category: '擦边' }, { word: '强暴', category: '擦边' },
108
+ { word: '轮奸', category: '擦边' }, { word: '援交', category: '擦边' },
109
+ { word: '嫖娼', category: '擦边' }, { word: '卖淫', category: '擦边' },
110
+ { word: '色情', category: '擦边' }, { word: '情色', category: '擦边' },
111
+ { word: '撸管', category: '擦边' }, { word: '自慰', category: '擦边' },
112
+ { word: '口交', category: '擦边' }, { word: '打炮', category: '擦边' },
113
+ { word: '约炮', category: '擦边' }, { word: '一夜情', category: '擦边' },
114
+ // 暴力血腥
115
+ { word: '碎尸', category: '暴力' }, { word: '分尸', category: '暴力' },
116
+ { word: '凌迟', category: '暴力' }, { word: '剥皮', category: '暴力' },
117
+ { word: '开膛', category: '暴力' }, { word: '剖腹', category: '暴力' },
118
+ { word: '挖心', category: '暴力' }, { word: '虐杀', category: '暴力' },
119
+ { word: '凌辱', category: '暴力' }, { word: '血腥', category: '暴力' },
120
+ { word: '大屠杀', category: '暴力' }, { word: '灭门', category: '暴力' },
121
+ { word: '满门抄斩', category: '暴力' }, { word: '腰斩', category: '暴力' },
122
+ { word: '活埋', category: '暴力' }, { word: '点天灯', category: '暴力' },
123
+ // 辱骂攻击
124
+ { word: '傻逼', category: '辱骂' }, { word: '傻B', category: '辱骂' },
125
+ { word: '草泥马', category: '辱骂' }, { word: '妈的', category: '辱骂' },
126
+ { word: '尼玛', category: '辱骂' }, { word: '去死', category: '辱骂' },
127
+ { word: '废物', category: '辱骂' }, { word: '垃圾', category: '辱骂' },
128
+ { word: '人渣', category: '辱骂' }, { word: '贱人', category: '辱骂' },
129
+ { word: '婊子', category: '辱骂' }, { word: '狗日的', category: '辱骂' },
130
+ // 广告引流
131
+ { word: '加微信', category: '广告' }, { word: '加QQ', category: '广告' },
132
+ { word: '微信公众号', category: '广告' }, { word: '淘宝', category: '广告' },
133
+ { word: '拼多多', category: '广告' }, { word: '刷单', category: '广告' },
134
+ { word: '充值返利', category: '广告' }, { word: '扫码领', category: '广告' },
135
+ { word: '加群领', category: '广告' }, { word: 'vx', category: '广告' },
136
+ { word: '扣扣', category: '广告' },
137
+ // 其他违禁
138
+ { word: '赌博', category: '其他' }, { word: '赌场', category: '其他' },
139
+ { word: '毒品', category: '其他' }, { word: '冰毒', category: '其他' },
140
+ { word: '摇头丸', category: '其他' }, { word: '自杀方法', category: '其他' },
141
+ { word: '邪教', category: '其他' }, { word: '传销', category: '其他' },
142
+ { word: '军火', category: '其他' }, { word: '枪支', category: '其他' },
143
+ { word: '管制刀具', category: '其他' },
144
+ ]
145
+
146
+ /** 对一段文本做违禁词硬匹配,返回命中(词/类别/次数)。 */
147
+ export function checkSensitiveText(text: string): Array<{ word: string; category: string; count: number }> {
148
+ const hits: Array<{ word: string; category: string; count: number }> = []
149
+ for (const entry of SENSITIVE_WORDS) {
150
+ let count = 0
151
+ let idx = text.indexOf(entry.word)
152
+ while (idx !== -1) {
153
+ count++
154
+ idx = text.indexOf(entry.word, idx + entry.word.length)
155
+ }
156
+ if (count > 0) hits.push({ word: entry.word, category: entry.category, count })
157
+ }
158
+ return hits
159
+ }
160
+
84
161
  /** List generated chapter files in the output dir (sorted). */
85
162
  export function listChapterFiles(outputDir: string): string[] {
86
163
  if (!existsSync(outputDir)) return []
@@ -337,6 +414,7 @@ export async function extractBible(ctx: Context, config: NovelConfig, outline: s
337
414
  traits: strArray(entry.traits),
338
415
  goals: typeof entry.goals === 'string' ? entry.goals : '',
339
416
  relations: typeof entry.relations === 'string' ? entry.relations : '',
417
+ knowledge: strArray(entry.knowledge),
340
418
  }))
341
419
  .filter(card => card.name !== '')
342
420
  : []
@@ -443,6 +521,9 @@ function writeSystemPrompt(project: ProjectState): string {
443
521
  for (const card of bible.characters) {
444
522
  const roleName = { protagonist: '主角', supporting: '配角', antagonist: '反派', other: '其他' }[card.role]
445
523
  sections.push(`- ${card.name}(${roleName}):${card.traits.join('、')}${card.goals !== '' ? `;目标:${card.goals}` : ''}${card.relations !== '' ? `;关系:${card.relations}` : ''}`)
524
+ if (Array.isArray(card.knowledge) && card.knowledge.length > 0) {
525
+ sections.push(` 已知信息(该角色知道的:${card.knowledge.join(';')};未列出的信息该角色一律不知道,不得写其知晓或提及)`)
526
+ }
446
527
  }
447
528
  }
448
529
  if (bible.redLines.length > 0) sections.push('写作红线(违反即失败):\n' + bible.redLines.map(r => `- ${r}`).join('\n'))
@@ -466,6 +547,14 @@ function writeSystemPrompt(project: ProjectState): string {
466
547
  sections.push(`- [${f.status === 'planted' ? '已埋设' : '推进中'}] ${f.description}${f.targetChapter !== undefined ? `(预计 ${f.targetChapter} 章回收)` : ''}`)
467
548
  }
468
549
  }
550
+ const lines = (project.plotlines ?? []).filter(l => l.status === 'active' || l.status === 'paused')
551
+ if (lines.length > 0) {
552
+ const kindName = { main: '主线', branch: '支线', character: '人物线', mystery: '悬念线' }
553
+ sections.push('==================== 剧情线(本章应推进至少一条活跃线) ====================')
554
+ for (const l of lines) {
555
+ sections.push(`- [${kindName[l.kind]}${l.status === 'paused' ? '·暂停中' : ''}] ${l.name}:${l.goal}${l.progress !== '' ? `(当前进度:${l.progress})` : ''}`)
556
+ }
557
+ }
469
558
  sections.push('')
470
559
  sections.push('写作硬性要求:')
471
560
  sections.push('1. 每章 3000-4000 字(按中文字符计),只输出章节正文,不要输出标题、章回名、作者的话或任何 Markdown 标记。')
@@ -606,6 +695,9 @@ function reviewSystemPrompt(project: ProjectState): string {
606
695
  sections.push('角色卡:')
607
696
  for (const card of bible.characters) {
608
697
  sections.push(`- ${card.name}(${card.role}):${card.traits.join('、')}`)
698
+ if (Array.isArray(card.knowledge) && card.knowledge.length > 0) {
699
+ sections.push(` 该角色知道:${card.knowledge.join(';')}(未列出的信息该角色不知道)`)
700
+ }
609
701
  }
610
702
  }
611
703
  if (bible.redLines.length > 0) sections.push('红线:\n' + bible.redLines.map(r => `- ${r}`).join('\n'))
@@ -700,6 +792,292 @@ export async function reviewChapterText(
700
792
  }
701
793
  }
702
794
 
795
+ /** Build the author-review system prompt (narrative structure, not prose). */
796
+ function authorReviewSystemPrompt(): string {
797
+ return [
798
+ '你是一位网文作者复盘助手。你会收到:本章正文、上一章结尾(钩子)、上一章作者复盘(如有)、活跃剧情线与编年录近期事实。',
799
+ '请从叙事结构层面复盘本章(不评文笔,那是审稿的事):',
800
+ '1. hookHonored:上一章结尾的钩子/悬念是否在本章兑现或推进(true/false)。',
801
+ '2. hookNote:钩子兑现情况一句话;未兑现时说明并给出"建议在第几章补"的建议。',
802
+ '3. endingHook:本章结尾钩子强度,0-10 的整数(低于 6 说明结尾平淡,读者可能不想看下一章)。',
803
+ '4. plotlineProgress:本章推进了哪条剧情线(主线/支线名),或"无实质推进"(连续无推进要提醒)。',
804
+ '5. advancedLines:本章实际推进的剧情线名称数组——从「活跃剧情线」清单中选出推进了的线(名称必须与清单中的线名一字不差;没推进任何线则输出空数组)。',
805
+ '6. continuity:与上一章结尾的衔接检查(人物位置/时间/伤势/资源/对话状态),发现问题要指出。',
806
+ '7. trend:结合上一章复盘看近期节奏趋势(是否连续拖沓、爽点密度是否下降、是否需要调整)。',
807
+ '输出必须是合法 JSON 对象,不要输出任何其他文字:',
808
+ '{"hookHonored": true或false, "hookNote": "一句话", "endingHook": 0-10整数, "plotlineProgress": "一句话", "advancedLines": ["线名"], "continuity": "一句话", "trend": "一句话"}',
809
+ '重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
810
+ '重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。',
811
+ ].join('\n')
812
+ }
813
+
814
+ /** 作者复盘:对一章做叙事结构复盘(钩子兑现/结尾钩子/推进/连续性/趋势)。 */
815
+ export async function authorReviewChapter(
816
+ ctx: Context,
817
+ config: NovelConfig,
818
+ project: ProjectState,
819
+ chapterNo: number,
820
+ body: string,
821
+ prevTail: string,
822
+ ): Promise<AuthorReview> {
823
+ const chapter = project.chapters.find(c => c.no === chapterNo)
824
+ const prevChapter = chapterNo > 1 ? project.chapters.find(c => c.no === chapterNo - 1) : undefined
825
+ const lines = (project.plotlines ?? []).filter(l => l.status === 'active' || l.status === 'paused')
826
+ const facts = (project.facts ?? []).slice(-10)
827
+ const user = [
828
+ `书名:《${project.bookName}》`,
829
+ chapter !== undefined ? `本章:第 ${chapter.no} 章《${chapter.title}》` : `本章:第 ${chapterNo} 章`,
830
+ prevTail !== ''
831
+ ? `==================== 上一章(第 ${chapterNo - 1} 章)结尾(钩子) ====================\n${prevTail}`
832
+ : '(本书第一章,无上一章钩子;hookHonored 视为 true,hookNote 写"开篇无前置钩子")',
833
+ prevChapter?.authorReview !== undefined
834
+ ? `==================== 上一章作者复盘 ====================\n${JSON.stringify(prevChapter.authorReview)}`
835
+ : '',
836
+ lines.length > 0
837
+ ? `==================== 活跃剧情线 ====================\n${lines.map(l => `- [${l.kind}] ${l.name}:${l.goal}${l.progress !== '' ? `(${l.progress})` : ''}`).join('\n')}`
838
+ : '',
839
+ facts.length > 0
840
+ ? `==================== 编年录近期事实 ====================\n${facts.map(f => `[第${f.chapterNo}章] ${f.text}`).join('\n')}`
841
+ : '',
842
+ '==================== 本章正文 ====================',
843
+ body.slice(0, 16000),
844
+ '',
845
+ '只输出 JSON 对象。',
846
+ ].join('\n')
847
+ const raw = parseJsonObject<{
848
+ hookHonored?: unknown
849
+ hookNote?: unknown
850
+ endingHook?: unknown
851
+ plotlineProgress?: unknown
852
+ advancedLines?: unknown
853
+ continuity?: unknown
854
+ trend?: unknown
855
+ }>(
856
+ await complete(ctx, config, { system: authorReviewSystemPrompt(), user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 4000) }),
857
+ )
858
+ // 解析推进的线名(与项目线名精确匹配;过滤不存在的名字)。
859
+ const knownLineNames = new Set((project.plotlines ?? []).map(l => l.name))
860
+ const advancedLines = Array.isArray(raw.advancedLines)
861
+ ? raw.advancedLines.filter((n): n is string => typeof n === 'string' && n.trim() !== '' && knownLineNames.has(n.trim())).map(n => n.trim())
862
+ : []
863
+ return {
864
+ hookHonored: raw.hookHonored === true,
865
+ hookNote: typeof raw.hookNote === 'string' ? raw.hookNote.slice(0, 200) : '',
866
+ endingHook: typeof raw.endingHook === 'number' ? Math.max(0, Math.min(10, Math.round(raw.endingHook))) : 5,
867
+ plotlineProgress: typeof raw.plotlineProgress === 'string' ? raw.plotlineProgress.slice(0, 200) : '',
868
+ advancedLines,
869
+ continuity: typeof raw.continuity === 'string' ? raw.continuity.slice(0, 200) : '',
870
+ trend: typeof raw.trend === 'string' ? raw.trend.slice(0, 200) : '',
871
+ reviewedAt: new Date().toISOString(),
872
+ }
873
+ }
874
+
875
+ /** 复盘后自动关联:把本章号写入复盘标记推进的剧情线(按名称匹配,去重)。 */
876
+ export function autoLinkPlotlines(project: ProjectState, chapterNo: number, advancedLines: string[]): void {
877
+ if (!Array.isArray(project.plotlines) || advancedLines.length === 0) return
878
+ for (const line of project.plotlines) {
879
+ if (advancedLines.includes(line.name) && !line.chapters.includes(chapterNo)) {
880
+ line.chapters.push(chapterNo)
881
+ }
882
+ }
883
+ }
884
+
885
+ /** AI 建议剧情线:基于大纲/卷计划/已写章节/编年录,提炼候选线。 */
886
+ export async function suggestPlotlines(ctx: Context, config: NovelConfig, project: ProjectState): Promise<Plotline[]> {
887
+ const system = [
888
+ '你是一位网文剧情架构师。根据本书的大纲、卷计划、已写章节标题与编年录,为作者提炼建议的剧情线(主线/支线/人物线/悬念线)。',
889
+ '每条线要:名称简洁有力;目标写清楚这条线最终要完成什么;progress 写当前推进到哪(没有就空字符串)。',
890
+ '建议 4-8 条,覆盖:1 条主线、1-2 条人物线、1-2 条悬念线、1-3 条支线。避免与大纲明显重复的废话线。',
891
+ '输出必须是合法 JSON 数组,格式:[{"name": "线名", "kind": "main|branch|character|mystery", "goal": "目标", "progress": ""}]',
892
+ '重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
893
+ '重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。',
894
+ ].join('\n')
895
+ const written = project.chapters.filter(c => c.status !== 'pending')
896
+ const user = [
897
+ `书名:《${project.bookName}》`,
898
+ `大纲(节选前 4000 字):\n${project.outline.slice(0, 4000)}`,
899
+ project.volumes !== undefined && project.volumes.length > 0
900
+ ? `卷计划:\n${project.volumes.map(v => `第${v.no}卷《${v.title}》:${v.summary}`).join('\n')}`
901
+ : '',
902
+ written.length > 0
903
+ ? `已写章节:\n${written.map(c => `第${c.no}章《${c.title}》${c.summary !== undefined && c.summary !== '' ? `:${c.summary.slice(0, 80)}` : ''}`).join('\n')}`
904
+ : '',
905
+ (project.facts ?? []).length > 0
906
+ ? `编年录近期事实(最近 15 条):\n${(project.facts ?? []).slice(-15).map(f => `[第${f.chapterNo}章] ${f.text.slice(0, 100)}`).join('\n')}`
907
+ : '',
908
+ '只输出 JSON 数组。',
909
+ ].join('\n\n')
910
+ const text = await complete(ctx, config, { system, user, temperature: 0.6, maxTokens: Math.max(config.maxTokens, 4000) })
911
+ const raw = parseJsonArray<Record<string, unknown>>(text)
912
+ const lines: Plotline[] = []
913
+ const kinds = new Set(['main', 'branch', 'character', 'mystery'])
914
+ for (const entry of raw) {
915
+ if (typeof entry !== 'object' || entry === null) continue
916
+ const name = typeof entry.name === 'string' ? entry.name.trim().slice(0, 40) : ''
917
+ if (name === '') continue
918
+ lines.push({
919
+ id: '',
920
+ name,
921
+ kind: kinds.has(entry.kind as string) ? entry.kind as Plotline['kind'] : 'branch',
922
+ goal: typeof entry.goal === 'string' ? entry.goal.trim().slice(0, 300) : '',
923
+ progress: typeof entry.progress === 'string' ? entry.progress.trim().slice(0, 300) : '',
924
+ status: 'active',
925
+ chapters: [],
926
+ createdAt: new Date().toISOString(),
927
+ })
928
+ }
929
+ return lines
930
+ }
931
+
932
+ /** AI 刷新单条剧情线的进度:结合编年录与各章摘要分析该线推进到哪。 */
933
+ export async function refreshPlotlineProgress(
934
+ ctx: Context,
935
+ config: NovelConfig,
936
+ project: ProjectState,
937
+ line: Plotline,
938
+ ): Promise<string> {
939
+ const system = [
940
+ '你是一位网文剧情线管理员。请根据「剧情线信息」与「本书已写章节摘要/编年录」,判断这条线目前推进到了哪一步。',
941
+ '输出一句话(30-60 字):这条线当前的状态、最近一次推进发生在第几章、下一步可能的方向。如果这条线还没开始推进,明确说"尚未推进"。',
942
+ '输出必须是合法 JSON 对象:{"progress": "一句话"}',
943
+ '重要:不要输出任何其他文字。',
944
+ ].join('\n')
945
+ const written = project.chapters.filter(c => c.status !== 'pending' && (c.summary !== undefined && c.summary !== ''))
946
+ const user = [
947
+ `剧情线:${line.name}(${line.kind})`,
948
+ `目标:${line.goal}`,
949
+ `已知进度:${line.progress !== '' ? line.progress : '(无)'}`,
950
+ `已关联章节:${line.chapters.length > 0 ? line.chapters.map(n => `第${n}章`).join('、') : '(无)'}`,
951
+ `章节摘要(最近 8 章):\n${written.slice(-8).map(c => `第${c.no}章《${c.title}》:${c.summary!.slice(0, 120)}`).join('\n')}`,
952
+ (project.facts ?? []).length > 0
953
+ ? `编年录近期事实(最近 15 条):\n${(project.facts ?? []).slice(-15).map(f => `[第${f.chapterNo}章] ${f.text.slice(0, 100)}`).join('\n')}`
954
+ : '',
955
+ '只输出 JSON 对象。',
956
+ ].join('\n\n')
957
+ const raw = parseJsonObject<{ progress?: unknown }>(
958
+ await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 2000) }),
959
+ )
960
+ return typeof raw.progress === 'string' ? raw.progress.trim().slice(0, 300) : ''
961
+ }
962
+
963
+ /** 🩺 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
964
+ export async function analyzePlotlineHealth(
965
+ ctx: Context,
966
+ config: NovelConfig,
967
+ project: ProjectState,
968
+ ): Promise<PlotlineHealthReport> {
969
+ const system = [
970
+ '你是一位网文剧情架构师。请对本书的「剧情线体系」做健康检查,判断当前是否需要新增剧情线、应在多少章后添加。',
971
+ '评估维度:各线最近推进到第几章(已写章节与关联章节的差值越大越危险)、各线状态、已写章节总数、卷计划当前进度、编年录近期事实。',
972
+ '输出规则:',
973
+ '1. verdict:一句话结论——"需要新增线" / "暂不需要" / "再写 N 章后需要"(N 给出具体章数)。',
974
+ '2. timing:说明建议添加的时机(如:第 25 章前引入新支线,因为主线预计第 22 章告一段落)。',
975
+ '3. reasons:3-5 条依据(引用具体数据:哪条线多少章没推进、已写章节数、卷进度等)。',
976
+ '4. lines:对每条线给健康度——ok(近期推进过)/ warning(超过 5 章未推进)/ stale(超过 10 章未推进或悬置过久)。',
977
+ '输出必须是合法 JSON 对象:{"verdict": "...", "timing": "...", "reasons": ["..."], "lines": [{"name": "线名", "health": "ok|warning|stale", "note": "一句说明"}]}',
978
+ '重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
979
+ '重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。',
980
+ ].join('\n')
981
+ const written = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating')
982
+ const lines = (project.plotlines ?? []).filter(l => l.status === 'active' || l.status === 'paused')
983
+ const user = [
984
+ `书名:《${project.bookName}》`,
985
+ `已写章节数:${written.length}(最新章号 ${written.length > 0 ? written[written.length - 1]!.no : 0})`,
986
+ project.volumes !== undefined && project.volumes.length > 0
987
+ ? `卷计划:\n${project.volumes.map(v => `第${v.no}卷《${v.title}》(${v.chapterStart}-${v.chapterEnd}):${v.summary.slice(0, 60)}`).join('\n')}`
988
+ : '',
989
+ `剧情线(${lines.length} 条):\n${lines.length > 0
990
+ ? lines.map(l => `- [${l.kind}] ${l.name}|目标:${l.goal}|进度:${l.progress !== '' ? l.progress : '未推进'}|最近关联章节:${l.chapters.length > 0 ? '第' + Math.max(...l.chapters) + '章' : '无'}`).join('\n')
991
+ : '(暂无剧情线)'}`,
992
+ (project.facts ?? []).length > 0
993
+ ? `编年录近期事实(最近 10 条):\n${(project.facts ?? []).slice(-10).map(f => `[第${f.chapterNo}章] ${f.text.slice(0, 80)}`).join('\n')}`
994
+ : '',
995
+ '只输出 JSON 对象。',
996
+ ].join('\n\n')
997
+ const raw = parseJsonObject<{
998
+ verdict?: unknown
999
+ timing?: unknown
1000
+ reasons?: unknown
1001
+ lines?: unknown
1002
+ }>(await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 3000) }))
1003
+ const strArr = (v: unknown): string[] => Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '') : []
1004
+ const lineArr = Array.isArray(raw.lines)
1005
+ ? raw.lines
1006
+ .filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
1007
+ .map(entry => ({
1008
+ name: typeof entry.name === 'string' ? entry.name.slice(0, 40) : '',
1009
+ health: (['ok', 'warning', 'stale'] as const).includes(entry.health as never) ? entry.health as 'ok' | 'warning' | 'stale' : 'ok',
1010
+ note: typeof entry.note === 'string' ? entry.note.slice(0, 150) : '',
1011
+ }))
1012
+ .filter(x => x.name !== '')
1013
+ : []
1014
+ return {
1015
+ verdict: typeof raw.verdict === 'string' ? raw.verdict.slice(0, 100) : '',
1016
+ timing: typeof raw.timing === 'string' ? raw.timing.slice(0, 200) : '',
1017
+ reasons: strArr(raw.reasons).map(r => r.slice(0, 200)),
1018
+ lines: lineArr,
1019
+ }
1020
+ }
1021
+
1022
+ /** ✨ AI 剧情方案:基于健康检查结果设计下一阶段方向与建议新线。 */
1023
+ export async function designPlotlinePlan(
1024
+ ctx: Context,
1025
+ config: NovelConfig,
1026
+ project: ProjectState,
1027
+ health?: PlotlineHealthReport,
1028
+ ): Promise<PlotlinePlan> {
1029
+ const system = [
1030
+ '你是一位网文剧情架构师。请为本书设计「下一阶段的剧情方案」:给出未来 5-10 章的剧情方向,并建议 2-3 条值得新增的剧情线。',
1031
+ '要求:方向必须结合本书大纲/卷计划/现有线/编年录;新线要能落地(和当前主角处境、已有伏笔、下一阶段舞台相关),不得重复已有线。',
1032
+ '输出必须是合法 JSON 对象:{"direction": "下一阶段方向 60-120 字", "suggestions": [{"name": "线名", "kind": "main|branch|character|mystery", "goal": "目标", "progress": "初始进度(可空)"}]}',
1033
+ '重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
1034
+ '重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。',
1035
+ ].join('\n')
1036
+ const written = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating')
1037
+ const user = [
1038
+ `书名:《${project.bookName}》`,
1039
+ health !== undefined
1040
+ ? `健康检查结论:\n判定:${health.verdict}\n时机:${health.timing}\n依据:${health.reasons.join(';')}`
1041
+ : '',
1042
+ `大纲(节选前 3000 字):\n${project.outline.slice(0, 3000)}`,
1043
+ project.volumes !== undefined && project.volumes.length > 0
1044
+ ? `卷计划:\n${project.volumes.map(v => `第${v.no}卷《${v.title}》:${v.summary.slice(0, 60)}`).join('\n')}`
1045
+ : '',
1046
+ `现有剧情线:\n${(project.plotlines ?? []).map(l => `- [${l.kind}${l.status === 'resolved' ? '·已完结' : ''}] ${l.name}:${l.goal}`).join('\n') || '(无)'}`,
1047
+ written.length > 0
1048
+ ? `最近写的章节:\n${written.slice(-5).map(c => `第${c.no}章《${c.title}》`).join('、')}`
1049
+ : '',
1050
+ '只输出 JSON 对象。',
1051
+ ].join('\n\n')
1052
+ const raw = parseJsonObject<{ direction?: unknown; suggestions?: unknown }>(
1053
+ await complete(ctx, config, { system, user, temperature: 0.6, maxTokens: Math.max(config.maxTokens, 3000) }),
1054
+ )
1055
+ const suggestions: Plotline[] = []
1056
+ const kinds = new Set(['main', 'branch', 'character', 'mystery'])
1057
+ if (Array.isArray(raw.suggestions)) {
1058
+ for (const entry of raw.suggestions) {
1059
+ if (typeof entry !== 'object' || entry === null) continue
1060
+ const e = entry as Record<string, unknown>
1061
+ const name = typeof e.name === 'string' ? e.name.trim().slice(0, 40) : ''
1062
+ if (name === '') continue
1063
+ suggestions.push({
1064
+ id: '',
1065
+ name,
1066
+ kind: kinds.has(e.kind as string) ? e.kind as Plotline['kind'] : 'branch',
1067
+ goal: typeof e.goal === 'string' ? e.goal.trim().slice(0, 300) : '',
1068
+ progress: typeof e.progress === 'string' ? e.progress.trim().slice(0, 300) : '',
1069
+ status: 'active',
1070
+ chapters: [],
1071
+ createdAt: new Date().toISOString(),
1072
+ })
1073
+ }
1074
+ }
1075
+ return {
1076
+ direction: typeof raw.direction === 'string' ? raw.direction.slice(0, 300) : '',
1077
+ suggestions,
1078
+ }
1079
+ }
1080
+
703
1081
  /** Build the rewrite system prompt (fix review issues / instructions). */
704
1082
  function rewriteSystemPrompt(project: ProjectState): string {
705
1083
  const base = writeSystemPrompt(project)
package/src/index.ts CHANGED
@@ -52,6 +52,8 @@ export interface Config {
52
52
  reviewPassScore?: number
53
53
  /** Whether generation auto-runs review after writing. */
54
54
  autoReview?: boolean
55
+ /** Whether generation auto-runs the author review (hook/continuity/trend). */
56
+ autoAuthorReview?: boolean
55
57
  }
56
58
 
57
59
  export const Config: z<Config> = z.object({
@@ -65,6 +67,7 @@ export const Config: z<Config> = z.object({
65
67
  maxTokens: z.number().default(12000),
66
68
  reviewPassScore: z.number().default(70),
67
69
  autoReview: z.boolean().default(true),
70
+ autoAuthorReview: z.boolean().default(true),
68
71
  })
69
72
 
70
73
  /** Schema defaults, re-read for hand-built test contexts. */
@@ -77,6 +80,7 @@ const DEFAULT_CHAPTER_CHARS = 3500
77
80
  const DEFAULT_MAX_TOKENS = 12000
78
81
  const DEFAULT_REVIEW_PASS_SCORE = 70
79
82
  const DEFAULT_AUTO_REVIEW = true
83
+ const DEFAULT_AUTO_AUTHOR_REVIEW = true
80
84
 
81
85
  /** Order of the announcement section within the tool-guidance band. */
82
86
  const SECTION_ORDER = 160
@@ -95,6 +99,7 @@ export function resolveConfig(value: Partial<Config> | undefined): NovelConfig {
95
99
  maxTokens: value?.maxTokens ?? DEFAULT_MAX_TOKENS,
96
100
  reviewPassScore: value?.reviewPassScore ?? DEFAULT_REVIEW_PASS_SCORE,
97
101
  autoReview: value?.autoReview ?? DEFAULT_AUTO_REVIEW,
102
+ autoAuthorReview: value?.autoAuthorReview ?? DEFAULT_AUTO_AUTHOR_REVIEW,
98
103
  }
99
104
  }
100
105
 
@@ -127,6 +132,7 @@ export function apply(ctx: Context, config?: Config): void {
127
132
  if (patch.maxTokens !== undefined) next.maxTokens = patch.maxTokens
128
133
  if (patch.reviewPassScore !== undefined) next.reviewPassScore = patch.reviewPassScore
129
134
  if (patch.autoReview !== undefined) next.autoReview = patch.autoReview
135
+ if (patch.autoAuthorReview !== undefined) next.autoAuthorReview = patch.autoAuthorReview
130
136
  // Persist through the settings seam when available; otherwise keep in memory.
131
137
  // (ctx.get is the non-strict service access — no inject requirement, same
132
138
  // pattern installSettingsSection itself uses.)