@waterwx/dsh-novel-forge 1.4.0 → 1.4.1

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
@@ -55,6 +55,30 @@ export const PROJECT_FILE = 'novel-project.json'
55
55
 
56
56
  // ------------------------------------------------------------------ helpers
57
57
 
58
+ /** 智能解码文本文件:UTF-8 BOM / UTF-16 BOM / UTF-8(严格校验)/ GB18030 回退。 */
59
+ function decodeTextSmart(buf: Buffer): string {
60
+ if (buf.length >= 3 && buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) return buf.subarray(3).toString('utf8')
61
+ if (buf.length >= 2 && buf[0] === 0xFF && buf[1] === 0xFE) return buf.subarray(2).toString('utf16le')
62
+ const utf8 = buf.toString('utf8')
63
+ const bad = countReplacementChars(utf8)
64
+ if (bad === 0) return utf8
65
+ try {
66
+ // GBK/GB18030 常见于网文 txt(Windows 下载站);UTF-8 解码出现替换符时回退。
67
+ const gbk = new TextDecoder('gb18030').decode(buf)
68
+ if (countReplacementChars(gbk) < bad) return gbk
69
+ } catch { /* TextDecoder gb18030 不可用则保持 UTF-8 结果 */ }
70
+ return utf8
71
+ }
72
+
73
+ /** 统计替换字符 U+FFFD 数量(UTF-8 乱码检测)。 */
74
+ function countReplacementChars(s: string): number {
75
+ let n = 0
76
+ for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 0xFFFD) n++
77
+ return n
78
+ }
79
+
80
+
81
+
58
82
  /** Sanitize a file name: keep CJK/alphanumerics/space/dash/underscore. */
59
83
  function safeFileName(name: string): string {
60
84
  return name
@@ -266,7 +290,7 @@ export function createProject(outline: string, outlinePath?: string): ProjectSta
266
290
  async function complete(
267
291
  ctx: Context,
268
292
  config: NovelConfig,
269
- options: { system: string; user: string; temperature?: number; maxTokens?: number },
293
+ options: { system: string; user: string; temperature?: number; maxTokens?: number; reasoning?: 'off' | 'low' | 'high' | 'max' },
270
294
  ): Promise<string> {
271
295
  const messages: Message[] = [createUserMessage({
272
296
  content: [{ type: 'text', text: options.user }],
@@ -279,7 +303,7 @@ async function complete(
279
303
  system: options.system,
280
304
  maxTokens: options.maxTokens ?? config.maxTokens,
281
305
  temperature: options.temperature ?? 0.7,
282
- reasoningEffort: ReasoningEffortId(config.reasoningEffort ?? 'off'),
306
+ reasoningEffort: ReasoningEffortId(options.reasoning ?? config.reasoningEffort ?? 'off'),
283
307
  }
284
308
  const assembler = new BlockAssembler()
285
309
  for await (const chunk of ctx.llm.stream(request)) {
@@ -458,6 +482,7 @@ export async function extractBible(ctx: Context, config: NovelConfig, outline: s
458
482
  user,
459
483
  temperature: 0.4,
460
484
  maxTokens: Math.max(config.maxTokens, 16000),
485
+ reasoning: config.analysisReasoning ?? 'low',
461
486
  })
462
487
  const raw = parseJsonObject<{
463
488
  genre?: unknown
@@ -1040,7 +1065,7 @@ export async function suggestPlotlines(ctx: Context, config: NovelConfig, projec
1040
1065
  : '',
1041
1066
  '只输出 JSON 数组。',
1042
1067
  ].join('\n\n')
1043
- const text = await complete(ctx, config, { system, user, temperature: 0.6, maxTokens: Math.max(config.maxTokens, 4000) })
1068
+ const text = await complete(ctx, config, { system, user, temperature: 0.6, maxTokens: Math.max(config.maxTokens, 4000), reasoning: config.analysisReasoning ?? 'low' })
1044
1069
  const raw = parseJsonArray<Record<string, unknown>>(text)
1045
1070
  const lines: Plotline[] = []
1046
1071
  const kinds = new Set(['main', 'branch', 'character', 'mystery'])
@@ -1088,7 +1113,7 @@ export async function refreshPlotlineProgress(
1088
1113
  '只输出 JSON 对象。',
1089
1114
  ].join('\n\n')
1090
1115
  const raw = parseJsonObject<{ progress?: unknown }>(
1091
- await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 2000) }),
1116
+ await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 2000) , reasoning: config.analysisReasoning ?? 'low' }),
1092
1117
  )
1093
1118
  return typeof raw.progress === 'string' ? raw.progress.trim().slice(0, 300) : ''
1094
1119
  }
