@waterwx/dsh-novel-forge 1.3.2 → 1.3.3-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.
Files changed (172) hide show
  1. package/README.md +288 -279
  2. package/lib/client.js +56288 -12061
  3. package/lib/index.js +14655 -5573
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +30 -0
  6. package/lib/types/assets.d.ts +22 -9
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +23 -0
  9. package/lib/types/bookshelf.d.ts +18 -3
  10. package/lib/types/client/api.d.ts +187 -10
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -2
  13. package/lib/types/client/index.d.ts +1 -1
  14. package/lib/types/client/llmLive.d.ts +31 -0
  15. package/lib/types/client/locales.d.ts +29 -67
  16. package/lib/types/client/mount.d.ts +0 -2
  17. package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
  18. package/lib/types/client/panel/AiTag.d.ts +1 -0
  19. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  20. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  21. package/lib/types/client/panel/AuthorHome.d.ts +34 -0
  22. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  23. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  24. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  25. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  26. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
  27. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  28. package/lib/types/client/panel/ImportModal.d.ts +7 -0
  29. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  30. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  31. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  32. package/lib/types/client/panel/ModelManager.d.ts +14 -0
  33. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  34. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  35. package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
  36. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  37. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  38. package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
  39. package/lib/types/client/panel/SettingsView.d.ts +19 -0
  40. package/lib/types/client/panel/ShelfView.d.ts +12 -1
  41. package/lib/types/client/panel/SubPage.d.ts +27 -0
  42. package/lib/types/client/panel/TensionView.d.ts +18 -0
  43. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  44. package/lib/types/client/panel/helpers.d.ts +5 -3
  45. package/lib/types/client/panel/market-radar-state.d.ts +56 -0
  46. package/lib/types/client/panel/toast.d.ts +2 -0
  47. package/lib/types/client/panel/views.d.ts +21 -35
  48. package/lib/types/client/run-status.d.ts +21 -0
  49. package/lib/types/client/sidebar-entry.d.ts +0 -2
  50. package/lib/types/client/text.d.ts +9 -0
  51. package/lib/types/docx-core.d.ts +16 -0
  52. package/lib/types/docx.d.ts +2 -3
  53. package/lib/types/engine.d.ts +186 -45
  54. package/lib/types/global-assets.d.ts +6 -0
  55. package/lib/types/home.d.ts +11 -0
  56. package/lib/types/index.d.ts +36 -10
  57. package/lib/types/llm-live.d.ts +70 -0
  58. package/lib/types/llm-retry.d.ts +38 -0
  59. package/lib/types/market-radar-scan.d.ts +18 -0
  60. package/lib/types/market-radar-sources.d.ts +31 -0
  61. package/lib/types/migrate-output.d.ts +25 -0
  62. package/lib/types/model-capability.d.ts +93 -0
  63. package/lib/types/novel-context.d.ts +80 -0
  64. package/lib/types/output-budget.d.ts +86 -0
  65. package/lib/types/prompt-slots.d.ts +57 -0
  66. package/lib/types/prompt-utils.d.ts +35 -0
  67. package/lib/types/prompting.d.ts +10 -0
  68. package/lib/types/protocol.d.ts +1378 -166
  69. package/lib/types/review-policy.d.ts +97 -0
  70. package/lib/types/revision.d.ts +76 -0
  71. package/lib/types/routes.d.ts +9 -3
  72. package/lib/types/run.d.ts +23 -1
  73. package/lib/types/settings-persist.d.ts +59 -0
  74. package/lib/types/snapshots.d.ts +46 -0
  75. package/lib/types/stage-contract.d.ts +58 -0
  76. package/lib/types/tension.d.ts +53 -0
  77. package/lib/types/timeline.d.ts +37 -0
  78. package/package.json +54 -28
  79. package/src/action-guard.ts +127 -0
  80. package/src/ai-scan.ts +155 -0
  81. package/src/assets.ts +1801 -520
  82. package/src/assistant.ts +1098 -770
  83. package/src/author-assets.ts +131 -0
  84. package/src/bookshelf.ts +193 -151
  85. package/src/client/api.ts +380 -23
  86. package/src/client/chip-prefs.ts +121 -0
  87. package/src/client/docx.ts +32 -69
  88. package/src/client/index.ts +3 -1
  89. package/src/client/llmLive.ts +111 -0
  90. package/src/client/locales.ts +29 -253
  91. package/src/client/mount.tsx +4 -3
  92. package/src/client/panel/AdaptModeView.tsx +535 -0
  93. package/src/client/panel/AiTag.tsx +7 -0
  94. package/src/client/panel/AssetsTab.tsx +448 -458
  95. package/src/client/panel/AssistantTab.tsx +355 -301
  96. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  97. package/src/client/panel/AuthorHome.tsx +309 -0
  98. package/src/client/panel/BookAnalysisView.tsx +63 -0
  99. package/src/client/panel/CmdPalette.tsx +102 -0
  100. package/src/client/panel/CreateBookView.tsx +366 -348
  101. package/src/client/panel/DirectorView.tsx +200 -0
  102. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
  103. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  104. package/src/client/panel/ImportModal.tsx +303 -0
  105. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  106. package/src/client/panel/LiveFeedLog.tsx +204 -0
  107. package/src/client/panel/MarketRadarView.tsx +415 -0
  108. package/src/client/panel/ModelManager.tsx +205 -0
  109. package/src/client/panel/NovelPanel.tsx +3013 -2500
  110. package/src/client/panel/PageHeader.tsx +37 -0
  111. package/src/client/panel/ProgressConsole.tsx +63 -0
  112. package/src/client/panel/PromptSlotsView.tsx +97 -0
  113. package/src/client/panel/ReaderView.tsx +306 -306
  114. package/src/client/panel/ReasoningSection.tsx +31 -0
  115. package/src/client/panel/RunPanel.tsx +64 -32
  116. package/src/client/panel/SettingsView.tsx +355 -0
  117. package/src/client/panel/ShelfView.tsx +266 -236
  118. package/src/client/panel/SubPage.tsx +42 -0
  119. package/src/client/panel/TensionView.tsx +212 -0
  120. package/src/client/panel/TimelineView.tsx +208 -0
  121. package/src/client/panel/WorldTab.tsx +202 -202
  122. package/src/client/panel/helpers.ts +7 -5
  123. package/src/client/panel/market-radar-state.ts +79 -0
  124. package/src/client/panel/panel.module.css +4481 -1655
  125. package/src/client/panel/toast.ts +51 -0
  126. package/src/client/panel/views.tsx +316 -314
  127. package/src/client/run-status.ts +171 -0
  128. package/src/client/sidebar-entry.ts +4 -4
  129. package/src/client/text.ts +28 -0
  130. package/src/docx-core.ts +53 -0
  131. package/src/docx.ts +44 -83
  132. package/src/engine.ts +4759 -2943
  133. package/src/global-assets.ts +58 -0
  134. package/src/home.ts +33 -0
  135. package/src/index.ts +206 -65
  136. package/src/llm-live.ts +245 -0
  137. package/src/llm-retry.ts +77 -0
  138. package/src/market-radar-scan.ts +35 -0
  139. package/src/market-radar-sources.ts +173 -0
  140. package/src/migrate-output.ts +76 -0
  141. package/src/model-capability.ts +215 -0
  142. package/src/novel-context.ts +286 -0
  143. package/src/output-budget.ts +147 -0
  144. package/src/prompt-slots.ts +125 -0
  145. package/src/prompting.ts +26 -0
  146. package/src/protocol.ts +1405 -158
  147. package/src/revision.ts +283 -0
  148. package/src/routes.ts +1959 -436
  149. package/src/run.ts +118 -21
  150. package/src/settings-persist.ts +114 -0
  151. package/src/snapshots.ts +174 -0
  152. package/src/stage-contract.ts +186 -0
  153. package/src/tension.ts +251 -0
  154. package/src/timeline.ts +159 -0
  155. package/lib/client.js.map +0 -1
  156. package/lib/index.js.map +0 -1
  157. package/lib/types/comic-presets.d.ts +0 -12
  158. package/scripts/assistant-frames.txt +0 -8
  159. package/scripts/diagnose-adapter.mts +0 -81
  160. package/scripts/diagnose-assistant.mjs +0 -58
  161. package/scripts/diagnose-bible.mjs +0 -72
  162. package/scripts/diagnose-harness.mts +0 -75
  163. package/scripts/novel-forge-restart.log +0 -10
  164. package/scripts/novel-forge-web.stderr.log +0 -2
  165. package/scripts/novel-forge-web.stdout.log +0 -1
  166. package/scripts/outline-sample.txt +0 -449
  167. package/scripts/probe.mjs +0 -6
  168. package/scripts/restart-web.ps1 +0 -88
  169. package/scripts/smoke.mts +0 -72
  170. package/scripts/upload-github.ps1 +0 -51
  171. package/src/client/panel/BookshelfBar.tsx +0 -170
  172. package/src/comic-presets.ts +0 -67
