/** * LangGraph 书籍生成 - API 路由 * 支持书籍创建时的预估显示 */ import Router from '@koa/router'; import { Context } from 'koa'; import { langGraphGenerator, getScaleConfig, resolveGenLevel, mapBookTypeToGenLevel } from './index'; import { bookStore } from './book-generator.store'; import { prisma } from '../../models'; import { estimateBookWords, estimateAudioMinutesFromWords, checkBookGenerationQuota, checkAudioQuota } from '../subscription/subscription.service'; import { optionalAuth } from '../../middleware/auth'; import { getAllBookTypes, getDetectableTypes, getBookTypeConfig, BOOK_TYPE_CONFIG, DETECTABLE_TYPES } from './book-type-config'; import { callLLMWithMessages, callLLM, ChatMessage } from '../../services/llm'; import { cleanLlmShortText, extractJsonFromResponse } from '../../services/llm/response-cleaner'; import { createVideoProjectFromBook, generateVideoForProject } from '../video-generator/video-generator.service'; import { mergeChapterAudios } from '../player/player.service'; import { exec } from 'child_process'; import { promisify } from 'util'; import fs from 'fs/promises'; import path from 'path'; import { advanceChapter, regenerateChapter } from './stage-manager'; import { deepPlanBookNode } from './nodes/deep-plan.node'; import { richOutlineNode } from './nodes/rich-outline.node'; import { writeChaptersParallelNode } from './nodes/content.node'; import { continuityEditNode } from './nodes/continuity-edit.node'; import { GraphState } from './graph'; const execPromise = promisify(exec); // 开发环境测试用户ID const TEST_USER_ID = '1'; const router = new Router(); /** * GET /api/book-generator/langgraph/estimate * 获取书籍规模预估信息 */ router.get('/estimate', async (ctx: Context) => { const { scale, userId, bookType } = ctx.query as { scale?: string; userId?: string; bookType?: string }; if (!scale) { ctx.status = 400; ctx.body = { code: 1, message: '请提供书籍规模' }; return; } const scaleConfig = getScaleConfig(scale); const totalWords = scaleConfig.totalWords; const audioMinutes = estimateAudioMinutesFromWords(totalWords); // 章节数 const estimatedChapters = scaleConfig.isShortArticle ? 1 : scaleConfig.chapters; // 大纲层级(优先使用书籍类型映射) const genLevel = bookType && bookType !== 'auto' ? mapBookTypeToGenLevel(bookType) : resolveGenLevel(scale); const genLevelDescriptions: Record = { 1: { label: '仅章', desc: '无节和小节,适合短文、小说' }, 2: { label: '章→节', desc: '有节无小节,适合科普、商业书' }, 3: { label: '章→节→小节', desc: '完整三级结构,适合教材、技术书' }, }; const result: any = { scale, scaleConfig, audioMinutes: { min: estimateAudioMinutesFromWords(Math.round(totalWords * 0.8)), max: estimateAudioMinutesFromWords(Math.round(totalWords * 1.2)), avg: audioMinutes }, estimatedChapters, defaultGenLevel: genLevel, genLevelInfo: genLevelDescriptions[genLevel] || { label: '章→节', desc: '' }, }; // 如果提供了 userId,同时检查用户配额 if (userId) { const quotaCheck = await checkBookGenerationQuota(parseInt(userId), scale); result.quotaCheck = quotaCheck; } ctx.body = { code: 0, message: 'success', data: result }; }); /** * POST /api/book-generator/langgraph/detect-scale * AI 预览检测书籍规模(创建前调用,让用户看到检测结果) */ router.post('/detect-scale', async (ctx) => { const { description } = ctx.request.body as { description?: string }; if (!description) { ctx.body = { code: 1, message: '缺少 description' }; return; } const scale = await autoDetectBookType('', description); const config = getScaleConfig(scale); ctx.body = { code: 0, data: { bookScale: scale, totalWords: config.totalWords, chapters: config.chapters, isShortArticle: config.isShortArticle, }, }; }); /** * GET /api/book-generator/langgraph/book-types * 获取所有书籍类型配置(供前端显示) */ router.get('/book-types', async (ctx: Context) => { const types = getAllBookTypes().map(t => ({ key: t.key, label: t.label, description: t.description, chapters: t.chapters, totalWords: t.totalWords, audioMinutes: t.audioMinutes, chapterWords: t.chapterWords, structureFormat: t.structureFormat, readingDifficulty: t.readingDifficulty, isShortArticle: t.isShortArticle, })); ctx.body = { code: 0, message: 'success', data: types, }; }); /** * POST /api/book-generator/langgraph/detect-book-type * AI 自动检测书籍类型 */ router.post('/detect-book-type', async (ctx: Context) => { const { title, description } = ctx.request.body as { title?: string; description?: string; }; if (!title) { ctx.status = 400; ctx.body = { code: 1, message: '请提供书籍标题' }; return; } const detectableTypes = DETECTABLE_TYPES; // 构建类型特征描述,帮助 AI 分类 const typeDescriptions = detectableTypes.map(key => { const t = BOOK_TYPE_CONFIG[key]; return `- ${t.key}: ${t.description},${t.chapters}章,总字数约${t.totalWords}字,每章约${t.chapterWords}字,结构形式:${t.structureFormat},阅读难度:${t.readingDifficulty}`; }).join('\n'); const messages: ChatMessage[] = [ { role: 'system', content: `你是一位专业的图书分类编辑。根据用户提供的标题和描述,判断这本书最可能属于以下哪种类型: 可选类型: ${typeDescriptions} ## 分类规则 1. 分析标题中的关键词(如"科普""青少年""专业""小说"等) 2. 分析描述中的目标读者、写作风格、内容深度 3. 匹配最符合的类型 ## 输出格式 必须返回 JSON,不要包含 markdown 代码块标记: { "detectedType": "类型 key", "confidence": 0.85, "reasoning": "分类理由,1-2句话" } confidence 是 0-1 的数值,表示置信程度。`, }, { role: 'user', content: `标题:《${title}》\n描述:${description || '无'}`, }, ]; try { const response = await callLLMWithMessages(messages); const parsed = parseDetectResult(response); if (!parsed) { // 降级到关键词匹配 const fallback = keywordFallback(title, description || ''); ctx.body = { code: 0, message: 'success', data: { detectedType: fallback.detectedType, confidence: 0.5, reasoning: '基于关键词匹配(AI 解析失败,使用降级策略)', config: getBookTypeConfig(fallback.detectedType), }, }; return; } ctx.body = { code: 0, message: 'success', data: { detectedType: parsed.detectedType, confidence: parsed.confidence, reasoning: parsed.reasoning, config: getBookTypeConfig(parsed.detectedType), }, }; } catch (error: any) { // 最终降级:关键词匹配 const fallback = keywordFallback(title, description || ''); ctx.body = { code: 0, message: 'success', data: { detectedType: fallback.detectedType, confidence: 0.3, reasoning: 'LLM 调用失败,使用关键词匹配降级', config: getBookTypeConfig(fallback.detectedType), }, }; } }); function parseDetectResult(text: string): { detectedType: string; confidence: number; reasoning: string } | null { try { const match = text.match(/\{[\s\S]*\}/); if (!match) return null; const data = JSON.parse(match[0]); if (!data.detectedType) return null; // 验证类型是否有效 if (!DETECTABLE_TYPES.includes(data.detectedType)) { // 尝试模糊匹配 const found = DETECTABLE_TYPES.find(t => data.detectedType.includes(t) || t.includes(data.detectedType)); if (!found) return null; data.detectedType = found; } return { detectedType: data.detectedType, confidence: Math.min(1, Math.max(0, data.confidence || 0.5)), reasoning: data.reasoning || '', }; } catch { return null; } } function keywordFallback(_title: string, _description: string): { detectedType: string } { // 完全由 AI 判断,fallback 只在 LLM 完全失败时作为最后保险 return { detectedType: '1000' }; // 最小规模 } /** * AI 根据描述自动生成书名 */ async function autoGenerateTitle(description: string): Promise { const prompt = `根据以下描述起一个书名。要求:中文不超过20字,英文不超过20个单词。只返回书名,不要任何解释、思考或前缀。 描述:${description.substring(0, 200)} 书名:`; try { const response = await callLLM(prompt); const title = cleanLlmShortText(response, { maxLength: 50, maxChineseChars: 20, maxEnglishWords: 20, }); console.log(`[LangGraph] AI 生成书名: "${title}"`); return title || '未命名书籍'; } catch (e) { console.warn('[LangGraph] AI 自动生成书名失败,使用默认:', e); return '未命名书籍'; } } /** * AI 自动检测书籍规模(字数等级),后端静默调用 * AI 分析描述中的字数要求,以 JSON 格式返回,供程序提取 */ async function autoDetectBookType(title: string, description: string): Promise { console.log(`[autoDetectBookType] 被调用: title="${title}", desc前50="${description?.substring(0, 50)}"`); const messages: ChatMessage[] = [ { role: 'system', content: `你是一位专业的图书分类编辑。根据标题和描述,判断书籍适合的字数规模。 ## 判断规则 **规则1 - 字数直接返回**:如果描述里有"X字"(如"200字""500字""几千字"),直接返回X作为bookScale值,不要映射到预定义规模。 - 描述含"200字"→返回200 - 描述含"500字"→返回500 - 描述含"几千字"→估计约3000,返回3000 - 描述含"上万字"→估计约10000,返回10000 **规则2 - 无法判断**:描述中没有字数信息 → 返回1000 ## 输出格式(必须严格JSON) {"bookScale": 数字} 只返回JSON,不要任何其他文字。`, }, { role: 'user', content: `标题:《${title}》\n描述:${description}`, }, ]; try { const response = await callLLMWithMessages(messages); const trimmed = response.trim(); console.log(`[autoDetectBookType] AI原始响应: ${trimmed.substring(0, 200)}`); // 尝试解析 JSON,提取 bookScale(实际字数) const jsonMatch = trimmed.match(/\{[\s\S]*\}/); if (jsonMatch) { try { const data = JSON.parse(jsonMatch[0]); if (typeof data.bookScale === 'number') { const wordCount = data.bookScale; console.log(`[autoDetectBookType] AI返回: bookScale=${wordCount}`); // 直接返回实际字数,getBookTypeConfig 支持任意数字 key return String(wordCount); } } catch { // JSON解析失败,忽略 } } // JSON解析失败 → 默认返回最小规模 console.log(`[autoDetectBookType] AI响应无法解析,使用默认值1000`); return '1000'; } catch (error: any) { console.error(`[autoDetectBookType] AI调用异常:`, error?.message || error); return '1000'; } } /** * POST /api/book-generator/langgraph/smart-recommend * 根据书籍描述智能推荐难度、人群、风格、领域 */ router.post('/smart-recommend', async (ctx: Context) => { try { const { description, title } = ctx.request.body as { description: string; title?: string }; if (!description || !description.trim()) { ctx.status = 400; ctx.body = { code: 1, message: '描述不能为空' }; return; } // 默认推荐值 const defaultResult = { knowledgeLevel: '入门', targetAudience: '通用', style: '轻松幽默', industry: '教育', reason: '默认推荐', }; const prompt = `根据以下书籍描述,推荐合适的知识难度、目标人群、写作风格和行业领域。 书名:${title || '未命名'} 描述:${description} 请以 JSON 格式返回推荐结果,不要包含任何其他文字: { "knowledgeLevel": "入门|进阶|专业", "targetAudience": "推荐的目标人群", "style": "推荐风格", "industry": "推荐行业领域", "reason": "简要说明推荐理由" }`; const messages: ChatMessage[] = [ { role: 'system', content: '你是一位专业的图书策划编辑,擅长根据书籍内容推荐合适的定位。' }, { role: 'user', content: prompt }, ]; const response = await callLLMWithMessages(messages); const result = extractJsonFromResponse(response) || defaultResult; ctx.body = { code: 0, message: 'success', data: result }; } catch (error) { console.error('智能推荐失败:', error); // 返回默认值而不是错误 ctx.body = { code: 0, message: 'success', data: { knowledgeLevel: '入门', targetAudience: '通用', style: '轻松幽默', industry: '教育', reason: 'AI服务暂时不可用,使用默认推荐', }, }; } }); /** * POST /api/book-generator/langgraph/books * 使用 LangGraph 创建并生成书籍(异步,自动生成大纲和内容) * AI 根据标题+描述自动判断书籍类型 * * 优化策略: * 1. 并行执行 autoGenerateTitle + autoDetectBookType(串行→并行,耗时减半) * 2. 先创建书籍返回响应,AI 结果异步回填(用户无需等待 AI 完成即可看到书籍) */ router.post('/books', optionalAuth, async (ctx: Context) => { try { const body = ctx.request.body as { title: string; description: string; bookScale?: string; bookType?: string; genLevel?: number; generateForeword?: boolean; generateAfterword?: boolean; immediateGenerate?: boolean; }; if (!body.description) { ctx.status = 400; ctx.body = { code: 1, message: '描述不能为空' }; return; } // 获取用户 ID(未登录时使用测试用户) const userId = ctx.state.user?.userId ? parseInt(ctx.state.user.userId as string) : parseInt(TEST_USER_ID); const providedTitle = body.title?.trim(); // 只接受数字格式的 bookScale(如 '200', '1000', '130000'),非数字当作未提供 const providedBookScale = body.bookScale && /^\d+$/.test(body.bookScale) ? body.bookScale : undefined; // ========== 快速路径:用户已提供 title 和 bookScale,无需 AI ========== if (providedTitle && providedBookScale) { const bookScale = providedBookScale; const genLevel = body.genLevel !== undefined ? resolveGenLevel(bookScale, body.genLevel) : body.bookType && body.bookType !== 'auto' ? mapBookTypeToGenLevel(body.bookType) : resolveGenLevel(bookScale); const scaleConfig = getScaleConfig(bookScale); const totalWords = scaleConfig.totalWords; const audioQuota = await checkAudioQuota(userId, totalWords); if (!audioQuota.allowed) { ctx.status = 403; ctx.body = { code: 1, message: audioQuota.reason, data: { estimatedWords: totalWords, estimatedAudioMinutes: estimateAudioMinutesFromWords(totalWords), remainingMinutes: audioQuota.remainingMinutes, totalMinutes: audioQuota.totalMinutes } }; return; } const book = await bookStore.create({ userId, title: providedTitle, description: body.description, bookScale, totalChapters: scaleConfig.isShortArticle ? 1 : scaleConfig.chapters, estimatedWords: totalWords, }); if (body.immediateGenerate === false) { ctx.body = { code: 0, message: '书籍创建成功(交互模式)', data: { book, genStage: 'draft', mode: 'interactive' } }; return; } await bookStore.update(book.id, { genStage: 'outlining' }); langGraphGenerator.generate(book.id.toString(), body.description, bookScale, genLevel) .then(() => console.log(`[LangGraph] 生成完成: bookId=${book.id}`)) .catch(err => { console.error(`[LangGraph] 生成失败: bookId=${book.id}`, err); bookStore.update(book.id.toString(), { genStage: 'failed', errorMsg: err instanceof Error ? err.message : '生成失败', failedStage: 'outlining' }); }); ctx.body = { code: 0, message: '书籍创建成功,生成已开始', data: { book, genStage: 'outlining', mode: 'async' } }; return; } // ========== 需要 AI 辅助的路径:先创建书籍,再异步回填 ========== // 使用临时标题/默认规模先创建书籍,立即返回给前端 // 规模会由 AI 异步回填 const fallbackScale = providedBookScale || '1000'; const fallbackTitle = providedTitle || '正在生成标题...'; const fallbackConfig = getScaleConfig(fallbackScale); const fallbackWords = fallbackConfig.totalWords; // 配额预检查(使用默认规模估算,后续可能因 AI 检测结果调整) const audioQuota = await checkAudioQuota(userId, fallbackWords); if (!audioQuota.allowed) { ctx.status = 403; ctx.body = { code: 1, message: audioQuota.reason, data: { estimatedWords: fallbackWords, estimatedAudioMinutes: estimateAudioMinutesFromWords(fallbackWords), remainingMinutes: audioQuota.remainingMinutes, totalMinutes: audioQuota.totalMinutes } }; return; } // 先创建书籍,立即返回 const book = await bookStore.create({ userId, title: fallbackTitle, description: body.description, bookScale: fallbackScale, totalChapters: fallbackConfig.isShortArticle ? 1 : fallbackConfig.chapters, estimatedWords: fallbackWords, }); // 标记为 AI 正在准备中 const needTitle = !providedTitle; const needScale = !providedBookScale; const aiPreparing = needTitle || needScale; if (body.immediateGenerate === false) { // 交互模式:异步填充 AI 结果,但不需要启动生成 if (aiPreparing) { fillAiResultsAsync(book.id.toString(), body.description, providedTitle, providedBookScale); } ctx.body = { code: 0, message: '书籍创建成功(交互模式)', data: { book, genStage: 'draft', mode: 'interactive', aiPreparing } }; return; } // 非交互模式:需要等 AI 完成后才能确定 bookScale/genLevel 启动生成 await bookStore.update(book.id, { genStage: 'outlining' }); // 立即返回响应 ctx.body = { code: 0, message: '书籍创建成功,AI 正在准备标题和规模...', data: { book, genStage: 'outlining', mode: 'async', aiPreparing, }, }; // 异步:并行执行 AI 生成标题 + 检测规模,然后启动内容生成 (async () => { try { let title = providedTitle; let bookScale = providedBookScale; // 并行执行 AI 调用 const aiPromises: Promise[] = []; const titleIdx = needTitle ? aiPromises.push(autoGenerateTitle(body.description)) - 1 : -1; const scaleIdx = needScale ? aiPromises.push(autoDetectBookType(providedTitle || '', body.description)) - 1 : -1; if (aiPromises.length > 0) { const results = await Promise.all(aiPromises); if (titleIdx >= 0) { title = results[titleIdx] as string; console.log(`[LangGraph] AI 自动生成书名: "${title}"`); } if (scaleIdx >= 0) { bookScale = results[scaleIdx] as string; console.log(`[LangGraph] AI 自动检测规模: ${title} -> ${bookScale}`); } } // 回填 AI 结果到数据库 const scaleConfig = getScaleConfig(bookScale!); await bookStore.update(book.id.toString(), { title: title!, bookScale: bookScale!, totalChapters: scaleConfig.isShortArticle ? 1 : scaleConfig.chapters, estimatedWords: scaleConfig.totalWords, }); // 确定生成层级并启动内容生成 const genLevel = body.genLevel !== undefined ? resolveGenLevel(bookScale!, body.genLevel) : body.bookType && body.bookType !== 'auto' ? mapBookTypeToGenLevel(body.bookType) : resolveGenLevel(bookScale!); await langGraphGenerator.generate(book.id.toString(), body.description, bookScale!, genLevel); console.log(`[LangGraph] 生成完成: bookId=${book.id}`); } catch (err) { console.error(`[LangGraph] AI 准备或生成失败: bookId=${book.id}`, err); bookStore.update(book.id.toString(), { genStage: 'failed', errorMsg: err instanceof Error ? err.message : '生成失败', failedStage: 'outlining', }); } })(); } catch (error) { console.error('启动失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '启动失败', }; } }); /** * 异步填充 AI 结果(交互模式使用,不启动生成) */ async function fillAiResultsAsync( bookId: string, description: string, providedTitle?: string, providedBookScale?: string, ): Promise { console.log(`[fillAiResultsAsync] 开始: bookId=${bookId}, providedTitle="${providedTitle}", providedBookScale="${providedBookScale}", desc前50字="${description?.substring(0, 50)}"`); try { let title = providedTitle; let bookScale = providedBookScale; const needTitle = !title; const needScale = !bookScale; const aiPromises: Promise[] = []; const titleIdx = needTitle ? aiPromises.push(autoGenerateTitle(description)) - 1 : -1; const scaleIdx = needScale ? aiPromises.push(autoDetectBookType(title || '', description)) - 1 : -1; if (aiPromises.length > 0) { const results = await Promise.all(aiPromises); if (titleIdx >= 0) { title = results[titleIdx] as string; console.log(`[LangGraph] AI 自动生成书名 (交互): "${title}"`); } if (scaleIdx >= 0) { bookScale = results[scaleIdx] as string; console.log(`[LangGraph] AI 自动检测规模 (交互): ${title} -> ${bookScale}`); } } const scaleConfig = getScaleConfig(bookScale!); await bookStore.update(bookId.toString(), { title: title!, bookScale: bookScale!, totalChapters: scaleConfig.isShortArticle ? 1 : scaleConfig.chapters, estimatedWords: scaleConfig.totalWords, }); } catch (err) { console.warn(`[LangGraph] AI 异步填充失败: bookId=${bookId}`, err); } } /** * GET /api/book-generator/langgraph/books * 获取书籍列表 * 返回:公开的书籍(有公开音频)+ 当前用户自己的书籍 * 注意:此接口已废弃,请使用 /public-books 或 /my-books */ router.get('/books', optionalAuth, async (ctx: Context) => { try { const userId = ctx.state.user?.userId; const userIdNum = userId ? parseInt(userId as string) : undefined; // 获取公开书籍(有公开音频的书籍) const publicBooks = await bookStore.getPublicBooks(); // 如果用户已登录,获取用户自己的书籍 let userBooks: any[] = []; if (userIdNum) { userBooks = await bookStore.getAllByUser(userIdNum, false); } // 合并并去重(按 id) const bookMap = new Map(); publicBooks.forEach(b => bookMap.set(b.id, b)); userBooks.forEach(b => { if (!bookMap.has(b.id)) { bookMap.set(b.id, b); } }); const books = Array.from(bookMap.values()); ctx.body = { code: 0, message: 'success', data: { books } }; } catch (error) { console.error('查询失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '查询失败' }; } }); /** * GET /api/book-generator/langgraph/public-books * 获取公开书籍列表(首页专用) * 只返回有公开音频的书籍 */ router.get('/public-books', async (ctx: Context) => { try { const publicBooks = await bookStore.getPublicBooks(); ctx.body = { code: 0, message: 'success', data: { books: publicBooks } }; } catch (error) { console.error('查询公开书籍失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '查询失败' }; } }); /** * GET /api/book-generator/langgraph/my-books * 获取当前用户自己的书籍列表(管理页专用) * 只返回当前用户创建的书籍 */ router.get('/my-books', optionalAuth, async (ctx: Context) => { try { const userId = ctx.state.user?.userId; if (!userId) { ctx.status = 401; ctx.body = { code: 1, message: '请先登录' }; return; } const userBooks = await bookStore.getAllByUser(parseInt(userId as string), false); ctx.body = { code: 0, message: 'success', data: { books: userBooks } }; } catch (error) { console.error('查询用户书籍失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '查询失败' }; } }); /** * PUT /api/book-generator/langgraph/books/:id/publish * 切换书籍公开状态 */ router.put('/books/:id/publish', optionalAuth, async (ctx: Context) => { try { const bookId = ctx.params.id; const newStatus = await bookStore.togglePublish(bookId); ctx.body = { code: 0, message: 'success', data: { isPublished: newStatus } }; } catch (error) { console.error('切换发布状态失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '操作失败' }; } }); /** * GET /api/book-generator/langgraph/books/:id * 获取书籍详情 * 支持公开过滤:?filterPublic=true&userId=1 */ router.get('/books/:id', optionalAuth, async (ctx: Context) => { try { const bookId = ctx.params.id as string; const userId = ctx.state.user?.userId || TEST_USER_ID; const filterPublic = ctx.query.filterPublic === 'true'; const book = await bookStore.getById(bookId, filterPublic, parseInt(userId as string)); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } ctx.body = { code: 0, message: 'success', data: { book } }; } catch (error) { console.error('查询失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '查询失败' }; } }); /** * GET /api/book-generator/langgraph/books/:id/progress * 获取书籍生成进度 */ router.get('/books/:id/progress', async (ctx: Context) => { try { const bookId = ctx.params.id as string; const book = await bookStore.getById(bookId); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } // 计算实际进度(基于章节完成情况) const completedChapters = book.chapters.filter((c) => c.genStage === 'video_completed').length; const totalChapters = book.outline?.chapters?.length || book.totalChapters || 0; // 查找当前正在生成的章节 const currentGenerating = book.chapters.find( (c) => c.genStage === 'content_generating' || c.genStage === 'audio_generating' || c.genStage === 'video_generating' ); const currentChapter = currentGenerating ? { number: currentGenerating.number, title: currentGenerating.title, level: currentGenerating.level } : null; // 如果有大纲,使用大纲章节数计算进度 let progress = book.progress; if (totalChapters > 0 && book.genStage !== 'video_completed') { progress = Math.round((completedChapters / totalChapters) * 100); } ctx.body = { code: 0, message: 'success', data: { bookId, genStage: book.genStage, progress, completedChapters, totalChapters, currentChapter, }, }; } catch (error) { console.error('查询进度失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '查询失败' }; } }); /** * DELETE /api/book-generator/langgraph/books/:id * 删除书籍 */ router.delete('/books/:id', async (ctx: Context) => { try { const bookId = ctx.params.id as string; await bookStore.delete(bookId); ctx.body = { code: 0, message: '删除成功' }; } catch (error) { console.error('删除失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '删除失败' }; } }); /** * POST /api/book-generator/langgraph/books/:id/generate * 对已有书籍使用 LangGraph 生成 */ router.post('/books/:id/generate', optionalAuth, async (ctx: Context) => { try { const bookId = ctx.params.id as string; console.log(`[LangGraph Generate] 收到请求 bookId=${bookId}`); const body = (ctx.request.body || {}) as { bookScale?: string; genLevel?: number }; const book = await bookStore.getById(bookId); console.log(`[LangGraph Generate] book对象:`, book ? '存在' : 'null'); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } // ========== 确定 bookScale ========== // 优先级: // 1) 请求显式指定(body.bookScale)→ 用户选的,直接用 // 2) AI 根据描述自动检测 → LLM 判断 // 3) 书籍已有规模 → 沿用 // 4) 兜底 → 1000(最小规模) let bookScale: string | undefined; if (body.bookScale && BOOK_TYPE_CONFIG[body.bookScale]) { // 用户在当前请求中显式选了规模 bookScale = body.bookScale; console.log(`[LangGraph Generate] 使用请求指定的 bookScale=${bookScale}`); } else { // 未显式选择 → 让 AI 根据描述判断 const desc = book.description || ''; try { console.log(`[LangGraph Generate] AI 自动检测规模...`); bookScale = await autoDetectBookType(book.title || '', desc); console.log(`[LangGraph Generate] AI 检测结果 → bookScale=${bookScale}`); } catch (e) { console.warn(`[LangGraph Generate] AI 检测失败,回退:`, e); } // AI 失败或返回无效值 → 用书籍已保存的 if (!bookScale || !BOOK_TYPE_CONFIG[bookScale]) { bookScale = book.bookScale && BOOK_TYPE_CONFIG[book.bookScale] ? book.bookScale : undefined; } } // 最终兜底:用最小规模(1000字),避免误判生成大量无用内容 bookScale = bookScale && BOOK_TYPE_CONFIG[bookScale] ? bookScale : '1000'; // 持久化检测结果 if (bookScale !== book.bookScale) { await bookStore.update(bookId, { bookScale }); } const genLevel = resolveGenLevel(bookScale, body.genLevel, book.description); // ========== 生成前配额检查 ========== const userId = ctx.state.user?.userId ? parseInt(ctx.state.user.userId as string) : (book.userId || parseInt(TEST_USER_ID)); const scaleConfig = getScaleConfig(bookScale); const totalWords = scaleConfig.totalWords; const audioQuota = await checkAudioQuota(userId, totalWords); if (!audioQuota.allowed) { ctx.status = 403; ctx.body = { code: 1, message: audioQuota.reason, data: { estimatedWords: totalWords, estimatedAudioMinutes: estimateAudioMinutesFromWords(totalWords), remainingMinutes: audioQuota.remainingMinutes, totalMinutes: audioQuota.totalMinutes, } }; return; } // 防止重复生成:如果书籍正在生成中,拒绝请求 if (book.genStage === 'content_generating' || book.genStage === 'audio_generating' || book.genStage === 'video_generating') { console.log(`[LangGraph Generate] 书籍正在生成中,拒绝重复请求 bookId=${bookId}`); ctx.status = 400; ctx.body = { code: 1, message: '书籍正在生成中,请勿重复提交', data: { bookId, genStage: book.genStage, progress: book.progress, } }; return; } // 异步生成(跳过队列,开发环境下队列不可靠) await bookStore.update(bookId, { genStage: 'outlining' }); langGraphGenerator.generate(bookId, book.description, bookScale, genLevel) .then(() => { console.log(`[LangGraph] 生成完成: bookId=${bookId}`); }) .catch(err => { console.error(`[LangGraph] 生成失败: bookId=${bookId}`, err); bookStore.update(bookId, { genStage: 'failed', failedStage: 'outlining', errorMsg: err instanceof Error ? err.message : '生成失败', }); }); ctx.body = { code: 0, message: 'LangGraph 生成任务已启动', data: { bookId, genStage: 'outlining', mode: 'async' }, }; } catch (error) { console.error('启动失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '启动失败', }; } }); /** * POST /api/book-generator/langgraph/books/:id/audio * 批量生成书籍所有小节的音频 */ router.post('/books/:id/audio', async (ctx: Context) => { try { const bookId = ctx.params.id as string; const { voiceId = 'longyingling_v3' } = ctx.request.body as { voiceId?: string }; const book = await bookStore.getById(bookId); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } // 获取章节树 const chapters = await bookStore.getChapterTree(bookId); // 动态获取叶节点层级(最大level值) const maxLevel = chapters.length > 0 ? Math.max(...chapters.map((c: any) => c.level || 0)) : 0; if (maxLevel === 0) { ctx.body = { code: 1, message: '没有章节' }; return; } // 获取叶节点(最大level的所有节点) const leafNodes = chapters.filter((c: any) => c.level === maxLevel); // 检查内容状态 - 叶节点必须有content且genStage为content_completed const leafNodesWithContent = leafNodes.filter((c: any) => c.content && c.genStage === 'content_completed'); if (leafNodes.length === 0) { ctx.body = { code: 1, message: `没有${maxLevel === 3 ? '小节' : maxLevel === 2 ? '节' : '章节'}` }; return; } if (leafNodesWithContent.length === 0) { ctx.body = { code: 1, message: `没有内容生成完成的${maxLevel === 3 ? '小节' : maxLevel === 2 ? '节' : '章节'},请先生成内容`, data: { totalSubsections: leafNodes.length, completedSubsections: 0, } }; return; } console.log(`[Audio] 书籍 ${bookId} 共有 ${leafNodes.length} 个${maxLevel === 3 ? '小节' : maxLevel === 2 ? '节' : '章节'},其中 ${leafNodesWithContent.length} 个内容已生成完成`); // 异步生成所有叶节点音频 for (const sub of leafNodesWithContent) { bookStore.generateChapterAudioById(sub.id, book.userId || 1).catch(err => { console.error(`[Audio] ${maxLevel === 3 ? '小节' : '节'}${sub.number}音频生成失败:`, err); }); } ctx.body = { code: 0, message: '音频生成任务已启动', data: { totalSubsections: leafNodesWithContent.length, taskId: `audio_${bookId}_${Date.now()}`, }, }; } catch (error) { console.error('音频生成失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '音频生成失败' }; } }); /** * POST /api/book-generator/langgraph/books/:id/audio/cancel * 取消书籍所有章节的音频生成 */ router.post('/books/:id/audio/cancel', async (ctx: Context) => { try { const bookId = ctx.params.id as string; const book = await bookStore.getById(bookId); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } const result = await bookStore.cancelAudioGeneration(bookId); if (result.cancelledCount === 0) { ctx.body = { code: 0, message: '没有正在运行的音频生成任务', data: result, }; return; } console.log(`[Audio] 书籍 ${bookId} 取消音频生成,已取消 ${result.cancelledCount} 个任务,回退 ${result.rolledBackChapters.length} 个章节`); ctx.body = { code: 0, message: `已取消 ${result.cancelledCount} 个音频生成任务`, data: result, }; } catch (error) { console.error('取消音频生成失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '取消失败' }; } }); /** * GET /api/book-generator/langgraph/books/:id/video-status * 获取书籍视频生成状态(用于前端轮询) */ router.get('/books/:id/video-status', async (ctx: Context) => { try { const bookId = ctx.params.id as string; const book = await bookStore.getById(bookId); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } // 获取章节树 const chapters = await bookStore.getChapterTree(bookId); // 动态获取叶节点层级 const maxLevel = chapters.length > 0 ? Math.max(...chapters.map((c: any) => c.level || 0)) : 0; // 获取叶节点 const leafNodes = chapters.filter((c: any) => c.level === maxLevel); // 统计视频状态 const total = leafNodes.length; const completed = leafNodes.filter((c: any) => c.videoUrl && c.videoUrl !== '').length; const pending = leafNodes.filter((c: any) => !c.videoUrl || c.videoUrl === '').length; ctx.body = { code: 0, message: 'success', data: { bookId, total, completed, pending, allCompleted: completed >= total && total > 0, }, }; } catch (error) { console.error('获取视频状态失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '获取失败' }; } }); /** * GET /api/book-generator/langgraph/books/:id/audio-status * 获取书籍音频生成状态(用于前端轮询) */ router.get('/books/:id/audio-status', async (ctx: Context) => { try { const bookId = ctx.params.id as string; const book = await bookStore.getById(bookId); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } // 获取章节树 const chapters = await bookStore.getChapterTree(bookId); // 动态获取叶节点层级 const maxLevel = chapters.length > 0 ? Math.max(...chapters.map((c: any) => c.level || 0)) : 0; // 获取叶节点 const leafNodes = chapters.filter((c: any) => c.level === maxLevel); // 统计音频状态 const total = leafNodes.length; const completed = leafNodes.filter((c: any) => c.audioUrl && c.audioUrl !== '').length; const pending = leafNodes.filter((c: any) => !c.audioUrl || c.audioUrl === '').length; ctx.body = { code: 0, message: 'success', data: { bookId, total, completed, pending, allCompleted: completed >= total && total > 0, }, }; } catch (error) { console.error('获取音频状态失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '获取失败' }; } }); /** * GET /api/book-generator/langgraph/books/:id/failed-chapters * 获取生成失败的小节列表 */ router.get('/books/:id/failed-chapters', async (ctx: Context) => { try { const bookId = ctx.params.id as string; const book = await bookStore.getById(bookId); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } // 获取所有失败的小节 const chapters = await bookStore.getChapterTree(bookId); const failedSubsections = chapters.filter(c => c.genStage === 'failed'); ctx.body = { code: 0, message: 'success', data: { bookId, failedCount: failedSubsections.length, failedChapters: failedSubsections.map(c => ({ id: c.id, number: c.number, title: c.title, level: c.level, errorMsg: c.errorMsg, parentId: c.parentId, })), }, }; } catch (error) { console.error('获取失败小节失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '获取失败' }; } }); /** * POST /api/book-generator/langgraph/books/:id/resume * 从断点处继续生成(重试失败的小节) */ router.post('/books/:id/resume', async (ctx: Context) => { try { const bookId = ctx.params.id as string; const book = await bookStore.getById(bookId); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } if (book.genStage === 'video_completed') { ctx.body = { code: 1, message: '书籍已生成完成,无需继续' }; return; } // 获取失败的小节 const chapters = await bookStore.getChapterTree(bookId); const failedSubsections = chapters.filter(c => c.genStage === 'failed'); if (failedSubsections.length === 0) { ctx.body = { code: 1, message: '没有失败的小节需要重试' }; return; } // 重置失败小节的状态为 pending for (const sub of failedSubsections) { await bookStore.updateChapterById(sub.id, { genStage: 'idle', errorMsg: null, content: null, }); } // 直接重新生成(跳过队列) const { langGraphGenerator } = await import('./index.js'); langGraphGenerator.generate( bookId, book.description || book.title, book.bookScale || '1000' ).then(() => { console.log(`[LangGraph] 续生成完成: bookId=${bookId}`); }).catch(err => { console.error(`[LangGraph] 续生成失败: bookId=${bookId}`, err); }); ctx.body = { code: 0, message: `已重启生成,将重试 ${failedSubsections.length} 个失败的小节`, data: { bookId, retryCount: failedSubsections.length, genStage: 'resuming', }, }; } catch (error) { console.error('继续生成失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '继续生成失败' }; } }); /** * POST /api/book-generator/langgraph/books/:id/retry-chapter * 单独重试某个失败的小节 */ router.post('/books/:id/retry-chapter', async (ctx: Context) => { try { const bookId = ctx.params.id as string; const { chapterId } = ctx.request.body as { chapterId: number }; if (!chapterId) { ctx.status = 400; ctx.body = { code: 1, message: '请提供章节 ID' }; return; } // 获取所有章节查找指定的章节 const chapters = await bookStore.getChapterTree(bookId); const chapter = chapters.find(c => c.id === chapterId); if (!chapter) { ctx.status = 404; ctx.body = { code: 1, message: '章节不存在' }; return; } // 重置章节状态 await bookStore.updateChapterById(chapterId, { genStage: 'idle', errorMsg: null, content: null, }); ctx.body = { code: 0, message: '章节已重置为待生成状态', data: { chapterId }, }; } catch (error) { console.error('重试章节失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '重试失败' }; } }); /** * GET /api/book-generator/langgraph/books/:id/full-content * 获取完整书籍内容(所有章节内容合并) */ router.get('/books/:id/full-content', async (ctx: Context) => { try { const bookId = ctx.params.id as string; const book = await bookStore.getById(bookId); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } // 获取章节树 const chapters = await bookStore.getChapterTree(bookId); // 按层级和顺序构建内容 let fullContent = `# ${book.title}\n\n`; if (book.description) { fullContent += `## 简介\n${book.description}\n\n`; } // 递归添加章节内容 const addChapterContent = (chapter: any, indent: string = '') => { if (chapter.content && chapter.genStage === 'content_completed') { fullContent += `${indent}${chapter.number}. ${chapter.title}\n\n`; fullContent += chapter.content + '\n\n'; } // 处理子章节 const children = chapters.filter((c: any) => c.parentId === chapter.id); children.forEach(child => { addChapterContent(child, indent + ' '); }); }; // 添加一级章节 const level1Chapters = chapters.filter((c: any) => c.level === 1); level1Chapters.forEach(chapter => addChapterContent(chapter)); ctx.body = { code: 0, message: 'success', data: { bookId, title: book.title, totalChapters: level1Chapters.length, content: fullContent, wordCount: book.estimatedWords, }, }; } catch (error) { console.error('获取完整内容失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '获取失败' }; } }); /** * POST /api/book-generator/langgraph/books/:id/outline * 生成书籍大纲 */ router.post('/books/:id/outline', async (ctx: Context) => { try { const bookId = ctx.params.id as string; const book = await bookStore.getById(bookId); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } // 检查是否已经有大纲 if (book.outline && book.outline.chapters && book.outline.chapters.length > 0) { ctx.body = { code: 0, message: '大纲已存在,无需重复生成', data: { outline: book.outline }, }; return; } // 获取书籍类型配置 const scaleConfig = getScaleConfig(book.bookScale || '1000'); // 调用AI生成大纲 const { generateOutline } = await import('./index.js'); const outline = await generateOutline(bookId, book.description, book.bookScale || '1000'); // 更新书籍的大纲 await bookStore.update(bookId, { outline }); ctx.body = { code: 0, message: '大纲生成成功', data: { outline }, }; } catch (error) { console.error('生成大纲失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '生成大纲失败' }; } }); /** * POST /api/book-generator/langgraph/books/:id/chapters * 生成单个章节(或全部章节,取决于参数) * body: { chapterNumber?: number } - 如果不传chapterNumber,则生成全部 */ router.post('/books/:id/chapters', async (ctx: Context) => { try { const bookId = ctx.params.id as string; const body = ctx.request.body as { chapterNumber?: number }; const book = await bookStore.getById(bookId); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } // 如果指定了章节号,只生成单个章节 if (body.chapterNumber) { // 检查章节是否存在 const chapters = await bookStore.getChapterTree(bookId); const targetChapter = chapters.find((c: any) => c.number === body.chapterNumber); if (!targetChapter) { ctx.status = 404; ctx.body = { code: 1, message: `章节 ${body.chapterNumber} 不存在` }; return; } // 异步生成章节内容 bookStore.generateSingleChapterContent(bookId, targetChapter.id).catch(err => { console.error(`[Generate Chapter] 章节${body.chapterNumber}生成失败:`, err); }); ctx.body = { code: 0, message: `章节 ${body.chapterNumber} 生成任务已启动`, data: { chapterId: targetChapter.id, chapterNumber: body.chapterNumber }, }; } else { // 没有指定章节号,触发整本书生成 ctx.body = { code: 0, message: '请使用 /generate 接口生成整本书', data: { hint: '使用 POST /books/:id/generate' }, }; } } catch (error) { console.error('生成章节失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '生成章节失败' }; } }); /** * POST /api/book-generator/langgraph/books/:id/foreword * 生成前言 */ router.post('/books/:id/foreword', async (ctx: Context) => { try { const bookId = ctx.params.id as string; const book = await bookStore.getById(bookId); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } // 调用LLM生成前言 const messages: ChatMessage[] = [ { role: 'system', content: `你是一位专业的图书作者。请根据以下信息为书籍生成前言(前言通常介绍写作背景、目标读者、内容概要)。 要求: 1. 字数控制在300-500字 2. 语言正式但亲切 3. 不要使用 markdown 格式,直接输出纯文本 输出格式: 直接输出前言内容,不要包含任何标记。`, }, { role: 'user', content: `书籍信息: - 书名:${book.title} - 描述:${book.description} - 目标读者:${book.targetAudience || '普通读者'} - 风格:${book.style || '专业'} 请生成这本书的前言。`, }, ]; const foreword = await callLLMWithMessages(messages); // 更新书籍前言字段 await bookStore.update(bookId, { foreword }); ctx.body = { code: 0, message: '前言生成成功', data: { foreword }, }; } catch (error) { console.error('生成前言失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '生成前言失败' }; } }); /** * POST /api/book-generator/langgraph/books/:id/afterword * 生成后记 */ router.post('/books/:id/afterword', async (ctx: Context) => { try { const bookId = ctx.params.id as string; const book = await bookStore.getById(bookId); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } // 调用LLM生成后记 const messages: ChatMessage[] = [ { role: 'system', content: `你是一位专业的图书作者。请为书籍生成后记(后记通常总结全书核心观点、分享写作心得、感谢读者)。 要求: 1. 字数控制在300-500字 2. 语言真挚、诚恳 3. 不要使用 markdown 格式,直接输出纯文本 输出格式: 直接输出后记内容,不要包含任何标记。`, }, { role: 'user', content: `书籍信息: - 书名:${book.title} - 描述:${book.description} - 章节数:${book.totalChapters} - 总字数:约${book.estimatedWords}字 请生成这本书的后记。`, }, ]; const afterword = await callLLMWithMessages(messages); // 更新书籍后记字段 await bookStore.update(bookId, { afterword }); ctx.body = { code: 0, message: '后记生成成功', data: { afterword }, }; } catch (error) { console.error('生成后记失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '生成后记失败' }; } }); /** * GET /api/book-generator/langgraph/workflow/:bookId * 获取书籍生成工作流状态 */ router.get('/workflow/:bookId', async (ctx: Context) => { try { const bookId = ctx.params.bookId as string; const book = await bookStore.getById(bookId); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } // 获取章节树 const chapters = await bookStore.getChapterTree(bookId); // 分析工作流状态 const completedChapters = chapters.filter((c: any) => ['content_completed', 'audio_completed', 'video_completed'].includes(c.genStage)).length; const failedChapters = chapters.filter((c: any) => c.genStage === 'failed').length; const pendingChapters = chapters.filter((c: any) => c.genStage === 'idle' || !c.genStage).length; const generatingChapters = chapters.filter((c: any) => ['content_generating', 'audio_generating', 'video_generating'].includes(c.genStage)).length; // 确定当前阶段 let phase: 'planning' | 'writing' | 'supplement' | 'done' = 'planning'; if (['content_generating', 'audio_generating', 'video_generating'].includes(book.genStage)) { phase = 'writing'; } else if (completedChapters > 0 && failedChapters === 0) { phase = 'done'; } else if (failedChapters > 0) { phase = 'supplement'; } ctx.body = { code: 0, message: 'success', data: { bookId, genStage: book.genStage, phase, progress: book.progress || 0, completedChapters, failedChapters, pendingChapters, generatingChapters, totalChapters: chapters.length, failedChapterIds: chapters.filter((c: any) => c.genStage === 'failed').map((c: any) => c.id), }, }; } catch (error) { console.error('获取工作流状态失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '获取失败' }; } }); /** * GET /api/book-generator/langgraph/books/:id/chapters/:chapterId/content * 获取指定章节的内容 */ router.get('/books/:id/chapters/:chapterId/content', optionalAuth, async (ctx: Context) => { try { const { id: bookId, chapterId } = ctx.params as { id: string; chapterId: string }; const chapterIdNum = parseInt(chapterId); // 获取书籍 const book = await bookStore.getById(bookId); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } // 获取章节 const chapter = await prisma.bookChapter.findUnique({ where: { id: chapterIdNum }, }); if (!chapter) { ctx.status = 404; ctx.body = { code: 1, message: '章节不存在' }; return; } // 检查是否属于该书籍 if (chapter.bookId !== parseInt(bookId)) { ctx.status = 404; ctx.body = { code: 1, message: '章节不属于该书籍' }; return; } ctx.body = { code: 0, message: 'success', data: { id: chapter.id, bookId: chapter.bookId, number: chapter.number, title: chapter.title, content: chapter.content || '', wordCount: chapter.wordCount || 0, genStage: chapter.genStage || null, level: chapter.level, parentId: chapter.parentId, summary: chapter.summary || '', keyPoints: chapter.keyPoints ? JSON.parse(chapter.keyPoints) : [], audioUrl: chapter.audioUrl || '', videoUrl: chapter.videoUrl || '', isPublic: chapter.isPublic || false, }, }; } catch (error) { console.error('获取章节内容失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '获取章节内容失败' }; } }); /** * POST /api/book-generator/langgraph/books/:id/chapters/:chapterId/content * 保存章节内容(手工编辑后保存) */ router.post('/books/:id/chapters/:chapterId/content', optionalAuth, async (ctx: Context) => { try { const { id: bookId, chapterId } = ctx.params as { id: string; chapterId: string }; const chapterIdNum = parseInt(chapterId); const { content } = ctx.request.body as { content: string }; if (content === undefined) { ctx.status = 400; ctx.body = { code: 1, message: '内容不能为空' }; return; } // 获取章节 const chapter = await prisma.bookChapter.findUnique({ where: { id: chapterIdNum }, }); if (!chapter) { ctx.status = 404; ctx.body = { code: 1, message: '章节不存在' }; return; } // 检查是否属于该书籍 if (chapter.bookId !== parseInt(bookId)) { ctx.status = 404; ctx.body = { code: 1, message: '章节不属于该书籍' }; return; } // 计算字数 const wordCount = content.replace(/<[^>]*>/g, '').length; // 更新章节内容 await bookStore.updateChapterById(chapterIdNum, { content, wordCount, }); ctx.body = { code: 0, message: '保存成功', data: { chapterId: chapterIdNum, wordCount }, }; } catch (error) { console.error('保存章节内容失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '保存章节内容失败' }; } }); /** * POST /api/book-generator/langgraph/books/:id/chapters/batch-regenerate * 批量重新生成章节内容 */ router.post('/books/:id/chapters/batch-regenerate', optionalAuth, async (ctx: Context) => { try { const { id: bookId } = ctx.params as { id: string }; const { chapterIds } = ctx.request.body as { chapterIds: number[] }; if (!chapterIds || !Array.isArray(chapterIds) || chapterIds.length === 0) { ctx.status = 400; ctx.body = { code: 1, message: '请选择要重新生成的章节' }; return; } // 获取书籍 const book = await bookStore.getById(bookId); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } // 重置并触发每个选中章节的重新生成 for (const chapterId of chapterIds) { await bookStore.updateChapterById(chapterId, { genStage: 'idle', content: null, }); // 后台异步执行 bookStore.generateSingleChapterContent(bookId, chapterId).catch(err => { console.error(`[BatchRegenerate] 章节${chapterId}内容生成失败:`, err); }); } ctx.body = { code: 0, message: `已提交 ${chapterIds.length} 个章节的重新生成任务`, data: { count: chapterIds.length }, }; } catch (error) { console.error('批量重新生成章节失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '批量重新生成失败' }; } }); /** * POST /api/book-generator/langgraph/books/:id/chapters/batch-delete * 批量删除章节 */ router.post('/books/:id/chapters/batch-delete', optionalAuth, async (ctx: Context) => { try { const { id: bookId } = ctx.params as { id: string }; const { chapterIds } = ctx.request.body as { chapterIds: number[] }; if (!chapterIds || !Array.isArray(chapterIds) || chapterIds.length === 0) { ctx.status = 400; ctx.body = { code: 1, message: '请选择要删除的章节' }; return; } // 获取书籍 const book = await bookStore.getById(bookId); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } // 删除章节(同时会删除子章节,因为没有设置级联删除,需要手动处理) // 对于每个要删除的章节,先删除其所有子节点,再删除自身 for (const chapterId of chapterIds) { // 删除子章节(level > 当前章节) await prisma.bookChapter.deleteMany({ where: { bookId: parseInt(bookId), parentId: chapterId, }, }); // 删除自身 await prisma.bookChapter.deleteMany({ where: { id: chapterId, bookId: parseInt(bookId), }, }); } ctx.body = { code: 0, message: `已删除 ${chapterIds.length} 个章节`, data: { count: chapterIds.length }, }; } catch (error) { console.error('批量删除章节失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '批量删除失败' }; } }); /** * POST /api/book-generator/langgraph/books/:id/chapters/:chapterId/regenerate * 重新生成指定章节的内容(仅叶节点允许) */ router.post('/books/:id/chapters/:chapterId/regenerate', async (ctx: Context) => { try { const { id: bookId, chapterId } = ctx.params as { id: string; chapterId: string }; const chapterIdNum = parseInt(chapterId); // 获取书籍 const book = await bookStore.getById(bookId); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } // 获取章节 const chapter = await prisma.bookChapter.findUnique({ where: { id: chapterIdNum }, }); if (!chapter) { ctx.status = 404; ctx.body = { code: 1, message: '章节不存在' }; return; } // 检查是否为叶节点(没有子节点的节点) const childChapters = await prisma.bookChapter.findMany({ where: { parentId: chapterIdNum }, }); if (childChapters.length > 0) { ctx.status = 400; ctx.body = { code: 1, message: '只有叶节点(小节)才允许重新生成内容' }; return; } // 重置章节状态和内容 await bookStore.updateChapterById(chapterIdNum, { genStage: 'idle', errorMsg: null, content: null, }); // 直接调用单个章节内容生成(不触发整个书籍生成流程) // 在后台异步执行 bookStore.generateSingleChapterContent(bookId, chapterIdNum).catch(err => { console.error(`[Regenerate] 章节${chapterIdNum}内容生成失败:`, err); }); ctx.body = { code: 0, message: '章节内容重新生成任务已启动', data: { chapterId: chapterIdNum }, }; } catch (error) { console.error('重新生成章节失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '重新生成失败' }; } }); /** * POST /api/book-generator/langgraph/books/:id/chapters/:chapterId/regenerate-audio * 重新生成章节音频(仅叶节点) */ router.post('/books/:id/chapters/:chapterId/regenerate-audio', async (ctx: Context) => { try { const { id: bookId, chapterId } = ctx.params as { id: string; chapterId: string }; const chapterIdNum = parseInt(chapterId); // 获取书籍 const book = await bookStore.getById(bookId); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } // 获取章节 const chapter = await prisma.bookChapter.findUnique({ where: { id: chapterIdNum }, }); if (!chapter) { ctx.status = 404; ctx.body = { code: 1, message: '章节不存在' }; return; } // 检查是否为叶节点 const childChapters = await prisma.bookChapter.findMany({ where: { parentId: chapterIdNum }, }); if (childChapters.length > 0) { ctx.status = 400; ctx.body = { code: 1, message: '只有叶节点(小节)才允许重新生成音频' }; return; } // 检查内容是否已完成(有内容即可重生成音频,content_generating 但 content 已存在也允许) if (!chapter.content || chapter.genStage === 'idle') { ctx.status = 400; ctx.body = { code: 1, message: '章节内容未生成完成,无法生成音频' }; return; } // 清空现有音频并回退状态 await bookStore.updateChapterById(chapterIdNum, { audioUrl: '', audioDuration: 0, lrcLyrics: null, genStage: 'content_completed', }); // 同时清空父章节的合并音频(因为子节音频变了,合并音频也需要重新生成) if (chapter.parentId) { const parentChapter = await prisma.bookChapter.findUnique({ where: { id: chapter.parentId }, }); if (parentChapter?.audioUrl?.includes('_merged')) { console.log(`[RegenerateAudio] 清除父章节 ${parentChapter.id} 的合并音频(子节${chapterIdNum}已重新生成)`); await bookStore.updateChapterById(parentChapter.id, { audioUrl: '', audioDuration: 0, }); // 如果父章节之上还有更高级的合并音频,也一并清除 if (parentChapter.parentId) { const grandParent = await prisma.bookChapter.findUnique({ where: { id: parentChapter.parentId }, }); if (grandParent?.audioUrl?.includes('_merged')) { console.log(`[RegenerateAudio] 清除祖父章节 ${grandParent.id} 的合并音频`); await bookStore.updateChapterById(grandParent.id, { audioUrl: '', audioDuration: 0, }); } } } } // 异步生成新音频 bookStore.generateChapterAudioById(chapterIdNum, book.userId || 1); ctx.body = { code: 0, message: '音频重新生成任务已启动', data: { chapterId: chapterIdNum }, }; } catch (error) { console.error('重新生成音频失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '重新生成音频失败' }; } }); /** * POST /api/book-generator/langgraph/books/:id/chapters/:chapterId/video * 生成单个章节视频 */ router.post('/books/:id/chapters/:chapterId/video', async (ctx: Context) => { try { const { id: bookId, chapterId } = ctx.params as { id: string; chapterId: string }; const chapterIdNum = parseInt(chapterId); const chapter = await prisma.bookChapter.findUnique({ where: { id: chapterIdNum }, }); if (!chapter) { ctx.status = 404; ctx.body = { code: 1, message: '章节不存在' }; return; } // 检查章节是否有音频 if (!chapter.audioUrl) { ctx.status = 400; ctx.body = { code: 1, message: '该章节没有音频,请先生成音频' }; return; } // 设置视频状态为生成中 await advanceChapter(chapterIdNum, 'video_generating'); // 调用视频服务创建视频项目 const project = await createVideoProjectFromBook(parseInt(bookId), chapterIdNum); if (!project) { await regenerateChapter(chapterIdNum, 'failed'); ctx.status = 400; ctx.body = { code: 1, message: '创建视频项目失败' }; return; } // 异步生成视频 generateVideoForProject(project.id) .then(async (result) => { if (result.success) { await bookStore.updateChapterById(chapterIdNum, { videoUrl: result.outputUrl, videoDuration: result.duration, }); await advanceChapter(chapterIdNum, 'video_completed'); } else { await regenerateChapter(chapterIdNum, 'failed'); } }) .catch(async (err) => { console.error(`[Video] 章节${chapter.number}视频生成失败:`, err); await regenerateChapter(chapterIdNum, 'failed'); }); ctx.body = { code: 0, message: '视频生成任务已启动', data: { projectId: project.id, chapterId: String(chapterId), }, }; } catch (error) { console.error('视频生成失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '视频生成失败' }; } }); /** * POST /api/book-generator/langgraph/books/:id/videos * 批量生成书籍所有章节视频 */ router.post('/books/:id/videos', async (ctx: Context) => { try { const bookId = ctx.params.id as string; const book = await bookStore.getById(bookId); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } // 获取章节树 const chapters = await bookStore.getChapterTree(bookId); // 动态获取叶节点层级(最大level值) const maxLevel = chapters.length > 0 ? Math.max(...chapters.map((c: any) => c.level || 0)) : 0; if (maxLevel === 0) { ctx.body = { code: 1, message: '没有章节' }; return; } // 获取叶节点(最大level的所有节点) const leafNodes = chapters.filter((c: any) => c.level === maxLevel); // 过滤出有音频的叶节点 const leafNodesWithAudio = leafNodes.filter((c: any) => c.audioUrl && c.audioUrl !== ''); const leafTypeName = maxLevel === 3 ? '小节' : maxLevel === 2 ? '节' : '章节'; if (leafNodes.length === 0) { ctx.body = { code: 1, message: `没有${leafTypeName}` }; return; } if (leafNodesWithAudio.length === 0) { ctx.body = { code: 1, message: `没有音频生成完成的${leafTypeName},请先生成音频`, data: { totalSubsections: leafNodes.length, audioCompletedSubsections: 0, } }; return; } console.log(`[Video] 书籍 ${bookId} 共有 ${leafNodes.length} 个${leafTypeName},其中 ${leafNodesWithAudio.length} 个音频已生成完成`); // 异步生成所有叶节点视频 const taskId = `video_${bookId}_${Date.now()}`; console.log(`[Video] 开始批量生成视频: bookId=${bookId}, taskId=${taskId}, 总数=${leafNodesWithAudio.length}`); for (const sub of leafNodesWithAudio) { // 设置视频状态为生成中 await advanceChapter(sub.id, 'video_generating').catch(() => {}); // 异步处理每个章节的视频生成 (async () => { try { console.log(`[Video] 开始生成章节 ${sub.number} 的视频: ${sub.title}`); // 从书籍章节创建视频项目 const project = await createVideoProjectFromBook( parseInt(bookId), sub.id, book.userId || 1 ); if (!project) { console.error(`[Video] 章节 ${sub.number} 视频项目创建失败`); await regenerateChapter(sub.id, 'failed').catch(() => {}); return; } console.log(`[Video] 章节 ${sub.number} 视频项目创建成功: projectId=${project.id}`); // 生成视频 const result = await generateVideoForProject(project.id); if (result.success && result.outputUrl) { await bookStore.updateChapterById(sub.id, { videoUrl: result.outputUrl, videoDuration: result.duration, }); await advanceChapter(sub.id, 'video_completed'); } else { await regenerateChapter(sub.id, 'failed'); } } catch (error) { console.error(`[Video] 章节 ${sub.number} 视频生成异常:`, error); await regenerateChapter(sub.id, 'failed'); } })(); } ctx.body = { code: 0, message: '视频生成任务已启动', data: { taskId, totalChapters: leafNodesWithAudio.length, totalSubsections: leafNodes.length, }, }; } catch (error) { console.error('批量生成视频失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '批量生成视频失败' }; } }); /** * POST /api/book-generator/langgraph/books/:id/merge-audio * 合并叶节点音频到上级章节 */ router.post('/books/:id/merge-audio', async (ctx: Context) => { try { const bookId = ctx.params.id as string; const book = await bookStore.getById(bookId); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } // 获取章节树 const chapters = await bookStore.getChapterTree(bookId); // 动态获取叶节点层级(最大level值) const maxLevel = chapters.length > 0 ? Math.max(...chapters.map((c: any) => c.level || 0)) : 0; if (maxLevel <= 1) { ctx.body = { code: 1, message: '书籍层级不足,无法合并音频' }; return; } // 获取叶节点(最大level的所有节点) const leafNodes = chapters.filter((c: any) => c.level === maxLevel); // 检查所有叶节点是否都有音频 const leafNodesWithAudio = leafNodes.filter((c: any) => c.audioUrl && c.audioUrl !== ''); if (leafNodes.length === 0) { ctx.body = { code: 1, message: `没有${maxLevel === 3 ? '小节' : maxLevel === 2 ? '节' : '章节'}` }; return; } if (leafNodesWithAudio.length !== leafNodes.length) { ctx.body = { code: 1, message: `并非所有${maxLevel === 3 ? '小节' : maxLevel === 2 ? '节' : '章节'}都已完成音频生成,无法合并`, data: { totalLeafNodes: leafNodes.length, completedAudioNodes: leafNodesWithAudio.length, } }; return; } // 构建 sectionId → chapterId 映射(3层树需要,2层树直接 parentId 就是 chapterId) const sectionToChapter = new Map(); if (maxLevel >= 3) { const sections = chapters.filter((c: any) => c.level === 2); for (const sec of sections) { if (sec.parentId) { sectionToChapter.set(sec.id, sec.parentId); } } } // 按章(level=1)分组叶节点 const groupedByChapter: { [key: number]: any[] } = {}; const chapterMeta: { [key: number]: any } = {}; // 章的基本信息 leafNodesWithAudio.forEach(node => { // 找到该叶节点所属的1级章ID let chapterId: number | null = null; if (maxLevel === 2) { chapterId = node.parentId; // level=2叶节点的parentId就是章 } else if (maxLevel === 3 && node.parentId) { chapterId = sectionToChapter.get(node.parentId) || null; // 通过节→章映射 } if (chapterId) { if (!groupedByChapter[chapterId]) { groupedByChapter[chapterId] = []; } groupedByChapter[chapterId].push(node); } }); // 收集各章的元信息 for (const ch of chapters) { if (ch.level === 1) { chapterMeta[ch.id] = ch; } } const chapterIds = Object.keys(groupedByChapter); // 对每个章下的叶节点音频进行合并 for (const chapterId of chapterIds) { const chId = parseInt(chapterId); const childNodes = groupedByChapter[chId]; const chInfo = chapterMeta[chId]; if (childNodes.length > 0) { try { const mergedAudioUrl = await mergeChapterAudios(chId); if (mergedAudioUrl) { console.log(`[Merge Audio] 章 ${chInfo?.number || chId}(${chInfo?.title || ''}) 音频合并完成: ${mergedAudioUrl}`); } } catch (error) { console.error(`[Merge Audio] 章 ${chInfo?.number || chId}(${chInfo?.title || ''}) 音频合并失败:`, error); } } } ctx.body = { code: 0, message: `音频合并任务已启动,共处理 ${chapterIds.length} 个章`, data: { processedParents: chapterIds.length, totalLeafNodes: leafNodes.length, }, }; } catch (error) { console.error('合并音频失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '合并音频失败' }; } }); /** * POST /api/book-generator/langgraph/books/:id/merge-video * 合并叶节点视频到上级章节 */ router.post('/books/:id/merge-video', async (ctx: Context) => { try { const bookId = ctx.params.id as string; const book = await bookStore.getById(bookId); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } // 获取章节树 const chapters = await bookStore.getChapterTree(bookId); // 动态获取叶节点层级(最大level值) const maxLevel = chapters.length > 0 ? Math.max(...chapters.map((c: any) => c.level || 0)) : 0; if (maxLevel <= 1) { ctx.body = { code: 1, message: '书籍层级不足,无法合并视频' }; return; } // 获取叶节点(最大level的所有节点) const leafNodes = chapters.filter((c: any) => c.level === maxLevel); // 检查所有叶节点是否都有视频 const leafNodesWithVideo = leafNodes.filter((c: any) => c.videoUrl && c.videoUrl !== ''); if (leafNodes.length === 0) { ctx.body = { code: 1, message: `没有${maxLevel === 3 ? '小节' : maxLevel === 2 ? '节' : '章节'}` }; return; } if (leafNodesWithVideo.length !== leafNodes.length) { ctx.body = { code: 1, message: `并非所有${maxLevel === 3 ? '小节' : maxLevel === 2 ? '节' : '章节'}都已完成视频生成,无法合并`, data: { totalLeafNodes: leafNodes.length, completedVideoNodes: leafNodesWithVideo.length, } }; return; } // 按父节点分组叶节点(通常是1级章节) const groupedByParent: { [key: number]: any[] } = {}; leafNodesWithVideo.forEach(node => { if (node.parentId) { if (!groupedByParent[node.parentId]) { groupedByParent[node.parentId] = []; } groupedByParent[node.parentId].push(node); } }); // 对每个父节点下的叶节点视频进行合并 let totalProcessedParents = 0; for (const parentId in groupedByParent) { const childNodes = groupedByParent[parentId]; if (childNodes.length > 0) { // 获取父章节信息 const parentChapter = chapters.find((c: any) => c.id === parseInt(parentId)); if (parentChapter) { try { // 调用合并函数 const mergedVideoUrl = await mergeChapterVideos( parentChapter, childNodes, book.userId || 1 ); if (mergedVideoUrl) { // 更新父章节的视频URL await bookStore.updateChapterById(parentChapter.id, { videoUrl: mergedVideoUrl, }); console.log(`[Merge Video] 章节 ${parentChapter.number}(${parentChapter.title}) 视频合并完成: ${mergedVideoUrl}`); totalProcessedParents++; } } catch (error) { console.error(`[Merge Video] 章节 ${parentChapter.number}(${parentChapter.title}) 视频合并失败:`, error); // 不抛出异常,继续处理其他章节 } } } } ctx.body = { code: 0, message: `视频合并任务已完成,共处理 ${totalProcessedParents} 个上级章节`, data: { processedParents: totalProcessedParents, totalLeafNodes: leafNodes.length, }, }; } catch (error) { console.error('合并视频失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '合并视频失败' }; } }); /** * 合并章节视频的辅助函数 */ async function mergeChapterVideos(parentChapter: any, childChapters: any[], userId: number) { try { // 获取项目根目录(假设视频文件存储在特定目录下) const videoDir = path.join(__dirname, '../../../../../../uploads/video'); const outputDir = path.join(videoDir, 'merged'); // 确保输出目录存在 await fs.mkdir(outputDir, { recursive: true }); // 创建合并文件列表 const fileListPath = path.join(videoDir, `merge_list_${parentChapter.id}.txt`); const outputPath = path.join(outputDir, `merged_chapter_${parentChapter.id}_${Date.now()}.mp4`); // 准备要合并的视频文件路径 const videoPaths = []; for (const child of childChapters) { if (child.videoUrl) { // 从URL提取文件名 const fileName = path.basename(child.videoUrl); const fullPath = path.join(videoDir, fileName); // 检查文件是否存在 try { await fs.access(fullPath); videoPaths.push(fullPath); } catch (err) { console.error(`[Merge Video] 文件不存在: ${fullPath}`, err); continue; // 跳过不存在的文件 } } } if (videoPaths.length === 0) { throw new Error('没有找到有效的视频文件'); } if (videoPaths.length === 1) { // 如果只有一个视频,检查格式是否一致 const srcExt = videoPaths[0].split('.').pop()?.toLowerCase(); const dstExt = outputPath.split('.').pop()?.toLowerCase(); if (srcExt !== dstExt) { // 格式不同,用 FFmpeg 转码 const cmd = `ffmpeg -i "${videoPaths[0]}" -c copy -y "${outputPath}"`; console.log(`[Merge Video] 格式不同,转码: ${cmd}`); await execPromise(cmd); } else { await fs.copyFile(videoPaths[0], outputPath); } const relativePath = outputPath.replace(videoDir, '/uploads/video').replace(/\\/g, '/'); return relativePath; } // 创建FFmpeg输入列表 - 使用concat协议来合并视频 const inputLines = videoPaths.map(videoPath => `file '${videoPath}'`).join('\n'); await fs.writeFile(fileListPath, inputLines); // 使用FFmpeg合并视频 - 更安全的方式,避免编码问题 // 先尝试简单的copy方式 const cmd = `ffmpeg -f concat -safe 0 -i "${fileListPath}" -c copy -avoid_negative_ts make_zero "${outputPath}"`; console.log(`[Merge Video] 执行命令: ${cmd}`); let result; try { result = await execPromise(cmd); console.log(`[Merge Video] FFmpeg stdout:`, result.stdout); console.log(`[Merge Video] FFmpeg stderr:`, result.stderr); } catch (execErr: any) { console.warn(`[Merge Video] copy模式失败,尝试重新编码:`, execErr.message); // 如果简单copy失败,尝试重新编码 const reencodeCmd = `ffmpeg -f concat -safe 0 -i "${fileListPath}" -c:v libx264 -c:a aac -avoid_negative_ts make_zero "${outputPath}"`; console.log(`[Merge Video] 执行重新编码命令: ${reencodeCmd}`); result = await execPromise(reencodeCmd); console.log(`[Merge Video] FFmpeg reencode stdout:`, result.stdout); console.log(`[Merge Video] FFmpeg reencode stderr:`, result.stderr); } // 清理临时文件 await fs.unlink(fileListPath).catch(() => {}); // 检查输出文件是否成功创建 try { await fs.access(outputPath); } catch (err) { throw new Error('视频合并后文件未生成'); } // 返回相对路径(相对于上传目录) const relativePath = outputPath.replace(videoDir, '/uploads/video').replace(/\\/g, '/'); return relativePath; } catch (error) { console.error('[Merge Video] 合并过程中出错:', error); throw error; } } /** * DEBUG: 手动触发章节音频生成(强制从content_completed重新开始) * POST /api/book-generator/debug/trigger-audio */ router.post('/debug/trigger-audio', async (ctx: Context) => { try { const { chapterId } = ctx.request.body as { chapterId?: number }; if (!chapterId) { ctx.status = 400; ctx.body = { code: 1, message: '缺少 chapterId' }; return; } // 先检查chapter状态,如果是audio_generating但没有audioUrl,说明之前的生成卡住了 const chapter = await prisma.bookChapter.findUnique({ where: { id: chapterId } }); if (chapter?.genStage === 'audio_generating' && !chapter?.audioUrl) { console.log(`[DEBUG] 章节${chapterId}卡在audio_generating无audioUrl,重置到content_completed重新生成`); await prisma.bookChapter.update({ where: { id: chapterId }, data: { genStage: 'content_completed' } }); } console.log(`[DEBUG] 手动触发章节${chapterId}音频生成`); await bookStore.generateChapterAudioById(chapterId); const updated = await prisma.bookChapter.findUnique({ where: { id: chapterId } }); ctx.body = { code: 0, message: '音频生成已触发', data: { chapterId, genStage: updated?.genStage, audioUrl: updated?.audioUrl }, }; } catch (error) { console.error('[DEBUG] 触发音频失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '触发失败' }; } }); // ============ 交互式生成 API ============ /** * POST /api/book-generator/langgraph/books/:id/interactive/plan * 异步启动深度规划,前端通过 GET 同名接口轮询结果 */ router.post('/books/:id/interactive/plan', optionalAuth, async (ctx: Context) => { try { const bookId = ctx.params.id; const idNum = parseInt(bookId); const book = await prisma.book.findUnique({ where: { id: idNum } }); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } // 标记为生成中,立即返回 await prisma.book.update({ where: { id: idNum }, data: { genStage: 'plan_generating' }, }); // 构造 minimal state const body = (ctx.request as any).body || {}; const userFeedback = body.feedback || ''; const initState: any = { bookId, topic: book.description, bookScale: book.bookScale || '1000', genLevel: (book as any).genLevel || 2, description: book.description, title: book.title, userId: book.userId, completedChapters: [], failedChapters: [], userFeedback, }; console.log(`[Interactive] 深度规划已启动(异步): bookId=${bookId}${userFeedback ? ', 含用户反馈' : ''}`); // 异步执行 AI 调用,不阻塞响应 setImmediate(async () => { try { const result = await deepPlanBookNode(initState); const bookPlan = result.bookPlan || ''; await prisma.book.update({ where: { id: idNum }, data: { bookAnalysis: bookPlan, genStage: 'plan_ready' }, }); console.log(`[Interactive] 深度规划完成: bookId=${bookId}`); } catch (err) { console.error(`[Interactive] 深度规划失败: bookId=${bookId}`, err); await prisma.book.update({ where: { id: idNum }, data: { genStage: 'plan_failed', errorMsg: err instanceof Error ? err.message : '规划失败' }, }); } }); ctx.body = { code: 0, message: '规划已启动', data: { genStage: 'plan_generating' }, }; } catch (error) { console.error('[Interactive] plan 启动失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '启动失败' }; } }); /** * GET /api/book-generator/langgraph/books/:id/interactive/plan * 轮询获取深度规划结果 */ router.get('/books/:id/interactive/plan', optionalAuth, async (ctx: Context) => { try { const bookId = ctx.params.id; const book = await prisma.book.findUnique({ where: { id: parseInt(bookId) } }); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } const stage = (book as any).genStage || ''; if (stage === 'plan_failed') { ctx.body = { code: 0, data: { genStage: 'plan_failed', error: (book as any).errorMsg || '规划失败' } }; return; } if (stage === 'plan_ready' || book.bookAnalysis) { const bookPlan = book.bookAnalysis || ''; let planData: any = null; try { const fixed_json = bookPlan.replace(/\\u000a/g, '\\n').replace(/\\\\/g, '\\'); const match = fixed_json.match(/\{[\s\S]*\}/); if (match) planData = JSON.parse(match[0]); } catch (e) { /* ignore */ } ctx.body = { code: 0, message: '规划完成', data: { rawPlan: bookPlan, planData, genStage: 'plan_ready' }, }; return; } // 还在生成中 ctx.body = { code: 0, data: { genStage: stage || 'plan_generating' }, }; } catch (error) { console.error('[Interactive] 查询 plan 失败:', error); ctx.status = 500; ctx.body = { code: 1, message: '查询失败' }; } }); /** * PUT /api/book-generator/langgraph/books/:id/interactive/plan * 保存用户修改后的 plan */ router.put('/books/:id/interactive/plan', optionalAuth, async (ctx: Context) => { try { const bookId = ctx.params.id; const { plan } = ctx.request.body as { plan: string }; if (!plan) { ctx.status = 400; ctx.body = { code: 1, message: 'plan 不能为空' }; return; } await prisma.book.update({ where: { id: parseInt(bookId) }, data: { bookAnalysis: plan }, }); ctx.body = { code: 0, message: 'Plan 已保存' }; } catch (error) { console.error('[Interactive] 保存 plan 失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '保存失败' }; } }); /** * POST /api/book-generator/langgraph/books/:id/interactive/outline * 异步启动大纲生成,前端通过 GET 同名接口轮询结果 */ router.post('/books/:id/interactive/outline', optionalAuth, async (ctx: Context) => { try { const bookId = ctx.params.id; const idNum = parseInt(bookId); const book = await prisma.book.findUnique({ where: { id: idNum } }); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } // 标记为生成中,立即返回 await prisma.book.update({ where: { id: idNum }, data: { genStage: 'outline_generating' }, }); // 构造 state,使用已保存的 plan const initState: any = { bookId, topic: book.description, bookScale: book.bookScale || '1000', genLevel: (book as any).genLevel || 2, description: book.description, title: book.title, userId: book.userId, bookPlan: book.bookAnalysis || '', completedChapters: [], failedChapters: [], }; console.log(`[Interactive] 大纲生成已启动(异步): bookId=${bookId}`); // 异步执行 AI 调用 setImmediate(async () => { try { await richOutlineNode(initState); // richOutlineNode 内部已通过 bookStore.update 写入 outlineJson await prisma.book.update({ where: { id: idNum }, data: { genStage: 'outline_ready' }, }); console.log(`[Interactive] 大纲生成完成: bookId=${bookId}`); } catch (err) { console.error(`[Interactive] 大纲生成失败: bookId=${bookId}`, err); await prisma.book.update({ where: { id: idNum }, data: { genStage: 'outline_failed', errorMsg: err instanceof Error ? err.message : '大纲生成失败' }, }); } }); ctx.body = { code: 0, message: '大纲生成已启动', data: { genStage: 'outline_generating' }, }; } catch (error) { console.error('[Interactive] outline 启动失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '启动失败' }; } }); /** * GET /api/book-generator/langgraph/books/:id/interactive/outline * 轮询获取大纲生成结果 */ router.get('/books/:id/interactive/outline', optionalAuth, async (ctx: Context) => { try { const bookId = ctx.params.id; const book = await prisma.book.findUnique({ where: { id: parseInt(bookId) } }); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } const stage = (book as any).genStage || ''; if (stage === 'outline_failed') { ctx.body = { code: 0, data: { genStage: 'outline_failed', error: (book as any).errorMsg || '大纲生成失败' } }; return; } if (stage === 'outline_ready' || book.outlineJson) { const outlineJson = book.outlineJson || ''; let outlineData: any = null; try { outlineData = typeof outlineJson === 'string' ? JSON.parse(outlineJson) : outlineJson; } catch (e) { /* ignore */ } ctx.body = { code: 0, message: '大纲生成完成', data: { rawOutline: outlineJson, outlineData, genStage: 'outline_ready' }, }; return; } // 还在生成中 ctx.body = { code: 0, data: { genStage: stage || 'outline_generating' }, }; } catch (error) { console.error('[Interactive] 查询 outline 失败:', error); ctx.status = 500; ctx.body = { code: 1, message: '查询失败' }; } }); /** * PUT /api/book-generator/langgraph/books/:id/interactive/outline * 保存用户修改后的大纲,并重建章节树 */ router.put('/books/:id/interactive/outline', optionalAuth, async (ctx: Context) => { try { const bookId = ctx.params.id; const { outline } = ctx.request.body as { outline: any }; if (!outline) { ctx.status = 400; ctx.body = { code: 1, message: 'outline 不能为空' }; return; } const outlineStr = typeof outline === 'string' ? outline : JSON.stringify(outline); await prisma.book.update({ where: { id: parseInt(bookId) }, data: { outlineJson: outlineStr }, }); // 根据大纲重建章节树(删除旧章节,创建新章节) const chapterData = typeof outline === 'string' ? JSON.parse(outline) : outline; const chapters = chapterData.chapters || []; // 删除旧章节 await prisma.bookChapter.deleteMany({ where: { bookId: parseInt(bookId) } }); // 插入新章节(递归处理章→节→小节) let chapterNumber = 0; async function insertChapters(parentId: number | null, items: any[], level: number) { for (const item of items) { chapterNumber++; const section = await prisma.bookChapter.create({ data: { bookId: parseInt(bookId), title: item.title || '未命名章节', summary: item.summary || '', content: '', wordCount: item.estimatedWords || 0, number: chapterNumber, level, parentId, genStage: 'draft', }, }); // 递归处理子节点(节、小节) if (item.sections && item.sections.length > 0) { await insertChapters(section.id, item.sections, level + 1); } if (item.subsections && item.subsections.length > 0) { await insertChapters(section.id, item.subsections, level + 1); } } } await insertChapters(null, chapters, 1); // 更新书籍的章节计数 await prisma.book.update({ where: { id: parseInt(bookId) }, data: { totalChapters: chapterNumber }, }); ctx.body = { code: 0, message: '大纲已保存,章节树已重建', data: { chapterCount: chapterNumber }, }; } catch (error) { console.error('[Interactive] 保存 outline 失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '保存失败' }; } }); /** * POST /api/book-generator/langgraph/books/:id/interactive/generate * 运行并行内容生成 + 连贯性编辑 */ router.post('/books/:id/interactive/generate', optionalAuth, async (ctx: Context) => { try { const bookId = ctx.params.id; const book = await prisma.book.findUnique({ where: { id: parseInt(bookId) } }); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '书籍不存在' }; return; } const userId = book.userId || parseInt(TEST_USER_ID); // 配额检查 const scaleConfig = getScaleConfig(book.bookScale || '1000'); const totalWords = scaleConfig.totalWords; const audioQuota = await checkAudioQuota(userId, totalWords); if (!audioQuota.allowed) { ctx.status = 403; ctx.body = { code: 1, message: audioQuota.reason }; return; } // 构造 minimal state const initState: any = { bookId, topic: book.description, bookScale: book.bookScale || '1000', genLevel: (book as any).genLevel || 2, description: book.description, title: book.title, userId, bookPlan: book.bookAnalysis || '', outline: book.outlineJson || '', completedChapters: [], failedChapters: [], }; console.log(`[Interactive] 开始并行生成内容: bookId=${bookId}`); // ===== 直接异步生成(跳过队列) ===== const { langGraphGenerator } = await import('./index.js'); langGraphGenerator.generate( bookId, book.description, book.bookScale || '1000', (book as any).genLevel || 2 ).then(() => { console.log(`[Interactive] 生成完成: bookId=${bookId}`); }).catch(err => { console.error(`[Interactive] 生成失败: bookId=${bookId}`, err); }); await prisma.book.update({ where: { id: parseInt(bookId) }, data: { genStage: 'generating_content' }, }); ctx.body = { code: 0, message: '内容生成已启动', data: { bookId, genStage: 'generating_content', mode: 'direct' }, }; return; try { await prisma.book.update({ where: { id: parseInt(bookId) }, data: { genStage: 'generating_content' }, }); // Step 1: 并行生成内容 const contentResult = await writeChaptersParallelNode(initState); console.log(`[Interactive] 并行内容生成完成: bookId=${bookId}`); // Step 2: 连贯性编辑 const mergeState = { ...initState, ...contentResult }; await continuityEditNode(mergeState); console.log(`[Interactive] 连贯性编辑完成: bookId=${bookId}`); ctx.body = { code: 0, message: '内容生成完成', data: { bookId, genStage: 'completed', mode: 'sync' }, }; } catch (generateError) { console.error(`[Interactive] 内容生成失败: bookId=${bookId}`, generateError); await prisma.book.update({ where: { id: parseInt(bookId) }, data: { genStage: 'failed', errorMsg: generateError instanceof Error ? generateError.message : '生成失败' }, }); ctx.status = 500; ctx.body = { code: 1, message: generateError instanceof Error ? generateError.message : '生成失败' }; } } catch (error) { console.error('[Interactive] generate 失败:', error); ctx.status = 500; ctx.body = { code: 1, message: error instanceof Error ? error.message : '生成失败' }; } }); export default router;