@@ -1103,6 +1128,8 @@ export async function extractRoles(
1103
1128
  '你是一位网文角色库管理员。请根据本书的大纲、设定、编年录与章节摘要,提炼完整的角色库。',
1104
1129
  '覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如"矮胖姑娘")用其身份简称收录并标注。',
1105
1130
  '数量控制:最多输出 10 个角色,宁缺毋滥;路人级一次带过的不要收录。',
1131
+ '重要:正文中若有明确的主角与主要反派,必须分别以 protagonist / antagonist 收录,禁止遗漏;反派确实未出场时才可省略。',
1132
+ '输出优先级:主角(protagonist)与主要反派(antagonist)必须优先输出并完整刻画,其次女主/重要配角;判断不出名字时用正文中的身份称呼。',
1106
1133
  '每个角色输出:',
1107
1134
  '1. name:角色名(或身份简称)。主角/有名配角必须用正文中实际出现的人名(如「沈放」),禁止用「主角(38岁超市理货员)」这类把身份塞进名字的占位名;正文确实没点名时才可用身份简称(如「富商」「灰衣老人」)。',
1108
1135
  '2. roleLabel:定位——protagonist=主角;female_lead=女主(唯一知己/感情线核心,无后宫前提下只此一位);female_support=重要女配;support=普通配角;antagonist=反派;extra=路人/背景。',
@@ -1120,13 +1147,23 @@ export async function extractRoles(
1120
1147
  ].join('\n')
1121
1148
  const written = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating')
1122
1149
  const existingRoles = project.roles ?? []
1123
- // 正文摘录:让提炼真正读到书的内容(角色姓名/身份/关系以正文为准)。
1150
+ // 正文摘录:均匀采样覆盖全书(开头/中间/结尾),避免只取前几章漏掉后期才出场的重要角色。
1151
+ const sampleChapters: ChapterPlan[] = written.length <= 6
1152
+ ? written
1153
+ : (() => {
1154
+ const picked = new Set<number>()
1155
+ for (let i = 0; i < 3 && i < written.length; i++) picked.add(i)
1156
+ const step = Math.max(1, Math.floor(written.length / 4))
1157
+ for (let i = step; i < written.length - 2; i += step) picked.add(i)
1158
+ for (let i = Math.max(0, written.length - 2); i < written.length; i++) picked.add(i)
1159
+ return [...picked].sort((a, b) => a - b).map(i => written[i])
1160
+ })()
1124
1161
  const excerptParts: string[] = []
1125
- for (const chapter of written.slice(0, 3)) {
1162
+ for (const chapter of sampleChapters) {
1126
1163
  const body = readChapterFile(config.outputDir, chapter)
1127
1164
  if (body === undefined) continue
1128
1165
  const text = body.replace(/^#.*$/gm, '').trim()
1129
- if (text.length > 0) excerptParts.push(`第${chapter.no}章《${chapter.title}》\n${text.slice(0, 2400)}`)
1166
+ if (text.length > 0) excerptParts.push(`第${chapter.no}章《${chapter.title}》\n${text.slice(0, 1500)}`)
1130
1167
  }
1131
1168
  const user = [
1132
1169
  `书名:《${project.bookName}》`,
@@ -1141,15 +1178,24 @@ export async function extractRoles(
1141
1178
  ? `已有角色卡(补充信息):\n${project.bible.characters.map(c => `- ${c.name}(${c.role}):${c.traits.join('、')}${c.goals !== '' ? `;目标:${c.goals}` : ''}`).join('\n')}`
1142
1179
  : '',
1143
1180
  (project.facts ?? []).length > 0
1144
- ? `编年录(最近 60 条):\n${(project.facts ?? []).slice(-60).map(f => `[第${f.chapterNo}章] ${f.text.slice(0, 80)}`).join('\n')}`
1181
+ ? `编年录(最近 30 条):\n${(project.facts ?? []).slice(-30).map(f => `[第${f.chapterNo}章] ${f.text.slice(0, 60)}`).join('\n')}`
1145
1182
  : '',
1146
1183
  written.length > 0
1147
1184
  ? `已写章节标题(${written.length} 章):\n${written.map(c => `第${c.no}章《${c.title}》`).join('、')}`
1148
1185
  : '',
1149
1186
  '只输出 JSON 数组。',
1150
1187
  ].join('\n\n')
1151
- const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 24000) })
1152
- const raw = parseJsonArray<Record<string, unknown>>(text)
1188
+ let text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 24000), reasoning: config.analysisReasoning ?? 'low' })
1189
+ let raw = parseJsonArray<Record<string, unknown>>(text)
1190
+ const hasProtagonist = raw.some(e => typeof e === 'object' && e !== null && e.roleLabel === 'protagonist')
1191
+ if (raw.length === 0 || !hasProtagonist) {
1192
+ // LLM 偶发输出非 JSON / 空数组 / 漏主角:重试一次(追加明确指令),避免静默返回空或缺主角的候选。
1193
+ const hint = raw.length === 0
1194
+ ? '\n上一次输出为空或格式不正确。请直接输出 JSON 数组(即使只有一个角色也要输出),不要输出其他文字。'
1195
+ : '\n上一次输出中缺少主角(roleLabel 为 protagonist 的角色)。请重新输出完整 JSON 数组,务必包含正文中的主角。'
1196
+ text = await complete(ctx, config, { system: system + hint, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 24000), reasoning: config.analysisReasoning ?? 'low' })
1197
+ raw = parseJsonArray<Record<string, unknown>>(text)
1198
+ }
1153
1199
  const labels = new Set(['protagonist', 'female_lead', 'female_support', 'support', 'antagonist', 'extra'])
