@waterwx/dsh-novel-forge 1.2.0 → 1.3.0-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +236 -349
  2. package/lib/client.js +56064 -10565
  3. package/lib/index.js +13922 -5218
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +36 -0
  6. package/lib/types/assets.d.ts +24 -9
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +25 -0
  9. package/lib/types/bookshelf.d.ts +11 -1
  10. package/lib/types/client/api.d.ts +178 -10
  11. package/lib/types/client/docx.d.ts +2 -0
  12. package/lib/types/client/index.d.ts +1 -1
  13. package/lib/types/client/llmLive.d.ts +31 -0
  14. package/lib/types/client/locales.d.ts +28 -11
  15. package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
  16. package/lib/types/client/panel/AiTag.d.ts +1 -0
  17. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  18. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  19. package/lib/types/client/panel/AuthorHome.d.ts +34 -0
  20. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  21. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  22. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  23. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  24. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
  25. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  26. package/lib/types/client/panel/ImportModal.d.ts +7 -0
  27. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  28. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  29. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  30. package/lib/types/client/panel/ModelManager.d.ts +14 -0
  31. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  32. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  33. package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
  34. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  35. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  36. package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
  37. package/lib/types/client/panel/SettingsView.d.ts +19 -0
  38. package/lib/types/client/panel/ShelfView.d.ts +12 -1
  39. package/lib/types/client/panel/SubPage.d.ts +27 -0
  40. package/lib/types/client/panel/TensionView.d.ts +14 -0
  41. package/lib/types/client/panel/TimelineView.d.ts +14 -0
  42. package/lib/types/client/panel/toast.d.ts +2 -0
  43. package/lib/types/client/panel/views.d.ts +25 -7
  44. package/lib/types/client/run-status.d.ts +19 -0
  45. package/lib/types/client/text.d.ts +9 -0
  46. package/lib/types/docx-core.d.ts +16 -0
  47. package/lib/types/docx.d.ts +2 -3
  48. package/lib/types/engine.d.ts +185 -41
  49. package/lib/types/global-assets.d.ts +6 -0
  50. package/lib/types/index.d.ts +22 -2
  51. package/lib/types/llm-live.d.ts +75 -0
  52. package/lib/types/llm-retry.d.ts +38 -0
  53. package/lib/types/market-radar-scan.d.ts +19 -0
  54. package/lib/types/market-radar-sources.d.ts +31 -0
  55. package/lib/types/migrate-output.d.ts +25 -0
  56. package/lib/types/novel-context.d.ts +80 -0
  57. package/lib/types/prompt-slots.d.ts +57 -0
  58. package/lib/types/prompt-utils.d.ts +35 -0
  59. package/lib/types/prompting.d.ts +10 -0
  60. package/lib/types/protocol.d.ts +1311 -125
  61. package/lib/types/review-policy.d.ts +97 -0
  62. package/lib/types/routes.d.ts +4 -0
  63. package/lib/types/run.d.ts +3 -1
  64. package/lib/types/snapshots.d.ts +46 -0
  65. package/lib/types/stage-contract.d.ts +60 -0
  66. package/lib/types/tension.d.ts +65 -0
  67. package/lib/types/timeline.d.ts +37 -0
  68. package/package.json +52 -27
  69. package/scripts/patch-schemastery.mjs +47 -0
  70. package/src/action-guard.ts +127 -0
  71. package/src/ai-scan.ts +179 -0
  72. package/src/assets.ts +1806 -520
  73. package/src/assistant.ts +1098 -770
  74. package/src/author-assets.ts +149 -0
  75. package/src/bookshelf.ts +187 -151
  76. package/src/client/api.ts +758 -410
  77. package/src/client/docx.ts +37 -69
  78. package/src/client/index.ts +3 -1
  79. package/src/client/llmLive.ts +111 -0
  80. package/src/client/locales.ts +418 -383
  81. package/src/client/mount.tsx +1 -1
  82. package/src/client/panel/AdaptModeView.tsx +535 -0
  83. package/src/client/panel/AiTag.tsx +7 -0
  84. package/src/client/panel/AssetsTab.tsx +448 -458
  85. package/src/client/panel/AssistantTab.tsx +355 -301
  86. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  87. package/src/client/panel/AuthorHome.tsx +309 -0
  88. package/src/client/panel/BookAnalysisView.tsx +63 -0
  89. package/src/client/panel/CmdPalette.tsx +102 -0
  90. package/src/client/panel/CreateBookView.tsx +366 -348
  91. package/src/client/panel/DirectorView.tsx +200 -0
  92. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
  93. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  94. package/src/client/panel/ImportModal.tsx +303 -0
  95. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  96. package/src/client/panel/LiveFeedLog.tsx +204 -0
  97. package/src/client/panel/MarketRadarView.tsx +377 -0
  98. package/src/client/panel/ModelManager.tsx +205 -0
  99. package/src/client/panel/NovelPanel.tsx +5517 -4597
  100. package/src/client/panel/PageHeader.tsx +37 -0
  101. package/src/client/panel/ProgressConsole.tsx +63 -0
  102. package/src/client/panel/PromptSlotsView.tsx +97 -0
  103. package/src/client/panel/ReaderView.tsx +306 -306
  104. package/src/client/panel/ReasoningSection.tsx +31 -0
  105. package/src/client/panel/RunPanel.tsx +223 -204
  106. package/src/client/panel/SettingsView.tsx +340 -0
  107. package/src/client/panel/ShelfView.tsx +266 -236
  108. package/src/client/panel/SubPage.tsx +42 -0
  109. package/src/client/panel/TensionView.tsx +196 -0
  110. package/src/client/panel/TimelineView.tsx +192 -0
  111. package/src/client/panel/WorldTab.tsx +202 -202
  112. package/src/client/panel/panel.module.css +5764 -2941
  113. package/src/client/panel/toast.ts +51 -0
  114. package/src/client/panel/views.tsx +401 -314
  115. package/src/client/run-status.ts +143 -0
  116. package/src/client/sidebar-entry.ts +1 -1
  117. package/src/client/text.ts +28 -0
  118. package/src/docx-core.ts +53 -0
  119. package/src/docx.ts +44 -83
  120. package/src/engine.ts +4672 -2787
  121. package/src/global-assets.ts +59 -0
  122. package/src/index.ts +323 -252
  123. package/src/llm-live.ts +260 -0
  124. package/src/llm-retry.ts +77 -0
  125. package/src/market-radar-scan.ts +41 -0
  126. package/src/market-radar-sources.ts +173 -0
  127. package/src/migrate-output.ts +76 -0
  128. package/src/novel-context.ts +286 -0
  129. package/src/prompt-slots.ts +125 -0
  130. package/src/prompting.ts +26 -0
  131. package/src/protocol.ts +2383 -1182
  132. package/src/review-policy.ts +161 -0
  133. package/src/routes.ts +3724 -2151
  134. package/src/run.ts +346 -330
  135. package/src/snapshots.ts +174 -0
  136. package/src/stage-contract.ts +189 -0
  137. package/src/tension.ts +240 -0
  138. package/src/timeline.ts +159 -0
  139. package/lib/client.js.map +0 -1
  140. package/lib/index.js.map +0 -1
  141. package/src/client/panel/BookshelfBar.tsx +0 -170
