diff --git a/backend/app/api/chapters.py b/backend/app/api/chapters.py index 16564f8..6cfc0a6 100644 --- a/backend/app/api/chapters.py +++ b/backend/app/api/chapters.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Query, BackgroundTasks from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func +from sqlalchemy.orm import selectinload import json import asyncio from typing import Optional @@ -125,7 +126,7 @@ async def get_project_chapters( request: Request, db: AsyncSession = Depends(get_db) ): - """获取指定项目的所有章节(路径参数版本)""" + """获取指定项目的所有章节(带大纲信息)""" # 验证用户权限 user_id = getattr(request.state, 'user_id', None) await verify_project_access(project_id, user_id, db) @@ -136,7 +137,7 @@ async def get_project_chapters( ) total = count_result.scalar_one() - # 获取章节列表 + # 获取章节列表,同时加载关联的大纲信息 result = await db.execute( select(Chapter) .where(Chapter.project_id == project_id) @@ -144,7 +145,46 @@ async def get_project_chapters( ) chapters = result.scalars().all() - return ChapterListResponse(total=total, items=chapters) + # 获取所有大纲信息(用于填充outline_title) + outline_ids = [ch.outline_id for ch in chapters if ch.outline_id] + outlines_map = {} + if outline_ids: + outlines_result = await db.execute( + select(Outline).where(Outline.id.in_(outline_ids)) + ) + outlines_map = {o.id: o for o in outlines_result.scalars().all()} + + # 为所有章节添加大纲信息(统一处理) + chapters_with_outline = [] + for chapter in chapters: + chapter_dict = { + "id": chapter.id, + "project_id": chapter.project_id, + "chapter_number": chapter.chapter_number, + "title": chapter.title, + "content": chapter.content, + "summary": chapter.summary, + "word_count": chapter.word_count, + "status": chapter.status, + "outline_id": chapter.outline_id, + "sub_index": chapter.sub_index, + "expansion_plan": chapter.expansion_plan, + "created_at": chapter.created_at, + "updated_at": chapter.updated_at, + } + + # 添加大纲信息 + if chapter.outline_id and chapter.outline_id in outlines_map: + outline = outlines_map[chapter.outline_id] + chapter_dict["outline_title"] = outline.title + chapter_dict["outline_order"] = outline.order_index + else: + chapter_dict["outline_title"] = None + chapter_dict["outline_order"] = None + + chapters_with_outline.append(chapter_dict) + + return ChapterListResponse(total=total, items=chapters_with_outline) @router.get("/{chapter_id}", response_model=ChapterResponse, summary="获取章节详情") diff --git a/backend/app/api/outlines.py b/backend/app/api/outlines.py index 31897b7..b79fb3f 100644 --- a/backend/app/api/outlines.py +++ b/backend/app/api/outlines.py @@ -17,11 +17,17 @@ from app.schemas.outline import ( OutlineResponse, OutlineListResponse, OutlineGenerateRequest, - OutlineReorderRequest + OutlineExpansionRequest, + OutlineExpansionResponse, + BatchOutlineExpansionRequest, + BatchOutlineExpansionResponse, + CreateChaptersFromPlansRequest, + CreateChaptersFromPlansResponse ) from app.services.ai_service import AIService from app.services.prompt_service import prompt_service from app.services.memory_service import memory_service +from app.services.plot_expansion_service import PlotExpansionService from app.logger import get_logger from app.api.settings import get_user_ai_service from app.utils.sse_response import SSEResponse, create_sse_response @@ -69,7 +75,7 @@ async def create_outline( request: Request, db: AsyncSession = Depends(get_db) ): - """创建新的章节大纲,同时创建对应的章节记录""" + """创建新的章节大纲(不自动创建章节,需通过展开功能生成章节)""" # 验证用户权限 user_id = getattr(request.state, 'user_id', None) await verify_project_access(outline.project_id, user_id, db) @@ -78,16 +84,6 @@ async def create_outline( db_outline = Outline(**outline.model_dump()) db.add(db_outline) - # 同步创建对应的章节记录 - chapter = Chapter( - project_id=outline.project_id, - chapter_number=outline.order_index, - title=outline.title, - summary=outline.content[:500] if len(outline.content) > 500 else outline.content, - status="draft" - ) - db.add(chapter) - await db.commit() await db.refresh(db_outline) return db_outline @@ -178,7 +174,7 @@ async def update_outline( request: Request, db: AsyncSession = Depends(get_db) ): - """更新大纲信息,同步更新对应章节和structure字段""" + """更新大纲信息并同步更新structure字段""" result = await db.execute( select(Outline).where(Outline.id == outline_id) ) @@ -218,26 +214,6 @@ async def update_outline( except json.JSONDecodeError: logger.warning(f"大纲 {outline_id} 的structure字段格式错误,跳过更新") - # 同步更新对应的章节标题和摘要 - if 'title' in update_data or 'content' in update_data: - chapter_result = await db.execute( - select(Chapter).where( - Chapter.project_id == outline.project_id, - Chapter.chapter_number == outline.order_index - ) - ) - chapter = chapter_result.scalar_one_or_none() - - if chapter: - if 'title' in update_data: - chapter.title = outline.title - if 'content' in update_data: - # 更新章节摘要(取content前500字符) - chapter.summary = outline.content[:500] if len(outline.content) > 500 else outline.content - logger.info(f"同步更新章节 {chapter.id} 的标题和摘要") - else: - logger.warning(f"未找到对应的章节记录 (order_index={outline.order_index})") - await db.commit() await db.refresh(outline) return outline @@ -249,7 +225,7 @@ async def delete_outline( request: Request, db: AsyncSession = Depends(get_db) ): - """删除大纲,同步删除章节,并重新排序后续项""" + """删除大纲,同时删除该大纲对应的所有章节""" result = await db.execute( select(Outline).where(Outline.id == outline_id) ) @@ -265,18 +241,18 @@ async def delete_outline( project_id = outline.project_id deleted_order = outline.order_index - # 删除对应的章节 - await db.execute( - delete(Chapter).where( - Chapter.project_id == project_id, - Chapter.chapter_number == deleted_order - ) + # 删除该大纲对应的所有章节(通过outline_id关联) + delete_result = await db.execute( + delete(Chapter).where(Chapter.outline_id == outline_id) ) + deleted_chapters_count = delete_result.rowcount + + logger.info(f"删除大纲 {outline_id},同时删除了 {deleted_chapters_count} 个关联章节") # 删除大纲 await db.delete(outline) - # 重新排序后续的大纲和章节(序号-1) + # 重新排序后续的大纲(序号-1) result = await db.execute( select(Outline).where( Outline.project_id == project_id, @@ -286,110 +262,14 @@ async def delete_outline( subsequent_outlines = result.scalars().all() for o in subsequent_outlines: - old_order = o.order_index o.order_index -= 1 - - # 同步更新对应的章节 - chapter_result = await db.execute( - select(Chapter).where( - Chapter.project_id == project_id, - Chapter.chapter_number == old_order - ) - ) - chapter = chapter_result.scalar_one_or_none() - if chapter: - chapter.chapter_number = old_order - 1 await db.commit() - return {"message": "大纲删除成功"} - - -@router.post("/reorder", summary="批量重排序大纲") -async def reorder_outlines( - reorder_request: OutlineReorderRequest, - http_request: Request, - db: AsyncSession = Depends(get_db) -): - """ - 批量调整大纲顺序,同步更新章节序号 - - 策略:先收集所有变更,最后一次性提交,避免临时冲突 - """ - try: - # 验证用户权限(通过第一个大纲的project_id) - user_id = getattr(http_request.state, 'user_id', None) - if reorder_request.orders and len(reorder_request.orders) > 0: - first_outline_result = await db.execute( - select(Outline).where(Outline.id == reorder_request.orders[0].id) - ) - first_outline = first_outline_result.scalar_one_or_none() - if first_outline: - await verify_project_access(first_outline.project_id, user_id, db) - - # 第一步:收集所有大纲和对应的章节 - outline_chapter_map = {} # {outline_id: (outline, chapter, old_order, new_order)} - - for item in reorder_request.orders: - outline_id = item.id - new_order = item.order_index - - # 获取大纲 - result = await db.execute( - select(Outline).where(Outline.id == outline_id) - ) - outline = result.scalar_one_or_none() - - if not outline: - logger.warning(f"大纲 {outline_id} 不存在,跳过") - continue - - old_order = outline.order_index - - # 获取对应的章节(通过旧的chapter_number匹配) - chapter_result = await db.execute( - select(Chapter).where( - Chapter.project_id == outline.project_id, - Chapter.chapter_number == old_order - ) - ) - chapter = chapter_result.first() - chapter_obj = chapter[0] if chapter else None - - outline_chapter_map[outline_id] = (outline, chapter_obj, old_order, new_order) - - # 第二步:批量更新所有大纲和章节 - updated_outlines = 0 - updated_chapters = 0 - - for outline_id, (outline, chapter, old_order, new_order) in outline_chapter_map.items(): - # 更新大纲 - outline.order_index = new_order - updated_outlines += 1 - - # 更新章节 - if chapter: - chapter.chapter_number = new_order - chapter.title = outline.title # 同步更新标题 - updated_chapters += 1 - else: - logger.warning(f"章节 {old_order} 不存在,跳过") - - # 第三步:一次性提交所有更改 - await db.commit() - - logger.info(f"重排序成功:更新了 {updated_outlines} 个大纲,{updated_chapters} 个章节") - - return { - "message": "重排序成功", - "updated_outlines": updated_outlines, - "updated_chapters": updated_chapters - } - - except Exception as e: - await db.rollback() - logger.error(f"重排序失败: {str(e)}") - raise HTTPException(status_code=500, detail=f"重排序失败: {str(e)}") + return { + "message": "大纲删除成功", + "deleted_chapters": deleted_chapters_count + } @router.post("/generate", response_model=OutlineListResponse, summary="AI生成/续写大纲") @@ -562,15 +442,12 @@ async def _generate_new_outline( # 解析响应 outline_data = _parse_ai_response(ai_content) - # 全新生成模式:必须删除旧大纲和章节 + # 全新生成模式:必须删除旧大纲(章节不自动删除,由用户手动管理) # 注意:这是"new"模式的核心逻辑,应该始终删除旧数据 - logger.info(f"删除项目 {project.id} 的旧大纲和章节") + logger.info(f"删除项目 {project.id} 的旧大纲") await db.execute( delete(Outline).where(Outline.project_id == project.id) ) - await db.execute( - delete(Chapter).where(Chapter.project_id == project.id) - ) # 保存新大纲 outlines = await _save_outlines( @@ -947,7 +824,7 @@ async def _save_outlines( db: AsyncSession, start_index: int = 1 ) -> List[Outline]: - """保存大纲到数据库""" + """保存大纲到数据库(不自动创建章节)""" outlines = [] for idx, chapter_data in enumerate(outline_data): @@ -973,16 +850,6 @@ async def _save_outlines( ) db.add(outline) outlines.append(outline) - - # 同步创建章节记录 - chapter = Chapter( - project_id=project_id, - chapter_number=order_idx, - title=title, - summary=content[:500] if len(content) > 500 else content, - status="draft" - ) - db.add(chapter) return outlines @@ -1113,15 +980,12 @@ async def new_outline_generator( # 解析响应 outline_data = _parse_ai_response(ai_content) - # 删除旧大纲和章节 - yield await SSEResponse.send_progress("清理旧数据...", 75) - logger.info(f"删除项目 {project_id} 的旧大纲和章节") + # 删除旧大纲(章节不自动删除,由用户手动管理) + yield await SSEResponse.send_progress("清理旧大纲...", 75) + logger.info(f"删除项目 {project_id} 的旧大纲") await db.execute( delete(Outline).where(Outline.project_id == project_id) ) - await db.execute( - delete(Chapter).where(Chapter.project_id == project_id) - ) # 保存新大纲 yield await SSEResponse.send_progress("💾 保存大纲到数据库...", 80) @@ -1571,4 +1435,935 @@ async def generate_outline_stream( raise HTTPException( status_code=400, detail=f"不支持的模式: {mode}" - ) \ No newline at end of file + ) + + +async def expand_outline_generator( + outline_id: str, + data: Dict[str, Any], + db: AsyncSession, + user_ai_service: AIService +) -> AsyncGenerator[str, None]: + """单个大纲展开SSE生成器 - 实时推送进度(支持分批生成)""" + db_committed = False + try: + yield await SSEResponse.send_progress("开始展开大纲...", 5) + + target_chapter_count = int(data.get("target_chapter_count", 3)) + expansion_strategy = data.get("expansion_strategy", "balanced") + enable_scene_analysis = data.get("enable_scene_analysis", True) + auto_create_chapters = data.get("auto_create_chapters", False) + batch_size = int(data.get("batch_size", 5)) # 支持自定义批次大小 + + # 获取大纲 + yield await SSEResponse.send_progress("加载大纲信息...", 10) + result = await db.execute( + select(Outline).where(Outline.id == outline_id) + ) + outline = result.scalar_one_or_none() + + if not outline: + yield await SSEResponse.send_error("大纲不存在", 404) + return + + # 获取项目信息 + yield await SSEResponse.send_progress("加载项目信息...", 15) + project_result = await db.execute( + select(Project).where(Project.id == outline.project_id) + ) + project = project_result.scalar_one_or_none() + if not project: + yield await SSEResponse.send_error("项目不存在", 404) + return + + yield await SSEResponse.send_progress( + f"准备展开《{outline.title}》为 {target_chapter_count} 章...", + 20 + ) + + # 创建展开服务实例 + expansion_service = PlotExpansionService(user_ai_service) + + # 定义进度回调函数 + async def progress_callback(batch_num: int, total_batches: int, start_idx: int, batch_size: int): + progress = 30 + int((batch_num - 1) / total_batches * 40) + yield await SSEResponse.send_progress( + f"📝 生成第{batch_num}/{total_batches}批(第{start_idx}-{start_idx + batch_size - 1}节)...", + progress + ) + + # 分析大纲并生成章节规划(支持分批) + if target_chapter_count > batch_size: + yield await SSEResponse.send_progress( + f"🤖 AI分批生成章节规划(每批{batch_size}章)...", + 30 + ) + else: + yield await SSEResponse.send_progress("🤖 AI分析大纲,生成章节规划...", 30) + + chapter_plans = await expansion_service.analyze_outline_for_chapters( + outline=outline, + project=project, + db=db, + target_chapter_count=target_chapter_count, + expansion_strategy=expansion_strategy, + enable_scene_analysis=enable_scene_analysis, + provider=data.get("provider"), + model=data.get("model"), + batch_size=batch_size, + progress_callback=None # SSE中暂不支持嵌套回调 + ) + + if not chapter_plans: + yield await SSEResponse.send_error("AI分析失败,未能生成章节规划", 500) + return + + yield await SSEResponse.send_progress( + f"✅ 规划生成完成!共 {len(chapter_plans)} 个章节", + 70 + ) + + # 根据配置决定是否创建章节记录 + created_chapters = None + if auto_create_chapters: + yield await SSEResponse.send_progress("💾 创建章节记录...", 80) + + created_chapters = await expansion_service.create_chapters_from_plans( + outline_id=outline_id, + chapter_plans=chapter_plans, + project_id=outline.project_id, + db=db, + start_chapter_number=None # 自动计算章节序号 + ) + + await db.commit() + db_committed = True + + # 刷新章节数据 + for chapter in created_chapters: + await db.refresh(chapter) + + yield await SSEResponse.send_progress( + f"✅ 成功创建 {len(created_chapters)} 个章节记录", + 90 + ) + + yield await SSEResponse.send_progress("整理结果数据...", 95) + + # 构建响应数据 + result_data = { + "outline_id": outline_id, + "outline_title": outline.title, + "target_chapter_count": target_chapter_count, + "actual_chapter_count": len(chapter_plans), + "expansion_strategy": expansion_strategy, + "chapter_plans": chapter_plans, + "created_chapters": [ + { + "id": ch.id, + "chapter_number": ch.chapter_number, + "title": ch.title, + "summary": ch.summary, + "outline_id": ch.outline_id, + "sub_index": ch.sub_index, + "status": ch.status + } + for ch in created_chapters + ] if created_chapters else None + } + + yield await SSEResponse.send_result(result_data) + yield await SSEResponse.send_progress("🎉 展开完成!", 100, "success") + yield await SSEResponse.send_done() + + except GeneratorExit: + logger.warning("大纲展开生成器被提前关闭") + if not db_committed and db.in_transaction(): + await db.rollback() + logger.info("大纲展开事务已回滚(GeneratorExit)") + except Exception as e: + logger.error(f"大纲展开失败: {str(e)}") + if not db_committed and db.in_transaction(): + await db.rollback() + logger.info("大纲展开事务已回滚(异常)") + yield await SSEResponse.send_error(f"展开失败: {str(e)}") + + +@router.post("/{outline_id}/expand", response_model=OutlineExpansionResponse, summary="展开单个大纲为多章") +async def expand_outline_to_chapters( + outline_id: str, + expansion_request: OutlineExpansionRequest, + request: Request, + db: AsyncSession = Depends(get_db), + user_ai_service: AIService = Depends(get_user_ai_service) +): + """ + 根据单个大纲摘要,通过AI分析生成多个章节规划 + + 流程: + 1. 获取大纲信息和上下文(前后大纲) + 2. 调用AI分析大纲,生成多章节规划 + 3. 根据规划创建章节记录(outline_id关联到原大纲) + + 参数: + - outline_id: 要展开的大纲ID + - expansion_request: 展开配置(章节数量、展开策略等) + + 返回: + - 展开后的章节列表和规划详情 + """ + # 验证用户权限 + user_id = getattr(request.state, 'user_id', None) + + # 获取大纲 + result = await db.execute( + select(Outline).where(Outline.id == outline_id) + ) + outline = result.scalar_one_or_none() + + if not outline: + raise HTTPException(status_code=404, detail="大纲不存在") + + # 验证项目权限 + await verify_project_access(outline.project_id, user_id, db) + + try: + # 创建展开服务实例 + expansion_service = PlotExpansionService(user_ai_service) + + # 获取项目信息 + project_result = await db.execute( + select(Project).where(Project.id == outline.project_id) + ) + project = project_result.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="项目不存在") + + # 分析大纲并生成章节规划 + logger.info(f"开始展开大纲 {outline_id},目标章节数: {expansion_request.target_chapter_count}") + + chapter_plans = await expansion_service.analyze_outline_for_chapters( + outline=outline, + project=project, + db=db, + target_chapter_count=expansion_request.target_chapter_count, + expansion_strategy=expansion_request.expansion_strategy, + enable_scene_analysis=expansion_request.enable_scene_analysis, + provider=expansion_request.provider, + model=expansion_request.model + ) + + if not chapter_plans: + raise HTTPException(status_code=500, detail="AI分析失败,未能生成章节规划") + + logger.info(f"AI分析完成,生成了 {len(chapter_plans)} 个章节规划") + + # 根据规划创建章节记录 + if expansion_request.auto_create_chapters: + created_chapters = await expansion_service.create_chapters_from_plans( + outline_id=outline_id, + chapter_plans=chapter_plans, + project_id=outline.project_id, + db=db, + start_chapter_number=None # 自动计算章节序号 + ) + + await db.commit() + + # 刷新章节数据 + for chapter in created_chapters: + await db.refresh(chapter) + + logger.info(f"成功创建 {len(created_chapters)} 个章节记录") + + # 构建响应 + return OutlineExpansionResponse( + outline_id=outline_id, + outline_title=outline.title, + target_chapter_count=expansion_request.target_chapter_count, + actual_chapter_count=len(chapter_plans), + expansion_strategy=expansion_request.expansion_strategy, + chapter_plans=chapter_plans, + created_chapters=[ + { + "id": ch.id, + "chapter_number": ch.chapter_number, + "title": ch.title, + "summary": ch.summary, + "outline_id": ch.outline_id, + "sub_index": ch.sub_index, + "status": ch.status + } + for ch in created_chapters + ] + ) + else: + # 仅返回章节规划,不创建记录 + logger.info(f"仅生成规划,未创建章节记录") + return OutlineExpansionResponse( + outline_id=outline_id, + outline_title=outline.title, + target_chapter_count=expansion_request.target_chapter_count, + actual_chapter_count=len(chapter_plans), + expansion_strategy=expansion_request.expansion_strategy, + chapter_plans=chapter_plans, + created_chapters=None + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"大纲展开失败: {str(e)}", exc_info=True) + await db.rollback() + raise HTTPException(status_code=500, detail=f"大纲展开失败: {str(e)}") + + +@router.post("/{outline_id}/expand-stream", summary="展开单个大纲为多章(SSE流式)") +async def expand_outline_to_chapters_stream( + outline_id: str, + data: Dict[str, Any], + request: Request, + db: AsyncSession = Depends(get_db), + user_ai_service: AIService = Depends(get_user_ai_service) +): + """ + 使用SSE流式展开单个大纲,实时推送进度 + + 请求体示例: + { + "target_chapter_count": 3, // 目标章节数 + "expansion_strategy": "balanced", // balanced/climax/detail + "auto_create_chapters": false, // 是否自动创建章节 + "enable_scene_analysis": true, // 是否启用场景分析 + "provider": "openai", // 可选 + "model": "gpt-4" // 可选 + } + + 进度阶段: + - 5% - 开始展开 + - 10% - 加载大纲信息 + - 15% - 加载项目信息 + - 20% - 准备展开参数 + - 30% - AI分析大纲(耗时) + - 70% - 规划生成完成 + - 80% - 创建章节记录(如果auto_create_chapters=True) + - 90% - 创建完成 + - 95% - 整理结果数据 + - 100% - 全部完成 + """ + # 获取大纲并验证权限 + result = await db.execute( + select(Outline).where(Outline.id == outline_id) + ) + outline = result.scalar_one_or_none() + + if not outline: + raise HTTPException(status_code=404, detail="大纲不存在") + + # 验证用户权限 + user_id = getattr(request.state, 'user_id', None) + await verify_project_access(outline.project_id, user_id, db) + + return create_sse_response(expand_outline_generator(outline_id, data, db, user_ai_service)) + + +@router.get("/{outline_id}/chapters", summary="获取大纲关联的章节") +async def get_outline_chapters( + outline_id: str, + request: Request, + db: AsyncSession = Depends(get_db) +): + """ + 获取指定大纲已展开的章节列表 + + 用于检查大纲是否已经展开过,如果有则返回章节信息 + """ + # 获取大纲 + result = await db.execute( + select(Outline).where(Outline.id == outline_id) + ) + outline = result.scalar_one_or_none() + + if not outline: + raise HTTPException(status_code=404, detail="大纲不存在") + + # 验证用户权限 + user_id = getattr(request.state, 'user_id', None) + await verify_project_access(outline.project_id, user_id, db) + + # 查询该大纲关联的章节 + chapters_result = await db.execute( + select(Chapter) + .where(Chapter.outline_id == outline_id) + .order_by(Chapter.sub_index) + ) + chapters = chapters_result.scalars().all() + + # 如果有章节,解析展开规划 + expansion_plans = [] + if chapters: + for chapter in chapters: + plan_data = None + if chapter.expansion_plan: + try: + plan_data = json.loads(chapter.expansion_plan) + except json.JSONDecodeError: + logger.warning(f"章节 {chapter.id} 的expansion_plan解析失败") + plan_data = None + + expansion_plans.append({ + "sub_index": chapter.sub_index, + "title": chapter.title, + "plot_summary": chapter.summary or "", + "key_events": plan_data.get("key_events", []) if plan_data else [], + "character_focus": plan_data.get("character_focus", []) if plan_data else [], + "emotional_tone": plan_data.get("emotional_tone", "") if plan_data else "", + "narrative_goal": plan_data.get("narrative_goal", "") if plan_data else "", + "conflict_type": plan_data.get("conflict_type", "") if plan_data else "", + "estimated_words": plan_data.get("estimated_words", 0) if plan_data else 0, + "scenes": plan_data.get("scenes") if plan_data else None + }) + + return { + "has_chapters": len(chapters) > 0, + "outline_id": outline_id, + "outline_title": outline.title, + "chapter_count": len(chapters), + "chapters": [ + { + "id": ch.id, + "chapter_number": ch.chapter_number, + "title": ch.title, + "summary": ch.summary, + "sub_index": ch.sub_index, + "status": ch.status, + "word_count": ch.word_count + } + for ch in chapters + ], + "expansion_plans": expansion_plans if expansion_plans else None + } + + +@router.post("/batch-expand", response_model=BatchOutlineExpansionResponse, summary="批量展开大纲为多章") +async def batch_expand_outlines( + batch_request: BatchOutlineExpansionRequest, + request: Request, + db: AsyncSession = Depends(get_db), + user_ai_service: AIService = Depends(get_user_ai_service) +): + """ + 批量展开项目中的所有大纲或指定大纲列表 + + 流程: + 1. 获取项目中的所有大纲(或指定大纲列表) + 2. 逐个分析大纲,生成多章节规划 + 3. 根据规划批量创建章节记录 + + 参数: + - batch_request: 批量展开配置 + + 返回: + - 所有展开的大纲和章节信息 + """ + # 验证用户权限 + user_id = getattr(request.state, 'user_id', None) + await verify_project_access(batch_request.project_id, user_id, db) + + try: + # 创建展开服务实例 + expansion_service = PlotExpansionService(user_ai_service) + + # 获取项目信息 + project_result = await db.execute( + select(Project).where(Project.id == batch_request.project_id) + ) + project = project_result.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="项目不存在") + + # 获取要展开的大纲列表 + if batch_request.outline_ids: + # 展开指定的大纲 + outlines_result = await db.execute( + select(Outline) + .where( + Outline.project_id == batch_request.project_id, + Outline.id.in_(batch_request.outline_ids) + ) + .order_by(Outline.order_index) + ) + else: + # 展开所有大纲 + outlines_result = await db.execute( + select(Outline) + .where(Outline.project_id == batch_request.project_id) + .order_by(Outline.order_index) + ) + + outlines = outlines_result.scalars().all() + + if not outlines: + raise HTTPException(status_code=404, detail="没有找到要展开的大纲") + + # 批量展开大纲 + logger.info(f"开始批量展开 {len(outlines)} 个大纲") + + expansion_results = [] + total_chapters_created = 0 + skipped_outlines = [] + + for outline in outlines: + try: + # 检查大纲是否已经展开过 + existing_chapters_result = await db.execute( + select(Chapter) + .where(Chapter.outline_id == outline.id) + .limit(1) + ) + existing_chapter = existing_chapters_result.scalar_one_or_none() + + if existing_chapter: + logger.info(f"大纲 {outline.title} (ID: {outline.id}) 已经展开过,跳过") + skipped_outlines.append({ + "outline_id": outline.id, + "outline_title": outline.title, + "reason": "已展开" + }) + continue + + # 分析大纲生成章节规划 + chapter_plans = await expansion_service.analyze_outline_for_chapters( + outline=outline, + project=project, + db=db, + target_chapter_count=batch_request.chapters_per_outline, + expansion_strategy=batch_request.expansion_strategy, + enable_scene_analysis=batch_request.enable_scene_analysis, + provider=batch_request.provider, + model=batch_request.model + ) + + created_chapters = None + if batch_request.auto_create_chapters: + # 创建章节记录 + chapters = await expansion_service.create_chapters_from_plans( + outline_id=outline.id, + chapter_plans=chapter_plans, + project_id=outline.project_id, + db=db, + start_chapter_number=None # 自动计算章节序号 + ) + created_chapters = [ + { + "id": ch.id, + "chapter_number": ch.chapter_number, + "title": ch.title, + "summary": ch.summary, + "outline_id": ch.outline_id, + "sub_index": ch.sub_index, + "status": ch.status + } + for ch in chapters + ] + total_chapters_created += len(chapters) + + expansion_results.append({ + "outline_id": outline.id, + "outline_title": outline.title, + "target_chapter_count": batch_request.chapters_per_outline, + "actual_chapter_count": len(chapter_plans), + "expansion_strategy": batch_request.expansion_strategy, + "chapter_plans": chapter_plans, + "created_chapters": created_chapters + }) + + logger.info(f"大纲 {outline.title} 展开完成,生成 {len(chapter_plans)} 个章节规划") + + except Exception as e: + logger.error(f"展开大纲 {outline.id} 失败: {str(e)}", exc_info=True) + expansion_results.append({ + "outline_id": outline.id, + "outline_title": outline.title, + "target_chapter_count": batch_request.chapters_per_outline, + "actual_chapter_count": 0, + "expansion_strategy": batch_request.expansion_strategy, + "chapter_plans": [], + "created_chapters": None, + "error": str(e) + }) + + logger.info(f"批量展开完成: {len(expansion_results)} 个大纲,共生成 {total_chapters_created} 个章节") + + # 构建响应 + return BatchOutlineExpansionResponse( + project_id=batch_request.project_id, + total_outlines_expanded=len(expansion_results), + total_chapters_created=total_chapters_created, + expansion_results=[ + OutlineExpansionResponse( + outline_id=result["outline_id"], + outline_title=result["outline_title"], + target_chapter_count=result["target_chapter_count"], + actual_chapter_count=result["actual_chapter_count"], + expansion_strategy=result["expansion_strategy"], + chapter_plans=result["chapter_plans"], + created_chapters=result.get("created_chapters") + ) + for result in expansion_results + ] + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"批量大纲展开失败: {str(e)}", exc_info=True) + await db.rollback() + raise HTTPException(status_code=500, detail=f"批量大纲展开失败: {str(e)}") + + +async def batch_expand_outlines_generator( + data: Dict[str, Any], + db: AsyncSession, + user_ai_service: AIService +) -> AsyncGenerator[str, None]: + """批量展开大纲SSE生成器 - 实时推送进度""" + db_committed = False + try: + yield await SSEResponse.send_progress("开始批量展开大纲...", 5) + + project_id = data.get("project_id") + chapters_per_outline = int(data.get("chapters_per_outline", 3)) + expansion_strategy = data.get("expansion_strategy", "balanced") + auto_create_chapters = data.get("auto_create_chapters", False) + outline_ids = data.get("outline_ids") + + # 获取项目信息 + yield await SSEResponse.send_progress("加载项目信息...", 10) + project_result = await db.execute( + select(Project).where(Project.id == project_id) + ) + project = project_result.scalar_one_or_none() + if not project: + yield await SSEResponse.send_error("项目不存在", 404) + return + + # 获取要展开的大纲列表 + yield await SSEResponse.send_progress("获取大纲列表...", 15) + if outline_ids: + outlines_result = await db.execute( + select(Outline) + .where( + Outline.project_id == project_id, + Outline.id.in_(outline_ids) + ) + .order_by(Outline.order_index) + ) + else: + outlines_result = await db.execute( + select(Outline) + .where(Outline.project_id == project_id) + .order_by(Outline.order_index) + ) + + outlines = outlines_result.scalars().all() + + if not outlines: + yield await SSEResponse.send_error("没有找到要展开的大纲", 404) + return + + total_outlines = len(outlines) + yield await SSEResponse.send_progress( + f"共找到 {total_outlines} 个大纲,开始批量展开...", + 20 + ) + + # 创建展开服务实例 + expansion_service = PlotExpansionService(user_ai_service) + + expansion_results = [] + total_chapters_created = 0 + skipped_outlines = [] + + for idx, outline in enumerate(outlines): + try: + # 计算当前进度 (20% - 90%) + progress = 20 + int((idx / total_outlines) * 70) + + yield await SSEResponse.send_progress( + f"📝 处理第 {idx + 1}/{total_outlines} 个大纲: {outline.title}", + progress + ) + + # 检查大纲是否已经展开过 + existing_chapters_result = await db.execute( + select(Chapter) + .where(Chapter.outline_id == outline.id) + .limit(1) + ) + existing_chapter = existing_chapters_result.scalar_one_or_none() + + if existing_chapter: + logger.info(f"大纲 {outline.title} (ID: {outline.id}) 已经展开过,跳过") + skipped_outlines.append({ + "outline_id": outline.id, + "outline_title": outline.title, + "reason": "已展开" + }) + yield await SSEResponse.send_progress( + f"⏭️ {outline.title} 已展开过,跳过", + progress + 1 + ) + continue + + # 分析大纲生成章节规划 + yield await SSEResponse.send_progress( + f"🤖 AI分析大纲: {outline.title}", + progress + 2 + ) + + chapter_plans = await expansion_service.analyze_outline_for_chapters( + outline=outline, + project=project, + db=db, + target_chapter_count=chapters_per_outline, + expansion_strategy=expansion_strategy, + enable_scene_analysis=data.get("enable_scene_analysis", True), + provider=data.get("provider"), + model=data.get("model") + ) + + yield await SSEResponse.send_progress( + f"✅ {outline.title} 规划生成完成 ({len(chapter_plans)} 章)", + progress + 3 + ) + + created_chapters = None + if auto_create_chapters: + # 创建章节记录 + chapters = await expansion_service.create_chapters_from_plans( + outline_id=outline.id, + chapter_plans=chapter_plans, + project_id=outline.project_id, + db=db, + start_chapter_number=None # 自动计算章节序号 + ) + created_chapters = [ + { + "id": ch.id, + "chapter_number": ch.chapter_number, + "title": ch.title, + "summary": ch.summary, + "outline_id": ch.outline_id, + "sub_index": ch.sub_index, + "status": ch.status + } + for ch in chapters + ] + total_chapters_created += len(chapters) + + yield await SSEResponse.send_progress( + f"💾 {outline.title} 章节创建完成 ({len(chapters)} 章)", + progress + 4 + ) + + expansion_results.append({ + "outline_id": outline.id, + "outline_title": outline.title, + "target_chapter_count": chapters_per_outline, + "actual_chapter_count": len(chapter_plans), + "expansion_strategy": expansion_strategy, + "chapter_plans": chapter_plans, + "created_chapters": created_chapters + }) + + logger.info(f"大纲 {outline.title} 展开完成,生成 {len(chapter_plans)} 个章节规划") + + except Exception as e: + logger.error(f"展开大纲 {outline.id} 失败: {str(e)}", exc_info=True) + yield await SSEResponse.send_progress( + f"❌ {outline.title} 展开失败: {str(e)}", + progress + ) + expansion_results.append({ + "outline_id": outline.id, + "outline_title": outline.title, + "target_chapter_count": chapters_per_outline, + "actual_chapter_count": 0, + "expansion_strategy": expansion_strategy, + "chapter_plans": [], + "created_chapters": None, + "error": str(e) + }) + + yield await SSEResponse.send_progress("整理结果数据...", 95) + + db_committed = True + + logger.info(f"批量展开完成: {len(expansion_results)} 个大纲,跳过 {len(skipped_outlines)} 个,共生成 {total_chapters_created} 个章节") + + # 发送最终结果 + result_data = { + "project_id": project_id, + "total_outlines_expanded": len(expansion_results), + "total_chapters_created": total_chapters_created, + "skipped_count": len(skipped_outlines), + "skipped_outlines": skipped_outlines, + "expansion_results": [ + { + "outline_id": result["outline_id"], + "outline_title": result["outline_title"], + "target_chapter_count": result["target_chapter_count"], + "actual_chapter_count": result["actual_chapter_count"], + "expansion_strategy": result["expansion_strategy"], + "chapter_plans": result["chapter_plans"], + "created_chapters": result.get("created_chapters") + } + for result in expansion_results + ] + } + + yield await SSEResponse.send_result(result_data) + yield await SSEResponse.send_progress("🎉 批量展开完成!", 100, "success") + yield await SSEResponse.send_done() + + except GeneratorExit: + logger.warning("批量展开生成器被提前关闭") + if not db_committed and db.in_transaction(): + await db.rollback() + logger.info("批量展开事务已回滚(GeneratorExit)") + except Exception as e: + logger.error(f"批量展开失败: {str(e)}") + if not db_committed and db.in_transaction(): + await db.rollback() + logger.info("批量展开事务已回滚(异常)") + yield await SSEResponse.send_error(f"批量展开失败: {str(e)}") + + +@router.post("/batch-expand-stream", summary="批量展开大纲为多章(SSE流式)") +async def batch_expand_outlines_stream( + data: Dict[str, Any], + request: Request, + db: AsyncSession = Depends(get_db), + user_ai_service: AIService = Depends(get_user_ai_service) +): + """ + 使用SSE流式批量展开大纲,实时推送每个大纲的处理进度 + + 请求体示例: + { + "project_id": "项目ID", + "outline_ids": ["大纲ID1", "大纲ID2"], // 可选,不传则展开所有大纲 + "chapters_per_outline": 3, // 每个大纲展开几章 + "expansion_strategy": "balanced", // balanced/climax/detail + "auto_create_chapters": false, // 是否自动创建章节 + "enable_scene_analysis": true, // 是否启用场景分析 + "provider": "openai", // 可选 + "model": "gpt-4" // 可选 + } + """ + # 验证用户权限 + user_id = getattr(request.state, 'user_id', None) + await verify_project_access(data.get("project_id"), user_id, db) + + return create_sse_response(batch_expand_outlines_generator(data, db, user_ai_service)) + + +@router.post("/{outline_id}/create-chapters-from-plans", response_model=CreateChaptersFromPlansResponse, summary="根据已有规划创建章节") +async def create_chapters_from_existing_plans( + outline_id: str, + plans_request: CreateChaptersFromPlansRequest, + request: Request, + db: AsyncSession = Depends(get_db), + user_ai_service: AIService = Depends(get_user_ai_service) +): + """ + 根据前端缓存的章节规划直接创建章节记录,避免重复调用AI + + 使用场景: + 1. 用户第一次调用 /outlines/{outline_id}/expand?auto_create_chapters=false 获取规划预览 + 2. 前端展示规划给用户确认 + 3. 用户确认后,前端调用此接口,传递缓存的规划数据,直接创建章节 + + 优势: + - 避免重复的AI调用,节省Token和时间 + - 确保用户看到的预览和实际创建的章节完全一致 + - 提升用户体验 + + 参数: + - outline_id: 要展开的大纲ID + - plans_request: 包含之前AI生成的章节规划列表 + + 返回: + - 创建的章节列表和统计信息 + """ + # 验证用户权限 + user_id = getattr(request.state, 'user_id', None) + + # 获取大纲 + result = await db.execute( + select(Outline).where(Outline.id == outline_id) + ) + outline = result.scalar_one_or_none() + + if not outline: + raise HTTPException(status_code=404, detail="大纲不存在") + + # 验证项目权限 + await verify_project_access(outline.project_id, user_id, db) + + try: + # 验证规划数据 + if not plans_request.chapter_plans: + raise HTTPException(status_code=400, detail="章节规划列表不能为空") + + logger.info(f"根据已有规划为大纲 {outline_id} 创建 {len(plans_request.chapter_plans)} 个章节") + + # 创建展开服务实例 + expansion_service = PlotExpansionService(user_ai_service) + + # 将Pydantic模型转换为字典列表 + chapter_plans_dict = [plan.model_dump() for plan in plans_request.chapter_plans] + + # 直接使用传入的规划创建章节记录(不调用AI) + created_chapters = await expansion_service.create_chapters_from_plans( + outline_id=outline_id, + chapter_plans=chapter_plans_dict, + project_id=outline.project_id, + db=db, + start_chapter_number=None # 自动计算章节序号 + ) + + await db.commit() + + # 刷新章节数据 + for chapter in created_chapters: + await db.refresh(chapter) + + logger.info(f"成功根据已有规划创建 {len(created_chapters)} 个章节记录") + + # 构建响应 + return CreateChaptersFromPlansResponse( + outline_id=outline_id, + outline_title=outline.title, + chapters_created=len(created_chapters), + created_chapters=[ + { + "id": ch.id, + "chapter_number": ch.chapter_number, + "title": ch.title, + "summary": ch.summary, + "outline_id": ch.outline_id, + "sub_index": ch.sub_index, + "status": ch.status + } + for ch in created_chapters + ] + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"根据已有规划创建章节失败: {str(e)}", exc_info=True) + await db.rollback() + raise HTTPException(status_code=500, detail=f"创建章节失败: {str(e)}") \ No newline at end of file diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py index eaad8d6..05a946e 100644 --- a/backend/app/api/projects.py +++ b/backend/app/api/projects.py @@ -354,7 +354,9 @@ async def export_project_chapters( txt_content.append("\n" + "=" * 80 + "\n\n") for chapter in chapters: - txt_content.append(f"第 {chapter.chapter_number} 章 {chapter.title}") + # 处理子章节序号显示 + chapter_display = f"{chapter.chapter_number}-{chapter.sub_index}" if chapter.sub_index and chapter.sub_index > 1 else str(chapter.chapter_number) + txt_content.append(f"第 {chapter_display} 章 {chapter.title}") txt_content.append("-" * 80) txt_content.append("") # 空行 diff --git a/backend/app/models/chapter.py b/backend/app/models/chapter.py index 7353073..a52c1dc 100644 --- a/backend/app/models/chapter.py +++ b/backend/app/models/chapter.py @@ -17,8 +17,16 @@ class Chapter(Base): summary = Column(Text, comment="章节摘要") word_count = Column(Integer, default=0, comment="字数统计") status = Column(String(20), default="draft", comment="章节状态") + + # 大纲关联字段(实现一对多关系) + outline_id = Column(String(36), ForeignKey("outlines.id", ondelete="SET NULL"), nullable=True, comment="关联的大纲ID") + sub_index = Column(Integer, default=1, comment="大纲下的子章节序号") + + # 大纲展开规划数据(JSON格式) + expansion_plan = Column(Text, comment="展开规划详情(JSON): 包含key_events, character_focus, emotional_tone等") + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") def __repr__(self): - return f"" \ No newline at end of file + return f"" \ No newline at end of file diff --git a/backend/app/schemas/chapter.py b/backend/app/schemas/chapter.py index 71b3082..7c31668 100644 --- a/backend/app/schemas/chapter.py +++ b/backend/app/schemas/chapter.py @@ -12,6 +12,9 @@ class ChapterBase(BaseModel): summary: Optional[str] = Field(None, description="章节摘要") word_count: Optional[int] = Field(0, description="字数") status: Optional[str] = Field("draft", description="章节状态") + outline_id: Optional[str] = Field(None, description="关联的大纲ID") + sub_index: Optional[int] = Field(1, description="大纲下的子章节序号") + expansion_plan: Optional[str] = Field(None, description="展开规划详情(JSON)") class ChapterCreate(BaseModel): @@ -22,6 +25,9 @@ class ChapterCreate(BaseModel): content: Optional[str] = Field(None, description="章节内容") summary: Optional[str] = Field(None, description="章节摘要") status: Optional[str] = Field("draft", description="章节状态") + outline_id: Optional[str] = Field(None, description="关联的大纲ID") + sub_index: Optional[int] = Field(1, description="大纲下的子章节序号") + expansion_plan: Optional[str] = Field(None, description="展开规划详情(JSON)") class ChapterUpdate(BaseModel): @@ -44,6 +50,11 @@ class ChapterResponse(BaseModel): summary: Optional[str] = None word_count: int = 0 status: str + outline_id: Optional[str] = None + sub_index: Optional[int] = 1 + expansion_plan: Optional[str] = None + outline_title: Optional[str] = None # 大纲标题(从Outline表联查) + outline_order: Optional[int] = None # 大纲排序序号(从Outline表联查) created_at: datetime updated_at: datetime diff --git a/backend/app/schemas/outline.py b/backend/app/schemas/outline.py index a471415..bf7518c 100644 --- a/backend/app/schemas/outline.py +++ b/backend/app/schemas/outline.py @@ -78,12 +78,70 @@ class OutlineListResponse(BaseModel): items: list[OutlineResponse] -class OutlineReorderItem(BaseModel): - """单个大纲重排序项""" - id: str = Field(..., description="大纲ID") - order_index: int = Field(..., description="新的序号", ge=1) +class ChapterPlanItem(BaseModel): + """单个章节规划项""" + sub_index: int = Field(..., description="子章节序号", ge=1) + title: str = Field(..., description="章节标题") + plot_summary: str = Field(..., description="剧情摘要(200-300字)") + key_events: list[str] = Field(..., description="关键事件列表") + character_focus: list[str] = Field(..., description="主要涉及的角色") + emotional_tone: str = Field(..., description="情感基调") + narrative_goal: str = Field(..., description="叙事目标") + conflict_type: str = Field(..., description="冲突类型") + estimated_words: int = Field(3000, description="预计字数", ge=1000) + scenes: Optional[list[str]] = Field(None, description="场景列表(可选)") -class OutlineReorderRequest(BaseModel): - """大纲批量重排序请求""" - orders: list[OutlineReorderItem] = Field(..., description="排序列表") \ No newline at end of file +class OutlineExpansionRequest(BaseModel): + """大纲展开为多章节的请求模型(outline_id从路径参数获取)""" + target_chapter_count: int = Field(3, description="目标章节数", ge=1, le=10) + expansion_strategy: str = Field("balanced", description="展开策略: balanced(均衡), climax(高潮重点), detail(细节丰富)") + enable_scene_analysis: bool = Field(False, description="是否包含场景规划") + auto_create_chapters: bool = Field(True, description="是否自动创建章节记录") + provider: Optional[str] = Field(None, description="AI提供商") + model: Optional[str] = Field(None, description="AI模型") + + +class OutlineExpansionResponse(BaseModel): + """大纲展开响应模型""" + outline_id: str = Field(..., description="大纲ID") + outline_title: str = Field(..., description="大纲标题") + target_chapter_count: int = Field(..., description="目标章节数") + actual_chapter_count: int = Field(..., description="实际生成的章节数") + expansion_strategy: str = Field(..., description="使用的展开策略") + chapter_plans: list[ChapterPlanItem] = Field(..., description="章节规划列表") + created_chapters: Optional[list] = Field(None, description="已创建的章节列表") + + +class BatchOutlineExpansionRequest(BaseModel): + """批量大纲展开请求模型""" + project_id: str = Field(..., description="项目ID") + outline_ids: Optional[list[str]] = Field(None, description="要展开的大纲ID列表(为空则展开所有)") + chapters_per_outline: int = Field(3, description="每个大纲的目标章节数", ge=1, le=10) + expansion_strategy: str = Field("balanced", description="展开策略") + enable_scene_analysis: bool = Field(False, description="是否包含场景规划") + auto_create_chapters: bool = Field(True, description="是否自动创建章节记录") + provider: Optional[str] = Field(None, description="AI提供商") + model: Optional[str] = Field(None, description="AI模型") + + +class BatchOutlineExpansionResponse(BaseModel): + """批量大纲展开响应模型""" + project_id: str = Field(..., description="项目ID") + total_outlines_expanded: int = Field(..., description="总共展开的大纲数") + total_chapters_created: int = Field(..., description="总共创建的章节数") + expansion_results: list[OutlineExpansionResponse] = Field(..., description="展开结果列表") + skipped_outlines: Optional[list[dict]] = Field(None, description="跳过的大纲列表(已展开)") + + +class CreateChaptersFromPlansRequest(BaseModel): + """根据已有规划创建章节的请求模型""" + chapter_plans: list[ChapterPlanItem] = Field(..., description="章节规划列表(来自之前的AI生成结果)") + + +class CreateChaptersFromPlansResponse(BaseModel): + """根据已有规划创建章节的响应模型""" + outline_id: str = Field(..., description="大纲ID") + outline_title: str = Field(..., description="大纲标题") + chapters_created: int = Field(..., description="创建的章节数") + created_chapters: list = Field(..., description="创建的章节列表") \ No newline at end of file diff --git a/backend/app/services/plot_expansion_service.py b/backend/app/services/plot_expansion_service.py new file mode 100644 index 0000000..ca9d84d --- /dev/null +++ b/backend/app/services/plot_expansion_service.py @@ -0,0 +1,723 @@ +"""大纲剧情展开服务 - 将大纲节点展开为多个章节""" +from typing import List, Dict, Any, Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +import json + +from app.models.outline import Outline +from app.models.project import Project +from app.models.character import Character +from app.models.chapter import Chapter +from app.services.ai_service import AIService +from app.logger import get_logger + +logger = get_logger(__name__) + + +class PlotExpansionService: + """大纲剧情展开服务""" + + def __init__(self, ai_service: AIService): + self.ai_service = ai_service + + async def analyze_outline_for_chapters( + self, + outline: Outline, + project: Project, + db: AsyncSession, + target_chapter_count: int = 3, + expansion_strategy: str = "balanced", + enable_scene_analysis: bool = True, + provider: Optional[str] = None, + model: Optional[str] = None, + batch_size: int = 5, + progress_callback: Optional[callable] = None + ) -> List[Dict[str, Any]]: + """ + 分析单个大纲,生成多章节规划(支持分批生成) + + Args: + outline: 大纲对象 + project: 项目对象 + db: 数据库会话 + target_chapter_count: 目标生成章节数 + expansion_strategy: 展开策略(balanced/climax/detail) + enable_scene_analysis: 是否启用场景级分析 + provider: AI提供商 + model: AI模型 + batch_size: 每批生成的章节数(默认5章) + progress_callback: 进度回调函数(可选) + + Returns: + 章节规划列表 + """ + logger.info(f"开始分析大纲 {outline.id},目标生成 {target_chapter_count} 章") + + # 如果章节数较少,直接生成 + if target_chapter_count <= batch_size: + return await self._generate_chapters_single_batch( + outline=outline, + project=project, + db=db, + target_chapter_count=target_chapter_count, + expansion_strategy=expansion_strategy, + enable_scene_analysis=enable_scene_analysis, + provider=provider, + model=model + ) + + # 章节数较多,分批生成 + logger.info(f"章节数({target_chapter_count})超过批次大小({batch_size}),启用分批生成") + return await self._generate_chapters_in_batches( + outline=outline, + project=project, + db=db, + target_chapter_count=target_chapter_count, + expansion_strategy=expansion_strategy, + enable_scene_analysis=enable_scene_analysis, + provider=provider, + model=model, + batch_size=batch_size, + progress_callback=progress_callback + ) + + async def _generate_chapters_single_batch( + self, + outline: Outline, + project: Project, + db: AsyncSession, + target_chapter_count: int, + expansion_strategy: str, + enable_scene_analysis: bool, + provider: Optional[str], + model: Optional[str] + ) -> List[Dict[str, Any]]: + """单批次生成章节规划""" + # 获取角色信息 + characters_result = await db.execute( + select(Character).where(Character.project_id == project.id) + ) + characters = characters_result.scalars().all() + characters_info = "\n".join([ + f"- {char.name} ({'组织' if char.is_organization else '角色'}, {char.role_type}): " + f"{char.personality[:100] if char.personality else '暂无描述'}" + for char in characters + ]) + + # 获取大纲上下文(前后大纲) + context_info = await self._get_outline_context(outline, project.id, db) + + # 构建分析提示词 + prompt = self._build_expansion_prompt( + outline=outline, + project=project, + characters_info=characters_info, + context_info=context_info, + target_chapter_count=target_chapter_count, + expansion_strategy=expansion_strategy, + enable_scene_analysis=enable_scene_analysis + ) + + # 调用AI生成章节规划 + logger.info(f"调用AI生成章节规划...") + ai_response = await self.ai_service.generate_text( + prompt=prompt, + provider=provider, + model=model + ) + + # 提取内容 + ai_content = ai_response.get("content", "") if isinstance(ai_response, dict) else ai_response + + # 解析AI响应 + chapter_plans = self._parse_expansion_response(ai_content, outline.id) + + logger.info(f"成功生成 {len(chapter_plans)} 个章节规划") + return chapter_plans + + async def _generate_chapters_in_batches( + self, + outline: Outline, + project: Project, + db: AsyncSession, + target_chapter_count: int, + expansion_strategy: str, + enable_scene_analysis: bool, + provider: Optional[str], + model: Optional[str], + batch_size: int, + progress_callback: Optional[callable] + ) -> List[Dict[str, Any]]: + """分批生成章节规划""" + # 计算批次数 + total_batches = (target_chapter_count + batch_size - 1) // batch_size + logger.info(f"分批生成计划: 总共{target_chapter_count}章,分{total_batches}批,每批{batch_size}章") + + # 获取角色信息(所有批次共用) + characters_result = await db.execute( + select(Character).where(Character.project_id == project.id) + ) + characters = characters_result.scalars().all() + characters_info = "\n".join([ + f"- {char.name} ({'组织' if char.is_organization else '角色'}, {char.role_type}): " + f"{char.personality[:100] if char.personality else '暂无描述'}" + for char in characters + ]) + + # 获取大纲上下文 + context_info = await self._get_outline_context(outline, project.id, db) + + all_chapter_plans = [] + + for batch_num in range(total_batches): + # 计算当前批次的章节数 + remaining_chapters = target_chapter_count - len(all_chapter_plans) + current_batch_size = min(batch_size, remaining_chapters) + current_start_index = len(all_chapter_plans) + 1 + + logger.info(f"开始生成第{batch_num + 1}/{total_batches}批,章节范围: {current_start_index}-{current_start_index + current_batch_size - 1}") + + # 回调通知进度 + if progress_callback: + await progress_callback(batch_num + 1, total_batches, current_start_index, current_batch_size) + + # 构建当前批次的提示词(包含已生成章节的上下文) + prompt = self._build_batch_expansion_prompt( + outline=outline, + project=project, + characters_info=characters_info, + context_info=context_info, + target_chapter_count=current_batch_size, + expansion_strategy=expansion_strategy, + enable_scene_analysis=enable_scene_analysis, + start_index=current_start_index, + previous_chapters=all_chapter_plans, + total_chapters=target_chapter_count + ) + + # 调用AI生成当前批次 + logger.info(f"调用AI生成第{batch_num + 1}批...") + ai_response = await self.ai_service.generate_text( + prompt=prompt, + provider=provider, + model=model + ) + + # 提取内容 + ai_content = ai_response.get("content", "") if isinstance(ai_response, dict) else ai_response + + # 解析AI响应 + batch_plans = self._parse_expansion_response(ai_content, outline.id) + + # 调整sub_index以保持连续性 + for i, plan in enumerate(batch_plans): + plan["sub_index"] = current_start_index + i + + all_chapter_plans.extend(batch_plans) + + logger.info(f"第{batch_num + 1}批生成完成,本批生成{len(batch_plans)}章,累计{len(all_chapter_plans)}章") + + logger.info(f"分批生成完成,共生成 {len(all_chapter_plans)} 个章节规划") + return all_chapter_plans + + async def batch_expand_outlines( + self, + project_id: str, + db: AsyncSession, + ai_service: AIService, + target_chapters_per_outline: int = 3, + expansion_strategy: str = "balanced", + provider: Optional[str] = None, + model: Optional[str] = None + ) -> Dict[str, Any]: + """ + 批量展开所有大纲为章节 + + Returns: + { + "total_outlines": 总大纲数, + "total_chapters_planned": 规划的总章节数, + "expansions": [每个大纲的展开结果] + } + """ + logger.info(f"开始批量展开项目 {project_id} 的所有大纲") + + # 获取项目 + project_result = await db.execute( + select(Project).where(Project.id == project_id) + ) + project = project_result.scalar_one_or_none() + if not project: + raise ValueError(f"项目 {project_id} 不存在") + + # 获取所有大纲 + outlines_result = await db.execute( + select(Outline) + .where(Outline.project_id == project_id) + .order_by(Outline.order_index) + ) + outlines = outlines_result.scalars().all() + + if not outlines: + logger.warning(f"项目 {project_id} 没有大纲") + return { + "total_outlines": 0, + "total_chapters_planned": 0, + "expansions": [] + } + + # 逐个展开大纲 + expansions = [] + total_chapters = 0 + + for outline in outlines: + try: + chapter_plans = await self.analyze_outline_for_chapters( + outline=outline, + project=project, + db=db, + target_chapter_count=target_chapters_per_outline, + expansion_strategy=expansion_strategy, + provider=provider, + model=model + ) + + expansions.append({ + "outline_id": outline.id, + "outline_title": outline.title, + "chapter_plans": chapter_plans, + "chapter_count": len(chapter_plans) + }) + + total_chapters += len(chapter_plans) + logger.info(f"大纲 {outline.title} 展开为 {len(chapter_plans)} 章") + + except Exception as e: + logger.error(f"展开大纲 {outline.id} 失败: {str(e)}") + expansions.append({ + "outline_id": outline.id, + "outline_title": outline.title, + "error": str(e), + "chapter_count": 0 + }) + + result = { + "total_outlines": len(outlines), + "total_chapters_planned": total_chapters, + "expansions": expansions + } + + logger.info(f"批量展开完成: {len(outlines)} 个大纲 → {total_chapters} 个章节规划") + return result + + async def create_chapters_from_plans( + self, + outline_id: str, + chapter_plans: List[Dict[str, Any]], + project_id: str, + db: AsyncSession, + start_chapter_number: int = None + ) -> List[Chapter]: + """ + 根据章节规划创建实际的章节记录 + + Args: + outline_id: 大纲ID + chapter_plans: 章节规划列表 + project_id: 项目ID + db: 数据库会话 + start_chapter_number: 起始章节号(如果为None,则自动计算) + + Returns: + 创建的章节列表 + """ + logger.info(f"根据规划创建 {len(chapter_plans)} 个章节记录") + + # 如果没有指定起始章节号,自动计算 + if start_chapter_number is None: + # 查询项目中已有章节的最大序号 + max_number_result = await db.execute( + select(func.max(Chapter.chapter_number)) + .where(Chapter.project_id == project_id) + ) + max_number = max_number_result.scalar() + start_chapter_number = (max_number or 0) + 1 + logger.info(f"自动计算起始章节号: {start_chapter_number} (当前最大序号: {max_number})") + + chapters = [] + for idx, plan in enumerate(chapter_plans): + # 保存完整的展开规划数据(JSON格式) + expansion_plan_json = json.dumps({ + "key_events": plan.get("key_events", []), + "character_focus": plan.get("character_focus", []), + "emotional_tone": plan.get("emotional_tone", ""), + "narrative_goal": plan.get("narrative_goal", ""), + "conflict_type": plan.get("conflict_type", ""), + "estimated_words": plan.get("estimated_words", 3000), + "scenes": plan.get("scenes", []) if plan.get("scenes") else None + }, ensure_ascii=False) + + chapter = Chapter( + project_id=project_id, + outline_id=outline_id, + chapter_number=start_chapter_number + idx, + sub_index=plan.get("sub_index", idx + 1), + title=plan.get("title", f"第{start_chapter_number + idx}章"), + summary=plan.get("plot_summary", ""), + expansion_plan=expansion_plan_json, + status="draft" + ) + db.add(chapter) + chapters.append(chapter) + + await db.commit() + + for chapter in chapters: + await db.refresh(chapter) + + logger.info(f"成功创建 {len(chapters)} 个章节记录(已保存展开规划数据)") + return chapters + + async def _get_outline_context( + self, + outline: Outline, + project_id: str, + db: AsyncSession + ) -> str: + """获取大纲的上下文(前后大纲)""" + # 获取前一个大纲 + prev_result = await db.execute( + select(Outline) + .where( + Outline.project_id == project_id, + Outline.order_index < outline.order_index + ) + .order_by(Outline.order_index.desc()) + .limit(1) + ) + prev_outline = prev_result.scalar_one_or_none() + + # 获取后一个大纲 + next_result = await db.execute( + select(Outline) + .where( + Outline.project_id == project_id, + Outline.order_index > outline.order_index + ) + .order_by(Outline.order_index) + .limit(1) + ) + next_outline = next_result.scalar_one_or_none() + + context = "" + if prev_outline: + context += f"【前一节】{prev_outline.title}: {prev_outline.content[:200]}...\n\n" + if next_outline: + context += f"【后一节】{next_outline.title}: {next_outline.content[:200]}...\n" + + return context if context else "(无前后文)" + + def _build_expansion_prompt( + self, + outline: Outline, + project: Project, + characters_info: str, + context_info: str, + target_chapter_count: int, + expansion_strategy: str, + enable_scene_analysis: bool + ) -> str: + """构建大纲展开提示词""" + + strategy_desc = { + "balanced": "均衡展开:每章剧情量相当,节奏平稳", + "climax": "高潮重点:重点章节剧情丰富,其他章节简洁过渡", + "detail": "细节丰富:每章都深入描写,场景和情感细腻" + } + + strategy_instruction = strategy_desc.get(expansion_strategy, strategy_desc["balanced"]) + + # 场景字段(避免f-string中的反斜杠) + scene_field = ',\n "main_scenes": ["场景1", "场景2"]' if enable_scene_analysis else '' + + scene_instruction = "" + if enable_scene_analysis: + scene_instruction = """ +5. 场景分析(每章需包含): + - 主要场景地点 + - 场景氛围 + - 关键道具/环境元素 +""" + + prompt = f"""你是专业的小说情节架构师。请分析以下大纲节点,将其展开为 {target_chapter_count} 个章节的详细规划。 + +【项目信息】 +小说名称:{project.title} +类型:{project.genre or '通用'} +主题:{project.theme or '未设定'} +叙事视角:{project.narrative_perspective or '第三人称'} + +【世界观背景】 +时间背景:{project.world_time_period or '未设定'} +地理位置:{project.world_location or '未设定'} +氛围基调:{project.world_atmosphere or '未设定'} + +【角色信息】 +{characters_info or '暂无角色'} + +【当前大纲节点 - 展开对象】 +序号:第 {outline.order_index} 节 +标题:{outline.title} +内容:{outline.content} + +【上下文参考】 +{context_info} + +【展开策略】 +{strategy_instruction} + +【⚠️ 重要约束 - 必须严格遵守】 +1. **内容边界约束**: + - ✅ 只能展开【当前大纲节点】中明确描述的内容 + - ❌ 绝对不能推进到后续大纲的内容(如果有【后一节】信息) + - ❌ 不要让剧情快速推进,要深化而非跨越 + +2. **展开原则**: + - 将当前大纲的单一事件拆解为多个细节丰富的章节 + - 深入挖掘情感、心理、环境、对话等细节 + - 放慢叙事节奏,让读者充分体验当前阶段的剧情 + - 每个章节都应该是当前大纲内容的不同侧面或阶段 + +3. **如何避免剧情越界**: + - 如果当前大纲描述"主角遇到困境",展开时应详写困境的发现、分析、情感冲击等 + - 不要直接写到"解决困境",除非原大纲明确包含解决过程 + - 如果看到【后一节】的内容,那些是禁区,绝不提前展开 + +【任务要求】 +1. 深度分析该大纲的剧情容量和叙事节奏 +2. 识别关键剧情点、冲突点和情感转折点(仅限当前大纲范围内) +3. 将大纲拆解为 {target_chapter_count} 个章节,每章需包含: + - sub_index: 子章节序号(1, 2, 3...) + - title: 章节标题(体现该章核心冲突或情感) + - plot_summary: 剧情摘要(200-300字,详细描述该章发生的事件,仅限当前大纲内容) + - key_events: 关键事件列表(3-5个关键剧情点,必须在当前大纲范围内) + - character_focus: 角色焦点(主要涉及的角色名称) + - emotional_tone: 情感基调(如:紧张、温馨、悲伤、激动等) + - narrative_goal: 叙事目标(该章要达成的叙事效果) + - conflict_type: 冲突类型(如:内心挣扎、人际冲突、环境挑战等) + - estimated_words: 预计字数(建议2000-5000字) +{scene_instruction} +4. 确保章节间: + - 衔接自然流畅 + - 剧情递进合理(但不超出当前大纲边界) + - 节奏张弛有度 + - 每章都有明确的叙事价值 + - 最后一章结束时,剧情发展程度应恰好完成当前大纲描述的内容,不多不少 + +【输出格式】 +请严格按照以下JSON数组格式输出,不要添加任何其他文字: +[ + {{ + "sub_index": 1, + "title": "章节标题", + "plot_summary": "该章详细剧情摘要...", + "key_events": ["关键事件1", "关键事件2", "关键事件3"], + "character_focus": ["角色A", "角色B"], + "emotional_tone": "情感基调", + "narrative_goal": "叙事目标", + "conflict_type": "冲突类型", + "estimated_words": 3000{scene_field} + }} +] + +请开始分析并生成章节规划: +""" + return prompt + + def _build_batch_expansion_prompt( + self, + outline: Outline, + project: Project, + characters_info: str, + context_info: str, + target_chapter_count: int, + expansion_strategy: str, + enable_scene_analysis: bool, + start_index: int, + previous_chapters: List[Dict[str, Any]], + total_chapters: int + ) -> str: + """构建分批展开提示词""" + + strategy_desc = { + "balanced": "均衡展开:每章剧情量相当,节奏平稳", + "climax": "高潮重点:重点章节剧情丰富,其他章节简洁过渡", + "detail": "细节丰富:每章都深入描写,场景和情感细腻" + } + + strategy_instruction = strategy_desc.get(expansion_strategy, strategy_desc["balanced"]) + + # 场景字段 + scene_field = ',\n "main_scenes": ["场景1", "场景2"]' if enable_scene_analysis else '' + + scene_instruction = "" + if enable_scene_analysis: + scene_instruction = """ +5. 场景分析(每章需包含): + - 主要场景地点 + - 场景氛围 + - 关键道具/环境元素 +""" + + # 构建已生成章节的摘要 + previous_context = "" + if previous_chapters: + previous_summaries = [] + for ch in previous_chapters[-3:]: # 只显示最近3章 + previous_summaries.append( + f"第{ch['sub_index']}节《{ch['title']}》: {ch['plot_summary'][:100]}..." + ) + previous_context = f""" +【已生成章节概要】(接续生成,注意衔接) +{chr(10).join(previous_summaries)} + +⚠️ 当前是第{start_index}-{start_index + target_chapter_count - 1}节(共{total_chapters}节中的一部分) +""" + + prompt = f"""你是专业的小说情节架构师。请继续分析以下大纲节点,将其展开为第{start_index}-{start_index + target_chapter_count - 1}节(共{target_chapter_count}个章节)的详细规划。 + +【项目信息】 +小说名称:{project.title} +类型:{project.genre or '通用'} +主题:{project.theme or '未设定'} +叙事视角:{project.narrative_perspective or '第三人称'} + +【世界观背景】 +时间背景:{project.world_time_period or '未设定'} +地理位置:{project.world_location or '未设定'} +氛围基调:{project.world_atmosphere or '未设定'} + +【角色信息】 +{characters_info or '暂无角色'} + +【当前大纲节点 - 展开对象】 +序号:第 {outline.order_index} 节 +标题:{outline.title} +内容:{outline.content} + +【上下文参考】 +{context_info} +{previous_context} + +【展开策略】 +{strategy_instruction} + +【⚠️ 重要约束 - 必须严格遵守】 +1. **内容边界约束**: + - ✅ 只能展开【当前大纲节点】中明确描述的内容 + - ❌ 绝对不能推进到后续大纲的内容(如果有【后一节】信息) + - ❌ 不要让剧情快速推进,要深化而非跨越 + +2. **分批连续性约束**: + - 这是第{start_index}-{start_index + target_chapter_count - 1}节,是整个展开的一部分 + - 必须与前面已生成的章节自然衔接 + - 从第{start_index}节开始编号(sub_index从{start_index}开始) + - 继续深化当前大纲的内容,保持叙事连贯性 + +3. **展开原则**: + - 将当前大纲的单一事件拆解为多个细节丰富的章节 + - 深入挖掘情感、心理、环境、对话等细节 + - 放慢叙事节奏,让读者充分体验当前阶段的剧情 + - 每个章节都应该是当前大纲内容的不同侧面或阶段 + +【任务要求】 +1. 深度分析该大纲的剧情容量和叙事节奏 +2. 识别关键剧情点、冲突点和情感转折点(仅限当前大纲范围内) +3. 生成第{start_index}-{start_index + target_chapter_count - 1}节的章节规划,每章需包含: + - sub_index: 子章节序号(从{start_index}开始) + - title: 章节标题(体现该章核心冲突或情感) + - plot_summary: 剧情摘要(200-300字,详细描述该章发生的事件) + - key_events: 关键事件列表(3-5个关键剧情点) + - character_focus: 角色焦点(主要涉及的角色名称) + - emotional_tone: 情感基调(如:紧张、温馨、悲伤、激动等) + - narrative_goal: 叙事目标(该章要达成的叙事效果) + - conflict_type: 冲突类型(如:内心挣扎、人际冲突、环境挑战等) + - estimated_words: 预计字数(建议2000-5000字) +{scene_instruction} +4. 确保章节间: + - 与前面章节衔接自然流畅 + - 剧情递进合理(但不超出当前大纲边界) + - 节奏张弛有度 + - 每章都有明确的叙事价值 + +【输出格式】 +请严格按照以下JSON数组格式输出,不要添加任何其他文字: +[ + {{ + "sub_index": {start_index}, + "title": "章节标题", + "plot_summary": "该章详细剧情摘要...", + "key_events": ["关键事件1", "关键事件2", "关键事件3"], + "character_focus": ["角色A", "角色B"], + "emotional_tone": "情感基调", + "narrative_goal": "叙事目标", + "conflict_type": "冲突类型", + "estimated_words": 3000{scene_field} + }} +] + +请开始分析并生成第{start_index}-{start_index + target_chapter_count - 1}节的章节规划: +""" + return prompt + + def _parse_expansion_response( + self, + ai_response: str, + outline_id: str + ) -> List[Dict[str, Any]]: + """解析AI的展开响应""" + try: + # 清理响应文本 + cleaned_text = ai_response.strip() + if cleaned_text.startswith('```json'): + cleaned_text = cleaned_text[7:] + if cleaned_text.startswith('```'): + cleaned_text = cleaned_text[3:] + if cleaned_text.endswith('```'): + cleaned_text = cleaned_text[:-3] + cleaned_text = cleaned_text.strip() + + # 解析JSON + chapter_plans = json.loads(cleaned_text) + + # 确保是列表 + if not isinstance(chapter_plans, list): + chapter_plans = [chapter_plans] + + # 为每个章节规划添加outline_id + for plan in chapter_plans: + plan["outline_id"] = outline_id + + return chapter_plans + + except json.JSONDecodeError as e: + logger.error(f"解析AI响应失败: {e}, 响应内容: {ai_response[:500]}") + # 返回一个基础规划 + return [{ + "outline_id": outline_id, + "sub_index": 1, + "title": "AI解析失败的默认章节", + "plot_summary": ai_response[:500], + "key_events": ["解析失败"], + "character_focus": [], + "emotional_tone": "未知", + "narrative_goal": "需要重新生成", + "conflict_type": "未知", + "estimated_words": 3000 + }] + + +# 工厂函数 +def create_plot_expansion_service(ai_service: AIService) -> PlotExpansionService: + """创建剧情展开服务实例""" + return PlotExpansionService(ai_service) \ No newline at end of file diff --git a/backend/app/services/prompt_service.py b/backend/app/services/prompt_service.py index 2c2cfb2..e7ecedc 100644 --- a/backend/app/services/prompt_service.py +++ b/backend/app/services/prompt_service.py @@ -789,6 +789,81 @@ class PromptService: 1. 只返回纯JSON对象,不要有```json```这样的标记 2. 文本中不要使用中文引号(""),改用【】或《》 3. 不要有任何额外的文字说明""" + + # 大纲展开为多章节的提示词 + OUTLINE_EXPANSION = """你是专业的小说情节架构师。请分析以下大纲节点,将其展开为 {target_chapters} 个章节的详细规划。 + +【项目信息】 +小说名称:{title} +类型:{genre} +主题:{theme} +叙事视角:{narrative_perspective} + +【世界观背景】 +时间背景:{time_period} +地理位置:{location} +氛围基调:{atmosphere} +世界规则:{rules} + +【角色信息】 +{characters_info} + +【大纲节点】 +序号:第 {outline_order} 节 +标题:{outline_title} +内容:{outline_content} + +【上下文】 +{context_info} + +【展开策略】 +{strategy_instruction} + +【任务要求】 +1. 深度分析该大纲的剧情容量和叙事节奏 +2. 识别关键剧情点、冲突点和情感转折点 +3. 将大纲拆解为 {target_chapters} 个章节,每章需包含: + - sub_index: 子章节序号(1, 2, 3...) + - title: 章节标题(体现该章核心冲突或情感) + - plot_summary: 剧情摘要(200-300字,详细描述该章发生的事件) + - key_events: 关键事件列表(3-5个关键剧情点) + - character_focus: 角色焦点(主要涉及的角色名称) + - emotional_tone: 情感基调(如:紧张、温馨、悲伤、激动等) + - narrative_goal: 叙事目标(该章要达成的叙事效果) + - conflict_type: 冲突类型(如:内心挣扎、人际冲突、环境挑战等) + - estimated_words: 预计字数(建议2000-5000字) +{scene_instruction} +4. 确保章节间: + - 衔接自然流畅 + - 剧情递进合理 + - 节奏张弛有度 + - 每章都有明确的叙事价值 + +**重要格式要求:** +1. 只返回纯JSON数组格式,不要包含任何markdown标记、代码块标记或其他说明文字 +2. 不要在JSON字符串值中使用中文引号(""''),请使用【】或《》 +3. 文本描述中的专有名词使用【】标记 + +请严格按照以下JSON数组格式输出: +[ + {{ + "sub_index": 1, + "title": "章节标题", + "plot_summary": "该章详细剧情摘要(200-300字)...", + "key_events": ["关键事件1", "关键事件2", "关键事件3"], + "character_focus": ["角色A", "角色B"], + "emotional_tone": "情感基调", + "narrative_goal": "叙事目标", + "conflict_type": "冲突类型", + "estimated_words": 3000{scene_field} + }} +] + +再次强调: +1. 只返回纯JSON数组,不要有```json```这样的标记 +2. 数组中要包含{target_chapters}个章节对象 +3. 每个plot_summary必须是200-300字的详细描述 +4. 文本中不要使用中文引号(""),改用【】或《》""" @staticmethod def format_prompt(template: str, **kwargs) -> str: @@ -1106,6 +1181,72 @@ class PromptService: project_context=project_context, user_input=user_input ) + + @classmethod + def get_outline_expansion_prompt(cls, title: str, genre: str, theme: str, + narrative_perspective: str, time_period: str, + location: str, atmosphere: str, rules: str, + characters_info: str, outline_order: int, + outline_title: str, outline_content: str, + context_info: str, strategy: str = "balanced", + target_chapters: int = 3, + include_scenes: bool = False) -> str: + """ + 获取大纲展开为多章节的提示词 + + Args: + title: 小说名称 + genre: 类型 + theme: 主题 + narrative_perspective: 叙事视角 + time_period: 时间背景 + location: 地理位置 + atmosphere: 氛围基调 + rules: 世界规则 + characters_info: 角色信息 + outline_order: 大纲序号 + outline_title: 大纲标题 + outline_content: 大纲内容 + context_info: 上下文信息 + strategy: 展开策略 (balanced/climax/detail) + target_chapters: 目标章节数 + include_scenes: 是否包含场景字段 + """ + # 根据策略生成指导说明 + strategy_instructions = { + "balanced": "采用均衡策略:将大纲内容平均分配到各章节,保持节奏均匀,每章剧情密度相当。", + "climax": "采用高潮重点策略:识别大纲中的高潮部分,为其分配更多章节进行细致展开,其他部分适当精简。", + "detail": "采用细节丰富策略:深挖大纲中的每个细节,为每个关键事件、情感转折都安排足够的叙事空间。" + } + strategy_instruction = strategy_instructions.get(strategy, strategy_instructions["balanced"]) + + # 场景相关的指令和字段 + scene_instruction = "" + scene_field = "" + if include_scenes: + scene_instruction = "\n - scenes: 场景列表(2-4个具体场景描述)" + scene_field = ',\n "scenes": ["场景1", "场景2"]' + + return cls.format_prompt( + cls.OUTLINE_EXPANSION, + title=title, + genre=genre, + theme=theme, + narrative_perspective=narrative_perspective, + time_period=time_period, + location=location, + atmosphere=atmosphere, + rules=rules, + characters_info=characters_info, + outline_order=outline_order, + outline_title=outline_title, + outline_content=outline_content, + context_info=context_info, + strategy_instruction=strategy_instruction, + target_chapters=target_chapters, + scene_instruction=scene_instruction, + scene_field=scene_field + ) # 创建全局提示词服务实例 diff --git a/backend/scripts/add_outline_chapter_relation.sql b/backend/scripts/add_outline_chapter_relation.sql new file mode 100644 index 0000000..c41172f --- /dev/null +++ b/backend/scripts/add_outline_chapter_relation.sql @@ -0,0 +1,45 @@ +-- 为Chapter表添加与Outline的关联关系 +-- 实现大纲到章节的一对多关系 + +-- 添加outline_id外键字段 +ALTER TABLE chapters +ADD COLUMN outline_id VARCHAR(36) NULL; + +-- 添加sub_index字段,表示在该大纲下的子章节序号 +ALTER TABLE chapters +ADD COLUMN sub_index INTEGER DEFAULT 1; + +-- 添加字段注释(PostgreSQL语法) +COMMENT ON COLUMN chapters.outline_id IS '关联的大纲ID'; +COMMENT ON COLUMN chapters.sub_index IS '大纲下的子章节序号'; + +-- 添加外键约束 +ALTER TABLE chapters +ADD CONSTRAINT fk_chapter_outline + FOREIGN KEY (outline_id) + REFERENCES outlines(id) + ON DELETE SET NULL; + +-- 创建索引优化查询性能 +CREATE INDEX idx_chapters_outline_id ON chapters(outline_id); +CREATE INDEX idx_chapters_outline_sub ON chapters(outline_id, sub_index); + +-- 说明: +-- outline_id为NULL表示旧数据或独立章节 +-- outline_id有值表示该章节由某个大纲展开生成 +-- sub_index表示在该大纲下的第几个子章节(从1开始) + +-- 为 chapters 表添加 expansion_plan 字段 +-- 用于存储大纲展开规划的详细数据(JSON格式) + +-- 添加字段 +ALTER TABLE chapters ADD COLUMN IF NOT EXISTS expansion_plan TEXT; + +-- 添加注释 +COMMENT ON COLUMN chapters.expansion_plan IS '展开规划详情(JSON): 包含key_events, character_focus, emotional_tone等'; + +-- 查看修改结果 +SELECT column_name, data_type, is_nullable, column_default +FROM information_schema.columns +WHERE table_name = 'chapters' +ORDER BY ordinal_position; \ No newline at end of file diff --git a/frontend/src/pages/Chapters.tsx b/frontend/src/pages/Chapters.tsx index c174b9d..dcf3bde 100644 --- a/frontend/src/pages/Chapters.tsx +++ b/frontend/src/pages/Chapters.tsx @@ -1,11 +1,10 @@ -import { useState, useEffect, useRef } from 'react'; -import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip, InputNumber, Progress, Alert, Radio } from 'antd'; -import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined } from '@ant-design/icons'; +import { useState, useEffect, useRef, useMemo } from 'react'; +import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip, InputNumber, Progress, Alert, Radio, Descriptions, Collapse } from 'antd'; +import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined, InfoCircleOutlined, CaretRightOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { useChapterSync } from '../store/hooks'; import { projectApi, writingStyleApi } from '../services/api'; -import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask } from '../types'; -import { cardStyles } from '../components/CardStyles'; +import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask, ExpansionPlanData } from '../types'; import ChapterAnalysis from '../components/ChapterAnalysis'; import { SSELoadingOverlay } from '../components/SSELoadingOverlay'; @@ -479,6 +478,34 @@ export default function Chapters() { const sortedChapters = [...chapters].sort((a, b) => a.chapter_number - b.chapter_number); + // 按大纲分组章节 + const groupedChapters = useMemo(() => { + const groups: Record = {}; + + sortedChapters.forEach(chapter => { + const key = chapter.outline_id || 'uncategorized'; + + if (!groups[key]) { + groups[key] = { + outlineId: chapter.outline_id || null, + outlineTitle: chapter.outline_title || '未分类章节', + outlineOrder: chapter.outline_order ?? 999, + chapters: [] + }; + } + + groups[key].chapters.push(chapter); + }); + + // 转换为数组并按大纲顺序排序 + return Object.values(groups).sort((a, b) => a.outlineOrder - b.outlineOrder); + }, [sortedChapters]); + const handleExport = () => { if (chapters.length === 0) { message.warning('当前项目没有章节,无法导出'); @@ -709,6 +736,97 @@ export default function Chapters() { } }; + // 显示展开规划详情 + const showExpansionPlanModal = (chapter: Chapter) => { + if (!chapter.expansion_plan) return; + + try { + const planData: ExpansionPlanData = JSON.parse(chapter.expansion_plan); + + Modal.info({ + title: ( + + + 第{chapter.chapter_number}章展开规划 + + ), + width: 800, + content: ( +
+ + + {chapter.title} + + + {planData.emotional_tone} + + + {planData.conflict_type} + + + {planData.estimated_words}字 + + + {planData.narrative_goal} + + + + {planData.key_events.map((event, idx) => ( +
+ {idx + 1} {event} +
+ ))} +
+
+ + + {planData.character_focus.map((char, idx) => ( + {char} + ))} + + + {planData.scenes && planData.scenes.length > 0 && ( + + + {planData.scenes.map((scene, idx) => ( + +
+ 📍 地点:{scene.location} +
+
+ 👥 角色: + + {scene.characters.map((char, charIdx) => ( + {char} + ))} + +
+
+ 🎯 目的:{scene.purpose} +
+
+ ))} +
+
+ )} +
+ +
+ ), + okText: '关闭', + }); + } catch (error) { + console.error('解析展开规划失败:', error); + message.error('展开规划数据格式错误'); + } + }; + return (
{chapters.length === 0 ? ( - - ) : ( - - ( - + ) : ( + idx.toString())} + expandIcon={({ isActive }) => } + style={{ background: 'transparent' }} + > + {groupedChapters.map((group, groupIndex) => ( + + + {group.outlineId ? `📖 大纲 ${group.outlineOrder}` : '📝 未分类'} + + + {group.outlineTitle} + + + sum + (ch.word_count || 0), 0)} 字`} + style={{ backgroundColor: '#1890ff' }} + /> +
+ } style={{ - padding: '16px 0', + marginBottom: 16, + background: '#fff', borderRadius: 8, - transition: 'background 0.3s ease', - flexDirection: isMobile ? 'column' : 'row', - alignItems: isMobile ? 'flex-start' : 'center' + border: '1px solid #f0f0f0', }} - actions={isMobile ? undefined : [ + > + ( + } onClick={() => handleOpenEditor(item.id)} @@ -806,85 +957,107 @@ export default function Chapters() { > 修改信息 , - ]} - > -
- } - title={ -
- 第{item.chapter_number}章:{item.title} - {getStatusText(item.status)} - - {renderAnalysisStatus(item.id)} - {!canGenerateChapter(item) && ( - - } color="warning"> - 需前置章节 - - - )} -
- } - description={ - item.content ? ( -
- {item.content.substring(0, isMobile ? 80 : 150)} - {item.content.length > (isMobile ? 80 : 150) && '...'} -
- ) : ( - 暂无内容 - ) - } - /> - - {isMobile && ( - -
+
)} -
- - )} - /> - + /> + + ))} + )} diff --git a/frontend/src/pages/Outline.tsx b/frontend/src/pages/Outline.tsx index d3b41f6..141da07 100644 --- a/frontend/src/pages/Outline.tsx +++ b/frontend/src/pages/Outline.tsx @@ -1,10 +1,12 @@ import { useState, useEffect } from 'react'; -import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag, Progress } from 'antd'; -import { EditOutlined, DeleteOutlined, ThunderboltOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'; +import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag, Progress, InputNumber, Tooltip, Tabs } from 'antd'; +import { EditOutlined, DeleteOutlined, ThunderboltOutlined, BranchesOutlined, AppstoreAddOutlined, CheckCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { useOutlineSync } from '../store/hooks'; import { cardStyles } from '../components/CardStyles'; import { SSEPostClient } from '../utils/sseClient'; +import { outlineApi, chapterApi } from '../services/api'; +import type { OutlineExpansionRequest, OutlineExpansionResponse, BatchOutlineExpansionResponse } from '../types'; const { TextArea } = Input; @@ -13,7 +15,19 @@ export default function Outline() { const [isGenerating, setIsGenerating] = useState(false); const [editForm] = Form.useForm(); const [generateForm] = Form.useForm(); + const [expansionForm] = Form.useForm(); + const [batchExpansionForm] = Form.useForm(); const [isMobile, setIsMobile] = useState(window.innerWidth <= 768); + const [isExpanding, setIsExpanding] = useState(false); + + // 缓存批量展开的规划数据,避免重复AI调用 + const [cachedBatchExpansionResponse, setCachedBatchExpansionResponse] = useState(null); + + // 批量展开预览的状态 + const [batchPreviewVisible, setBatchPreviewVisible] = useState(false); + const [batchPreviewData, setBatchPreviewData] = useState(null); + const [selectedOutlineIdx, setSelectedOutlineIdx] = useState(0); + const [selectedChapterIdx, setSelectedChapterIdx] = useState(0); // SSE进度状态 const [sseProgress, setSSEProgress] = useState(0); @@ -33,8 +47,7 @@ export default function Outline() { const { refreshOutlines, updateOutline, - deleteOutline, - reorderOutlines + deleteOutline } = useOutlineSync(); // 初始加载大纲列表 @@ -103,51 +116,13 @@ export default function Outline() { try { await deleteOutline(id); message.success('删除成功'); + // 删除后刷新大纲列表,确保显示最新的顺序 + await refreshOutlines(); } catch { message.error('删除失败'); } }; - const handleMoveUp = async (index: number) => { - if (index === 0) return; - - const items = Array.from(sortedOutlines); - [items[index - 1], items[index]] = [items[index], items[index - 1]]; - - const newOrders = items.map((item, idx) => ({ - id: item.id, - order_index: idx + 1 - })); - - try { - await reorderOutlines(newOrders); - message.success('上移成功'); - } catch (error) { - message.error('调整失败'); - console.error('重排序失败:', error); - } - }; - - const handleMoveDown = async (index: number) => { - if (index === sortedOutlines.length - 1) return; - - const items = Array.from(sortedOutlines); - [items[index], items[index + 1]] = [items[index + 1], items[index]]; - - const newOrders = items.map((item, idx) => ({ - id: item.id, - order_index: idx + 1 - })); - - try { - await reorderOutlines(newOrders); - message.success('下移成功'); - } catch (error) { - message.error('调整失败'); - console.error('重排序失败:', error); - } - }; - interface GenerateFormValues { theme?: string; chapter_count?: number; @@ -371,8 +346,820 @@ export default function Outline() { }); }; + // 展开单个大纲为多章 - 使用SSE显示进度 + const handleExpandOutline = async (outlineId: string, outlineTitle: string) => { + try { + setIsExpanding(true); + + // 第一步:检查是否已有展开的章节 + const existingChapters = await outlineApi.getOutlineChapters(outlineId); + + if (existingChapters.has_chapters && existingChapters.expansion_plans && existingChapters.expansion_plans.length > 0) { + // 如果已有章节,显示已有的展开规划信息 + setIsExpanding(false); + showExistingExpansionPreview(outlineTitle, existingChapters); + return; + } + + // 如果没有章节,显示展开表单 + setIsExpanding(false); + Modal.confirm({ + title: ( + + + 展开大纲为多章 + + ), + width: 600, + centered: true, + content: ( +
+
+
大纲标题
+
{outlineTitle}
+
+
+ + + + + + + 均衡分配 + 高潮重点 + 细节丰富 + + +
+
+ ), + okText: '生成规划预览', + cancelText: '取消', + onOk: async () => { + try { + const values = await expansionForm.validateFields(); + + // 关闭配置表单 + Modal.destroyAll(); + + // 显示SSE进度Modal + setSSEProgress(0); + setSSEMessage('正在准备展开大纲...'); + setSSEModalVisible(true); + setIsExpanding(true); + + // 准备请求数据 + const requestData = { + ...values, + auto_create_chapters: false, // 第一步:仅生成规划 + enable_scene_analysis: true + }; + + // 使用SSE客户端调用新的流式端点 + const apiUrl = `/api/outlines/${outlineId}/expand-stream`; + const client = new SSEPostClient(apiUrl, requestData, { + onProgress: (msg: string, progress: number) => { + setSSEMessage(msg); + setSSEProgress(progress); + }, + onResult: (data: OutlineExpansionResponse) => { + console.log('展开完成,结果:', data); + // 关闭SSE进度Modal + setSSEModalVisible(false); + // 显示规划预览 + showExpansionPreview(outlineId, data); + }, + onError: (error: string) => { + message.error(`展开失败: ${error}`); + setSSEModalVisible(false); + setIsExpanding(false); + }, + onComplete: () => { + setSSEModalVisible(false); + setIsExpanding(false); + } + }); + + // 开始连接 + client.connect(); + + } catch (error) { + console.error('展开失败:', error); + message.error('展开失败'); + setSSEModalVisible(false); + setIsExpanding(false); + } + }, + }); + } catch (error) { + console.error('检查章节失败:', error); + message.error('检查章节失败'); + setIsExpanding(false); + } + }; + + // 删除展开的章节内容(保留大纲) + const handleDeleteExpandedChapters = async (outlineTitle: string, chapters: Array<{ id: string }>) => { + try { + // 批量删除所有章节 + const deletePromises = chapters.map(chapter => + chapterApi.deleteChapter(chapter.id) + ); + await Promise.all(deletePromises); + + message.success(`已删除《${outlineTitle}》展开的所有 ${chapters.length} 个章节`); + refreshOutlines(); + } catch (error: any) { + message.error(error.response?.data?.detail || '删除章节失败'); + } + }; + + // 显示已存在章节的展开规划 + const showExistingExpansionPreview = ( + outlineTitle: string, + data: { + chapter_count: number; + chapters: Array<{ id: string; chapter_number: number; title: string }>; + expansion_plans: Array<{ + sub_index: number; + title: string; + plot_summary: string; + key_events: string[]; + character_focus: string[]; + emotional_tone: string; + narrative_goal: string; + conflict_type: string; + estimated_words: number; + scenes?: Array<{ + location: string; + characters: string[]; + purpose: string; + }> | null; + }> | null; + } + ) => { + const modal = Modal.info({ + title: ( + + + 已存在的展开章节 + + ), + width: 900, + centered: true, + okText: '关闭', + footer: (_, { OkBtn }) => ( + + + + + ), + content: ( +
+
+ 大纲: {outlineTitle} + 章节数: {data.chapter_count} + 已创建章节 +
+ ({ + key: idx.toString(), + label: ( + + {plan.sub_index}. {plan.title} + + ), + children: ( +
+ + + + {plan.emotional_tone} + {plan.conflict_type} + 约{plan.estimated_words}字 + + + + + {plan.plot_summary} + + + + {plan.narrative_goal} + + + + + {plan.key_events.map((event, eventIdx) => ( +
• {event}
+ ))} +
+
+ + + + {plan.character_focus.map((char, charIdx) => ( + {char} + ))} + + + + {plan.scenes && plan.scenes.length > 0 && ( + + + {plan.scenes.map((scene, sceneIdx) => ( + +
地点:{scene.location}
+
角色:{scene.characters.join('、')}
+
目的:{scene.purpose}
+
+ ))} +
+
+ )} +
+
+ ) + }))} + /> +
+ ), + }); + }; + + // 显示展开规划预览,并提供确认创建章节的选项 + const showExpansionPreview = (outlineId: string, response: OutlineExpansionResponse) => { + // 缓存AI生成的规划数据 + const cachedPlans = response.chapter_plans; + + Modal.confirm({ + title: ( + + + 展开规划预览 + + ), + width: 900, + centered: true, + okText: '确认并创建章节', + cancelText: '暂不创建', + content: ( +
+
+ 策略: {response.expansion_strategy} + 章节数: {response.actual_chapter_count} + 预览模式(未创建章节) +
+ ({ + key: idx.toString(), + label: ( + + {idx + 1}. {plan.title} + + ), + children: ( +
+ + + + {plan.emotional_tone} + {plan.conflict_type} + 约{plan.estimated_words}字 + + + + + {plan.plot_summary} + + + + {plan.narrative_goal} + + + + + {plan.key_events.map((event, eventIdx) => ( +
• {event}
+ ))} +
+
+ + + + {plan.character_focus.map((char, charIdx) => ( + {char} + ))} + + + + {plan.scenes && plan.scenes.length > 0 && ( + + + {plan.scenes.map((scene, sceneIdx) => ( + +
地点:{scene.location}
+
角色:{scene.characters.join('、')}
+
目的:{scene.purpose}
+
+ ))} +
+
+ )} +
+
+ ) + }))} + /> +
+ ), + onOk: async () => { + // 第二步:用户确认后,直接使用缓存的规划创建章节(避免重复调用AI) + await handleConfirmCreateChapters(outlineId, cachedPlans); + }, + onCancel: () => { + message.info('已取消创建章节'); + } + }); + }; + + // 确认创建章节 - 使用缓存的规划数据,避免重复AI调用 + const handleConfirmCreateChapters = async ( + outlineId: string, + cachedPlans: any[] + ) => { + try { + setIsExpanding(true); + + // 使用新的API端点,直接传递缓存的规划数据 + const response = await outlineApi.createChaptersFromPlans(outlineId, cachedPlans); + + message.success( + `成功创建${response.chapters_created}个章节!`, + 3 + ); + + console.log('✅ 使用缓存的规划创建章节,避免了重复的AI调用'); + + // 刷新大纲和章节列表 + refreshOutlines(); + + } catch (error) { + console.error('创建章节失败:', error); + message.error('创建章节失败'); + } finally { + setIsExpanding(false); + } + }; + + // 批量展开所有大纲 - 使用SSE流式显示进度 + const handleBatchExpandOutlines = () => { + if (!currentProject?.id || outlines.length === 0) { + message.warning('没有可展开的大纲'); + return; + } + + Modal.confirm({ + title: ( + + + 批量展开所有大纲 + + ), + width: 600, + centered: true, + content: ( +
+
+
+ ⚠️ 将对当前项目的所有 {outlines.length} 个大纲进行展开 +
+
+
+ + + + + + + 均衡分配 + 高潮重点 + 细节丰富 + + +
+
+ ), + okText: '开始展开', + cancelText: '取消', + okButtonProps: { type: 'primary' }, + onOk: async () => { + try { + const values = await batchExpansionForm.validateFields(); + + // 关闭配置表单 + Modal.destroyAll(); + + // 显示SSE进度Modal + setSSEProgress(0); + setSSEMessage('正在准备批量展开...'); + setSSEModalVisible(true); + setIsExpanding(true); + + // 准备请求数据 + const requestData = { + project_id: currentProject.id, + ...values, + auto_create_chapters: false // 第一步:仅生成规划 + }; + + // 使用SSE客户端 + const apiUrl = `/api/outlines/batch-expand-stream`; + const client = new SSEPostClient(apiUrl, requestData, { + onProgress: (msg: string, progress: number) => { + setSSEMessage(msg); + setSSEProgress(progress); + }, + onResult: (data: any) => { + console.log('批量展开完成,结果:', data); + // 缓存AI生成的规划数据 + setCachedBatchExpansionResponse(data); + setBatchPreviewData(data); + // 关闭SSE进度Modal + setSSEModalVisible(false); + // 重置选择状态 + setSelectedOutlineIdx(0); + setSelectedChapterIdx(0); + // 显示批量预览Modal + setBatchPreviewVisible(true); + }, + onError: (error: string) => { + message.error(`批量展开失败: ${error}`); + setSSEModalVisible(false); + setIsExpanding(false); + }, + onComplete: () => { + setSSEModalVisible(false); + setIsExpanding(false); + } + }); + + // 开始连接 + client.connect(); + + } catch (error) { + console.error('批量展开失败:', error); + message.error('批量展开失败'); + setSSEModalVisible(false); + setIsExpanding(false); + } + }, + }); + }; + + // 渲染批量展开预览 Modal 内容 + const renderBatchPreviewContent = () => { + if (!batchPreviewData) return null; + + return ( +
+ {/* 顶部统计信息 */} +
+ 已处理: {batchPreviewData.total_outlines_expanded} 个大纲 + 总章节数: {batchPreviewData.expansion_results.reduce((sum: number, r: OutlineExpansionResponse) => sum + r.actual_chapter_count, 0)} + 预览模式(未创建章节) + {batchPreviewData.skipped_outlines && batchPreviewData.skipped_outlines.length > 0 && ( + 跳过: {batchPreviewData.skipped_outlines.length} 个大纲 + )} +
+ + {/* 显示跳过的大纲信息 */} + {batchPreviewData.skipped_outlines && batchPreviewData.skipped_outlines.length > 0 && ( +
+
+ ⚠️ 以下大纲已展开过,已自动跳过: +
+ + {batchPreviewData.skipped_outlines.map((skipped: any, idx: number) => ( +
+ • {skipped.outline_title} {skipped.reason} +
+ ))} +
+
+ )} + + {/* 水平三栏布局 */} +
+ {/* 左栏:大纲列表 */} +
+
大纲列表
+ ( + { + setSelectedOutlineIdx(idx); + setSelectedChapterIdx(0); + }} + style={{ + cursor: 'pointer', + padding: '8px 12px', + background: selectedOutlineIdx === idx ? '#e6f7ff' : 'transparent', + borderRadius: 4, + marginBottom: 4, + border: selectedOutlineIdx === idx ? '1px solid #1890ff' : '1px solid transparent' + }} + > +
+
+ {idx + 1}. {result.outline_title} +
+ + {result.expansion_strategy} + {result.actual_chapter_count} 章 + +
+
+ )} + /> +
+ + {/* 中栏:章节列表 */} +
+
+ 章节列表 ({batchPreviewData.expansion_results[selectedOutlineIdx]?.actual_chapter_count || 0} 章) +
+ {batchPreviewData.expansion_results[selectedOutlineIdx] && ( + ( + setSelectedChapterIdx(idx)} + style={{ + cursor: 'pointer', + padding: '8px 12px', + background: selectedChapterIdx === idx ? '#e6f7ff' : 'transparent', + borderRadius: 4, + marginBottom: 4, + border: selectedChapterIdx === idx ? '1px solid #1890ff' : '1px solid transparent' + }} + > +
+
+ {idx + 1}. {plan.title} +
+ + {plan.emotional_tone} + {plan.conflict_type} + 约{plan.estimated_words}字 + +
+
+ )} + /> + )} +
+ + {/* 右栏:章节详情 */} +
+
章节详情
+ {batchPreviewData.expansion_results[selectedOutlineIdx]?.chapter_plans[selectedChapterIdx] ? ( + + + {batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].plot_summary} + + + + {batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].narrative_goal} + + + + + {batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].key_events.map((event: string, eventIdx: number) => ( +
• {event}
+ ))} +
+
+ + + + {batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].character_focus.map((char: string, charIdx: number) => ( + {char} + ))} + + + + {batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].scenes && batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].scenes!.length > 0 && ( + + + {batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].scenes!.map((scene: any, sceneIdx: number) => ( + +
地点:{scene.location}
+
角色:{scene.characters.join('、')}
+
目的:{scene.purpose}
+
+ ))} +
+
+ )} +
+ ) : ( + + )} +
+
+
+ ); + }; + + // 处理批量预览确认 + const handleBatchPreviewOk = async () => { + setBatchPreviewVisible(false); + await handleConfirmBatchCreateChapters(); + }; + + // 处理批量预览取消 + const handleBatchPreviewCancel = () => { + setBatchPreviewVisible(false); + message.info('已取消创建章节,规划已保存'); + }; + + + // 确认批量创建章节 - 使用缓存的规划数据 + const handleConfirmBatchCreateChapters = async () => { + try { + setIsExpanding(true); + + // 使用缓存的规划数据,避免重复调用AI + if (!cachedBatchExpansionResponse) { + message.error('规划数据丢失,请重新展开'); + return; + } + + console.log('✅ 使用缓存的批量规划数据创建章节,避免重复AI调用'); + + // 逐个大纲创建章节 + let totalCreated = 0; + const errors: string[] = []; + + for (const result of cachedBatchExpansionResponse.expansion_results) { + try { + // 使用create-chapters-from-plans接口,直接传递缓存的规划 + const response = await outlineApi.createChaptersFromPlans( + result.outline_id, + result.chapter_plans + ); + totalCreated += response.chapters_created; + } catch (error: any) { + const errorMsg = error.response?.data?.detail || error.message || '未知错误'; + errors.push(`${result.outline_title}: ${errorMsg}`); + console.error(`创建大纲 ${result.outline_title} 的章节失败:`, error); + } + } + + // 显示结果 + if (errors.length === 0) { + message.success( + `批量创建完成!共创建 ${totalCreated} 个章节`, + 3 + ); + } else { + message.warning( + `部分完成:成功创建 ${totalCreated} 个章节,${errors.length} 个失败`, + 5 + ); + console.error('失败详情:', errors); + } + + // 清除缓存 + setCachedBatchExpansionResponse(null); + + // 刷新列表 + refreshOutlines(); + + } catch (error) { + console.error('批量创建章节失败:', error); + message.error('批量创建章节失败'); + } finally { + setIsExpanding(false); + } + }; + return ( <> + {/* 批量展开预览 Modal */} + + + 批量展开规划预览 + + } + open={batchPreviewVisible} + onOk={handleBatchPreviewOk} + onCancel={handleBatchPreviewCancel} + width={1200} + centered + okText="确认并批量创建章节" + cancelText="暂不创建" + okButtonProps={{ danger: true }} + > + {renderBatchPreviewContent()} + + {/* SSE进度Modal */}

故事大纲

- + + + {outlines.length > 0 && ( + + + + )} + {/* 可滚动内容区域 */} @@ -439,7 +1240,7 @@ export default function Outline() { ( + renderItem={(item) => ( } - onClick={() => handleMoveUp(index)} - disabled={index === 0} - title="上移" - > - 上移 - , - , + + + ,