@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/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)
|
|
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('
|
|
65
|
-
outputDir: z.string().default('
|
|
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 = '
|
|
79
|
-
const DEFAULT_OUTPUT_DIR = '
|
|
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
|
|
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
|
|
1214
|
-
auditedAt:
|
|
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
|
|