@@ -0,0 +1,174 @@
1
+ /**
2
+ * 章节历史版本(快照):覆盖写入前自动留存正文,支持列表 / 回滚 / 清理。
3
+ *
4
+ * 为什么需要它:生成、按意见重写、去 AI 味润色、采纳草稿都会**覆盖** `第N章_*.md`,
5
+ * 一旦改差(尤其是自动修订把好稿改坏)就没有退路。这里在覆盖前把旧稿存到
6
+ * `<书目录>/snapshots/`,并维护一份 `snapshots/index.json` 索引。
7
+ *
8
+ * 纯 Node 文件操作,不依赖模型;路由层只做参数校验与响应。
9
+ */
10
+
11
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
12
+ import { join } from 'node:path'
13
+ import type { ChapterPlan, ChapterSnapshot } from './protocol.ts'
14
+
15
+ /** 快照目录名(书目录下)。 */
16
+ export const SNAPSHOT_DIR = 'snapshots'
17
+
18
+ /** 索引文件名。 */
19
+ const INDEX_FILE = 'index.json'
20
+
21
+ /** 每章默认保留的快照数(超出后按时间淘汰最旧的)。 */
22
+ export const DEFAULT_SNAPSHOT_KEEP = 20
23
+
24
+ /** 快照目录绝对路径。 */
25
+ export function snapshotDir(outputDir: string): string {
26
+ return join(outputDir, SNAPSHOT_DIR)
27
+ }
28
+
29
+ /** 读取索引(缺失/损坏时返回空数组)。 */
30
+ export function loadSnapshots(outputDir: string): ChapterSnapshot[] {
31
+ const file = join(snapshotDir(outputDir), INDEX_FILE)
32
+ if (!existsSync(file)) return []
33
+ try {
34
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as { snapshots?: ChapterSnapshot[] }
35
+ return Array.isArray(parsed.snapshots) ? parsed.snapshots : []
36
+ } catch {
37
+ return []
38
+ }
39
+ }
40
+
41
+ /** 写回索引。 */
42
+ function saveSnapshots(outputDir: string, snapshots: ChapterSnapshot[]): void {
43
+ const dir = snapshotDir(outputDir)
44
+ mkdirSync(dir, { recursive: true })
45
+ writeFileSync(join(dir, INDEX_FILE), JSON.stringify({ snapshots }, null, 2), 'utf8')
46
+ }
47
+
48
+ /** 快照占用字节数(索引 + 正文)。 */
49
+ export function snapshotsBytes(outputDir: string): number {
50
+ const dir = snapshotDir(outputDir)
51
+ if (!existsSync(dir)) return 0
52
+ let total = 0
53
+ try {
54
+ for (const name of readdirSync(dir)) {
55
+ try { total += statSync(join(dir, name)).size } catch { /* 跳过读不到的条目 */ }
56
+ }
57
+ } catch { /* 目录不可读时按 0 计 */ }
58
+ return total
59
+ }
60
+
61
+ /** 生成快照文件名(章节 + 时间戳 + 原因短标识)。 */
62
+ function snapshotFileName(chapterNo: number, reason: string, at: string): string {
63
+ const stamp = at.replace(/[:.]/g, '-')
64
+ const tag = reason.replace(/[^\w\u4e00-\u9fa5-]+/g, '').slice(0, 12) || 'snap'
65
+ return `ch${String(chapterNo).padStart(4, '0')}_${stamp}_${tag}.md`
66
+ }
67
+
68
+ /**
69
+ * 为一个章节留存快照(覆盖写入前调用)。
70
+ *
71
+ * @param outputDir 书目录。
72
+ * @param chapter 章节(需要已存在正文文件)。
73
+ * @param reason 留存原因(生成覆盖 / 采纳草稿 / 修订 / 润色 / 手工)。
74
+ * @param keep 每章保留数量(超出淘汰最旧)。
75
+ * @returns 新建的快照;无正文文件时返回 undefined(不产生空快照)。
76
+ */
77
+ export function createSnapshot(
78
+ outputDir: string,
79
+ chapter: ChapterPlan,
80
+ reason: string,
81
+ keep = DEFAULT_SNAPSHOT_KEEP,
82
+ ): ChapterSnapshot | undefined {
83
+ if (chapter.file === undefined || chapter.file === '') return undefined
84
+ const source = join(outputDir, chapter.file)
85
+ if (!existsSync(source)) return undefined
86
+ const at = new Date().toISOString()
87
+ const dir = snapshotDir(outputDir)
88
+ mkdirSync(dir, { recursive: true })
89
+ const name = snapshotFileName(chapter.no, reason, at)
90
+ const target = join(dir, name)
91
+ try {
92
+ copyFileSync(source, target)
93
+ } catch {
94
+ return undefined
95
+ }
96
+ const chars = chapter.chars ?? 0
97
+ const snapshot: ChapterSnapshot = {
98
+ id: name,
99
+ chapterNo: chapter.no,
100
+ file: `${SNAPSHOT_DIR}/${name}`,
101
+ reason,
102
+ chars,
103
+ at,
104
+ }
105
+ const snapshots = [...loadSnapshots(outputDir), snapshot]
106
+ saveSnapshots(outputDir, pruneList(snapshots, keep))
107
+ return snapshot
108
+ }
109
+
110
+ /** 按「每章保留最近 keep 个」裁剪列表,并删除被淘汰的文件。 */
111
+ function pruneList(snapshots: ChapterSnapshot[], keep: number): ChapterSnapshot[] {
112
+ const byChapter = new Map<number, ChapterSnapshot[]>()
113
+ for (const s of snapshots) {
114
+ const list = byChapter.get(s.chapterNo) ?? []
115
+ list.push(s)
116
+ byChapter.set(s.chapterNo, list)
117
+ }
118
+ const kept: ChapterSnapshot[] = []
119
+ for (const list of byChapter.values()) {
120
+ list.sort((a, b) => b.at.localeCompare(a.at))
121
+ kept.push(...list.slice(0, Math.max(1, keep)))
122
+ }
123
+ return kept.sort((a, b) => a.at.localeCompare(b.at))
124
+ }
125
+
126
+ /**
127
+ * 回滚到某个快照:把快照内容写回章节正文文件。
128
+ *
129
+ * 回滚前会**先给当前正文留一份快照**(reason=回滚前自动留存),所以回滚本身也可撤销。
130
+ *
131
+ * @param outputDir 书目录。
132
+ * @param chapter 目标章节。
133
+ * @param snapshotId 快照 id。
134
+ * @returns 回滚后的字符数。
135
+ * @throws 快照不存在或章节无正文文件时抛错。
136
+ */
137
+ export function restoreSnapshot(outputDir: string, chapter: ChapterPlan, snapshotId: string): number {
138
+ const snapshots = loadSnapshots(outputDir)
139
+ const snapshot = snapshots.find(s => s.id === snapshotId)
140
+ if (snapshot === undefined) throw new Error(`快照不存在:${snapshotId}`)
141
+ if (snapshot.chapterNo !== chapter.no) throw new Error(`快照属于第 ${snapshot.chapterNo} 章,与目标第 ${chapter.no} 章不符`)
142
+ const source = join(outputDir, snapshot.file)
143
+ if (!existsSync(source)) throw new Error(`快照文件已丢失:${snapshot.file}`)
144
+ if (chapter.file === undefined || chapter.file === '') throw new Error(`第 ${chapter.no} 章没有正文文件,无法回滚`)
145
+ createSnapshot(outputDir, chapter, '回滚前留存')
146
+ const body = readFileSync(source, 'utf8')
147
+ writeFileSync(join(outputDir, chapter.file), body, 'utf8')
148
+ return body.length
149
+ }
150
+
151
+ /** 删除一个快照(连同文件)。 */
152
+ export function removeSnapshot(outputDir: string, snapshotId: string): boolean {
153
+ const snapshots = loadSnapshots(outputDir)
154
+ const snapshot = snapshots.find(s => s.id === snapshotId)
155
+ if (snapshot === undefined) return false
156
+ try { rmSync(join(outputDir, snapshot.file), { force: true }) } catch { /* 文件已不在 */ }
157
+ saveSnapshots(outputDir, snapshots.filter(s => s.id !== snapshotId))
158
+ return true
159
+ }
160
+
161
+ /** 按每章保留数量清理(连文件一起删)。 */
162
+ export function pruneSnapshots(outputDir: string, keep = DEFAULT_SNAPSHOT_KEEP): number {
163
+ const snapshots = loadSnapshots(outputDir)
164
+ const kept = pruneList(snapshots, keep)
165
+ const keptIds = new Set(kept.map(s => s.id))
166
+ let removed = 0
167
+ for (const s of snapshots) {
168
+ if (keptIds.has(s.id)) continue
169
+ try { rmSync(join(outputDir, s.file), { force: true }) } catch { /* ignore */ }
170
+ removed++
171
+ }
172
+ saveSnapshots(outputDir, kept)
173
+ return removed
174
+ }
@@ -0,0 +1,189 @@
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
+ /** 创作阶段标识(线上形状见 protocol.ts 的 StageInfo)。 */
18
+ export type BookStageId = StageInfo['id']
19
+
20
+ /** 一个阶段契约。 */
21
+ export type BookStage = StageInfo
22
+
23
+ /** 大纲视为「已开书」的最短长度。 */
24
+ const MIN_OUTLINE_CHARS = 50
25
+
26
+ /** 通用续写指令(作者没有指定具体对象时,阶段白名单成为唯一命令)。 */
27
+ const GENERIC_CONTINUE = /^(继续|下一步|接着来|接着写|往下|往下走|继续吧|go\s*on|continue|next)[。.!!??\s]*$/i
28
+
29
+ /**
30
+ * 作者这一轮是否只说了「继续/下一步」这类没有具体对象的指令。
31
+ *
32
+ * @param message 本轮用户消息。
33
+ * @returns 是通用续写指令时为 true。
34
+ */
35
+ export function isGenericContinue(message: string): boolean {
36
+ return GENERIC_CONTINUE.test((message ?? '').trim())
37
+ }
38
+
39
+ /**
40
+ * 根据项目真实状态计算当前创作阶段。
41
+ *
42
+ * @param project 当前项目(未加载时为 undefined)。
43
+ * @returns 阶段契约(推荐动作 + 写操作白名单 + 判定理由)。
44
+ */
45
+ export function computeBookStage(project: ProjectState | undefined): BookStage {
46
+ if (project === undefined || (project.outline ?? '').trim().length < MIN_OUTLINE_CHARS) {
47
+ return {
48
+ id: 'outline',
49
+ label: '开书',
50
+ recommend: ['outline_replace'],
51
+ allow: ['outline_replace'],
52
+ reason: project === undefined ? '尚无项目' : '大纲为空或过短',
53
+ }
54
+ }
55
+ if (project.bible === undefined) {
56
+ return {
57
+ id: 'bible',
58
+ label: '立设定',
59
+ recommend: ['bible_set_rule', 'bible_set_redline'],
60
+ allow: ['bible_set_rule', 'bible_set_redline'],
61
+ reason: '已有大纲,尚未提炼道藏',
62
+ }
63
+ }
64
+ const chapters = project.chapters ?? []
65
+ if (chapters.length === 0) {
66
+ return {
67
+ id: 'plan',
68
+ label: '排章节',
69
+ recommend: [],
70
+ // 章节规划不在助手的动作集里:通用「继续」不应被解释成改写总纲或设定。
71
+ allow: [],
72
+ reason: '有道藏但还没有章节计划(规划在「小说工坊」面板或生产单里执行)',
73
+ }
74
+ }
75
+ const generating = chapters.filter(c => c.status === 'generating')
76
+ if (generating.length > 0) {
77
+ return {
78
+ id: 'wait',
79
+ label: '等待中',
80
+ recommend: [],
81
+ allow: [],
82
+ reason: `第 ${generating.map(c => c.no).join('、')} 章正在生成,等它结束后再安排下一步`,
83
+ }
84
+ }
85
+ const errored = chapters.filter(c => c.status === 'error')
86
+ if (errored.length > 0) {
87
+ return {
88
+ id: 'write',
89
+ label: '逐章编译',
90
+ recommend: ['chapter_generate'],
91
+ allow: ['chapter_generate', 'chapter_rewrite', 'chapter_review'],
92
+ reason: `第 ${errored.map(c => c.no).join('、')} 章生成失败,需要重写`,
93
+ }
94
+ }
95
+ const rejected = chapters.filter(c => c.status === 'rejected')
96
+ if (rejected.length > 0) {
97
+ return {
98
+ id: 'revise',
99
+ label: '修订循环',
100
+ recommend: ['chapter_rewrite'],
101
+ allow: ['chapter_rewrite', 'chapter_review'],
102
+ reason: `第 ${rejected.map(c => c.no).join('、')} 章审稿未过,需要按意见修订`,
103
+ }
104
+ }
105
+ const awaitingReview = chapters.filter(c => c.status === 'written' || c.status === 'reviewing')
106
+ if (awaitingReview.length > 0) {
107
+ return {
108
+ id: 'review',
109
+ label: '审稿',
110
+ recommend: ['chapter_review'],
111
+ allow: ['chapter_review', 'chapter_rewrite'],
112
+ reason: `第 ${awaitingReview.map(c => c.no).join('、')} 章已写出但尚未审稿`,
113
+ }
114
+ }
115
+ const pending = chapters.filter(c => c.status === 'pending')
116
+ if (pending.length > 0) {
117
+ return {
118
+ id: 'write',
119
+ label: '逐章编译',
120
+ recommend: ['chapter_generate'],
121
+ allow: ['chapter_generate', 'chapter_rewrite', 'chapter_review'],
122
+ reason: `还有 ${pending.length} 章待写(下一章:第 ${pending[0]!.no} 章)`,
123
+ }
124
+ }
125
+ return {
126
+ id: 'export',
127
+ label: '定稿导出',
128
+ recommend: ['export_txt', 'blurb'],
129
+ // 全部通过后不再允许在「继续」下改写正文:要改必须由作者点名章节。
130
+ allow: ['export_txt', 'blurb'],
131
+ reason: `全书 ${chapters.length} 章均已通过审稿`,
132
+ }
133
+ }
134
+
135
+ /**
136
+ * 判断某写操作是否在当前阶段的允许白名单内。
137
+ *
138
+ * @param stage 阶段契约。
139
+ * @param tool 助手工具名。
140
+ * @returns 允许时为 true。
141
+ */
142
+ export function stageAllows(stage: BookStage, tool: string): boolean {
143
+ return stage.allow.includes(tool)
144
+ }
145
+
146
+ /**
147
+ * 按阶段把一组动作分成「本阶段可用」与「阶段外」。
148
+ *
149
+ * @param stage 阶段契约。
150
+ * @param tools 动作名列表(通常是写操作登记表)。
151
+ * @returns 两个保持输入顺序的子集。
152
+ */
153
+ export function splitByStage(stage: BookStage, tools: readonly string[]): { available: string[]; outside: string[] } {
154
+ const available: string[] = []
155
+ const outside: string[] = []
156
+ for (const tool of tools) {
157
+ if (stageAllows(stage, tool)) available.push(tool)
158
+ else outside.push(tool)
159
+ }
160
+ return { available, outside }
161
+ }
162
+
163
+ /**
164
+ * 渲染阶段契约为可注入提示词的一段文字。
165
+ *
166
+ * @param stage 阶段契约。
167
+ * @returns 多行文本。
168
+ */
169
+ export function renderStageContract(stage: BookStage): string {
170
+ const recommend = stage.recommend.length > 0 ? stage.recommend.join('、') : '无(等待或由作者指定)'
171
+ const allow = stage.allow.length > 0 ? stage.allow.join('、') : '无'
172
+ return [
173
+ '==================== 当前创作阶段(宿主根据项目真实状态计算) ====================',
174
+ `阶段:${stage.label}(${stage.id})`,
175
+ `判定依据:${stage.reason}`,
176
+ `建议下一步:${recommend}`,
177
+ `本阶段允许的写操作:${allow}`,
178
+ '阶段纪律:作者只说了「继续/下一步」而没指定具体对象时,只有上面的写操作会被执行,其余写操作一律拒绝;作者显式点名某个操作时以作者指令为准。',
179
+ ].join('\n')
180
+ }
181
+
182
+ /** 汇总章节状态计数(供面板/提示词展示,避免各处重复统计)。 */
183
+ export function summarizeChapters(chapters: ChapterPlan[]): Record<string, number> {
184
+ const summary: Record<string, number> = {}
185
+ for (const chapter of chapters) {
186
+ summary[chapter.status] = (summary[chapter.status] ?? 0) + 1
187
+ }
188
+ return summary
189
+ }
package/src/tension.ts ADDED
@@ -0,0 +1,240 @@
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
+ export function detectTensionIssues(project: ProjectState): TensionIssue[] {
130
+ const chapters = [...(project.chapters ?? [])].sort((a, b) => a.no - b.no)
131
+ const values = chapters.map(c => ({ no: c.no, target: targetTension(c), actual: actualTension(c) }))
132
+ const known = values.filter(v => v.target !== undefined || v.actual !== undefined)
133
+ if (known.length < 3) return []
134
+ const issues: TensionIssue[] = []
135
+
136
+ // 1) 偏差
137
+ for (const v of values) {
138
+ if (v.target === undefined || v.actual === undefined) continue
139
+ const gap = v.actual - v.target
140
+ if (Math.abs(gap) > 25) {
141
+ issues.push({
142
+ severity: Math.abs(gap) > 40 ? 'high' : 'medium',
143
+ chapters: [v.no],
144
+ item: `第${v.no}章张力偏差较大:目标 ${v.target},实际 ${v.actual}(${gap > 0 ? '比目标更紧' : '比目标更松'})`,
145
+ suggestion: gap > 0 ? '本章对抗/揭示密度偏高,考虑把部分冲突后移,留出呼吸段。' : '本章推进偏平,考虑加一处对抗、揭示或代价。',
146
+ })
147
+ }
148
+ }
149
+
150
+ // 2) 平台期(连续同值)
151
+ let run = 1
152
+ for (let i = 1; i < values.length; i++) {
153
+ const a = values[i - 1]!.target ?? values[i - 1]!.actual
154
+ const b = values[i]!.target ?? values[i]!.actual
155
+ if (a !== undefined && b !== undefined && Math.abs(b - a) < 6) {
156
+ run++
157
+ if (run === 4) {
158
+ const from = values[i - run + 1]!.no
159
+ issues.push({
160
+ severity: 'medium',
161
+ chapters: [from, values[i]!.no],
162
+ item: `第${from}-${values[i]!.no}章张力几乎无变化(连续 ${run} 章)`,
163
+ suggestion: '安排一次明显抬升或一次释放,让曲线有起伏,避免读者疲劳。',
164
+ })
165
+ }
166
+ } else {
167
+ run = 1
168
+ }
169
+ }
170
+
171
+ // 3) 高位不回落
172
+ let highRun = 0
173
+ for (const v of values) {
174
+ const val = v.target ?? v.actual
175
+ if (val !== undefined && val >= 80) {
176
+ highRun++
177
+ if (highRun === 3) {
178
+ issues.push({
179
+ severity: 'medium',
180
+ chapters: [v.no - 2, v.no],
181
+ item: `连续 ${highRun} 章处在高位(≥80),缺少呼吸口`,
182
+ suggestion: '高潮之后补一章缓冲(关系推进、信息消化或日常),再进入下一轮对抗。',
183
+ })
184
+ }
185
+ } else {
186
+ highRun = 0
187
+ }
188
+ }
189
+
190
+ // 4) 长期低位
191
+ let lowRun = 0
192
+ for (const v of values) {
193
+ const val = v.target ?? v.actual
194
+ if (val !== undefined && val < 40) {
195
+ lowRun++
196
+ if (lowRun === 5) {
197
+ issues.push({
198
+ severity: 'high',
199
+ chapters: [v.no - 4, v.no],
200
+ item: `连续 ${lowRun} 章张力低于 40,推进可能偏慢`,
201
+ suggestion: '提前兑现一个目标、抛出一个新风险,或让配角线产生冲撞。',
202
+ })
203
+ }
204
+ } else {
205
+ lowRun = 0
206
+ }
207
+ }
208
+
209
+ return issues
210
+ }
211
+
212
+ /**
213
+ * 组装面板需要的曲线数据(章节号 / 目标 / 实际 / 参考)。
214
+ *
215
+ * @param project 项目状态。
216
+ * @returns 逐章数据点与预设名。
217
+ */
218
+ export function buildCurveData(project: ProjectState): {
219
+ preset: TensionCurvePreset
220
+ points: Array<{ no: number; title: string; target?: number; actual?: number; reference?: number }>
221
+ } {
222
+ const preset: TensionCurvePreset = project.tensionCurve?.preset ?? 'escalation'
223
+ const chapters = [...(project.chapters ?? [])].sort((a, b) => a.no - b.no)
224
+ const custom = project.tensionCurve?.custom ?? []
225
+ const reference = preset === 'custom'
226
+ ? custom
227
+ : referenceCurve(preset, chapters.length)
228
+ return {
229
+ preset,
230
+ points: chapters.map((c, i) => {
231
+ const point: { no: number; title: string; target?: number; actual?: number; reference?: number } = { no: c.no, title: c.title }
232
+ const t = targetTension(c)
233
+ const a = actualTension(c)
234
+ if (t !== undefined) point.target = t
235
+ if (a !== undefined) point.actual = a
236
+ if (reference[i] !== undefined) point.reference = reference[i]
237
+ return point
238
+ }),
239
+ }
240
+ }