import { BaseWorkflowCommand, CommandExecuteParams } from './base-command' import { useProjectStore } from '../../../stores/project-store' import { getPromptTemplate } from '../../prompt-templates' import { ChapterPromptBuilder } from '../../prompts/prompt-builder' import { ipc } from '../../ipc-client' import { DIR_PROMPTS } from '../../../shared/project-paths' import type { ChapterInfo } from '../chapter-workflow' import { buildCanonContext, renderCanonContext, runConsistencyGate, } from '../../narrative-consistency' export class GenerateDraftCommand extends BaseWorkflowCommand { constructor(private chapterInfo: ChapterInfo) { super() } async execute({ context, callbacks }: CommandExecuteParams): Promise { const project = useProjectStore.getState().currentProject if (!project) throw new Error('未打开项目') callbacks.log('拼装章节上下文 (强类型注入中)...') const architecture = await this.readArchitecture(project.path) const projectPrompts = await this.readProjectPrompts(project.path) const mergedGuidance = [project.novelConfig.globalGuidance || '', projectPrompts].filter(Boolean).join('\n\n') const characterState = await this.readCharacterStates(project.path) const allCharacters = await ipc.invoke('db:character-get-all').catch(() => [] as Array<{ name: string; role: string; currentState?: { location?: string; powerLevel?: string; physicalState?: string; mentalState?: string; keyItems?: string; recentEvents?: string; updatedAtChapter?: number } }>) let futureBlueprintsStr = '(无后续蓝图)' try { const { loadDirectoryBlueprints } = await import('../directory-workflow') const allBlueprints = await loadDirectoryBlueprints() const futureBlueprintsArr = allBlueprints.filter( b => b.chapterNumber > this.chapterInfo.chapterNumber && b.chapterNumber <= this.chapterInfo.chapterNumber + 5 ) if (futureBlueprintsArr.length > 0) { futureBlueprintsStr = futureBlueprintsArr.map(b => `第${b.chapterNumber}章 ${b.title}:${b.keyEvents}`).join('\n') } } catch { /* 忽略 */ } // ========================================== // [Canon] 构造叙事一致性 Canon Context(所有生成路径都强制经过此闸门) // ========================================== let canonRendered = '' let canonForValidation: import('../../narrative-consistency').CanonContext | null = null try { const core = await ipc.invoke('db:project-core-get').catch(() => null as null | { premise?: string; charactersArch?: string; worldbuilding?: string; synopsis?: string }) // 拆解 architecture 字符串回填到结构化字段(architecture 由 readArchitecture 按 premise/charactersArch/worldbuilding/synopsis 顺序拼装) const archParts = (architecture || '').split(/\n\n---\n\n/) canonForValidation = await buildCanonContext({ chapterNumber: this.chapterInfo.chapterNumber, architecture: { premise: core?.premise ?? archParts[0] ?? '', charactersArch: core?.charactersArch ?? archParts[1] ?? '', worldbuilding: core?.worldbuilding ?? archParts[1] ?? '', synopsis: core?.synopsis ?? archParts[3] ?? '', }, characters: (allCharacters || []).map(c => ({ name: c.name, role: c.role, currentState: c.currentState, })), chapterGoal: typeof this.chapterInfo === 'object' ? JSON.stringify(this.chapterInfo) : String(this.chapterInfo), previousEnding: '', // 下面在非首章分支填充 ragContext: '', // 下面在非首章分支填充 writingStyle: project.novelConfig.writingStyle || '', globalGuidance: mergedGuidance, }) canonRendered = renderCanonContext(canonForValidation) callbacks.log(` 🛡️ 已注入 Canon 上下文(时间线 ${canonForValidation.timeline.length} 条 / 角色状态 ${canonForValidation.characterStates.length} 条 / 剧情线 ${canonForValidation.openPlotLines.length} 条)`) } catch (e) { callbacks.log(` ⚠️ Canon 上下文构造失败,继续以基础上下文生成:${String(e)}`) } const isFirstChapter = this.chapterInfo.chapterNumber === 1 const templateKey = isFirstChapter ? 'first_chapter_draft' : 'next_chapter_draft' const template = getPromptTemplate(templateKey) if (!template) throw new Error(`未找到模板: ${templateKey}`) // ========================================== // Prompt 构建——按「稳定前缀 → 可变后缀」排列 // 以最大化 LLM 上下文缓存命中率 // ========================================== const promptBuilder = new ChapterPromptBuilder(template) // ---- 缓存命中区(跨章稳定,前缀对齐)---- .withArchitecture(architecture) .withGlobalGuidance(mergedGuidance) .withWritingStyle(project.novelConfig.writingStyle || '') .withNovelConfig(project.novelConfig) .withWordNumber(project.novelConfig.wordsPerChapter) if (!isFirstChapter) { // 从蓝图 JSON 的 notes 字段读取章节要点时间线(按序拼装,利于前缀缓存) const chapterTimeline = await this.readChapterNotesTimeline(project.path, this.chapterInfo.chapterNumber) callbacks.log(` 📋 已加载章节要点时间线(${chapterTimeline.length} 字)`) let previousEnding = '' try { const prevNum = this.chapterInfo.chapterNumber - 1 const meta = await ipc.invoke('db:draft-get-finalized', prevNum) if (meta) { const full = await ipc.invoke('db:draft-get-full', meta.id) if (full?.content) previousEnding = full.content.slice(-1000) } } catch { /* 忽略 */ } let filteredContext = '' try { callbacks.log(' 🔍 检索知识库相关片段...') let searchQuery = `${this.chapterInfo.title} ${this.chapterInfo.keyEvents} ${this.chapterInfo.characters.join(' ')}` if (this.chapterInfo.knowledgeQueryHint?.trim()) { searchQuery += ` ${this.chapterInfo.knowledgeQueryHint.trim()}` callbacks.log(` 📌 追加用户检索关键词:${this.chapterInfo.knowledgeQueryHint.trim()}`) } const results = await ipc.invoke('kb:search', searchQuery, 5) filteredContext = results.length > 0 ? results.map((r: { fileName: string; score: number; text: string }, i: number) => `[${i + 1}] (${r.fileName}, 相关度 ${(r.score * 100).toFixed(0)}%)\n${r.text}`).join('\n\n') : '(知识库中无相关内容)' } catch { filteredContext = '(知识库检索不可用)' } promptBuilder // ---- 缓存命中区续(要点时间线按序追加,前缀对齐)---- .withGlobalSummary(chapterTimeline) .withCharacterStates(characterState) // ---- 缓存失效区(逐章变化)---- .withPreviousEnding(previousEnding || '(无前文)') .withChapterInfo(this.chapterInfo) .withFutureBlueprints(futureBlueprintsStr) .withFilteredContext(filteredContext) .withShortSummary('') .withUserGuidance(this.chapterInfo.userGuidance?.trim() || '(无微操指导)') // [Canon] 二次注入:在 RAG 与上一章结尾就绪后,把它们写回 Canon 并重渲染 if (canonForValidation) { canonForValidation.previousEnding = previousEnding || '(无前文)' canonForValidation.ragContext = filteredContext || '(无 RAG 检索结果)' canonRendered = renderCanonContext(canonForValidation) } } // [Canon] 注入叙事一致性上下文(强制最高优先级) if (canonRendered) { promptBuilder.withCanonContext(canonRendered) } // Token 预算管控:中文约 1.5 字符/token,预留 4K 给输出 const prompt = promptBuilder.build() const estimatedTokens = Math.ceil(prompt.length / 1.5) const TOKEN_BUDGET = 28000 if (estimatedTokens > TOKEN_BUDGET) { callbacks.log(`⚠️ Prompt 预估 ${estimatedTokens} tokens,超出预算 ${TOKEN_BUDGET},请考虑精简上下文`) } callbacks.log('调用 AI 生成章节草稿...') const draftText = await this.callLLMWithBuilder(promptBuilder, callbacks) const cleanDraftText = this.stripThinkingTags(draftText) // ========================================== // [Canon] 生成后一致性 Gate + 自动修复 // ========================================== let finalDraft = cleanDraftText if (canonForValidation) { try { const gateResult = await runConsistencyGate({ chapterNumber: this.chapterInfo.chapterNumber, chapterContent: cleanDraftText, canon: canonForValidation, }) callbacks.log(` 🛡️ [Gate] ${gateResult.verdict}: ${gateResult.report}`) if (gateResult.verdict === 'BLOCK') { throw new Error(`生成草稿被叙事一致性 Gate 阻止:${gateResult.blockingReasons.join(';')}`) } if (gateResult.verdict === 'REPAIR' && gateResult.repairedContent) { finalDraft = gateResult.repairedContent callbacks.log(` 🛡️ [Canon] 自动修复 ${gateResult.repairAttempts} 轮后保存修复稿`) } if (gateResult.issues.length === 0) { callbacks.log(` ✅ [Canon] 一致性检查通过`) } const remaining = gateResult.issues.map(i => i.issue) if (remaining.length > 0) context.data.consistencyWarnings = remaining context.data.consistencyReport = { verdict: gateResult.verdict, totalIssues: gateResult.issues.length, repairAttempts: gateResult.repairAttempts, remaining: gateResult.issues.length, } } catch (e) { callbacks.log(` ❌ [Canon] Gate 异常:${String(e)}`) throw e } } // 落于数据库 const nextVersion: number = await ipc.invoke('db:draft-next-version', this.chapterInfo.chapterNumber) const createResult = await ipc.invoke('db:draft-create', { chapterNumber: this.chapterInfo.chapterNumber, version: nextVersion, source: 'write', content: finalDraft, wordCount: finalDraft.length, }) const pseudoPath = createResult.id ? `vela://draft/${createResult.id}` : `vela://draft/ch${this.chapterInfo.chapterNumber}/v${nextVersion}` context.data.draft = finalDraft context.data.draftContent = finalDraft context.data.draftPath = pseudoPath context.data.chapterNumber = this.chapterInfo.chapterNumber context.data.chapterInfo = this.chapterInfo context.data.mergedGuidance = mergedGuidance context.data.shortSummary = '' useProjectStore.getState().refreshFileTree() try { const { useDraftStore } = await import('../../../stores/draft-store') await useDraftStore.getState().loadAllDrafts() } catch { /* 忽略 */ } try { const { useEditorStore } = await import('../../../stores/editor-store') useEditorStore.getState().openFile({ id: pseudoPath, name: `第${this.chapterInfo.chapterNumber}章 ${this.chapterInfo.title} v${nextVersion}`, type: 'chapter', filePath: pseudoPath, content: cleanDraftText, }) } catch { /* 忽略 */ } callbacks.log(`✅ 草稿已自动入库保存为版本 v${nextVersion}(${draftText.length} 字)`) return draftText } // --- 抽取自原文件的辅助方法 --- // eslint-disable-next-line @typescript-eslint/no-unused-vars private async readArchitecture(_projectPath: string): Promise { const core = await ipc.invoke('db:project-core-get') const parts: string[] = [] if (core?.premise) parts.push(core.premise.trim()) if (core?.charactersArch) parts.push(core.charactersArch.trim()) if (core?.worldbuilding) parts.push(core.worldbuilding.trim()) if (core?.synopsis) parts.push(core.synopsis.trim()) return parts.join('\n\n---\n\n') } private async readProjectPrompts(projectPath: string): Promise { try { const files = await ipc.invoke('fs:list-dir', `${projectPath}/${DIR_PROMPTS}`) const mdFiles = files.filter((f: { isDir: boolean; name: string }) => !f.isDir && f.name.endsWith('.md')) if (mdFiles.length === 0) return '' const parts: string[] = [] for (const f of mdFiles) { const result = await ipc.invoke('fs:read-file', f.path) if (result.success && result.content.trim()) { parts.push(`## 项目专属指导(${f.name.replace(/\.md$/, '')})\n${result.content.trim()}`) } } return parts.join('\n\n') } catch { return '' } } // eslint-disable-next-line @typescript-eslint/no-unused-vars private async readCharacterStates(_projectPath: string): Promise { try { const allChars = await ipc.invoke('db:character-get-all') const states: string[] = [] for (const card of allChars) { if (card.name && card.currentState) { const cs = card.currentState states.push( `${card.name}(${card.role || '未知'})| ` + `境界:${cs.powerLevel || '未知'} | ` + `位置:${cs.location || '未知'} | ` + `身体:${cs.physicalState || '正常'} | ` + `心理:${cs.mentalState || '正常'} | ` + `道具:${cs.keyItems || '无'} | ` + `最近:第${cs.updatedAtChapter || 0}章 ${cs.recentEvents || ''}` ) } } return states.length > 0 ? `【角色状态档案】\n${states.join('\n')}` : '(暂无角色状态档案)' } catch { return '(角色状态档案读取失败)' } } /** * 从蓝图 JSON 的 notes 字段读取章节要点时间线。 * 近 5 章完整收录;更早期仅保留标题行,控制总量 ≤ 3000 字。 * 按序拼装保证前缀稳定,最大化 LLM 上下文缓存命中。 */ private async readChapterNotesTimeline(_projectPath: string, currentChapter: number): Promise { const FULL_WINDOW = 5 // 近 N 章完整收录 const MAX_CHARS = 3000 // 总量上限 const lines: string[] = [] for (let i = 1; i < currentChapter; i++) { try { const bp = await ipc.invoke('db:blueprint-get', i) if (!bp) continue const isRecent = i >= currentChapter - FULL_WINDOW if (isRecent && bp.notes?.trim()) { // 近 N 章:完整收录要点 lines.push(`【第${i}章 ${bp.title || ''}】\n${bp.notes.trim()}`) } else { // 远期章节:仅保留标题行(节省 Token) lines.push(`【第${i}章 ${bp.title || ''}】`) } } catch { /* 忽略单章读取失败 */ } } // Token 预算控制:超限时从最早的完整要点开始精简 let result = lines.join('\n\n') if (result.length > MAX_CHARS) { // 保留近章完整内容,远期章节已经是标题行了 result = result.slice(-MAX_CHARS) } return result || '(无章节要点)' } }