@@ -0,0 +1,186 @@
1
+ /**
2
+ * 阶段契约(Stage Contract)——宿主计算的「当前阶段 → 本轮允许的动作」。
3
+ *
4
+ * 设计来源(「作者请求中的当前阶段决定唯一允许命令」这一约定):
5
+ * 允许的动作必须由宿主根据真实项目状态算出,而不是指望模型记住或自律。
6
+ * 宿主计算结果同时用于三处:
7
+ * 1) 注入助手系统提示词:模型知道自己在哪一步、下一步该做什么;
8
+ * 2) 通用续写指令(「继续 / 下一步」)下的写操作白名单强约束(阶段即唯一命令);
9
+ * 3) 拒绝写操作时给出可执行的、阶段相关的理由,而不是一句「不允许」。
10
+ *
11
+ * 注意:作者显式点名的写操作永远优先于阶段白名单(作者终审权);
12
+ * 阶段强约束只在作者没有指定具体对象(通用续写)时生效。
13
+ */
14
+
15
+ import type { ChapterPlan, ProjectState, StageInfo } from './protocol.ts'
16
+
17
+ /** 一个阶段契约(assistant 侧需要作为类型使用)。 */
18
+ export type BookStage = StageInfo
19
+
20
+ /** 大纲视为「已开书」的最短长度。 */
21
+ const MIN_OUTLINE_CHARS = 50
22
+
23
+ /** 通用续写指令(作者没有指定具体对象时,阶段白名单成为唯一命令)。 */
24
+ const GENERIC_CONTINUE = /^(继续|下一步|接着来|接着写|往下|往下走|继续吧|go\s*on|continue|next)[。.!!??\s]*$/i
25
+
26
+ /**
27
+ * 作者这一轮是否只说了「继续/下一步」这类没有具体对象的指令。
28
+ *
29
+ * @param message 本轮用户消息。
30
+ * @returns 是通用续写指令时为 true。
31
+ */
32
+ export function isGenericContinue(message: string): boolean {
33
+ return GENERIC_CONTINUE.test((message ?? '').trim())
34
+ }
35
+
36
+ /**
37
+ * 根据项目真实状态计算当前创作阶段。
38
+ *
39
+ * @param project 当前项目(未加载时为 undefined)。
40
+ * @returns 阶段契约(推荐动作 + 写操作白名单 + 判定理由)。
41
+ */
42
+ export function computeBookStage(project: ProjectState | undefined): BookStage {
43
+ if (project === undefined || (project.outline ?? '').trim().length < MIN_OUTLINE_CHARS) {
44
+ return {
45
+ id: 'outline',
46
+ label: '开书',
47
+ recommend: ['outline_replace'],
48
+ allow: ['outline_replace'],
49
+ reason: project === undefined ? '尚无项目' : '大纲为空或过短',
50
+ }
51
+ }
52
+ if (project.bible === undefined) {
53
+ return {
54
+ id: 'bible',
55
+ label: '立设定',
56
+ recommend: ['bible_set_rule', 'bible_set_redline'],
57
+ allow: ['bible_set_rule', 'bible_set_redline'],
58
+ reason: '已有大纲,尚未提炼道藏',
59
+ }
60
+ }
61
+ const chapters = project.chapters ?? []
62
+ if (chapters.length === 0) {
63
+ return {
64
+ id: 'plan',
65
+ label: '排章节',
66
+ recommend: [],
67
+ // 章节规划不在助手的动作集里:通用「继续」不应被解释成改写总纲或设定。
68
+ allow: [],
69
+ reason: '有道藏但还没有章节计划(规划在「小说工坊」面板或生产单里执行)',
70
+ }
71
+ }
72
+ const generating = chapters.filter(c => c.status === 'generating')
73
+ if (generating.length > 0) {
74
+ return {
75
+ id: 'wait',
76
+ label: '等待中',
77
+ recommend: [],
78
+ allow: [],
79
+ reason: `第 ${generating.map(c => c.no).join('、')} 章正在生成,等它结束后再安排下一步`,
80
+ }
81
+ }
82
+ const errored = chapters.filter(c => c.status === 'error')
83
+ if (errored.length > 0) {
84
+ return {
85
+ id: 'write',
86
+ label: '逐章编译',
87
+ recommend: ['chapter_generate'],
88
+ allow: ['chapter_generate', 'chapter_rewrite', 'chapter_review'],
89
+ reason: `第 ${errored.map(c => c.no).join('、')} 章生成失败,需要重写`,
90
+ }
91
+ }
92
+ const rejected = chapters.filter(c => c.status === 'rejected')
93
+ if (rejected.length > 0) {
94
+ return {
95
+ id: 'revise',
96
+ label: '修订循环',
97
+ recommend: ['chapter_rewrite'],
98
+ allow: ['chapter_rewrite', 'chapter_review'],
99
+ reason: `第 ${rejected.map(c => c.no).join('、')} 章审稿未过,需要按意见修订`,
100
+ }
101
+ }
102
+ const awaitingReview = chapters.filter(c => c.status === 'written' || c.status === 'reviewing')
103
+ if (awaitingReview.length > 0) {
104
+ return {
105
+ id: 'review',
106
+ label: '审稿',
107
+ recommend: ['chapter_review'],
108
+ allow: ['chapter_review', 'chapter_rewrite'],
109
+ reason: `第 ${awaitingReview.map(c => c.no).join('、')} 章已写出但尚未审稿`,
110
+ }
111
+ }
112
+ const pending = chapters.filter(c => c.status === 'pending')
113
+ if (pending.length > 0) {
114
+ return {
115
+ id: 'write',
116
+ label: '逐章编译',
117
+ recommend: ['chapter_generate'],
118
+ allow: ['chapter_generate', 'chapter_rewrite', 'chapter_review'],
119
+ reason: `还有 ${pending.length} 章待写(下一章:第 ${pending[0]!.no} 章)`,
120
+ }
121
+ }
122
+ return {
123
+ id: 'export',
124
+ label: '定稿导出',
125
+ recommend: ['export_txt', 'blurb'],
126
+ // 全部通过后不再允许在「继续」下改写正文:要改必须由作者点名章节。
127
+ allow: ['export_txt', 'blurb'],
128
+ reason: `全书 ${chapters.length} 章均已通过审稿`,
129
+ }
130
+ }
131
+
132
+ /**
133
+ * 判断某写操作是否在当前阶段的允许白名单内。
134
+ *
135
+ * @param stage 阶段契约。
136
+ * @param tool 助手工具名。
137
+ * @returns 允许时为 true。
138
+ */
139
+ export function stageAllows(stage: BookStage, tool: string): boolean {
140
+ return stage.allow.includes(tool)
141
+ }
142
+
143
+ /**
144
+ * 按阶段把一组动作分成「本阶段可用」与「阶段外」。
145
+ *
146
+ * @param stage 阶段契约。
147
+ * @param tools 动作名列表(通常是写操作登记表)。
148
+ * @returns 两个保持输入顺序的子集。
149
+ */
150
+ export function splitByStage(stage: BookStage, tools: readonly string[]): { available: string[]; outside: string[] } {
151
+ const available: string[] = []
152
+ const outside: string[] = []
153
+ for (const tool of tools) {
154
+ if (stageAllows(stage, tool)) available.push(tool)
155
+ else outside.push(tool)
156
+ }
157
+ return { available, outside }
158
+ }
159
+
160
+ /**
161
+ * 渲染阶段契约为可注入提示词的一段文字。
162
+ *
163
+ * @param stage 阶段契约。
164
+ * @returns 多行文本。
165
+ */
166
+ export function renderStageContract(stage: BookStage): string {
167
+ const recommend = stage.recommend.length > 0 ? stage.recommend.join('、') : '无(等待或由作者指定)'
168
+ const allow = stage.allow.length > 0 ? stage.allow.join('、') : '无'
169
+ return [
170
+ '==================== 当前创作阶段(宿主根据项目真实状态计算) ====================',
171
+ `阶段:${stage.label}(${stage.id})`,
172
+ `判定依据:${stage.reason}`,
173
+ `建议下一步:${recommend}`,
174
+ `本阶段允许的写操作:${allow}`,
175
+ '阶段纪律:作者只说了「继续/下一步」而没指定具体对象时,只有上面的写操作会被执行,其余写操作一律拒绝;作者显式点名某个操作时以作者指令为准。',
176
+ ].join('\n')
177
+ }
178
+
179
+ /** 汇总章节状态计数(供面板/提示词展示,避免各处重复统计)。 */
180
+ export function summarizeChapters(chapters: ChapterPlan[]): Record<string, number> {
181
+ const summary: Record<string, number> = {}
182
+ for (const chapter of chapters) {
183
+ summary[chapter.status] = (summary[chapter.status] ?? 0) + 1
184
+ }
185
+ return summary
186
+ }
package/src/tension.ts ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * 张力曲线:把「每章该有多紧」显式化,并在出章后核对实际走向。
3
+ *
4
+ * 为什么要有它:长篇最常见的两种崩法——**一直高能没有呼吸**(读者疲劳)与
5
+ * **长时间低位打转**(读者流失);这两种都不是单章问题,而是"曲线"问题,
6
+ * 单章审稿看不见。这里提供:
7
+ * 1) 参考曲线(本项目自行设计,仅作形状参照);
8
+ * 2) 把「本章目标张力」注入写作提示词;
9
+ * 3) 规则核对:实际 vs 目标偏差、连续同值、高潮后不回落、长期低位。
10
+ *
11
+ * 本模块只放纯逻辑(可离线单测);模型交互与落盘在 engine/routes。
12
+ */
13
+
14
+ import type { ChapterPlan, ProjectState, TensionCurvePreset, TensionIssue } from './protocol.ts'
15
+
16
+ /** 单章张力的取值范围。 */
17
+ export const TENSION_MIN = 0
18
+ export const TENSION_MAX = 100
19
+
20
+ /**
21
+ * 本项目设计的参考曲线形状(0-100 的采样序列)。
22
+ * 只描述"形状",不含任何外部数值表。
23
+ */
24
+ const REFERENCE_SHAPES: Record<Exclude<TensionCurvePreset, 'custom'>, readonly number[]> = {
25
+ // 递进爬升:整体上行,波峰越来越大
26
+ escalation: [20, 26, 34, 32, 44, 52, 48, 62, 70, 66, 82, 92],
27
+ // 悬疑加压:中段起缓慢抬升,末段释放
28
+ suspense: [30, 42, 38, 50, 46, 58, 62, 56, 70, 78, 86, 74],
29
+ // 波浪呼吸:高低交替,给读者留呼吸口
30
+ wave: [45, 30, 55, 34, 62, 40, 70, 46, 78, 52, 84, 60],
31
+ // 前紧后稳:开篇抓人,中后段平缓推进
32
+ frontLoad: [78, 66, 58, 50, 46, 44, 42, 44, 46, 48, 50, 52],
33
+ // 平稳推进:几乎无起伏(适合日常/经营类)
34
+ flat: [40, 42, 44, 42, 46, 44, 48, 46, 50, 48, 52, 50],
35
+ }
36
+
37
+ /** 参考曲线的中文标签(面板展示)。 */
38
+ export const TENSION_PRESET_LABELS: Record<TensionCurvePreset, string> = {
39
+ escalation: '递进爬升',
40
+ suspense: '悬疑加压',
41
+ wave: '波浪呼吸',
42
+ frontLoad: '前紧后稳',
43
+ flat: '平稳推进',
44
+ custom: '自定义',
45
+ }
46
+
47
+ /** 全部可选预设(UI 下拉用)。 */
48
+ export const TENSION_PRESETS: readonly TensionCurvePreset[] = ['escalation', 'suspense', 'wave', 'frontLoad', 'flat', 'custom']
49
+
50
+ /** 把参考形状按章节数重采样(最近邻,保持形状不引入平滑失真)。 */
51
+ export function referenceCurve(preset: TensionCurvePreset, chapterCount: number): number[] {
52
+ if (chapterCount <= 0) return []
53
+ if (preset === 'custom') return []
54
+ const shape = REFERENCE_SHAPES[preset]
55
+ if (chapterCount === 1) return [shape[Math.floor(shape.length / 2)] ?? 50]
56
+ return Array.from({ length: chapterCount }, (_, i) => {
57
+ const pos = (i / (chapterCount - 1)) * (shape.length - 1)
58
+ const lo = Math.floor(pos)
59
+ const hi = Math.min(shape.length - 1, lo + 1)
60
+ const t = pos - lo
61
+ const a = shape[lo] ?? 50
62
+ const b = shape[hi] ?? a
63
+ return Math.round(a + (b - a) * t)
64
+ })
65
+ }
66
+
67
+ /** 读一章的目标张力(未设置时返回 undefined)。 */
68
+ export function targetTension(chapter: ChapterPlan): number | undefined {
69
+ return typeof chapter.tension === 'number' ? clampTension(chapter.tension) : undefined
70
+ }
71
+
72
+ /** 读一章的实际张力(审稿时模型打分,未设置时返回 undefined)。 */
73
+ export function actualTension(chapter: ChapterPlan): number | undefined {
74
+ const fromReview = chapter.review?.tension
75
+ if (typeof fromReview === 'number') return clampTension(fromReview)
76
+ return typeof chapter.tensionActual === 'number' ? clampTension(chapter.tensionActual) : undefined
77
+ }
78
+
79
+ /** 收敛到合法区间并取整。 */
80
+ export function clampTension(value: number): number {
81
+ if (!Number.isFinite(value)) return TENSION_MIN
82
+ return Math.max(TENSION_MIN, Math.min(TENSION_MAX, Math.round(value)))
83
+ }
84
+
85
+ /**
86
+ * 渲染注入写作提示词的「本章张力」块。
87
+ *
88
+ * @param project 项目状态。
89
+ * @param chapterNo 本章章号。
90
+ * @returns 提示词片段;没有张力信息时返回空串。
91
+ */
92
+ export function renderTensionBlock(project: ProjectState, chapterNo: number): string {
93
+ const preset = project.tensionCurve?.preset
94
+ const chapters = [...(project.chapters ?? [])].sort((a, b) => a.no - b.no)
95
+ const idx = chapters.findIndex(c => c.no === chapterNo)
96
+ if (idx === -1) return ''
97
+ const target = targetTension(chapters[idx]!)
98
+ const prev = idx > 0 ? targetTension(chapters[idx - 1]!) : undefined
99
+ if (target === undefined && prev === undefined) return ''
100
+ const ref = preset !== undefined && preset !== 'custom' ? referenceCurve(preset, chapters.length)[idx] : undefined
101
+ const parts: string[] = []
102
+ if (target !== undefined) parts.push(`本章目标张力 ${target}/100`)
103
+ if (prev !== undefined && target !== undefined) {
104
+ const delta = target - prev
105
+ parts.push(delta >= 10 ? '比上一章明显更紧' : delta <= -10 ? '比上一章明显放松(给读者呼吸口)' : '与上一章持平')
106
+ }
107
+ if (ref !== undefined) parts.push(`参考曲线(${TENSION_PRESET_LABELS[preset ?? 'custom']})在此处约为 ${ref}`)
108
+ if (parts.length === 0) return ''
109
+ return [
110
+ '==================== 本章张力(写作时把握松紧) ====================',
111
+ parts.join(';') + '。',
112
+ '低张力段用于铺垫关系、交代信息、留呼吸;高张力段用于对抗、揭示、抉择与代价。',
113
+ '同一章内可以有起伏,但整章的平均松紧要贴近目标值;不要连续多章都停在同一个紧张度。',
114
+ ].join('\n')
115
+ }
116
+
117
+ /**
118
+ * 规则核对张力曲线(不调模型)。
119
+ *
120
+ * 检查四类曲线级问题:
121
+ * 1) 实际与目标偏差过大(>25);
122
+ * 2) 连续多章几乎同值(≥4 章落差 <6,平台期无起伏);
123
+ * 3) 高点之后没有回落(连续 ≥3 章处在高位 ≥80);
124
+ * 4) 长期低位(连续 ≥5 章 <40,容易拖沓)。
125
+ *
126
+ * @param project 项目状态。
127
+ * @returns 问题清单(可能为空)。
128
+ */
129
+ /**
130
+ * run 型问题(连续同值 / 连续高位 / 连续低位)的受影响章号。
131
+ * 返回**完整区间**而不是首尾两点:中间章同样受这条曲线问题影响,
132
+ * 面板要能在每一章上给出「一键按建议修订」,合并层也要能把建议落到中间章。
133
+ */
134
+ function runChapters(from: number, to: number): number[] {
135
+ const out: number[] = []
136
+ for (let no = from; no <= to; no++) out.push(no)
137
+ return out
138
+ }
139
+
140
+ export function detectTensionIssues(project: ProjectState): TensionIssue[] {
141
+ const chapters = [...(project.chapters ?? [])].sort((a, b) => a.no - b.no)
142
+ const values = chapters.map(c => ({ no: c.no, target: targetTension(c), actual: actualTension(c) }))
143
+ const known = values.filter(v => v.target !== undefined || v.actual !== undefined)
144
+ if (known.length < 3) return []
145
+ const issues: TensionIssue[] = []
146
+
147
+ // 1) 偏差
148
+ for (const v of values) {
149
+ if (v.target === undefined || v.actual === undefined) continue
150
+ const gap = v.actual - v.target
151
+ if (Math.abs(gap) > 25) {
152
+ issues.push({
153
+ severity: Math.abs(gap) > 40 ? 'high' : 'medium',
154
+ chapters: [v.no],
155
+ item: `第${v.no}章张力偏差较大:目标 ${v.target},实际 ${v.actual}(${gap > 0 ? '比目标更紧' : '比目标更松'})`,
156
+ suggestion: gap > 0 ? '本章对抗/揭示密度偏高,考虑把部分冲突后移,留出呼吸段。' : '本章推进偏平,考虑加一处对抗、揭示或代价。',
157
+ })
158
+ }
159
+ }
160
+
161
+ // 2) 平台期(连续同值)
162
+ let run = 1
163
+ for (let i = 1; i < values.length; i++) {
164
+ const a = values[i - 1]!.target ?? values[i - 1]!.actual
165
+ const b = values[i]!.target ?? values[i]!.actual
166
+ if (a !== undefined && b !== undefined && Math.abs(b - a) < 6) {
167
+ run++
168
+ if (run === 4) {
169
+ const from = values[i - run + 1]!.no
170
+ issues.push({
171
+ severity: 'medium',
172
+ chapters: runChapters(from, values[i]!.no),
173
+ item: `第${from}-${values[i]!.no}章张力几乎无变化(连续 ${run} 章)`,
174
+ suggestion: '安排一次明显抬升或一次释放,让曲线有起伏,避免读者疲劳。',
175
+ })
176
+ }
177
+ } else {
178
+ run = 1
179
+ }
180
+ }
181
+
182
+ // 3) 高位不回落
183
+ let highRun = 0
184
+ for (const v of values) {
185
+ const val = v.target ?? v.actual
186
+ if (val !== undefined && val >= 80) {
187
+ highRun++
188
+ if (highRun === 3) {
189
+ issues.push({
190
+ severity: 'medium',
191
+ chapters: runChapters(v.no - 2, v.no),
192
+ item: `连续 ${highRun} 章处在高位(≥80),缺少呼吸口`,
193
+ suggestion: '高潮之后补一章缓冲(关系推进、信息消化或日常),再进入下一轮对抗。',
194
+ })
195
+ }
196
+ } else {
197
+ highRun = 0
198
+ }
199
+ }
200
+
201
+ // 4) 长期低位
202
+ let lowRun = 0
203
+ for (const v of values) {
204
+ const val = v.target ?? v.actual
205
+ if (val !== undefined && val < 40) {
206
+ lowRun++
207
+ if (lowRun === 5) {
208
+ issues.push({
209
+ severity: 'high',
210
+ chapters: runChapters(v.no - 4, v.no),
211
+ item: `连续 ${lowRun} 章张力低于 40,推进可能偏慢`,
212
+ suggestion: '提前兑现一个目标、抛出一个新风险,或让配角线产生冲撞。',
213
+ })
214
+ }
215
+ } else {
216
+ lowRun = 0
217
+ }
218
+ }
219
+
220
+ return issues
221
+ }
222
+
223
+ /**
224
+ * 组装面板需要的曲线数据(章节号 / 目标 / 实际 / 参考)。
225
+ *
226
+ * @param project 项目状态。
227
+ * @returns 逐章数据点与预设名。
228
+ */
229
+ export function buildCurveData(project: ProjectState): {
230
+ preset: TensionCurvePreset
231
+ points: Array<{ no: number; title: string; target?: number; actual?: number; reference?: number }>
232
+ } {
233
+ const preset: TensionCurvePreset = project.tensionCurve?.preset ?? 'escalation'
234
+ const chapters = [...(project.chapters ?? [])].sort((a, b) => a.no - b.no)
235
+ const custom = project.tensionCurve?.custom ?? []
236
+ const reference = preset === 'custom'
237
+ ? custom
238
+ : referenceCurve(preset, chapters.length)
239
+ return {
240
+ preset,
241
+ points: chapters.map((c, i) => {
242
+ const point: { no: number; title: string; target?: number; actual?: number; reference?: number } = { no: c.no, title: c.title }
243
+ const t = targetTension(c)
244
+ const a = actualTension(c)
245
+ if (t !== undefined) point.target = t
246
+ if (a !== undefined) point.actual = a
247
+ if (reference[i] !== undefined) point.reference = reference[i]
248
+ return point
249
+ }),
250
+ }
251
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * 故事时间线:把「章节正文」压成可排序的时间事件,用于后续章节的时间连续性。
3
+ *
4
+ * 与编年录的分工:编年录记「事实」(人物状态/资源/关系),时间线记「顺序」
5
+ * (什么时候、在哪里、谁在场、发生了什么),专门用来抓时间倒流、地点瞬移与
6
+ * 跨越章节的时序矛盾。
7
+ *
8
+ * 本模块只放纯逻辑(可离线单测):排序、注入渲染、规则初筛、事件归一化;
9
+ * 模型交互(抽取/复核)在 engine.ts,落盘与路由在 routes.ts。
10
+ */
11
+
12
+ import type { ProjectState, TimelineEvent, TimelineIssue } from './protocol.ts'
13
+
14
+ /** 注入后续章节的时间线锚点条数(超过则显式声明省略,遵循截断显式化规则)。 */
15
+ export const TIMELINE_CONTEXT_LIMIT = 12
16
+
17
+ /** 时间线矛盾初筛的最少事件数(太少没有意义)。 */
18
+ const MIN_EVENTS_FOR_CHECK = 2
19
+
20
+ /** 归一化事件(清洗字符串、补 id/时间戳)。 */
21
+ export function normalizeTimelineEvent(raw: Partial<TimelineEvent>, chapterNo: number, index: number, now = new Date().toISOString()): TimelineEvent {
22
+ const characters = Array.isArray(raw.characters) ? raw.characters.filter(c => typeof c === 'string' && c.trim() !== '') : []
23
+ return {
24
+ id: typeof raw.id === 'string' && raw.id !== '' ? raw.id : `tl-${chapterNo}-${index}-${Math.random().toString(36).slice(2, 8)}`,
25
+ chapterNo: typeof raw.chapterNo === 'number' ? raw.chapterNo : chapterNo,
26
+ time: (raw.time ?? '').trim(),
27
+ order: typeof raw.order === 'number' && Number.isFinite(raw.order) ? raw.order : undefined,
28
+ place: (raw.place ?? '').trim(),
29
+ characters: characters.map(c => c.trim()),
30
+ event: (raw.event ?? '').trim(),
31
+ source: raw.source === 'manual' ? 'manual' : 'extracted',
32
+ createdAt: raw.createdAt ?? now,
33
+ }
34
+ }
35
+
36
+ /** 时间线排序:先章节、再 order(缺省排在该章末尾)、最后 createdAt。 */
37
+ export function sortTimeline(events: readonly TimelineEvent[]): TimelineEvent[] {
38
+ return [...events].sort((a, b) => {
39
+ if (a.chapterNo !== b.chapterNo) return a.chapterNo - b.chapterNo
40
+ const ao = a.order ?? Number.MAX_SAFE_INTEGER
41
+ const bo = b.order ?? Number.MAX_SAFE_INTEGER
42
+ if (ao !== bo) return ao - bo
43
+ return a.createdAt.localeCompare(b.createdAt)
44
+ })
45
+ }
46
+
47
+ /**
48
+ * 渲染注入到「写作/规划」提示词的时间线锚点块。
49
+ *
50
+ * @param project 项目状态。
51
+ * @param beforeChapter 只注入该章之前的锚点(含该章本身不注入,避免剧透)。
52
+ * @returns 提示词片段(无事件时返回空串)。带显式截断声明。
53
+ */
54
+ export function renderTimelineBlock(project: ProjectState, beforeChapter: number): string {
55
+ const all = sortTimeline(project.timeline ?? []).filter(e => e.chapterNo < beforeChapter)
56
+ if (all.length === 0) return ''
57
+ const shown = all.slice(-TIMELINE_CONTEXT_LIMIT)
58
+ const omitted = all.length - shown.length
59
+ const head = omitted > 0
60
+ ? `【故事时间线(共 ${all.length} 条,此处只列最近 ${shown.length} 条;更早 ${omitted} 条未列出)】`
61
+ : `【故事时间线(共 ${shown.length} 条)】`
62
+ const lines = shown.map(e => {
63
+ const parts = [`[第${e.chapterNo}章]`]
64
+ if (e.time !== '') parts.push(e.time)
65
+ if (e.place !== '') parts.push(`@${e.place}`)
66
+ if (e.characters.length > 0) parts.push(`(${e.characters.join('、')})`)
67
+ parts.push(e.event)
68
+ return '- ' + parts.join(' ')
69
+ })
70
+ return `${head}\n${lines.join('\n')}\n写作时必须与前文时间顺序、地点衔接一致;不得让时间倒流或让人物瞬移。`
71
+ }
72
+
73
+ /**
74
+ * 规则初筛时间线矛盾(不调模型,瞬时计算)。
75
+ *
76
+ * 覆盖三类最容易犯又最容易用规则抓到的错:
77
+ * 1) 同一章内事件顺序号倒退;
78
+ * 2) 全书顺序号在章节推进后倒退(时间倒流);
79
+ * 3) 相邻两章之间地点瞬移(同一角色在未交代移动的情况下跨地点)。
80
+ *
81
+ * @param project 项目状态。
82
+ * @returns 问题清单(可能为空)。
83
+ */
84
+ export function detectTimelineIssues(project: ProjectState): TimelineIssue[] {
85
+ // 注意:这里用**原始数组顺序**分组(= 模型/作者的叙述顺序),而不是按 order 排序后的顺序。
86
+ // 否则「模型自报 order 与叙述顺序打架」这一类问题会被排序抹平,永远查不出来。
87
+ const stored = project.timeline ?? []
88
+ if (stored.length < MIN_EVENTS_FOR_CHECK) return []
89
+ const issues: TimelineIssue[] = []
90
+
91
+ // 1) 同章内顺序倒退(叙述顺序 vs 自报 order)
92
+ const byChapter = new Map<number, TimelineEvent[]>()
93
+ for (const e of stored) {
94
+ const list = byChapter.get(e.chapterNo) ?? []
95
+ list.push(e)
96
+ byChapter.set(e.chapterNo, list)
97
+ }
98
+ for (const [no, list] of byChapter) {
99
+ let lastOrder: number | undefined
100
+ for (const e of list) {
101
+ if (e.order === undefined) continue
102
+ if (lastOrder !== undefined && e.order < lastOrder) {
103
+ issues.push({
104
+ severity: 'medium',
105
+ chapters: [no],
106
+ item: `第${no}章内时间顺序倒退:事件「${e.event}」的顺序号 ${e.order} 小于前一条 ${lastOrder}`,
107
+ suggestion: '检查这两条事件的先后关系,修正 order 或改写正文的时间描述。',
108
+ })
109
+ }
110
+ lastOrder = e.order
111
+ }
112
+ }
113
+
114
+ // 2) 跨章时间倒流(用每章最大 order 与上一章比较)
115
+ const chapterMax: Array<{ no: number; max: number; event: TimelineEvent }> = []
116
+ for (const [no, list] of [...byChapter].sort((a, b) => a[0] - b[0])) {
117
+ const withOrder = list.filter(e => e.order !== undefined)
118
+ if (withOrder.length === 0) continue
119
+ const maxEvent = withOrder.reduce((best, e) => ((e.order ?? 0) > (best.order ?? 0) ? e : best), withOrder[0]!)
120
+ chapterMax.push({ no, max: maxEvent.order ?? 0, event: maxEvent })
121
+ }
122
+ for (let i = 1; i < chapterMax.length; i++) {
123
+ const prev = chapterMax[i - 1]!
124
+ const cur = chapterMax[i]!
125
+ if (cur.max < prev.max) {
126
+ issues.push({
127
+ severity: 'high',
128
+ chapters: [prev.no, cur.no],
129
+ item: `时间倒流:第${cur.no}章(${cur.event.time || '未标注时间'})早于第${prev.no}章(${prev.event.time || '未标注时间'})`,
130
+ suggestion: '确认是否需要补写过渡(回忆/插叙)或调整章节顺序;若是有意插叙,请在正文中明确标注。',
131
+ })
132
+ }
133
+ }
134
+
135
+ // 3) 相邻章地点瞬移(同一角色、地点变化且新地点首次出现)
136
+ const seenPlaces = new Set<string>()
137
+ let prevPlace = ''
138
+ const lastChapterOf = new Map<string, string>()
139
+ for (const e of sortTimeline(stored)) {
140
+ if (e.place !== '') {
141
+ if (prevPlace !== '' && e.place !== prevPlace && !seenPlaces.has(e.place)) {
142
+ // 新地点首次出现:只有在同一角色上一章还在别处时才提示
143
+ const moved = e.characters.filter(c => lastChapterOf.get(c) !== undefined && lastChapterOf.get(c) !== e.place)
144
+ if (moved.length > 0) {
145
+ issues.push({
146
+ severity: 'medium',
147
+ chapters: [e.chapterNo],
148
+ item: `地点衔接存疑:${moved.join('、')} 上一场景在「${lastChapterOf.get(moved[0]!) ?? '?'}」,本章直接出现在「${e.place}」`,
149
+ suggestion: '补一句移动/时间流逝的交代,或确认是否漏写了赶路段落。',
150
+ })
151
+ }
152
+ }
153
+ seenPlaces.add(e.place)
154
+ prevPlace = e.place
155
+ for (const c of e.characters) lastChapterOf.set(c, e.place)
156
+ }
157
+ }
158
+ return issues
159
+ }