@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/lib/client.js +825 -564
- package/lib/client.js.map +1 -1
- package/lib/index.js +330 -58
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +7 -1
- package/lib/types/client/locales.d.ts +4 -2
- package/lib/types/engine.d.ts +27 -1
- package/lib/types/index.d.ts +2 -0
- package/lib/types/protocol.d.ts +45 -3
- package/package.json +1 -1
- package/src/client/api.ts +16 -1
- package/src/client/locales.ts +7 -3
- package/src/client/panel/ImportModal.tsx +123 -21
- package/src/client/panel/NovelPanel.tsx +221 -147
- package/src/client/panel/panel.module.css +77 -0
- package/src/engine.ts +210 -39
- package/src/index.ts +6 -0
- package/src/protocol.ts +36 -3
- package/src/routes.ts +88 -11
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
|
|
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,
|
|
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
|
-
? `编年录(最近
|
|
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
|
-
|
|
1152
|
-
|
|
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
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1270
|
-
|
|
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
|
-
|
|
1277
|
-
current = {
|
|
1278
|
-
|
|
1279
|
-
|
|
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
|
-
|
|
1285
|
-
const
|
|
1286
|
-
|
|
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
|
|
1291
|
-
const body = c.body
|
|
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
|
-
/**
|
|
153
|
-
filePath
|
|
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
|