From 8121c04af94f5d8af8522d2262ad056fc38ac9d5 Mon Sep 17 00:00:00 2001 From: xiamuceer Date: Wed, 26 Nov 2025 14:56:13 +0800 Subject: [PATCH] =?UTF-8?q?update:1.=E4=BF=AE=E5=A4=8D=E5=A4=A7=E7=BA=B2?= =?UTF-8?q?=E5=B1=95=E5=BC=80=E5=8A=9F=E8=83=BDbug=EF=BC=8C=E6=8C=89?= =?UTF-8?q?=E9=A1=BA=E5=BA=8F=E5=B1=95=E5=BC=80=202.=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=A4=A7=E7=BA=B2=E7=BB=86=E5=8C=96UI=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=EF=BC=8C=E5=A4=A7=E7=BA=B2=E8=AE=BE=E7=BD=AE=E4=B8=BA=E5=8D=B7?= =?UTF-8?q?=203.=E5=AE=9E=E7=8E=B0=E8=A7=92=E8=89=B2=E5=85=B3=E7=B3=BB?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=8A=9F=E8=83=BD=204.=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D=E9=81=BF=E5=85=8D=E5=87=BA=E7=8E=B0?= =?UTF-8?q?=E8=BF=87=E5=A4=9A=E7=89=B9=E6=AE=8A=E7=AC=A6=E5=8F=B7=205.?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=90=91=E5=AF=BC=E9=A1=B5=E9=9D=A2=E7=9A=84?= =?UTF-8?q?AI=E7=94=9F=E4=BA=A7=E8=BF=9B=E5=BA=A6=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=92=8C=E7=81=B5=E6=84=9F=E6=A8=A1=E5=BC=8F=E4=BF=9D=E6=8C=81?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=EF=BC=8C=E6=94=AF=E6=8C=81=E9=87=8D=E8=AF=95?= =?UTF-8?q?=206.=E4=BC=98=E5=8C=96=E9=A1=B9=E7=9B=AE=E7=94=9F=E6=88=90?= =?UTF-8?q?=E8=BF=87=E9=95=BF=E4=B8=AD=E6=96=AD=E6=B7=BB=E5=8A=A0=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=81=A2=E5=A4=8D=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- backend/app/api/chapters.py | 61 +- backend/app/api/characters.py | 121 ++- backend/app/api/outlines.py | 265 +++-- backend/app/api/wizard_stream.py | 10 +- backend/app/database.py | 1 + backend/app/services/prompt_service.py | 180 ++-- frontend/package.json | 2 +- .../src/components/AIProjectGenerator.tsx | 922 ++++++++++++++++++ .../components/ChapterRegenerationModal.tsx | 25 +- frontend/src/components/SSEProgressModal.tsx | 127 +++ frontend/src/pages/Chapters.tsx | 228 +++-- frontend/src/pages/Inspiration.tsx | 653 +------------ frontend/src/pages/Outline.tsx | 289 ++++-- frontend/src/pages/ProjectList.tsx | 20 +- frontend/src/pages/ProjectWizardNew.tsx | 399 ++------ frontend/src/pages/Relationships.tsx | 92 +- frontend/src/pages/UserManagement.tsx | 2 + 18 files changed, 2094 insertions(+), 1307 deletions(-) create mode 100644 frontend/src/components/AIProjectGenerator.tsx create mode 100644 frontend/src/components/SSEProgressModal.tsx diff --git a/README.md b/README.md index 37b2ece..52a637b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@
-![Version](https://img.shields.io/badge/version-1.0.4-blue.svg) +![Version](https://img.shields.io/badge/version-1.0.5-blue.svg) ![Python](https://img.shields.io/badge/python-3.11-blue.svg) ![FastAPI](https://img.shields.io/badge/FastAPI-0.109.0-green.svg) ![React](https://img.shields.io/badge/react-18.3.1-blue.svg) @@ -22,7 +22,7 @@ 如果这个项目对你有帮助,欢迎通过以下方式支持开发: -**[☕ 请我喝杯咖啡](https://zanzhupage.vercel.app/)** +**[☕ 请我喝杯咖啡](https://mumuverse.space:1588/)** 您的支持是我持续开发的动力!🙏 diff --git a/backend/app/api/chapters.py b/backend/app/api/chapters.py index 87cde3b..2fbb3c6 100644 --- a/backend/app/api/chapters.py +++ b/backend/app/api/chapters.py @@ -1124,14 +1124,23 @@ async def generate_chapter_content_stream( # 发送开始事件 yield f"data: {json.dumps({'type': 'start', 'message': '开始AI创作...'}, ensure_ascii=False)}\n\n" - # 🔧 MCP工具增强:收集章节参考资料 + # 🔧 MCP工具增强:收集章节参考资料(优化版) mcp_reference_materials = "" if enable_mcp and current_user_id: try: - yield f"data: {json.dumps({'type': 'progress', 'message': '🔍 尝试使用MCP工具收集参考资料...', 'progress': 28}, ensure_ascii=False)}\n\n" + # 1️⃣ 静默检查工具可用性 + from app.services.mcp_tool_service import mcp_tool_service + available_tools = await mcp_tool_service.get_user_enabled_tools( + user_id=current_user_id, + db_session=db_session + ) - # 构建资料收集提示词 - planning_prompt = f"""你正在为小说《{project.title}》创作第{current_chapter.chapter_number}章《{current_chapter.title}》。 + # 2️⃣ 只在有工具时才显示消息和调用 + if available_tools: + yield f"data: {json.dumps({'type': 'progress', 'message': '🔍 使用MCP工具收集参考资料...', 'progress': 28}, ensure_ascii=False)}\n\n" + + # 构建资料收集提示词 + planning_prompt = f"""你正在为小说《{project.title}》创作第{current_chapter.chapter_number}章《{current_chapter.title}》。 【章节大纲】 {outline.content if outline else current_chapter.summary or '暂无大纲'} @@ -1151,30 +1160,32 @@ async def generate_chapter_content_stream( 4. 文化习俗和生活细节 请根据章节内容,有针对性地查询1-2个最关键的问题。""" - - # 调用MCP增强的AI(非流式,最多2轮工具调用) - planning_result = await user_ai_service.generate_text_with_mcp( - prompt=planning_prompt, - user_id=current_user_id, - db_session=db_session, - enable_mcp=True, - max_tool_rounds=2, - tool_choice="auto", - provider=None, - model=None - ) - - # 提取参考资料 - if planning_result.get("tool_calls_made", 0) > 0: - tool_count = planning_result["tool_calls_made"] - yield f"data: {json.dumps({'type': 'progress', 'message': f'✅ MCP工具调用成功({tool_count}次)', 'progress': 32}, ensure_ascii=False)}\n\n" - mcp_reference_materials = planning_result.get("content", "") - logger.info(f"📚 MCP工具收集参考资料:{len(mcp_reference_materials)} 字符") + + # 调用MCP增强的AI(非流式,限制1轮避免超时) + planning_result = await user_ai_service.generate_text_with_mcp( + prompt=planning_prompt, + user_id=current_user_id, + db_session=db_session, + enable_mcp=True, + max_tool_rounds=1, # ✅ 减少为1轮,避免超时 + tool_choice="auto", + provider=None, + model=None + ) + + # 3️⃣ 提取参考资料并显示结果 + if planning_result.get("tool_calls_made", 0) > 0: + tool_count = planning_result["tool_calls_made"] + yield f"data: {json.dumps({'type': 'progress', 'message': f'✅ MCP工具调用成功({tool_count}次)', 'progress': 32}, ensure_ascii=False)}\n\n" + mcp_reference_materials = planning_result.get("content", "") + logger.info(f"📚 MCP工具收集参考资料:{len(mcp_reference_materials)} 字符") + else: + yield f"data: {json.dumps({'type': 'progress', 'message': 'ℹ️ MCP未使用工具,继续', 'progress': 32}, ensure_ascii=False)}\n\n" else: - yield f"data: {json.dumps({'type': 'progress', 'message': 'ℹ️ 未使用MCP工具(无可用工具或不需要)', 'progress': 32}, ensure_ascii=False)}\n\n" + logger.debug(f"用户 {current_user_id} 未启用MCP工具,跳过MCP增强") except Exception as e: - logger.warning(f"MCP工具调用失败(降级处理): {e}") + logger.warning(f"⚠️ MCP工具调用失败,降级为基础模式: {str(e)}") yield f"data: {json.dumps({'type': 'progress', 'message': '⚠️ MCP工具暂时不可用,使用基础模式', 'progress': 32}, ensure_ascii=False)}\n\n" # 根据是否有前置内容选择不同的提示词,并应用写作风格、记忆增强和MCP参考资料 diff --git a/backend/app/api/characters.py b/backend/app/api/characters.py index 2253c14..8f64496 100644 --- a/backend/app/api/characters.py +++ b/backend/app/api/characters.py @@ -436,27 +436,54 @@ async def generate_character( logger.info(f" - 用户ID:{user_id}") try: - # 使用支持MCP的生成方法 - result = await user_ai_service.generate_text_with_mcp( - prompt=prompt, - user_id=user_id, - db_session=db, - enable_mcp=True, - max_tool_rounds=2, - tool_choice="auto", - provider=None, # 使用AIService初始化时的配置 - model=None # 使用AIService初始化时的配置 - ) - - # 提取内容 - if isinstance(result, dict): - ai_response = result.get('content', '') - logger.info(f"✅ AI响应接收完成(MCP增强),长度:{len(ai_response)} 字符") - if result.get('tool_calls'): - logger.info(f" - 工具调用:{len(result['tool_calls'])} 次") + # 🔧 MCP工具增强:静默检查并收集参考资料 + mcp_enhanced_prompt = prompt + if user_id: + try: + from app.services.mcp_tool_service import mcp_tool_service + available_tools = await mcp_tool_service.get_user_enabled_tools( + user_id=user_id, + db_session=db + ) + + # 只在有工具时才调用 + if available_tools: + logger.info(f"🔍 检测到可用MCP工具,尝试收集参考资料...") + result = await user_ai_service.generate_text_with_mcp( + prompt=prompt, + user_id=user_id, + db_session=db, + enable_mcp=True, + max_tool_rounds=1, # 减少为1轮,避免超时 + tool_choice="auto", + provider=None, + model=None + ) + + # 提取内容 + if isinstance(result, dict): + ai_response = result.get('content', '') + logger.info(f"✅ AI响应接收完成(MCP增强),长度:{len(ai_response)} 字符") + if result.get('tool_calls_made', 0) > 0: + logger.info(f" - MCP工具调用:{result['tool_calls_made']} 次") + else: + ai_response = result + logger.info(f"✅ AI响应接收完成,长度:{len(ai_response) if ai_response else 0} 字符") + else: + logger.debug(f"用户 {user_id} 未启用MCP工具,使用基础模式") + # 不使用MCP,直接生成 + result = await user_ai_service.generate_text(prompt=prompt) + ai_response = result.get('content', '') if isinstance(result, dict) else result + + except Exception as mcp_error: + logger.warning(f"⚠️ MCP工具调用失败,降级为基础模式: {str(mcp_error)}") + # 降级:不使用MCP + result = await user_ai_service.generate_text(prompt=prompt) + ai_response = result.get('content', '') if isinstance(result, dict) else result else: - ai_response = result - logger.info(f"✅ AI响应接收完成,长度:{len(ai_response) if ai_response else 0} 字符") + # 无用户ID,直接使用基础模式 + result = await user_ai_service.generate_text(prompt=prompt) + ai_response = result.get('content', '') if isinstance(result, dict) else result except Exception as ai_error: logger.error(f"❌ AI服务调用异常:{str(ai_error)}") @@ -807,21 +834,47 @@ async def generate_character_stream( logger.info(f"🎯 开始为项目 {request.project_id} 生成角色(SSE流式)") try: - result = await user_ai_service.generate_text_with_mcp( - prompt=prompt, - user_id=user_id, - db_session=db, - enable_mcp=True, - max_tool_rounds=2, - tool_choice="auto", - provider=None, - model=None - ) - - if isinstance(result, dict): - ai_response = result.get('content', '') + # 🔧 MCP工具增强:静默检查并收集参考资料 + if user_id: + try: + from app.services.mcp_tool_service import mcp_tool_service + available_tools = await mcp_tool_service.get_user_enabled_tools( + user_id=user_id, + db_session=db + ) + + # 只在有工具时才调用 + if available_tools: + logger.info(f"🔍 检测到可用MCP工具,尝试收集参考资料...") + result = await user_ai_service.generate_text_with_mcp( + prompt=prompt, + user_id=user_id, + db_session=db, + enable_mcp=True, + max_tool_rounds=1, # 减少为1轮,避免超时 + tool_choice="auto", + provider=None, + model=None + ) + + if isinstance(result, dict): + ai_response = result.get('content', '') + if result.get('tool_calls_made', 0) > 0: + logger.info(f"✅ MCP工具调用成功({result['tool_calls_made']}次)") + else: + ai_response = result + else: + logger.debug(f"用户 {user_id} 未启用MCP工具,使用基础模式") + result = await user_ai_service.generate_text(prompt=prompt) + ai_response = result.get('content', '') if isinstance(result, dict) else result + + except Exception as mcp_error: + logger.warning(f"⚠️ MCP工具调用失败,降级为基础模式: {str(mcp_error)}") + result = await user_ai_service.generate_text(prompt=prompt) + ai_response = result.get('content', '') if isinstance(result, dict) else result else: - ai_response = result + result = await user_ai_service.generate_text(prompt=prompt) + ai_response = result.get('content', '') if isinstance(result, dict) else result except Exception as ai_error: logger.error(f"❌ AI服务调用异常:{str(ai_error)}") diff --git a/backend/app/api/outlines.py b/backend/app/api/outlines.py index b79fb3f..b1cb953 100644 --- a/backend/app/api/outlines.py +++ b/backend/app/api/outlines.py @@ -310,7 +310,7 @@ async def generate_outline( # 模式:全新生成 if actual_mode == "new": return await _generate_new_outline( - request, project, db, user_ai_service + request, project, db, user_ai_service, user_id ) # 模式:续写 @@ -344,7 +344,8 @@ async def _generate_new_outline( request: OutlineGenerateRequest, project: Project, db: AsyncSession, - user_ai_service: AIService + user_ai_service: AIService, + user_id: str = None ) -> OutlineListResponse: """全新生成大纲(MCP增强版)""" logger.info(f"全新生成大纲 - 项目: {project.id}, enable_mcp: {request.enable_mcp}") @@ -360,14 +361,26 @@ async def _generate_new_outline( for char in characters ]) - # 🔍 MCP工具增强:收集情节设计参考资料 + # 🔍 MCP工具增强:收集情节设计参考资料(优化版) mcp_reference_materials = "" if request.enable_mcp: try: - logger.info(f"🔍 尝试使用MCP工具收集大纲设计参考资料...") + # 1️⃣ 静默检查工具可用性(注意:新建大纲时user_id可能不可用) + from app.services.mcp_tool_service import mcp_tool_service + # 使用传入的user_id参数 - # 构建资料收集查询 - planning_query = f"""你正在为小说《{project.title}》设计完整大纲。 + if user_id: + available_tools = await mcp_tool_service.get_user_enabled_tools( + user_id=user_id, + db_session=db + ) + + # 2️⃣ 只在有工具时才调用 + if available_tools: + logger.info(f"🔍 检测到可用MCP工具,收集大纲设计参考资料...") + + # 构建资料收集查询 + planning_query = f"""你正在为小说《{project.title}》设计完整大纲。 项目信息: - 主题:{request.theme or project.theme} - 类型:{request.genre or project.genre} @@ -389,27 +402,31 @@ async def _generate_new_outline( 3. 符合世界观的情节元素和场景设计灵感 请有针对性地查询1-2个最关键的问题。""" - - # 调用MCP增强的AI(非流式,最多2轮工具调用) - planning_result = await user_ai_service.generate_text_with_mcp( - prompt=planning_query, - user_id="system", # 全新生成时可能没有用户上下文 - db_session=db, - enable_mcp=True, - max_tool_rounds=2, - tool_choice="auto", - provider=None, - model=None - ) - - # 提取参考资料 - if planning_result.get("tool_calls_made", 0) > 0: - mcp_reference_materials = planning_result.get("content", "") - logger.info(f"📚 MCP工具收集参考资料:{len(mcp_reference_materials)} 字符") + + # 调用MCP增强的AI(非流式,限制1轮避免超时) + planning_result = await user_ai_service.generate_text_with_mcp( + prompt=planning_query, + user_id=user_id, + db_session=db, + enable_mcp=True, + max_tool_rounds=1, # ✅ 减少为1轮,避免超时 + tool_choice="auto", + provider=None, + model=None + ) + + # 提取参考资料 + if planning_result.get("tool_calls_made", 0) > 0: + mcp_reference_materials = planning_result.get("content", "") + logger.info(f"✅ MCP工具收集参考资料:{len(mcp_reference_materials)} 字符") + else: + logger.info(f"ℹ️ MCP未使用工具,继续") + else: + logger.debug(f"用户 {user_id} 未启用MCP工具,跳过MCP增强") else: - logger.info(f"ℹ️ MCP工具未进行调用,继续正常生成") + logger.debug("无用户上下文,跳过MCP增强") except Exception as e: - logger.warning(f"⚠️ MCP工具调用失败,继续使用常规模式: {str(e)}") + logger.warning(f"⚠️ MCP工具调用失败,降级为基础模式: {str(e)}") mcp_reference_materials = "" # 使用完整提示词(插入MCP参考资料) @@ -659,15 +676,24 @@ async def _continue_outline( logger.warning(f"⚠️ 记忆上下文构建失败,继续不使用记忆: {str(e)}") memory_context = None - # 🔍 MCP工具增强:收集续写参考资料 + # 🔍 MCP工具增强:收集续写参考资料(优化版) mcp_reference_materials = "" if request.enable_mcp: try: - logger.info(f"🔍 第{batch_num + 1}批:尝试使用MCP工具收集续写参考资料...") + # 1️⃣ 静默检查工具可用性 + from app.services.mcp_tool_service import mcp_tool_service + available_tools = await mcp_tool_service.get_user_enabled_tools( + user_id=user_id, + db_session=db + ) - # 构建资料收集查询 - latest_summary = latest_outlines[-1].content if latest_outlines else "" - planning_query = f"""你正在为小说《{project.title}》续写大纲。 + # 2️⃣ 只在有工具时才调用 + if available_tools: + logger.info(f"🔍 第{batch_num + 1}批:检测到可用MCP工具,收集续写参考资料...") + + # 构建资料收集查询 + latest_summary = latest_outlines[-1].content if latest_outlines else "" + planning_query = f"""你正在为小说《{project.title}》续写大纲。 当前进度:已有{len(latest_outlines)}章,即将续写第{current_start_chapter}-{current_start_chapter + current_batch_size - 1}章 项目信息: @@ -686,27 +712,29 @@ async def _continue_outline( 3. 符合类型特点的场景设计和剧情元素 请有针对性地查询1-2个最关键的问题。""" - - # 调用MCP增强的AI(非流式,最多2轮工具调用) - planning_result = await user_ai_service.generate_text_with_mcp( - prompt=planning_query, - user_id=user_id, - db_session=db, - enable_mcp=True, - max_tool_rounds=2, - tool_choice="auto", - provider=None, - model=None - ) - - # 提取参考资料 - if planning_result.get("tool_calls_made", 0) > 0: - mcp_reference_materials = planning_result.get("content", "") - logger.info(f"📚 第{batch_num + 1}批MCP工具收集参考资料:{len(mcp_reference_materials)} 字符") + + # 调用MCP增强的AI(非流式,限制1轮避免超时) + planning_result = await user_ai_service.generate_text_with_mcp( + prompt=planning_query, + user_id=user_id, + db_session=db, + enable_mcp=True, + max_tool_rounds=1, # ✅ 减少为1轮,避免超时 + tool_choice="auto", + provider=None, + model=None + ) + + # 提取参考资料 + if planning_result.get("tool_calls_made", 0) > 0: + mcp_reference_materials = planning_result.get("content", "") + logger.info(f"✅ 第{batch_num + 1}批MCP工具收集参考资料:{len(mcp_reference_materials)} 字符") + else: + logger.info(f"ℹ️ 第{batch_num + 1}批MCP未使用工具,继续") else: - logger.info(f"ℹ️ 第{batch_num + 1}批MCP工具未进行调用,继续正常生成") + logger.debug(f"用户 {user_id} 未启用MCP工具,跳过第{batch_num + 1}批MCP增强") except Exception as e: - logger.warning(f"⚠️ 第{batch_num + 1}批MCP工具调用失败,继续使用常规模式: {str(e)}") + logger.warning(f"⚠️ 第{batch_num + 1}批MCP工具调用失败,降级为基础模式: {str(e)}") mcp_reference_materials = "" # 使用标准续写提示词模板(支持记忆+MCP增强) @@ -892,15 +920,29 @@ async def new_outline_generator( for char in characters ]) - # 🔍 MCP工具增强:收集情节设计参考资料 + # 🔍 MCP工具增强:收集情节设计参考资料(优化版) mcp_reference_materials = "" if enable_mcp: try: - yield await SSEResponse.send_progress("🔍 使用MCP工具收集参考资料...", 18) - logger.info(f"🔍 尝试使用MCP工具收集大纲设计参考资料...") + # 1️⃣ 静默检查工具可用性 + from app.services.mcp_tool_service import mcp_tool_service + # 尝试从环境获取user_id(SSE流式场景下可能没有) + # 这里可以考虑让前端传递user_id + user_id_for_mcp = data.get("user_id") # 需要前端传递 - # 构建资料收集查询 - planning_query = f"""你正在为小说《{project.title}》设计完整大纲。 + if user_id_for_mcp: + available_tools = await mcp_tool_service.get_user_enabled_tools( + user_id=user_id_for_mcp, + db_session=db + ) + + # 2️⃣ 只在有工具时才显示消息和调用 + if available_tools: + yield await SSEResponse.send_progress("🔍 使用MCP工具收集参考资料...", 18) + logger.info(f"🔍 检测到可用MCP工具,收集大纲设计参考资料...") + + # 构建资料收集查询 + planning_query = f"""你正在为小说《{project.title}》设计完整大纲。 项目信息: - 主题:{data.get('theme') or project.theme} - 类型:{data.get('genre') or project.genre} @@ -922,28 +964,32 @@ async def new_outline_generator( 3. 符合世界观的情节元素和场景设计灵感 请有针对性地查询1-2个最关键的问题。""" - - # 调用MCP增强的AI(非流式,最多2轮工具调用) - planning_result = await user_ai_service.generate_text_with_mcp( - prompt=planning_query, - user_id="system", - db_session=db, - enable_mcp=True, - max_tool_rounds=2, - tool_choice="auto", - provider=None, - model=None - ) - - # 提取参考资料 - if planning_result.get("tool_calls_made", 0) > 0: - mcp_reference_materials = planning_result.get("content", "") - logger.info(f"📚 MCP工具收集参考资料:{len(mcp_reference_materials)} 字符") - yield await SSEResponse.send_progress(f"📚 MCP收集到参考资料 ({len(mcp_reference_materials)}字符)", 19) + + # 调用MCP增强的AI(非流式,限制1轮避免超时) + planning_result = await user_ai_service.generate_text_with_mcp( + prompt=planning_query, + user_id=user_id_for_mcp, + db_session=db, + enable_mcp=True, + max_tool_rounds=1, # ✅ 减少为1轮,避免超时 + tool_choice="auto", + provider=None, + model=None + ) + + # 提取参考资料 + if planning_result.get("tool_calls_made", 0) > 0: + mcp_reference_materials = planning_result.get("content", "") + logger.info(f"✅ MCP工具收集参考资料:{len(mcp_reference_materials)} 字符") + yield await SSEResponse.send_progress(f"✅ MCP收集到参考资料 ({len(mcp_reference_materials)}字符)", 19) + else: + logger.info(f"ℹ️ MCP未使用工具,继续") + else: + logger.debug(f"用户 {user_id_for_mcp} 未启用MCP工具,跳过MCP增强") else: - logger.info(f"ℹ️ MCP工具未进行调用,继续正常生成") + logger.debug("无用户上下文,跳过MCP增强") except Exception as e: - logger.warning(f"⚠️ MCP工具调用失败,继续使用常规模式: {str(e)}") + logger.warning(f"⚠️ MCP工具调用失败,降级为基础模式: {str(e)}") mcp_reference_materials = "" # 使用完整提示词(插入MCP参考资料) @@ -1185,20 +1231,29 @@ async def continue_outline_generator( except Exception as e: logger.warning(f"⚠️ 记忆上下文构建失败: {str(e)}") memory_context = None - # 🔍 MCP工具增强:收集续写参考资料 + # 🔍 MCP工具增强:收集续写参考资料(优化版) mcp_reference_materials = "" enable_mcp = data.get("enable_mcp", True) if enable_mcp: try: - yield await SSEResponse.send_progress( - f"🔍 第{str(batch_num + 1)}批:使用MCP工具收集参考资料...", - batch_progress + 4 + # 1️⃣ 静默检查工具可用性 + from app.services.mcp_tool_service import mcp_tool_service + available_tools = await mcp_tool_service.get_user_enabled_tools( + user_id=user_id, + db_session=db ) - logger.info(f"🔍 第{batch_num + 1}批:尝试使用MCP工具收集续写参考资料...") - # 构建资料收集查询 - latest_summary = latest_outlines[-1].content if latest_outlines else "" - planning_query = f"""你正在为小说《{project.title}》续写大纲。 + # 2️⃣ 只在有工具时才显示消息和调用 + if available_tools: + yield await SSEResponse.send_progress( + f"🔍 第{str(batch_num + 1)}批:使用MCP工具收集参考资料...", + batch_progress + 4 + ) + logger.info(f"🔍 第{batch_num + 1}批:检测到可用MCP工具,收集续写参考资料...") + + # 构建资料收集查询 + latest_summary = latest_outlines[-1].content if latest_outlines else "" + planning_query = f"""你正在为小说《{project.title}》续写大纲。 当前进度:已有{len(latest_outlines)}章,即将续写第{current_start_chapter}-{current_start_chapter + current_batch_size - 1}章 项目信息: @@ -1217,31 +1272,33 @@ async def continue_outline_generator( 3. 符合类型特点的场景设计和剧情元素 请有针对性地查询1-2个最关键的问题。""" - - # 调用MCP增强的AI(非流式,最多2轮工具调用) - planning_result = await user_ai_service.generate_text_with_mcp( - prompt=planning_query, - user_id=user_id, - db_session=db, - enable_mcp=True, - max_tool_rounds=2, - tool_choice="auto", - provider=None, - model=None - ) - - # 提取参考资料 - if planning_result.get("tool_calls_made", 0) > 0: - mcp_reference_materials = planning_result.get("content", "") - logger.info(f"📚 第{batch_num + 1}批MCP工具收集参考资料:{len(mcp_reference_materials)} 字符") - yield await SSEResponse.send_progress( - f"📚 第{str(batch_num + 1)}批收集到参考资料 ({len(mcp_reference_materials)}字符)", - batch_progress + 4.5 + + # 调用MCP增强的AI(非流式,限制1轮避免超时) + planning_result = await user_ai_service.generate_text_with_mcp( + prompt=planning_query, + user_id=user_id, + db_session=db, + enable_mcp=True, + max_tool_rounds=1, # ✅ 减少为1轮,避免超时 + tool_choice="auto", + provider=None, + model=None ) + + # 提取参考资料 + if planning_result.get("tool_calls_made", 0) > 0: + mcp_reference_materials = planning_result.get("content", "") + logger.info(f"✅ 第{batch_num + 1}批MCP工具收集参考资料:{len(mcp_reference_materials)} 字符") + yield await SSEResponse.send_progress( + f"✅ 第{str(batch_num + 1)}批收集到参考资料 ({len(mcp_reference_materials)}字符)", + batch_progress + 4.5 + ) + else: + logger.info(f"ℹ️ 第{batch_num + 1}批MCP未使用工具,继续") else: - logger.info(f"ℹ️ 第{batch_num + 1}批MCP工具未进行调用,继续正常生成") + logger.debug(f"用户 {user_id} 未启用MCP工具,跳过第{batch_num + 1}批MCP增强") except Exception as e: - logger.warning(f"⚠️ 第{batch_num + 1}批MCP工具调用失败,继续使用常规模式: {str(e)}") + logger.warning(f"⚠️ 第{batch_num + 1}批MCP工具调用失败,降级为基础模式: {str(e)}") mcp_reference_materials = "" diff --git a/backend/app/api/wizard_stream.py b/backend/app/api/wizard_stream.py index c83946c..b3a0ab3 100644 --- a/backend/app/api/wizard_stream.py +++ b/backend/app/api/wizard_stream.py @@ -246,6 +246,10 @@ async def world_building_generator( except Exception as e: logger.warning(f"设置默认写作风格失败: {e},不影响项目创建") + # 更新向导步骤状态为1(世界观已完成) + project.wizard_step = 1 + await db.commit() + db_committed = True # 发送最终结果 @@ -824,8 +828,9 @@ async def characters_generator( logger.info(f" - 创建角色关系:{relationships_created} 条") logger.info(f" - 创建组织成员:{members_created} 条") - # 更新项目的角色数量 + # 更新项目的角色数量和向导步骤状态为2(角色已完成) project.character_count = len(created_characters) + project.wizard_step = 2 logger.info(f"✅ 更新项目角色数量: {project.character_count}") await db.commit() @@ -1022,7 +1027,7 @@ async def outline_generator( project.target_words = target_words project.status = "writing" project.wizard_status = "completed" - project.wizard_step = 4 + project.wizard_step = 3 await db.commit() db_committed = True @@ -1269,7 +1274,6 @@ async def regenerate_world_building_stream( # 从中间件注入user_id到data中 if hasattr(request.state, 'user_id'): data['user_id'] = request.state.user_id - return create_sse_response(world_building_regenerate_generator(project_id, data, db, user_ai_service)) diff --git a/backend/app/database.py b/backend/app/database.py index ede79d8..fd06871 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -86,6 +86,7 @@ async def get_engine(user_id: str): "server_settings": { "application_name": settings.app_name, "jit": "off", + "search_path": "public", }, "command_timeout": 60, "statement_cache_size": 500, diff --git a/backend/app/services/prompt_service.py b/backend/app/services/prompt_service.py index e9a98cc..31187e7 100644 --- a/backend/app/services/prompt_service.py +++ b/backend/app/services/prompt_service.py @@ -112,61 +112,109 @@ class PromptService: """提示词模板管理""" # 世界构建提示词 - WORLD_BUILDING = """你是一位资深的世界观设计师(World-Building Architect)。你的任务是基于输入信息,构建一个高度原创、深度自洽、且充满戏剧冲突的小说世界观。 + WORLD_BUILDING = """你是一位资深的世界观设计师。基于以下输入信息,构建一个高度原创、深度自洽、充满戏剧冲突的小说世界观。 -# 1. 输入信息 +# 输入信息 书名:{title} 主题:{theme} 类型:{genre} -# 2. 核心指令(CRITICAL) -* **去标签化**:严禁使用通用的“XX纪元”、“XX时代”、“XX年”作为时间背景的开头或核心描述。请直接描述世界所处的状态、技术水平或生存现状。 -* **动态演绎**:所有设定必须直接由输入的主题衍生而来。例如,如果是赛博朋克,不要只写“高科技”,要写“义体技术如何导致了贫民窟的特定生活方式”。 -* **拒绝陈词滥调**:避免使用宏大的空洞词汇,专注于具体的、可感知的细节。 +# 核心要求 +* **类型适配性**:世界观必须符合小说类型的特征,不要生成不匹配的设定 +* **主题贴合性**:时代背景要能有效支撑和体现小说主题 +* **原创性**:在类型框架内发挥创意,创造独特但合理的世界设定 +* **具象化**:避免空洞概念,用具体可感的细节描述世界 +* **逻辑自洽**:确保所有设定相互支撑,形成完整体系 +* **戏剧张力**:设定要能为故事冲突提供支撑 -# 3. 世界构建框架 -请生成包含以下四个核心板块的JSON。请确保所有板块互为因果,逻辑严密。 +# 类型指导原则 +根据小说类型选择适当的设定风格: -**重要说明:每个字段的value必须是一个完整的文本字符串,将以下所有要点整合成连贯的段落描述,不要使用嵌套的JSON对象或数组。** +**现代都市/言情/青春**: +- 时间设定:当代现实社会(2020年代)或近未来(2030-2050年) +- 避免使用:大崩解、纪元、末日、重生等宏大概念 +- 重点描述:具体的城市环境、社会现状、文化氛围 +- 例如:一线城市的竞争压力、职场文化、代际冲突、社交媒体影响等 -1. **time_period (时间线与文明阶段)**: - 请将以下内容整合为一段完整的文字描述(300-500字): - * 描述当前世界处于什么**发展阶段**(是毁灭边缘、新生萌芽、还是停滞不前?),**不要给这个阶段起名字**,而是描述其**特征**。 - * **历史转折点**:具体的事件(战争、发明、灾难),它如何直接导致了现在的局面? - * **当下的核心矛盾**:时间流逝带来的具体焦虑是什么?(例如:资源枯竭的倒计时、某种信仰的崩塌)。 -2. **location (空间与生态环境)**: - 请将以下内容整合为一段完整的文字描述(300-500字): - * **舞台特征**:描述主要故事发生的地理或空间环境(如:悬浮的破碎岛屿、被真菌覆盖的地铁网络)。 - * **环境与生存**:地理环境如何强迫居民改变了生活方式?(例如:因为引力失衡,建筑都是倒挂的)。 - * **标志性奇观**:一个能代表这个世界独特性的具体场景或建筑。 -3. **atmosphere (感官体验与基调)**: - 请将以下内容整合为一段完整的文字描述(300-500字): - * **感官细节**:如果站在这个世界的街头,会**闻**到什么?**听**到什么?(不要只写"压抑",要写"空气中弥漫着铁锈和合成营养膏的酸味")。 - * **视觉美学**:描述具体的色彩倾向和光影质感。 - * **居民心态**:普通人普遍的心理状态(是麻木、狂热、还是某种特定的恐惧)。 -4. **rules (运作逻辑与禁忌)**: - 请将以下内容整合为一段完整的文字描述(300-500字): - * **核心法则**:这个世界运行的底层逻辑(物理、魔法或科技)。**重点描述代价**(使用力量需要支付什么?)。 - * **权力架构**:谁掌握资源?他们通过什么手段维持控制(暴力、技术垄断、宗教洗脑)? - * **红线禁忌**:这个社会绝对不能触碰的具体底线,以及违反后的直接后果。 +**历史/古代**: +- 时间设定:明确的历史朝代或虚构但有历史感的古代社会 +- 避免使用:科技元素、未来概念 +- 重点描述:时代特征、礼教制度、阶级分化 -# 4. 严格格式要求 -1. **绝对纯净JSON**:你的[唯一]输出必须是一个完整的JSON对象。输出必须以左花括号开始,并以右花括号结束。 -2. **禁止额外字符**:不要在JSON对象之前或之后包含任何说明文字、Markdown标记(如三个反引号加json)、注释或任何其他非JSON字符。 -3. **JSON内部文本规则**:在JSON的value字符串内部: - * 严禁使用任何中文引号(""'')或英文引号来表示强调或引用。 - * 所有【专有名词】(如地点、人物、组织)应使用【】包裹。 - * 所有《作品》或《特殊概念》的标题应使用《》包裹。 -4. **JSON结构**:严格遵守`"key": "value"`的英文双引号结构,并使用下面指定的key。 -5. **内容密度**:每个字段的描述都必须【深入且详实】,提供至少5-7个具体的设定点或细节,整合为连贯的段落文本。 -6. **禁止嵌套结构**:value必须是纯文本字符串,绝对不能是JSON对象或数组,所有信息都要整合在一个字符串中。 +**玄幻/仙侠/修真**: +- 时间设定:修炼文明的特定时期,可以有门派兴衰、修炼体系变革 +- 可以使用宏大设定,但要与修炼体系紧密结合 +- 重点描述:修炼规则、灵气环境、门派势力 -{{ - "time_period": "(此处填写一段完整的文字描述,包含发展阶段特征、历史转折点、核心矛盾等内容,300-500字)", - "location": "(此处填写一段完整的文字描述,包含舞台特征、环境与生存、标志性奇观等内容,300-500字)", - "atmosphere": "(此处填写一段完整的文字描述,包含感官细节、视觉美学、居民心态等内容,300-500字)", - "rules": "(此处填写一段完整的文字描述,包含核心法则、权力架构、红线禁忌等内容,300-500字)" -}}""" +**科幻**: +- 时间设定:未来某个明确时期(如2150年、星际时代初期等) +- 可以有文明转折,但要具体说明科技水平和社会形态 +- 避免空泛的纪元名称,多用具体的科技特征描述 + +**奇幻/魔法**: +- 时间设定:魔法文明的特定阶段 +- 重点描述:魔法体系、种族关系、大陆格局 + +**悬疑/推理/惊悚**: +- 时间设定:当代或历史某个时期 +- 重点描述:案件背景、社会环境、人际关系网 + +**军事/战争**: +- 时间设定:战争时期的具体年代 +- 重点描述:战争形势、阵营对立、军事科技水平 + +# 设定尺度控制 +**切记:不要为所有类型都生成宏大的世界观!** + +- 如果是现代都市题材,就写现实社会的某个城市、某个行业、某个阶层 +- 如果是校园青春,就写学校环境、学生生活、成长困境 +- 如果是职场言情,就写公司文化、行业特点、职业压力 +- 只有史诗级题材(玄幻、科幻、奇幻等)才需要宏大的世界观架构 + +# 输出要求 +生成包含以下四个字段的JSON对象,每个字段用300-500字的连贯段落描述: + +1. **time_period**(时间背景与社会状态) + - **重要**:根据类型和主题,设定合适规模的时间背景 + - 现代题材:描述当前社会的具体特征(如:2024年的北京,互联网行业高速发展...) + - 历史题材:明确朝代和历史阶段(如:明朝嘉靖年间,海禁政策下的沿海地区...) + - 幻想题材:描述文明发展阶段,但要具体而非空泛(如:大陆诸国林立的战国时代,而非"XX纪元") + - 阐明时代核心矛盾和社会焦虑(要贴合主题) + +2. **location**(空间环境与地理特征) + - 描绘故事主要发生的空间环境(具体的城市、地区、场所) + - 现代题材:具体城市名或城市类型(一线城市、沿海城市、内陆小城等) + - 说明环境如何影响居民的生存方式 + - 刻画能代表世界独特性的标志性场景 + +3. **atmosphere**(感官体验与情感基调) + - 描述身临其境的感官细节(视觉、听觉、嗅觉等) + - 阐述世界的美学风格和色彩基调 + - 刻画居民普遍的心理状态和情绪氛围 + - **要与主题情感呼应**(如竞争焦虑、成长迷茫、爱情憧憬等) + +4. **rules**(世界规则与社会结构) + - 阐明世界运行的核心法则和底层逻辑 + - 现代题材:社会规则、行业潜规则、人际交往法则 + - 幻想题材:力量体系、社会等级、资源分配 + - 描述权力结构和利益格局 + - 揭示社会禁忌及违反后的后果 + +# 格式规范 +1. **纯JSON输出**:只输出JSON对象,以左花括号开始、右花括号结束 +2. **无额外标记**:不要包含markdown标记、代码块符号或任何说明文字 +3. **纯文本值**:每个字段值必须是完整的段落文本,不使用嵌套结构 +4. **无特殊符号**:文本中不使用引号、方括号等特殊符号包裹内容 +5. **丰富细节**:每个字段提供充实的原创内容,避免模板化表达 + +# 反面示例(避免这样的设定) +❌ 不好的设定:故事设定在大崩解后的XX纪元、新世界秩序、文明重启... +✅ 好的设定:故事设定在2024年的深圳,互联网创业浪潮下的年轻人... + +❌ 不好的设定:升华纪元、共鸣指数、灵光纯度...(现代都市题材不要用这些) +✅ 好的设定:通过高考分数、学历背景、家庭条件来衡量个人价值...(符合现实) + +请根据输入的类型和主题,生成**规模适当、风格匹配**的世界观设定。""" # 批量角色生成提示词 CHARACTERS_BATCH_GENERATION = """你是一位专业的角色设定师。请根据以下世界观和要求,生成{count}个立体丰满的角色和组织: @@ -199,8 +247,8 @@ class PromptService: **重要格式要求:** 1. 只返回纯JSON数组格式,不要包含任何markdown标记、代码块标记或其他说明文字 -2. 不要在JSON字符串值中使用中文引号(""''),请使用英文引号或【】《》标记 -3. 专有名词和强调内容使用【】或《》,不要用引号 +2. JSON字符串值的内容描述中严禁使用任何特殊符号(包括中文引号、英文引号、方括号、书名号等) +3. 所有专有名词、地点、人物、组织名称等直接书写,不使用任何符号包裹 请严格按照以下JSON数组格式返回(每个角色为数组中的一个对象): [ @@ -279,7 +327,7 @@ class PromptService: 1. 只返回纯JSON数组,不要有```json```这样的标记 2. 数组中必须精确包含{count}个对象 3. 不要引用任何本批次中不存在的角色或组织名称 -4. 文本描述中不要使用中文引号(""),改用【】或《》""" +4. 所有内容描述中严禁使用任何特殊符号,包括但不限于中文引号、英文引号、方括号、书名号等""" # 向导大纲生成提示词 COMPLETE_OUTLINE_GENERATION = """你是一位经验丰富的小说作家和编剧。请根据以下信息生成完整的{chapter_count}章小说大纲: @@ -315,8 +363,8 @@ class PromptService: **重要格式要求:** 1. 只返回纯JSON数组格式,不要包含任何markdown标记、代码块标记或其他说明文字 -2. 不要在JSON字符串值中使用中文引号(""''),请使用【】或《》标记 -3. 专有名词、书名、事件名使用【】或《》 +2. JSON字符串值的内容描述中严禁使用任何特殊符号(包括中文引号、英文引号、方括号、书名号等) +3. 所有专有名词、事件名等直接书写,不使用任何符号包裹 请严格按照以下JSON数组格式返回(共{chapter_count}个章节对象): [ @@ -345,7 +393,7 @@ class PromptService: 再次强调: 1. 只返回纯JSON数组,不要有```json```这样的标记 2. 数组中要包含{chapter_count}个章节对象 -3. 文本中不要使用中文引号(""),改用【】或《》""" +3. 所有内容描述中严禁使用任何特殊符号""" # 大纲续写提示词(记忆增强版) OUTLINE_CONTINUE_GENERATION = """你是一位经验丰富的小说作家和编剧。请基于以下信息续写小说大纲: @@ -396,8 +444,8 @@ class PromptService: **重要格式要求:** 1. 只返回纯JSON数组格式,不要包含任何markdown标记、代码块标记或其他说明文字 -2. 不要在JSON字符串值中使用中文引号(""''),请使用【】或《》 -3. 文本描述中的专有名词使用【】标记 +2. JSON字符串值的内容描述中严禁使用任何特殊符号(包括中文引号、英文引号、方括号、书名号等) +3. 所有专有名词直接书写,不使用任何符号包裹 请严格按照以下JSON数组格式返回(共{chapter_count}个章节对象): [ @@ -428,7 +476,7 @@ class PromptService: 2. 数组中要包含{chapter_count}个章节对象 3. 每个summary必须是100-200字的详细描述 4. 确保字段结构与已有章节完全一致 -5. 文本中不要使用中文引号(""),改用【】或《》""" +5. 所有内容描述中严禁使用任何特殊符号""" # AI去味提示词(核心特色功能) AI_DENOISING = """你是一位追求自然写作风格的编辑。你的任务是将AI生成的文本改写得更像人类作家的手笔。 @@ -593,8 +641,8 @@ class PromptService: **重要格式要求:** 1. 只返回纯JSON格式,不要包含任何markdown标记、代码块标记或其他说明文字 -2. 不要在JSON字符串值中使用中文引号(""''),改用【】或《》 -3. 专有名词和强调内容使用【】标记 +2. JSON字符串值的内容描述中严禁使用任何特殊符号(包括中文引号、英文引号、方括号、书名号等) +3. 所有专有名词直接书写,不使用任何符号包裹 请严格按照以下JSON格式返回: {{ @@ -609,7 +657,7 @@ class PromptService: 再次强调: 1. 只返回纯JSON对象,不要有```json```这样的标记 -2. 文本中不要使用中文引号(""),改用【】或《》 +2. 所有内容描述中严禁使用任何特殊符号 3. 不要有任何额外的文字说明""" # 单个角色生成提示词 @@ -655,8 +703,8 @@ class PromptService: **重要格式要求:** 1. 只返回纯JSON格式,不要包含任何markdown标记、代码块标记或其他说明文字 -2. 不要在JSON字符串值中使用中文引号(""''),改用【】或《》 -3. 文本描述中的专有名词使用【】标记 +2. JSON字符串值的内容描述中严禁使用任何特殊符号(包括中文引号、英文引号、方括号、书名号等) +3. 所有专有名词直接书写,不使用任何符号包裹 请严格按照以下JSON格式返回: {{ @@ -714,7 +762,7 @@ class PromptService: 再次强调: 1. 只返回纯JSON对象,不要有```json```这样的标记 -2. 文本中不要使用中文引号(""),改用【】或《》 +2. 所有内容描述中严禁使用任何特殊符号 3. 不要有任何额外的文字说明""" # 单个组织生成提示词 @@ -765,8 +813,8 @@ class PromptService: **重要格式要求:** 1. 只返回纯JSON格式,不要包含任何markdown标记、代码块标记或其他说明文字 -2. 不要在JSON字符串值中使用中文引号(""''),改用【】或《》 -3. 文本描述中的专有名词使用【】标记 +2. JSON字符串值的内容描述中严禁使用任何特殊符号(包括中文引号、英文引号、方括号、书名号等) +3. 所有专有名词直接书写,不使用任何符号包裹 请严格按照以下JSON格式返回: {{ @@ -799,7 +847,7 @@ class PromptService: 再次强调: 1. 只返回纯JSON对象,不要有```json```这样的标记 -2. 文本中不要使用中文引号(""),改用【】或《》 +2. 所有内容描述中严禁使用任何特殊符号 3. 不要有任何额外的文字说明""" # 大纲展开为多章节的提示词 @@ -853,8 +901,8 @@ class PromptService: **重要格式要求:** 1. 只返回纯JSON数组格式,不要包含任何markdown标记、代码块标记或其他说明文字 -2. 不要在JSON字符串值中使用中文引号(""''),请使用【】或《》 -3. 文本描述中的专有名词使用【】标记 +2. JSON字符串值的内容描述中严禁使用任何特殊符号(包括中文引号、英文引号、方括号、书名号等) +3. 所有专有名词直接书写,不使用任何符号包裹 请严格按照以下JSON数组格式输出: [ @@ -875,7 +923,7 @@ class PromptService: 1. 只返回纯JSON数组,不要有```json```这样的标记 2. 数组中要包含{target_chapters}个章节对象 3. 每个plot_summary必须是200-300字的详细描述 -4. 文本中不要使用中文引号(""),改用【】或《》""" +4. 所有内容描述中严禁使用任何特殊符号""" @staticmethod def format_prompt(template: str, **kwargs) -> str: diff --git a/frontend/package.json b/frontend/package.json index cfc1b2f..eb63205 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.0.4", + "version": "1.0.5", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/AIProjectGenerator.tsx b/frontend/src/components/AIProjectGenerator.tsx new file mode 100644 index 0000000..5798863 --- /dev/null +++ b/frontend/src/components/AIProjectGenerator.tsx @@ -0,0 +1,922 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Card, Button, Space, Typography, message, Progress } from 'antd'; +import { CheckCircleOutlined, LoadingOutlined } from '@ant-design/icons'; +import { wizardStreamApi } from '../services/api'; +import type { ApiError } from '../types'; + +const { Title, Paragraph, Text } = Typography; + +export interface GenerationConfig { + title: string; + description: string; + theme: string; + genre: string | string[]; + narrative_perspective: string; + target_words: number; + chapter_count: number; + character_count: number; +} + +interface AIProjectGeneratorProps { + config: GenerationConfig; + storagePrefix: 'wizard' | 'inspiration'; + onComplete: (projectId: string) => void; + onBack?: () => void; + isMobile?: boolean; + resumeProjectId?: string; +} + +type GenerationStep = 'pending' | 'processing' | 'completed' | 'error'; + +interface GenerationSteps { + worldBuilding: GenerationStep; + characters: GenerationStep; + outline: GenerationStep; +} + +export const AIProjectGenerator: React.FC = ({ + config, + storagePrefix, + onComplete, + isMobile = false, + resumeProjectId +}) => { + const navigate = useNavigate(); + + // 状态管理 + const [loading, setLoading] = useState(false); + const [projectId, setProjectId] = useState(''); + + // SSE流式进度状态 + const [progress, setProgress] = useState(0); + const [progressMessage, setProgressMessage] = useState(''); + const [errorDetails, setErrorDetails] = useState(''); + const [generationSteps, setGenerationSteps] = useState({ + worldBuilding: 'pending', + characters: 'pending', + outline: 'pending' + }); + + // 保存生成数据,用于重试 + const [generationData, setGenerationData] = useState(null); + // 保存世界观生成结果,用于后续步骤 + const [worldBuildingResult, setWorldBuildingResult] = useState(null); + + // LocalStorage 键名 + const storageKeys = { + projectId: `${storagePrefix}_project_id`, + generationData: `${storagePrefix}_generation_data`, + currentStep: `${storagePrefix}_current_step` + }; + + // 保存进度到localStorage + const saveProgress = (projectId: string, data: GenerationConfig, step: string) => { + try { + localStorage.setItem(storageKeys.projectId, projectId); + localStorage.setItem(storageKeys.generationData, JSON.stringify(data)); + localStorage.setItem(storageKeys.currentStep, step); + } catch (error) { + console.error('保存进度失败:', error); + } + }; + + // 清理localStorage + const clearStorage = () => { + localStorage.removeItem(storageKeys.projectId); + localStorage.removeItem(storageKeys.generationData); + localStorage.removeItem(storageKeys.currentStep); + }; + + // 开始自动化生成流程 + useEffect(() => { + if (config) { + if (resumeProjectId) { + // 恢复生成模式 + handleResumeGenerate(config, resumeProjectId); + } else { + // 新建项目模式 + handleAutoGenerate(config); + } + } + }, [config, resumeProjectId]); + + // 恢复未完成项目的生成 + const handleResumeGenerate = async (data: GenerationConfig, projectIdParam: string) => { + try { + setLoading(true); + setProgress(0); + setProgressMessage('检查项目状态...'); + setErrorDetails(''); + setGenerationData(data); + setProjectId(projectIdParam); + + // 获取项目信息,判断当前完成到哪一步 + const response = await fetch(`/api/projects/${projectIdParam}`, { + credentials: 'include' + }); + if (!response.ok) { + throw new Error('获取项目信息失败'); + } + const project = await response.json(); + const wizardStep = project.wizard_step || 0; + + // 根据wizard_step判断从哪里继续 + if (wizardStep === 0) { + // 从世界观开始 + message.info('从世界观步骤开始生成...'); + setGenerationSteps({ worldBuilding: 'processing', characters: 'pending', outline: 'pending' }); + await resumeFromWorldBuilding(data); + } else if (wizardStep === 1) { + // 世界观已完成,从角色开始 + message.info('世界观已完成,从角色步骤继续...'); + setGenerationSteps({ worldBuilding: 'completed', characters: 'processing', outline: 'pending' }); + + // 获取世界观数据 + const worldResult = { + project_id: projectIdParam, + time_period: project.world_time_period || '', + location: project.world_location || '', + atmosphere: project.world_atmosphere || '', + rules: project.world_rules || '' + }; + setWorldBuildingResult(worldResult); + setProgress(33); + + await resumeFromCharacters(data, worldResult); + } else if (wizardStep === 2) { + // 世界观和角色已完成,从大纲开始 + message.info('世界观和角色已完成,从大纲步骤继续...'); + setGenerationSteps({ worldBuilding: 'completed', characters: 'completed', outline: 'processing' }); + setProgress(66); + await resumeFromOutline(data, projectIdParam); + } else { + // 已全部完成 + message.success('项目已完成,正在跳转...'); + setProgress(100); + onComplete(projectIdParam); + setTimeout(() => { + navigate(`/project/${projectIdParam}`); + }, 1000); + } + } catch (error) { + const apiError = error as ApiError; + const errorMsg = apiError.response?.data?.detail || apiError.message || '未知错误'; + console.error('恢复生成失败:', errorMsg); + setErrorDetails(errorMsg); + message.error('恢复生成失败:' + errorMsg); + setLoading(false); + } + }; + + // 恢复:从世界观步骤开始 + const resumeFromWorldBuilding = async (data: GenerationConfig) => { + const genreString = Array.isArray(data.genre) ? data.genre.join('、') : data.genre; + + const worldResult = await wizardStreamApi.generateWorldBuildingStream( + { + title: data.title, + description: data.description, + theme: data.theme, + genre: genreString, + narrative_perspective: data.narrative_perspective, + target_words: data.target_words, + chapter_count: data.chapter_count, + character_count: data.character_count, + }, + { + onProgress: (msg, prog) => { + setProgress(Math.floor(prog / 3)); + setProgressMessage(msg); + }, + onResult: (result) => { + setWorldBuildingResult(result); + setGenerationSteps(prev => ({ ...prev, worldBuilding: 'completed' })); + }, + onError: (error) => { + console.error('世界观生成失败:', error); + setErrorDetails(`世界观生成失败: ${error}`); + setGenerationSteps(prev => ({ ...prev, worldBuilding: 'error' })); + setLoading(false); + throw new Error(error); + }, + onComplete: () => { + console.log('世界观生成完成'); + } + } + ); + + await resumeFromCharacters(data, worldResult); + }; + + // 恢复:从角色步骤继续 + const resumeFromCharacters = async (data: GenerationConfig, worldResult: any) => { + const genreString = Array.isArray(data.genre) ? data.genre.join('、') : data.genre; + const pid = projectId || worldResult.project_id; + + setGenerationSteps(prev => ({ ...prev, characters: 'processing' })); + setProgressMessage('正在生成角色...'); + + await wizardStreamApi.generateCharactersStream( + { + project_id: pid, + count: data.character_count, + world_context: { + time_period: worldResult.time_period || '', + location: worldResult.location || '', + atmosphere: worldResult.atmosphere || '', + rules: worldResult.rules || '', + }, + theme: data.theme, + genre: genreString, + }, + { + onProgress: (msg, prog) => { + setProgress(33 + Math.floor(prog / 3)); + setProgressMessage(msg); + }, + onResult: (result) => { + console.log(`成功生成${result.characters?.length || 0}个角色`); + setGenerationSteps(prev => ({ ...prev, characters: 'completed' })); + }, + onError: (error) => { + console.error('角色生成失败:', error); + setErrorDetails(`角色生成失败: ${error}`); + setGenerationSteps(prev => ({ ...prev, characters: 'error' })); + setLoading(false); + throw new Error(error); + }, + onComplete: () => { + console.log('角色生成完成'); + } + } + ); + + await resumeFromOutline(data, pid); + }; + + // 恢复:从大纲步骤继续 + const resumeFromOutline = async (data: GenerationConfig, pid: string) => { + setGenerationSteps(prev => ({ ...prev, outline: 'processing' })); + setProgressMessage('正在生成大纲...'); + + await wizardStreamApi.generateCompleteOutlineStream( + { + project_id: pid, + chapter_count: data.chapter_count, + narrative_perspective: data.narrative_perspective, + target_words: data.target_words, + }, + { + onProgress: (msg, prog) => { + setProgress(66 + Math.floor(prog / 3)); + setProgressMessage(msg); + }, + onResult: () => { + console.log('大纲生成完成'); + setGenerationSteps(prev => ({ ...prev, outline: 'completed' })); + }, + onError: (error) => { + console.error('大纲生成失败:', error); + setErrorDetails(`大纲生成失败: ${error}`); + setGenerationSteps(prev => ({ ...prev, outline: 'error' })); + setLoading(false); + throw new Error(error); + }, + onComplete: () => { + console.log('大纲生成完成'); + } + } + ); + + // 全部完成 + setProgress(100); + setProgressMessage('项目创建完成!正在跳转...'); + message.success('项目创建成功!正在进入项目...'); + clearStorage(); + setLoading(false); + + onComplete(pid); + setTimeout(() => { + navigate(`/project/${pid}`); + }, 1000); + }; + + // 自动化生成流程 + const handleAutoGenerate = async (data: GenerationConfig) => { + try { + setLoading(true); + setProgress(0); + setProgressMessage('开始创建项目...'); + setErrorDetails(''); + setGenerationData(data); + saveProgress('', data, 'generating'); + + const genreString = Array.isArray(data.genre) ? data.genre.join('、') : data.genre; + + // 步骤1: 生成世界观并创建项目 + setGenerationSteps(prev => ({ ...prev, worldBuilding: 'processing' })); + setProgressMessage('正在生成世界观...'); + + const worldResult = await wizardStreamApi.generateWorldBuildingStream( + { + title: data.title, + description: data.description, + theme: data.theme, + genre: genreString, + narrative_perspective: data.narrative_perspective, + target_words: data.target_words, + chapter_count: data.chapter_count, + character_count: data.character_count, + }, + { + onProgress: (msg, prog) => { + setProgress(Math.floor(prog / 3)); + setProgressMessage(msg); + }, + onResult: (result) => { + setProjectId(result.project_id); + setWorldBuildingResult(result); + setGenerationSteps(prev => ({ ...prev, worldBuilding: 'completed' })); + }, + onError: (error) => { + console.error('世界观生成失败:', error); + setErrorDetails(`世界观生成失败: ${error}`); + setGenerationSteps(prev => ({ ...prev, worldBuilding: 'error' })); + setLoading(false); + throw new Error(error); + }, + onComplete: () => { + console.log('世界观生成完成'); + } + } + ); + + if (!worldResult?.project_id) { + throw new Error('项目创建失败:未获取到项目ID'); + } + + const createdProjectId = worldResult.project_id; + setProjectId(createdProjectId); + setWorldBuildingResult(worldResult); + saveProgress(createdProjectId, data, 'generating'); + + // 步骤2: 生成角色 + setGenerationSteps(prev => ({ ...prev, characters: 'processing' })); + setProgressMessage('正在生成角色...'); + + await wizardStreamApi.generateCharactersStream( + { + project_id: createdProjectId, + count: data.character_count, + world_context: { + time_period: worldResult.time_period || '', + location: worldResult.location || '', + atmosphere: worldResult.atmosphere || '', + rules: worldResult.rules || '', + }, + theme: data.theme, + genre: genreString, + }, + { + onProgress: (msg, prog) => { + setProgress(33 + Math.floor(prog / 3)); + setProgressMessage(msg); + }, + onResult: (result) => { + console.log(`成功生成${result.characters?.length || 0}个角色`); + setGenerationSteps(prev => ({ ...prev, characters: 'completed' })); + }, + onError: (error) => { + console.error('角色生成失败:', error); + setErrorDetails(`角色生成失败: ${error}`); + setGenerationSteps(prev => ({ ...prev, characters: 'error' })); + setLoading(false); + throw new Error(error); + }, + onComplete: () => { + console.log('角色生成完成'); + } + } + ); + + // 步骤3: 生成大纲 + setGenerationSteps(prev => ({ ...prev, outline: 'processing' })); + setProgressMessage('正在生成大纲...'); + + await wizardStreamApi.generateCompleteOutlineStream( + { + project_id: createdProjectId, + chapter_count: data.chapter_count, + narrative_perspective: data.narrative_perspective, + target_words: data.target_words, + }, + { + onProgress: (msg, prog) => { + setProgress(66 + Math.floor(prog / 3)); + setProgressMessage(msg); + }, + onResult: () => { + console.log('大纲生成完成'); + setGenerationSteps(prev => ({ ...prev, outline: 'completed' })); + }, + onError: (error) => { + console.error('大纲生成失败:', error); + setErrorDetails(`大纲生成失败: ${error}`); + setGenerationSteps(prev => ({ ...prev, outline: 'error' })); + setLoading(false); + throw new Error(error); + }, + onComplete: () => { + console.log('大纲生成完成'); + } + } + ); + + // 全部完成 - 自动跳转到项目详情页 + setProgress(100); + setProgressMessage('项目创建完成!正在跳转...'); + message.success('项目创建成功!正在进入项目...'); + clearStorage(); + + // 调用完成回调 + onComplete(createdProjectId); + + // 延迟1秒后自动跳转到项目详情页 + setTimeout(() => { + navigate(`/project/${createdProjectId}`); + }, 1000); + + } catch (error) { + const apiError = error as ApiError; + const errorMsg = apiError.response?.data?.detail || apiError.message || '未知错误'; + console.error('创建项目失败:', errorMsg); + setErrorDetails(errorMsg); + message.error('创建项目失败:' + errorMsg); + setLoading(false); + } + }; + + // 智能重试:从失败的步骤继续生成 + const handleSmartRetry = async () => { + if (!generationData) { + message.warning('缺少生成数据'); + return; + } + + setLoading(true); + setErrorDetails(''); + + try { + if (generationSteps.worldBuilding === 'error') { + message.info('从世界观步骤开始重新生成...'); + await retryFromWorldBuilding(); + } else if (generationSteps.characters === 'error') { + message.info('从角色步骤继续生成...'); + await retryFromCharacters(); + } else if (generationSteps.outline === 'error') { + message.info('从大纲步骤继续生成...'); + await retryFromOutline(); + } + } catch (error: any) { + console.error('智能重试失败:', error); + message.error('重试失败:' + (error.message || '未知错误')); + setLoading(false); + } + }; + + // 从世界观步骤重新开始 + const retryFromWorldBuilding = async () => { + if (!generationData) return; + + setGenerationSteps(prev => ({ ...prev, worldBuilding: 'processing' })); + setProgressMessage('重新生成世界观...'); + + const genreString = Array.isArray(generationData.genre) ? generationData.genre.join('、') : generationData.genre; + + const worldResult = await wizardStreamApi.generateWorldBuildingStream( + { + title: generationData.title, + description: generationData.description, + theme: generationData.theme, + genre: genreString, + narrative_perspective: generationData.narrative_perspective, + target_words: generationData.target_words, + chapter_count: generationData.chapter_count, + character_count: generationData.character_count, + }, + { + onProgress: (msg, prog) => { + setProgress(Math.floor(prog / 3)); + setProgressMessage(msg); + }, + onResult: (result) => { + setProjectId(result.project_id); + setWorldBuildingResult(result); + setGenerationSteps(prev => ({ ...prev, worldBuilding: 'completed' })); + }, + onError: (error) => { + console.error('世界观生成失败:', error); + setErrorDetails(`世界观生成失败: ${error}`); + setGenerationSteps(prev => ({ ...prev, worldBuilding: 'error' })); + setLoading(false); + throw new Error(error); + }, + onComplete: () => { + console.log('世界观重新生成完成'); + } + } + ); + + if (!worldResult?.project_id) { + throw new Error('项目创建失败:未获取到项目ID'); + } + + await continueFromCharacters(worldResult); + }; + + // 从角色步骤继续 + const retryFromCharacters = async () => { + if (!generationData || !projectId || !worldBuildingResult) { + message.warning('缺少必要数据,无法从角色步骤继续'); + setLoading(false); + return; + } + + setGenerationSteps(prev => ({ ...prev, characters: 'processing' })); + setProgressMessage('重新生成角色...'); + + const genreString = Array.isArray(generationData.genre) ? generationData.genre.join('、') : generationData.genre; + + await wizardStreamApi.generateCharactersStream( + { + project_id: projectId, + count: generationData.character_count, + world_context: { + time_period: worldBuildingResult.time_period || '', + location: worldBuildingResult.location || '', + atmosphere: worldBuildingResult.atmosphere || '', + rules: worldBuildingResult.rules || '', + }, + theme: generationData.theme, + genre: genreString, + }, + { + onProgress: (msg, prog) => { + setProgress(33 + Math.floor(prog / 3)); + setProgressMessage(msg); + }, + onResult: (result) => { + console.log(`成功生成${result.characters?.length || 0}个角色`); + setGenerationSteps(prev => ({ ...prev, characters: 'completed' })); + }, + onError: (error) => { + console.error('角色生成失败:', error); + setErrorDetails(`角色生成失败: ${error}`); + setGenerationSteps(prev => ({ ...prev, characters: 'error' })); + setLoading(false); + throw new Error(error); + }, + onComplete: () => { + console.log('角色重新生成完成'); + } + } + ); + + await continueFromOutline(); + }; + + // 从大纲步骤继续 + const retryFromOutline = async () => { + if (!generationData || !projectId) { + message.warning('缺少必要数据,无法从大纲步骤继续'); + setLoading(false); + return; + } + + setGenerationSteps(prev => ({ ...prev, outline: 'processing' })); + setProgressMessage('重新生成大纲...'); + + await wizardStreamApi.generateCompleteOutlineStream( + { + project_id: projectId, + chapter_count: generationData.chapter_count, + narrative_perspective: generationData.narrative_perspective, + target_words: generationData.target_words, + }, + { + onProgress: (msg, prog) => { + setProgress(66 + Math.floor(prog / 3)); + setProgressMessage(msg); + }, + onResult: () => { + console.log('大纲生成完成'); + setGenerationSteps(prev => ({ ...prev, outline: 'completed' })); + }, + onError: (error) => { + console.error('大纲生成失败:', error); + setErrorDetails(`大纲生成失败: ${error}`); + setGenerationSteps(prev => ({ ...prev, outline: 'error' })); + setLoading(false); + throw new Error(error); + }, + onComplete: () => { + console.log('大纲重新生成完成'); + } + } + ); + + setProgress(100); + setProgressMessage('项目创建完成!正在跳转...'); + message.success('项目创建成功!正在进入项目...'); + setLoading(false); + + // 调用完成回调 + if (projectId) { + onComplete(projectId); + + // 延迟1秒后自动跳转到项目详情页 + setTimeout(() => { + navigate(`/project/${projectId}`); + }, 1000); + } + }; + + // 从角色步骤开始的完整流程 + const continueFromCharacters = async (worldResult: any) => { + if (!generationData || !worldResult?.project_id) return; + + const genreString = Array.isArray(generationData.genre) ? generationData.genre.join('、') : generationData.genre; + + setGenerationSteps(prev => ({ ...prev, characters: 'processing' })); + setProgressMessage('正在生成角色...'); + + await wizardStreamApi.generateCharactersStream( + { + project_id: worldResult.project_id, + count: generationData.character_count, + world_context: { + time_period: worldResult.time_period || '', + location: worldResult.location || '', + atmosphere: worldResult.atmosphere || '', + rules: worldResult.rules || '', + }, + theme: generationData.theme, + genre: genreString, + }, + { + onProgress: (msg, prog) => { + setProgress(33 + Math.floor(prog / 3)); + setProgressMessage(msg); + }, + onResult: (result) => { + console.log(`成功生成${result.characters?.length || 0}个角色`); + setGenerationSteps(prev => ({ ...prev, characters: 'completed' })); + }, + onError: (error) => { + console.error('角色生成失败:', error); + setErrorDetails(`角色生成失败: ${error}`); + setGenerationSteps(prev => ({ ...prev, characters: 'error' })); + setLoading(false); + throw new Error(error); + }, + onComplete: () => { + console.log('角色生成完成'); + } + } + ); + + await continueFromOutline(); + }; + + // 从大纲步骤开始的完整流程 + const continueFromOutline = async () => { + if (!generationData || !projectId) return; + + setGenerationSteps(prev => ({ ...prev, outline: 'processing' })); + setProgressMessage('正在生成大纲...'); + + await wizardStreamApi.generateCompleteOutlineStream( + { + project_id: projectId, + chapter_count: generationData.chapter_count, + narrative_perspective: generationData.narrative_perspective, + target_words: generationData.target_words, + }, + { + onProgress: (msg, prog) => { + setProgress(66 + Math.floor(prog / 3)); + setProgressMessage(msg); + }, + onResult: () => { + console.log('大纲生成完成'); + setGenerationSteps(prev => ({ ...prev, outline: 'completed' })); + }, + onError: (error) => { + console.error('大纲生成失败:', error); + setErrorDetails(`大纲生成失败: ${error}`); + setGenerationSteps(prev => ({ ...prev, outline: 'error' })); + setLoading(false); + throw new Error(error); + }, + onComplete: () => { + console.log('大纲生成完成'); + } + } + ); + + setProgress(100); + setProgressMessage('项目创建完成!正在跳转...'); + message.success('项目创建成功!正在进入项目...'); + setLoading(false); + + // 调用完成回调 + if (projectId) { + onComplete(projectId); + + // 延迟1秒后自动跳转到项目详情页 + setTimeout(() => { + navigate(`/project/${projectId}`); + }, 1000); + } + }; + + + // 获取步骤状态图标和颜色 + const getStepStatus = (step: GenerationStep) => { + if (step === 'completed') return { icon: , color: '#52c41a' }; + if (step === 'processing') return { icon: , color: '#1890ff' }; + if (step === 'error') return { icon: '✗', color: '#ff4d4f' }; + return { icon: '○', color: '#d9d9d9' }; + }; + + const hasError = generationSteps.worldBuilding === 'error' || + generationSteps.characters === 'error' || + generationSteps.outline === 'error'; + + // 渲染生成进度页面 + const renderGenerating = () => ( +
+ + 正在为《{config.title}》生成内容 + + + + + + + {progressMessage} + + + {errorDetails && ( + + 错误详情: +
+ + {errorDetails} + +
+ )} + + + {[ + { key: 'worldBuilding', label: '生成世界观', step: generationSteps.worldBuilding }, + { key: 'characters', label: '生成角色', step: generationSteps.characters }, + { key: 'outline', label: '生成大纲', step: generationSteps.outline }, + ].map(({ key, label, step }) => { + const status = getStepStatus(step); + return ( +
+ + {label} + + + {status.icon} + +
+ ); + })} +
+
+ + + {hasError ? '生成过程中出现错误,请点击重试按钮重新生成' : '请耐心等待,AI正在为您精心创作...'} + + + {hasError && ( + + + + )} + +
+ ); + + return renderGenerating(); +}; \ No newline at end of file diff --git a/frontend/src/components/ChapterRegenerationModal.tsx b/frontend/src/components/ChapterRegenerationModal.tsx index 08d5ef6..560906f 100644 --- a/frontend/src/components/ChapterRegenerationModal.tsx +++ b/frontend/src/components/ChapterRegenerationModal.tsx @@ -9,7 +9,6 @@ import { Space, Alert, Divider, - Progress, Tag, message, Collapse, @@ -22,6 +21,7 @@ import { CloseCircleOutlined } from '@ant-design/icons'; import { ssePost } from '../utils/sseClient'; +import { SSEProgressModal } from './SSEProgressModal'; const { TextArea } = Input; const { Panel } = Collapse; @@ -242,22 +242,6 @@ const ChapterRegenerationModal: React.FC = ({ ) } > - {status === 'generating' && ( - - -
- 已生成 {wordCount} 字 -
-
- } - type="info" - showIcon - style={{ marginBottom: 16 }} - /> - )} {status === 'success' && ( = ({ + {/* 使用统一的进度显示组件 */} + ); }; diff --git a/frontend/src/components/SSEProgressModal.tsx b/frontend/src/components/SSEProgressModal.tsx new file mode 100644 index 0000000..741a40e --- /dev/null +++ b/frontend/src/components/SSEProgressModal.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { Modal, Spin } from 'antd'; +import { LoadingOutlined } from '@ant-design/icons'; + +interface SSEProgressModalProps { + visible: boolean; + progress: number; + message: string; + title?: string; + showPercentage?: boolean; + showIcon?: boolean; +} + +/** + * 统一的SSE进度显示Modal组件 + * 用于在Modal中显示AI生成进度,样式与SSELoadingOverlay保持一致 + */ +export const SSEProgressModal: React.FC = ({ + visible, + progress, + message, + title = 'AI生成中...', + showPercentage = true, + showIcon = true, +}) => { + if (!visible) return null; + + return ( + +
+ {/* 标题和图标 */} + {showIcon && ( +
+ } + /> +
+ {title} +
+
+ )} + + {/* 进度条 */} +
+
+
0 ? '0 0 10px rgba(24, 144, 255, 0.3)' : 'none' + }} /> +
+ + {/* 进度百分比 */} + {showPercentage && ( +
+ {progress}% +
+ )} +
+ + {/* 状态消息 */} +
+ {message || '准备生成...'} +
+ + {/* 提示文字 */} +
+ 请勿关闭页面,生成过程需要一定时间 +
+
+ + ); +}; + +export default SSEProgressModal; \ No newline at end of file diff --git a/frontend/src/pages/Chapters.tsx b/frontend/src/pages/Chapters.tsx index 676749d..9b94df5 100644 --- a/frontend/src/pages/Chapters.tsx +++ b/frontend/src/pages/Chapters.tsx @@ -1,5 +1,5 @@ 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, Popconfirm, FloatButton } from 'antd'; +import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip, InputNumber, Alert, Radio, Descriptions, Collapse, Popconfirm, FloatButton } from 'antd'; import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined, InfoCircleOutlined, CaretRightOutlined, DeleteOutlined, BookOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { useChapterSync } from '../store/hooks'; @@ -7,6 +7,7 @@ import { projectApi, writingStyleApi } from '../services/api'; import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask, ExpansionPlanData } from '../types'; import ChapterAnalysis from '../components/ChapterAnalysis'; import { SSELoadingOverlay } from '../components/SSELoadingOverlay'; +import { SSEProgressModal } from '../components/SSEProgressModal'; import FloatingIndexPanel from '../components/FloatingIndexPanel'; const { TextArea } = Input; @@ -748,43 +749,129 @@ export default function Chapters() { Modal.info({ title: ( - + - 第{chapter.chapter_number}章展开规划 + 第{chapter.chapter_number}章展开规划 ), - width: 800, + width: isMobile ? '95%' : 800, + centered: true, + style: isMobile ? { + top: 20, + maxWidth: 'calc(100vw - 16px)', + margin: '0 8px' + } : undefined, + styles: { + body: { + maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(80vh - 110px)', + overflowY: 'auto' + } + }, content: (
- + - {chapter.title} + + {chapter.title} + - {planData.emotional_tone} + + {planData.emotional_tone} + - {planData.conflict_type} + + {planData.conflict_type} + {planData.estimated_words}字 - {planData.narrative_goal} + + {planData.narrative_goal} + {planData.key_events.map((event, idx) => ( -
- {idx + 1} {event} +
+ {idx + 1}{' '} + + {event} +
))} - + {planData.character_focus.map((char, idx) => ( - {char} + + {char} + ))} @@ -792,20 +879,68 @@ export default function Chapters() { {planData.scenes.map((scene, idx) => ( - -
- 📍 地点:{scene.location} + +
+ 📍 地点: + + {scene.location} +
👥 角色: - + {scene.characters.map((char, charIdx) => ( - {char} + + {char} + ))}
-
- 🎯 目的:{scene.purpose} +
+ 🎯 目的: + + {scene.purpose} +
))} @@ -1590,42 +1725,6 @@ export default function Chapters() { ) : (
-
-
- 生成进度: - - - {batchProgress?.completed || 0} / {batchProgress?.total || 0} - - 章 - -
- -
- - {batchProgress?.current_chapter_number && ( - } - style={{ marginBottom: 16 }} - /> - )} - - {batchProgress?.estimated_time_minutes && batchProgress.completed === 0 && ( -
- ⏱️ 预计耗时:约 {batchProgress.estimated_time_minutes} 分钟 -
- )} - 批量生成需要一定时间,可以切换到其他页面
  • 关闭页面后重新打开,会自动恢复任务进度
  • 可以随时点击"取消任务"按钮中止生成
  • + {batchProgress?.estimated_time_minutes && batchProgress.completed === 0 && ( +
  • ⏱️ 预计耗时:约 {batchProgress.estimated_time_minutes} 分钟
  • + )} } - type="warning" + type="info" showIcon style={{ marginBottom: 16 }} /> @@ -1669,6 +1771,18 @@ export default function Chapters() { message={singleChapterProgressMessage} /> + {/* 批量生成进度显示 - 使用统一的进度组件 */} + + } type="primary" diff --git a/frontend/src/pages/Inspiration.tsx b/frontend/src/pages/Inspiration.tsx index 97cd652..faf2cf9 100644 --- a/frontend/src/pages/Inspiration.tsx +++ b/frontend/src/pages/Inspiration.tsx @@ -1,9 +1,9 @@ import React, { useState, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Card, Input, Button, Space, Typography, message, Spin, Progress } from 'antd'; -import { SendOutlined, ArrowLeftOutlined, CheckCircleOutlined, LoadingOutlined, RocketOutlined } from '@ant-design/icons'; -import { inspirationApi, wizardStreamApi } from '../services/api'; -import type { ApiError } from '../types'; +import { Card, Input, Button, Space, Typography, message, Spin } from 'antd'; +import { SendOutlined, ArrowLeftOutlined } from '@ant-design/icons'; +import { inspirationApi } from '../services/api'; +import { AIProjectGenerator, type GenerationConfig } from '../components/AIProjectGenerator'; const { Title, Text, Paragraph } = Typography; const { TextArea } = Input; @@ -43,26 +43,8 @@ const Inspiration: React.FC = () => { // 保存用户的原始想法,用于保持上下文一致性 const [initialIdea, setInitialIdea] = useState(''); - // 项目生成状态 - const [projectId, setProjectId] = useState(''); - const [projectTitle, setProjectTitle] = useState(''); - const [progress, setProgress] = useState(0); - const [progressMessage, setProgressMessage] = useState(''); - const [errorDetails, setErrorDetails] = useState(''); // 新增:错误详情 - const [generationSteps, setGenerationSteps] = useState<{ - worldBuilding: 'pending' | 'processing' | 'completed' | 'error'; - characters: 'pending' | 'processing' | 'completed' | 'error'; - outline: 'pending' | 'processing' | 'completed' | 'error'; - }>({ - worldBuilding: 'pending', - characters: 'pending', - outline: 'pending' - }); - - // 新增:保存生成数据,用于重试 - const [generationData, setGenerationData] = useState(null); - // 保存世界观生成结果,用于后续步骤 - const [worldBuildingResult, setWorldBuildingResult] = useState(null); + // 生成配置 + const [generationConfig, setGenerationConfig] = useState(null); // 滚动容器引用 const messagesEndRef = useRef(null); @@ -74,9 +56,8 @@ const Inspiration: React.FC = () => { context: Partial; } | null>(null); - // 自动滚动到底部 - 使用更丝滑的方式 + // 自动滚动到底部 const scrollToBottom = () => { - // 使用 setTimeout 确保 DOM 已更新 setTimeout(() => { if (chatContainerRef.current) { chatContainerRef.current.scrollTo({ @@ -108,7 +89,6 @@ const Inspiration: React.FC = () => { return; } - // 移除失败消息,添加成功的AI消息 setMessages(prev => { const newMessages = [...prev]; if (newMessages[newMessages.length - 1].type === 'ai' && @@ -156,7 +136,6 @@ const Inspiration: React.FC = () => { try { if (currentStep === 'idea') { - // 保存用户的原始想法 setInitialIdea(userInput); const requestData = { @@ -169,7 +148,6 @@ const Inspiration: React.FC = () => { const response = await inspirationApi.generateOptions(requestData); - // 前端格式校验:检查是否有错误或选项数量不足 if (response.error || !response.options || response.options.length < 3) { const errorMessage: Message = { type: 'ai', @@ -222,7 +200,6 @@ const Inspiration: React.FC = () => { } if (currentStep === 'perspective') { - // 叙事视角是单选 const userMessage: Message = { type: 'user', content: option, @@ -232,7 +209,6 @@ const Inspiration: React.FC = () => { const updatedData = { ...wizardData, narrative_perspective: option, genre: wizardData.genre || [] } as WizardData; setWizardData(updatedData); - // 显示预览和确认选项 const summary = ` 太棒了!你的小说设定已完成,请确认: @@ -270,7 +246,19 @@ const Inspiration: React.FC = () => { setMessages(prev => [...prev, aiMessage]); // 开始生成项目 - await handleAutoGenerate(wizardData as WizardData); + const data = wizardData as WizardData; + const config: GenerationConfig = { + title: data.title, + description: data.description, + theme: data.theme, + genre: data.genre, + narrative_perspective: data.narrative_perspective, + target_words: 100000, + chapter_count: 3, + character_count: 5, + }; + setGenerationConfig(config); + setCurrentStep('generating'); return; } else if (option === '🔄 重新开始') { handleRestart(); @@ -332,439 +320,6 @@ const Inspiration: React.FC = () => { } }; - // 自动化生成项目流程 - const handleAutoGenerate = async (data: WizardData) => { - try { - setLoading(true); - setCurrentStep('generating'); - setProjectTitle(data.title); - setProgress(0); - setProgressMessage('开始创建项目...'); - setErrorDetails(''); // 清空错误详情 - setGenerationData(data); // 保存数据用于重试 - - // 步骤1: 生成世界观并创建项目 - setGenerationSteps(prev => ({ ...prev, worldBuilding: 'processing' })); - setProgressMessage('正在生成世界观...'); - - const worldResult = await wizardStreamApi.generateWorldBuildingStream( - { - title: data.title, - description: data.description, - theme: data.theme, - genre: data.genre.join('、'), - narrative_perspective: data.narrative_perspective, - target_words: 100000, - chapter_count: 5, - character_count: 5, - }, - { - onProgress: (msg, prog) => { - setProgress(Math.floor(prog / 3)); - setProgressMessage(msg); - }, - onResult: (result) => { - setProjectId(result.project_id); - setWorldBuildingResult(result); // 保存世界观结果 - setGenerationSteps(prev => ({ ...prev, worldBuilding: 'completed' })); - }, - onError: (error) => { - console.error('世界观生成失败:', error); - setErrorDetails(`世界观生成失败: ${error}`); - setGenerationSteps(prev => ({ ...prev, worldBuilding: 'error' })); - setLoading(false); // 确保错误时解除加载状态 - throw new Error(error); - }, - onComplete: () => { - console.log('世界观生成完成'); - } - } - ); - - if (!worldResult?.project_id) { - throw new Error('项目创建失败:未获取到项目ID'); - } - - const createdProjectId = worldResult.project_id; - setProjectId(createdProjectId); - setWorldBuildingResult(worldResult); // 保存世界观结果 - - // 步骤2: 生成角色 - setGenerationSteps(prev => ({ ...prev, characters: 'processing' })); - setProgressMessage('正在生成角色...'); - - await wizardStreamApi.generateCharactersStream( - { - project_id: createdProjectId, - count: 5, - world_context: { - time_period: worldResult.time_period || '', - location: worldResult.location || '', - atmosphere: worldResult.atmosphere || '', - rules: worldResult.rules || '', - }, - theme: data.theme, - genre: data.genre.join('、'), - }, - { - onProgress: (msg, prog) => { - setProgress(33 + Math.floor(prog / 3)); - setProgressMessage(msg); - }, - onResult: (result) => { - console.log(`成功生成${result.characters?.length || 0}个角色`); - setGenerationSteps(prev => ({ ...prev, characters: 'completed' })); - }, - onError: (error) => { - console.error('角色生成失败:', error); - setErrorDetails(`角色生成失败: ${error}`); - setGenerationSteps(prev => ({ ...prev, characters: 'error' })); - setLoading(false); // 确保错误时解除加载状态 - throw new Error(error); - }, - onComplete: () => { - console.log('角色生成完成'); - } - } - ); - - // 步骤3: 生成大纲 - setGenerationSteps(prev => ({ ...prev, outline: 'processing' })); - setProgressMessage('正在生成大纲...'); - - await wizardStreamApi.generateCompleteOutlineStream( - { - project_id: createdProjectId, - chapter_count: 3, - narrative_perspective: data.narrative_perspective, - target_words: 100000, - }, - { - onProgress: (msg, prog) => { - setProgress(66 + Math.floor(prog / 3)); - setProgressMessage(msg); - }, - onResult: () => { - console.log('大纲生成完成'); - setGenerationSteps(prev => ({ ...prev, outline: 'completed' })); - }, - onError: (error) => { - console.error('大纲生成失败:', error); - setErrorDetails(`大纲生成失败: ${error}`); - setGenerationSteps(prev => ({ ...prev, outline: 'error' })); - setLoading(false); // 确保错误时解除加载状态 - throw new Error(error); - }, - onComplete: () => { - console.log('大纲生成完成'); - } - } - ); - - // 全部完成 - setProgress(100); - setProgressMessage('项目创建完成!'); - setCurrentStep('complete'); - message.success('项目创建成功!'); - - } catch (error) { - const apiError = error as ApiError; - const errorMsg = apiError.response?.data?.detail || apiError.message || '未知错误'; - console.error('创建项目失败:', errorMsg); - setErrorDetails(errorMsg); - message.error('创建项目失败:' + errorMsg); - // 不重置步骤,保持在generating状态以显示重试按钮 - setLoading(false); // 确保在错误时也设置loading为false - } - }; - - // 智能重试:从失败的步骤继续生成 - const handleSmartRetry = async () => { - if (!generationData) { - message.warning('缺少生成数据'); - return; - } - - setLoading(true); - setErrorDetails(''); - - try { - // 判断从哪个步骤开始重试 - if (generationSteps.worldBuilding === 'error') { - // 世界观失败,从世界观开始重新生成 - message.info('从世界观步骤开始重新生成...'); - await retryFromWorldBuilding(); - } else if (generationSteps.characters === 'error') { - // 角色失败,从角色开始生成 - message.info('从角色步骤继续生成...'); - await retryFromCharacters(); - } else if (generationSteps.outline === 'error') { - // 大纲失败,从大纲开始生成 - message.info('从大纲步骤继续生成...'); - await retryFromOutline(); - } - } catch (error: any) { - console.error('智能重试失败:', error); - message.error('重试失败:' + (error.message || '未知错误')); - setLoading(false); - } - }; - - // 从世界观步骤重新开始 - const retryFromWorldBuilding = async () => { - if (!generationData) return; - - setGenerationSteps(prev => ({ ...prev, worldBuilding: 'processing' })); - setProgressMessage('重新生成世界观...'); - - try { - const worldResult = await wizardStreamApi.generateWorldBuildingStream( - { - title: generationData.title, - description: generationData.description, - theme: generationData.theme, - genre: generationData.genre.join('、'), - narrative_perspective: generationData.narrative_perspective, - target_words: 100000, - chapter_count: 5, - character_count: 5, - }, - { - onProgress: (msg, prog) => { - setProgress(Math.floor(prog / 3)); - setProgressMessage(msg); - }, - onResult: (result) => { - setProjectId(result.project_id); - setWorldBuildingResult(result); - setGenerationSteps(prev => ({ ...prev, worldBuilding: 'completed' })); - }, - onError: (error) => { - console.error('世界观生成失败:', error); - setErrorDetails(`世界观生成失败: ${error}`); - setGenerationSteps(prev => ({ ...prev, worldBuilding: 'error' })); - setLoading(false); - throw new Error(error); - }, - onComplete: () => { - console.log('世界观重新生成完成'); - } - } - ); - - if (!worldResult?.project_id) { - throw new Error('项目创建失败:未获取到项目ID'); - } - - // 继续生成角色和大纲 - await continueFromCharacters(worldResult); - } catch (error: any) { - throw error; - } - }; - - // 从角色步骤继续 - const retryFromCharacters = async () => { - if (!generationData || !projectId || !worldBuildingResult) { - message.warning('缺少必要数据,无法从角色步骤继续'); - setLoading(false); - return; - } - - setGenerationSteps(prev => ({ ...prev, characters: 'processing' })); - setProgressMessage('重新生成角色...'); - - try { - await wizardStreamApi.generateCharactersStream( - { - project_id: projectId, - count: 5, - world_context: { - time_period: worldBuildingResult.time_period || '', - location: worldBuildingResult.location || '', - atmosphere: worldBuildingResult.atmosphere || '', - rules: worldBuildingResult.rules || '', - }, - theme: generationData.theme, - genre: generationData.genre.join('、'), - }, - { - onProgress: (msg, prog) => { - setProgress(33 + Math.floor(prog / 3)); - setProgressMessage(msg); - }, - onResult: (result) => { - console.log(`成功生成${result.characters?.length || 0}个角色`); - setGenerationSteps(prev => ({ ...prev, characters: 'completed' })); - }, - onError: (error) => { - console.error('角色生成失败:', error); - setErrorDetails(`角色生成失败: ${error}`); - setGenerationSteps(prev => ({ ...prev, characters: 'error' })); - setLoading(false); - throw new Error(error); - }, - onComplete: () => { - console.log('角色重新生成完成'); - } - } - ); - - // 继续生成大纲 - await continueFromOutline(); - } catch (error: any) { - throw error; - } - }; - - // 从大纲步骤继续 - const retryFromOutline = async () => { - if (!generationData || !projectId) { - message.warning('缺少必要数据,无法从大纲步骤继续'); - setLoading(false); - return; - } - - setGenerationSteps(prev => ({ ...prev, outline: 'processing' })); - setProgressMessage('重新生成大纲...'); - - try { - await wizardStreamApi.generateCompleteOutlineStream( - { - project_id: projectId, - chapter_count: 5, - narrative_perspective: generationData.narrative_perspective, - target_words: 100000, - }, - { - onProgress: (msg, prog) => { - setProgress(66 + Math.floor(prog / 3)); - setProgressMessage(msg); - }, - onResult: () => { - console.log('大纲生成完成'); - setGenerationSteps(prev => ({ ...prev, outline: 'completed' })); - }, - onError: (error) => { - console.error('大纲生成失败:', error); - setErrorDetails(`大纲生成失败: ${error}`); - setGenerationSteps(prev => ({ ...prev, outline: 'error' })); - setLoading(false); - throw new Error(error); - }, - onComplete: () => { - console.log('大纲重新生成完成'); - } - } - ); - - // 全部完成 - setProgress(100); - setProgressMessage('项目创建完成!'); - setCurrentStep('complete'); - message.success('项目创建成功!'); - setLoading(false); - } catch (error: any) { - throw error; - } - }; - - // 从角色步骤开始的完整流程(世界观成功后调用) - const continueFromCharacters = async (worldResult: any) => { - if (!generationData || !worldResult?.project_id) return; - - try { - // 生成角色 - setGenerationSteps(prev => ({ ...prev, characters: 'processing' })); - setProgressMessage('正在生成角色...'); - - await wizardStreamApi.generateCharactersStream( - { - project_id: worldResult.project_id, - count: 5, - world_context: { - time_period: worldResult.time_period || '', - location: worldResult.location || '', - atmosphere: worldResult.atmosphere || '', - rules: worldResult.rules || '', - }, - theme: generationData.theme, - genre: generationData.genre.join('、'), - }, - { - onProgress: (msg, prog) => { - setProgress(33 + Math.floor(prog / 3)); - setProgressMessage(msg); - }, - onResult: (result) => { - console.log(`成功生成${result.characters?.length || 0}个角色`); - setGenerationSteps(prev => ({ ...prev, characters: 'completed' })); - }, - onError: (error) => { - console.error('角色生成失败:', error); - setErrorDetails(`角色生成失败: ${error}`); - setGenerationSteps(prev => ({ ...prev, characters: 'error' })); - setLoading(false); - throw new Error(error); - }, - onComplete: () => { - console.log('角色生成完成'); - } - } - ); - - // 生成大纲 - await continueFromOutline(); - } catch (error: any) { - console.error('继续生成失败:', error); - throw error; - } - }; - - // 从大纲步骤开始的完整流程(角色成功后调用) - const continueFromOutline = async () => { - if (!generationData || !projectId) return; - - setGenerationSteps(prev => ({ ...prev, outline: 'processing' })); - setProgressMessage('正在生成大纲...'); - - await wizardStreamApi.generateCompleteOutlineStream( - { - project_id: projectId, - chapter_count: 5, - narrative_perspective: generationData.narrative_perspective, - target_words: 100000, - }, - { - onProgress: (msg, prog) => { - setProgress(66 + Math.floor(prog / 3)); - setProgressMessage(msg); - }, - onResult: () => { - console.log('大纲生成完成'); - setGenerationSteps(prev => ({ ...prev, outline: 'completed' })); - }, - onError: (error) => { - console.error('大纲生成失败:', error); - setErrorDetails(`大纲生成失败: ${error}`); - setGenerationSteps(prev => ({ ...prev, outline: 'error' })); - setLoading(false); - throw new Error(error); - }, - onComplete: () => { - console.log('大纲生成完成'); - } - } - ); - - // 全部完成 - setProgress(100); - setProgressMessage('项目创建完成!'); - setCurrentStep('complete'); - message.success('项目创建成功!'); - setLoading(false); - }; - const handleConfirmGenres = async () => { if (selectedOptions.length === 0) { message.warning('请至少选择一个类型'); @@ -781,7 +336,6 @@ const Inspiration: React.FC = () => { setWizardData(updatedData); setSelectedOptions([]); - // 进入叙事视角选择 setLoading(true); try { const aiMessage: Message = { @@ -810,7 +364,6 @@ const Inspiration: React.FC = () => { }; const response = await inspirationApi.generateOptions(requestData); - // 前端格式校验 if (response.error || !response.options || response.options.length < 3) { const errorMessage: Message = { type: 'ai', @@ -844,7 +397,6 @@ const Inspiration: React.FC = () => { }; const response = await inspirationApi.generateOptions(requestData); - // 前端格式校验 if (response.error || !response.options || response.options.length < 3) { const errorMessage: Message = { type: 'ai', @@ -879,7 +431,6 @@ const Inspiration: React.FC = () => { }; const response = await inspirationApi.generateOptions(requestData); - // 前端格式校验 if (response.error || !response.options || response.options.length < 3) { const errorMessage: Message = { type: 'ai', @@ -915,7 +466,7 @@ const Inspiration: React.FC = () => { } ]); setWizardData({}); - setInitialIdea(''); // 重置原始想法 + setInitialIdea(''); setSelectedOptions([]); setLoading(false); }; @@ -924,145 +475,22 @@ const Inspiration: React.FC = () => { navigate('/projects'); }; - // 渲染生成进度页面 - const renderGenerating = () => { - const getStepStatus = (step: 'pending' | 'processing' | 'completed' | 'error') => { - if (step === 'completed') return { icon: , color: '#52c41a' }; - if (step === 'processing') return { icon: , color: '#1890ff' }; - if (step === 'error') return { icon: '✗', color: '#ff4d4f' }; - return { icon: '○', color: '#d9d9d9' }; - }; - - const hasError = generationSteps.worldBuilding === 'error' || - generationSteps.characters === 'error' || - generationSteps.outline === 'error'; - - return ( -
    - - 正在为《{projectTitle}》生成内容 - - - - - - - {progressMessage} - - - {/* 错误详情显示 */} - {errorDetails && ( - - 错误详情: -
    - {errorDetails} -
    - )} - - - {[ - { key: 'worldBuilding', label: '生成世界观', step: generationSteps.worldBuilding }, - { key: 'characters', label: '生成角色', step: generationSteps.characters }, - { key: 'outline', label: '生成大纲', step: generationSteps.outline }, - ].map(({ key, label, step }) => { - const status = getStepStatus(step); - return ( -
    - - {label} - - - {status.icon} - -
    - ); - })} -
    -
    - - - {hasError ? '生成过程中出现错误,请点击重试按钮重新生成' : '请耐心等待,AI正在为您精心创作...'} - - - {hasError && ( - - - - )} -
    - ); + // 生成完成回调 + const handleComplete = (projectId: string) => { + console.log('灵感模式项目创建完成:', projectId); + setCurrentStep('complete'); }; - // 渲染完成页面 - const renderComplete = () => ( -
    - -
    - ✓ -
    - - 项目创建完成! - - - 《{projectTitle}》已成功创建,包含完整的世界观、角色和开局大纲 - - - - - - -
    -
    - ); + // 返回对话界面 + const handleBackToChat = () => { + setCurrentStep('idea'); + setGenerationConfig(null); + handleRestart(); + }; // 渲染对话界面 const renderChat = () => ( <> - {/* 对话区域 */} { {msg.content} - {/* 选项卡片 */} {msg.options && msg.options.length > 0 && ( { ))} - {/* 多选确认按钮 */} {msg.isMultiSelect && (
    )} - {/* 滚动锚点 */}
    - {/* 输入区域 */} { `}
    - {/* 头部 */}
    {
    - {/* 根据当前步骤渲染不同内容 */} {(currentStep === 'idea' || currentStep === 'title' || currentStep === 'description' || currentStep === 'theme' || currentStep === 'genre' || currentStep === 'perspective' || currentStep === 'confirm') && renderChat()} - {currentStep === 'generating' && renderGenerating()} - {currentStep === 'complete' && renderComplete()} + {(currentStep === 'generating' || currentStep === 'complete') && generationConfig && ( + + )}
    ); }; export default Inspiration; - \ No newline at end of file + \ No newline at end of file diff --git a/frontend/src/pages/Outline.tsx b/frontend/src/pages/Outline.tsx index 73f2223..c2a3a18 100644 --- a/frontend/src/pages/Outline.tsx +++ b/frontend/src/pages/Outline.tsx @@ -1,10 +1,11 @@ import { useState, useEffect } from 'react'; -import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag, Progress, InputNumber, Tooltip, Tabs } from 'antd'; +import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag, 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 { SSEProgressModal } from '../components/SSEProgressModal'; import { outlineApi, chapterApi } from '../services/api'; import type { OutlineExpansionResponse, BatchOutlineExpansionResponse } from '../types'; @@ -20,6 +21,9 @@ export default function Outline() { const [isMobile, setIsMobile] = useState(window.innerWidth <= 768); const [isExpanding, setIsExpanding] = useState(false); + // ✅ 新增:记录每个大纲的展开状态 + const [outlineExpandStatus, setOutlineExpandStatus] = useState>({}); + // 缓存批量展开的规划数据,避免重复AI调用 const [cachedBatchExpansionResponse, setCachedBatchExpansionResponse] = useState(null); @@ -58,6 +62,27 @@ export default function Outline() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentProject?.id]); // 只依赖 ID,不依赖函数 + // ✅ 新增:加载所有大纲的展开状态 + useEffect(() => { + const loadExpandStatus = async () => { + if (outlines.length === 0) return; + + const statusMap: Record = {}; + for (const outline of outlines) { + try { + const chapters = await outlineApi.getOutlineChapters(outline.id); + statusMap[outline.id] = chapters.has_chapters; + } catch (error) { + console.error(`加载大纲 ${outline.id} 状态失败:`, error); + statusMap[outline.id] = false; + } + } + setOutlineExpandStatus(statusMap); + }; + + loadExpandStatus(); + }, [outlines]); + // 移除事件监听,避免无限循环 // Hook 内部已经更新了 store,不需要再次刷新 @@ -207,7 +232,7 @@ export default function Outline() { title: hasOutlines ? ( AI生成/续写大纲 - 当前已有 {outlines.length} 章 + 当前已有 {outlines.length} 卷 ) : 'AI生成大纲', width: 700, @@ -351,6 +376,59 @@ export default function Outline() { try { setIsExpanding(true); + // ✅ 新增:检查是否需要按顺序展开 + const currentOutline = sortedOutlines.find(o => o.id === outlineId); + if (currentOutline) { + // 获取所有在当前大纲之前的大纲 + const previousOutlines = sortedOutlines.filter( + o => o.order_index < currentOutline.order_index + ); + + // 检查前面的大纲是否都已展开 + for (const prevOutline of previousOutlines) { + try { + const prevChapters = await outlineApi.getOutlineChapters(prevOutline.id); + if (!prevChapters.has_chapters) { + // 如果前面有未展开的大纲,显示提示并阻止操作 + setIsExpanding(false); + Modal.warning({ + title: '请按顺序展开大纲', + width: 600, + centered: true, + content: ( +
    +

    + 为了保持章节编号的连续性和内容的连贯性,请先展开前面的大纲。 +

    +
    +
    + ⚠️ 需要先展开: +
    +
    + 第{prevOutline.order_index}卷:《{prevOutline.title}》 +
    +
    +

    + 💡 提示:您也可以使用「批量展开」功能,系统会自动按顺序处理所有大纲。 +

    +
    + ), + okText: '我知道了' + }); + return; + } + } catch (error) { + console.error(`检查大纲 ${prevOutline.id} 失败:`, error); + // 如果检查失败,继续处理(避免因网络问题阻塞) + } + } + } + // 第一步:检查是否已有展开的章节 const existingChapters = await outlineApi.getOutlineChapters(outlineId); @@ -521,16 +599,27 @@ export default function Outline() { ) => { const modal = Modal.info({ title: ( - + 已存在的展开章节 ), - width: 900, + width: isMobile ? '95%' : 900, centered: true, okText: '关闭', + style: isMobile ? { + top: 20, + maxWidth: 'calc(100vw - 16px)', + margin: '0 8px' + } : undefined, + styles: { + body: { + maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(80vh - 110px)', + overflowY: 'auto' + } + }, footer: (_, { OkBtn }) => ( - + @@ -565,9 +656,22 @@ export default function Outline() { content: (
    - 大纲: {outlineTitle} - 章节数: {data.chapter_count} - 已创建章节 + + + 大纲: {outlineTitle} + + 章节数: {data.chapter_count} + 已创建章节 +
    ({ key: idx.toString(), label: ( - - {plan.sub_index}. {plan.title} + + + {plan.sub_index}. {plan.title} + ), children: (
    - - {plan.emotional_tone} - {plan.conflict_type} + + + {plan.emotional_tone} + + + {plan.conflict_type} + 约{plan.estimated_words}字 - {plan.plot_summary} +
    + {plan.plot_summary} +
    - {plan.narrative_goal} +
    + {plan.narrative_goal} +
    {plan.key_events.map((event, eventIdx) => ( -
    • {event}
    +
    + • {event} +
    ))}
    - + {plan.character_focus.map((char, charIdx) => ( - {char} + + {char} + ))} @@ -618,10 +785,36 @@ export default function Outline() { {plan.scenes.map((scene, sceneIdx) => ( - -
    地点:{scene.location}
    -
    角色:{scene.characters.join('、')}
    -
    目的:{scene.purpose}
    + +
    + 地点:{scene.location} +
    +
    + 角色:{scene.characters.join('、')} +
    +
    + 目的:{scene.purpose} +
    ))}
    @@ -1160,35 +1353,13 @@ export default function Outline() { {renderBatchPreviewContent()} - {/* SSE进度Modal */} - -
    - -
    - {sseMessage} -
    -
    -
    + {/* SSE进度Modal - 使用统一组件 */} +
    {/* 固定头部 */} @@ -1282,12 +1453,18 @@ export default function Outline() {
    - - 第{item.order_index || '?'}章 + + + 第{item.order_index || '?'}卷 - {item.title} - + {item.title} + {/* ✅ 新增:展开状态标识 */} + {outlineExpandStatus[item.id] ? ( + }>已展开 + ) : ( + 未展开 + )} + } description={
    diff --git a/frontend/src/pages/ProjectList.tsx b/frontend/src/pages/ProjectList.tsx index 94546f9..5bf5d86 100644 --- a/frontend/src/pages/ProjectList.tsx +++ b/frontend/src/pages/ProjectList.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Card, Button, Empty, Modal, message, Spin, Row, Col, Statistic, Space, Tag, Progress, Typography, Tooltip, Badge, Alert, Upload, Checkbox, Divider, Switch, Dropdown, Form, Input, InputNumber } from 'antd'; -import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, FireOutlined, SettingOutlined, InfoCircleOutlined, CloseOutlined, UploadOutlined, DownloadOutlined, ApiOutlined, MoreOutlined, BulbOutlined } from '@ant-design/icons'; +import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, FireOutlined, SettingOutlined, InfoCircleOutlined, CloseOutlined, UploadOutlined, DownloadOutlined, ApiOutlined, MoreOutlined, BulbOutlined, LoadingOutlined } from '@ant-design/icons'; import { projectApi } from '../services/api'; import { useStore } from '../store'; import { useProjectSync } from '../store/hooks'; @@ -115,9 +115,15 @@ export default function ProjectList() { } }; - const handleEnterProject = (id: string) => { - // 简化后直接进入项目,不再检查向导状态 - navigate(`/project/${id}`); + const handleEnterProject = async (project: any) => { + // 检查项目是否未完成生成(wizard_status为incomplete) + if (project.wizard_status === 'incomplete') { + // 未完成的项目跳转到生成页面继续生成 + navigate(`/wizard?project_id=${project.id}`); + } else { + // 已完成的项目进入项目详情页 + navigate(`/project/${project.id}`); + } }; const getStatusTag = (status: string) => { @@ -725,14 +731,16 @@ export default function ProjectList() { return ( }>生成中断 + ) : getStatusTag(project.status)} color="transparent" style={{ top: 12, right: 12 }} > handleEnterProject(project.id)} + onClick={() => handleEnterProject(project)} style={cardStyles.project} styles={{ body: { padding: 0, overflow: 'hidden' } }} {...cardHoverHandlers} diff --git a/frontend/src/pages/ProjectWizardNew.tsx b/frontend/src/pages/ProjectWizardNew.tsx index a986fac..b98dbcc 100644 --- a/frontend/src/pages/ProjectWizardNew.tsx +++ b/frontend/src/pages/ProjectWizardNew.tsx @@ -1,43 +1,28 @@ import { useState, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { - Form, Input, InputNumber, Select, Button, message, Card, - Row, Col, Typography, Space, Progress + Form, Input, InputNumber, Select, Button, Card, + Row, Col, Typography, Space, message } from 'antd'; import { - RocketOutlined, ArrowLeftOutlined, CheckCircleOutlined, - LoadingOutlined + RocketOutlined, ArrowLeftOutlined } from '@ant-design/icons'; -import { wizardStreamApi } from '../services/api'; -import type { WizardBasicInfo, ApiError } from '../types'; -import { SSELoadingOverlay } from '../components/SSELoadingOverlay'; +import { AIProjectGenerator, type GenerationConfig } from '../components/AIProjectGenerator'; +import type { WizardBasicInfo } from '../types'; const { TextArea } = Input; -const { Title, Paragraph, Text } = Typography; +const { Title, Paragraph } = Typography; export default function ProjectWizardNew() { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const [form] = Form.useForm(); const [isMobile, setIsMobile] = useState(window.innerWidth <= 768); // 状态管理 - const [loading, setLoading] = useState(false); - const [currentStep, setCurrentStep] = useState<'form' | 'generating' | 'complete'>('form'); - const [projectId, setProjectId] = useState(''); - const [projectTitle, setProjectTitle] = useState(''); - - // SSE流式进度状态 - const [progress, setProgress] = useState(0); - const [progressMessage, setProgressMessage] = useState(''); - const [generationSteps, setGenerationSteps] = useState<{ - worldBuilding: 'pending' | 'processing' | 'completed' | 'error'; - characters: 'pending' | 'processing' | 'completed' | 'error'; - outline: 'pending' | 'processing' | 'completed' | 'error'; - }>({ - worldBuilding: 'pending', - characters: 'pending', - outline: 'pending' - }); + const [currentStep, setCurrentStep] = useState<'form' | 'generating'>('form'); + const [generationConfig, setGenerationConfig] = useState(null); + const [resumeProjectId, setResumeProjectId] = useState(null); useEffect(() => { const handleResize = () => { @@ -47,140 +32,72 @@ export default function ProjectWizardNew() { return () => window.removeEventListener('resize', handleResize); }, []); - // 自动化生成流程 - const handleAutoGenerate = async (values: WizardBasicInfo) => { - try { - setLoading(true); - setCurrentStep('generating'); - setProjectTitle(values.title); - setProgress(0); - setProgressMessage('开始创建项目...'); - - // 步骤1: 生成世界观并创建项目 - setGenerationSteps(prev => ({ ...prev, worldBuilding: 'processing' })); - setProgressMessage('正在生成世界观...'); - - const worldResult = await wizardStreamApi.generateWorldBuildingStream( - { - title: values.title, - description: values.description, - theme: values.theme, - genre: Array.isArray(values.genre) ? values.genre.join('、') : values.genre, - narrative_perspective: values.narrative_perspective, - target_words: values.target_words, - chapter_count: values.chapter_count || 30, - character_count: values.character_count || 5, - }, - { - onProgress: (msg, prog) => { - setProgress(Math.floor(prog / 3)); // 0-33% - setProgressMessage(msg); - }, - onResult: (data) => { - setProjectId(data.project_id); - setGenerationSteps(prev => ({ ...prev, worldBuilding: 'completed' })); - }, - onError: (error) => { - setGenerationSteps(prev => ({ ...prev, worldBuilding: 'error' })); - throw new Error(error); - }, - onComplete: () => { - console.log('世界观生成完成'); - } - } - ); - - if (!worldResult?.project_id) { - throw new Error('项目创建失败'); - } - - const createdProjectId = worldResult.project_id; - setProjectId(createdProjectId); - - // 步骤2: 生成角色 - setGenerationSteps(prev => ({ ...prev, characters: 'processing' })); - setProgressMessage('正在生成角色...'); - - await wizardStreamApi.generateCharactersStream( - { - project_id: createdProjectId, - count: values.character_count || 5, - world_context: { - time_period: worldResult.time_period || '', - location: worldResult.location || '', - atmosphere: worldResult.atmosphere || '', - rules: worldResult.rules || '', - }, - theme: values.theme, - genre: Array.isArray(values.genre) ? values.genre.join('、') : values.genre, - }, - { - onProgress: (msg, prog) => { - setProgress(33 + Math.floor(prog / 3)); // 33-66% - setProgressMessage(msg); - }, - onResult: (data) => { - console.log(`成功生成${data.characters?.length || 0}个角色`); - setGenerationSteps(prev => ({ ...prev, characters: 'completed' })); - }, - onError: (error) => { - setGenerationSteps(prev => ({ ...prev, characters: 'error' })); - throw new Error(error); - }, - onComplete: () => { - console.log('角色生成完成'); - } - } - ); - - // 步骤3: 生成大纲 - setGenerationSteps(prev => ({ ...prev, outline: 'processing' })); - setProgressMessage('正在生成大纲...'); - - await wizardStreamApi.generateCompleteOutlineStream( - { - project_id: createdProjectId, - chapter_count: 3, // 生成3个大纲节点(不展开) - narrative_perspective: values.narrative_perspective, - target_words: values.target_words, - }, - { - onProgress: (msg, prog) => { - setProgress(66 + Math.floor(prog / 3)); // 66-99% - setProgressMessage(msg); - }, - onResult: () => { - console.log('大纲生成完成'); - setGenerationSteps(prev => ({ ...prev, outline: 'completed' })); - }, - onError: (error) => { - setGenerationSteps(prev => ({ ...prev, outline: 'error' })); - throw new Error(error); - }, - onComplete: () => { - console.log('大纲生成完成'); - } - } - ); - - // 全部完成 - setProgress(100); - setProgressMessage('项目创建完成!'); - setCurrentStep('complete'); - message.success('项目创建成功!'); - - } catch (error) { - const apiError = error as ApiError; - message.error('创建项目失败:' + (apiError.response?.data?.detail || apiError.message || '未知错误')); - setCurrentStep('form'); - setGenerationSteps({ - worldBuilding: 'pending', - characters: 'pending', - outline: 'pending' - }); - } finally { - setLoading(false); + // 检查URL参数,如果有project_id则恢复生成 + useEffect(() => { + const projectId = searchParams.get('project_id'); + if (projectId) { + setResumeProjectId(projectId); + handleResumeGeneration(projectId); } + }, [searchParams]); + + // 恢复未完成项目的生成 + const handleResumeGeneration = async (projectId: string) => { + try { + const response = await fetch(`/api/projects/${projectId}`, { + credentials: 'include' + }); + if (!response.ok) { + throw new Error('获取项目信息失败'); + } + const project = await response.json(); + + const config: GenerationConfig = { + title: project.title, + description: project.description || '', + theme: project.theme || '', + genre: project.genre || '', + narrative_perspective: project.narrative_perspective || '第三人称', + target_words: project.target_words || 100000, + chapter_count: 3, + character_count: project.character_count || 5, + }; + + setGenerationConfig(config); + setCurrentStep('generating'); + } catch (error) { + console.error('恢复生成失败:', error); + message.error('恢复生成失败,请重试'); + navigate('/'); + } + }; + + // 开始生成流程 + const handleAutoGenerate = async (values: WizardBasicInfo) => { + const config: GenerationConfig = { + title: values.title, + description: values.description, + theme: values.theme, + genre: values.genre, + narrative_perspective: values.narrative_perspective, + target_words: values.target_words || 100000, + chapter_count: 3, // 默认生成3章大纲 + character_count: values.character_count || 5, + }; + + setGenerationConfig(config); + setCurrentStep('generating'); + }; + + // 生成完成回调 + const handleComplete = (projectId: string) => { + console.log('项目创建完成:', projectId); + }; + + // 返回表单页面 + const handleBack = () => { + setCurrentStep('form'); + setGenerationConfig(null); }; // 渲染表单页面 @@ -317,7 +234,6 @@ export default function ProjectWizardNew() { htmlType="submit" size="large" block - loading={loading} icon={} > 开始创建项目 @@ -335,150 +251,12 @@ export default function ProjectWizardNew() { ); - // 渲染生成进度页面 - const renderGenerating = () => { - const getStepStatus = (step: 'pending' | 'processing' | 'completed' | 'error') => { - if (step === 'completed') return { icon: , color: '#52c41a' }; - if (step === 'processing') return { icon: , color: '#1890ff' }; - if (step === 'error') return { icon: '✗', color: '#ff4d4f' }; - return { icon: '○', color: '#d9d9d9' }; - }; - - return ( - -
    - - 正在为《{projectTitle}》生成内容 - - - - - - {progressMessage} - - - - {[ - { key: 'worldBuilding', label: '生成世界观', step: generationSteps.worldBuilding }, - { key: 'characters', label: '生成角色', step: generationSteps.characters }, - { key: 'outline', label: '生成大纲', step: generationSteps.outline }, - ].map(({ key, label, step }) => { - const status = getStepStatus(step); - return ( -
    - - {label} - - - {status.icon} - -
    - ); - })} -
    - - - 请耐心等待,AI正在为您精心创作... - -
    -
    - ); - }; - - // 渲染完成页面 - const renderComplete = () => ( - -
    -
    - ✓ -
    - - 项目创建完成! - - - 《{projectTitle}》已成功创建,包含完整的世界观、角色和大纲节点 - - - 💡 提示:进入项目后,可在"大纲"页面将大纲节点展开为详细章节 - - - - - - -
    -
    - ); - return (
    {/* 顶部标题栏 - 固定不滚动 */}
    {currentStep === 'form' && renderForm()} - {currentStep === 'generating' && renderGenerating()} - {currentStep === 'complete' && renderComplete()} + {currentStep === 'generating' && generationConfig && ( + + )}
    - - {/* SSE加载覆盖层 */} -
    ); } \ No newline at end of file diff --git a/frontend/src/pages/Relationships.tsx b/frontend/src/pages/Relationships.tsx index 5b6f560..57f1b49 100644 --- a/frontend/src/pages/Relationships.tsx +++ b/frontend/src/pages/Relationships.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { Card, Table, Tag, Button, Space, message, Modal, Form, Select, Slider, Input, Tabs, AutoComplete } from 'antd'; -import { PlusOutlined, TeamOutlined, UserOutlined } from '@ant-design/icons'; +import { PlusOutlined, TeamOutlined, UserOutlined, EditOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import axios from 'axios'; @@ -40,6 +40,8 @@ export default function Relationships() { const [characters, setCharacters] = useState([]); const [loading, setLoading] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false); + const [isEditMode, setIsEditMode] = useState(false); + const [editingRelationship, setEditingRelationship] = useState(null); const [form] = Form.useForm(); const [isMobile, setIsMobile] = useState(window.innerWidth <= 768); const [pageSize, setPageSize] = useState(10); @@ -103,6 +105,49 @@ export default function Relationships() { } }; + const handleEditRelationship = (record: Relationship) => { + setEditingRelationship(record); + setIsEditMode(true); + form.setFieldsValue({ + character_from_id: record.character_from_id, + character_to_id: record.character_to_id, + relationship_name: record.relationship_name, + intimacy_level: record.intimacy_level, + status: record.status, + description: record.description, + }); + setIsModalOpen(true); + }; + + const handleUpdateRelationship = async (values: { + character_from_id: string; + character_to_id: string; + relationship_name: string; + intimacy_level: number; + status: string; + description?: string; + }) => { + if (!editingRelationship) return; + + try { + await axios.put(`/api/relationships/${editingRelationship.id}`, { + relationship_name: values.relationship_name, + intimacy_level: values.intimacy_level, + status: values.status, + description: values.description, + }); + message.success('关系更新成功'); + setIsModalOpen(false); + setIsEditMode(false); + setEditingRelationship(null); + form.resetFields(); + loadData(); + } catch (error) { + message.error('更新关系失败'); + console.error(error); + } + }; + const handleDeleteRelationship = async (id: string) => { Modal.confirm({ title: '确认删除', @@ -218,16 +263,26 @@ export default function Relationships() { title: '操作', key: 'action', render: (_: unknown, record: Relationship) => ( - + + + + ), - width: 80, + width: 140, fixed: isMobile ? ('right' as const) : undefined, }, ]; @@ -345,10 +400,12 @@ export default function Relationships() { { setIsModalOpen(false); + setIsEditMode(false); + setEditingRelationship(null); form.resetFields(); }} footer={null} @@ -360,7 +417,7 @@ export default function Relationships() {
    (option?.label ?? '').toLowerCase().includes(input.toLowerCase()) } @@ -404,6 +462,7 @@ export default function Relationships() {