@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/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
  ]