@waterwx/dsh-novel-forge 1.1.2 → 1.1.4
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 +10 -0
- package/lib/client.js +4413 -2428
- package/lib/client.js.map +1 -1
- package/lib/index.js +926 -37
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +11 -0
- package/lib/types/client/locales.d.ts +46 -2
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/engine.d.ts +19 -1
- package/lib/types/index.d.ts +2 -0
- package/lib/types/protocol.d.ts +131 -0
- package/package.json +1 -1
- package/src/assistant.ts +59 -36
- package/src/client/api.ts +20 -0
- package/src/client/locales.ts +92 -4
- package/src/client/panel/AssetsTab.tsx +7 -7
- package/src/client/panel/NovelPanel.tsx +1139 -152
- package/src/client/panel/panel.module.css +398 -57
- package/src/engine.ts +378 -0
- package/src/index.ts +6 -0
- package/src/protocol.ts +135 -0
- package/src/routes.ts +295 -0
package/src/protocol.ts
CHANGED
|
@@ -54,6 +54,12 @@ export const NOVEL_API = {
|
|
|
54
54
|
world: '/api/dsh-novel-forge/world',
|
|
55
55
|
/** 封面:GET 读取(dataUrl)/ POST 上传或移除。 */
|
|
56
56
|
cover: '/api/dsh-novel-forge/blurb/cover',
|
|
57
|
+
/** 剧情线管理:增删改 + 关联章节。 */
|
|
58
|
+
plotlines: '/api/dsh-novel-forge/plotlines',
|
|
59
|
+
/** 作者复盘补跑:对已写章节补齐 authorReview(全书流式 / 单章 JSON)。 */
|
|
60
|
+
reviewBackfill: '/api/dsh-novel-forge/review/backfill',
|
|
61
|
+
/** 敏感词检查:全书已写章节或指定文本。 */
|
|
62
|
+
sensitiveCheck: '/api/dsh-novel-forge/sensitive-check',
|
|
57
63
|
config: '/api/dsh-novel-forge/config',
|
|
58
64
|
openFolder: '/api/dsh-novel-forge/open-folder',
|
|
59
65
|
} as const
|
|
@@ -137,6 +143,8 @@ export interface ChapterPlan {
|
|
|
137
143
|
summary?: string
|
|
138
144
|
/** Latest review report (present once reviewed). */
|
|
139
145
|
review?: ReviewReport
|
|
146
|
+
/** 作者复盘:钩子兑现/结尾钩子/剧情线推进/连续性/节奏趋势(生成后自动)。 */
|
|
147
|
+
authorReview?: AuthorReview
|
|
140
148
|
/**
|
|
141
149
|
* 待确认草稿:润色(去AI味)或整章重写的产物正文。生成时先存这里,
|
|
142
150
|
* 用户看过对比后点「采纳」才覆盖正文文件;点「放弃」则丢弃。刷新页面不丢失。
|
|
@@ -144,6 +152,26 @@ export interface ChapterPlan {
|
|
|
144
152
|
pendingDraft?: string
|
|
145
153
|
}
|
|
146
154
|
|
|
155
|
+
/** 作者复盘:叙事结构层面的逐章检查(钩子/推进/连续性/趋势)。 */
|
|
156
|
+
export interface AuthorReview {
|
|
157
|
+
/** 上一章结尾钩子是否在本章兑现。 */
|
|
158
|
+
hookHonored: boolean
|
|
159
|
+
/** 钩子兑现说明(未兑现时给出建议)。 */
|
|
160
|
+
hookNote: string
|
|
161
|
+
/** 本章结尾钩子强度 0-10。 */
|
|
162
|
+
endingHook: number
|
|
163
|
+
/** 剧情线推进情况(推进了哪条线/或未推进)。 */
|
|
164
|
+
plotlineProgress: string
|
|
165
|
+
/** 结构化:本章推进的剧情线名称列表(与项目剧情线 name 精确匹配,复盘后自动关联章节)。 */
|
|
166
|
+
advancedLines?: string[]
|
|
167
|
+
/** 连续性检查(人物位置/时间/伤势/资源是否与上章衔接)。 */
|
|
168
|
+
continuity: string
|
|
169
|
+
/** 近期节奏趋势提示(拖沓/爽点密度等)。 */
|
|
170
|
+
trend: string
|
|
171
|
+
/** 复盘时间。 */
|
|
172
|
+
reviewedAt: string
|
|
173
|
+
}
|
|
174
|
+
|
|
147
175
|
/** One review finding. */
|
|
148
176
|
export interface ReviewIssue {
|
|
149
177
|
/** Severity: high = must fix, medium = should fix, low = suggestion. */
|
|
@@ -192,6 +220,8 @@ export interface CharacterCard {
|
|
|
192
220
|
goals: string
|
|
193
221
|
/** Key relations to other characters. */
|
|
194
222
|
relations: string
|
|
223
|
+
/** 知情度:该角色已经知道的事实/秘密(未列出的信息该角色不知道)。 */
|
|
224
|
+
knowledge?: string[]
|
|
195
225
|
}
|
|
196
226
|
|
|
197
227
|
/** The structured story bible (worldbuilding extracted from the outline). */
|
|
@@ -254,6 +284,102 @@ export interface AuditResponse {
|
|
|
254
284
|
auditedAt: string
|
|
255
285
|
}
|
|
256
286
|
|
|
287
|
+
/** 一条剧情线(主线/支线/人物线/悬念线)。 */
|
|
288
|
+
export interface Plotline {
|
|
289
|
+
/** 稳定 id。 */
|
|
290
|
+
id: string
|
|
291
|
+
/** 线名。 */
|
|
292
|
+
name: string
|
|
293
|
+
/** 类型:主线 / 支线 / 人物线 / 悬念线。 */
|
|
294
|
+
kind: 'main' | 'branch' | 'character' | 'mystery'
|
|
295
|
+
/** 目标/终点(这条线最终要完成什么)。 */
|
|
296
|
+
goal: string
|
|
297
|
+
/** 当前进度说明(最近推进到哪)。 */
|
|
298
|
+
progress: string
|
|
299
|
+
/** 生命周期状态。 */
|
|
300
|
+
status: 'active' | 'paused' | 'resolved' | 'abandoned'
|
|
301
|
+
/** 关联章节号(推进/落地的章节)。 */
|
|
302
|
+
chapters: number[]
|
|
303
|
+
/** 创建时间。 */
|
|
304
|
+
createdAt: string
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** POST /plotlines 请求:剧情线增删改 + 关联章节 + AI 辅助。 */
|
|
308
|
+
export interface PlotlinesRequest {
|
|
309
|
+
op: 'add' | 'update' | 'remove' | 'link' | 'suggest' | 'refresh' | 'health' | 'plan'
|
|
310
|
+
/** add / update 时传入的完整剧情线。 */
|
|
311
|
+
line?: Plotline
|
|
312
|
+
/** remove / link / refresh 时的目标线 id。 */
|
|
313
|
+
id?: string
|
|
314
|
+
/** link 时关联的章节号。 */
|
|
315
|
+
chapterNo?: number
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** 剧情线健康检查报告。 */
|
|
319
|
+
export interface PlotlineHealthReport {
|
|
320
|
+
/** 是否需要新线(需要 / 暂不需要 / 再写 X 章后需要)。 */
|
|
321
|
+
verdict: string
|
|
322
|
+
/** 建议添加新线的时机说明。 */
|
|
323
|
+
timing: string
|
|
324
|
+
/** 依据(基于数据的理由,每条一句)。 */
|
|
325
|
+
reasons: string[]
|
|
326
|
+
/** 各线健康度。 */
|
|
327
|
+
lines: Array<{
|
|
328
|
+
name: string
|
|
329
|
+
/** ok=健康 / warning=预警 / stale=搁置过久。 */
|
|
330
|
+
health: 'ok' | 'warning' | 'stale'
|
|
331
|
+
note: string
|
|
332
|
+
}>
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** AI 剧情方案:下一阶段目标 + 建议新线。 */
|
|
336
|
+
export interface PlotlinePlan {
|
|
337
|
+
/** 下一阶段(未来 5-10 章)剧情方向。 */
|
|
338
|
+
direction: string
|
|
339
|
+
/** 建议的新线(可逐条采纳)。 */
|
|
340
|
+
suggestions: Plotline[]
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** POST /plotlines 响应。 */
|
|
344
|
+
export interface PlotlinesResponse {
|
|
345
|
+
plotlines: Plotline[]
|
|
346
|
+
/** op=suggest 时的 AI 建议候选线。 */
|
|
347
|
+
suggestions?: Plotline[]
|
|
348
|
+
/** op=health 时的健康检查报告。 */
|
|
349
|
+
health?: PlotlineHealthReport
|
|
350
|
+
/** op=plan 时的剧情方案。 */
|
|
351
|
+
plan?: PlotlinePlan
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** 一条敏感词命中。 */
|
|
355
|
+
export interface SensitiveHit {
|
|
356
|
+
/** 命中章节号(文本检测时为 0)。 */
|
|
357
|
+
chapterNo: number
|
|
358
|
+
/** 命中的违禁词。 */
|
|
359
|
+
word: string
|
|
360
|
+
/** 类别:政治 / 擦边 / 暴力 / 辱骂 / 广告 / 其他。 */
|
|
361
|
+
category: string
|
|
362
|
+
/** 出现次数。 */
|
|
363
|
+
count: number
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** POST /sensitive-check 请求:检测指定章节/任意文本/全书。 */
|
|
367
|
+
export interface SensitiveCheckRequest {
|
|
368
|
+
/** 检测该章正文。 */
|
|
369
|
+
chapterNo?: number
|
|
370
|
+
/** 检测任意文本(优先于 chapterNo)。 */
|
|
371
|
+
text?: string
|
|
372
|
+
/** 扫描全部已写章节。 */
|
|
373
|
+
all?: boolean
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** POST /sensitive-check 响应。 */
|
|
377
|
+
export interface SensitiveCheckResponse {
|
|
378
|
+
hits: SensitiveHit[]
|
|
379
|
+
/** 参与扫描的章节数。 */
|
|
380
|
+
scannedChapters: number
|
|
381
|
+
}
|
|
382
|
+
|
|
257
383
|
/** 角色卡:角色当前状态(从事实库聚合)。 */
|
|
258
384
|
export interface RoleStatusCard {
|
|
259
385
|
name: string
|
|
@@ -272,6 +398,8 @@ export interface BiblePatchRequest {
|
|
|
272
398
|
worldRules?: string[]
|
|
273
399
|
redLines?: string[]
|
|
274
400
|
style?: string[]
|
|
401
|
+
/** 角色卡整体替换(人物志编辑知情度等)。 */
|
|
402
|
+
characters?: CharacterCard[]
|
|
275
403
|
}
|
|
276
404
|
|
|
277
405
|
/** POST /blurb 请求:AI 生成/补全或手动保存小说简介。 */
|
|
@@ -384,6 +512,8 @@ export interface ProjectState {
|
|
|
384
512
|
coverPath?: string
|
|
385
513
|
/** 大世界结构化数据(境界体系/区域/势力)。 */
|
|
386
514
|
world?: WorldState
|
|
515
|
+
/** 剧情线(主线/支线/人物线/悬念线)。 */
|
|
516
|
+
plotlines?: Plotline[]
|
|
387
517
|
/** ISO timestamps. */
|
|
388
518
|
createdAt: string
|
|
389
519
|
updatedAt: string
|
|
@@ -407,6 +537,8 @@ export interface NovelConfig {
|
|
|
407
537
|
reviewPassScore: number
|
|
408
538
|
/** Whether generation auto-runs review after writing. */
|
|
409
539
|
autoReview: boolean
|
|
540
|
+
/** Whether generation auto-runs the author review (hook/continuity/trend) after writing. */
|
|
541
|
+
autoAuthorReview: boolean
|
|
410
542
|
}
|
|
411
543
|
|
|
412
544
|
/** GET /status response. */
|
|
@@ -482,6 +614,8 @@ export type JobFrame =
|
|
|
482
614
|
| { type: 'progress'; chars: number }
|
|
483
615
|
| { type: 'done'; no: number; file: string; chars: number; title: string }
|
|
484
616
|
| { type: 'review'; no: number; report: ReviewReport }
|
|
617
|
+
| { type: 'author-review'; no: number; review: AuthorReview }
|
|
618
|
+
| { type: 'author-backfill-done'; count: number }
|
|
485
619
|
| { type: 'rewritten'; no: number; file: string; chars: number }
|
|
486
620
|
/** 润色/重写完成,产物作为待确认草稿(尚未覆盖正文)。 */
|
|
487
621
|
| { type: 'drafted'; no: number; chars: number; draft: string }
|
|
@@ -562,6 +696,7 @@ export interface ConfigPatch {
|
|
|
562
696
|
maxTokens?: number
|
|
563
697
|
reviewPassScore?: number
|
|
564
698
|
autoReview?: boolean
|
|
699
|
+
autoAuthorReview?: boolean
|
|
565
700
|
}
|
|
566
701
|
|
|
567
702
|
// ------------------------------------------------------------ assistant
|
package/src/routes.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type AssistantHistoryResponse,
|
|
23
23
|
type AssistantRequest,
|
|
24
24
|
type AuditResponse,
|
|
25
|
+
type AuthorReview,
|
|
25
26
|
type BiblePatchRequest,
|
|
26
27
|
type BibleRequest,
|
|
27
28
|
type BlurbRequest,
|
|
@@ -48,6 +49,8 @@ import {
|
|
|
48
49
|
type LoadOutlineRequest,
|
|
49
50
|
type LoadOutlineResponse,
|
|
50
51
|
type NovelConfig,
|
|
52
|
+
type PlotlinesRequest,
|
|
53
|
+
type PlotlinesResponse,
|
|
51
54
|
type PlanRequest,
|
|
52
55
|
type PlanResponse,
|
|
53
56
|
type PolishRequest,
|
|
@@ -55,7 +58,11 @@ import {
|
|
|
55
58
|
type ReviewReport,
|
|
56
59
|
type ReviewRequest,
|
|
57
60
|
type RewriteRequest,
|
|
61
|
+
type SensitiveCheckRequest,
|
|
62
|
+
type SensitiveCheckResponse,
|
|
63
|
+
type SensitiveHit,
|
|
58
64
|
type StatusResponse,
|
|
65
|
+
type StoryBible,
|
|
59
66
|
type StyleEngineRequest,
|
|
60
67
|
type SummaryRequest,
|
|
61
68
|
type VolumesRequest,
|
|
@@ -68,6 +75,8 @@ import { BUILTIN_ANTI_AI_RULES, BUILTIN_GENRE_LIBRARY, BUILTIN_PROGRESSION_MODES
|
|
|
68
75
|
import {
|
|
69
76
|
chapterFileName,
|
|
70
77
|
auditBook,
|
|
78
|
+
authorReviewChapter,
|
|
79
|
+
autoLinkPlotlines,
|
|
71
80
|
backfillFacts,
|
|
72
81
|
createProject,
|
|
73
82
|
exportBook,
|
|
@@ -83,11 +92,16 @@ import {
|
|
|
83
92
|
polishChapterStream,
|
|
84
93
|
readChapterFile,
|
|
85
94
|
refreshCharacters,
|
|
95
|
+
refreshPlotlineProgress,
|
|
86
96
|
reviewChapter,
|
|
87
97
|
reviewChapterText,
|
|
88
98
|
rewriteChapterStream,
|
|
89
99
|
saveProject,
|
|
100
|
+
analyzePlotlineHealth,
|
|
101
|
+
designPlotlinePlan,
|
|
102
|
+
checkSensitiveText,
|
|
90
103
|
suggestForeshadows,
|
|
104
|
+
suggestPlotlines,
|
|
91
105
|
summarizeAndExtractFacts,
|
|
92
106
|
summarizeChapter,
|
|
93
107
|
syncProjectWithDisk,
|
|
@@ -438,6 +452,31 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
438
452
|
chapter.status = 'approved'
|
|
439
453
|
saveProject(config.outputDir, project)
|
|
440
454
|
}
|
|
455
|
+
// 作者复盘:叙事结构检查(钩子兑现/结尾钩子/推进/连续性/趋势;不改变章节状态)。
|
|
456
|
+
if (config.autoAuthorReview ?? true) {
|
|
457
|
+
try {
|
|
458
|
+
const currentBody = readChapterFile(config.outputDir, chapter)
|
|
459
|
+
let prevTail = ''
|
|
460
|
+
if (no > 1) {
|
|
461
|
+
const prev = project.chapters.find(c => c.no === no - 1)
|
|
462
|
+
if (prev !== undefined) {
|
|
463
|
+
prevTail = (readChapterFile(config.outputDir, prev) ?? '').replace(/^#.*$/m, '').trim().slice(-600)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (currentBody !== undefined) {
|
|
467
|
+
const review = await authorReviewChapter(ctx, config, project, no, currentBody, prevTail)
|
|
468
|
+
chapter.authorReview = review
|
|
469
|
+
// 复盘标记推进的剧情线 → 自动关联本章。
|
|
470
|
+
if (review.advancedLines !== undefined) {
|
|
471
|
+
autoLinkPlotlines(project, no, review.advancedLines)
|
|
472
|
+
}
|
|
473
|
+
saveProject(config.outputDir, project)
|
|
474
|
+
send({ type: 'author-review', no, review })
|
|
475
|
+
}
|
|
476
|
+
} catch (error) {
|
|
477
|
+
console.warn('[dsh-novel-forge] author review failed:', (error as Error).message)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
441
480
|
res.end()
|
|
442
481
|
} catch (error) {
|
|
443
482
|
chapter.status = 'error'
|
|
@@ -1180,6 +1219,22 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1180
1219
|
if (Array.isArray(body?.worldRules)) project.bible.worldRules = body.worldRules.filter(r => r.trim() !== '')
|
|
1181
1220
|
if (Array.isArray(body?.redLines)) project.bible.redLines = body.redLines.filter(r => r.trim() !== '')
|
|
1182
1221
|
if (Array.isArray(body?.style)) project.bible.style = body.style.filter(r => r.trim() !== '')
|
|
1222
|
+
if (Array.isArray(body?.characters)) {
|
|
1223
|
+
project.bible.characters = body.characters
|
|
1224
|
+
.filter(c => c !== undefined && c !== null && typeof c.name === 'string' && c.name.trim() !== '')
|
|
1225
|
+
.map(c => ({
|
|
1226
|
+
name: c.name.trim(),
|
|
1227
|
+
role: (['protagonist', 'supporting', 'antagonist', 'other'] as const).includes(c.role as never)
|
|
1228
|
+
? c.role as StoryBible['characters'][number]['role']
|
|
1229
|
+
: 'other',
|
|
1230
|
+
traits: Array.isArray(c.traits) ? c.traits.filter((t): t is string => typeof t === 'string' && t.trim() !== '') : [],
|
|
1231
|
+
goals: typeof c.goals === 'string' ? c.goals : '',
|
|
1232
|
+
relations: typeof c.relations === 'string' ? c.relations : '',
|
|
1233
|
+
knowledge: Array.isArray(c.knowledge)
|
|
1234
|
+
? c.knowledge.filter((k): k is string => typeof k === 'string' && k.trim() !== '')
|
|
1235
|
+
: undefined,
|
|
1236
|
+
}))
|
|
1237
|
+
}
|
|
1183
1238
|
project.updatedAt = new Date().toISOString()
|
|
1184
1239
|
saveProject(config.outputDir, project)
|
|
1185
1240
|
writeJson(res, 200, { bible: project.bible })
|
|
@@ -1359,6 +1414,243 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1359
1414
|
},
|
|
1360
1415
|
}
|
|
1361
1416
|
|
|
1417
|
+
// ------------------------------------------------------------- plotlines
|
|
1418
|
+
/** 剧情线管理:增删改 + 关联章节(主线/支线/人物线/悬念线)。 */
|
|
1419
|
+
const plotlinesRoute: WebRoute = {
|
|
1420
|
+
kind: 'exact',
|
|
1421
|
+
path: NOVEL_API.plotlines,
|
|
1422
|
+
handler: async (req, res) => {
|
|
1423
|
+
if (!guard(req, res, 'POST')) return
|
|
1424
|
+
const config = getConfig()
|
|
1425
|
+
const project = requireProject(res)
|
|
1426
|
+
if (project === undefined) return
|
|
1427
|
+
const body = await readJsonBody<PlotlinesRequest>(req)
|
|
1428
|
+
if (project.plotlines === undefined) project.plotlines = []
|
|
1429
|
+
const op = body?.op
|
|
1430
|
+
if (op === 'add' && body?.line !== undefined) {
|
|
1431
|
+
const line = body.line
|
|
1432
|
+
project.plotlines.push({
|
|
1433
|
+
id: line.id !== '' ? line.id : `pl-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
1434
|
+
name: line.name.slice(0, 40),
|
|
1435
|
+
kind: line.kind,
|
|
1436
|
+
goal: line.goal.slice(0, 300),
|
|
1437
|
+
progress: line.progress.slice(0, 300),
|
|
1438
|
+
status: line.status,
|
|
1439
|
+
chapters: Array.isArray(line.chapters) ? line.chapters.filter((n): n is number => typeof n === 'number') : [],
|
|
1440
|
+
createdAt: line.createdAt !== '' ? line.createdAt : new Date().toISOString(),
|
|
1441
|
+
})
|
|
1442
|
+
} else if (op === 'update' && body?.line !== undefined && body.line.id !== '') {
|
|
1443
|
+
const idx = project.plotlines.findIndex(l => l.id === body.line!.id)
|
|
1444
|
+
if (idx !== -1) {
|
|
1445
|
+
const line = body.line
|
|
1446
|
+
project.plotlines[idx] = {
|
|
1447
|
+
...project.plotlines[idx]!,
|
|
1448
|
+
name: line.name.slice(0, 40),
|
|
1449
|
+
kind: line.kind,
|
|
1450
|
+
goal: line.goal.slice(0, 300),
|
|
1451
|
+
progress: line.progress.slice(0, 300),
|
|
1452
|
+
status: line.status,
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
} else if (op === 'remove' && body?.id !== undefined) {
|
|
1456
|
+
project.plotlines = project.plotlines.filter(l => l.id !== body.id)
|
|
1457
|
+
} else if (op === 'link' && body?.id !== undefined && typeof body.chapterNo === 'number' && body.chapterNo > 0) {
|
|
1458
|
+
const line = project.plotlines.find(l => l.id === body.id)
|
|
1459
|
+
if (line !== undefined && !line.chapters.includes(body.chapterNo)) {
|
|
1460
|
+
line.chapters.push(body.chapterNo)
|
|
1461
|
+
if (line.status === 'active' && line.progress === '') {
|
|
1462
|
+
line.progress = `推进至第 ${body.chapterNo} 章`
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
} else if (op === 'suggest') {
|
|
1466
|
+
try {
|
|
1467
|
+
const suggestions = await suggestPlotlines(ctx, config, project)
|
|
1468
|
+
writeJson(res, 200, { plotlines: project.plotlines, suggestions } satisfies PlotlinesResponse)
|
|
1469
|
+
return
|
|
1470
|
+
} catch (error) {
|
|
1471
|
+
writeJson(res, 500, { error: `AI 建议失败:${(error as Error).message}` })
|
|
1472
|
+
return
|
|
1473
|
+
}
|
|
1474
|
+
} else if (op === 'refresh' && body?.id !== undefined) {
|
|
1475
|
+
const line = project.plotlines.find(l => l.id === body.id)
|
|
1476
|
+
if (line === undefined) {
|
|
1477
|
+
writeJson(res, 404, { error: '剧情线不存在' })
|
|
1478
|
+
return
|
|
1479
|
+
}
|
|
1480
|
+
try {
|
|
1481
|
+
const progress = await refreshPlotlineProgress(ctx, config, project, line)
|
|
1482
|
+
line.progress = progress
|
|
1483
|
+
project.updatedAt = new Date().toISOString()
|
|
1484
|
+
saveProject(config.outputDir, project)
|
|
1485
|
+
writeJson(res, 200, { plotlines: project.plotlines } satisfies PlotlinesResponse)
|
|
1486
|
+
return
|
|
1487
|
+
} catch (error) {
|
|
1488
|
+
writeJson(res, 500, { error: `刷新进度失败:${(error as Error).message}` })
|
|
1489
|
+
return
|
|
1490
|
+
}
|
|
1491
|
+
} else if (op === 'health') {
|
|
1492
|
+
try {
|
|
1493
|
+
const health = await analyzePlotlineHealth(ctx, config, project)
|
|
1494
|
+
writeJson(res, 200, { plotlines: project.plotlines, health } satisfies PlotlinesResponse)
|
|
1495
|
+
return
|
|
1496
|
+
} catch (error) {
|
|
1497
|
+
writeJson(res, 500, { error: `健康检查失败:${(error as Error).message}` })
|
|
1498
|
+
return
|
|
1499
|
+
}
|
|
1500
|
+
} else if (op === 'plan') {
|
|
1501
|
+
try {
|
|
1502
|
+
const health = await analyzePlotlineHealth(ctx, config, project)
|
|
1503
|
+
const plan = await designPlotlinePlan(ctx, config, project, health)
|
|
1504
|
+
writeJson(res, 200, { plotlines: project.plotlines, health, plan } satisfies PlotlinesResponse)
|
|
1505
|
+
return
|
|
1506
|
+
} catch (error) {
|
|
1507
|
+
writeJson(res, 500, { error: `剧情方案生成失败:${(error as Error).message}` })
|
|
1508
|
+
return
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
project.updatedAt = new Date().toISOString()
|
|
1512
|
+
saveProject(config.outputDir, project)
|
|
1513
|
+
const response: PlotlinesResponse = { plotlines: project.plotlines }
|
|
1514
|
+
writeJson(res, 200, response)
|
|
1515
|
+
},
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// --------------------------------------------------------- sensitive
|
|
1519
|
+
/** 敏感词检查:指定章节 / 任意文本 / 全书已写章节。 */
|
|
1520
|
+
const sensitiveRoute: WebRoute = {
|
|
1521
|
+
kind: 'exact',
|
|
1522
|
+
path: NOVEL_API.sensitiveCheck,
|
|
1523
|
+
handler: async (req, res) => {
|
|
1524
|
+
if (!guard(req, res, 'POST')) return
|
|
1525
|
+
const config = getConfig()
|
|
1526
|
+
const project = requireProject(res)
|
|
1527
|
+
if (project === undefined) return
|
|
1528
|
+
const body = await readJsonBody<SensitiveCheckRequest>(req)
|
|
1529
|
+
const hits: SensitiveHit[] = []
|
|
1530
|
+
let scanned = 0
|
|
1531
|
+
if (body?.text !== undefined) {
|
|
1532
|
+
for (const hit of checkSensitiveText(body.text)) {
|
|
1533
|
+
hits.push({ chapterNo: 0, word: hit.word, category: hit.category, count: hit.count })
|
|
1534
|
+
}
|
|
1535
|
+
} else if (typeof body?.chapterNo === 'number') {
|
|
1536
|
+
const chapter = project.chapters.find(c => c.no === body.chapterNo)
|
|
1537
|
+
if (chapter !== undefined) {
|
|
1538
|
+
const text = readChapterFile(config.outputDir, chapter)
|
|
1539
|
+
if (text !== undefined) {
|
|
1540
|
+
scanned = 1
|
|
1541
|
+
for (const hit of checkSensitiveText(text)) {
|
|
1542
|
+
hits.push({ chapterNo: chapter.no, word: hit.word, category: hit.category, count: hit.count })
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
} else if (body?.all === true) {
|
|
1547
|
+
for (const chapter of project.chapters) {
|
|
1548
|
+
if (chapter.status === 'pending' || chapter.status === 'generating') continue
|
|
1549
|
+
const text = readChapterFile(config.outputDir, chapter)
|
|
1550
|
+
if (text === undefined) continue
|
|
1551
|
+
scanned++
|
|
1552
|
+
for (const hit of checkSensitiveText(text)) {
|
|
1553
|
+
hits.push({ chapterNo: chapter.no, word: hit.word, category: hit.category, count: hit.count })
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
} else {
|
|
1557
|
+
writeJson(res, 400, { error: '请提供 chapterNo / text / all 之一' })
|
|
1558
|
+
return
|
|
1559
|
+
}
|
|
1560
|
+
const response: SensitiveCheckResponse = { hits, scannedChapters: scanned }
|
|
1561
|
+
writeJson(res, 200, response)
|
|
1562
|
+
},
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// ------------------------------------------------------ author review backfill
|
|
1566
|
+
/** 作者复盘补跑:对已写章节补齐 authorReview(body.chapterNo=单章 JSON,缺省=全书 NDJSON 流)。 */
|
|
1567
|
+
const reviewBackfillRoute: WebRoute = {
|
|
1568
|
+
kind: 'exact',
|
|
1569
|
+
path: NOVEL_API.reviewBackfill,
|
|
1570
|
+
handler: async (req, res) => {
|
|
1571
|
+
if (!guard(req, res, 'POST')) return
|
|
1572
|
+
const config = getConfig()
|
|
1573
|
+
const project = requireProject(res)
|
|
1574
|
+
if (project === undefined) return
|
|
1575
|
+
const body = await readJsonBody<{ chapterNo?: number }>(req)
|
|
1576
|
+
|
|
1577
|
+
/** 对一章执行作者复盘(读取已落盘正文,不改变章节状态/正文)。 */
|
|
1578
|
+
const runOne = async (chapter: ChapterPlan): Promise<AuthorReview> => {
|
|
1579
|
+
const currentBody = readChapterFile(config.outputDir, chapter)
|
|
1580
|
+
if (currentBody === undefined) throw new Error(`章节 ${chapter.no} 的正文文件不存在`)
|
|
1581
|
+
let prevTail = ''
|
|
1582
|
+
if (chapter.no > 1) {
|
|
1583
|
+
const prev = project.chapters.find(c => c.no === chapter.no - 1)
|
|
1584
|
+
if (prev !== undefined) {
|
|
1585
|
+
prevTail = (readChapterFile(config.outputDir, prev) ?? '').replace(/^#.*$/m, '').trim().slice(-600)
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
return authorReviewChapter(ctx, config, project, chapter.no, currentBody, prevTail)
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// 单章:JSON 响应。
|
|
1592
|
+
if (typeof body?.chapterNo === 'number' && body.chapterNo > 0) {
|
|
1593
|
+
const chapter = project.chapters.find(c => c.no === body.chapterNo)
|
|
1594
|
+
if (chapter === undefined) {
|
|
1595
|
+
writeJson(res, 404, { error: `章节 ${body.chapterNo} 不在计划中` })
|
|
1596
|
+
return
|
|
1597
|
+
}
|
|
1598
|
+
if (chapter.status === 'pending') {
|
|
1599
|
+
writeJson(res, 400, { error: '该章尚未生成正文,无法复盘' })
|
|
1600
|
+
return
|
|
1601
|
+
}
|
|
1602
|
+
try {
|
|
1603
|
+
const review = await runOne(chapter)
|
|
1604
|
+
chapter.authorReview = review
|
|
1605
|
+
if (review.advancedLines !== undefined) {
|
|
1606
|
+
autoLinkPlotlines(project, chapter.no, review.advancedLines)
|
|
1607
|
+
}
|
|
1608
|
+
project.updatedAt = new Date().toISOString()
|
|
1609
|
+
saveProject(config.outputDir, project)
|
|
1610
|
+
writeJson(res, 200, { no: chapter.no, review } satisfies { no: number; review: AuthorReview })
|
|
1611
|
+
return
|
|
1612
|
+
} catch (error) {
|
|
1613
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
1614
|
+
return
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// 全书:NDJSON 流式补跑缺失复盘的已写章节。
|
|
1619
|
+
const missing = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.authorReview === undefined)
|
|
1620
|
+
if (missing.length === 0) {
|
|
1621
|
+
writeJson(res, 200, { count: 0 })
|
|
1622
|
+
return
|
|
1623
|
+
}
|
|
1624
|
+
res.writeHead(200, {
|
|
1625
|
+
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
1626
|
+
'cache-control': 'no-cache',
|
|
1627
|
+
'referrer-policy': 'no-referrer',
|
|
1628
|
+
})
|
|
1629
|
+
const send = (frame: JobFrame): void => {
|
|
1630
|
+
res.write(JSON.stringify(frame) + '\n')
|
|
1631
|
+
}
|
|
1632
|
+
let done = 0
|
|
1633
|
+
for (const chapter of missing) {
|
|
1634
|
+
try {
|
|
1635
|
+
const review = await runOne(chapter)
|
|
1636
|
+
chapter.authorReview = review
|
|
1637
|
+
if (review.advancedLines !== undefined) {
|
|
1638
|
+
autoLinkPlotlines(project, chapter.no, review.advancedLines)
|
|
1639
|
+
}
|
|
1640
|
+
done++
|
|
1641
|
+
saveProject(config.outputDir, project)
|
|
1642
|
+
send({ type: 'author-review', no: chapter.no, review })
|
|
1643
|
+
} catch (error) {
|
|
1644
|
+
console.warn(`[dsh-novel-forge] author backfill ch.${chapter.no} failed:`, (error as Error).message)
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
project.updatedAt = new Date().toISOString()
|
|
1648
|
+
saveProject(config.outputDir, project)
|
|
1649
|
+
send({ type: 'author-backfill-done', count: done })
|
|
1650
|
+
res.end()
|
|
1651
|
+
},
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1362
1654
|
// --------------------------------------------------------------- config
|
|
1363
1655
|
const configRoute: WebRoute = {
|
|
1364
1656
|
kind: 'exact',
|
|
@@ -1433,6 +1725,9 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1433
1725
|
coverRoute,
|
|
1434
1726
|
worldRoute,
|
|
1435
1727
|
renameRoute,
|
|
1728
|
+
plotlinesRoute,
|
|
1729
|
+
sensitiveRoute,
|
|
1730
|
+
reviewBackfillRoute,
|
|
1436
1731
|
configRoute,
|
|
1437
1732
|
openFolderRoute,
|
|
1438
1733
|
]
|