|
|
@@ -0,0 +1,191 @@
|
|
|
+/**
|
|
|
+ * 内容生成节点
|
|
|
+ * 生成叶节点(小节)的实际内容
|
|
|
+ */
|
|
|
+
|
|
|
+import { GraphState } from '../graph';
|
|
|
+import { bookStore } from '../book-generator.store';
|
|
|
+import { prisma } from '../../../models';
|
|
|
+import { callLLMWithMessages, callLLMWithTools, ChatMessage } from '../../../services/llm';
|
|
|
+import { createBookTools } from '../../../services/llm/book-tools';
|
|
|
+import { checkQuotaForWords, markGenerationInterrupted, getGeneratedWordCount } from '../../subscription/subscription.service';
|
|
|
+import { SUBSECTION_CONTENT_SYSTEM_PROMPT } from '../prompts/templates';
|
|
|
+import { PROGRESS, countWords } from '../utils';
|
|
|
+
|
|
|
+/**
|
|
|
+ * 构建小节内容生成消息
|
|
|
+ */
|
|
|
+function buildSubsectionContentMessages(
|
|
|
+ topic: string,
|
|
|
+ chapterTitle: string,
|
|
|
+ chapterSummary: string,
|
|
|
+ sectionTitle: string,
|
|
|
+ sectionSummary: string,
|
|
|
+ subsection: any
|
|
|
+): ChatMessage[] {
|
|
|
+ return [
|
|
|
+ { role: 'system', content: SUBSECTION_CONTENT_SYSTEM_PROMPT },
|
|
|
+ {
|
|
|
+ role: 'user',
|
|
|
+ content: `书名:《${topic}》
|
|
|
+章标题:${chapterTitle}
|
|
|
+章概述:${chapterSummary || ''}
|
|
|
+节标题:${sectionTitle}
|
|
|
+节概述:${sectionSummary}
|
|
|
+小节标题:${subsection.title}
|
|
|
+小节概述:${subsection.summary || ''}
|
|
|
+核心知识点:${(subsection.keyPoints || []).join('、')}
|
|
|
+预估字数:${subsection.estimatedWords || 500}字
|
|
|
+
|
|
|
+请撰写该小节的正文内容。`,
|
|
|
+ },
|
|
|
+ ];
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 内容生成节点
|
|
|
+ * 为所有叶节点(level=3小节)生成实际内容
|
|
|
+ */
|
|
|
+export async function writeChaptersNode(state: typeof GraphState.State): Promise<Partial<typeof GraphState.State>> {
|
|
|
+ console.log('[LangGraph] 生成章节内容, bookId:', state.bookId);
|
|
|
+
|
|
|
+ const book = await bookStore.getById(state.bookId);
|
|
|
+ if (!book || !book.outline) {
|
|
|
+ console.log('[LangGraph] 无大纲或书籍,跳过章节生成');
|
|
|
+ return { finished: true, progress: PROGRESS.CONTENT_END };
|
|
|
+ }
|
|
|
+
|
|
|
+ const bookIdNum = parseInt(state.bookId);
|
|
|
+
|
|
|
+ // 筛选出未完成内容的叶节点(支持断点续传)
|
|
|
+ const allSubsections = await prisma.bookChapter.findMany({
|
|
|
+ where: { bookId: bookIdNum, level: 3 },
|
|
|
+ orderBy: [
|
|
|
+ { parentId: 'asc' },
|
|
|
+ { number: 'asc' }
|
|
|
+ ],
|
|
|
+ });
|
|
|
+
|
|
|
+ const subsections = allSubsections.filter((s: any) => s.contentStatus !== 'completed');
|
|
|
+ const completedCount = allSubsections.length - subsections.length;
|
|
|
+
|
|
|
+ if (completedCount > 0) {
|
|
|
+ console.log(`[LangGraph] ✅ 跳过 ${completedCount} 个已生成内容的小节,待生成 ${subsections.length} 个`);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (subsections.length === 0) {
|
|
|
+ console.log('[LangGraph] ✅ 所有小节内容已完成,跳过内容生成');
|
|
|
+ return { finished: true, progress: PROGRESS.CONTENT_END };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 构建父节点映射
|
|
|
+ const chapterMap = new Map<number, any>();
|
|
|
+ const sectionMap = new Map<number, any>();
|
|
|
+
|
|
|
+ const chaptersAndSections = await prisma.bookChapter.findMany({
|
|
|
+ where: {
|
|
|
+ bookId: bookIdNum,
|
|
|
+ level: { in: [1, 2] }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ chaptersAndSections.forEach(c => {
|
|
|
+ if (c.level === 1) chapterMap.set(c.id, c);
|
|
|
+ if (c.level === 2) sectionMap.set(c.id, c);
|
|
|
+ });
|
|
|
+
|
|
|
+ const failedChapters: number[] = [];
|
|
|
+ const bookTools = createBookTools(state.bookId, bookStore);
|
|
|
+
|
|
|
+ let currentWordCount = await getGeneratedWordCount(state.bookId);
|
|
|
+ console.log(`[LangGraph] 当前已生成字数: ${currentWordCount}`);
|
|
|
+ console.log(`[LangGraph] 待生成小节数: ${subsections.length}`);
|
|
|
+
|
|
|
+ const totalSubsections = allSubsections.length;
|
|
|
+ let completedSubsections = completedCount;
|
|
|
+
|
|
|
+ for (const subsection of subsections) {
|
|
|
+ const parentSection = sectionMap.get(subsection.parentId || 0);
|
|
|
+ const parentChapter = parentSection ? chapterMap.get(parentSection.parentId || 0) : null;
|
|
|
+
|
|
|
+ // 额度监控
|
|
|
+ try {
|
|
|
+ const quotaCheck = await checkQuotaForWords(book.userId || 1, currentWordCount);
|
|
|
+ if (!quotaCheck.sufficient) {
|
|
|
+ console.warn(`[LangGraph] ⚠️ 额度不足,中断生成: ${quotaCheck.reason}`);
|
|
|
+ await markGenerationInterrupted(state.bookId, subsection.number - 1, currentWordCount);
|
|
|
+ return {
|
|
|
+ currentChapter: subsection.number,
|
|
|
+ progress: Math.round((completedSubsections / totalSubsections) * 80) + 15,
|
|
|
+ error: `额度不足中断:${quotaCheck.reason},已保存进度`
|
|
|
+ };
|
|
|
+ }
|
|
|
+ } catch (quotaErr) {
|
|
|
+ console.warn(`[LangGraph] 额度检查失败,继续生成`);
|
|
|
+ }
|
|
|
+
|
|
|
+ const chapterTitle = parentChapter?.title || '未知章';
|
|
|
+ const sectionTitle = parentSection?.title || '未知节';
|
|
|
+ const chapterSummary = parentChapter?.summary || '';
|
|
|
+ const sectionSummary = parentSection?.summary || '';
|
|
|
+
|
|
|
+ console.log(`[LangGraph] 生成${chapterTitle} - ${sectionTitle} - ${subsection.title}`);
|
|
|
+ const messages = buildSubsectionContentMessages(
|
|
|
+ state.topic,
|
|
|
+ chapterTitle,
|
|
|
+ chapterSummary,
|
|
|
+ sectionTitle,
|
|
|
+ sectionSummary,
|
|
|
+ subsection
|
|
|
+ );
|
|
|
+
|
|
|
+ try {
|
|
|
+ let content: string;
|
|
|
+ try {
|
|
|
+ const result = await callLLMWithTools(messages, bookTools);
|
|
|
+ content = result.text;
|
|
|
+ if (result.toolCalls.length > 0) {
|
|
|
+ console.log(`[LangGraph] 小节「${subsection.title}」使用了 ${result.toolCalls.length} 次工具调用`);
|
|
|
+ }
|
|
|
+ } catch (toolErr: any) {
|
|
|
+ console.log(`[LangGraph] 工具调用不可用,降级为普通调用`);
|
|
|
+ content = await callLLMWithMessages(messages);
|
|
|
+ }
|
|
|
+
|
|
|
+ const wordCount = countWords(content);
|
|
|
+ currentWordCount += wordCount;
|
|
|
+ completedSubsections++;
|
|
|
+
|
|
|
+ await bookStore.updateChapterById(subsection.id, {
|
|
|
+ content,
|
|
|
+ wordCount,
|
|
|
+ contentStatus: 'completed'
|
|
|
+ });
|
|
|
+
|
|
|
+ const progress = Math.round((completedSubsections / totalSubsections) * (PROGRESS.CONTENT_END - PROGRESS.CONTENT_START)) + PROGRESS.CONTENT_START;
|
|
|
+ await bookStore.update(state.bookId, { progress, status: 'generating' });
|
|
|
+ console.log(`[LangGraph] 小节「${subsection.title}」内容完成 (${completedSubsections}/${totalSubsections}),累计${currentWordCount}字,进度${progress}%`);
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ const errorMsg = error instanceof Error ? error.message : '失败';
|
|
|
+ await bookStore.updateChapterById(subsection.id, {
|
|
|
+ contentStatus: 'failed',
|
|
|
+ contentError: errorMsg
|
|
|
+ });
|
|
|
+ failedChapters.push(subsection.number);
|
|
|
+ console.error(`[LangGraph] 小节「${subsection.title}」内容失败:`, errorMsg);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 更新所有父节和章的状态
|
|
|
+ await prisma.bookChapter.updateMany({
|
|
|
+ where: { bookId: bookIdNum, level: { in: [1, 2] } },
|
|
|
+ data: { status: 'completed' }
|
|
|
+ });
|
|
|
+
|
|
|
+ return {
|
|
|
+ currentChapter: chaptersAndSections.filter(c => c.level === 1).length,
|
|
|
+ progress: PROGRESS.CONTENT_END,
|
|
|
+ error: failedChapters.length > 0 ? `小节${failedChapters.join(',')}失败` : undefined
|
|
|
+ };
|
|
|
+}
|