|
|
@@ -5,21 +5,18 @@
|
|
|
|
|
|
import Router from '@koa/router';
|
|
|
import { Context } from 'koa';
|
|
|
-import { langGraphGenerator } from './langgraph-generator';
|
|
|
+import { langGraphGenerator, getScaleConfig } from './langgraph-generator';
|
|
|
+import { queueService, QueueType } from '../../services/queue.service';
|
|
|
import { bookStore } from './book-generator.store';
|
|
|
import { estimateBookWords, estimateAudioMinutesFromWords, checkBookGenerationQuota } from '../subscription/subscription.service';
|
|
|
+import { optionalAuth } from '../../middleware/auth';
|
|
|
+import { getAllBookTypes, getDetectableTypes, getBookTypeConfig, BOOK_TYPE_CONFIG, DETECTABLE_TYPES } from './book-type-config';
|
|
|
+import { callLLMWithMessages, ChatMessage } from '../../services/llm';
|
|
|
|
|
|
-const router = new Router();
|
|
|
+// 开发环境测试用户ID
|
|
|
+const TEST_USER_ID = '1';
|
|
|
|
|
|
-// 书籍规模到章节数映射
|
|
|
-const SCALE_TO_CHAPTERS: Record<string, number> = {
|
|
|
- '800': 1,
|
|
|
- '2000': 1,
|
|
|
- '5000': 1,
|
|
|
- '小册子': 5,
|
|
|
- '标准教程': 10,
|
|
|
- '系统教材': 15,
|
|
|
-};
|
|
|
+const router = new Router();
|
|
|
|
|
|
/**
|
|
|
* GET /api/book-generator/langgraph/estimate
|
|
|
@@ -33,17 +30,18 @@ router.get('/estimate', async (ctx: Context) => {
|
|
|
ctx.body = { code: 1, message: '请提供书籍规模' };
|
|
|
return;
|
|
|
}
|
|
|
-
|
|
|
- const wordEstimate = estimateBookWords(scale);
|
|
|
- const audioMinutes = estimateAudioMinutesFromWords(wordEstimate.avg);
|
|
|
- const estimatedChapters = SCALE_TO_CHAPTERS[scale] || 10;
|
|
|
-
|
|
|
+
|
|
|
+ const scaleConfig = getScaleConfig(scale);
|
|
|
+ const avgWords = Math.round((scaleConfig.wordRange.min + scaleConfig.wordRange.max) / 2);
|
|
|
+ const audioMinutes = estimateAudioMinutesFromWords(avgWords);
|
|
|
+ const estimatedChapters = Math.round((scaleConfig.chapterRange.min + scaleConfig.chapterRange.max) / 2);
|
|
|
+
|
|
|
const result: any = {
|
|
|
scale,
|
|
|
- words: wordEstimate,
|
|
|
+ scaleConfig,
|
|
|
audioMinutes: {
|
|
|
- min: estimateAudioMinutesFromWords(wordEstimate.min),
|
|
|
- max: estimateAudioMinutesFromWords(wordEstimate.max),
|
|
|
+ min: estimateAudioMinutesFromWords(scaleConfig.wordRange.min),
|
|
|
+ max: estimateAudioMinutesFromWords(scaleConfig.wordRange.max),
|
|
|
avg: audioMinutes
|
|
|
},
|
|
|
estimatedChapters
|
|
|
@@ -62,16 +60,232 @@ router.get('/estimate', async (ctx: Context) => {
|
|
|
};
|
|
|
});
|
|
|
|
|
|
+/**
|
|
|
+ * 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,
|
|
|
+ chapterWords: t.chapterWords,
|
|
|
+ sectionWords: t.sectionWords,
|
|
|
+ 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.min}-${t.chapters.max}章,总字数${t.totalWords.min}-${t.totalWords.max}字,每章约${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 } {
|
|
|
+ const text = `${title} ${description}`.toLowerCase();
|
|
|
+
|
|
|
+ if (text.includes('小说') || text.includes('故事') || text.includes('fiction')) {
|
|
|
+ if (text.includes('网络') || text.includes('连载') || text.includes('修仙') || text.includes('穿越')) {
|
|
|
+ return { detectedType: '网络小说' };
|
|
|
+ }
|
|
|
+ return { detectedType: '现代出版长篇小说' };
|
|
|
+ }
|
|
|
+ if (text.includes('科普') || text.includes('经管') || text.includes('畅销') || text.includes('通俗')) {
|
|
|
+ return { detectedType: '科普经管畅销书' };
|
|
|
+ }
|
|
|
+ if (text.includes('专业') || text.includes('大学') || text.includes('研究生') || text.includes('算法') || text.includes('操作系统') || text.includes('数据库')) {
|
|
|
+ return { detectedType: '大学专业教材' };
|
|
|
+ }
|
|
|
+ if (text.includes('中小学') || text.includes('初中') || text.includes('高中') || text.includes('青少年') || text.includes('儿童')) {
|
|
|
+ return { detectedType: '中小学课本' };
|
|
|
+ }
|
|
|
+ if (text.includes('古典') || text.includes('章回') || text.includes('名著') || text.includes('红楼') || text.includes('西游') || text.includes('三国') || text.includes('水浒')) {
|
|
|
+ return { detectedType: '古典名著' };
|
|
|
+ }
|
|
|
+
|
|
|
+ return { detectedType: '中小学课本' }; // 默认
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * AI 自动检测书籍类型(后端静默调用,前端无感知)
|
|
|
+ */
|
|
|
+async function autoDetectBookType(title: string, description: string): Promise<string> {
|
|
|
+ const detectableTypes = DETECTABLE_TYPES;
|
|
|
+ const typeDescriptions = detectableTypes.map(key => {
|
|
|
+ const t = BOOK_TYPE_CONFIG[key];
|
|
|
+ return `- ${t.key}: ${t.description},${t.chapters.min}-${t.chapters.max}章,总字数${t.totalWords.min}-${t.totalWords.max}字`;
|
|
|
+ }).join('\n');
|
|
|
+
|
|
|
+ const messages: ChatMessage[] = [
|
|
|
+ {
|
|
|
+ role: 'system',
|
|
|
+ content: `你是一位专业的图书分类编辑。根据标题和描述,判断书籍属于以下哪种类型:
|
|
|
+
|
|
|
+可选类型:
|
|
|
+${typeDescriptions}
|
|
|
+
|
|
|
+分类规则:
|
|
|
+1. 分析标题中的关键词(如"科普""青少年""专业""小说"等)
|
|
|
+2. 分析描述中的目标读者、写作风格、内容深度
|
|
|
+3. 匹配最符合的类型
|
|
|
+
|
|
|
+输出格式:只返回类型 key,不要其他内容。`,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ role: 'user',
|
|
|
+ content: `标题:《${title}》\n描述:${description}`,
|
|
|
+ },
|
|
|
+ ];
|
|
|
+
|
|
|
+ try {
|
|
|
+ const response = await callLLMWithMessages(messages);
|
|
|
+ const trimmed = response.trim();
|
|
|
+ if (detectableTypes.includes(trimmed)) return trimmed;
|
|
|
+ const found = detectableTypes.find(t => trimmed.includes(t) || t.includes(trimmed));
|
|
|
+ if (found) return found;
|
|
|
+ return keywordFallback(title, description).detectedType;
|
|
|
+ } catch {
|
|
|
+ return keywordFallback(title, description).detectedType;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* POST /api/book-generator/langgraph/books
|
|
|
- * 使用 LangGraph 创建并生成书籍
|
|
|
+ * 使用 LangGraph 创建并生成书籍(异步,自动生成大纲和内容)
|
|
|
+ * AI 根据标题+描述自动判断书籍类型
|
|
|
*/
|
|
|
router.post('/books', async (ctx: Context) => {
|
|
|
try {
|
|
|
const body = ctx.request.body as {
|
|
|
title: string;
|
|
|
description: string;
|
|
|
- bookScale?: 'short' | 'medium' | 'long';
|
|
|
+ bookScale?: string;
|
|
|
generateForeword?: boolean;
|
|
|
generateAfterword?: boolean;
|
|
|
};
|
|
|
@@ -82,32 +296,40 @@ router.post('/books', async (ctx: Context) => {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- const bookScale = body.bookScale || '标准教程';
|
|
|
+ // AI 自动检测书籍类型(如果前端传了 bookScale 则用前端的,否则自动检测)
|
|
|
+ let bookScale = body.bookScale;
|
|
|
+ if (!bookScale) {
|
|
|
+ const detectResult = await autoDetectBookType(body.title, body.description);
|
|
|
+ bookScale = detectResult;
|
|
|
+ console.log(`[LangGraph] AI 自动检测类型: ${body.title} -> ${bookScale}`);
|
|
|
+ }
|
|
|
|
|
|
- // 创建书籍(先设置一个预估章节数,实际数量由AI分析后确定)
|
|
|
- const estimatedChapters = SCALE_TO_CHAPTERS[bookScale] || 10;
|
|
|
+ // 创建书籍(预估章节数,实际数量由AI根据字数范围分析后确定)
|
|
|
+ const scaleConfig = getScaleConfig(bookScale);
|
|
|
+ const estimatedChapters = Math.round((scaleConfig.chapterRange.min + scaleConfig.chapterRange.max) / 2);
|
|
|
const book = await bookStore.create({
|
|
|
title: body.title,
|
|
|
description: body.description,
|
|
|
+ bookScale: bookScale,
|
|
|
totalChapters: estimatedChapters,
|
|
|
});
|
|
|
|
|
|
- // 启动 LangGraph 生成(异步,不阻塞)
|
|
|
- langGraphGenerator.generate(
|
|
|
- book.id,
|
|
|
- body.description,
|
|
|
- bookScale
|
|
|
- ).catch(err => {
|
|
|
- console.error('[LangGraph] 生成失败:', err);
|
|
|
+ // 将生成任务加入队列(由队列处理器异步执行)
|
|
|
+ const jobId = await queueService.addBookGenerationTask({
|
|
|
+ bookId: book.id,
|
|
|
+ topic: body.description,
|
|
|
+ bookScale,
|
|
|
+ });
|
|
|
+ console.log([LangGraph] 生成任务已加入队列: bookId=, jobId=\);
|
|
|
});
|
|
|
|
|
|
ctx.body = {
|
|
|
code: 0,
|
|
|
- message: 'LangGraph 生成任务已启动',
|
|
|
+ message: '书籍创建成功,生成已开始',
|
|
|
data: {
|
|
|
- bookId: book.id,
|
|
|
+ book,
|
|
|
taskId: `lg_${book.id}_${Date.now()}`,
|
|
|
- status: 'started',
|
|
|
+ status: 'generating',
|
|
|
},
|
|
|
};
|
|
|
} catch (error) {
|
|
|
@@ -123,10 +345,34 @@ router.post('/books', async (ctx: Context) => {
|
|
|
/**
|
|
|
* GET /api/book-generator/langgraph/books
|
|
|
* 获取书籍列表
|
|
|
+ * 返回:公开的书籍(有公开音频)+ 当前用户自己的书籍
|
|
|
+ * 注意:此接口已废弃,请使用 /public-books 或 /my-books
|
|
|
*/
|
|
|
-router.get('/books', async (ctx: Context) => {
|
|
|
+router.get('/books', optionalAuth, async (ctx: Context) => {
|
|
|
try {
|
|
|
- const books = await bookStore.getAllByUser();
|
|
|
+ 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<string, any>();
|
|
|
+ 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);
|
|
|
@@ -135,14 +381,77 @@ router.get('/books', async (ctx: Context) => {
|
|
|
}
|
|
|
});
|
|
|
|
|
|
+/**
|
|
|
+ * 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', async (ctx: Context) => {
|
|
|
+router.get('/books/:id', optionalAuth, async (ctx: Context) => {
|
|
|
try {
|
|
|
const bookId = ctx.params.id as string;
|
|
|
- const book = await bookStore.getById(bookId);
|
|
|
+ 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;
|
|
|
@@ -158,6 +467,49 @@ router.get('/books/:id', async (ctx: Context) => {
|
|
|
}
|
|
|
});
|
|
|
|
|
|
+/**
|
|
|
+ * 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.status === 'completed').length;
|
|
|
+ const totalChapters = book.outline?.chapters?.length || book.totalChapters || 0;
|
|
|
+
|
|
|
+ // 如果有大纲,使用大纲章节数计算进度
|
|
|
+ let progress = book.progress;
|
|
|
+ if (totalChapters > 0 && book.status !== 'completed') {
|
|
|
+ progress = Math.round((completedChapters / totalChapters) * 100);
|
|
|
+ }
|
|
|
+
|
|
|
+ ctx.body = {
|
|
|
+ code: 0,
|
|
|
+ message: 'success',
|
|
|
+ data: {
|
|
|
+ bookId,
|
|
|
+ status: book.status,
|
|
|
+ progress,
|
|
|
+ completedChapters,
|
|
|
+ totalChapters,
|
|
|
+ },
|
|
|
+ };
|
|
|
+ } 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
|
|
|
* 删除书籍
|
|
|
@@ -181,6 +533,7 @@ router.delete('/books/:id', async (ctx: Context) => {
|
|
|
router.post('/books/:id/generate', async (ctx: Context) => {
|
|
|
try {
|
|
|
const bookId = ctx.params.id as string;
|
|
|
+ const body = ctx.request.body as { bookScale?: string };
|
|
|
const book = await bookStore.getById(bookId);
|
|
|
|
|
|
if (!book) {
|
|
|
@@ -189,14 +542,17 @@ router.post('/books/:id/generate', async (ctx: Context) => {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- // 启动 LangGraph 生成
|
|
|
- langGraphGenerator.generate(
|
|
|
+ // 优先使用请求传入的 scale,否则使用书籍保存的 scale,最后默认标准教程
|
|
|
+ const bookScale = body.bookScale || book.bookScale || '标准教程';
|
|
|
+
|
|
|
+ // 将生成任务加入队列
|
|
|
+ const jobId = await queueService.addBookGenerationTask({
|
|
|
bookId,
|
|
|
- book.description,
|
|
|
- '标准教程' // 固定为标准教程规模
|
|
|
- ).catch(err => {
|
|
|
- console.error('[LangGraph] 生成失败:', err);
|
|
|
+ topic: book.description,
|
|
|
+ bookScale,
|
|
|
});
|
|
|
+
|
|
|
+ console.log(`[LangGraph] 重新生成任务已加入队列: bookId=${bookId}, jobId=${jobId}`);
|
|
|
|
|
|
ctx.body = {
|
|
|
code: 0,
|
|
|
@@ -217,4 +573,198 @@ router.post('/books/:id/generate', async (ctx: Context) => {
|
|
|
}
|
|
|
});
|
|
|
|
|
|
+/**
|
|
|
+ * 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 = 'cherry' } = ctx.request.body as { voiceId?: string };
|
|
|
+
|
|
|
+ const book = await bookStore.getById(bookId);
|
|
|
+ if (!book) {
|
|
|
+ ctx.status = 404;
|
|
|
+ ctx.body = { code: 1, message: '书籍不存在' };
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取所有小节 (level=3)
|
|
|
+ const chapters = await bookStore.getChapterTree(bookId);
|
|
|
+ const subsections = chapters.filter(c => c.level === 3 && c.content);
|
|
|
+
|
|
|
+ if (subsections.length === 0) {
|
|
|
+ ctx.body = { code: 1, message: '没有可生成音频的小节' };
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 异步生成所有小节音频
|
|
|
+ for (const sub of subsections) {
|
|
|
+ bookStore.generateChapterAudioById(sub.id, book.userId || 1).catch(err => {
|
|
|
+ console.error(`[Audio] 小节${sub.number}音频生成失败:`, err);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ ctx.body = {
|
|
|
+ code: 0,
|
|
|
+ message: '音频生成任务已启动',
|
|
|
+ data: {
|
|
|
+ totalSubsections: subsections.length,
|
|
|
+ taskId: `audio_${bookId}_${Date.now()}`,
|
|
|
+ },
|
|
|
+ };
|
|
|
+ } 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.status === '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.status === 'completed') {
|
|
|
+ ctx.body = { code: 1, message: '书籍已生成完成,无需继续' };
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取失败的小节
|
|
|
+ const chapters = await bookStore.getChapterTree(bookId);
|
|
|
+ const failedSubsections = chapters.filter(c => c.status === 'failed');
|
|
|
+
|
|
|
+ if (failedSubsections.length === 0) {
|
|
|
+ ctx.body = { code: 1, message: '没有失败的小节需要重试' };
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 重置失败小节的状态为 pending
|
|
|
+ for (const sub of failedSubsections) {
|
|
|
+ await bookStore.updateChapterById(sub.id, {
|
|
|
+ status: 'pending',
|
|
|
+ errorMsg: null,
|
|
|
+ content: null,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 将续生成任务加入队列
|
|
|
+ const jobId = await queueService.addBookGenerationTask({
|
|
|
+ bookId,
|
|
|
+ topic: book.description || book.title,
|
|
|
+ bookScale: book.bookScale || '标准教程',
|
|
|
+ });
|
|
|
+
|
|
|
+ console.log(`[LangGraph] 续生成任务已加入队列: bookId=${bookId}, jobId=${jobId}`);
|
|
|
+ });
|
|
|
+
|
|
|
+ ctx.body = {
|
|
|
+ code: 0,
|
|
|
+ message: `已重启生成,将重试 ${failedSubsections.length} 个失败的小节`,
|
|
|
+ data: {
|
|
|
+ bookId,
|
|
|
+ retryCount: failedSubsections.length,
|
|
|
+ status: '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, {
|
|
|
+ status: 'pending',
|
|
|
+ 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 : '重试失败' };
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
export default router;
|