@waterwx/dsh-novel-forge 0.1.0
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/LICENSE +202 -0
- package/README.md +180 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.js +3924 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +3120 -0
- package/lib/index.js.map +1 -0
- package/lib/types/assets.d.ts +35 -0
- package/lib/types/assistant.d.ts +43 -0
- package/lib/types/bookshelf.d.ts +35 -0
- package/lib/types/client/api.d.ts +68 -0
- package/lib/types/client/docx.d.ts +15 -0
- package/lib/types/client/index.d.ts +14 -0
- package/lib/types/client/locales.d.ts +139 -0
- package/lib/types/client/mount.d.ts +9 -0
- package/lib/types/client/panel/AssetsTab.d.ts +7 -0
- package/lib/types/client/panel/AssistantTab.d.ts +7 -0
- package/lib/types/client/panel/BookshelfBar.d.ts +11 -0
- package/lib/types/client/panel/NovelPanel.d.ts +13 -0
- package/lib/types/client/panel/controller.d.ts +19 -0
- package/lib/types/client/panel/helpers.d.ts +8 -0
- package/lib/types/client/sidebar-entry.d.ts +13 -0
- package/lib/types/docx.d.ts +19 -0
- package/lib/types/engine.d.ts +95 -0
- package/lib/types/index.d.ts +55 -0
- package/lib/types/protocol.d.ts +521 -0
- package/lib/types/routes.d.ts +29 -0
- package/package.json +105 -0
- package/src/assets.ts +518 -0
- package/src/assistant.ts +547 -0
- package/src/bookshelf.ts +137 -0
- package/src/client/api.ts +254 -0
- package/src/client/css-modules.d.ts +8 -0
- package/src/client/docx.ts +69 -0
- package/src/client/index.ts +34 -0
- package/src/client/locales.ts +271 -0
- package/src/client/mount.tsx +97 -0
- package/src/client/panel/AssetsTab.tsx +341 -0
- package/src/client/panel/AssistantTab.tsx +188 -0
- package/src/client/panel/BookshelfBar.tsx +116 -0
- package/src/client/panel/NovelPanel.tsx +990 -0
- package/src/client/panel/controller.ts +45 -0
- package/src/client/panel/helpers.ts +17 -0
- package/src/client/panel/panel.module.css +894 -0
- package/src/client/sidebar-entry.ts +122 -0
- package/src/docx.ts +83 -0
- package/src/engine.ts +1019 -0
- package/src/index.ts +184 -0
- package/src/protocol.ts +539 -0
- package/src/routes.ts +955 -0
package/src/engine.ts
ADDED
|
@@ -0,0 +1,1019 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Novel engine — the host half's core: LLM-driven story-bible extraction,
|
|
3
|
+
* volume planning, chapter planning, chapter-by-chapter writing with
|
|
4
|
+
* auto-review + rewrite, polish (de-AI-ify), narrative summaries, foreshadow
|
|
5
|
+
* tracking, project persistence, and whole-book export. Pure Node (no
|
|
6
|
+
* web-server dependencies), so routes stay thin and logic is testable.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdirSync, readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { createUserMessage, BlockAssembler, ReasoningEffortId, type GenerateOptions, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
12
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
13
|
+
import { emptyProjectAssets, renderAllAssets, styleEngineSystemPrompt } from './assets.ts'
|
|
14
|
+
import type {
|
|
15
|
+
ChapterPlan,
|
|
16
|
+
CharacterCard,
|
|
17
|
+
Foreshadow,
|
|
18
|
+
NovelConfig,
|
|
19
|
+
ProjectState,
|
|
20
|
+
ReviewReport,
|
|
21
|
+
StoryBible,
|
|
22
|
+
Volume,
|
|
23
|
+
} from './protocol.ts'
|
|
24
|
+
|
|
25
|
+
/** Project state file name inside the output dir. */
|
|
26
|
+
export const PROJECT_FILE = 'novel-project.json'
|
|
27
|
+
|
|
28
|
+
// ------------------------------------------------------------------ helpers
|
|
29
|
+
|
|
30
|
+
/** Sanitize a file name: keep CJK/alphanumerics/space/dash/underscore. */
|
|
31
|
+
function safeFileName(name: string): string {
|
|
32
|
+
return name
|
|
33
|
+
.replace(/[\\/:*?"<>|]/g, '')
|
|
34
|
+
.replace(/\s+/g, ' ')
|
|
35
|
+
.trim()
|
|
36
|
+
.slice(0, 60)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Chapter output file name, e.g. 第001章_开篇.md */
|
|
40
|
+
export function chapterFileName(chapter: ChapterPlan): string {
|
|
41
|
+
const title = safeFileName(chapter.title) || `第${chapter.no}章`
|
|
42
|
+
return `第${String(chapter.no).padStart(3, '0')}章_${title}.md`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Infer a book name from the outline's first non-empty line. */
|
|
46
|
+
export function inferBookName(outline: string): string {
|
|
47
|
+
const line = outline.split('\n').map(l => l.trim()).find(l => l.length > 0)
|
|
48
|
+
return (line ?? '未命名小说').replace(/^《/, '').replace(/》.*$/, '').slice(0, 40)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ------------------------------------------------------------------ project
|
|
52
|
+
|
|
53
|
+
/** Read the persisted project from the output dir (undefined when absent). */
|
|
54
|
+
export function loadProject(outputDir: string): ProjectState | undefined {
|
|
55
|
+
const file = join(outputDir, PROJECT_FILE)
|
|
56
|
+
if (!existsSync(file)) return undefined
|
|
57
|
+
try {
|
|
58
|
+
let rawText = readFileSync(file, 'utf8')
|
|
59
|
+
// Tolerate a UTF-8 BOM (some editors / PowerShell writes add one).
|
|
60
|
+
if (rawText.charCodeAt(0) === 0xFEFF) rawText = rawText.slice(1)
|
|
61
|
+
const raw = JSON.parse(rawText) as ProjectState
|
|
62
|
+
if (typeof raw.outline !== 'string' || !Array.isArray(raw.chapters)) return undefined
|
|
63
|
+
// Normalize legacy projects (foreshadows / assets may be missing).
|
|
64
|
+
if (!Array.isArray(raw.foreshadows)) raw.foreshadows = []
|
|
65
|
+
if (raw.assets === undefined || typeof raw.assets !== 'object') raw.assets = emptyProjectAssets()
|
|
66
|
+
if (!Array.isArray(raw.assets.antiAiRules)) raw.assets.antiAiRules = []
|
|
67
|
+
if (!Array.isArray(raw.assets.auxiliaryProgressions)) raw.assets.auxiliaryProgressions = []
|
|
68
|
+
if (!Array.isArray(raw.assets.styleAssets)) raw.assets.styleAssets = []
|
|
69
|
+
return raw
|
|
70
|
+
} catch {
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Persist the project state next to the chapters. */
|
|
76
|
+
export function saveProject(outputDir: string, project: ProjectState): void {
|
|
77
|
+
mkdirSync(outputDir, { recursive: true })
|
|
78
|
+
writeFileSync(join(outputDir, PROJECT_FILE), JSON.stringify(project, null, 2), 'utf8')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** List generated chapter files in the output dir (sorted). */
|
|
82
|
+
export function listChapterFiles(outputDir: string): string[] {
|
|
83
|
+
if (!existsSync(outputDir)) return []
|
|
84
|
+
try {
|
|
85
|
+
return readdirSync(outputDir)
|
|
86
|
+
.filter(name => /^第\d+章_.*\.md$/.test(name))
|
|
87
|
+
.sort((a, b) => {
|
|
88
|
+
const na = Number(/^第(\d+)章/.exec(a)?.[1] ?? 0)
|
|
89
|
+
const nb = Number(/^第(\d+)章/.exec(b)?.[1] ?? 0)
|
|
90
|
+
return na - nb
|
|
91
|
+
})
|
|
92
|
+
} catch {
|
|
93
|
+
return []
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Re-sync chapter status against files on disk (a file may exist without state). */
|
|
98
|
+
export function syncProjectWithDisk(project: ProjectState, outputDir: string): void {
|
|
99
|
+
const files = new Map<string, string>()
|
|
100
|
+
for (const file of listChapterFiles(outputDir)) {
|
|
101
|
+
const no = Number(/^第(\d+)章/.exec(file)?.[1] ?? 0)
|
|
102
|
+
if (no > 0) files.set(String(no), file)
|
|
103
|
+
}
|
|
104
|
+
for (const chapter of project.chapters) {
|
|
105
|
+
const file = files.get(String(chapter.no))
|
|
106
|
+
if (file !== undefined && (chapter.status === 'pending' || chapter.status === 'generating')) {
|
|
107
|
+
chapter.status = 'written'
|
|
108
|
+
chapter.file = file
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
project.updatedAt = new Date().toISOString()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Read a chapter's markdown body from disk (undefined when missing). */
|
|
115
|
+
export function readChapterFile(outputDir: string, chapter: ChapterPlan): string | undefined {
|
|
116
|
+
if (chapter.file === undefined) return undefined
|
|
117
|
+
const path = join(outputDir, chapter.file)
|
|
118
|
+
if (!existsSync(path)) return undefined
|
|
119
|
+
return readFileSync(path, 'utf8')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Create a fresh project from an outline. */
|
|
123
|
+
export function createProject(outline: string, outlinePath?: string): ProjectState {
|
|
124
|
+
const now = new Date().toISOString()
|
|
125
|
+
return {
|
|
126
|
+
bookName: inferBookName(outline),
|
|
127
|
+
outline,
|
|
128
|
+
outlinePath,
|
|
129
|
+
chapters: [],
|
|
130
|
+
foreshadows: [],
|
|
131
|
+
assets: emptyProjectAssets(),
|
|
132
|
+
createdAt: now,
|
|
133
|
+
updatedAt: now,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ------------------------------------------------------------------- llm
|
|
138
|
+
|
|
139
|
+
/** One complete non-streaming LLM call. */
|
|
140
|
+
async function complete(
|
|
141
|
+
ctx: Context,
|
|
142
|
+
config: NovelConfig,
|
|
143
|
+
options: { system: string; user: string; temperature?: number; maxTokens?: number },
|
|
144
|
+
): Promise<string> {
|
|
145
|
+
const messages: Message[] = [createUserMessage({
|
|
146
|
+
content: [{ type: 'text', text: options.user }],
|
|
147
|
+
source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
|
|
148
|
+
})]
|
|
149
|
+
const request: GenerateOptions = {
|
|
150
|
+
provider: config.provider,
|
|
151
|
+
model: config.model,
|
|
152
|
+
messages,
|
|
153
|
+
system: options.system,
|
|
154
|
+
maxTokens: options.maxTokens ?? config.maxTokens,
|
|
155
|
+
temperature: options.temperature ?? 0.7,
|
|
156
|
+
}
|
|
157
|
+
const assembler = new BlockAssembler()
|
|
158
|
+
for await (const chunk of ctx.llm.stream(request)) {
|
|
159
|
+
assembler.push(chunk)
|
|
160
|
+
}
|
|
161
|
+
const finish = assembler.finish
|
|
162
|
+
if (finish.kind === 'error' || finish.kind === 'aborted') {
|
|
163
|
+
throw new Error(`LLM 调用失败(${finish.kind}): ${finish.failure.message}`)
|
|
164
|
+
}
|
|
165
|
+
if (finish.kind === 'max-tokens') {
|
|
166
|
+
throw new Error('LLM 输出达到 maxTokens 上限,请增大配置后重试')
|
|
167
|
+
}
|
|
168
|
+
const blocks = assembler.blocks()
|
|
169
|
+
// Diagnostics: log the assembled block shape (reasoning-only turns yield no
|
|
170
|
+
// text blocks — the v4-flash model can answer entirely in the reasoning
|
|
171
|
+
// channel, which the adapter surfaces as a reasoning block).
|
|
172
|
+
if (process.env.DSH_NOVEL_DEBUG === '1') {
|
|
173
|
+
console.error('[dsh-novel-forge] complete: finish=%j blocks=%j', JSON.stringify(finish), blocks.map(b => `${b.type}:${'text' in b ? b.text.length : '?'}`))
|
|
174
|
+
}
|
|
175
|
+
const textBlocks = blocks
|
|
176
|
+
.filter((block): block is Extract<StreamChunk, { type: 'block-end' }>['block'] & { type: 'text' } => block.type === 'text')
|
|
177
|
+
.map(block => block.text)
|
|
178
|
+
let text = textBlocks.join('\n').trim()
|
|
179
|
+
// v4-flash can answer entirely in the reasoning channel (the adapter
|
|
180
|
+
// surfaces that as a 'reasoning' block). Fall back to it when no text came
|
|
181
|
+
// back — the reasoning content is the model's actual answer here.
|
|
182
|
+
if (text === '') {
|
|
183
|
+
const reasoning = blocks
|
|
184
|
+
.filter((block): block is { type: 'reasoning'; text: string } => block.type === 'reasoning')
|
|
185
|
+
.map(block => block.text)
|
|
186
|
+
.join('\n')
|
|
187
|
+
.trim()
|
|
188
|
+
if (reasoning !== '') text = reasoning
|
|
189
|
+
}
|
|
190
|
+
return text
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Parse a JSON value out of a model response. Multi-level tolerance because
|
|
195
|
+
* models are sloppy: prose around the JSON, ```json fences, a truncated tail,
|
|
196
|
+
* or raw newlines inside string values all defeat a single JSON.parse. We
|
|
197
|
+
* walk candidates from strictest to loosest.
|
|
198
|
+
*/
|
|
199
|
+
function parseJson<T>(text: string, wantArray: boolean): T {
|
|
200
|
+
const candidates: string[] = []
|
|
201
|
+
const push = (value: string | undefined): void => {
|
|
202
|
+
if (value !== undefined && value.trim() !== '') candidates.push(value.trim())
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 1. Whole response, and any ```json fence body.
|
|
206
|
+
push(text)
|
|
207
|
+
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text)
|
|
208
|
+
push(fenced?.[1])
|
|
209
|
+
// 2. From the first opener to the last closer.
|
|
210
|
+
const opener = wantArray ? '[' : '{'
|
|
211
|
+
const closer = wantArray ? ']' : '}'
|
|
212
|
+
const start = text.indexOf(opener)
|
|
213
|
+
const end = text.lastIndexOf(closer)
|
|
214
|
+
if (start !== -1 && end > start) push(text.slice(start, end + 1))
|
|
215
|
+
// 3. Trim trailing prose (a "}..." tail after the last closer).
|
|
216
|
+
const trimmed = text.replace(new RegExp(`${closer}[\\s\\S]*$`), closer)
|
|
217
|
+
push(trimmed)
|
|
218
|
+
const start2 = trimmed.indexOf(opener)
|
|
219
|
+
if (start2 !== -1) push(trimmed.slice(start2))
|
|
220
|
+
|
|
221
|
+
// Repair: models love raw newlines inside string values, which JSON forbids.
|
|
222
|
+
const repair = (value: string): string => {
|
|
223
|
+
let out = ''
|
|
224
|
+
let inString = false
|
|
225
|
+
for (let i = 0; i < value.length; i++) {
|
|
226
|
+
const ch = value[i]!
|
|
227
|
+
if (inString) {
|
|
228
|
+
if (ch === '\\') {
|
|
229
|
+
out += ch + (value[i + 1] ?? '')
|
|
230
|
+
i++
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
if (ch === '"') {
|
|
234
|
+
inString = false
|
|
235
|
+
out += ch
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
if (ch === '\n' || ch === '\r') {
|
|
239
|
+
out += '\\n'
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
out += ch
|
|
243
|
+
} else {
|
|
244
|
+
if (ch === '"') inString = true
|
|
245
|
+
out += ch
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return out
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
for (const candidate of candidates) {
|
|
252
|
+
for (const attempt of [candidate, repair(candidate)]) {
|
|
253
|
+
try {
|
|
254
|
+
return JSON.parse(attempt) as T
|
|
255
|
+
} catch {
|
|
256
|
+
// try the next candidate
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const preview = text.length > 300 ? text.slice(0, 300) + '…' : text
|
|
261
|
+
throw new Error(`模型输出中未找到 JSON 数据。模型原始输出:${preview}`)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Parse a JSON array (chapters, volumes, issues...). */
|
|
265
|
+
function parseJsonArray<T>(text: string): T[] {
|
|
266
|
+
const value = parseJson<T[]>(text, true)
|
|
267
|
+
return Array.isArray(value) ? value : []
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Parse a JSON object. */
|
|
271
|
+
function parseJsonObject<T>(text: string): T {
|
|
272
|
+
const value = parseJson<T>(text, false)
|
|
273
|
+
if (typeof value !== 'object' || value === null) throw new Error('模型输出不是 JSON 对象')
|
|
274
|
+
return value
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ------------------------------------------------------------------ bible
|
|
278
|
+
|
|
279
|
+
/** System prompt for story-bible extraction. */
|
|
280
|
+
function bibleSystemPrompt(): string {
|
|
281
|
+
return [
|
|
282
|
+
'你是一位资深网文编辑兼设定架构师。你会收到一份小说大纲,请把它提炼成结构化的「设定圣经」(Story Bible),供后续写作时严格引用。',
|
|
283
|
+
'要求:',
|
|
284
|
+
'1. 忠于大纲,不自行发明大纲之外的设定。',
|
|
285
|
+
'2. 角色卡覆盖大纲明确出现的角色(主角必含),每个角色给出性格标签、目标、关键关系。',
|
|
286
|
+
'3. 世界规则覆盖力量体系、金手指机制、势力、地理等所有硬性规则,逐条列出。',
|
|
287
|
+
'4. 红线列出大纲中明确禁止的内容(如无后宫、不圣母、无无脑碾压等)。',
|
|
288
|
+
'5. 风格列出叙事基调、节奏、POV 等写作风格要点。',
|
|
289
|
+
'输出必须是合法 JSON 对象,不要输出任何其他文字或 Markdown 代码块标记。',
|
|
290
|
+
'重要:所有字符串值内部不得包含换行符(不要用多行字符串),JSON 必须在一段内完整结束。',
|
|
291
|
+
'重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。',
|
|
292
|
+
'JSON 结构:',
|
|
293
|
+
'{"genre": "题材与基调一句话", "worldRules": ["规则1", "规则2", ...], "characters": [{"name": "角色名", "role": "protagonist|supporting|antagonist|other", "traits": ["标签1", ...], "goals": "目标与动机", "relations": "关键关系"}], "redLines": ["红线1", ...], "style": ["风格1", ...]}',
|
|
294
|
+
].join('\n')
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Extract the story bible from an outline. */
|
|
298
|
+
export async function extractBible(ctx: Context, config: NovelConfig, outline: string): Promise<StoryBible> {
|
|
299
|
+
const user = `请为下面这部小说提炼设定圣经:\n\n${outline}`
|
|
300
|
+
const text = await complete(ctx, config, {
|
|
301
|
+
system: bibleSystemPrompt(),
|
|
302
|
+
user,
|
|
303
|
+
temperature: 0.4,
|
|
304
|
+
maxTokens: Math.max(config.maxTokens, 16000),
|
|
305
|
+
})
|
|
306
|
+
const raw = parseJsonObject<{
|
|
307
|
+
genre?: unknown
|
|
308
|
+
worldRules?: unknown
|
|
309
|
+
characters?: unknown
|
|
310
|
+
redLines?: unknown
|
|
311
|
+
style?: unknown
|
|
312
|
+
}>(text)
|
|
313
|
+
const strArray = (value: unknown): string[] =>
|
|
314
|
+
Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string' && v.trim() !== '') : []
|
|
315
|
+
const characters: CharacterCard[] = Array.isArray(raw.characters)
|
|
316
|
+
? raw.characters
|
|
317
|
+
.filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
|
|
318
|
+
.map(entry => ({
|
|
319
|
+
name: typeof entry.name === 'string' ? entry.name.trim() : '未命名',
|
|
320
|
+
role: (['protagonist', 'supporting', 'antagonist', 'other'] as const).includes(entry.role as never)
|
|
321
|
+
? entry.role as CharacterCard['role']
|
|
322
|
+
: 'other',
|
|
323
|
+
traits: strArray(entry.traits),
|
|
324
|
+
goals: typeof entry.goals === 'string' ? entry.goals : '',
|
|
325
|
+
relations: typeof entry.relations === 'string' ? entry.relations : '',
|
|
326
|
+
}))
|
|
327
|
+
.filter(card => card.name !== '')
|
|
328
|
+
: []
|
|
329
|
+
const bible: StoryBible = {
|
|
330
|
+
genre: typeof raw.genre === 'string' ? raw.genre : '',
|
|
331
|
+
worldRules: strArray(raw.worldRules),
|
|
332
|
+
characters,
|
|
333
|
+
redLines: strArray(raw.redLines),
|
|
334
|
+
style: strArray(raw.style),
|
|
335
|
+
generatedAt: new Date().toISOString(),
|
|
336
|
+
}
|
|
337
|
+
if (bible.worldRules.length === 0 && bible.characters.length === 0 && bible.redLines.length === 0) {
|
|
338
|
+
throw new Error('设定圣经生成失败:模型没有返回有效内容')
|
|
339
|
+
}
|
|
340
|
+
return bible
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ------------------------------------------------------------------ volumes
|
|
344
|
+
|
|
345
|
+
/** System prompt for volume planning. */
|
|
346
|
+
function volumeSystemPrompt(): string {
|
|
347
|
+
return [
|
|
348
|
+
'你是一位资深网文总编。你会收到一份小说大纲,请把全书划分为若干「卷」(分卷),每卷有明确的剧情定位与起止章节。',
|
|
349
|
+
'要求:',
|
|
350
|
+
'1. 大纲已有分卷时,严格遵循大纲的分卷结构;没有时按剧情弧线合理划分(3-8 卷)。',
|
|
351
|
+
'2. 卷定位一句话说明该卷的剧情重心。',
|
|
352
|
+
'3. chapterStart/chapterEnd 给出该卷覆盖的章节区间(从 1 开始连续编号)。',
|
|
353
|
+
'输出必须是合法 JSON 数组,不要输出任何其他文字:',
|
|
354
|
+
'[{"no": 1, "title": "卷名", "summary": "卷定位与剧情重心", "chapterStart": 1, "chapterEnd": 80}]',
|
|
355
|
+
'重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
356
|
+
'重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。',
|
|
357
|
+
].join('\n')
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Plan volumes from an outline. */
|
|
361
|
+
export async function planVolumes(ctx: Context, config: NovelConfig, outline: string): Promise<Volume[]> {
|
|
362
|
+
const user = `请为下面这部小说划分卷:\n\n${outline}`
|
|
363
|
+
const text = await complete(ctx, config, { system: volumeSystemPrompt(), user, temperature: 0.4 })
|
|
364
|
+
const parsed = parseJsonArray<Record<string, unknown>>(text)
|
|
365
|
+
const volumes: Volume[] = []
|
|
366
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
367
|
+
const entry = parsed[i]
|
|
368
|
+
if (typeof entry !== 'object' || entry === null) continue
|
|
369
|
+
const no = typeof entry.no === 'number' ? entry.no : i + 1
|
|
370
|
+
const title = typeof entry.title === 'string' ? entry.title.trim() : `第${no}卷`
|
|
371
|
+
const summary = typeof entry.summary === 'string' ? entry.summary.trim() : ''
|
|
372
|
+
const start = typeof entry.chapterStart === 'number' ? entry.chapterStart : undefined
|
|
373
|
+
const end = typeof entry.chapterEnd === 'number' ? entry.chapterEnd : undefined
|
|
374
|
+
volumes.push({
|
|
375
|
+
no,
|
|
376
|
+
title: title.slice(0, 40),
|
|
377
|
+
summary: summary.slice(0, 300),
|
|
378
|
+
chapterStart: start ?? 1,
|
|
379
|
+
chapterEnd: end ?? 1,
|
|
380
|
+
})
|
|
381
|
+
}
|
|
382
|
+
if (volumes.length === 0) throw new Error('卷计划生成失败:模型没有返回有效卷')
|
|
383
|
+
return volumes
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Assign a chapter to its volume by number. */
|
|
387
|
+
function volumeOf(chapterNo: number, volumes: Volume[] | undefined): number {
|
|
388
|
+
if (volumes === undefined || volumes.length === 0) return 0
|
|
389
|
+
for (const volume of volumes) {
|
|
390
|
+
if (chapterNo >= volume.chapterStart && chapterNo <= volume.chapterEnd) return volume.no
|
|
391
|
+
}
|
|
392
|
+
return volumes[volumes.length - 1]?.no ?? 0
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ------------------------------------------------------------------- plan
|
|
396
|
+
|
|
397
|
+
/** The chapter-planning prompt template. */
|
|
398
|
+
function planSystemPrompt(volumes: Volume[] | undefined): string {
|
|
399
|
+
const volumeBlock = volumes !== undefined && volumes.length > 0
|
|
400
|
+
? ['\n全书分卷结构(规划章节时需落在对应卷内):']
|
|
401
|
+
.concat(volumes.map(v => `第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})`))
|
|
402
|
+
.join('\n')
|
|
403
|
+
: ''
|
|
404
|
+
return [
|
|
405
|
+
'你是一位资深中文网文策划编辑,擅长把小说大纲拆解为可执行的章节计划。',
|
|
406
|
+
'你会收到一份小说大纲。请根据大纲的设定、主线与节奏,规划出一份章节计划。',
|
|
407
|
+
'要求:',
|
|
408
|
+
'1. 每章必须有明确的核心剧情推进(不能只是过渡或凑字数)。',
|
|
409
|
+
'2. 章节之间要衔接自然,前章结尾为后章埋下钩子。',
|
|
410
|
+
'3. 严格遵循大纲的人设、金手指规则、战力体系与世界观设定,不得自行发明冲突设定。',
|
|
411
|
+
'4. 输出必须是合法的 JSON 数组,不要输出任何其他文字或 Markdown 代码块标记。',
|
|
412
|
+
'5. 数组每个元素格式:{"title": "章节标题(10字以内,有网文感)", "beats": "本章剧情要点(150-250字,含起承转合与钩子)"}',
|
|
413
|
+
'重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
414
|
+
'重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。',
|
|
415
|
+
volumeBlock,
|
|
416
|
+
].join('\n')
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Build the writing system prompt (bible + outline + active foreshadows). */
|
|
420
|
+
function writeSystemPrompt(project: ProjectState): string {
|
|
421
|
+
const bible = project.bible
|
|
422
|
+
const sections: string[] = []
|
|
423
|
+
if (bible !== undefined) {
|
|
424
|
+
sections.push('==================== 设定圣经(写作时严格遵守) ====================')
|
|
425
|
+
if (bible.genre !== '') sections.push(`题材基调:${bible.genre}`)
|
|
426
|
+
if (bible.worldRules.length > 0) sections.push('世界规则:\n' + bible.worldRules.map(r => `- ${r}`).join('\n'))
|
|
427
|
+
if (bible.characters.length > 0) {
|
|
428
|
+
sections.push('角色卡:')
|
|
429
|
+
for (const card of bible.characters) {
|
|
430
|
+
const roleName = { protagonist: '主角', supporting: '配角', antagonist: '反派', other: '其他' }[card.role]
|
|
431
|
+
sections.push(`- ${card.name}(${roleName}):${card.traits.join('、')}${card.goals !== '' ? `;目标:${card.goals}` : ''}${card.relations !== '' ? `;关系:${card.relations}` : ''}`)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (bible.redLines.length > 0) sections.push('写作红线(违反即失败):\n' + bible.redLines.map(r => `- ${r}`).join('\n'))
|
|
435
|
+
if (bible.style.length > 0) sections.push('风格要求:\n' + bible.style.map(r => `- ${r}`).join('\n'))
|
|
436
|
+
}
|
|
437
|
+
sections.push('==================== 全书大纲 ====================')
|
|
438
|
+
sections.push(project.outline)
|
|
439
|
+
sections.push('==================== 大纲结束 ====================')
|
|
440
|
+
const assetsBlock = renderAllAssets(project.assets)
|
|
441
|
+
if (assetsBlock !== '') sections.push(assetsBlock)
|
|
442
|
+
const active = project.foreshadows.filter(f => f.status === 'planted' || f.status === 'progressing')
|
|
443
|
+
if (active.length > 0) {
|
|
444
|
+
sections.push('==================== 活跃伏笔(近期需推进或回收的线索) ====================')
|
|
445
|
+
for (const f of active) {
|
|
446
|
+
sections.push(`- [${f.status === 'planted' ? '已埋设' : '推进中'}] ${f.description}${f.targetChapter !== undefined ? `(预计 ${f.targetChapter} 章回收)` : ''}`)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
sections.push('')
|
|
450
|
+
sections.push('写作硬性要求:')
|
|
451
|
+
sections.push('1. 每章 3000-4000 字(按中文字符计),只输出章节正文,不要输出标题、章回名、作者的话或任何 Markdown 标记。')
|
|
452
|
+
sections.push('2. 以主角视角展开,动作、对话、心理描写交替推进,禁止大段设定说明。')
|
|
453
|
+
sections.push('3. 尊重大纲与设定圣经:人设不崩、金手指规则不自相矛盾、战力不随意膨胀。')
|
|
454
|
+
sections.push('4. 章末留一个钩子(悬念、反转或新线索),吸引读者读下一章。')
|
|
455
|
+
sections.push('5. 语言流畅自然,符合中文网文语感,避免翻译腔与病句。')
|
|
456
|
+
return sections.join('\n')
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Plan chapters from an outline (optionally for one volume).
|
|
461
|
+
*/
|
|
462
|
+
export async function planChapters(
|
|
463
|
+
ctx: Context,
|
|
464
|
+
config: NovelConfig,
|
|
465
|
+
project: ProjectState,
|
|
466
|
+
chapterCount: number,
|
|
467
|
+
volumeNo?: number,
|
|
468
|
+
): Promise<ChapterPlan[]> {
|
|
469
|
+
const volume = project.volumes?.find(v => v.no === volumeNo)
|
|
470
|
+
const user = [
|
|
471
|
+
'请为下面这部小说规划章节。',
|
|
472
|
+
volume !== undefined
|
|
473
|
+
? `本次只规划第 ${volume.no} 卷《${volume.title}》的章节:\n${volume.summary}`
|
|
474
|
+
: '请规划全书开篇章节。',
|
|
475
|
+
`大纲如下:\n${project.outline}`,
|
|
476
|
+
'',
|
|
477
|
+
`请规划 ${chapterCount} 章。输出 JSON 数组(不要输出其他文字):`,
|
|
478
|
+
].join('\n')
|
|
479
|
+
const text = await complete(ctx, config, { system: planSystemPrompt(project.volumes), user, temperature: 0.7 })
|
|
480
|
+
const parsed = parseJsonArray<Record<string, unknown>>(text)
|
|
481
|
+
const chapters: ChapterPlan[] = []
|
|
482
|
+
const existing = new Set(project.chapters.map(c => c.no))
|
|
483
|
+
const startNo = project.chapters.length + 1
|
|
484
|
+
for (let i = 0; i < Math.min(parsed.length, chapterCount); i++) {
|
|
485
|
+
const item = parsed[i]
|
|
486
|
+
if (typeof item !== 'object' || item === null) continue
|
|
487
|
+
const entry = item as Record<string, unknown>
|
|
488
|
+
const title = typeof entry.title === 'string' ? entry.title.trim().slice(0, 30) : ''
|
|
489
|
+
const beats = typeof entry.beats === 'string' ? entry.beats.trim() : ''
|
|
490
|
+
if (title === '' && beats === '') continue
|
|
491
|
+
const no = startNo + i
|
|
492
|
+
if (existing.has(no)) continue
|
|
493
|
+
chapters.push({
|
|
494
|
+
no,
|
|
495
|
+
volume: volumeOf(no, project.volumes),
|
|
496
|
+
title: title || `第${no}章`,
|
|
497
|
+
beats,
|
|
498
|
+
targetChars: config.chapterChars,
|
|
499
|
+
status: 'pending',
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
if (chapters.length === 0) {
|
|
503
|
+
throw new Error('章节计划生成失败:模型没有返回有效章节')
|
|
504
|
+
}
|
|
505
|
+
return chapters
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ------------------------------------------------------------------ writing
|
|
509
|
+
|
|
510
|
+
/** The review system prompt. */
|
|
511
|
+
function reviewSystemPrompt(project: ProjectState): string {
|
|
512
|
+
const bible = project.bible
|
|
513
|
+
const sections: string[] = [
|
|
514
|
+
'你是一位严格的网文审稿编辑。你会收到一章正文以及本书的设定圣经与红线。',
|
|
515
|
+
'请从以下维度审查本章:',
|
|
516
|
+
'1. 人设一致性:角色行为是否符合角色卡(主角不圣母、不无脑、痞坏有分寸等)。',
|
|
517
|
+
'2. 设定一致性:金手指规则、战力体系、世界观是否与设定圣经冲突。',
|
|
518
|
+
'3. 红线检查:是否触犯写作红线(无后宫、无擦边、无无脑碾压等)。',
|
|
519
|
+
'4. 文笔质量:语病、翻译腔、AI 套话("不禁""仿佛""一时间"等高频词滥用)、流水账。',
|
|
520
|
+
'5. 节奏与爽点:本章是否有推进、有钩子,是否拖沓灌水。',
|
|
521
|
+
'6. 逻辑漏洞:前后矛盾、时间线错误、对话失真。',
|
|
522
|
+
'7. 反 AI 规则:逐条核对下方「反 AI 规则」清单,命中即列为问题。',
|
|
523
|
+
'输出必须是合法 JSON 对象,不要输出任何其他文字:',
|
|
524
|
+
'{"score": 0-100的整数, "verdict": "一句话总评", "issues": [{"severity": "high|medium|low", "item": "问题描述", "suggestion": "修改建议"}]}',
|
|
525
|
+
'重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
526
|
+
'重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。',
|
|
527
|
+
]
|
|
528
|
+
const assetsBlock = renderAllAssets(project.assets)
|
|
529
|
+
if (assetsBlock !== '') sections.push('\n' + assetsBlock)
|
|
530
|
+
if (bible !== undefined) {
|
|
531
|
+
sections.push('\n==================== 设定圣经 ====================')
|
|
532
|
+
if (bible.worldRules.length > 0) sections.push('世界规则:\n' + bible.worldRules.map(r => `- ${r}`).join('\n'))
|
|
533
|
+
if (bible.characters.length > 0) {
|
|
534
|
+
sections.push('角色卡:')
|
|
535
|
+
for (const card of bible.characters) {
|
|
536
|
+
sections.push(`- ${card.name}(${card.role}):${card.traits.join('、')}`)
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (bible.redLines.length > 0) sections.push('红线:\n' + bible.redLines.map(r => `- ${r}`).join('\n'))
|
|
540
|
+
}
|
|
541
|
+
return sections.join('\n')
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Run the AI review on one chapter. */
|
|
545
|
+
export async function reviewChapter(
|
|
546
|
+
ctx: Context,
|
|
547
|
+
config: NovelConfig,
|
|
548
|
+
project: ProjectState,
|
|
549
|
+
outputDir: string,
|
|
550
|
+
chapterNo: number,
|
|
551
|
+
): Promise<ReviewReport> {
|
|
552
|
+
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
553
|
+
if (chapter === undefined) throw new Error(`章节 ${chapterNo} 不在计划中`)
|
|
554
|
+
const body = readChapterFile(outputDir, chapter)
|
|
555
|
+
if (body === undefined) throw new Error(`章节 ${chapterNo} 的正文文件不存在`)
|
|
556
|
+
const user = [
|
|
557
|
+
`本章标题:《${chapter.title}》`,
|
|
558
|
+
`本章剧情要点:${chapter.beats}`,
|
|
559
|
+
'==================== 章节正文 ====================',
|
|
560
|
+
body.replace(/^#\s+.*$/m, '').trim(),
|
|
561
|
+
].join('\n')
|
|
562
|
+
const text = await complete(ctx, config, { system: reviewSystemPrompt(project), user, temperature: 0.3 })
|
|
563
|
+
const raw = parseJsonObject<{ score?: unknown; verdict?: unknown; issues?: unknown }>(text)
|
|
564
|
+
const issues = Array.isArray(raw.issues)
|
|
565
|
+
? raw.issues
|
|
566
|
+
.filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
|
|
567
|
+
.map(entry => ({
|
|
568
|
+
severity: (['high', 'medium', 'low'] as const).includes(entry.severity as never)
|
|
569
|
+
? entry.severity as 'high' | 'medium' | 'low'
|
|
570
|
+
: 'medium',
|
|
571
|
+
item: typeof entry.item === 'string' ? entry.item : '',
|
|
572
|
+
suggestion: typeof entry.suggestion === 'string' ? entry.suggestion : '',
|
|
573
|
+
}))
|
|
574
|
+
.filter(issue => issue.item !== '')
|
|
575
|
+
: []
|
|
576
|
+
const score = typeof raw.score === 'number' ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60
|
|
577
|
+
const report: ReviewReport = {
|
|
578
|
+
score,
|
|
579
|
+
passed: score >= config.reviewPassScore,
|
|
580
|
+
verdict: typeof raw.verdict === 'string' ? raw.verdict.slice(0, 200) : '',
|
|
581
|
+
issues,
|
|
582
|
+
reviewedAt: new Date().toISOString(),
|
|
583
|
+
}
|
|
584
|
+
chapter.review = report
|
|
585
|
+
chapter.status = report.passed ? 'approved' : 'rejected'
|
|
586
|
+
project.updatedAt = new Date().toISOString()
|
|
587
|
+
saveProject(outputDir, project)
|
|
588
|
+
return report
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Build the rewrite system prompt (fix review issues / instructions). */
|
|
592
|
+
function rewriteSystemPrompt(project: ProjectState): string {
|
|
593
|
+
const base = writeSystemPrompt(project)
|
|
594
|
+
return base + '\n\n额外要求:你正在【修订】一章已写好的正文。保留原文中好的部分,只修改需要修改的地方,输出完整的新正文(不要只输出修改片段),字数与原文相当。'
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Stream a chapter rewrite. With `target` (a passage of the body), only that
|
|
599
|
+
* passage's paragraph is rewritten and spliced back — everything else stays
|
|
600
|
+
* untouched (local revision). Without `target`, the whole chapter is
|
|
601
|
+
* rewritten. Yields delta text; persists when done.
|
|
602
|
+
*/
|
|
603
|
+
export async function* rewriteChapterStream(
|
|
604
|
+
ctx: Context,
|
|
605
|
+
config: NovelConfig,
|
|
606
|
+
project: ProjectState,
|
|
607
|
+
outputDir: string,
|
|
608
|
+
chapterNo: number,
|
|
609
|
+
instructions: string,
|
|
610
|
+
target?: string,
|
|
611
|
+
): AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'done'; file: string; chars: number }, void, unknown> {
|
|
612
|
+
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
613
|
+
if (chapter === undefined) throw new Error(`章节 ${chapterNo} 不在计划中`)
|
|
614
|
+
const body = readChapterFile(outputDir, chapter)
|
|
615
|
+
if (body === undefined) throw new Error(`章节 ${chapterNo} 的正文文件不存在`)
|
|
616
|
+
|
|
617
|
+
const reviewBlock = chapter.review !== undefined
|
|
618
|
+
? '审稿意见:\n' + chapter.review.issues.map(i => `[${i.severity}] ${i.item} → ${i.suggestion}`).join('\n')
|
|
619
|
+
: ''
|
|
620
|
+
|
|
621
|
+
// Local revision: find the paragraph containing `target` and only rewrite it.
|
|
622
|
+
const bodyText = body.replace(/^#\s+.*$/m, '').trim()
|
|
623
|
+
let localTarget: { paragraph: string; before: string; after: string } | undefined
|
|
624
|
+
if (target !== undefined && target.trim() !== '') {
|
|
625
|
+
const wanted = target.trim()
|
|
626
|
+
// Normalize whitespace so multi-line / quoted snippets still match:
|
|
627
|
+
// the assistant often copies a passage with line breaks and quotes.
|
|
628
|
+
const normalize = (value: string): string => value.replace(/\s+/g, ' ').replace(/[“”"'‘’]/g, '')
|
|
629
|
+
const wantedFlat = normalize(wanted)
|
|
630
|
+
// Split into paragraphs on blank lines (or double newlines).
|
|
631
|
+
const paragraphs = bodyText.split(/\n{2,}/)
|
|
632
|
+
const idx = paragraphs.findIndex(p => normalize(p).includes(wantedFlat))
|
|
633
|
+
if (idx === -1) {
|
|
634
|
+
throw new Error(`在正文中未找到要修改的片段:「${wanted.slice(0, 40)}…」。请从正文中复制原文片段(无需整段,取片段即可)。`)
|
|
635
|
+
}
|
|
636
|
+
localTarget = {
|
|
637
|
+
paragraph: paragraphs[idx]!,
|
|
638
|
+
before: paragraphs.slice(0, idx).join('\n\n'),
|
|
639
|
+
after: paragraphs.slice(idx + 1).join('\n\n'),
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const user = localTarget === undefined
|
|
644
|
+
? [
|
|
645
|
+
`请修订第 ${chapter.no} 章《${chapter.title}》。`,
|
|
646
|
+
reviewBlock,
|
|
647
|
+
instructions !== '' ? `本次修订重点:${instructions}` : '',
|
|
648
|
+
'==================== 原正文 ====================',
|
|
649
|
+
bodyText,
|
|
650
|
+
].filter(line => line !== '').join('\n')
|
|
651
|
+
: [
|
|
652
|
+
`请修订第 ${chapter.no} 章《${chapter.title}》中的一个自然段。`,
|
|
653
|
+
instructions !== '' ? `修改要求:${instructions}` : '',
|
|
654
|
+
'==================== 需要修改的原文段落 ====================',
|
|
655
|
+
localTarget.paragraph,
|
|
656
|
+
'',
|
|
657
|
+
'要求:',
|
|
658
|
+
'1. 只输出修改后的【这一个段落】的完整新文本,不要输出任何说明、标题或 Markdown 标记。',
|
|
659
|
+
'2. 保留该段的情节走向与角色口吻,只按修改要求调整。',
|
|
660
|
+
'3. 段落长度与原文相当。',
|
|
661
|
+
].filter(line => line !== '').join('\n')
|
|
662
|
+
|
|
663
|
+
const system = localTarget === undefined
|
|
664
|
+
? rewriteSystemPrompt(project)
|
|
665
|
+
: '你是一位中文网文润色师。你会收到一章中的一个段落,请按修改要求重写该段。只输出新段落文本。'
|
|
666
|
+
|
|
667
|
+
const messages: Message[] = [createUserMessage({
|
|
668
|
+
content: [{ type: 'text', text: user }],
|
|
669
|
+
source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
|
|
670
|
+
})]
|
|
671
|
+
const request: GenerateOptions = {
|
|
672
|
+
provider: config.provider,
|
|
673
|
+
model: config.model,
|
|
674
|
+
messages,
|
|
675
|
+
system,
|
|
676
|
+
// Rewriting outputs a full chapter: budget generously and skip
|
|
677
|
+
// reasoning (a transform task) so the whole budget goes to the body.
|
|
678
|
+
maxTokens: Math.max(config.maxTokens, 20000),
|
|
679
|
+
temperature: 0.7,
|
|
680
|
+
reasoningEffort: ReasoningEffortId('off'),
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
yield { frame: 'start' }
|
|
684
|
+
const assembler = new BlockAssembler()
|
|
685
|
+
let streamError: Error | undefined
|
|
686
|
+
for await (const chunk of ctx.llm.stream(request)) {
|
|
687
|
+
assembler.push(chunk)
|
|
688
|
+
if (chunk.type === 'text-delta') yield { frame: 'delta', text: chunk.text }
|
|
689
|
+
}
|
|
690
|
+
const finish = assembler.finish
|
|
691
|
+
if (finish.kind === 'error' || finish.kind === 'aborted') {
|
|
692
|
+
streamError = new Error(`修订失败(${finish.kind}): ${finish.failure.message}`)
|
|
693
|
+
} else if (finish.kind === 'max-tokens') {
|
|
694
|
+
streamError = new Error('修订输出达到 maxTokens 上限,请增大配置后重试')
|
|
695
|
+
}
|
|
696
|
+
const rewritten = assembler
|
|
697
|
+
.blocks()
|
|
698
|
+
.filter((block): block is Extract<StreamChunk, { type: 'block-end' }>['block'] & { type: 'text' } => block.type === 'text')
|
|
699
|
+
.map(block => block.text)
|
|
700
|
+
.join('\n')
|
|
701
|
+
.trim()
|
|
702
|
+
if (streamError !== undefined) throw streamError
|
|
703
|
+
if (rewritten.length < 20) throw new Error('修订结果过短,可能失败,请重试')
|
|
704
|
+
|
|
705
|
+
// Splice: local -> replace the paragraph; whole -> replace the body.
|
|
706
|
+
let newBody: string
|
|
707
|
+
if (localTarget !== undefined) {
|
|
708
|
+
newBody = [localTarget.before, rewritten, localTarget.after].filter(part => part !== '').join('\n\n')
|
|
709
|
+
} else {
|
|
710
|
+
newBody = rewritten
|
|
711
|
+
}
|
|
712
|
+
if (newBody.length < 100) throw new Error('修订结果过短,可能失败,请重试')
|
|
713
|
+
|
|
714
|
+
const fileName = chapterFileName(chapter)
|
|
715
|
+
const markdown = `# 第${chapter.no}章 ${chapter.title}\n\n${newBody}\n`
|
|
716
|
+
writeFileSync(join(outputDir, fileName), markdown, 'utf8')
|
|
717
|
+
chapter.status = 'written'
|
|
718
|
+
chapter.chars = newBody.length
|
|
719
|
+
chapter.error = undefined
|
|
720
|
+
chapter.review = undefined
|
|
721
|
+
project.updatedAt = new Date().toISOString()
|
|
722
|
+
saveProject(outputDir, project)
|
|
723
|
+
yield { frame: 'done', file: fileName, chars: newBody.length }
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** The de-AI-ify polish system prompt (with project writing assets injected). */
|
|
727
|
+
function polishSystemPrompt(project: ProjectState): string {
|
|
728
|
+
const assetsBlock = renderAllAssets(project.assets)
|
|
729
|
+
return [
|
|
730
|
+
'你是一位中文网文润色师。你会收到一章正文,请做「去 AI 味」润色:',
|
|
731
|
+
'1. 删除/替换 AI 高频套话与模式词:如"不禁""仿佛""一时间""不由得""顿时""然而""缓缓""轻轻""微微""默默""似乎""终于"等滥用。',
|
|
732
|
+
'2. 把书面翻译腔改成口语化的中文网文语感。',
|
|
733
|
+
'3. 拆分过长的排比句与堆砌的修饰语。',
|
|
734
|
+
'4. 保留全部情节、人物、对话内容不变,只改表达。',
|
|
735
|
+
'5. 输出完整的新正文,不要输出任何说明文字或 Markdown 标记。',
|
|
736
|
+
'6. 必须遵守下方「反 AI 规则」与「写法资产」的表达边界;写法资产要求保留的风格特征(句式、台词、节奏)不得在润色中丢失。',
|
|
737
|
+
assetsBlock !== '' ? assetsBlock : '',
|
|
738
|
+
].join('\n')
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** Stream a chapter polish (de-AI-ify). */
|
|
742
|
+
export async function* polishChapterStream(
|
|
743
|
+
ctx: Context,
|
|
744
|
+
config: NovelConfig,
|
|
745
|
+
project: ProjectState,
|
|
746
|
+
outputDir: string,
|
|
747
|
+
chapterNo: number,
|
|
748
|
+
): AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'done'; file: string; chars: number }, void, unknown> {
|
|
749
|
+
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
750
|
+
if (chapter === undefined) throw new Error(`章节 ${chapterNo} 不在计划中`)
|
|
751
|
+
const body = readChapterFile(outputDir, chapter)
|
|
752
|
+
if (body === undefined) throw new Error(`章节 ${chapterNo} 的正文文件不存在`)
|
|
753
|
+
const messages: Message[] = [createUserMessage({
|
|
754
|
+
content: [{ type: 'text', text: body.replace(/^#\s+.*$/m, '').trim() }],
|
|
755
|
+
source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
|
|
756
|
+
})]
|
|
757
|
+
const request: GenerateOptions = {
|
|
758
|
+
provider: config.provider,
|
|
759
|
+
model: config.model,
|
|
760
|
+
messages,
|
|
761
|
+
system: polishSystemPrompt(project),
|
|
762
|
+
// Polish rewrites the whole chapter: generous budget, no reasoning
|
|
763
|
+
// (transform task — the entire budget should go to the body).
|
|
764
|
+
maxTokens: Math.max(config.maxTokens, 20000),
|
|
765
|
+
temperature: 0.5,
|
|
766
|
+
reasoningEffort: ReasoningEffortId('off'),
|
|
767
|
+
}
|
|
768
|
+
yield { frame: 'start' }
|
|
769
|
+
const assembler = new BlockAssembler()
|
|
770
|
+
let streamError: Error | undefined
|
|
771
|
+
for await (const chunk of ctx.llm.stream(request)) {
|
|
772
|
+
assembler.push(chunk)
|
|
773
|
+
if (chunk.type === 'text-delta') yield { frame: 'delta', text: chunk.text }
|
|
774
|
+
}
|
|
775
|
+
const finish = assembler.finish
|
|
776
|
+
if (finish.kind === 'error' || finish.kind === 'aborted') {
|
|
777
|
+
streamError = new Error(`润色失败(${finish.kind}): ${finish.failure.message}`)
|
|
778
|
+
} else if (finish.kind === 'max-tokens') {
|
|
779
|
+
streamError = new Error('润色输出达到 maxTokens 上限')
|
|
780
|
+
}
|
|
781
|
+
const newBody = assembler
|
|
782
|
+
.blocks()
|
|
783
|
+
.filter((block): block is Extract<StreamChunk, { type: 'block-end' }>['block'] & { type: 'text' } => block.type === 'text')
|
|
784
|
+
.map(block => block.text)
|
|
785
|
+
.join('\n')
|
|
786
|
+
.trim()
|
|
787
|
+
if (streamError !== undefined) throw streamError
|
|
788
|
+
if (newBody.length < 100) throw new Error('润色结果过短,可能失败,请重试')
|
|
789
|
+
const fileName = chapterFileName(chapter)
|
|
790
|
+
writeFileSync(join(outputDir, fileName), `# 第${chapter.no}章 ${chapter.title}\n\n${newBody}\n`, 'utf8')
|
|
791
|
+
chapter.status = 'written'
|
|
792
|
+
chapter.chars = newBody.length
|
|
793
|
+
project.updatedAt = new Date().toISOString()
|
|
794
|
+
saveProject(outputDir, project)
|
|
795
|
+
yield { frame: 'done', file: fileName, chars: newBody.length }
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** Generate one chapter (streaming). Yields progress frames; persists when done. */
|
|
799
|
+
export async function* generateChapterStream(
|
|
800
|
+
ctx: Context,
|
|
801
|
+
config: NovelConfig,
|
|
802
|
+
project: ProjectState,
|
|
803
|
+
outputDir: string,
|
|
804
|
+
chapterNo: number,
|
|
805
|
+
): AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'done'; file: string; chars: number }, void, unknown> {
|
|
806
|
+
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
807
|
+
if (chapter === undefined) throw new Error(`章节 ${chapterNo} 不在计划中`)
|
|
808
|
+
// Note: the route layer owns the 'generating' status + concurrency guard;
|
|
809
|
+
// this function must not refuse when status is 'generating' (the route sets
|
|
810
|
+
// it before calling us).
|
|
811
|
+
|
|
812
|
+
// Continuity: previous chapter's ending + its summary (narrative memory).
|
|
813
|
+
let continuity = ''
|
|
814
|
+
const prev = project.chapters.find(c => c.no === chapterNo - 1)
|
|
815
|
+
if (prev?.file !== undefined) {
|
|
816
|
+
const prevPath = join(outputDir, prev.file)
|
|
817
|
+
if (existsSync(prevPath)) {
|
|
818
|
+
const text = readFileSync(prevPath, 'utf8')
|
|
819
|
+
continuity = text.slice(-900)
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
const prevSummary = prev?.summary
|
|
823
|
+
|
|
824
|
+
const user = [
|
|
825
|
+
`现在写第 ${chapter.no} 章,标题《${chapter.title}》。`,
|
|
826
|
+
`本章剧情要点:${chapter.beats}`,
|
|
827
|
+
'',
|
|
828
|
+
prevSummary !== undefined && prevSummary !== ''
|
|
829
|
+
? `上一章摘要:${prevSummary}`
|
|
830
|
+
: '',
|
|
831
|
+
continuity !== ''
|
|
832
|
+
? `上一章结尾(用于衔接,不要复述):\n${continuity}`
|
|
833
|
+
: '这是第一章,注意开篇要有吸引力。',
|
|
834
|
+
'',
|
|
835
|
+
`请写 ${chapter.targetChars} 字左右的正文,只输出正文。`,
|
|
836
|
+
].filter(line => line !== '').join('\n')
|
|
837
|
+
|
|
838
|
+
const messages: Message[] = [createUserMessage({
|
|
839
|
+
content: [{ type: 'text', text: user }],
|
|
840
|
+
source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
|
|
841
|
+
})]
|
|
842
|
+
const request: GenerateOptions = {
|
|
843
|
+
provider: config.provider,
|
|
844
|
+
model: config.model,
|
|
845
|
+
messages,
|
|
846
|
+
system: writeSystemPrompt(project),
|
|
847
|
+
// Full-chapter output: budget generously (4000 chars ≈ 8-12k tokens,
|
|
848
|
+
// plus the model's reasoning channel).
|
|
849
|
+
maxTokens: Math.max(config.maxTokens, 20000),
|
|
850
|
+
temperature: 0.85,
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
yield { frame: 'start' }
|
|
854
|
+
|
|
855
|
+
const assembler = new BlockAssembler()
|
|
856
|
+
let streamError: Error | undefined
|
|
857
|
+
for await (const chunk of ctx.llm.stream(request)) {
|
|
858
|
+
assembler.push(chunk)
|
|
859
|
+
if (chunk.type === 'text-delta') {
|
|
860
|
+
yield { frame: 'delta', text: chunk.text }
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
const finish = assembler.finish
|
|
864
|
+
if (finish.kind === 'error' || finish.kind === 'aborted') {
|
|
865
|
+
streamError = new Error(`生成失败(${finish.kind}): ${finish.failure.message}`)
|
|
866
|
+
} else if (finish.kind === 'max-tokens') {
|
|
867
|
+
streamError = new Error('达到 maxTokens 上限,正文可能不完整,请增大 maxTokens 后重试')
|
|
868
|
+
}
|
|
869
|
+
const body = assembler
|
|
870
|
+
.blocks()
|
|
871
|
+
.filter((block): block is Extract<StreamChunk, { type: 'block-end' }>['block'] & { type: 'text' } => block.type === 'text')
|
|
872
|
+
.map(block => block.text)
|
|
873
|
+
.join('\n')
|
|
874
|
+
.trim()
|
|
875
|
+
if (streamError !== undefined) throw streamError
|
|
876
|
+
if (body.length < 100) throw new Error('生成内容过短,可能失败,请重试')
|
|
877
|
+
|
|
878
|
+
// Write the chapter file.
|
|
879
|
+
const fileName = chapterFileName(chapter)
|
|
880
|
+
mkdirSync(outputDir, { recursive: true })
|
|
881
|
+
writeFileSync(join(outputDir, fileName), `# 第${chapter.no}章 ${chapter.title}\n\n${body}\n`, 'utf8')
|
|
882
|
+
|
|
883
|
+
chapter.status = 'written'
|
|
884
|
+
chapter.chars = body.length
|
|
885
|
+
chapter.file = fileName
|
|
886
|
+
chapter.error = undefined
|
|
887
|
+
project.updatedAt = new Date().toISOString()
|
|
888
|
+
saveProject(outputDir, project)
|
|
889
|
+
|
|
890
|
+
yield { frame: 'done', file: fileName, chars: body.length }
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** Generate a chapter summary (narrative memory). */
|
|
894
|
+
export async function summarizeChapter(
|
|
895
|
+
ctx: Context,
|
|
896
|
+
config: NovelConfig,
|
|
897
|
+
project: ProjectState,
|
|
898
|
+
outputDir: string,
|
|
899
|
+
chapterNo: number,
|
|
900
|
+
): Promise<string> {
|
|
901
|
+
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
902
|
+
if (chapter === undefined) throw new Error(`章节 ${chapterNo} 不在计划中`)
|
|
903
|
+
const body = readChapterFile(outputDir, chapter)
|
|
904
|
+
if (body === undefined) throw new Error(`章节 ${chapterNo} 的正文文件不存在`)
|
|
905
|
+
const system = [
|
|
906
|
+
'你是一位网文编辑。请为下面一章写一段 120-200 字的摘要,供后续章节写作时保持连贯性。',
|
|
907
|
+
'摘要必须包含:本章发生的关键事件、主角状态变化(境界/资源/伤势/心境)、新增的伏笔或线索、角色关系变化。',
|
|
908
|
+
'用客观陈述句,不要评价,不要剧透式感叹。只输出摘要正文。',
|
|
909
|
+
].join('\n')
|
|
910
|
+
const user = body.replace(/^#\s+.*$/m, '').trim()
|
|
911
|
+
const summary = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: 800 })
|
|
912
|
+
chapter.summary = summary.slice(0, 500)
|
|
913
|
+
project.updatedAt = new Date().toISOString()
|
|
914
|
+
saveProject(outputDir, project)
|
|
915
|
+
return chapter.summary
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// ------------------------------------------------------------- foreshadows
|
|
919
|
+
|
|
920
|
+
/** System prompt for foreshadow suggestions. */
|
|
921
|
+
function foreshadowSystemPrompt(): string {
|
|
922
|
+
return [
|
|
923
|
+
'你是一位网文伏笔设计师。你会收到大纲和已写的章节信息,请为小说建议 3-8 条值得埋设的伏笔。',
|
|
924
|
+
'要求:',
|
|
925
|
+
'1. 伏笔必须有明确的回收价值(推动主线、人物弧光、世界观揭秘)。',
|
|
926
|
+
'2. 描述要具体,指出埋设章节与预计回收章节(可空缺)。',
|
|
927
|
+
'3. 优先从大纲的暗线(如记忆代价、残片收集、身世谜团)中提炼。',
|
|
928
|
+
'输出必须是合法 JSON 数组:',
|
|
929
|
+
'[{"description": "伏笔描述", "plantedChapter": 章节号或null, "targetChapter": 章节号或null}]',
|
|
930
|
+
'重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
931
|
+
].join('\n')
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/** Suggest foreshadows from the outline + plan. */
|
|
935
|
+
export async function suggestForeshadows(
|
|
936
|
+
ctx: Context,
|
|
937
|
+
config: NovelConfig,
|
|
938
|
+
project: ProjectState,
|
|
939
|
+
): Promise<Foreshadow[]> {
|
|
940
|
+
const user = [
|
|
941
|
+
'请为下面这部小说设计伏笔。',
|
|
942
|
+
`大纲:\n${project.outline}`,
|
|
943
|
+
`已规划章节数:${project.chapters.length}`,
|
|
944
|
+
].join('\n')
|
|
945
|
+
const text = await complete(ctx, config, { system: foreshadowSystemPrompt(), user, temperature: 0.5 })
|
|
946
|
+
const parsed = parseJsonArray<Record<string, unknown>>(text)
|
|
947
|
+
const existing = new Set(project.foreshadows.map(f => f.description))
|
|
948
|
+
const created: Foreshadow[] = []
|
|
949
|
+
for (const entry of parsed) {
|
|
950
|
+
if (typeof entry !== 'object' || entry === null) continue
|
|
951
|
+
const description = typeof entry.description === 'string' ? entry.description.trim() : ''
|
|
952
|
+
if (description === '' || existing.has(description)) continue
|
|
953
|
+
existing.add(description)
|
|
954
|
+
created.push({
|
|
955
|
+
id: `fs-${Date.now().toString(36)}-${created.length}`,
|
|
956
|
+
description: description.slice(0, 200),
|
|
957
|
+
plantedChapter: typeof entry.plantedChapter === 'number' ? entry.plantedChapter : undefined,
|
|
958
|
+
targetChapter: typeof entry.targetChapter === 'number' ? entry.targetChapter : undefined,
|
|
959
|
+
status: 'planned',
|
|
960
|
+
})
|
|
961
|
+
}
|
|
962
|
+
project.foreshadows.push(...created)
|
|
963
|
+
project.updatedAt = new Date().toISOString()
|
|
964
|
+
return created
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// -------------------------------------------------------------- style asset
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* 写法引擎:从样本文本提取一份写法资产(叙事风格规则)。
|
|
971
|
+
* @returns 提取出的风格规则(未持久化,由调用方存入 project.assets)。
|
|
972
|
+
*/
|
|
973
|
+
export async function extractStyleAsset(
|
|
974
|
+
ctx: Context,
|
|
975
|
+
config: NovelConfig,
|
|
976
|
+
sampleText: string,
|
|
977
|
+
): Promise<{ proseRules: string[]; dialogueRules: string[]; descriptionRules: string[]; boundaries: string[] }> {
|
|
978
|
+
const user = `请分析下面这段样本文本,提炼其叙事风格规则:\n\n${sampleText}`
|
|
979
|
+
const text = await complete(ctx, config, { system: styleEngineSystemPrompt(), user, temperature: 0.3 })
|
|
980
|
+
const raw = parseJsonObject<{ proseRules?: unknown; dialogueRules?: unknown; descriptionRules?: unknown; boundaries?: unknown }>(text)
|
|
981
|
+
const strArray = (value: unknown): string[] =>
|
|
982
|
+
Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string' && v.trim() !== '') : []
|
|
983
|
+
const result = {
|
|
984
|
+
proseRules: strArray(raw.proseRules),
|
|
985
|
+
dialogueRules: strArray(raw.dialogueRules),
|
|
986
|
+
descriptionRules: strArray(raw.descriptionRules),
|
|
987
|
+
boundaries: strArray(raw.boundaries),
|
|
988
|
+
}
|
|
989
|
+
if (result.proseRules.length + result.dialogueRules.length + result.descriptionRules.length + result.boundaries.length === 0) {
|
|
990
|
+
throw new Error('写法提取失败:模型没有返回有效规则')
|
|
991
|
+
}
|
|
992
|
+
return result
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// ------------------------------------------------------------------ export
|
|
996
|
+
|
|
997
|
+
/** Export the whole book as one txt/md file. */
|
|
998
|
+
export function exportBook(outputDir: string, project: ProjectState, format: 'txt' | 'md'): { file: string; chars: number; chapters: number } {
|
|
999
|
+
const parts: string[] = []
|
|
1000
|
+
if (format === 'md') {
|
|
1001
|
+
parts.push(`# ${project.bookName}\n`)
|
|
1002
|
+
} else {
|
|
1003
|
+
parts.push(project.bookName, '')
|
|
1004
|
+
}
|
|
1005
|
+
const done = project.chapters.filter(c => c.file !== undefined)
|
|
1006
|
+
for (const chapter of done) {
|
|
1007
|
+
const body = readChapterFile(outputDir, chapter) ?? ''
|
|
1008
|
+
if (format === 'md') {
|
|
1009
|
+
parts.push(`\n## 第${chapter.no}章 ${chapter.title}\n`, body.trim(), '')
|
|
1010
|
+
} else {
|
|
1011
|
+
parts.push('', `第${chapter.no}章 ${chapter.title}`, '', body.trim(), '')
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
const content = parts.join('\n')
|
|
1015
|
+
const ext = format === 'md' ? 'md' : 'txt'
|
|
1016
|
+
const file = `《${safeFileName(project.bookName)}》全本.${ext}`
|
|
1017
|
+
writeFileSync(join(outputDir, file), content, 'utf8')
|
|
1018
|
+
return { file, chars: content.length, chapters: done.length }
|
|
1019
|
+
}
|