1154
1200
  const strArr = (v: unknown): string[] => Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '') : []
1155
1201
  const roles: RoleRecord[] = []
@@ -1218,7 +1264,7 @@ export async function extractScenes(
1218
1264
  : '',
1219
1265
  '只输出 JSON 数组。',
1220
1266
  ].filter(s => s !== '').join('\n')
1221
- const text = await complete(ctx, config, { system, user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 12000) })
1267
+ const text = await complete(ctx, config, { system, user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low' })
1222
1268
  const raw = parseJsonArray<Record<string, unknown>>(text)
1223
1269
  const strArr = (v: unknown): string[] => Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '') : []
1224
1270
  const str = (v: unknown): string => typeof v === 'string' ? v.trim() : ''
@@ -1246,15 +1292,18 @@ export async function extractScenes(
1246
1292
  return scenes
1247
1293
  }
1248
1294
 
1249
- /** 从 txt/md 全本拆分章节并建立项目:正文落盘为章节文件,status=written(待审稿)。 */
1250
- export function importBookText(
1251
- filePath: string,
1252
- outputDir: string,
1253
- ): { bookName: string; chapters: number; skipped: string[] } {
1254
- const raw = readFileSync(filePath, 'utf8')
1295
+
1296
+
1297
+
1298
+
1299
+ /** 从 txt/md 全本文本拆章(纯逻辑,不落盘):识别章节头、剥离重复标题、去重、排序并统一重新编号。 */
1300
+ export function splitBookText(raw: string): Array<{ no: number; title: string; body: string }> {
1255
1301
  const lines = raw.split(/\r?\n/)
1256
- // 拆章:识别"第X章/第X回/第X节/第X卷"(可带 # 标题前缀),后接可选标题。
1302
+ // 拆章:中文「第X章/回/节/卷」、中文数字章节(一、二、三…)、特殊章节(序章/楔子/尾声/番外…)、英文 Chapter N(均可带 # 前缀)。
1257
1303
  const chapterHead = /^\s*(?:#\s*)?第\s*(\d+|[一二三四五六七八九十百千]+)\s*[章回节卷]\s*(.*?)\s*$/
1304
+ const cnOnlyHead = /^\s*(?:#\s*)?([一二三四五六七八九十百千万零〇]{1,6})(?:[、..::]\s*(.*?)\s*)?$/
1305
+ const specialHead = /^\s*(?:#\s*)?(序章|序言|楔子|引子|前言|开篇|尾声|终章|大结局|番外(?:篇|章)?|后记|完结感言|上架感言|作者的话)\s*[::、.\s]*(.*?)\s*$/
1306
+ const enHead = /^\s*(?:#\s*)?chapter\s+(\d+)\s*[.::、\-\s]*(.*?)\s*$/i
1258
1307
  const cnNum: Record<string, number> = { 一:1,二:2,三:3,四:4,五:5,六:6,七:7,八:8,九:9,十:10,百:100,千:1000 }
1259
1308
  const parseCn = (s: string): number => {
1260
1309
  if (/^\d+$/.test(s)) return Number(s)
@@ -1266,29 +1315,92 @@ export function importBookText(
1266
1315
  }
1267
1316
  return total + section
1268
1317
  }
1269
- const chunks: Array<{ no: number; title: string; body: string[] }> = []
1270
- let current: { no: number; title: string; body: string[] } | null = null
1318
+ interface Chunk { sortKey: number; title: string; body: string[] }
1319
+ const chunks: Chunk[] = []
1320
+ let current: Chunk | null = null
1321
+ let specialSeq = 0
1271
1322
  for (const line of lines) {
1272
1323
  const m = chapterHead.exec(line)
1273
1324
  if (m !== null) {
1274
- if (current !== null) chunks.push(current)
1275
1325
  const no = parseCn(m[1])
1276
- const title = (m[2] ?? '').trim() || ''
1277
- current = { no: no > 0 ? no : chunks.length + 1, title, body: [] }
1278
- } else if (current !== null) {
1279
- current.body.push(line)
1326
+ if (current !== null) chunks.push(current)
1327
+ current = { sortKey: no > 0 ? no : chunks.length + 1, title: (m[2] ?? '').trim(), body: [] }
1328
+ continue
1329
+ }
1330
+ const mc = cnOnlyHead.exec(line)
1331
+ if (mc !== null) {
1332
+ const no = parseCn(mc[1])
1333
+ if (current !== null) chunks.push(current)
1334
+ const t = (mc[2] ?? '').trim()
1335
+ current = { sortKey: no > 0 ? no : chunks.length + 1, title: t !== '' ? t : '第' + mc[1] + '章', body: [] }
1336
+ continue
1280
1337
  }
1338
+ const ms = specialHead.exec(line)
1339
+ if (ms !== null) {
1340
+ specialSeq++
1341
+ const name = ms[1]
1342
+ const front = /序章|序言|楔子|引子|前言|开篇/.test(name)
1343
+ const title = (ms[2] ?? '').trim()
1344
+ if (current !== null) chunks.push(current)
1345
+ current = { sortKey: front ? specialSeq * 0.001 : 999999 + specialSeq * 0.001, title: title !== '' ? title : name, body: [] }
1346
+ continue
1347
+ }
1348
+ const me = enHead.exec(line)
1349
+ if (me !== null) {
1350
+ if (current !== null) chunks.push(current)
1351
+ current = { sortKey: Number(me[1]), title: (me[2] ?? '').trim(), body: [] }
1352
+ continue
1353
+ }
1354
+ if (current !== null) current.body.push(line)
1281
1355
  }
1282
1356
  if (current !== null) chunks.push(current)
1283
- if (chunks.length === 0) throw new Error('未识别到章节(需要"第X章"格式,或带 # 的章节标题)')
1284
- const seen = new Set<number>()
1285
- const ordered = chunks.filter(c => { if (seen.has(c.no)) return false; seen.add(c.no); return true }).sort((a, b) => a.no - b.no)
1286
- const bookName = basename(filePath, extname(filePath)).slice(0, 40) || '导入小说'
1357
+ if (chunks.length === 0) throw new Error('未识别到章节(需要"第X章"格式、中文数字章节(一、二、三…)、序章/楔子/尾声等章节标题、英文 Chapter N,或带 # 的章节标题)')
1358
+ // 章节头重复行(如 "第1章 xxx" 与 "# 第1章 xxx" 同现、空行)先从正文剥离。
1359
+ const stripHead = (b: string[]): string => {
1360
+ let i = 0
1361
+ while (i < b.length) {
1362
+ const t = b[i].trim()
1363
+ if (t === '' || chapterHead.test(t) || cnOnlyHead.test(t) || specialHead.test(t) || enHead.test(t)) i++
1364
+ else break
1365
+ }
1366
+ return b.slice(i).join('\n').trim()
1367
+ }
1368
+ // 同编号(目录+正文重复)保留正文最长的一份,再按位置排序后统一重新编号。
1369
+ const byKey = new Map<number, Chunk>()
1370
+ for (const c of chunks) {
1371
+ const len = stripHead(c.body).length
1372
+ const ex = byKey.get(c.sortKey)
1373
+ if (ex === undefined || len > stripHead(ex.body).length) byKey.set(c.sortKey, c)
1374
+ }
1375
+ const ordered = [...byKey.values()].sort((a, b) => a.sortKey - b.sortKey)
1376
+ return ordered.map((c, i) => ({ no: i + 1, title: c.title, body: stripHead(c.body) }))
1377
+ }
1378
+
1379
+ /** 拆章预览(不落盘):章节编号/标题/字数 + 跳过清单。 */
1380
+ export function previewBookText(raw: string): { chapters: Array<{ no: number; title: string; chars: number }>; skipped: string[] } {
1381
+ const chapters: Array<{ no: number; title: string; chars: number }> = []
1382
+ const skipped: string[] = []
1383
+ for (const c of splitBookText(raw)) {
1384
+ if (c.body.length < 50) {
1385
+ skipped.push('第' + c.no + '章' + (c.title !== '' ? '「' + c.title + '」' : '') + '(内容过短,已跳过)')
1386
+ continue
1387
+ }
1388
+ chapters.push({ no: c.no, title: c.title !== '' ? c.title : '第' + c.no + '章', chars: c.body.length })
1389
+ }
1390
+ return { chapters, skipped }
1391
+ }
1392
+
1393
+ /** 从全本文本导入(浏览器上传 / 服务器文件共用):建项目、写章节文件、保存。 */
1394
+ export function importBookTextFromText(
1395
+ raw: string,
1396
+ outputDir: string,
1397
+ bookName: string,
1398
+ ): { bookName: string; chapters: number; skipped: string[] } {
1287
1399
  const project = createProject(bookName)
1288
1400
  mkdirSync(outputDir, { recursive: true })
1289
1401
  const skipped: string[] = []
1290
- for (const c of ordered) {
1291
- const body = c.body.join('\n').trim()
1402
+ for (const c of splitBookText(raw)) {
1403
+ const body = c.body
1292
1404
  if (body.length < 50) { skipped.push('第' + c.no + '章' + (c.title !== '' ? '「' + c.title + '」' : '') + '(内容过短,已跳过)'); continue }
1293
1405
  const chapter: ChapterPlan = {
1294
1406
  no: c.no, volume: 0, title: c.title !== '' ? c.title : '第' + c.no + '章', beats: '', targetChars: 0,
@@ -1304,6 +1416,16 @@ export function importBookText(
1304
1416
  return { bookName, chapters: project.chapters.length, skipped }
1305
1417
  }
1306
1418
 
1419
+ /** 从 txt/md 全本文件导入:编码自适应读取后拆章建项目,status=written(待审稿)。 */
1420
+ export function importBookText(
1421
+ filePath: string,
1422
+ outputDir: string,
1423
+ ): { bookName: string; chapters: number; skipped: string[] } {
1424
+ const raw = decodeTextSmart(readFileSync(filePath))
1425
+ const bookName = basename(filePath, extname(filePath)).slice(0, 40) || '导入小说'
1426
+ return importBookTextFromText(raw, outputDir, bookName)
1427
+ }
1428
+
1307
1429
  /** 动漫形象描述词(中文描述 + 英文 booru 标签 + 关键外貌标签)。 */
1308
1430
  export interface RoleVisualPrompt {
1309
1431
  zh: string
@@ -1408,7 +1530,7 @@ export async function extractRoleVisual(
1408
1530
  ...excerpts.map(e => `[第${e.no}章] ${e.text}`),
1409
1531
  '只输出 JSON 对象。',
1410
1532
  ].join('\n\n')
1411
- const text = await complete(ctx, config, { system, user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 12000) })
1533
+ const text = await complete(ctx, config, { system, user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low' })
1412
1534
  const raw = parseJsonObject<{ zh?: unknown; en?: unknown; tags?: unknown; source?: unknown; expressions?: unknown; promptKit?: unknown }>(text)
1413
1535
  const zh = typeof raw.zh === 'string' ? raw.zh.trim().slice(0, 500) : ''
1414
1536
  const en = typeof raw.en === 'string' ? raw.en.trim().slice(0, 1500) : ''
@@ -1483,7 +1605,7 @@ export async function generateRolePromptKit(
1483
1605
  `表情清单:${(role.expressions ?? []).join('、') || '(未提供,按角色气质推断 6 个)'}`,
1484
1606
  '只输出 JSON 对象。',
1485
1607
  ].join('\n\n')
1486
- const text = await complete(ctx, config, { system, user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 12000) })
1608
+ const text = await complete(ctx, config, { system, user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low' })
1487
1609
  const raw = parseJsonObject<Record<string, unknown>>(text)
1488
1610
  const str = (v: unknown): string => typeof v === 'string' ? v.trim() : ''
1489
1611
  const pair = (v: unknown): { zh: string; en: string } => {
@@ -1533,7 +1655,7 @@ export async function extractVisualRules(
1533
1655
  : '',
1534
1656
  '只输出 JSON 数组。',
1535
1657
  ].filter(s => s !== '').join('\n\n')
1536
- const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 4000) })
1658
+ const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 4000), reasoning: config.analysisReasoning ?? 'low' })
1537
1659
  const raw = parseJsonArray<string>(text)
1538
1660
  return raw.map(r => typeof r === 'string' ? r.trim().slice(0, 80) : '').filter(r => r !== '').slice(0, 8)
1539
1661
  }
@@ -1580,7 +1702,7 @@ export async function suggestOutlines(
1580
1702
  `请生成 ${n} 个大纲方案。`,
1581
1703
  '只输出 JSON 数组。',
1582
1704
  ].join('\n\n')
1583
- const text = await complete(ctx, config, { system, user, temperature: 0.85, maxTokens: Math.max(config.maxTokens, 12000) })
1705
+ const text = await complete(ctx, config, { system, user, temperature: 0.85, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low' })
1584
1706
  const parsed = parseJsonArray<Record<string, unknown>>(text)
1585
1707
  const candidates: OutlineCandidate[] = []
1586
1708
  for (const entry of parsed) {
@@ -1655,7 +1777,7 @@ export async function breakdownBook(
1655
1777
  for (const ch of chunks) {
1656
1778
  const noteUser = [`第${ch.no}章《${ch.title}》`, '正文:', ch.body.slice(0, 3000)].join('\n')
1657
1779
  try {
1658
- const text = await complete(ctx, config, { system: noteSystem, user: noteUser, temperature: 0.2, maxTokens: Math.max(config.maxTokens, 3000) })
1780
+ const text = await complete(ctx, config, { system: noteSystem, user: noteUser, temperature: 0.2, maxTokens: Math.max(config.maxTokens, 3000), reasoning: config.analysisReasoning ?? 'low' })
1659
1781
  const raw = parseJsonObject<Record<string, unknown>>(text)
1660
1782
  const pick = (k: string): string[] => Array.isArray(raw[k]) ? raw[k].filter((x): x is string => typeof x === 'string' && x.trim() !== '').map(x => x.trim().slice(0, 120)).slice(0, 4) : []
1661
1783
  notes.push(
@@ -1745,6 +1867,7 @@ export async function breakdownBook(
1745
1867
  user: `分析范围:${selected.length} 章(${scope === 'all' ? '全书' : scope === 'recent' ? '最近 20 章' : '指定卷'})。\n\n章节笔记:\n${notesText}`,
1746
1868
  temperature: 0.3,
1747
1869
  maxTokens: Math.max(config.maxTokens, 6000),
1870
+ reasoning: config.analysisReasoning ?? 'low',
1748
1871
  })
1749
1872
  const raw = parseJsonObject<{ markdown?: unknown; structured?: unknown }>(text)
1750
1873
  sections.push({
@@ -2321,6 +2444,54 @@ export async function summarizeChapter(
2321
2444
  return chapter.summary
2322
2445
  }
2323
2446
 
2447
+ /**
2448
+ * 反向推大纲:从已写章节正文反推出全书总纲(分卷 + 章节要点 + 主线/人物弧线/伏笔清单)。
2449
+ * 两阶段:分批提取章节事件摘要 → 汇总生成大纲。不修改章节/设定,只返回大纲文本。
2450
+ */
2451
+ export async function reverseOutlineFromChapters(
2452
+ ctx: Context,
2453
+ config: NovelConfig,
2454
+ project: ProjectState,
2455
+ outputDir: string,
2456
+ onProgress?: (done: number, total: number, phase: string) => void,
2457
+ ): Promise<string> {
2458
+ const written = project.chapters
2459
+ .filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error')
2460
+ .filter(c => readChapterFile(outputDir, c) !== undefined)
2461
+ .sort((a, b) => a.no - b.no)
2462
+ if (written.length === 0) throw new Error('本书还没有已写章节,无法反推大纲')
2463
+
2464
+ // 阶段 1:分批提取章节事件摘要(每批 10 章,正文各取前 1000 字控制成本)。
2465
+ const BATCH = 10
2466
+ const notes: string[] = []
2467
+ const total = written.length
2468
+ for (let i = 0; i < written.length; i += BATCH) {
2469
+ const batch = written.slice(i, i + BATCH)
2470
+ const bodies = batch.map(c => {
2471
+ const body = readChapterFile(outputDir, c) ?? ''
2472
+ return '第' + c.no + '章《' + (c.title || '无题') + '》\n' + body.replace(/^#\s+.*$/m, '').trim().slice(0, 1000)
2473
+ }).join('\n\n---\n\n')
2474
+ const system = '你是一位网文编辑。下面是一本书若干章正文的节选。请为每一章输出一行「事件摘要」,格式严格为:第N章《标题》:关键事件+主角状态变化+新增伏笔或线索。每章恰好一行,不要空行,不要评价,不要输出其他内容。'
2475
+ const note = await complete(ctx, config, { system, user: bodies, temperature: 0.2, maxTokens: Math.max(config.maxTokens, 3000), reasoning: config.analysisReasoning ?? 'low' })
2476
+ notes.push(note.trim())
2477
+ onProgress?.(Math.min(i + BATCH, total), total, '章节摘要')
2478
+ }
2479
+
2480
+ // 阶段 2:汇总反推总纲。
2481
+ onProgress?.(total, total, '生成大纲')
2482
+ const system2 = [
2483
+ '你是一位经验丰富的小说主编。根据下面全书各章事件摘要,反推出这本书的总纲(大纲),可直接作为后续写作依据。要求:',
2484
+ '1. 第一行写《书名》(从摘要中的书名或内容推断,若无则用《未命名》)。',
2485
+ '2. 按故事弧线划分卷/部分:每卷给出卷名与主旨(标注覆盖章节范围)。',
2486
+ '3. 每一章列出:章节号 + 标题 + 一句话核心情节(若原章无标题可自拟)。',
2487
+ '4. 最后给出:全书主线、主要人物弧线、已埋设待回收的伏笔清单。',
2488
+ '5. 输出为纯文本 Markdown 结构(# 一级标题、## 二级标题、- 列表),不要多余寒暄。',
2489
+ ].join('\n')
2490
+ const outline = await complete(ctx, config, { system: system2, user: notes.join('\n\n'), temperature: 0.4, maxTokens: Math.max(config.maxTokens, 6000), reasoning: config.analysisReasoning ?? 'low' })
2491
+ onProgress?.(total, total, '完成')
2492
+ return outline.trim()
2493
+ }
2494
+
2324
2495
  /**
2325
2496
  * 摘要 + 事实抽取合并为一次 LLM 调用(省一次调用与一次正文输入,
2326
2497
  * 批量生成时整体开销约省 25%)。
@@ -2561,7 +2732,7 @@ export async function generateBlurb(
2561
2732
  ? `已有开头草稿(请保留其内容与语气,续写补全为完整简介):\n${partial.trim()}`
2562
2733
  : '请全量生成一份完整简介。',
2563
2734
  ].filter(s => s !== '').join('\n\n')
2564
- const text = await complete(ctx, config, { system, user, temperature: 0.7, maxTokens: Math.max(config.maxTokens, 4000) })
2735
+ const text = await complete(ctx, config, { system, user, temperature: 0.7, maxTokens: Math.max(config.maxTokens, 4000), reasoning: config.analysisReasoning ?? 'low' })
2565
2736
  const blurb = text.replace(/^["'「『]|["'」』]$/g, '').replace(/^简介[::]\s*/, '').trim().slice(0, 600)
2566
2737
  return blurb
2567
2738
  }
@@ -2759,7 +2930,7 @@ export async function extractWorld(
2759
2930
  '大纲:\n' + project.outline.slice(0, 5000),
2760
2931
  '只输出 JSON 对象。',
2761
2932
  ].filter(s => s !== '').join('\n\n')
2762
- const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 12000) })
2933
+ const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low' })
2763
2934
  const raw = parseJsonObject<{ realms?: unknown; regions?: unknown; factions?: unknown }>(text)
2764
2935
  const str = (value: unknown): string => typeof value === 'string' ? value.trim() : ''
2765
2936
  const objArray = (value: unknown): Record<string, unknown>[] =>
@@ -2924,7 +3095,7 @@ export async function suggestForeshadows(
2924
3095
  `大纲:\n${project.outline}`,
2925
3096
  `已规划章节数:${project.chapters.length}`,
2926
3097
  ].join('\n')
2927
- const text = await complete(ctx, config, { system: foreshadowSystemPrompt(), user, temperature: 0.5, maxTokens: Math.max(config.maxTokens, 12000) })
3098
+ const text = await complete(ctx, config, { system: foreshadowSystemPrompt(), user, temperature: 0.5, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low' })
2928
3099
  const parsed = parseJsonArray<Record<string, unknown>>(text)
2929
3100
  const existing = new Set(project.foreshadows.map(f => f.description))
2930
3101
  const created: Foreshadow[] = []
package/src/index.ts CHANGED
@@ -48,6 +48,8 @@ export interface Config {
48
48
  model?: string
49
49
  /** LLM reasoning effort (off/low/high/max). */
50
50
  reasoningEffort?: 'off' | 'low' | 'high' | 'max'
51
+ /** 分析类任务(提炼/拆书/反推大纲等)的推理档位;默认 low。 */
52
+ analysisReasoning?: 'off' | 'low' | 'high' | 'max'
51
53
  /** Target characters per chapter. */
52
54
  chapterChars?: number
53
55
  /** Max output tokens per chapter call. */
@@ -76,6 +78,7 @@ export const Config: z<Config> = z.object({
76
78
  provider: z.string().default('deepseek-official'),
77
79
  model: z.string().default('deepseek-v4-flash'),
78
80
  reasoningEffort: z.union(['off', 'low', 'high', 'max']).default('off'),
81
+ analysisReasoning: z.union(['off', 'low', 'high', 'max']).default('low'),
79
82
  chapterChars: z.number().default(3500),
80
83
  maxTokens: z.number().default(12000),
81
84
  reviewPassScore: z.number().default(70),
@@ -94,6 +97,7 @@ const DEFAULT_OUTPUT_DIR = join(homedir(), '.dsh', 'novels')
94
97
  const DEFAULT_PROVIDER = 'deepseek-official'
95
98
  const DEFAULT_MODEL = 'deepseek-v4-flash'
96
99
  const DEFAULT_REASONING_EFFORT = 'off' as const
100
+ const DEFAULT_ANALYSIS_REASONING = 'low' as const
97
101
  const DEFAULT_CHAPTER_CHARS = 3500
98
102
  const DEFAULT_MAX_TOKENS = 12000
99
103
  const DEFAULT_REVIEW_PASS_SCORE = 70
@@ -115,6 +119,7 @@ export function resolveConfig(value: Partial<Config> | undefined): NovelConfig {
115
119
  provider: value?.provider ?? DEFAULT_PROVIDER,
116
120
  model: value?.model ?? DEFAULT_MODEL,
117
121
  reasoningEffort: value?.reasoningEffort ?? DEFAULT_REASONING_EFFORT,
122
+ analysisReasoning: value?.analysisReasoning ?? DEFAULT_ANALYSIS_REASONING,
118
123
  chapterChars: value?.chapterChars ?? DEFAULT_CHAPTER_CHARS,
119
124
  maxTokens: value?.maxTokens ?? DEFAULT_MAX_TOKENS,
120
125
  reviewPassScore: value?.reviewPassScore ?? DEFAULT_REVIEW_PASS_SCORE,
@@ -153,6 +158,7 @@ export function apply(ctx: Context, config?: Config): void {
153
158
  if (patch.provider !== undefined) next.provider = patch.provider
154
159
  if (patch.model !== undefined) next.model = patch.model
155
160
  if (patch.reasoningEffort !== undefined) next.reasoningEffort = patch.reasoningEffort
161
+ if (patch.analysisReasoning !== undefined) next.analysisReasoning = patch.analysisReasoning
156
162
  if (patch.chapterChars !== undefined) next.chapterChars = patch.chapterChars
157
163
  if (patch.maxTokens !== undefined) next.maxTokens = patch.maxTokens
158
164
  if (patch.reviewPassScore !== undefined) next.reviewPassScore = patch.reviewPassScore
package/src/protocol.ts CHANGED
@@ -40,6 +40,8 @@ export const NOVEL_API = {
40
40
  bookshelfImportDir: '/api/dsh-novel-forge/bookshelf/import-dir',
41
41
  /** 导入 txt/md 全本:拆章建项目并登记书架。 */
42
42
  bookshelfImportText: '/api/dsh-novel-forge/bookshelf/import-text',
43
+ /** 导入 txt/md 全本:拆章预览(不落盘)。 */
44
+ bookshelfImportTextPreview: '/api/dsh-novel-forge/bookshelf/import-text/preview',
43
45
  /** 重置项目(可选携带新大纲):清空设定/卷/章节/伏笔/资产/事实库。 */
44
46
  reset: '/api/dsh-novel-forge/reset',
45
47
  /** 全书一致性质检:LLM 扫描已生成章节,输出矛盾问题清单。 */
@@ -74,6 +76,8 @@ export const NOVEL_API = {
74
76
  sensitiveCheck: '/api/dsh-novel-forge/sensitive-check',
75
77
  /** 开书想法 → AI 补全大纲:输入一句话想法,生成 2-3 个可选大纲方案。 */
76
78
  outlineSuggest: '/api/dsh-novel-forge/outline/suggest',
79
+ /** 反推大纲:从已写章节正文反向生成全书总纲(NDJSON 流)。 */
80
+ outlineReverse: '/api/dsh-novel-forge/outline/reverse',
77
81
  /** 拆书分析:对已写章节做结构/人物/文风/卖点四维体检(两阶段:源笔记→分节分析)。 */
78
82
  breakdown: '/api/dsh-novel-forge/breakdown',
79
83
  /** 漫剧分镜生成:章节 → 角色锚点 + 分镜表(可适配豆包/Seedance/SD)。 */
@@ -147,10 +151,17 @@ export interface BookImportDirResponse {
147
151
  existed: boolean
148
152
  }
149
153
 
150
- /** POST /bookshelf/import-text 请求:导入 txt/md 全本。 */
154
+ /** POST /bookshelf/import-text 请求:导入 txt/md 全本。两种模式二选一:
155
+ * - filePath:服务器本地文件路径;
156
+ * - text + fileName:浏览器上传的全文内容(fileName 用于推断书名)。
157
+ */
151
158
  export interface BookImportTextRequest {
152
- /** 源文件绝对路径(txt 或 md)。 */
153
- filePath: string
159
+ /** 模式一:源文件绝对路径(txt 或 md)。 */
160
+ filePath?: string
161
+ /** 模式二:浏览器上传的全文内容。 */
162
+ text?: string
163
+ /** 模式二:原文件名(txt/md),用于推断书名与显示。 */
164
+ fileName?: string
154
165
  /** 输出目录;缺省为 ~/.dsh/novels/书名。 */
155
166
  outputDir?: string
156
167
  }
@@ -166,6 +177,23 @@ export interface BookImportTextResponse {
166
177
  book: BookEntry
167
178
  }
168
179
 
180
+ /** POST /bookshelf/import-text/preview 请求:上传全文做拆章预览(不落盘)。 */
181
+ export interface BookImportTextPreviewRequest {
182
+ text: string
183
+ /** 原文件名(txt/md),用于推断书名。 */
184
+ fileName?: string
185
+ }
186
+
187
+ /** POST /bookshelf/import-text/preview 响应。 */
188
+ export interface BookImportTextPreviewResponse {
189
+ /** 预计书名(fileName 去扩展名)。 */
190
+ bookName: string
191
+ /** 识别出的章节(已按正文长度过滤过短章节)。 */
192
+ chapters: Array<{ no: number; title: string; chars: number }>
193
+ /** 因内容过短被跳过的章节标题列表。 */
194
+ skipped: string[]
195
+ }
196
+
169
197
 
170
198
  /** Chapter lifecycle states (the writing pipeline's state machine). */
171
199
  export type ChapterStatus =
@@ -848,6 +876,8 @@ export interface NovelConfig {
848
876
  model: string
849
877
  /** LLM reasoning effort: off = no thinking; low/high/max = thinking intensity. */
850
878
  reasoningEffort: 'off' | 'low' | 'high' | 'max'
879
+ /** 分析类任务(提炼/拆书/反推大纲等)的推理档位;默认 low,不受上面写作档位影响。 */
880
+ analysisReasoning: 'off' | 'low' | 'high' | 'max'
851
881
  /** Target characters per chapter. */
852
882
  chapterChars: number
853
883
  /** Max output tokens per chapter call. */
@@ -994,6 +1024,8 @@ export type JobFrame =
994
1024
  | { type: 'rewritten'; no: number; file: string; chars: number }
995
1025
  /** 润色/重写完成,产物作为待确认草稿(尚未覆盖正文)。 */
996
1026
  | { type: 'drafted'; no: number; chars: number; draft: string }
1027
+ | { type: 'outline-progress'; done: number; total: number; phase: string }
1028
+ | { type: 'outline-done'; outline: string; chars: number }
997
1029
  | { type: 'error'; no: number; message: string }
998
1030
 
999
1031
  /** POST /review request: review one written chapter. */
@@ -1070,6 +1102,7 @@ export interface ConfigPatch {
1070
1102
  provider?: string
1071
1103
  model?: string
1072
1104
  reasoningEffort?: 'off' | 'low' | 'high' | 'max'
1105
+ analysisReasoning?: 'off' | 'low' | 'high' | 'max'
1073
1106
  chapterChars?: number
1074
1107
  maxTokens?: number
1075
1108
  reviewPassScore?: number