@waterwx/dsh-novel-forge 0.1.0 → 1.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/src/engine.ts CHANGED
@@ -12,12 +12,13 @@ import { createUserMessage, BlockAssembler, ReasoningEffortId, type GenerateOpti
12
12
  import type { Context } from '@deepseek-ai/cordis'
13
13
  import { emptyProjectAssets, renderAllAssets, styleEngineSystemPrompt } from './assets.ts'
14
14
  import type {
15
+ AuditIssue,
15
16
  ChapterPlan,
16
- CharacterCard,
17
17
  Foreshadow,
18
18
  NovelConfig,
19
19
  ProjectState,
20
20
  ReviewReport,
21
+ RoleStatusCard,
21
22
  StoryBible,
22
23
  Volume,
23
24
  } from './protocol.ts'
@@ -66,6 +67,7 @@ export function loadProject(outputDir: string): ProjectState | undefined {
66
67
  if (!Array.isArray(raw.assets.antiAiRules)) raw.assets.antiAiRules = []
67
68
  if (!Array.isArray(raw.assets.auxiliaryProgressions)) raw.assets.auxiliaryProgressions = []
68
69
  if (!Array.isArray(raw.assets.styleAssets)) raw.assets.styleAssets = []
70
+ if (!Array.isArray(raw.facts)) raw.facts = []
69
71
  return raw
70
72
  } catch {
71
73
  return undefined
@@ -83,7 +85,7 @@ export function listChapterFiles(outputDir: string): string[] {
83
85
  if (!existsSync(outputDir)) return []
84
86
  try {
85
87
  return readdirSync(outputDir)
86
- .filter(name => /^第\d+章_.*\.md$/.test(name))
88
+ .filter(name => /^第\d+章_.*\.md$/.test(name) && !name.endsWith('.bak.md'))
87
89
  .sort((a, b) => {
88
90
  const na = Number(/^第(\d+)章/.exec(a)?.[1] ?? 0)
89
91
  const nb = Number(/^第(\d+)章/.exec(b)?.[1] ?? 0)
@@ -129,6 +131,7 @@ export function createProject(outline: string, outlinePath?: string): ProjectSta
129
131
  chapters: [],
130
132
  foreshadows: [],
131
133
  assets: emptyProjectAssets(),
134
+ facts: [],
132
135
  createdAt: now,
133
136
  updatedAt: now,
134
137
  }
@@ -312,13 +315,13 @@ export async function extractBible(ctx: Context, config: NovelConfig, outline: s
312
315
  }>(text)
313
316
  const strArray = (value: unknown): string[] =>
314
317
  Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string' && v.trim() !== '') : []
315
- const characters: CharacterCard[] = Array.isArray(raw.characters)
318
+ const characters: StoryBible['characters'] = Array.isArray(raw.characters)
316
319
  ? raw.characters
317
320
  .filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
318
321
  .map(entry => ({
319
322
  name: typeof entry.name === 'string' ? entry.name.trim() : '未命名',
320
323
  role: (['protagonist', 'supporting', 'antagonist', 'other'] as const).includes(entry.role as never)
321
- ? entry.role as CharacterCard['role']
324
+ ? entry.role as StoryBible['characters'][number]['role']
322
325
  : 'other',
323
326
  traits: strArray(entry.traits),
324
327
  goals: typeof entry.goals === 'string' ? entry.goals : '',
@@ -409,8 +412,8 @@ function planSystemPrompt(volumes: Volume[] | undefined): string {
409
412
  '2. 章节之间要衔接自然,前章结尾为后章埋下钩子。',
410
413
  '3. 严格遵循大纲的人设、金手指规则、战力体系与世界观设定,不得自行发明冲突设定。',
411
414
  '4. 输出必须是合法的 JSON 数组,不要输出任何其他文字或 Markdown 代码块标记。',
412
- '5. 数组每个元素格式:{"title": "章节标题(10字以内,有网文感)", "beats": "本章剧情要点(150-250字,含起承转合与钩子)"}',
413
- '重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
415
+ '5. 数组每个元素格式:{"title": "章节标题(10字以内,有网文感)", "beats": "结构化剧情要点(150-250字,必须包含四段,段间用换行分隔):\\n本章目标:本章要完成的核心推进;\\n剧情要点:主要情节的起承转合(2-4 句);\\n爽点/钩子:本章的爽点兑现或情绪钩子;\\n结尾钩子:本章结尾为下一章埋下的悬念"}',
416
+ '重要:beats 字段内部必须使用 \\n 转义表示换行(JSON 字符串内不得有真实换行符),其余字符串值也不得包含真实换行符,JSON 必须在一段内完整结束。',
414
417
  '重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。',
415
418
  volumeBlock,
416
419
  ].join('\n')
@@ -608,7 +611,7 @@ export async function* rewriteChapterStream(
608
611
  chapterNo: number,
609
612
  instructions: string,
610
613
  target?: string,
611
- ): AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'done'; file: string; chars: number }, void, unknown> {
614
+ ): AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'drafted'; chars: number; draft: string }, void, unknown> {
612
615
  const chapter = project.chapters.find(c => c.no === chapterNo)
613
616
  if (chapter === undefined) throw new Error(`章节 ${chapterNo} 不在计划中`)
614
617
  const body = readChapterFile(outputDir, chapter)
@@ -711,16 +714,14 @@ export async function* rewriteChapterStream(
711
714
  }
712
715
  if (newBody.length < 100) throw new Error('修订结果过短,可能失败,请重试')
713
716
 
714
- const fileName = chapterFileName(chapter)
715
- const markdown = `# 第${chapter.no}章 ${chapter.title}\n\n${newBody}\n`
716
- writeFileSync(join(outputDir, fileName), markdown, 'utf8')
717
- chapter.status = 'written'
718
- chapter.chars = newBody.length
717
+ // Draft mode: do NOT overwrite the file yet. Store the new body as a
718
+ // pending draft; the user reviews the diff and decides to apply or
719
+ // discard. File overwrite + status change happen on draft/apply.
720
+ chapter.pendingDraft = newBody
719
721
  chapter.error = undefined
720
- chapter.review = undefined
721
722
  project.updatedAt = new Date().toISOString()
722
723
  saveProject(outputDir, project)
723
- yield { frame: 'done', file: fileName, chars: newBody.length }
724
+ yield { frame: 'drafted', chars: newBody.length, draft: newBody }
724
725
  }
725
726
 
726
727
  /** The de-AI-ify polish system prompt (with project writing assets injected). */
@@ -738,14 +739,15 @@ function polishSystemPrompt(project: ProjectState): string {
738
739
  ].join('\n')
739
740
  }
740
741
 
741
- /** Stream a chapter polish (de-AI-ify). */
742
+ /** Stream a chapter polish (de-AI-ify). Draft-mode: the polished body lands
743
+ * in `chapter.pendingDraft` and is only applied on draft/apply. */
742
744
  export async function* polishChapterStream(
743
745
  ctx: Context,
744
746
  config: NovelConfig,
745
747
  project: ProjectState,
746
748
  outputDir: string,
747
749
  chapterNo: number,
748
- ): AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'done'; file: string; chars: number }, void, unknown> {
750
+ ): AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'drafted'; chars: number; draft: string }, void, unknown> {
749
751
  const chapter = project.chapters.find(c => c.no === chapterNo)
750
752
  if (chapter === undefined) throw new Error(`章节 ${chapterNo} 不在计划中`)
751
753
  const body = readChapterFile(outputDir, chapter)
@@ -786,13 +788,12 @@ export async function* polishChapterStream(
786
788
  .trim()
787
789
  if (streamError !== undefined) throw streamError
788
790
  if (newBody.length < 100) throw new Error('润色结果过短,可能失败,请重试')
789
- const fileName = chapterFileName(chapter)
790
- writeFileSync(join(outputDir, fileName), `# 第${chapter.no}章 ${chapter.title}\n\n${newBody}\n`, 'utf8')
791
- chapter.status = 'written'
792
- chapter.chars = newBody.length
791
+
792
+ // Draft mode: keep the original file untouched until the user decides.
793
+ chapter.pendingDraft = newBody
793
794
  project.updatedAt = new Date().toISOString()
794
795
  saveProject(outputDir, project)
795
- yield { frame: 'done', file: fileName, chars: newBody.length }
796
+ yield { frame: 'drafted', chars: newBody.length, draft: newBody }
796
797
  }
797
798
 
798
799
  /** Generate one chapter (streaming). Yields progress frames; persists when done. */
@@ -820,11 +821,16 @@ export async function* generateChapterStream(
820
821
  }
821
822
  }
822
823
  const prevSummary = prev?.summary
824
+ // 事实库注入:最近 20 条已确立事实,约束本章不与既定状态矛盾。
825
+ const recentFacts = (project.facts ?? []).slice(-20).map(f => f.text)
823
826
 
824
827
  const user = [
825
828
  `现在写第 ${chapter.no} 章,标题《${chapter.title}》。`,
826
829
  `本章剧情要点:${chapter.beats}`,
827
830
  '',
831
+ recentFacts.length > 0
832
+ ? `本书已确立的事实(新写内容不得与之矛盾):\n${recentFacts.join('\n')}`
833
+ : '',
828
834
  prevSummary !== undefined && prevSummary !== ''
829
835
  ? `上一章摘要:${prevSummary}`
830
836
  : '',
@@ -915,6 +921,214 @@ export async function summarizeChapter(
915
921
  return chapter.summary
916
922
  }
917
923
 
924
+ /**
925
+ * 抽取本章「已确立事实」追加到事实库/时间线(最多 300 条,最新优先)。
926
+ * 事实注入后续章节生成提示词,保证人物状态/境界/资源/关系长期一致。
927
+ * @returns 新增事实条数(失败返回 0,调用方 best-effort)。
928
+ */
929
+ export async function extractFacts(
930
+ ctx: Context,
931
+ config: NovelConfig,
932
+ project: ProjectState,
933
+ outputDir: string,
934
+ chapterNo: number,
935
+ ): Promise<number> {
936
+ const chapter = project.chapters.find(c => c.no === chapterNo)
937
+ if (chapter === undefined) return 0
938
+ const body = readChapterFile(outputDir, chapter)
939
+ if (body === undefined) return 0
940
+ const system = [
941
+ '你是一位网文编辑。请从本章正文中抽取「已确立事实」,供后续章节保持一致。',
942
+ '事实指:人物当前状态(境界/修为/伤势/资源/心境)、重要关系变化、地点与时间线、已落地或新增的伏笔线索、关键道具去向。',
943
+ '要求:',
944
+ '1. 只抽取本章明确写出的、对后续有约束力的内容;纯心理活动与无关细节不要。',
945
+ '2. 每行一条事实,用客观陈述句,不含主观评价。',
946
+ '3. 输出 3-6 条,每行一条,不要编号、不要前缀、不要解释。',
947
+ ].join('\n')
948
+ const user = body.replace(/^#\s+.*$/m, '').trim()
949
+ // v4-flash 推理模型:reasoning channel 也占 maxTokens,预算给足避免截断。
950
+ const text = await complete(ctx, config, { system, user, temperature: 0.2, maxTokens: Math.max(config.maxTokens, 4000) })
951
+ const lines = text.split('\n')
952
+ .map(line => line.replace(/^[-*\d.\s]+/, '').trim())
953
+ .filter(line => line.length > 8)
954
+ .slice(0, 8)
955
+ if (lines.length === 0) return 0
956
+ const facts = project.facts ?? []
957
+ for (const line of lines) facts.push({ chapterNo, text: line.slice(0, 140) })
958
+ project.facts = facts.slice(-300)
959
+ project.updatedAt = new Date().toISOString()
960
+ saveProject(outputDir, project)
961
+ return lines.length
962
+ }
963
+
964
+ // ------------------------------------------------------------ book audit
965
+
966
+ /** 全书一致性质检:LLM 扫描已生成章节 + 设定 + 事实库,输出矛盾清单。 */
967
+ export async function auditBook(
968
+ ctx: Context,
969
+ config: NovelConfig,
970
+ project: ProjectState,
971
+ outputDir: string,
972
+ ): Promise<AuditIssue[]> {
973
+ const written = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating')
974
+ if (written.length === 0) return []
975
+ const system = [
976
+ '你是一位严谨的网文连续性审校编辑。你会收到一本小说的设定圣经、事实库和各章正文节选。',
977
+ '请找出全书的一致性矛盾,例如:',
978
+ '- 人物状态冲突:境界/修为/伤势/资源在同一章内或跨章前后矛盾。',
979
+ '- 设定违背:正文与世界观规则、金手指规则、写作红线冲突。',
980
+ '- 时间线错乱:事件顺序、时间跨度、地点移动不合逻辑。',
981
+ '- 细节穿帮:人名/地名/物品/数字前后不一致。',
982
+ '要求:',
983
+ '1. 只报告有实质证据的矛盾,不要泛泛而谈写作质量问题。',
984
+ '2. 每条必须定位到具体章节号。',
985
+ '3. 输出必须是合法 JSON 数组,格式:[{"chapterNo": 章节号, "severity": "high|medium|low", "item": "矛盾描述", "suggestion": "修改建议"}]',
986
+ '重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
987
+ ].join('\n')
988
+ const factsBlock = (project.facts ?? []).slice(-80).map(f => `[第${f.chapterNo}章] ${f.text}`).join('\n')
989
+ const chapterBlocks = written.map(c => {
990
+ const body = readChapterFile(outputDir, c)
991
+ const excerpt = (body ?? '').replace(/^#\s+.*$/m, '').trim().slice(0, 700)
992
+ return `【第${c.no}章《${c.title}》】\n${excerpt}`
993
+ }).join('\n\n')
994
+ const user = [
995
+ '请对以下小说做全书一致性质检。',
996
+ project.bible !== undefined
997
+ ? '设定圣经:\n' + [
998
+ project.bible.worldRules.length > 0 ? `世界规则:\n${project.bible.worldRules.map(r => `- ${r}`).join('\n')}` : '',
999
+ project.bible.redLines.length > 0 ? `写作红线:\n${project.bible.redLines.map(r => `- ${r}`).join('\n')}` : '',
1000
+ project.bible.characters.length > 0 ? `角色:\n${project.bible.characters.map(ch => `- ${ch.name}(${ch.traits.join('、')})`).join('\n')}` : '',
1001
+ ].filter(s => s !== '').join('\n')
1002
+ : '',
1003
+ factsBlock !== '' ? `已确立事实库:\n${factsBlock}` : '',
1004
+ `正文节选(每章前 700 字):\n${chapterBlocks}`,
1005
+ '只输出 JSON 数组。',
1006
+ ].filter(s => s !== '').join('\n\n')
1007
+ const text = await complete(ctx, config, { system, user, temperature: 0.2, maxTokens: Math.max(config.maxTokens, 12000) })
1008
+ const parsed = parseJsonArray<Record<string, unknown>>(text)
1009
+ const issues: AuditIssue[] = []
1010
+ for (const entry of parsed) {
1011
+ const item = typeof entry.item === 'string' ? entry.item : ''
1012
+ if (item === '') continue
1013
+ issues.push({
1014
+ chapterNo: Number(entry.chapterNo) || 0,
1015
+ severity: ['high', 'medium', 'low'].includes(entry.severity as string)
1016
+ ? entry.severity as AuditIssue['severity']
1017
+ : 'medium',
1018
+ item,
1019
+ suggestion: typeof entry.suggestion === 'string' ? entry.suggestion : '',
1020
+ })
1021
+ }
1022
+ return issues.slice(0, 30)
1023
+ }
1024
+
1025
+ /**
1026
+ * 事实库回填:对历史已生成章节批量抽取事实(无事实记录的旧章节)。
1027
+ * @returns 回填的章节数。
1028
+ */
1029
+ export async function backfillFacts(
1030
+ ctx: Context,
1031
+ config: NovelConfig,
1032
+ project: ProjectState,
1033
+ outputDir: string,
1034
+ ): Promise<number> {
1035
+ const have = new Set((project.facts ?? []).map(f => f.chapterNo))
1036
+ let filled = 0
1037
+ for (const chapter of project.chapters) {
1038
+ if (chapter.status === 'pending' || chapter.status === 'generating') continue
1039
+ if (chapter.file === undefined || have.has(chapter.no)) continue
1040
+ try {
1041
+ const n = await extractFacts(ctx, config, project, outputDir, chapter.no)
1042
+ if (n > 0) filled++
1043
+ } catch { /* best-effort per chapter */ }
1044
+ have.add(chapter.no)
1045
+ }
1046
+ return filled
1047
+ }
1048
+
1049
+ /**
1050
+ * 角色卡刷新:出场统计由服务端从正文精确计算(角色名出现过的章节数、
1051
+ * 最近出现章节),LLM 只负责聚合「当前状态」一句话。
1052
+ */
1053
+ export async function refreshCharacters(
1054
+ ctx: Context,
1055
+ config: NovelConfig,
1056
+ project: ProjectState,
1057
+ outputDir: string,
1058
+ ): Promise<RoleStatusCard[]> {
1059
+ const roster = project.bible?.characters ?? []
1060
+ const facts = project.facts ?? []
1061
+ if (roster.length === 0 && facts.length === 0) return []
1062
+
1063
+ // 服务端精确出场统计:遍历已写章节正文,统计每个角色名出现过的章节。
1064
+ const stat = new Map<string, { chapters: Set<number>; last: number }>()
1065
+ const known = roster.map(card => card.name)
1066
+ for (const chapter of project.chapters) {
1067
+ if (chapter.status === 'pending' || chapter.status === 'generating') continue
1068
+ const body = readChapterFile(outputDir, chapter)
1069
+ if (body === undefined) continue
1070
+ for (const name of known) {
1071
+ if (body.includes(name)) {
1072
+ const entry = stat.get(name) ?? { chapters: new Set<number>(), last: 0 }
1073
+ entry.chapters.add(chapter.no)
1074
+ if (chapter.no > entry.last) entry.last = chapter.no
1075
+ stat.set(name, entry)
1076
+ }
1077
+ }
1078
+ }
1079
+
1080
+ // LLM 只聚合状态:名单(含 traits)+ 事实库 → [{name, status}]
1081
+ let statuses = new Map<string, string>()
1082
+ if (facts.length > 0) {
1083
+ const system = [
1084
+ '你是一位网文角色档案管理员。请根据「角色名单」与「已确立事实库」,为每个角色输出「当前状态」一句话(境界/修为/伤势/资源/心境)。',
1085
+ '输出必须是合法 JSON 数组,格式:[{"name": "角色名", "status": "当前状态一句话"}]',
1086
+ '重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
1087
+ ].join('\n')
1088
+ const rosterBlock = roster.map(ch => `- ${ch.name}(${ch.traits.join('、')})`).join('\n')
1089
+ const factsBlock = facts.map(f => `[第${f.chapterNo}章] ${f.text}`).join('\n')
1090
+ const user = [
1091
+ `角色名单:\n${rosterBlock}`,
1092
+ `已确立事实库(${facts.length} 条):\n${factsBlock.slice(-6000)}`,
1093
+ '只输出 JSON 数组。',
1094
+ ].join('\n\n')
1095
+ try {
1096
+ const text = await complete(ctx, config, { system, user, temperature: 0.2, maxTokens: Math.max(config.maxTokens, 8000) })
1097
+ for (const entry of parseJsonArray<Record<string, unknown>>(text)) {
1098
+ const name = typeof entry.name === 'string' ? entry.name : ''
1099
+ if (name !== '' && typeof entry.status === 'string') statuses.set(name, entry.status)
1100
+ }
1101
+ } catch { /* status 聚合失败则只给出场统计 */ }
1102
+ }
1103
+
1104
+ // 合并:出场统计(精确)+ 状态(LLM)+ 名单角色补全。
1105
+ const cards: RoleStatusCard[] = []
1106
+ const roleOf = (name: string): string => roster.find(c => c.name === name)?.role ?? 'other'
1107
+ for (const card of roster) {
1108
+ const entry = stat.get(card.name)
1109
+ cards.push({
1110
+ name: card.name,
1111
+ role: card.role,
1112
+ status: statuses.get(card.name) ?? '',
1113
+ lastChapter: entry?.last ?? 0,
1114
+ appearances: entry?.chapters.size ?? 0,
1115
+ })
1116
+ }
1117
+ // 名单外的角色(从事实库中识别到但不在设定圣经名单)仅当有出场统计时补充。
1118
+ for (const [name, entry] of stat) {
1119
+ if (!cards.some(c => c.name === name)) {
1120
+ cards.push({
1121
+ name,
1122
+ role: roleOf(name),
1123
+ status: statuses.get(name) ?? '',
1124
+ lastChapter: entry.last,
1125
+ appearances: entry.chapters.size,
1126
+ })
1127
+ }
1128
+ }
1129
+ return cards
1130
+ }
1131
+
918
1132
  // ------------------------------------------------------------- foreshadows
919
1133
 
920
1134
  /** System prompt for foreshadow suggestions. */
package/src/protocol.ts CHANGED
@@ -19,6 +19,10 @@ export const NOVEL_API = {
19
19
  review: '/api/dsh-novel-forge/review',
20
20
  rewrite: '/api/dsh-novel-forge/rewrite',
21
21
  polish: '/api/dsh-novel-forge/polish',
22
+ /** 采纳待确认草稿(润色/重写产物)覆盖正文文件。 */
23
+ draftApply: '/api/dsh-novel-forge/draft/apply',
24
+ /** 放弃待确认草稿,保留原稿。 */
25
+ draftDiscard: '/api/dsh-novel-forge/draft/discard',
22
26
  summary: '/api/dsh-novel-forge/summary',
23
27
  foreshadow: '/api/dsh-novel-forge/foreshadow',
24
28
  exportBook: '/api/dsh-novel-forge/export',
@@ -26,6 +30,16 @@ export const NOVEL_API = {
26
30
  assistant: '/api/dsh-novel-forge/assistant',
27
31
  assistantHistory: '/api/dsh-novel-forge/assistant-history',
28
32
  bookshelf: '/api/dsh-novel-forge/bookshelf',
33
+ /** 重置项目(可选携带新大纲):清空设定/卷/章节/伏笔/资产/事实库。 */
34
+ reset: '/api/dsh-novel-forge/reset',
35
+ /** 全书一致性质检:LLM 扫描已生成章节,输出矛盾问题清单。 */
36
+ audit: '/api/dsh-novel-forge/audit',
37
+ /** 角色卡刷新:基于事实库与各章摘要聚合角色当前状态。 */
38
+ charactersRefresh: '/api/dsh-novel-forge/characters/refresh',
39
+ /** 事实库回填:对历史已生成章节批量抽取事实(旧章节无事实记录时用)。 */
40
+ factsBackfill: '/api/dsh-novel-forge/facts/backfill',
41
+ /** 设定圣经局部修补(如世界观规则编辑)。 */
42
+ biblePatch: '/api/dsh-novel-forge/bible/patch',
29
43
  config: '/api/dsh-novel-forge/config',
30
44
  openFolder: '/api/dsh-novel-forge/open-folder',
31
45
  } as const
@@ -55,6 +69,14 @@ export interface BookshelfSnapshot {
55
69
  export interface BookCreateRequest {
56
70
  bookName: string
57
71
  outputDir?: string
72
+ /** 开书向导:创建时直接导入的大纲文本(提供则立即建立项目)。 */
73
+ outline?: string
74
+ }
75
+
76
+ /** POST /reset 请求:重置项目(可选更新大纲)。 */
77
+ export interface ResetRequest {
78
+ /** 新大纲文本;提供则替换 outline,否则保留原大纲。 */
79
+ outline?: string
58
80
  }
59
81
 
60
82
  /** POST /bookshelf/activate 请求:切换当前书。 */
@@ -101,6 +123,11 @@ export interface ChapterPlan {
101
123
  summary?: string
102
124
  /** Latest review report (present once reviewed). */
103
125
  review?: ReviewReport
126
+ /**
127
+ * 待确认草稿:润色(去AI味)或整章重写的产物正文。生成时先存这里,
128
+ * 用户看过对比后点「采纳」才覆盖正文文件;点「放弃」则丢弃。刷新页面不丢失。
129
+ */
130
+ pendingDraft?: string
104
131
  }
105
132
 
106
133
  /** One review finding. */
@@ -185,6 +212,54 @@ export interface Foreshadow {
185
212
  resolvedNote?: string
186
213
  }
187
214
 
215
+ /** 一条已确立的叙事事实(事实库/时间线,注入后续章节生成)。 */
216
+ export interface ChapterFact {
217
+ /** 来源章节号。 */
218
+ chapterNo: number
219
+ /** 事实文本(人物状态/境界资源/关系变化/伏笔落地等)。 */
220
+ text: string
221
+ }
222
+
223
+ /** 一条全书质检发现的问题(一致性矛盾,定位到章)。 */
224
+ export interface AuditIssue {
225
+ /** 问题所在章节号(无法定位时 0)。 */
226
+ chapterNo: number
227
+ severity: 'high' | 'medium' | 'low'
228
+ /** 矛盾描述。 */
229
+ item: string
230
+ /** 修改建议。 */
231
+ suggestion: string
232
+ }
233
+
234
+ /** POST /audit 响应。 */
235
+ export interface AuditResponse {
236
+ issues: AuditIssue[]
237
+ /** 参与质检的章节数。 */
238
+ auditedChapters: number
239
+ /** 质检时间。 */
240
+ auditedAt: string
241
+ }
242
+
243
+ /** 角色卡:角色当前状态(从事实库聚合)。 */
244
+ export interface RoleStatusCard {
245
+ name: string
246
+ /** protagonist / supporting / antagonist / other。 */
247
+ role: string
248
+ /** 当前状态一句话(境界/资源/伤势/心境)。 */
249
+ status: string
250
+ /** 最近出场章节。 */
251
+ lastChapter: number
252
+ /** 出场次数。 */
253
+ appearances: number
254
+ }
255
+
256
+ /** POST /bible/patch 请求:局部修补设定圣经。 */
257
+ export interface BiblePatchRequest {
258
+ worldRules?: string[]
259
+ redLines?: string[]
260
+ style?: string[]
261
+ }
262
+
188
263
  /** The persisted project: outline + bible + plan + progress. */
189
264
  export interface ProjectState {
190
265
  /** Book title (first non-empty line of the outline, usually). */
@@ -203,6 +278,8 @@ export interface ProjectState {
203
278
  foreshadows: Foreshadow[]
204
279
  /** 写作资产(题材基底/推进模式/反AI规则/写法资产)。 */
205
280
  assets?: ProjectAssets
281
+ /** 事实库/时间线:每章生成后抽取,注入后续章节保持一致性。 */
282
+ facts?: ChapterFact[]
206
283
  /** ISO timestamps. */
207
284
  createdAt: string
208
285
  updatedAt: string
@@ -302,6 +379,8 @@ export type JobFrame =
302
379
  | { type: 'done'; no: number; file: string; chars: number; title: string }
303
380
  | { type: 'review'; no: number; report: ReviewReport }
304
381
  | { type: 'rewritten'; no: number; file: string; chars: number }
382
+ /** 润色/重写完成,产物作为待确认草稿(尚未覆盖正文)。 */
383
+ | { type: 'drafted'; no: number; chars: number; draft: string }
305
384
  | { type: 'error'; no: number; message: string }
306
385
 
307
386
  /** POST /review request: review one written chapter. */
@@ -326,6 +405,11 @@ export interface PolishRequest {
326
405
  chapterNo: number
327
406
  }
328
407
 
408
+ /** POST /draft/apply | /draft/discard request: 采纳或放弃待确认草稿。 */
409
+ export interface DraftDecisionRequest {
410
+ chapterNo: number
411
+ }
412
+
329
413
  /** POST /summary request: (re)generate a chapter summary. */
330
414
  export interface SummaryRequest {
331
415
  chapterNo: number