@waterwx/dsh-novel-forge 1.1.8 → 1.2.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 (126) hide show
  1. package/README.md +231 -349
  2. package/lib/client.js +54833 -9926
  3. package/lib/index.js +12137 -4734
  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 +18 -3
  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 +153 -9
  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 +33 -0
  14. package/lib/types/client/locales.d.ts +34 -9
  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 +8 -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/ReasoningSection.d.ts +10 -0
  36. package/lib/types/client/panel/RunPanel.d.ts +5 -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/toast.d.ts +2 -0
  41. package/lib/types/client/panel/views.d.ts +25 -7
  42. package/lib/types/client/run-status.d.ts +19 -0
  43. package/lib/types/client/text.d.ts +9 -0
  44. package/lib/types/docx-core.d.ts +16 -0
  45. package/lib/types/docx.d.ts +2 -3
  46. package/lib/types/engine.d.ts +152 -40
  47. package/lib/types/global-assets.d.ts +6 -0
  48. package/lib/types/index.d.ts +20 -2
  49. package/lib/types/llm-live.d.ts +44 -0
  50. package/lib/types/market-radar-scan.d.ts +19 -0
  51. package/lib/types/market-radar-sources.d.ts +31 -0
  52. package/lib/types/migrate-output.d.ts +25 -0
  53. package/lib/types/novel-context.d.ts +80 -0
  54. package/lib/types/prompt-utils.d.ts +35 -0
  55. package/lib/types/prompting.d.ts +10 -0
  56. package/lib/types/protocol.d.ts +1106 -123
  57. package/lib/types/review-policy.d.ts +97 -0
  58. package/lib/types/routes.d.ts +4 -0
  59. package/lib/types/run.d.ts +34 -0
  60. package/lib/types/stage-contract.d.ts +60 -0
  61. package/package.json +52 -27
  62. package/scripts/patch-schemastery.mjs +47 -0
  63. package/src/action-guard.ts +127 -0
  64. package/src/ai-scan.ts +179 -0
  65. package/src/assets.ts +1774 -520
  66. package/src/assistant.ts +1082 -770
  67. package/src/author-assets.ts +149 -0
  68. package/src/bookshelf.ts +187 -151
  69. package/src/client/api.ts +684 -387
  70. package/src/client/docx.ts +37 -69
  71. package/src/client/index.ts +3 -1
  72. package/src/client/llmLive.ts +101 -0
  73. package/src/client/locales.ts +418 -367
  74. package/src/client/mount.tsx +1 -1
  75. package/src/client/panel/AdaptModeView.tsx +535 -0
  76. package/src/client/panel/AiTag.tsx +7 -0
  77. package/src/client/panel/AssetsTab.tsx +448 -439
  78. package/src/client/panel/AssistantTab.tsx +355 -301
  79. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  80. package/src/client/panel/AuthorHome.tsx +309 -0
  81. package/src/client/panel/BookAnalysisView.tsx +63 -0
  82. package/src/client/panel/CmdPalette.tsx +102 -0
  83. package/src/client/panel/CreateBookView.tsx +366 -348
  84. package/src/client/panel/DirectorView.tsx +200 -0
  85. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
  86. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  87. package/src/client/panel/ImportModal.tsx +303 -0
  88. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  89. package/src/client/panel/LiveFeedLog.tsx +67 -0
  90. package/src/client/panel/MarketRadarView.tsx +377 -0
  91. package/src/client/panel/ModelManager.tsx +205 -0
  92. package/src/client/panel/NovelPanel.tsx +5403 -4294
  93. package/src/client/panel/PageHeader.tsx +37 -0
  94. package/src/client/panel/ProgressConsole.tsx +63 -0
  95. package/src/client/panel/ReaderView.tsx +306 -306
  96. package/src/client/panel/ReasoningSection.tsx +31 -0
  97. package/src/client/panel/RunPanel.tsx +223 -0
  98. package/src/client/panel/SettingsView.tsx +305 -0
  99. package/src/client/panel/ShelfView.tsx +266 -235
  100. package/src/client/panel/SubPage.tsx +42 -0
  101. package/src/client/panel/WorldTab.tsx +202 -202
  102. package/src/client/panel/panel.module.css +5764 -2817
  103. package/src/client/panel/toast.ts +51 -0
  104. package/src/client/panel/views.tsx +401 -314
  105. package/src/client/run-status.ts +143 -0
  106. package/src/client/sidebar-entry.ts +1 -1
  107. package/src/client/text.ts +28 -0
  108. package/src/docx-core.ts +53 -0
  109. package/src/docx.ts +44 -83
  110. package/src/engine.ts +4231 -2746
  111. package/src/global-assets.ts +59 -0
  112. package/src/index.ts +311 -246
  113. package/src/llm-live.ts +68 -0
  114. package/src/market-radar-scan.ts +41 -0
  115. package/src/market-radar-sources.ts +173 -0
  116. package/src/migrate-output.ts +76 -0
  117. package/src/novel-context.ts +286 -0
  118. package/src/prompting.ts +26 -0
  119. package/src/protocol.ts +2072 -1104
  120. package/src/review-policy.ts +161 -0
  121. package/src/routes.ts +3405 -2050
  122. package/src/run.ts +342 -0
  123. package/src/stage-contract.ts +189 -0
  124. package/lib/client.js.map +0 -1
  125. package/lib/index.js.map +0 -1
  126. package/src/client/panel/BookshelfBar.tsx +0 -170
