@waterwx/dsh-novel-forge 1.1.2 → 1.1.3
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 +7 -0
- package/lib/client.js +1256 -294
- package/lib/client.js.map +1 -1
- package/lib/index.js +486 -2
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +4 -0
- package/lib/types/client/locales.d.ts +42 -0
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/engine.d.ts +6 -0
- package/lib/types/protocol.d.ts +69 -0
- package/package.json +1 -1
- package/src/client/api.ts +10 -0
- package/src/client/locales.ts +84 -0
- package/src/client/panel/AssetsTab.tsx +7 -7
- package/src/client/panel/NovelPanel.tsx +505 -68
- package/src/client/panel/panel.module.css +398 -57
- package/src/engine.ts +88 -0
- package/src/protocol.ts +75 -0
- package/src/routes.ts +128 -0
package/src/engine.ts
CHANGED
|
@@ -69,6 +69,7 @@ export function loadProject(outputDir: string): ProjectState | undefined {
|
|
|
69
69
|
if (!Array.isArray(raw.assets.auxiliaryProgressions)) raw.assets.auxiliaryProgressions = []
|
|
70
70
|
if (!Array.isArray(raw.assets.styleAssets)) raw.assets.styleAssets = []
|
|
71
71
|
if (!Array.isArray(raw.facts)) raw.facts = []
|
|
72
|
+
if (!Array.isArray(raw.plotlines)) raw.plotlines = []
|
|
72
73
|
return raw
|
|
73
74
|
} catch {
|
|
74
75
|
return undefined
|
|
@@ -81,6 +82,78 @@ export function saveProject(outputDir: string, project: ProjectState): void {
|
|
|
81
82
|
writeFileSync(join(outputDir, PROJECT_FILE), JSON.stringify(project, null, 2), 'utf8')
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
// ------------------------------------------------------------ sensitive words
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 内置违禁词库(网文平台常见审查类别)。只做硬匹配提示,不代替人工判断。
|
|
89
|
+
* 词语刻意保持常见写法;作者可自行判断是否修改。
|
|
90
|
+
*/
|
|
91
|
+
const SENSITIVE_WORDS: ReadonlyArray<{ word: string; category: string }> = [
|
|
92
|
+
// 政治敏感
|
|
93
|
+
{ word: '共匪', category: '政治' }, { word: '独裁', category: '政治' },
|
|
94
|
+
{ word: '法轮', category: '政治' }, { word: '六四', category: '政治' },
|
|
95
|
+
{ word: '天安门事件', category: '政治' }, { word: '翻墙', category: '政治' },
|
|
96
|
+
{ word: '政治敏感', category: '政治' },
|
|
97
|
+
// 色情擦边
|
|
98
|
+
{ word: '乳沟', category: '擦边' }, { word: '酥胸', category: '擦边' },
|
|
99
|
+
{ word: '淫荡', category: '擦边' }, { word: '做爱', category: '擦边' },
|
|
100
|
+
{ word: '上床', category: '擦边' }, { word: '裸体', category: '擦边' },
|
|
101
|
+
{ word: '一丝不挂', category: '擦边' }, { word: '胴体', category: '擦边' },
|
|
102
|
+
{ word: '春药', category: '擦边' }, { word: '催情', category: '擦边' },
|
|
103
|
+
{ word: '迷奸', category: '擦边' }, { word: '强暴', category: '擦边' },
|
|
104
|
+
{ word: '轮奸', category: '擦边' }, { word: '援交', category: '擦边' },
|
|
105
|
+
{ word: '嫖娼', category: '擦边' }, { word: '卖淫', category: '擦边' },
|
|
106
|
+
{ word: '色情', category: '擦边' }, { word: '情色', category: '擦边' },
|
|
107
|
+
{ word: '撸管', category: '擦边' }, { word: '自慰', category: '擦边' },
|
|
108
|
+
{ word: '口交', category: '擦边' }, { word: '打炮', category: '擦边' },
|
|
109
|
+
{ word: '约炮', category: '擦边' }, { word: '一夜情', category: '擦边' },
|
|
110
|
+
// 暴力血腥
|
|
111
|
+
{ word: '碎尸', category: '暴力' }, { word: '分尸', category: '暴力' },
|
|
112
|
+
{ word: '凌迟', category: '暴力' }, { word: '剥皮', category: '暴力' },
|
|
113
|
+
{ word: '开膛', category: '暴力' }, { word: '剖腹', category: '暴力' },
|
|
114
|
+
{ word: '挖心', category: '暴力' }, { word: '虐杀', category: '暴力' },
|
|
115
|
+
{ word: '凌辱', category: '暴力' }, { word: '血腥', category: '暴力' },
|
|
116
|
+
{ word: '大屠杀', category: '暴力' }, { word: '灭门', category: '暴力' },
|
|
117
|
+
{ word: '满门抄斩', category: '暴力' }, { word: '腰斩', category: '暴力' },
|
|
118
|
+
{ word: '活埋', category: '暴力' }, { word: '点天灯', category: '暴力' },
|
|
119
|
+
// 辱骂攻击
|
|
120
|
+
{ word: '傻逼', category: '辱骂' }, { word: '傻B', category: '辱骂' },
|
|
121
|
+
{ word: '草泥马', category: '辱骂' }, { word: '妈的', category: '辱骂' },
|
|
122
|
+
{ word: '尼玛', category: '辱骂' }, { word: '去死', category: '辱骂' },
|
|
123
|
+
{ word: '废物', category: '辱骂' }, { word: '垃圾', category: '辱骂' },
|
|
124
|
+
{ word: '人渣', category: '辱骂' }, { word: '贱人', category: '辱骂' },
|
|
125
|
+
{ word: '婊子', category: '辱骂' }, { word: '狗日的', category: '辱骂' },
|
|
126
|
+
// 广告引流
|
|
127
|
+
{ word: '加微信', category: '广告' }, { word: '加QQ', category: '广告' },
|
|
128
|
+
{ word: '微信公众号', category: '广告' }, { word: '淘宝', category: '广告' },
|
|
129
|
+
{ word: '拼多多', category: '广告' }, { word: '刷单', category: '广告' },
|
|
130
|
+
{ word: '充值返利', category: '广告' }, { word: '扫码领', category: '广告' },
|
|
131
|
+
{ word: '加群领', category: '广告' }, { word: 'vx', category: '广告' },
|
|
132
|
+
{ word: '扣扣', category: '广告' },
|
|
133
|
+
// 其他违禁
|
|
134
|
+
{ word: '赌博', category: '其他' }, { word: '赌场', category: '其他' },
|
|
135
|
+
{ word: '毒品', category: '其他' }, { word: '冰毒', category: '其他' },
|
|
136
|
+
{ word: '摇头丸', category: '其他' }, { word: '自杀方法', category: '其他' },
|
|
137
|
+
{ word: '邪教', category: '其他' }, { word: '传销', category: '其他' },
|
|
138
|
+
{ word: '军火', category: '其他' }, { word: '枪支', category: '其他' },
|
|
139
|
+
{ word: '管制刀具', category: '其他' },
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
/** 对一段文本做违禁词硬匹配,返回命中(词/类别/次数)。 */
|
|
143
|
+
export function checkSensitiveText(text: string): Array<{ word: string; category: string; count: number }> {
|
|
144
|
+
const hits: Array<{ word: string; category: string; count: number }> = []
|
|
145
|
+
for (const entry of SENSITIVE_WORDS) {
|
|
146
|
+
let count = 0
|
|
147
|
+
let idx = text.indexOf(entry.word)
|
|
148
|
+
while (idx !== -1) {
|
|
149
|
+
count++
|
|
150
|
+
idx = text.indexOf(entry.word, idx + entry.word.length)
|
|
151
|
+
}
|
|
152
|
+
if (count > 0) hits.push({ word: entry.word, category: entry.category, count })
|
|
153
|
+
}
|
|
154
|
+
return hits
|
|
155
|
+
}
|
|
156
|
+
|
|
84
157
|
/** List generated chapter files in the output dir (sorted). */
|
|
85
158
|
export function listChapterFiles(outputDir: string): string[] {
|
|
86
159
|
if (!existsSync(outputDir)) return []
|
|
@@ -337,6 +410,7 @@ export async function extractBible(ctx: Context, config: NovelConfig, outline: s
|
|
|
337
410
|
traits: strArray(entry.traits),
|
|
338
411
|
goals: typeof entry.goals === 'string' ? entry.goals : '',
|
|
339
412
|
relations: typeof entry.relations === 'string' ? entry.relations : '',
|
|
413
|
+
knowledge: strArray(entry.knowledge),
|
|
340
414
|
}))
|
|
341
415
|
.filter(card => card.name !== '')
|
|
342
416
|
: []
|
|
@@ -443,6 +517,9 @@ function writeSystemPrompt(project: ProjectState): string {
|
|
|
443
517
|
for (const card of bible.characters) {
|
|
444
518
|
const roleName = { protagonist: '主角', supporting: '配角', antagonist: '反派', other: '其他' }[card.role]
|
|
445
519
|
sections.push(`- ${card.name}(${roleName}):${card.traits.join('、')}${card.goals !== '' ? `;目标:${card.goals}` : ''}${card.relations !== '' ? `;关系:${card.relations}` : ''}`)
|
|
520
|
+
if (Array.isArray(card.knowledge) && card.knowledge.length > 0) {
|
|
521
|
+
sections.push(` 已知信息(该角色知道的:${card.knowledge.join(';')};未列出的信息该角色一律不知道,不得写其知晓或提及)`)
|
|
522
|
+
}
|
|
446
523
|
}
|
|
447
524
|
}
|
|
448
525
|
if (bible.redLines.length > 0) sections.push('写作红线(违反即失败):\n' + bible.redLines.map(r => `- ${r}`).join('\n'))
|
|
@@ -466,6 +543,14 @@ function writeSystemPrompt(project: ProjectState): string {
|
|
|
466
543
|
sections.push(`- [${f.status === 'planted' ? '已埋设' : '推进中'}] ${f.description}${f.targetChapter !== undefined ? `(预计 ${f.targetChapter} 章回收)` : ''}`)
|
|
467
544
|
}
|
|
468
545
|
}
|
|
546
|
+
const lines = (project.plotlines ?? []).filter(l => l.status === 'active' || l.status === 'paused')
|
|
547
|
+
if (lines.length > 0) {
|
|
548
|
+
const kindName = { main: '主线', branch: '支线', character: '人物线', mystery: '悬念线' }
|
|
549
|
+
sections.push('==================== 剧情线(本章应推进至少一条活跃线) ====================')
|
|
550
|
+
for (const l of lines) {
|
|
551
|
+
sections.push(`- [${kindName[l.kind]}${l.status === 'paused' ? '·暂停中' : ''}] ${l.name}:${l.goal}${l.progress !== '' ? `(当前进度:${l.progress})` : ''}`)
|
|
552
|
+
}
|
|
553
|
+
}
|
|
469
554
|
sections.push('')
|
|
470
555
|
sections.push('写作硬性要求:')
|
|
471
556
|
sections.push('1. 每章 3000-4000 字(按中文字符计),只输出章节正文,不要输出标题、章回名、作者的话或任何 Markdown 标记。')
|
|
@@ -606,6 +691,9 @@ function reviewSystemPrompt(project: ProjectState): string {
|
|
|
606
691
|
sections.push('角色卡:')
|
|
607
692
|
for (const card of bible.characters) {
|
|
608
693
|
sections.push(`- ${card.name}(${card.role}):${card.traits.join('、')}`)
|
|
694
|
+
if (Array.isArray(card.knowledge) && card.knowledge.length > 0) {
|
|
695
|
+
sections.push(` 该角色知道:${card.knowledge.join(';')}(未列出的信息该角色不知道)`)
|
|
696
|
+
}
|
|
609
697
|
}
|
|
610
698
|
}
|
|
611
699
|
if (bible.redLines.length > 0) sections.push('红线:\n' + bible.redLines.map(r => `- ${r}`).join('\n'))
|
package/src/protocol.ts
CHANGED
|
@@ -54,6 +54,10 @@ 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
|
+
/** 敏感词检查:全书已写章节或指定文本。 */
|
|
60
|
+
sensitiveCheck: '/api/dsh-novel-forge/sensitive-check',
|
|
57
61
|
config: '/api/dsh-novel-forge/config',
|
|
58
62
|
openFolder: '/api/dsh-novel-forge/open-folder',
|
|
59
63
|
} as const
|
|
@@ -192,6 +196,8 @@ export interface CharacterCard {
|
|
|
192
196
|
goals: string
|
|
193
197
|
/** Key relations to other characters. */
|
|
194
198
|
relations: string
|
|
199
|
+
/** 知情度:该角色已经知道的事实/秘密(未列出的信息该角色不知道)。 */
|
|
200
|
+
knowledge?: string[]
|
|
195
201
|
}
|
|
196
202
|
|
|
197
203
|
/** The structured story bible (worldbuilding extracted from the outline). */
|
|
@@ -254,6 +260,71 @@ export interface AuditResponse {
|
|
|
254
260
|
auditedAt: string
|
|
255
261
|
}
|
|
256
262
|
|
|
263
|
+
/** 一条剧情线(主线/支线/人物线/悬念线)。 */
|
|
264
|
+
export interface Plotline {
|
|
265
|
+
/** 稳定 id。 */
|
|
266
|
+
id: string
|
|
267
|
+
/** 线名。 */
|
|
268
|
+
name: string
|
|
269
|
+
/** 类型:主线 / 支线 / 人物线 / 悬念线。 */
|
|
270
|
+
kind: 'main' | 'branch' | 'character' | 'mystery'
|
|
271
|
+
/** 目标/终点(这条线最终要完成什么)。 */
|
|
272
|
+
goal: string
|
|
273
|
+
/** 当前进度说明(最近推进到哪)。 */
|
|
274
|
+
progress: string
|
|
275
|
+
/** 生命周期状态。 */
|
|
276
|
+
status: 'active' | 'paused' | 'resolved' | 'abandoned'
|
|
277
|
+
/** 关联章节号(推进/落地的章节)。 */
|
|
278
|
+
chapters: number[]
|
|
279
|
+
/** 创建时间。 */
|
|
280
|
+
createdAt: string
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** POST /plotlines 请求:剧情线增删改 + 关联章节。 */
|
|
284
|
+
export interface PlotlinesRequest {
|
|
285
|
+
op: 'add' | 'update' | 'remove' | 'link'
|
|
286
|
+
/** add / update 时传入的完整剧情线。 */
|
|
287
|
+
line?: Plotline
|
|
288
|
+
/** remove / link 时的目标线 id。 */
|
|
289
|
+
id?: string
|
|
290
|
+
/** link 时关联的章节号。 */
|
|
291
|
+
chapterNo?: number
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** POST /plotlines 响应。 */
|
|
295
|
+
export interface PlotlinesResponse {
|
|
296
|
+
plotlines: Plotline[]
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** 一条敏感词命中。 */
|
|
300
|
+
export interface SensitiveHit {
|
|
301
|
+
/** 命中章节号(文本检测时为 0)。 */
|
|
302
|
+
chapterNo: number
|
|
303
|
+
/** 命中的违禁词。 */
|
|
304
|
+
word: string
|
|
305
|
+
/** 类别:政治 / 擦边 / 暴力 / 辱骂 / 广告 / 其他。 */
|
|
306
|
+
category: string
|
|
307
|
+
/** 出现次数。 */
|
|
308
|
+
count: number
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** POST /sensitive-check 请求:检测指定章节/任意文本/全书。 */
|
|
312
|
+
export interface SensitiveCheckRequest {
|
|
313
|
+
/** 检测该章正文。 */
|
|
314
|
+
chapterNo?: number
|
|
315
|
+
/** 检测任意文本(优先于 chapterNo)。 */
|
|
316
|
+
text?: string
|
|
317
|
+
/** 扫描全部已写章节。 */
|
|
318
|
+
all?: boolean
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** POST /sensitive-check 响应。 */
|
|
322
|
+
export interface SensitiveCheckResponse {
|
|
323
|
+
hits: SensitiveHit[]
|
|
324
|
+
/** 参与扫描的章节数。 */
|
|
325
|
+
scannedChapters: number
|
|
326
|
+
}
|
|
327
|
+
|
|
257
328
|
/** 角色卡:角色当前状态(从事实库聚合)。 */
|
|
258
329
|
export interface RoleStatusCard {
|
|
259
330
|
name: string
|
|
@@ -272,6 +343,8 @@ export interface BiblePatchRequest {
|
|
|
272
343
|
worldRules?: string[]
|
|
273
344
|
redLines?: string[]
|
|
274
345
|
style?: string[]
|
|
346
|
+
/** 角色卡整体替换(人物志编辑知情度等)。 */
|
|
347
|
+
characters?: CharacterCard[]
|
|
275
348
|
}
|
|
276
349
|
|
|
277
350
|
/** POST /blurb 请求:AI 生成/补全或手动保存小说简介。 */
|
|
@@ -384,6 +457,8 @@ export interface ProjectState {
|
|
|
384
457
|
coverPath?: string
|
|
385
458
|
/** 大世界结构化数据(境界体系/区域/势力)。 */
|
|
386
459
|
world?: WorldState
|
|
460
|
+
/** 剧情线(主线/支线/人物线/悬念线)。 */
|
|
461
|
+
plotlines?: Plotline[]
|
|
387
462
|
/** ISO timestamps. */
|
|
388
463
|
createdAt: string
|
|
389
464
|
updatedAt: string
|
package/src/routes.ts
CHANGED
|
@@ -48,6 +48,8 @@ import {
|
|
|
48
48
|
type LoadOutlineRequest,
|
|
49
49
|
type LoadOutlineResponse,
|
|
50
50
|
type NovelConfig,
|
|
51
|
+
type PlotlinesRequest,
|
|
52
|
+
type PlotlinesResponse,
|
|
51
53
|
type PlanRequest,
|
|
52
54
|
type PlanResponse,
|
|
53
55
|
type PolishRequest,
|
|
@@ -55,7 +57,11 @@ import {
|
|
|
55
57
|
type ReviewReport,
|
|
56
58
|
type ReviewRequest,
|
|
57
59
|
type RewriteRequest,
|
|
60
|
+
type SensitiveCheckRequest,
|
|
61
|
+
type SensitiveCheckResponse,
|
|
62
|
+
type SensitiveHit,
|
|
58
63
|
type StatusResponse,
|
|
64
|
+
type StoryBible,
|
|
59
65
|
type StyleEngineRequest,
|
|
60
66
|
type SummaryRequest,
|
|
61
67
|
type VolumesRequest,
|
|
@@ -87,6 +93,7 @@ import {
|
|
|
87
93
|
reviewChapterText,
|
|
88
94
|
rewriteChapterStream,
|
|
89
95
|
saveProject,
|
|
96
|
+
checkSensitiveText,
|
|
90
97
|
suggestForeshadows,
|
|
91
98
|
summarizeAndExtractFacts,
|
|
92
99
|
summarizeChapter,
|
|
@@ -1180,6 +1187,22 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1180
1187
|
if (Array.isArray(body?.worldRules)) project.bible.worldRules = body.worldRules.filter(r => r.trim() !== '')
|
|
1181
1188
|
if (Array.isArray(body?.redLines)) project.bible.redLines = body.redLines.filter(r => r.trim() !== '')
|
|
1182
1189
|
if (Array.isArray(body?.style)) project.bible.style = body.style.filter(r => r.trim() !== '')
|
|
1190
|
+
if (Array.isArray(body?.characters)) {
|
|
1191
|
+
project.bible.characters = body.characters
|
|
1192
|
+
.filter(c => c !== undefined && c !== null && typeof c.name === 'string' && c.name.trim() !== '')
|
|
1193
|
+
.map(c => ({
|
|
1194
|
+
name: c.name.trim(),
|
|
1195
|
+
role: (['protagonist', 'supporting', 'antagonist', 'other'] as const).includes(c.role as never)
|
|
1196
|
+
? c.role as StoryBible['characters'][number]['role']
|
|
1197
|
+
: 'other',
|
|
1198
|
+
traits: Array.isArray(c.traits) ? c.traits.filter((t): t is string => typeof t === 'string' && t.trim() !== '') : [],
|
|
1199
|
+
goals: typeof c.goals === 'string' ? c.goals : '',
|
|
1200
|
+
relations: typeof c.relations === 'string' ? c.relations : '',
|
|
1201
|
+
knowledge: Array.isArray(c.knowledge)
|
|
1202
|
+
? c.knowledge.filter((k): k is string => typeof k === 'string' && k.trim() !== '')
|
|
1203
|
+
: undefined,
|
|
1204
|
+
}))
|
|
1205
|
+
}
|
|
1183
1206
|
project.updatedAt = new Date().toISOString()
|
|
1184
1207
|
saveProject(config.outputDir, project)
|
|
1185
1208
|
writeJson(res, 200, { bible: project.bible })
|
|
@@ -1359,6 +1382,109 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1359
1382
|
},
|
|
1360
1383
|
}
|
|
1361
1384
|
|
|
1385
|
+
// ------------------------------------------------------------- plotlines
|
|
1386
|
+
/** 剧情线管理:增删改 + 关联章节(主线/支线/人物线/悬念线)。 */
|
|
1387
|
+
const plotlinesRoute: WebRoute = {
|
|
1388
|
+
kind: 'exact',
|
|
1389
|
+
path: NOVEL_API.plotlines,
|
|
1390
|
+
handler: async (req, res) => {
|
|
1391
|
+
if (!guard(req, res, 'POST')) return
|
|
1392
|
+
const config = getConfig()
|
|
1393
|
+
const project = requireProject(res)
|
|
1394
|
+
if (project === undefined) return
|
|
1395
|
+
const body = await readJsonBody<PlotlinesRequest>(req)
|
|
1396
|
+
if (project.plotlines === undefined) project.plotlines = []
|
|
1397
|
+
const op = body?.op
|
|
1398
|
+
if (op === 'add' && body?.line !== undefined) {
|
|
1399
|
+
const line = body.line
|
|
1400
|
+
project.plotlines.push({
|
|
1401
|
+
id: line.id !== '' ? line.id : `pl-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
1402
|
+
name: line.name.slice(0, 40),
|
|
1403
|
+
kind: line.kind,
|
|
1404
|
+
goal: line.goal.slice(0, 300),
|
|
1405
|
+
progress: line.progress.slice(0, 300),
|
|
1406
|
+
status: line.status,
|
|
1407
|
+
chapters: Array.isArray(line.chapters) ? line.chapters.filter((n): n is number => typeof n === 'number') : [],
|
|
1408
|
+
createdAt: line.createdAt !== '' ? line.createdAt : new Date().toISOString(),
|
|
1409
|
+
})
|
|
1410
|
+
} else if (op === 'update' && body?.line !== undefined && body.line.id !== '') {
|
|
1411
|
+
const idx = project.plotlines.findIndex(l => l.id === body.line!.id)
|
|
1412
|
+
if (idx !== -1) {
|
|
1413
|
+
const line = body.line
|
|
1414
|
+
project.plotlines[idx] = {
|
|
1415
|
+
...project.plotlines[idx]!,
|
|
1416
|
+
name: line.name.slice(0, 40),
|
|
1417
|
+
kind: line.kind,
|
|
1418
|
+
goal: line.goal.slice(0, 300),
|
|
1419
|
+
progress: line.progress.slice(0, 300),
|
|
1420
|
+
status: line.status,
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
} else if (op === 'remove' && body?.id !== undefined) {
|
|
1424
|
+
project.plotlines = project.plotlines.filter(l => l.id !== body.id)
|
|
1425
|
+
} else if (op === 'link' && body?.id !== undefined && typeof body.chapterNo === 'number' && body.chapterNo > 0) {
|
|
1426
|
+
const line = project.plotlines.find(l => l.id === body.id)
|
|
1427
|
+
if (line !== undefined && !line.chapters.includes(body.chapterNo)) {
|
|
1428
|
+
line.chapters.push(body.chapterNo)
|
|
1429
|
+
if (line.status === 'active' && line.progress === '') {
|
|
1430
|
+
line.progress = `推进至第 ${body.chapterNo} 章`
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
project.updatedAt = new Date().toISOString()
|
|
1435
|
+
saveProject(config.outputDir, project)
|
|
1436
|
+
const response: PlotlinesResponse = { plotlines: project.plotlines }
|
|
1437
|
+
writeJson(res, 200, response)
|
|
1438
|
+
},
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// --------------------------------------------------------- sensitive
|
|
1442
|
+
/** 敏感词检查:指定章节 / 任意文本 / 全书已写章节。 */
|
|
1443
|
+
const sensitiveRoute: WebRoute = {
|
|
1444
|
+
kind: 'exact',
|
|
1445
|
+
path: NOVEL_API.sensitiveCheck,
|
|
1446
|
+
handler: async (req, res) => {
|
|
1447
|
+
if (!guard(req, res, 'POST')) return
|
|
1448
|
+
const config = getConfig()
|
|
1449
|
+
const project = requireProject(res)
|
|
1450
|
+
if (project === undefined) return
|
|
1451
|
+
const body = await readJsonBody<SensitiveCheckRequest>(req)
|
|
1452
|
+
const hits: SensitiveHit[] = []
|
|
1453
|
+
let scanned = 0
|
|
1454
|
+
if (body?.text !== undefined) {
|
|
1455
|
+
for (const hit of checkSensitiveText(body.text)) {
|
|
1456
|
+
hits.push({ chapterNo: 0, word: hit.word, category: hit.category, count: hit.count })
|
|
1457
|
+
}
|
|
1458
|
+
} else if (typeof body?.chapterNo === 'number') {
|
|
1459
|
+
const chapter = project.chapters.find(c => c.no === body.chapterNo)
|
|
1460
|
+
if (chapter !== undefined) {
|
|
1461
|
+
const text = readChapterFile(config.outputDir, chapter)
|
|
1462
|
+
if (text !== undefined) {
|
|
1463
|
+
scanned = 1
|
|
1464
|
+
for (const hit of checkSensitiveText(text)) {
|
|
1465
|
+
hits.push({ chapterNo: chapter.no, word: hit.word, category: hit.category, count: hit.count })
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
} else if (body?.all === true) {
|
|
1470
|
+
for (const chapter of project.chapters) {
|
|
1471
|
+
if (chapter.status === 'pending' || chapter.status === 'generating') continue
|
|
1472
|
+
const text = readChapterFile(config.outputDir, chapter)
|
|
1473
|
+
if (text === undefined) continue
|
|
1474
|
+
scanned++
|
|
1475
|
+
for (const hit of checkSensitiveText(text)) {
|
|
1476
|
+
hits.push({ chapterNo: chapter.no, word: hit.word, category: hit.category, count: hit.count })
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
} else {
|
|
1480
|
+
writeJson(res, 400, { error: '请提供 chapterNo / text / all 之一' })
|
|
1481
|
+
return
|
|
1482
|
+
}
|
|
1483
|
+
const response: SensitiveCheckResponse = { hits, scannedChapters: scanned }
|
|
1484
|
+
writeJson(res, 200, response)
|
|
1485
|
+
},
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1362
1488
|
// --------------------------------------------------------------- config
|
|
1363
1489
|
const configRoute: WebRoute = {
|
|
1364
1490
|
kind: 'exact',
|
|
@@ -1433,6 +1559,8 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1433
1559
|
coverRoute,
|
|
1434
1560
|
worldRoute,
|
|
1435
1561
|
renameRoute,
|
|
1562
|
+
plotlinesRoute,
|
|
1563
|
+
sensitiveRoute,
|
|
1436
1564
|
configRoute,
|
|
1437
1565
|
openFolderRoute,
|
|
1438
1566
|
]
|