@waterwx/dsh-novel-forge 1.3.1 → 1.3.2-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 +288 -279
- package/lib/client.js +56309 -12221
- package/lib/index.js +14239 -5595
- package/lib/types/action-guard.d.ts +61 -0
- package/lib/types/ai-scan.d.ts +36 -0
- package/lib/types/assets.d.ts +24 -9
- package/lib/types/assistant.d.ts +29 -0
- package/lib/types/author-assets.d.ts +25 -0
- package/lib/types/bookshelf.d.ts +11 -1
- package/lib/types/client/api.d.ts +186 -10
- package/lib/types/client/chip-prefs.d.ts +36 -0
- package/lib/types/client/docx.d.ts +2 -0
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/llmLive.d.ts +31 -0
- package/lib/types/client/locales.d.ts +28 -67
- package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
- package/lib/types/client/panel/AiTag.d.ts +1 -0
- package/lib/types/client/panel/AssistantTab.d.ts +4 -1
- package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
- package/lib/types/client/panel/AuthorHome.d.ts +34 -0
- package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
- package/lib/types/client/panel/CmdPalette.d.ts +12 -0
- package/lib/types/client/panel/CreateBookView.d.ts +5 -1
- package/lib/types/client/panel/DirectorView.d.ts +7 -0
- package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
- package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
- package/lib/types/client/panel/ImportModal.d.ts +7 -0
- package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
- package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
- package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
- package/lib/types/client/panel/ModelManager.d.ts +14 -0
- package/lib/types/client/panel/NovelPanel.d.ts +6 -1
- package/lib/types/client/panel/PageHeader.d.ts +18 -0
- package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
- package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
- package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
- package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
- package/lib/types/client/panel/SettingsView.d.ts +19 -0
- package/lib/types/client/panel/ShelfView.d.ts +12 -1
- package/lib/types/client/panel/SubPage.d.ts +27 -0
- package/lib/types/client/panel/TensionView.d.ts +18 -0
- package/lib/types/client/panel/TimelineView.d.ts +18 -0
- package/lib/types/client/panel/helpers.d.ts +5 -3
- package/lib/types/client/panel/toast.d.ts +2 -0
- package/lib/types/client/panel/views.d.ts +25 -7
- package/lib/types/client/run-status.d.ts +21 -0
- package/lib/types/client/text.d.ts +9 -0
- package/lib/types/docx-core.d.ts +16 -0
- package/lib/types/docx.d.ts +2 -3
- package/lib/types/engine.d.ts +186 -45
- package/lib/types/global-assets.d.ts +6 -0
- package/lib/types/index.d.ts +22 -8
- package/lib/types/llm-live.d.ts +75 -0
- package/lib/types/llm-retry.d.ts +38 -0
- package/lib/types/market-radar-scan.d.ts +19 -0
- package/lib/types/market-radar-sources.d.ts +31 -0
- package/lib/types/migrate-output.d.ts +25 -0
- package/lib/types/novel-context.d.ts +80 -0
- package/lib/types/prompt-slots.d.ts +57 -0
- package/lib/types/prompt-utils.d.ts +35 -0
- package/lib/types/prompting.d.ts +10 -0
- package/lib/types/protocol.d.ts +1362 -166
- package/lib/types/review-policy.d.ts +97 -0
- package/lib/types/revision.d.ts +76 -0
- package/lib/types/routes.d.ts +4 -0
- package/lib/types/run.d.ts +23 -1
- package/lib/types/snapshots.d.ts +46 -0
- package/lib/types/stage-contract.d.ts +60 -0
- package/lib/types/tension.d.ts +53 -0
- package/lib/types/timeline.d.ts +37 -0
- package/package.json +52 -28
- package/src/action-guard.ts +127 -0
- package/src/ai-scan.ts +179 -0
- package/src/assets.ts +1806 -520
- package/src/assistant.ts +1098 -770
- package/src/author-assets.ts +149 -0
- package/src/bookshelf.ts +187 -151
- package/src/client/api.ts +378 -21
- package/src/client/chip-prefs.ts +121 -0
- package/src/client/docx.ts +37 -69
- package/src/client/index.ts +3 -1
- package/src/client/llmLive.ts +111 -0
- package/src/client/locales.ts +28 -253
- package/src/client/mount.tsx +1 -1
- package/src/client/panel/AdaptModeView.tsx +535 -0
- package/src/client/panel/AiTag.tsx +7 -0
- package/src/client/panel/AssetsTab.tsx +448 -458
- package/src/client/panel/AssistantTab.tsx +355 -301
- package/src/client/panel/AuthorAssetsView.tsx +206 -0
- package/src/client/panel/AuthorHome.tsx +309 -0
- package/src/client/panel/BookAnalysisView.tsx +63 -0
- package/src/client/panel/CmdPalette.tsx +102 -0
- package/src/client/panel/CreateBookView.tsx +366 -348
- package/src/client/panel/DirectorView.tsx +200 -0
- package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
- package/src/client/panel/IdeaInspirationView.tsx +76 -0
- package/src/client/panel/ImportModal.tsx +303 -0
- package/src/client/panel/KnowledgeBaseView.tsx +125 -0
- package/src/client/panel/LiveFeedLog.tsx +204 -0
- package/src/client/panel/MarketRadarView.tsx +377 -0
- package/src/client/panel/ModelManager.tsx +205 -0
- package/src/client/panel/NovelPanel.tsx +3135 -2651
- package/src/client/panel/PageHeader.tsx +37 -0
- package/src/client/panel/ProgressConsole.tsx +63 -0
- package/src/client/panel/PromptSlotsView.tsx +97 -0
- package/src/client/panel/ReaderView.tsx +306 -306
- package/src/client/panel/ReasoningSection.tsx +31 -0
- package/src/client/panel/RunPanel.tsx +64 -32
- package/src/client/panel/SettingsView.tsx +354 -0
- package/src/client/panel/ShelfView.tsx +266 -236
- package/src/client/panel/SubPage.tsx +42 -0
- package/src/client/panel/TensionView.tsx +212 -0
- package/src/client/panel/TimelineView.tsx +208 -0
- package/src/client/panel/WorldTab.tsx +202 -202
- package/src/client/panel/helpers.ts +7 -5
- package/src/client/panel/panel.module.css +4481 -1655
- package/src/client/panel/toast.ts +51 -0
- package/src/client/panel/views.tsx +401 -314
- package/src/client/run-status.ts +171 -0
- package/src/client/sidebar-entry.ts +1 -1
- package/src/client/text.ts +28 -0
- package/src/docx-core.ts +53 -0
- package/src/docx.ts +44 -83
- package/src/engine.ts +4672 -2943
- package/src/global-assets.ts +59 -0
- package/src/index.ts +89 -33
- package/src/llm-live.ts +260 -0
- package/src/llm-retry.ts +77 -0
- package/src/market-radar-scan.ts +41 -0
- package/src/market-radar-sources.ts +173 -0
- package/src/migrate-output.ts +76 -0
- package/src/novel-context.ts +286 -0
- package/src/prompt-slots.ts +125 -0
- package/src/prompting.ts +26 -0
- package/src/protocol.ts +1388 -158
- package/src/review-policy.ts +161 -0
- package/src/revision.ts +283 -0
- package/src/routes.ts +1934 -434
- package/src/run.ts +118 -21
- package/src/snapshots.ts +174 -0
- package/src/stage-contract.ts +189 -0
- package/src/tension.ts +251 -0
- package/src/timeline.ts +159 -0
- package/lib/client.js.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types/comic-presets.d.ts +0 -12
- package/scripts/assistant-frames.txt +0 -8
- package/scripts/diagnose-adapter.mts +0 -81
- package/scripts/diagnose-assistant.mjs +0 -58
- package/scripts/diagnose-bible.mjs +0 -72
- package/scripts/diagnose-harness.mts +0 -75
- package/scripts/novel-forge-restart.log +0 -10
- package/scripts/novel-forge-web.stderr.log +0 -2
- package/scripts/novel-forge-web.stdout.log +0 -1
- package/scripts/outline-sample.txt +0 -449
- package/scripts/probe.mjs +0 -6
- package/scripts/restart-web.ps1 +0 -88
- package/scripts/smoke.mts +0 -72
- package/scripts/upload-github.ps1 +0 -51
- package/src/client/panel/BookshelfBar.tsx +0 -170
- package/src/comic-presets.ts +0 -67
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
|
+
}
|
package/src/timeline.ts
ADDED
|
@@ -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
|
+
}
|