package/src/run.ts ADDED
@@ -0,0 +1,342 @@
1
+ /**
2
+ * 生产单(Production Run):批量章节生产的标准执行器。
3
+ * 职责:计划补足 → 逐章生成(完整质量门)→ 被拒分级处理(豁免/修订+验证/待人工)→ 断点续跑。
4
+ * 与路由层解耦:只依赖 engine 导出函数与磁盘状态,自身不碰 HTTP。
5
+ * 串行纪律:单例执行器 + working 锁;每章重新从磁盘加载,写前合并易变字段。
6
+ */
7
+ import { existsSync, mkdirSync, readFileSync, copyFileSync, writeFileSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+ import type { Context } from '@deepseek-ai/cordis'
10
+ import type { ChapterPlan, NovelConfig, ProjectState, ReviewReport, RunState } from './protocol.ts'
11
+ import {
12
+ loadProject,
13
+ saveProject,
14
+ mergeVolatileFromDisk,
15
+ planChapters,
16
+ generateChapterStream,
17
+ summarizeAndExtractFacts,
18
+ markForeshadowPlanted,
19
+ reviewChapter,
20
+ reviewChapterText,
21
+ authorReviewChapter,
22
+ autoLinkPlotlines,
23
+ readChapterFile,
24
+ rewriteChapterStream,
25
+ chapterFileName,
26
+ } from './engine.ts'
27
+ import { countHanzi } from './engine.ts'
28
+ import { loadBookshelf } from './bookshelf.ts'
29
+
30
+ /** 生产单 checkpoint 文件名(放在书目录下)。 */
31
+ function runStateFile(outputDir: string): string {
32
+ return join(outputDir, 'run-state.json')
33
+ }
34
+
35
+ export class ProductionRunner {
36
+ private state: RunState | null = null
37
+ private working = false
38
+ private pauseRequested = false
39
+ private stopRequested = false
40
+ /** 生产单绑定目录(start 时快照;所有读写固定用它,防止运行中切书导致写错目录)。 */
41
+ private bookDir: string | null = null
42
+
43
+ constructor(private deps: { ctx: Context; getConfig: () => NovelConfig }) {}
44
+
45
+ /** 当前生产单状态(内存优先;web 重启后从磁盘恢复)。 */
46
+ status(): RunState | null {
47
+ if (this.state !== null) return this.state
48
+ // 重启恢复:先找当前激活书目录,再扫书架所有书目录(防切书后找不到)。
49
+ const candidates: string[] = [this.deps.getConfig().outputDir]
50
+ const bookshelf = loadBookshelf()
51
+ for (const b of bookshelf.books) if (!candidates.includes(b.outputDir)) candidates.push(b.outputDir)
52
+ for (const outputDir of candidates) {
53
+ const file = runStateFile(outputDir)
54
+ if (!existsSync(file)) continue
55
+ try {
56
+ const raw = readFileSync(file, 'utf8')
57
+ const parsed = JSON.parse(raw) as RunState
58
+ if (parsed?.runId === undefined) continue
59
+ // 重启恢复:若之前 running,则视为可续跑(loop 未在跑)。
60
+ if (parsed.status === 'running') parsed.status = 'paused'
61
+ this.state = parsed
62
+ this.bookDir = outputDir
63
+ return this.state
64
+ } catch { /* 该目录 run-state 损坏则跳过 */ }
65
+ }
66
+ return null
67
+ }
68
+
69
+ private persist(): void {
70
+ if (this.state === null) return
71
+ const outputDir = this.bookDir ?? this.deps.getConfig().outputDir
72
+ mkdirSync(outputDir, { recursive: true })
73
+ writeFileSync(runStateFile(outputDir), JSON.stringify(this.state, null, 2), 'utf8')
74
+ }
75
+
76
+ private log(text: string): void {
77
+ if (this.state === null) return
78
+ this.state.log.push({ at: new Date().toISOString(), text })
79
+ if (this.state.log.length > 300) this.state.log = this.state.log.slice(-300)
80
+ this.state.updatedAt = new Date().toISOString()
81
+ }
82
+
83
+ /** 启动/续跑生产单:startNo..endNo 区间,endNo 超出计划时先自动补计划。 */
84
+ async start(startNo: number, endNo: number, runDir?: string): Promise<RunState> {
85
+ const config = this.deps.getConfig()
86
+ if (this.working) throw new Error('生产单正在运行中,请先暂停或停止')
87
+ const outputDir = runDir ?? config.outputDir
88
+ let project = loadProject(outputDir)
89
+ if (project === undefined) throw new Error('输出目录中没有项目')
90
+
91
+ // 计划补足:目标区间超出已有计划 → 续写规划追加。
92
+ if (endNo > project.chapters.length) {
93
+ const need = endNo - project.chapters.length
94
+ this.log(`计划不足,追加 ${need} 章计划…`)
95
+ const chapters = await planChapters(this.deps.ctx, config, project, need, undefined, outputDir)
96
+ mergeVolatileFromDisk(outputDir, project)
97
+ project.chapters.push(...chapters)
98
+ project.updatedAt = new Date().toISOString()
99
+ saveProject(outputDir, project)
100
+ this.log(`计划已追加,全书 ${project.chapters.length} 章`)
101
+ }
102
+
103
+ this.state = {
104
+ runId: `run-${Date.now().toString(36)}`,
105
+ startNo,
106
+ endNo,
107
+ status: 'running',
108
+ currentNo: startNo,
109
+ stats: { generated: 0, revised: 0, exempted: 0, regenerated: 0, error: 0 },
110
+ pendingManual: [],
111
+ log: [],
112
+ startedAt: new Date().toISOString(),
113
+ updatedAt: new Date().toISOString(),
114
+ }
115
+ this.pauseRequested = false
116
+ this.stopRequested = false
117
+ // 绑定生产单目录(快照):运行期间切书不影响本单的读写目标。
118
+ this.bookDir = outputDir
119
+ this.working = true
120
+ this.persist()
121
+ void this.loop()
122
+ return this.state
123
+ }
124
+
125
+ pause(): void { this.pauseRequested = true }
126
+ resume(): void {
127
+ if (this.state === null || this.state.status !== 'paused') return
128
+ this.state.status = 'running'
129
+ this.working = true
130
+ this.pauseRequested = false
131
+ this.stopRequested = false
132
+ this.persist()
133
+ void this.loop()
134
+ }
135
+ stop(): void { this.stopRequested = true }
136
+
137
+ /** 是否有生产单正在执行(目录迁移等磁盘级操作前须为 false)。 */
138
+ isWorking(): boolean { return this.working }
139
+
140
+ /** 主循环:从 currentNo 扫描到 endNo,逐章处理;支持暂停/停止。 */
141
+ private async loop(): Promise<void> {
142
+ // 全部读写固定使用生产单绑定目录(bookDir),绝不跟随当前激活书。
143
+ const config = this.deps.getConfig()
144
+ const outputDir = this.bookDir ?? config.outputDir
145
+ try {
146
+ while (this.state !== null) {
147
+ if (this.stopRequested) {
148
+ this.stopRequested = false
149
+ this.state.status = 'stopped'
150
+ this.log('生产单已停止')
151
+ this.persist()
152
+ break
153
+ }
154
+ if (this.pauseRequested) {
155
+ this.pauseRequested = false
156
+ this.state.status = 'paused'
157
+ this.log('生产单已暂停(随时可继续)')
158
+ this.persist()
159
+ break
160
+ }
161
+ const project = loadProject(outputDir)
162
+ if (project === undefined) {
163
+ this.state.status = 'error'
164
+ this.state.error = '项目丢失'
165
+ this.persist()
166
+ break
167
+ }
168
+ // 找下一个需要处理的章(approved 快进)。
169
+ let next: ChapterPlan | undefined
170
+ for (let no = this.state.currentNo; no <= this.state.endNo; no++) {
171
+ const ch = project.chapters.find(c => c.no === no)
172
+ if (ch === undefined) continue
173
+ if (ch.status === 'approved') { this.state.currentNo = no; continue }
174
+ next = ch
175
+ break
176
+ }
177
+ if (next === undefined) {
178
+ this.state.status = 'done'
179
+ this.log(`生产单完成:${this.state.startNo}-${this.state.endNo} 章处理完毕`)
180
+ this.persist()
181
+ break
182
+ }
183
+ this.state.currentNo = next.no
184
+ await this.processChapter(project, next)
185
+ this.persist()
186
+ }
187
+ } catch (error) {
188
+ if (this.state !== null) {
189
+ this.state.status = 'error'
190
+ this.state.error = (error as Error).message
191
+ this.log(`生产单异常:${(error as Error).message}`)
192
+ this.persist()
193
+ }
194
+ } finally {
195
+ this.working = false
196
+ }
197
+ }
198
+
199
+ private async processChapter(project: ProjectState, chapter: ChapterPlan): Promise<void> {
200
+ if (chapter.status === 'pending' || chapter.status === 'error' || chapter.status === 'written') {
201
+ await this.produce(project, chapter)
202
+ } else if (chapter.status === 'rejected') {
203
+ await this.handleRejected(project, chapter)
204
+ } else if (chapter.status === 'generating') {
205
+ // 中断残留:复位重生成。
206
+ chapter.status = 'pending'
207
+ await this.produce(project, chapter)
208
+ }
209
+ }
210
+
211
+ /** 完整质量门:生成 → 摘要+事实 → 伏笔标记 → 审稿 → 作者复盘。 */
212
+ private async produce(project: ProjectState, chapter: ChapterPlan): Promise<void> {
213
+ const { ctx, getConfig } = this.deps
214
+ const config = getConfig()
215
+ const outputDir = this.bookDir ?? config.outputDir
216
+ const no = chapter.no
217
+ const wasError = chapter.status === 'error'
218
+ chapter.status = 'generating'
219
+ chapter.error = undefined
220
+ chapter.generatingAt = new Date().toISOString()
221
+ mergeVolatileFromDisk(outputDir, project)
222
+ saveProject(outputDir, project)
223
+ this.log(`${wasError ? '重新生成' : '生成'} 第${no}章《${chapter.title}》…(模型 ${config.generateModel || config.model})`)
224
+ try {
225
+ for await (const _step of generateChapterStream(ctx, config, project, outputDir, no)) { /* drain */ }
226
+ try { await summarizeAndExtractFacts(ctx, config, project, outputDir, no) } catch (e) { console.warn('[dsh-novel-forge] run summary/facts:', (e as Error).message) }
227
+ try { markForeshadowPlanted(project, outputDir, no) } catch (e) { console.warn('[dsh-novel-forge] run foreshadow:', (e as Error).message) }
228
+ if (config.autoReview ?? true) {
229
+ const report = await reviewChapter(ctx, config, project, outputDir, no)
230
+ if (this.state !== null) this.state.stats[wasError ? 'regenerated' : 'generated']++
231
+ this.log(`第${no}章 审稿 ${report.score}分 ${report.passed ? ' 通过' : ' 被拒'}(模型 ${config.reviewModel || config.model})`)
232
+ } else {
233
+ chapter.status = 'approved'
234
+ mergeVolatileFromDisk(outputDir, project)
235
+ saveProject(outputDir, project)
236
+ }
237
+ if (config.autoAuthorReview ?? true) {
238
+ try {
239
+ const body = readChapterFile(outputDir, chapter)
240
+ if (body !== undefined) {
241
+ let prevTail = ''
242
+ if (no > 1) {
243
+ const prev = project.chapters.find(c => c.no === no - 1)
244
+ if (prev !== undefined) prevTail = (readChapterFile(outputDir, prev) ?? '').replace(/^#.*$/m, '').trim().slice(-600)
245
+ }
246
+ const review = await authorReviewChapter(ctx, config, project, no, body, prevTail)
247
+ chapter.authorReview = review
248
+ if (review.advancedLines !== undefined) autoLinkPlotlines(project, no, review.advancedLines)
249
+ // 结果回灌:把本章状态变化/新线索写进编年录事实库,供后续计划与整本控制用。
250
+ const backfillFacts: string[] = [...(review.stateChanges ?? []), ...(review.clues ?? [])]
251
+ if (backfillFacts.length > 0) {
252
+ project.facts ??= []
253
+ for (const text of backfillFacts) project.facts.push({ chapterNo: no, text })
254
+ }
255
+ mergeVolatileFromDisk(outputDir, project)
256
+ saveProject(outputDir, project)
257
+ }
258
+ } catch (e) { console.warn('[dsh-novel-forge] run author review:', (e as Error).message) }
259
+ }
260
+ } catch (error) {
261
+ chapter.status = 'error'
262
+ chapter.error = (error as Error).message
263
+ mergeVolatileFromDisk(outputDir, project)
264
+ saveProject(outputDir, project)
265
+ if (this.state !== null) this.state.stats.error++
266
+ this.log(`第${no}章 失败:${(error as Error).message}`)
267
+ }
268
+ }
269
+
270
+ /** 被拒分级处理:无 high 豁免;有 high 按意见修订(最多 2 轮)+ 验证模式;仍不过 → 待人工。 */
271
+ private async handleRejected(project: ProjectState, chapter: ChapterPlan): Promise<void> {
272
+ const { ctx, getConfig } = this.deps
273
+ const config = getConfig()
274
+ const outputDir = this.bookDir ?? config.outputDir
275
+ const no = chapter.no
276
+ const report = chapter.review
277
+ const highs = (report?.issues ?? []).filter(i => i.severity === 'high')
278
+ if (highs.length === 0) {
279
+ chapter.status = 'approved'
280
+ project.updatedAt = new Date().toISOString()
281
+ saveProject(outputDir, project)
282
+ if (this.state !== null) this.state.stats.exempted++
283
+ this.log(`第${no}章 豁免通过(无 high)`)
284
+ return
285
+ }
286
+ this.log(`第${no}章 修订(${highs.length} 个 high)…`)
287
+ for (let round = 1; round <= 2; round++) {
288
+ const instr = '按审稿意见修订(优先处理):\n' + highs.map(h => `[${h.severity}] ${h.item}${h.suggestion !== '' ? `\n建议:${h.suggestion}` : ''}`).join('\n\n')
289
+ try {
290
+ for await (const _step of rewriteChapterStream(ctx, config, project, outputDir, no, instr, undefined)) { /* drain */ }
291
+ } catch (error) {
292
+ this.log(`第${no}章 第${round}轮修订出错:${(error as Error).message}`)
293
+ continue
294
+ }
295
+ // rewrite 已写盘(pendingDraft + saveProject),重载拿最新草稿。
296
+ const fresh = loadProject(outputDir)
297
+ const freshCh = fresh?.chapters.find(c => c.no === no)
298
+ const draft = freshCh?.pendingDraft
299
+ if (draft === undefined || draft.length < 50) {
300
+ this.log(`第${no}章 第${round}轮草稿缺失,重试`)
301
+ continue
302
+ }
303
+ const verify = await reviewChapterText(ctx, config, fresh!, draft, chapter.review)
304
+ const highs2 = (verify.issues ?? []).filter(i => i.severity === 'high')
305
+ if (verify.passed || highs2.length === 0) {
306
+ // 应用草稿(与 draft/apply 路由同逻辑):备份原稿 → 落盘 → 定状态。
307
+ this.applyDraft(fresh!, freshCh!, draft, verify)
308
+ if (this.state !== null) this.state.stats.revised++
309
+ this.log(`第${no}章 第${round}轮修订通过(${verify.score}分)`)
310
+ return
311
+ }
312
+ this.log(`第${no}章 第${round}轮仍不过(${highs2.length} high)`)
313
+ }
314
+ if (this.state !== null) this.state.pendingManual.push(no)
315
+ this.log(`第${no}章 两轮修订仍不过 → 保留草稿待人工`)
316
+ }
317
+
318
+ private applyDraft(project: ProjectState, chapter: ChapterPlan, draft: string, report: ReviewReport): void {
319
+ const config = this.deps.getConfig()
320
+ const outputDir = this.bookDir ?? config.outputDir
321
+ const fileName = chapterFileName(chapter)
322
+ mkdirSync(outputDir, { recursive: true })
323
+ const targetPath = join(outputDir, fileName)
324
+ if (existsSync(targetPath)) {
325
+ copyFileSync(targetPath, join(outputDir, `${fileName.replace(/\.md$/, '')}.bak.md`))
326
+ }
327
+ writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, 'utf8')
328
+ chapter.pendingDraft = undefined
329
+ chapter.chars = countHanzi(draft)
330
+ chapter.file = fileName
331
+ if (typeof report.score === 'number') {
332
+ chapter.review = report
333
+ chapter.status = report.passed === true ? 'approved' : 'rejected'
334
+ } else {
335
+ chapter.status = 'written'
336
+ chapter.review = undefined
337
+ }
338
+ chapter.error = undefined
339
+ project.updatedAt = new Date().toISOString()
340
+ saveProject(outputDir, project)
341
+ }
342
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * 阶段契约(Stage Contract)——宿主计算的「当前阶段 → 本轮允许的动作」。
3
+ *
4
+ * 设计来源(借鉴 dsh-ai-novel-writer 的「作者请求中的当前阶段决定唯一允许命令」):
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
+ }