@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/src/engine.ts CHANGED
@@ -6,6 +6,23 @@
6
6
  * web-server dependencies), so routes stay thin and logic is testable.
7
7
  */
8
8
 
9
+ /**
10
+ * 内容合规红线(平台硬性要求):所有书籍、所有章节无条件生效,
11
+ * 优先级高于单书大纲/圣经中的任何设定与作者自定义红线。
12
+ * 注入点:章节生成系统提示 + 审稿系统提示(命中即 high)。
13
+ */
14
+ export const COMPLIANCE_REDLINES: ReadonlyArray<string> = [
15
+ '1. 不得出现反对宪法所确定的基本原则的内容。',
16
+ '2. 不得出现危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的内容。',
17
+ '3. 不得出现危害国家荣誉和利益的内容。',
18
+ '4. 不得出现煽动民族仇恨、民族歧视、破坏民族团结的内容。',
19
+ '5. 不得出现破坏国家宗教政策、宣扬邪教和愚昧迷信的内容(不得以真实宗教、邪教或迷信活动为背景进行宣扬)。',
20
+ '6. 不得出现散布谣言、扰乱社会秩序、破坏社会稳定的内容。',
21
+ '7. 不得出现淫秽色情、赌博、暴力、凶杀、恐怖或教唆犯罪的内容(网文语境:禁止露骨性描写、血腥暴力渲染、赌博教唆、犯罪手法详细教学)。',
22
+ '8. 不得出现侮辱或者诽谤他人、侵害他人合法权益的内容(不得以真实人物、组织为原型进行侮辱或影射攻击)。',
23
+ '9. 不得出现法律法规禁止的其他内容。',
24
+ ]
25
+
9
26
  import { mkdirSync, readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs'
10
27
  import { join } from 'node:path'
11
28
  import { createUserMessage, BlockAssembler, ReasoningEffortId, type GenerateOptions, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
@@ -262,6 +279,7 @@ async function complete(
262
279
  system: options.system,
263
280
  maxTokens: options.maxTokens ?? config.maxTokens,
264
281
  temperature: options.temperature ?? 0.7,
282
+ reasoningEffort: ReasoningEffortId(config.reasoningEffort ?? 'off'),
265
283
  }
266
284
  const assembler = new BlockAssembler()
267
285
  for await (const chunk of ctx.llm.stream(request)) {
@@ -599,6 +617,14 @@ function writeSystemPrompt(project: ProjectState): string {
599
617
  sections.push('3. 尊重大纲与设定圣经:人设不崩、金手指规则不自相矛盾、战力不随意膨胀。')
600
618
  sections.push('4. 章末留一个钩子(悬念、反转或新线索),吸引读者读下一章。')
601
619
  sections.push('5. 语言流畅自然,符合中文网文语感,避免翻译腔与病句。')
620
+ sections.push('6. 对话与冲突密度:每章至少 1 处实质对话或正面对抗/交锋场面;推理与心理活动必须用动作、环境细节、微表情、对话呈现,禁止整章纯内心独白铺陈(禁止"解说式"交代线索)。')
621
+ sections.push('7. 反派与对手的行动力:本章出现的反派/对手必须有其行动、反制或压迫感(布局、试探、追索、交锋至少占其一),不得作为纯背景板存在。')
622
+ sections.push('8. 配角辨识度:重要新登场配角应给姓名或可辨识的独有特征;禁止通篇用"瘦高个/灰衣人/戴面具者"等身形标签代称同一角色。')
623
+ sections.push('9. 信息呈现方式:关键线索、设定、局势通过对话、动作、发现物呈现,禁止主角内心"讲解"给读者听。')
624
+ sections.push('')
625
+ sections.push('==================== 内容合规红线(平台硬性要求,最高优先级,违反即失败) ====================')
626
+ sections.push(COMPLIANCE_REDLINES.join('\n'))
627
+ sections.push('以上九条为硬性底线,任何情况下不得以任何形式出现或影射;若剧情确需涉及(如批判、反讽),只能以明确否定、揭露、批判的立场呈现,且不得展开细节。')
602
628
  return sections.join('\n')
603
629
  }
604
630
 
@@ -718,6 +744,8 @@ function reviewSystemPrompt(project: ProjectState): string {
718
744
  '5. 节奏与爽点:本章是否有推进、有钩子,是否拖沓灌水。',
719
745
  '6. 逻辑漏洞:前后矛盾、时间线错误、对话失真。',
720
746
  '7. 反 AI 规则:逐条核对下方「反 AI 规则」清单,命中即列为问题。',
747
+ '8. 呈现方式:整章是否纯内心推理铺陈(无对话/无对抗,推理全靠解说);反派是否纯背景板无行动;重要配角是否无名标签化(瘦高个/灰衣人全程代称)——命中即列为问题。',
748
+ '9. 内容合规(最高优先级):逐条核对下方「内容合规红线」,任何一条命中(含影射、暗示、详细描写)必须列为 high,并给出改写建议。',
721
749
  '输出必须是合法 JSON 对象,不要输出任何其他文字:',
722
750
  '{"score": 0-100的整数, "verdict": "一句话总评", "issues": [{"severity": "high|medium|low", "item": "问题描述", "suggestion": "修改建议"}]}',
723
751
  '重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
@@ -739,6 +767,9 @@ function reviewSystemPrompt(project: ProjectState): string {
739
767
  }
740
768
  if (bible.redLines.length > 0) sections.push('红线:\n' + bible.redLines.map(r => `- ${r}`).join('\n'))
741
769
  }
770
+ sections.push('\n==================== 内容合规红线(平台硬性要求,最高优先级) ====================')
771
+ sections.push(COMPLIANCE_REDLINES.join('\n'))
772
+ sections.push('以上九条为硬性底线:正文中任何一条命中(含影射、暗示、详细展开)都必须列为 high,并给出改写建议;作者自定义红线不得豁免这九条。')
742
773
  return sections.join('\n')
743
774
  }
744
775
 
@@ -1217,6 +1248,9 @@ export async function suggestOutlines(
1217
1248
  ].join('\n')
1218
1249
  const user = [
1219
1250
  `作者的想法:${idea}`,
1251
+ idea.trim().length < 40
1252
+ ? '作者的想法非常简短(可能只有一句)。请基于通用网文套路合理扩展补全:为每个方案自洽地设计金手指/核心设定、主角人设与动机、主线走向,使其成为完整可开书的大纲;不同方案的方向仍须明显差异。'
1253
+ : '',
1220
1254
  exclude.length > 0
1221
1255
  ? `需避开的已暂留方案方向(新方案不得与之雷同):\n${exclude.map((e, i) => `${i + 1}. ${e}`).join('\n')}`
1222
1256
  : '',
@@ -2266,16 +2300,23 @@ export async function auditBook(
2266
2300
  config: NovelConfig,
2267
2301
  project: ProjectState,
2268
2302
  outputDir: string,
2303
+ onProgress?: (completedBatches: number, totalBatches: number) => void,
2269
2304
  ): Promise<AuditIssue[]> {
2270
2305
  const written = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating')
2271
- if (written.length === 0) return []
2306
+ if (written.length === 0) {
2307
+ onProgress?.(0, 0)
2308
+ return []
2309
+ }
2272
2310
  // 分批:每批 AUDIT_BATCH_SIZE 章,避免超长后单次爆上下文。
2311
+ const totalBatches = Math.ceil(written.length / AUDIT_BATCH_SIZE)
2273
2312
  const all: AuditIssue[] = []
2313
+ onProgress?.(0, totalBatches)
2274
2314
  for (let i = 0; i < written.length; i += AUDIT_BATCH_SIZE) {
2275
2315
  const batch = written.slice(i, i + AUDIT_BATCH_SIZE)
2276
2316
  try {
2277
2317
  all.push(...await auditBatch(ctx, config, project, outputDir, batch))
2278
2318
  } catch { /* 单批失败不阻断其余批次 */ }
2319
+ onProgress?.(Math.min(Math.ceil((i + AUDIT_BATCH_SIZE) / AUDIT_BATCH_SIZE), totalBatches), totalBatches)
2279
2320
  }
2280
2321
  return all.slice(0, 50)
2281
2322
  }
package/src/index.ts CHANGED
@@ -44,6 +44,8 @@ export interface Config {
44
44
  provider?: string
45
45
  /** LLM model id. */
46
46
  model?: string
47
+ /** LLM reasoning effort (off/low/high/max). */
48
+ reasoningEffort?: 'off' | 'low' | 'high' | 'max'
47
49
  /** Target characters per chapter. */
48
50
  chapterChars?: number
49
51
  /** Max output tokens per chapter call. */
@@ -61,10 +63,11 @@ export interface Config {
61
63
  export const Config: z<Config> = z.object({
62
64
  announceToAgent: z.boolean().default(true),
63
65
  enabled: z.boolean().default(true),
64
- outlinePath: z.string().default('C:\\Users\\Ryan\\Desktop\\《归墟玉主》全书大纲_重新排版版.docx'),
65
- outputDir: z.string().default('C:\\Users\\Ryan\\Desktop\\归墟玉主'),
66
+ outlinePath: z.string().default('D:\\ryan work\\harness\\novels\\归墟玉主\\《归墟玉主》全书大纲_重新排版版.docx'),
67
+ outputDir: z.string().default('D:\\ryan work\\harness\\novels\\归墟玉主'),
66
68
  provider: z.string().default('deepseek-official'),
67
69
  model: z.string().default('deepseek-v4-flash'),
70
+ reasoningEffort: z.union(['off', 'low', 'high', 'max']).default('off'),
68
71
  chapterChars: z.number().default(3500),
69
72
  maxTokens: z.number().default(12000),
70
73
  reviewPassScore: z.number().default(70),
@@ -75,10 +78,11 @@ export const Config: z<Config> = z.object({
75
78
 
76
79
  /** Schema defaults, re-read for hand-built test contexts. */
77
80
  const DEFAULT_ANNOUNCE = true
78
- const DEFAULT_OUTLINE_PATH = 'C:\\Users\\Ryan\\Desktop\\《归墟玉主》全书大纲_重新排版版.docx'
79
- const DEFAULT_OUTPUT_DIR = 'C:\\Users\\Ryan\\Desktop\\归墟玉主'
81
+ const DEFAULT_OUTLINE_PATH = 'D:\\ryan work\\harness\\novels\\归墟玉主\\《归墟玉主》全书大纲_重新排版版.docx'
82
+ const DEFAULT_OUTPUT_DIR = 'D:\\ryan work\\harness\\novels\\归墟玉主'
80
83
  const DEFAULT_PROVIDER = 'deepseek-official'
81
84
  const DEFAULT_MODEL = 'deepseek-v4-flash'
85
+ const DEFAULT_REASONING_EFFORT = 'off' as const
82
86
  const DEFAULT_CHAPTER_CHARS = 3500
83
87
  const DEFAULT_MAX_TOKENS = 12000
84
88
  const DEFAULT_REVIEW_PASS_SCORE = 70
@@ -90,7 +94,7 @@ const DEFAULT_AUTO_REVIEW_AFTER_REVISE = true
90
94
  const SECTION_ORDER = 160
91
95
 
92
96
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
93
- export const NOVEL_GUIDANCE = '本机已安装 dsh-novel-forge 插件(AI 编译小说工作台):侧边栏「小说工坊」入口。能力:读取 docx 大纲(默认桌面《归墟玉主》大纲)或粘贴大纲文本;用 LLM 提炼道藏(人设/世界观/金手指规则/写作红线,即设定圣经);生成卷计划与章节计划;逐章调用 LLM 生成 3000-4000 字正文并保存为 Markdown(默认输出到 桌面\\归墟玉主);每章自动生成摘要(叙事记忆)、自动 AI 审稿(人设/设定/红线/文笔/爽点/逻辑),支持按审稿意见重写、去 AI 味润色、暗线(伏笔)管理、批量连写与全本导出(txt/md)。限制:生成消耗 LLM API 额度;输出目录与模型可在插件设置中修改;章节正文质量取决于大纲完整度。用户提到「小说 / 大纲 / 写小说 / 章节 / 审稿 / 润色 / 归墟玉主」时即指本插件,请据此协作。'
97
+ export const NOVEL_GUIDANCE = '本机已安装 dsh-novel-forge 插件(AI 编译小说工作台):侧边栏「小说工坊」入口。能力:读取 docx 大纲(默认 D:\\ryan work\\harness\\novels\\归墟玉主 大纲)或粘贴大纲文本;用 LLM 提炼道藏(人设/世界观/金手指规则/写作红线,即设定圣经);生成卷计划与章节计划;逐章调用 LLM 生成 3000-4000 字正文并保存为 Markdown(默认输出到 D:\\ryan work\\harness\\novels\\归墟玉主);每章自动生成摘要(叙事记忆)、自动 AI 审稿(人设/设定/红线/文笔/爽点/逻辑),支持按审稿意见重写、去 AI 味润色、暗线(伏笔)管理、批量连写与全本导出(txt/md)。限制:生成消耗 LLM API 额度;输出目录与模型可在插件设置中修改;章节正文质量取决于大纲完整度。用户提到「小说 / 大纲 / 写小说 / 章节 / 审稿 / 润色 / 归墟玉主」时即指本插件,请据此协作。'
94
98
 
95
99
  /** Resolve a config-like value into the full runtime config. */
96
100
  export function resolveConfig(value: Partial<Config> | undefined): NovelConfig {
@@ -99,6 +103,7 @@ export function resolveConfig(value: Partial<Config> | undefined): NovelConfig {
99
103
  outputDir: value?.outputDir ?? DEFAULT_OUTPUT_DIR,
100
104
  provider: value?.provider ?? DEFAULT_PROVIDER,
101
105
  model: value?.model ?? DEFAULT_MODEL,
106
+ reasoningEffort: value?.reasoningEffort ?? DEFAULT_REASONING_EFFORT,
102
107
  chapterChars: value?.chapterChars ?? DEFAULT_CHAPTER_CHARS,
103
108
  maxTokens: value?.maxTokens ?? DEFAULT_MAX_TOKENS,
104
109
  reviewPassScore: value?.reviewPassScore ?? DEFAULT_REVIEW_PASS_SCORE,
@@ -133,6 +138,7 @@ export function apply(ctx: Context, config?: Config): void {
133
138
  if (patch.outputDir !== undefined) next.outputDir = patch.outputDir
134
139
  if (patch.provider !== undefined) next.provider = patch.provider
135
140
  if (patch.model !== undefined) next.model = patch.model
141
+ if (patch.reasoningEffort !== undefined) next.reasoningEffort = patch.reasoningEffort
136
142
  if (patch.chapterChars !== undefined) next.chapterChars = patch.chapterChars
137
143
  if (patch.maxTokens !== undefined) next.maxTokens = patch.maxTokens
138
144
  if (patch.reviewPassScore !== undefined) next.reviewPassScore = patch.reviewPassScore
package/src/protocol.ts CHANGED
@@ -74,6 +74,12 @@ export const NOVEL_API = {
74
74
  storyboard: '/api/dsh-novel-forge/storyboard',
75
75
  /** 漫剧分集计划:读一卷 → 按故事弧线分集(高潮拆集/过渡并章)。 */
76
76
  storyboardPlan: '/api/dsh-novel-forge/storyboard/plan',
77
+ /** 生产单:启动批量生产(计划补足 + 逐章生成 + 被拒分级处理)。 */
78
+ runStart: '/api/dsh-novel-forge/run/start',
79
+ /** 生产单控制:pause / resume / stop。 */
80
+ runControl: '/api/dsh-novel-forge/run/control',
81
+ /** 生产单状态(含进度统计与日志)。 */
82
+ runStatus: '/api/dsh-novel-forge/run/status',
77
83
  config: '/api/dsh-novel-forge/config',
78
84
  openFolder: '/api/dsh-novel-forge/open-folder',
79
85
  } as const
@@ -300,6 +306,27 @@ export interface AuditResponse {
300
306
  auditedAt: string
301
307
  }
302
308
 
309
+ /** 全书质检的实时状态(通过 /status 暴露给面板/外部读取)。 */
310
+ export interface AuditStatus {
311
+ status: 'idle' | 'running' | 'done' | 'error'
312
+ /** 开始时间(ISO)。 */
313
+ startedAt?: string
314
+ /** 结束时间(ISO)。 */
315
+ finishedAt?: string
316
+ /** 总批次数(0 = 尚未开始/无章节)。 */
317
+ totalBatches: number
318
+ /** 已完成批次数。 */
319
+ completedBatches: number
320
+ /** 参与质检的章节数。 */
321
+ auditedChapters?: number
322
+ /** 发现的问题数(done 后有效)。 */
323
+ issuesCount?: number
324
+ /** 最近一次质检的问题清单(done 后有效;error 时为空)。 */
325
+ issues?: AuditIssue[]
326
+ /** 失败信息(error 时有效)。 */
327
+ error?: string
328
+ }
329
+
303
330
  /** 一条剧情线(主线/支线/人物线/悬念线)。 */
304
331
  export interface Plotline {
305
332
  /** 稳定 id。 */
@@ -766,6 +793,8 @@ export interface NovelConfig {
766
793
  provider: string
767
794
  /** LLM model id (e.g. deepseek-v4-flash). */
768
795
  model: string
796
+ /** LLM reasoning effort: off = no thinking; low/high/max = thinking intensity. */
797
+ reasoningEffort: 'off' | 'low' | 'high' | 'max'
769
798
  /** Target characters per chapter. */
770
799
  chapterChars: number
771
800
  /** Max output tokens per chapter call. */
@@ -787,6 +816,54 @@ export interface StatusResponse {
787
816
  project?: ProjectState
788
817
  /** Chapter files already on disk (basenames, sorted). */
789
818
  generatedFiles: string[]
819
+ /** 全书质检实时状态(用于面板/外部读取进度)。 */
820
+ audit?: AuditStatus
821
+ }
822
+
823
+ /** 生产单状态(生产单 = 区间批量生产执行器:计划补足 + 逐章生成 + 被拒分级处理)。 */
824
+ export interface RunState {
825
+ runId: string
826
+ /** 区间起点(含)。 */
827
+ startNo: number
828
+ /** 区间终点(含)。 */
829
+ endNo: number
830
+ status: 'running' | 'paused' | 'done' | 'stopped' | 'error'
831
+ /** 当前处理到的章号(已 approved 的章会快进)。 */
832
+ currentNo: number
833
+ stats: {
834
+ /** 新生成(含审稿)的章数。 */
835
+ generated: number
836
+ /** 被拒后按意见修订并通过的章数。 */
837
+ revised: number
838
+ /** 被拒后豁免通过(无 high)的章数。 */
839
+ exempted: number
840
+ /** error 后重新生成的章数。 */
841
+ regenerated: number
842
+ /** 生成失败的章数。 */
843
+ error: number
844
+ }
845
+ /** 两轮修订仍不过、保留草稿待人工的章号。 */
846
+ pendingManual: number[]
847
+ /** 运行日志(保留最近 300 条)。 */
848
+ log: Array<{ at: string; text: string }>
849
+ startedAt: string
850
+ updatedAt: string
851
+ error?: string
852
+ }
853
+
854
+ /** POST /run/start request. */
855
+ export interface RunStartRequest {
856
+ /** 起始章号(默认 1)。 */
857
+ startNo?: number
858
+ /** 结束章号(含;超出计划时自动补计划)。 */
859
+ endNo?: number
860
+ /** 或指定新增章数(从当前最后一章 +1 起)。 */
861
+ count?: number
862
+ }
863
+
864
+ /** POST /run/control request. */
865
+ export interface RunControlRequest {
866
+ action: 'pause' | 'resume' | 'stop'
790
867
  }
791
868
 
792
869
  /** POST /load-outline request: either a docx path or raw text. */
@@ -933,6 +1010,7 @@ export interface ConfigPatch {
933
1010
  outputDir?: string
934
1011
  provider?: string
935
1012
  model?: string
1013
+ reasoningEffort?: 'off' | 'low' | 'high' | 'max'
936
1014
  chapterChars?: number
937
1015
  maxTokens?: number
938
1016
  reviewPassScore?: number
package/src/routes.ts CHANGED
@@ -14,6 +14,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSyn
14
14
  import { join } from 'node:path'
15
15
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
16
16
  import type { Context } from '@deepseek-ai/cordis'
17
+ import { ProductionRunner } from './run.ts'
17
18
  import {
18
19
  NOVEL_API,
19
20
  type AssetsPatch,
@@ -22,6 +23,7 @@ import {
22
23
  type AssistantHistoryResponse,
23
24
  type AssistantRequest,
24
25
  type AuditResponse,
26
+ type AuditStatus,
25
27
  type AuthorReview,
26
28
  type BiblePatchRequest,
27
29
  type BibleRequest,
@@ -57,6 +59,9 @@ import {
57
59
  type ResetRequest,
58
60
  type ReviewReport,
59
61
  type ReviewRequest,
62
+ type RunControlRequest,
63
+ type RunStartRequest,
64
+ type RunState,
60
65
  type RewriteRequest,
61
66
  type RolesRequest,
62
67
  type RolesResponse,
@@ -195,6 +200,9 @@ const DEFAULT_PLAN_COUNT = 30
195
200
  export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
196
201
  const { ctx, getConfig, patchConfig } = deps
197
202
 
203
+ /** 全书质检实时状态(内存态,重启后回到 idle;用于 /status 暴露进度)。 */
204
+ let auditState: AuditStatus = { status: 'idle', totalBatches: 0, completedBatches: 0 }
205
+
198
206
  /** Guard helper: fence + method check. */
199
207
  const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
200
208
  if (!isLoopbackRequest(req)) {
@@ -256,6 +264,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
256
264
  ? { ...project, facts: (project.facts ?? []).slice(-80) }
257
265
  : undefined,
258
266
  generatedFiles: listChapterFiles(config.outputDir),
267
+ audit: auditState,
259
268
  }
260
269
  writeJson(res, 200, response)
261
270
  },
@@ -1206,15 +1215,37 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1206
1215
  const config = getConfig()
1207
1216
  const project = requireProject(res)
1208
1217
  if (project === undefined) return
1218
+ const startedAt = new Date().toISOString()
1219
+ auditState = { status: 'running', startedAt, totalBatches: 0, completedBatches: 0 }
1209
1220
  try {
1210
- const issues = await auditBook(ctx, config, project, config.outputDir)
1221
+ const issues = await auditBook(ctx, config, project, config.outputDir, (completed, total) => {
1222
+ auditState.totalBatches = total
1223
+ auditState.completedBatches = completed
1224
+ })
1225
+ const auditedChapters = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating').length
1226
+ auditState = {
1227
+ status: 'done',
1228
+ startedAt,
1229
+ finishedAt: new Date().toISOString(),
1230
+ totalBatches: auditState.totalBatches,
1231
+ completedBatches: auditState.totalBatches,
1232
+ auditedChapters,
1233
+ issuesCount: issues.length,
1234
+ issues,
1235
+ }
1211
1236
  const response: AuditResponse = {
1212
1237
  issues,
1213
- auditedChapters: project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating').length,
1214
- auditedAt: new Date().toISOString(),
1238
+ auditedChapters,
1239
+ auditedAt: auditState.finishedAt!,
1215
1240
  }
1216
1241
  writeJson(res, 200, response)
1217
1242
  } catch (error) {
1243
+ auditState = {
1244
+ ...auditState,
1245
+ status: 'error',
1246
+ finishedAt: new Date().toISOString(),
1247
+ error: (error as Error).message,
1248
+ }
1218
1249
  writeJson(res, 500, { error: (error as Error).message })
1219
1250
  }
1220
1251
  },
@@ -1794,7 +1825,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1794
1825
  }
1795
1826
 
1796
1827
  // 全书:NDJSON 流式补跑缺失复盘的已写章节。
1797
- const missing = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.authorReview === undefined)
1828
+ const missing = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error' && c.authorReview === undefined)
1798
1829
  if (missing.length === 0) {
1799
1830
  writeJson(res, 200, { count: 0 })
1800
1831
  return
@@ -1985,6 +2016,73 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1985
2016
  },
1986
2017
  }
1987
2018
 
2019
+ // ----------------------------------------------------------- production run
2020
+ /** 生产单执行器(单例):计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。 */
2021
+ const runner = new ProductionRunner({ ctx, getConfig })
2022
+
2023
+ const runStartRoute: WebRoute = {
2024
+ kind: 'exact',
2025
+ path: NOVEL_API.runStart,
2026
+ handler: async (req, res) => {
2027
+ if (!guard(req, res, 'POST')) return
2028
+ const body = await readJsonBody<RunStartRequest>(req)
2029
+ const config = getConfig()
2030
+ const project = loadProject(config.outputDir)
2031
+ if (project === undefined) {
2032
+ writeJson(res, 400, { error: '输出目录中没有项目' })
2033
+ return
2034
+ }
2035
+ const startNo = Number.isInteger(body?.startNo) && (body!.startNo! >= 1) ? body!.startNo! : 1
2036
+ let endNo: number
2037
+ if (Number.isInteger(body?.endNo) && (body!.endNo! >= startNo)) {
2038
+ endNo = body!.endNo!
2039
+ } else if (Number.isInteger(body?.count) && (body!.count! >= 1) && (body!.count! <= 200)) {
2040
+ const last = Math.max(0, ...project.chapters.map(c => c.no))
2041
+ endNo = last + body!.count!
2042
+ } else {
2043
+ writeJson(res, 400, { error: '请提供 endNo 或 count(1-200)' })
2044
+ return
2045
+ }
2046
+ try {
2047
+ const state = await runner.start(startNo, endNo)
2048
+ writeJson(res, 200, state satisfies RunState)
2049
+ } catch (error) {
2050
+ writeJson(res, 409, { error: (error as Error).message })
2051
+ }
2052
+ },
2053
+ }
2054
+
2055
+ const runControlRoute: WebRoute = {
2056
+ kind: 'exact',
2057
+ path: NOVEL_API.runControl,
2058
+ handler: async (req, res) => {
2059
+ if (!guard(req, res, 'POST')) return
2060
+ const body = await readJsonBody<RunControlRequest>(req)
2061
+ const state = runner.status()
2062
+ if (state === null) {
2063
+ writeJson(res, 400, { error: '没有生产单' })
2064
+ return
2065
+ }
2066
+ if (body?.action === 'pause') runner.pause()
2067
+ else if (body?.action === 'resume') runner.resume()
2068
+ else if (body?.action === 'stop') runner.stop()
2069
+ else {
2070
+ writeJson(res, 400, { error: 'action 须为 pause / resume / stop' })
2071
+ return
2072
+ }
2073
+ writeJson(res, 200, runner.status())
2074
+ },
2075
+ }
2076
+
2077
+ const runStatusRoute: WebRoute = {
2078
+ kind: 'exact',
2079
+ path: NOVEL_API.runStatus,
2080
+ handler: (req, res) => {
2081
+ if (!guard(req, res, 'GET')) return
2082
+ writeJson(res, 200, runner.status())
2083
+ },
2084
+ }
2085
+
1988
2086
  return [
1989
2087
  statusRoute,
1990
2088
  loadOutlineRoute,
@@ -2033,6 +2131,9 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2033
2131
  breakdownRoute,
2034
2132
  storyboardRoute,
2035
2133
  storyboardPlanRoute,
2134
+ runStartRoute,
2135
+ runControlRoute,
2136
+ runStatusRoute,
2036
2137
  ]
2037
2138
  }
2038
2139