@waterwx/dsh-novel-forge 1.1.8 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/lib/client.js +2262 -1242
- package/lib/client.js.map +1 -1
- package/lib/index.js +1017 -551
- package/lib/index.js.map +1 -1
- package/lib/types/bookshelf.d.ts +1 -1
- package/lib/types/client/api.d.ts +6 -0
- package/lib/types/client/locales.d.ts +8 -0
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/client/panel/RunPanel.d.ts +5 -0
- package/lib/types/engine.d.ts +7 -1
- package/lib/types/index.d.ts +3 -1
- package/lib/types/protocol.d.ts +77 -0
- package/lib/types/run.d.ts +32 -0
- package/package.json +2 -1
- package/src/bookshelf.ts +2 -2
- package/src/client/api.ts +23 -0
- package/src/client/locales.ts +16 -0
- package/src/client/panel/AssetsTab.tsx +19 -0
- package/src/client/panel/CreateBookView.tsx +3 -3
- package/src/client/panel/NovelPanel.tsx +528 -225
- package/src/client/panel/RunPanel.tsx +204 -0
- package/src/client/panel/ShelfView.tsx +13 -12
- package/src/client/panel/panel.module.css +192 -68
- package/src/engine.ts +42 -1
- package/src/index.ts +11 -5
- package/src/protocol.ts +78 -0
- package/src/routes.ts +105 -4
- package/src/run.ts +330 -0
package/src/run.ts
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
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 { loadBookshelf } from './bookshelf.ts'
|
|
28
|
+
|
|
29
|
+
/** 生产单 checkpoint 文件名(放在书目录下)。 */
|
|
30
|
+
function runStateFile(outputDir: string): string {
|
|
31
|
+
return join(outputDir, 'run-state.json')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class ProductionRunner {
|
|
35
|
+
private state: RunState | null = null
|
|
36
|
+
private working = false
|
|
37
|
+
private pauseRequested = false
|
|
38
|
+
private stopRequested = false
|
|
39
|
+
/** 生产单绑定目录(start 时快照;所有读写固定用它,防止运行中切书导致写错目录)。 */
|
|
40
|
+
private bookDir: string | null = null
|
|
41
|
+
|
|
42
|
+
constructor(private deps: { ctx: Context; getConfig: () => NovelConfig }) {}
|
|
43
|
+
|
|
44
|
+
/** 当前生产单状态(内存优先;web 重启后从磁盘恢复)。 */
|
|
45
|
+
status(): RunState | null {
|
|
46
|
+
if (this.state !== null) return this.state
|
|
47
|
+
// 重启恢复:先找当前激活书目录,再扫书架所有书目录(防切书后找不到)。
|
|
48
|
+
const candidates: string[] = [this.deps.getConfig().outputDir]
|
|
49
|
+
const bookshelf = loadBookshelf()
|
|
50
|
+
for (const b of bookshelf.books) if (!candidates.includes(b.outputDir)) candidates.push(b.outputDir)
|
|
51
|
+
for (const outputDir of candidates) {
|
|
52
|
+
const file = runStateFile(outputDir)
|
|
53
|
+
if (!existsSync(file)) continue
|
|
54
|
+
try {
|
|
55
|
+
const raw = readFileSync(file, 'utf8')
|
|
56
|
+
const parsed = JSON.parse(raw) as RunState
|
|
57
|
+
if (parsed?.runId === undefined) continue
|
|
58
|
+
// 重启恢复:若之前 running,则视为可续跑(loop 未在跑)。
|
|
59
|
+
if (parsed.status === 'running') parsed.status = 'paused'
|
|
60
|
+
this.state = parsed
|
|
61
|
+
this.bookDir = outputDir
|
|
62
|
+
return this.state
|
|
63
|
+
} catch { /* 该目录 run-state 损坏则跳过 */ }
|
|
64
|
+
}
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private persist(): void {
|
|
69
|
+
if (this.state === null) return
|
|
70
|
+
const outputDir = this.bookDir ?? this.deps.getConfig().outputDir
|
|
71
|
+
mkdirSync(outputDir, { recursive: true })
|
|
72
|
+
writeFileSync(runStateFile(outputDir), JSON.stringify(this.state, null, 2), 'utf8')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private log(text: string): void {
|
|
76
|
+
if (this.state === null) return
|
|
77
|
+
this.state.log.push({ at: new Date().toISOString(), text })
|
|
78
|
+
if (this.state.log.length > 300) this.state.log = this.state.log.slice(-300)
|
|
79
|
+
this.state.updatedAt = new Date().toISOString()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 启动/续跑生产单:startNo..endNo 区间,endNo 超出计划时先自动补计划。 */
|
|
83
|
+
async start(startNo: number, endNo: number): Promise<RunState> {
|
|
84
|
+
const config = this.deps.getConfig()
|
|
85
|
+
if (this.working) throw new Error('生产单正在运行中,请先暂停或停止')
|
|
86
|
+
let project = loadProject(config.outputDir)
|
|
87
|
+
if (project === undefined) throw new Error('输出目录中没有项目')
|
|
88
|
+
|
|
89
|
+
// 计划补足:目标区间超出已有计划 → 续写规划追加。
|
|
90
|
+
if (endNo > project.chapters.length) {
|
|
91
|
+
const need = endNo - project.chapters.length
|
|
92
|
+
this.log(`计划不足,追加 ${need} 章计划…`)
|
|
93
|
+
const chapters = await planChapters(this.deps.ctx, config, project, need, undefined, config.outputDir)
|
|
94
|
+
mergeVolatileFromDisk(config.outputDir, project)
|
|
95
|
+
project.chapters.push(...chapters)
|
|
96
|
+
project.updatedAt = new Date().toISOString()
|
|
97
|
+
saveProject(config.outputDir, project)
|
|
98
|
+
this.log(`计划已追加,全书 ${project.chapters.length} 章`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.state = {
|
|
102
|
+
runId: `run-${Date.now().toString(36)}`,
|
|
103
|
+
startNo,
|
|
104
|
+
endNo,
|
|
105
|
+
status: 'running',
|
|
106
|
+
currentNo: startNo,
|
|
107
|
+
stats: { generated: 0, revised: 0, exempted: 0, regenerated: 0, error: 0 },
|
|
108
|
+
pendingManual: [],
|
|
109
|
+
log: [],
|
|
110
|
+
startedAt: new Date().toISOString(),
|
|
111
|
+
updatedAt: new Date().toISOString(),
|
|
112
|
+
}
|
|
113
|
+
this.pauseRequested = false
|
|
114
|
+
this.stopRequested = false
|
|
115
|
+
// 绑定生产单目录(快照):运行期间切书不影响本单的读写目标。
|
|
116
|
+
this.bookDir = config.outputDir
|
|
117
|
+
this.working = true
|
|
118
|
+
this.persist()
|
|
119
|
+
void this.loop()
|
|
120
|
+
return this.state
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
pause(): void { this.pauseRequested = true }
|
|
124
|
+
resume(): void {
|
|
125
|
+
if (this.state === null || this.state.status !== 'paused') return
|
|
126
|
+
this.state.status = 'running'
|
|
127
|
+
this.working = true
|
|
128
|
+
this.pauseRequested = false
|
|
129
|
+
this.stopRequested = false
|
|
130
|
+
this.persist()
|
|
131
|
+
void this.loop()
|
|
132
|
+
}
|
|
133
|
+
stop(): void { this.stopRequested = true }
|
|
134
|
+
|
|
135
|
+
/** 主循环:从 currentNo 扫描到 endNo,逐章处理;支持暂停/停止。 */
|
|
136
|
+
private async loop(): Promise<void> {
|
|
137
|
+
// 全部读写固定使用生产单绑定目录(bookDir),绝不跟随当前激活书。
|
|
138
|
+
const config = this.deps.getConfig()
|
|
139
|
+
const outputDir = this.bookDir ?? config.outputDir
|
|
140
|
+
try {
|
|
141
|
+
while (this.state !== null) {
|
|
142
|
+
if (this.stopRequested) {
|
|
143
|
+
this.stopRequested = false
|
|
144
|
+
this.state.status = 'stopped'
|
|
145
|
+
this.log('生产单已停止')
|
|
146
|
+
this.persist()
|
|
147
|
+
break
|
|
148
|
+
}
|
|
149
|
+
if (this.pauseRequested) {
|
|
150
|
+
this.pauseRequested = false
|
|
151
|
+
this.state.status = 'paused'
|
|
152
|
+
this.log('生产单已暂停(随时可继续)')
|
|
153
|
+
this.persist()
|
|
154
|
+
break
|
|
155
|
+
}
|
|
156
|
+
const project = loadProject(outputDir)
|
|
157
|
+
if (project === undefined) {
|
|
158
|
+
this.state.status = 'error'
|
|
159
|
+
this.state.error = '项目丢失'
|
|
160
|
+
this.persist()
|
|
161
|
+
break
|
|
162
|
+
}
|
|
163
|
+
// 找下一个需要处理的章(approved 快进)。
|
|
164
|
+
let next: ChapterPlan | undefined
|
|
165
|
+
for (let no = this.state.currentNo; no <= this.state.endNo; no++) {
|
|
166
|
+
const ch = project.chapters.find(c => c.no === no)
|
|
167
|
+
if (ch === undefined) continue
|
|
168
|
+
if (ch.status === 'approved') { this.state.currentNo = no; continue }
|
|
169
|
+
next = ch
|
|
170
|
+
break
|
|
171
|
+
}
|
|
172
|
+
if (next === undefined) {
|
|
173
|
+
this.state.status = 'done'
|
|
174
|
+
this.log(`生产单完成:${this.state.startNo}-${this.state.endNo} 章处理完毕`)
|
|
175
|
+
this.persist()
|
|
176
|
+
break
|
|
177
|
+
}
|
|
178
|
+
this.state.currentNo = next.no
|
|
179
|
+
await this.processChapter(project, next)
|
|
180
|
+
this.persist()
|
|
181
|
+
}
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (this.state !== null) {
|
|
184
|
+
this.state.status = 'error'
|
|
185
|
+
this.state.error = (error as Error).message
|
|
186
|
+
this.log(`生产单异常:${(error as Error).message}`)
|
|
187
|
+
this.persist()
|
|
188
|
+
}
|
|
189
|
+
} finally {
|
|
190
|
+
this.working = false
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private async processChapter(project: ProjectState, chapter: ChapterPlan): Promise<void> {
|
|
195
|
+
if (chapter.status === 'pending' || chapter.status === 'error' || chapter.status === 'written') {
|
|
196
|
+
await this.produce(project, chapter)
|
|
197
|
+
} else if (chapter.status === 'rejected') {
|
|
198
|
+
await this.handleRejected(project, chapter)
|
|
199
|
+
} else if (chapter.status === 'generating') {
|
|
200
|
+
// 中断残留:复位重生成。
|
|
201
|
+
chapter.status = 'pending'
|
|
202
|
+
await this.produce(project, chapter)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** 完整质量门:生成 → 摘要+事实 → 伏笔标记 → 审稿 → 作者复盘。 */
|
|
207
|
+
private async produce(project: ProjectState, chapter: ChapterPlan): Promise<void> {
|
|
208
|
+
const { ctx, getConfig } = this.deps
|
|
209
|
+
const config = getConfig()
|
|
210
|
+
const outputDir = this.bookDir ?? config.outputDir
|
|
211
|
+
const no = chapter.no
|
|
212
|
+
const wasError = chapter.status === 'error'
|
|
213
|
+
chapter.status = 'generating'
|
|
214
|
+
chapter.error = undefined
|
|
215
|
+
chapter.generatingAt = new Date().toISOString()
|
|
216
|
+
mergeVolatileFromDisk(outputDir, project)
|
|
217
|
+
saveProject(outputDir, project)
|
|
218
|
+
this.log(wasError ? `第${no}章《${chapter.title}》重新生成…` : `第${no}章《${chapter.title}》生成…`)
|
|
219
|
+
try {
|
|
220
|
+
for await (const step of generateChapterStream(ctx, config, project, outputDir, no)) { /* drain */ }
|
|
221
|
+
try { await summarizeAndExtractFacts(ctx, config, project, outputDir, no) } catch (e) { console.warn('[dsh-novel-forge] run summary/facts:', (e as Error).message) }
|
|
222
|
+
try { markForeshadowPlanted(project, outputDir, no) } catch (e) { console.warn('[dsh-novel-forge] run foreshadow:', (e as Error).message) }
|
|
223
|
+
if (config.autoReview ?? true) {
|
|
224
|
+
const report = await reviewChapter(ctx, config, project, outputDir, no)
|
|
225
|
+
if (this.state !== null) this.state.stats[wasError ? 'regenerated' : 'generated']++
|
|
226
|
+
this.log(`第${no}章 审稿 ${report.score}分 ${report.passed ? '✅ 通过' : '⚠️ 被拒'}`)
|
|
227
|
+
} else {
|
|
228
|
+
chapter.status = 'approved'
|
|
229
|
+
mergeVolatileFromDisk(outputDir, project)
|
|
230
|
+
saveProject(outputDir, project)
|
|
231
|
+
}
|
|
232
|
+
if (config.autoAuthorReview ?? true) {
|
|
233
|
+
try {
|
|
234
|
+
const body = readChapterFile(outputDir, chapter)
|
|
235
|
+
if (body !== undefined) {
|
|
236
|
+
let prevTail = ''
|
|
237
|
+
if (no > 1) {
|
|
238
|
+
const prev = project.chapters.find(c => c.no === no - 1)
|
|
239
|
+
if (prev !== undefined) prevTail = (readChapterFile(outputDir, prev) ?? '').replace(/^#.*$/m, '').trim().slice(-600)
|
|
240
|
+
}
|
|
241
|
+
const review = await authorReviewChapter(ctx, config, project, no, body, prevTail)
|
|
242
|
+
chapter.authorReview = review
|
|
243
|
+
if (review.advancedLines !== undefined) autoLinkPlotlines(project, no, review.advancedLines)
|
|
244
|
+
mergeVolatileFromDisk(outputDir, project)
|
|
245
|
+
saveProject(outputDir, project)
|
|
246
|
+
}
|
|
247
|
+
} catch (e) { console.warn('[dsh-novel-forge] run author review:', (e as Error).message) }
|
|
248
|
+
}
|
|
249
|
+
} catch (error) {
|
|
250
|
+
chapter.status = 'error'
|
|
251
|
+
chapter.error = (error as Error).message
|
|
252
|
+
mergeVolatileFromDisk(outputDir, project)
|
|
253
|
+
saveProject(outputDir, project)
|
|
254
|
+
if (this.state !== null) this.state.stats.error++
|
|
255
|
+
this.log(`第${no}章 失败:${(error as Error).message}`)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** 被拒分级处理:无 high 豁免;有 high 按意见修订(最多 2 轮)+ 验证模式;仍不过 → 待人工。 */
|
|
260
|
+
private async handleRejected(project: ProjectState, chapter: ChapterPlan): Promise<void> {
|
|
261
|
+
const { ctx, getConfig } = this.deps
|
|
262
|
+
const config = getConfig()
|
|
263
|
+
const outputDir = this.bookDir ?? config.outputDir
|
|
264
|
+
const no = chapter.no
|
|
265
|
+
const report = chapter.review
|
|
266
|
+
const highs = (report?.issues ?? []).filter(i => i.severity === 'high')
|
|
267
|
+
if (highs.length === 0) {
|
|
268
|
+
chapter.status = 'approved'
|
|
269
|
+
project.updatedAt = new Date().toISOString()
|
|
270
|
+
saveProject(outputDir, project)
|
|
271
|
+
if (this.state !== null) this.state.stats.exempted++
|
|
272
|
+
this.log(`第${no}章 豁免通过(无 high)`)
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
this.log(`第${no}章 修订(${highs.length} 个 high)…`)
|
|
276
|
+
for (let round = 1; round <= 2; round++) {
|
|
277
|
+
const instr = '按审稿意见修订(优先处理):\n' + highs.map(h => `[${h.severity}] ${h.item}${h.suggestion !== '' ? `\n建议:${h.suggestion}` : ''}`).join('\n\n')
|
|
278
|
+
try {
|
|
279
|
+
for await (const step of rewriteChapterStream(ctx, config, project, outputDir, no, instr, undefined)) { /* drain */ }
|
|
280
|
+
} catch (error) {
|
|
281
|
+
this.log(`第${no}章 第${round}轮修订出错:${(error as Error).message}`)
|
|
282
|
+
continue
|
|
283
|
+
}
|
|
284
|
+
// rewrite 已写盘(pendingDraft + saveProject),重载拿最新草稿。
|
|
285
|
+
const fresh = loadProject(outputDir)
|
|
286
|
+
const freshCh = fresh?.chapters.find(c => c.no === no)
|
|
287
|
+
const draft = freshCh?.pendingDraft
|
|
288
|
+
if (draft === undefined || draft.length < 50) {
|
|
289
|
+
this.log(`第${no}章 第${round}轮草稿缺失,重试`)
|
|
290
|
+
continue
|
|
291
|
+
}
|
|
292
|
+
const verify = await reviewChapterText(ctx, config, fresh!, draft, chapter.review)
|
|
293
|
+
const highs2 = (verify.issues ?? []).filter(i => i.severity === 'high')
|
|
294
|
+
if (verify.passed || highs2.length === 0) {
|
|
295
|
+
// 应用草稿(与 draft/apply 路由同逻辑):备份原稿 → 落盘 → 定状态。
|
|
296
|
+
this.applyDraft(fresh!, freshCh!, draft, verify)
|
|
297
|
+
if (this.state !== null) this.state.stats.revised++
|
|
298
|
+
this.log(`第${no}章 第${round}轮修订通过(${verify.score}分)`)
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
this.log(`第${no}章 第${round}轮仍不过(${highs2.length} high)`)
|
|
302
|
+
}
|
|
303
|
+
if (this.state !== null) this.state.pendingManual.push(no)
|
|
304
|
+
this.log(`第${no}章 ⚠️ 两轮修订仍不过 → 保留草稿待人工`)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private applyDraft(project: ProjectState, chapter: ChapterPlan, draft: string, report: ReviewReport): void {
|
|
308
|
+
const config = this.deps.getConfig()
|
|
309
|
+
const fileName = chapterFileName(chapter)
|
|
310
|
+
mkdirSync(config.outputDir, { recursive: true })
|
|
311
|
+
const targetPath = join(config.outputDir, fileName)
|
|
312
|
+
if (existsSync(targetPath)) {
|
|
313
|
+
copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, '')}.bak.md`))
|
|
314
|
+
}
|
|
315
|
+
writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, 'utf8')
|
|
316
|
+
chapter.pendingDraft = undefined
|
|
317
|
+
chapter.chars = draft.length
|
|
318
|
+
chapter.file = fileName
|
|
319
|
+
if (typeof report.score === 'number') {
|
|
320
|
+
chapter.review = report
|
|
321
|
+
chapter.status = report.passed === true ? 'approved' : 'rejected'
|
|
322
|
+
} else {
|
|
323
|
+
chapter.status = 'written'
|
|
324
|
+
chapter.review = undefined
|
|
325
|
+
}
|
|
326
|
+
chapter.error = undefined
|
|
327
|
+
project.updatedAt = new Date().toISOString()
|
|
328
|
+
saveProject(config.outputDir, project)
|
|
329
|
+
}
|
|
330
|
+
}
|