update:1.修复大纲展开功能bug,按顺序展开 2.优化大纲细化UI展示,大纲设置为卷 3.实现角色关系修改功能 4.优化提示词避免出现过多特殊符号 5.优化向导页面的AI生产进度页面和灵感模式保持统一,支持重试 6.优化项目生成过长中断添加自动恢复逻辑
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
如果这个项目对你有帮助,欢迎通过以下方式支持开发:
|
||||
|
||||
**[☕ 请我喝杯咖啡](https://zanzhupage.vercel.app/)**
|
||||
**[☕ 请我喝杯咖啡](https://mumuverse.space:1588/)**
|
||||
|
||||
您的支持是我持续开发的动力!🙏
|
||||
|
||||
|
||||
+36
-25
@@ -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参考资料
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
+161
-104
@@ -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 = ""
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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<AIProjectGeneratorProps> = ({
|
||||
config,
|
||||
storagePrefix,
|
||||
onComplete,
|
||||
isMobile = false,
|
||||
resumeProjectId
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 状态管理
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [projectId, setProjectId] = useState<string>('');
|
||||
|
||||
// SSE流式进度状态
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [progressMessage, setProgressMessage] = useState('');
|
||||
const [errorDetails, setErrorDetails] = useState<string>('');
|
||||
const [generationSteps, setGenerationSteps] = useState<GenerationSteps>({
|
||||
worldBuilding: 'pending',
|
||||
characters: 'pending',
|
||||
outline: 'pending'
|
||||
});
|
||||
|
||||
// 保存生成数据,用于重试
|
||||
const [generationData, setGenerationData] = useState<GenerationConfig | null>(null);
|
||||
// 保存世界观生成结果,用于后续步骤
|
||||
const [worldBuildingResult, setWorldBuildingResult] = useState<any>(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: <CheckCircleOutlined />, color: '#52c41a' };
|
||||
if (step === 'processing') return { icon: <LoadingOutlined />, 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 = () => (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: isMobile ? '32px 16px' : '40px 20px',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<Title
|
||||
level={isMobile ? 4 : 3}
|
||||
style={{
|
||||
marginBottom: 32,
|
||||
color: '#fff',
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
overflowWrap: 'break-word'
|
||||
}}
|
||||
>
|
||||
正在为《{config.title}》生成内容
|
||||
</Title>
|
||||
|
||||
<Card style={{ marginBottom: 24, maxWidth: '100%' }}>
|
||||
<Progress
|
||||
percent={progress}
|
||||
status={hasError ? 'exception' : (progress === 100 ? 'success' : 'active')}
|
||||
strokeColor={{
|
||||
'0%': '#667eea',
|
||||
'100%': '#764ba2',
|
||||
}}
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
|
||||
<Paragraph
|
||||
style={{
|
||||
fontSize: isMobile ? 14 : 16,
|
||||
marginBottom: 32,
|
||||
color: hasError ? '#ff4d4f' : '#666',
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
overflowWrap: 'break-word'
|
||||
}}
|
||||
>
|
||||
{progressMessage}
|
||||
</Paragraph>
|
||||
|
||||
{errorDetails && (
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
background: '#fff2f0',
|
||||
borderColor: '#ffccc7',
|
||||
textAlign: 'left',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<Text strong style={{ color: '#ff4d4f' }}>错误详情:</Text>
|
||||
<br />
|
||||
<Text
|
||||
style={{
|
||||
color: '#666',
|
||||
fontSize: 14,
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
overflowWrap: 'break-word',
|
||||
display: 'block'
|
||||
}}
|
||||
>
|
||||
{errorDetails}
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Space
|
||||
direction="vertical"
|
||||
size={16}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: isMobile ? '100%' : 400,
|
||||
margin: '0 auto'
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ 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 (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: isMobile ? '10px 12px' : '12px 20px',
|
||||
background: step === 'processing' ? '#f0f5ff' : (step === 'error' ? '#fff2f0' : '#fafafa'),
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${step === 'processing' ? '#d6e4ff' : (step === 'error' ? '#ffccc7' : '#f0f0f0')}`,
|
||||
gap: '8px',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: isMobile ? 14 : 16,
|
||||
fontWeight: step === 'processing' ? 600 : 400,
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
overflowWrap: 'break-word',
|
||||
flex: 1,
|
||||
textAlign: 'left'
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 20,
|
||||
color: status.color,
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
{status.icon}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Paragraph
|
||||
type="secondary"
|
||||
style={{
|
||||
color: '#fff',
|
||||
opacity: 0.9,
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
overflowWrap: 'break-word',
|
||||
fontSize: isMobile ? 14 : 16
|
||||
}}
|
||||
>
|
||||
{hasError ? '生成过程中出现错误,请点击重试按钮重新生成' : '请耐心等待,AI正在为您精心创作...'}
|
||||
</Paragraph>
|
||||
|
||||
{hasError && (
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={handleSmartRetry}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
智能重试
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
return renderGenerating();
|
||||
};
|
||||
@@ -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<ChapterRegenerationModalProps> = ({
|
||||
)
|
||||
}
|
||||
>
|
||||
{status === 'generating' && (
|
||||
<Alert
|
||||
message="正在重新生成中..."
|
||||
description={
|
||||
<div>
|
||||
<Progress percent={progress} status="active" />
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
|
||||
已生成 {wordCount} 字
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<Alert
|
||||
@@ -395,6 +379,13 @@ const ChapterRegenerationModal: React.FC<ChapterRegenerationModalProps> = ({
|
||||
</Collapse>
|
||||
</Form>
|
||||
|
||||
{/* 使用统一的进度显示组件 */}
|
||||
<SSEProgressModal
|
||||
visible={status === 'generating'}
|
||||
progress={progress}
|
||||
message={`正在重新生成中... (已生成 ${wordCount} 字)`}
|
||||
title="重新生成章节"
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<SSEProgressModalProps> = ({
|
||||
visible,
|
||||
progress,
|
||||
message,
|
||||
title = 'AI生成中...',
|
||||
showPercentage = true,
|
||||
showIcon = true,
|
||||
}) => {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={null}
|
||||
open={visible}
|
||||
footer={null}
|
||||
closable={false}
|
||||
centered
|
||||
width={500}
|
||||
maskClosable={false}
|
||||
keyboard={false}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '40px 40px 32px',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{/* 标题和图标 */}
|
||||
{showIcon && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
marginBottom: 24
|
||||
}}>
|
||||
<Spin
|
||||
indicator={<LoadingOutlined style={{ fontSize: 48, color: '#1890ff' }} spin />}
|
||||
/>
|
||||
<div style={{
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
marginTop: 16,
|
||||
color: '#262626'
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 进度条 */}
|
||||
<div style={{
|
||||
marginBottom: showPercentage ? 16 : 24
|
||||
}}>
|
||||
<div style={{
|
||||
height: 12,
|
||||
background: '#f0f0f0',
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden',
|
||||
marginBottom: showPercentage ? 12 : 0
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
background: progress === 100
|
||||
? 'linear-gradient(90deg, #52c41a 0%, #73d13d 100%)'
|
||||
: 'linear-gradient(90deg, #1890ff 0%, #40a9ff 100%)',
|
||||
width: `${progress}%`,
|
||||
transition: 'all 0.3s ease',
|
||||
borderRadius: 6,
|
||||
boxShadow: progress > 0 ? '0 0 10px rgba(24, 144, 255, 0.3)' : 'none'
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* 进度百分比 */}
|
||||
{showPercentage && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
color: progress === 100 ? '#52c41a' : '#1890ff',
|
||||
marginBottom: 8
|
||||
}}>
|
||||
{progress}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 状态消息 */}
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
color: '#595959',
|
||||
minHeight: 24,
|
||||
padding: '0 20px',
|
||||
marginBottom: 16
|
||||
}}>
|
||||
{message || '准备生成...'}
|
||||
</div>
|
||||
|
||||
{/* 提示文字 */}
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
fontSize: 13,
|
||||
color: '#8c8c8c'
|
||||
}}>
|
||||
请勿关闭页面,生成过程需要一定时间
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SSEProgressModal;
|
||||
+171
-57
@@ -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: (
|
||||
<Space>
|
||||
<Space style={{ flexWrap: 'wrap' }}>
|
||||
<InfoCircleOutlined style={{ color: '#1890ff' }} />
|
||||
<span>第{chapter.chapter_number}章展开规划</span>
|
||||
<span style={{ wordBreak: 'break-word' }}>第{chapter.chapter_number}章展开规划</span>
|
||||
</Space>
|
||||
),
|
||||
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: (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions
|
||||
column={1}
|
||||
size="small"
|
||||
bordered
|
||||
labelStyle={{
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
width: isMobile ? '80px' : '100px'
|
||||
}}
|
||||
contentStyle={{
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word'
|
||||
}}
|
||||
>
|
||||
<Descriptions.Item label="章节标题">
|
||||
<strong>{chapter.title}</strong>
|
||||
<strong style={{
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
overflowWrap: 'break-word'
|
||||
}}>
|
||||
{chapter.title}
|
||||
</strong>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="情感基调">
|
||||
<Tag color="blue">{planData.emotional_tone}</Tag>
|
||||
<Tag
|
||||
color="blue"
|
||||
style={{
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
height: 'auto',
|
||||
lineHeight: '1.5',
|
||||
padding: '4px 8px'
|
||||
}}
|
||||
>
|
||||
{planData.emotional_tone}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="冲突类型">
|
||||
<Tag color="orange">{planData.conflict_type}</Tag>
|
||||
<Tag
|
||||
color="orange"
|
||||
style={{
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
height: 'auto',
|
||||
lineHeight: '1.5',
|
||||
padding: '4px 8px'
|
||||
}}
|
||||
>
|
||||
{planData.conflict_type}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="预估字数">
|
||||
<Tag color="green">{planData.estimated_words}字</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="叙事目标">
|
||||
{planData.narrative_goal}
|
||||
<span style={{
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
overflowWrap: 'break-word'
|
||||
}}>
|
||||
{planData.narrative_goal}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="关键事件">
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
{planData.key_events.map((event, idx) => (
|
||||
<div key={idx} style={{ padding: '4px 0' }}>
|
||||
<Tag color="purple">{idx + 1}</Tag> {event}
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
padding: '4px 0',
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
overflowWrap: 'break-word'
|
||||
}}
|
||||
>
|
||||
<Tag color="purple" style={{ flexShrink: 0 }}>{idx + 1}</Tag>{' '}
|
||||
<span style={{
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
overflowWrap: 'break-word'
|
||||
}}>
|
||||
{event}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="涉及角色">
|
||||
<Space wrap>
|
||||
<Space wrap style={{ maxWidth: '100%' }}>
|
||||
{planData.character_focus.map((char, idx) => (
|
||||
<Tag key={idx} color="cyan">{char}</Tag>
|
||||
<Tag
|
||||
key={idx}
|
||||
color="cyan"
|
||||
style={{
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
height: 'auto',
|
||||
lineHeight: '1.5'
|
||||
}}
|
||||
>
|
||||
{char}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
@@ -792,20 +879,68 @@ export default function Chapters() {
|
||||
<Descriptions.Item label="场景规划">
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
{planData.scenes.map((scene, idx) => (
|
||||
<Card key={idx} size="small" style={{ backgroundColor: '#fafafa' }}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<strong>📍 地点:</strong>{scene.location}
|
||||
<Card
|
||||
key={idx}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: '#fafafa',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
marginBottom: 4,
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
overflowWrap: 'break-word'
|
||||
}}>
|
||||
<strong>📍 地点:</strong>
|
||||
<span style={{
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
overflowWrap: 'break-word'
|
||||
}}>
|
||||
{scene.location}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<strong>👥 角色:</strong>
|
||||
<Space size="small" wrap style={{ marginLeft: 8 }}>
|
||||
<Space
|
||||
size="small"
|
||||
wrap
|
||||
style={{
|
||||
marginLeft: isMobile ? 0 : 8,
|
||||
marginTop: isMobile ? 4 : 0,
|
||||
display: isMobile ? 'flex' : 'inline-flex'
|
||||
}}
|
||||
>
|
||||
{scene.characters.map((char, charIdx) => (
|
||||
<Tag key={charIdx}>{char}</Tag>
|
||||
<Tag
|
||||
key={charIdx}
|
||||
style={{
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
height: 'auto'
|
||||
}}
|
||||
>
|
||||
{char}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
<div>
|
||||
<strong>🎯 目的:</strong>{scene.purpose}
|
||||
<div style={{
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
overflowWrap: 'break-word'
|
||||
}}>
|
||||
<strong>🎯 目的:</strong>
|
||||
<span style={{
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
overflowWrap: 'break-word'
|
||||
}}>
|
||||
{scene.purpose}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
@@ -1590,42 +1725,6 @@ export default function Chapters() {
|
||||
</Form>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span>生成进度:</span>
|
||||
<span>
|
||||
<strong style={{ color: '#1890ff', fontSize: 18 }}>
|
||||
{batchProgress?.completed || 0} / {batchProgress?.total || 0}
|
||||
</strong>
|
||||
章
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
percent={batchProgress ? Math.round((batchProgress.completed / batchProgress.total) * 100) : 0}
|
||||
status={batchProgress?.status === 'failed' ? 'exception' : 'active'}
|
||||
strokeColor={{
|
||||
'0%': '#722ed1',
|
||||
'100%': '#1890ff',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{batchProgress?.current_chapter_number && (
|
||||
<Alert
|
||||
message={`正在生成第 ${batchProgress.current_chapter_number} 章...`}
|
||||
type="info"
|
||||
showIcon
|
||||
icon={<SyncOutlined spin />}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{batchProgress?.estimated_time_minutes && batchProgress.completed === 0 && (
|
||||
<div style={{ marginBottom: 16, color: '#666', fontSize: 13 }}>
|
||||
⏱️ 预计耗时:约 {batchProgress.estimated_time_minutes} 分钟
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Alert
|
||||
message="温馨提示"
|
||||
description={
|
||||
@@ -1633,9 +1732,12 @@ export default function Chapters() {
|
||||
<li>批量生成需要一定时间,可以切换到其他页面</li>
|
||||
<li>关闭页面后重新打开,会自动恢复任务进度</li>
|
||||
<li>可以随时点击"取消任务"按钮中止生成</li>
|
||||
{batchProgress?.estimated_time_minutes && batchProgress.completed === 0 && (
|
||||
<li>⏱️ 预计耗时:约 {batchProgress.estimated_time_minutes} 分钟</li>
|
||||
)}
|
||||
</ul>
|
||||
}
|
||||
type="warning"
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
@@ -1669,6 +1771,18 @@ export default function Chapters() {
|
||||
message={singleChapterProgressMessage}
|
||||
/>
|
||||
|
||||
{/* 批量生成进度显示 - 使用统一的进度组件 */}
|
||||
<SSEProgressModal
|
||||
visible={batchGenerating}
|
||||
progress={batchProgress ? Math.round((batchProgress.completed / batchProgress.total) * 100) : 0}
|
||||
message={
|
||||
batchProgress?.current_chapter_number
|
||||
? `正在生成第 ${batchProgress.current_chapter_number} 章... (${batchProgress.completed}/${batchProgress.total})`
|
||||
: `批量生成进行中... (${batchProgress?.completed || 0}/${batchProgress?.total || 0})`
|
||||
}
|
||||
title="批量生成章节"
|
||||
/>
|
||||
|
||||
<FloatButton
|
||||
icon={<BookOutlined />}
|
||||
type="primary"
|
||||
|
||||
@@ -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<string>('');
|
||||
|
||||
// 项目生成状态
|
||||
const [projectId, setProjectId] = useState<string>('');
|
||||
const [projectTitle, setProjectTitle] = useState<string>('');
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [progressMessage, setProgressMessage] = useState('');
|
||||
const [errorDetails, setErrorDetails] = useState<string>(''); // 新增:错误详情
|
||||
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<WizardData | null>(null);
|
||||
// 保存世界观生成结果,用于后续步骤
|
||||
const [worldBuildingResult, setWorldBuildingResult] = useState<any>(null);
|
||||
// 生成配置
|
||||
const [generationConfig, setGenerationConfig] = useState<GenerationConfig | null>(null);
|
||||
|
||||
// 滚动容器引用
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
@@ -74,9 +56,8 @@ const Inspiration: React.FC = () => {
|
||||
context: Partial<WizardData>;
|
||||
} | 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: <CheckCircleOutlined />, color: '#52c41a' };
|
||||
if (step === 'processing') return { icon: <LoadingOutlined />, 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 (
|
||||
<div style={{ textAlign: 'center', padding: '40px 20px' }}>
|
||||
<Title level={3} style={{ marginBottom: 32, color: '#fff' }}>
|
||||
正在为《{projectTitle}》生成内容
|
||||
</Title>
|
||||
|
||||
<Card style={{ marginBottom: 24 }}>
|
||||
<Progress
|
||||
percent={progress}
|
||||
status={hasError ? 'exception' : (progress === 100 ? 'success' : 'active')}
|
||||
strokeColor={{
|
||||
'0%': '#667eea',
|
||||
'100%': '#764ba2',
|
||||
}}
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
|
||||
<Paragraph style={{ fontSize: 16, marginBottom: 32, color: hasError ? '#ff4d4f' : '#666' }}>
|
||||
{progressMessage}
|
||||
</Paragraph>
|
||||
|
||||
{/* 错误详情显示 */}
|
||||
{errorDetails && (
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
background: '#fff2f0',
|
||||
borderColor: '#ffccc7',
|
||||
textAlign: 'left'
|
||||
}}
|
||||
>
|
||||
<Text strong style={{ color: '#ff4d4f' }}>错误详情:</Text>
|
||||
<br />
|
||||
<Text style={{ color: '#666', fontSize: 14 }}>{errorDetails}</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Space direction="vertical" size={16} style={{ width: '100%', maxWidth: 400, margin: '0 auto' }}>
|
||||
{[
|
||||
{ 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 (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 20px',
|
||||
background: step === 'processing' ? '#f0f5ff' : (step === 'error' ? '#fff2f0' : '#fafafa'),
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${step === 'processing' ? '#d6e4ff' : (step === 'error' ? '#ffccc7' : '#f0f0f0')}`,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 16, fontWeight: step === 'processing' ? 600 : 400 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<span style={{ fontSize: 20, color: status.color }}>
|
||||
{status.icon}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Paragraph type="secondary" style={{ color: '#fff', opacity: 0.9 }}>
|
||||
{hasError ? '生成过程中出现错误,请点击重试按钮重新生成' : '请耐心等待,AI正在为您精心创作...'}
|
||||
</Paragraph>
|
||||
|
||||
{hasError && (
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={handleSmartRetry}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
继续生成
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
// 生成完成回调
|
||||
const handleComplete = (projectId: string) => {
|
||||
console.log('灵感模式项目创建完成:', projectId);
|
||||
setCurrentStep('complete');
|
||||
};
|
||||
|
||||
// 渲染完成页面
|
||||
const renderComplete = () => (
|
||||
<div style={{ textAlign: 'center', padding: '40px 20px' }}>
|
||||
<Card>
|
||||
<div style={{ fontSize: 72, color: '#52c41a', marginBottom: 24 }}>
|
||||
✓
|
||||
</div>
|
||||
<Title level={2} style={{ color: '#52c41a', marginBottom: 16 }}>
|
||||
项目创建完成!
|
||||
</Title>
|
||||
<Paragraph style={{ fontSize: 16, marginTop: 24, marginBottom: 48 }}>
|
||||
《{projectTitle}》已成功创建,包含完整的世界观、角色和开局大纲
|
||||
</Paragraph>
|
||||
|
||||
<Space size={16}>
|
||||
<Button size="large" onClick={() => navigate('/')}>
|
||||
返回首页
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<RocketOutlined />}
|
||||
onClick={() => navigate(`/project/${projectId}`)}
|
||||
>
|
||||
进入项目
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
// 返回对话界面
|
||||
const handleBackToChat = () => {
|
||||
setCurrentStep('idea');
|
||||
setGenerationConfig(null);
|
||||
handleRestart();
|
||||
};
|
||||
|
||||
// 渲染对话界面
|
||||
const renderChat = () => (
|
||||
<>
|
||||
{/* 对话区域 */}
|
||||
<Card
|
||||
ref={chatContainerRef}
|
||||
style={{
|
||||
@@ -1106,7 +534,6 @@ const Inspiration: React.FC = () => {
|
||||
{msg.content}
|
||||
</Paragraph>
|
||||
|
||||
{/* 选项卡片 */}
|
||||
{msg.options && msg.options.length > 0 && (
|
||||
<Space
|
||||
direction="vertical"
|
||||
@@ -1145,7 +572,6 @@ const Inspiration: React.FC = () => {
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* 多选确认按钮 */}
|
||||
{msg.isMultiSelect && (
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -1172,12 +598,10 @@ const Inspiration: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 滚动锚点 */}
|
||||
<div ref={messagesEndRef} />
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* 输入区域 */}
|
||||
<Card
|
||||
style={{ boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
|
||||
styles={{ body: { padding: 12 } }}
|
||||
@@ -1261,7 +685,6 @@ const Inspiration: React.FC = () => {
|
||||
`}
|
||||
</style>
|
||||
<div style={{ maxWidth: 800, margin: '0 auto' }}>
|
||||
{/* 头部 */}
|
||||
<div style={{
|
||||
marginBottom: window.innerWidth <= 768 ? 12 : 24,
|
||||
position: 'relative'
|
||||
@@ -1308,16 +731,22 @@ const Inspiration: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 根据当前步骤渲染不同内容 */}
|
||||
{(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 && (
|
||||
<AIProjectGenerator
|
||||
config={generationConfig}
|
||||
storagePrefix="inspiration"
|
||||
onComplete={handleComplete}
|
||||
onBack={handleBackToChat}
|
||||
isMobile={window.innerWidth <= 768}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Inspiration;
|
||||
|
||||
|
||||
+233
-56
@@ -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<Record<string, boolean>>({});
|
||||
|
||||
// 缓存批量展开的规划数据,避免重复AI调用
|
||||
const [cachedBatchExpansionResponse, setCachedBatchExpansionResponse] = useState<BatchOutlineExpansionResponse | null>(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<string, boolean> = {};
|
||||
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 ? (
|
||||
<Space>
|
||||
<span>AI生成/续写大纲</span>
|
||||
<Tag color="blue">当前已有 {outlines.length} 章</Tag>
|
||||
<Tag color="blue">当前已有 {outlines.length} 卷</Tag>
|
||||
</Space>
|
||||
) : '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: (
|
||||
<div>
|
||||
<p style={{ marginBottom: 12 }}>
|
||||
为了保持章节编号的连续性和内容的连贯性,请先展开前面的大纲。
|
||||
</p>
|
||||
<div style={{
|
||||
padding: 12,
|
||||
background: '#fff7e6',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #ffd591'
|
||||
}}>
|
||||
<div style={{ fontWeight: 500, marginBottom: 8, color: '#fa8c16' }}>
|
||||
⚠️ 需要先展开:
|
||||
</div>
|
||||
<div style={{ color: '#666' }}>
|
||||
第{prevOutline.order_index}卷:《{prevOutline.title}》
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ marginTop: 12, color: '#666', fontSize: 13 }}>
|
||||
💡 提示:您也可以使用「批量展开」功能,系统会自动按顺序处理所有大纲。
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
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: (
|
||||
<Space>
|
||||
<Space style={{ flexWrap: 'wrap' }}>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||
<span>已存在的展开章节</span>
|
||||
</Space>
|
||||
),
|
||||
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 }) => (
|
||||
<Space>
|
||||
<Space wrap style={{ width: '100%', justifyContent: isMobile ? 'center' : 'flex-end' }}>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
@@ -556,6 +645,8 @@ export default function Outline() {
|
||||
onOk: () => handleDeleteExpandedChapters(outlineTitle, data.chapters || []),
|
||||
});
|
||||
}}
|
||||
block={isMobile}
|
||||
size={isMobile ? 'middle' : undefined}
|
||||
>
|
||||
删除所有展开的章节 ({data.chapter_count}章)
|
||||
</Button>
|
||||
@@ -565,9 +656,22 @@ export default function Outline() {
|
||||
content: (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Tag color="blue">大纲: {outlineTitle}</Tag>
|
||||
<Tag color="green">章节数: {data.chapter_count}</Tag>
|
||||
<Tag color="orange">已创建章节</Tag>
|
||||
<Space wrap style={{ maxWidth: '100%' }}>
|
||||
<Tag
|
||||
color="blue"
|
||||
style={{
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
height: 'auto',
|
||||
lineHeight: '1.5',
|
||||
padding: '4px 8px'
|
||||
}}
|
||||
>
|
||||
大纲: {outlineTitle}
|
||||
</Tag>
|
||||
<Tag color="green">章节数: {data.chapter_count}</Tag>
|
||||
<Tag color="orange">已创建章节</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
<Tabs
|
||||
defaultActiveKey="0"
|
||||
@@ -575,41 +679,104 @@ export default function Outline() {
|
||||
items={data.expansion_plans?.map((plan, idx) => ({
|
||||
key: idx.toString(),
|
||||
label: (
|
||||
<Space size="small">
|
||||
<span style={{ fontWeight: 500 }}>{plan.sub_index}. {plan.title}</span>
|
||||
<Space size="small" style={{ maxWidth: isMobile ? '150px' : 'none' }}>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
whiteSpace: isMobile ? 'normal' : 'nowrap',
|
||||
wordBreak: isMobile ? 'break-word' : 'normal',
|
||||
fontSize: isMobile ? 12 : 14
|
||||
}}
|
||||
>
|
||||
{plan.sub_index}. {plan.title}
|
||||
</span>
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<div style={{ maxHeight: '500px', overflowY: 'auto', padding: '8px 0' }}>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card size="small" title="基本信息">
|
||||
<Space wrap>
|
||||
<Tag color="blue">{plan.emotional_tone}</Tag>
|
||||
<Tag color="orange">{plan.conflict_type}</Tag>
|
||||
<Space wrap style={{ maxWidth: '100%' }}>
|
||||
<Tag
|
||||
color="blue"
|
||||
style={{
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
height: 'auto',
|
||||
lineHeight: '1.5',
|
||||
padding: '4px 8px'
|
||||
}}
|
||||
>
|
||||
{plan.emotional_tone}
|
||||
</Tag>
|
||||
<Tag
|
||||
color="orange"
|
||||
style={{
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
height: 'auto',
|
||||
lineHeight: '1.5',
|
||||
padding: '4px 8px'
|
||||
}}
|
||||
>
|
||||
{plan.conflict_type}
|
||||
</Tag>
|
||||
<Tag color="green">约{plan.estimated_words}字</Tag>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="情节概要">
|
||||
{plan.plot_summary}
|
||||
<div style={{
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
overflowWrap: 'break-word'
|
||||
}}>
|
||||
{plan.plot_summary}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="叙事目标">
|
||||
{plan.narrative_goal}
|
||||
<div style={{
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
overflowWrap: 'break-word'
|
||||
}}>
|
||||
{plan.narrative_goal}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="关键事件">
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
{plan.key_events.map((event, eventIdx) => (
|
||||
<div key={eventIdx}>• {event}</div>
|
||||
<div
|
||||
key={eventIdx}
|
||||
style={{
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
overflowWrap: 'break-word'
|
||||
}}
|
||||
>
|
||||
• {event}
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="涉及角色">
|
||||
<Space wrap>
|
||||
<Space wrap style={{ maxWidth: '100%' }}>
|
||||
{plan.character_focus.map((char, charIdx) => (
|
||||
<Tag key={charIdx} color="purple">{char}</Tag>
|
||||
<Tag
|
||||
key={charIdx}
|
||||
color="purple"
|
||||
style={{
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
height: 'auto',
|
||||
lineHeight: '1.5'
|
||||
}}
|
||||
>
|
||||
{char}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
@@ -618,10 +785,36 @@ export default function Outline() {
|
||||
<Card size="small" title="场景">
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
{plan.scenes.map((scene, sceneIdx) => (
|
||||
<Card key={sceneIdx} size="small" style={{ backgroundColor: '#fafafa' }}>
|
||||
<div><strong>地点:</strong>{scene.location}</div>
|
||||
<div><strong>角色:</strong>{scene.characters.join('、')}</div>
|
||||
<div><strong>目的:</strong>{scene.purpose}</div>
|
||||
<Card
|
||||
key={sceneIdx}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: '#fafafa',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
overflowWrap: 'break-word'
|
||||
}}>
|
||||
<strong>地点:</strong>{scene.location}
|
||||
</div>
|
||||
<div style={{
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
overflowWrap: 'break-word'
|
||||
}}>
|
||||
<strong>角色:</strong>{scene.characters.join('、')}
|
||||
</div>
|
||||
<div style={{
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
overflowWrap: 'break-word'
|
||||
}}>
|
||||
<strong>目的:</strong>{scene.purpose}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
@@ -1160,35 +1353,13 @@ export default function Outline() {
|
||||
{renderBatchPreviewContent()}
|
||||
</Modal>
|
||||
|
||||
{/* SSE进度Modal */}
|
||||
<Modal
|
||||
title="生成大纲中"
|
||||
open={sseModalVisible}
|
||||
footer={null}
|
||||
closable={false}
|
||||
centered
|
||||
width={500}
|
||||
>
|
||||
<div style={{ padding: '20px 0' }}>
|
||||
<Progress
|
||||
percent={sseProgress}
|
||||
status={sseProgress === 100 ? 'success' : 'active'}
|
||||
strokeColor={{
|
||||
'0%': '#108ee9',
|
||||
'100%': '#87d068',
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
marginTop: 16,
|
||||
color: '#666',
|
||||
fontSize: 14,
|
||||
minHeight: 40,
|
||||
lineHeight: '20px'
|
||||
}}>
|
||||
{sseMessage}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
{/* SSE进度Modal - 使用统一组件 */}
|
||||
<SSEProgressModal
|
||||
visible={sseModalVisible}
|
||||
progress={sseProgress}
|
||||
message={sseMessage}
|
||||
title="AI生成中..."
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* 固定头部 */}
|
||||
@@ -1282,12 +1453,18 @@ export default function Outline() {
|
||||
<div style={{ width: '100%' }}>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<span style={{ fontSize: isMobile ? 14 : 16 }}>
|
||||
<span style={{ color: '#1890ff', marginRight: 8, fontWeight: 'bold' }}>
|
||||
第{item.order_index || '?'}章
|
||||
<Space size="small" style={{ fontSize: isMobile ? 14 : 16, flexWrap: 'wrap' }}>
|
||||
<span style={{ color: '#1890ff', fontWeight: 'bold' }}>
|
||||
第{item.order_index || '?'}卷
|
||||
</span>
|
||||
{item.title}
|
||||
</span>
|
||||
<span>{item.title}</span>
|
||||
{/* ✅ 新增:展开状态标识 */}
|
||||
{outlineExpandStatus[item.id] ? (
|
||||
<Tag color="success" icon={<CheckCircleOutlined />}>已展开</Tag>
|
||||
) : (
|
||||
<Tag color="default">未展开</Tag>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<div style={{ fontSize: isMobile ? 12 : 14 }}>
|
||||
|
||||
@@ -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 (
|
||||
<Col {...gridConfig} key={project.id}>
|
||||
<Badge.Ribbon
|
||||
text={getStatusTag(project.status)}
|
||||
text={project.wizard_status === 'incomplete' ? (
|
||||
<Tag color="orange" icon={<LoadingOutlined spin />}>生成中断</Tag>
|
||||
) : getStatusTag(project.status)}
|
||||
color="transparent"
|
||||
style={{ top: 12, right: 12 }}
|
||||
>
|
||||
<Card
|
||||
hoverable
|
||||
variant="borderless"
|
||||
onClick={() => handleEnterProject(project.id)}
|
||||
onClick={() => handleEnterProject(project)}
|
||||
style={cardStyles.project}
|
||||
styles={{ body: { padding: 0, overflow: 'hidden' } }}
|
||||
{...cardHoverHandlers}
|
||||
|
||||
@@ -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<string>('');
|
||||
const [projectTitle, setProjectTitle] = useState<string>('');
|
||||
|
||||
// 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<GenerationConfig | null>(null);
|
||||
const [resumeProjectId, setResumeProjectId] = useState<string | null>(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={<RocketOutlined />}
|
||||
>
|
||||
开始创建项目
|
||||
@@ -335,150 +251,12 @@ export default function ProjectWizardNew() {
|
||||
</Card>
|
||||
);
|
||||
|
||||
// 渲染生成进度页面
|
||||
const renderGenerating = () => {
|
||||
const getStepStatus = (step: 'pending' | 'processing' | 'completed' | 'error') => {
|
||||
if (step === 'completed') return { icon: <CheckCircleOutlined />, color: '#52c41a' };
|
||||
if (step === 'processing') return { icon: <LoadingOutlined />, color: '#1890ff' };
|
||||
if (step === 'error') return { icon: '✗', color: '#ff4d4f' };
|
||||
return { icon: '○', color: '#d9d9d9' };
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div style={{ textAlign: 'center', padding: isMobile ? '32px 16px' : '40px 0' }}>
|
||||
<Title level={isMobile ? 4 : 3} style={{ marginBottom: 32 }}>
|
||||
正在为《{projectTitle}》生成内容
|
||||
</Title>
|
||||
|
||||
<Progress
|
||||
percent={progress}
|
||||
status={progress === 100 ? 'success' : 'active'}
|
||||
strokeColor={{
|
||||
'0%': '#667eea',
|
||||
'100%': '#764ba2',
|
||||
}}
|
||||
style={{ marginBottom: 32 }}
|
||||
/>
|
||||
|
||||
<Paragraph style={{ fontSize: 16, marginBottom: 48, color: '#666' }}>
|
||||
{progressMessage}
|
||||
</Paragraph>
|
||||
|
||||
<Space direction="vertical" size={24} style={{ width: '100%', maxWidth: 400, margin: '0 auto' }}>
|
||||
{[
|
||||
{ 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 (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 20px',
|
||||
background: step === 'processing' ? '#f0f5ff' : '#fafafa',
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${step === 'processing' ? '#d6e4ff' : '#f0f0f0'}`,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 16, fontWeight: step === 'processing' ? 600 : 400 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<span style={{ fontSize: 20, color: status.color }}>
|
||||
{status.icon}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
|
||||
<Paragraph type="secondary" style={{ marginTop: 48 }}>
|
||||
请耐心等待,AI正在为您精心创作...
|
||||
</Paragraph>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染完成页面
|
||||
const renderComplete = () => (
|
||||
<Card>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: isMobile ? '32px 16px' : '40px 0'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: isMobile ? 56 : 72,
|
||||
color: '#52c41a',
|
||||
marginBottom: isMobile ? 16 : 24
|
||||
}}>
|
||||
✓
|
||||
</div>
|
||||
<Title
|
||||
level={isMobile ? 3 : 2}
|
||||
style={{
|
||||
color: '#52c41a',
|
||||
marginBottom: isMobile ? 8 : 16
|
||||
}}
|
||||
>
|
||||
项目创建完成!
|
||||
</Title>
|
||||
<Paragraph style={{
|
||||
fontSize: isMobile ? 14 : 16,
|
||||
marginTop: isMobile ? 16 : 24,
|
||||
marginBottom: isMobile ? 32 : 48,
|
||||
}}>
|
||||
《{projectTitle}》已成功创建,包含完整的世界观、角色和大纲节点
|
||||
</Paragraph>
|
||||
<Paragraph type="secondary" style={{
|
||||
fontSize: isMobile ? 12 : 14,
|
||||
marginTop: 8,
|
||||
}}>
|
||||
💡 提示:进入项目后,可在"大纲"页面将大纲节点展开为详细章节
|
||||
</Paragraph>
|
||||
|
||||
<Space
|
||||
size={isMobile ? 12 : 16}
|
||||
direction={isMobile ? 'vertical' : 'horizontal'}
|
||||
style={{ width: isMobile ? '100%' : 'auto' }}
|
||||
>
|
||||
<Button
|
||||
size="large"
|
||||
onClick={() => navigate('/')}
|
||||
block={isMobile}
|
||||
style={{
|
||||
minWidth: 120,
|
||||
height: isMobile ? 44 : 40
|
||||
}}
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<RocketOutlined />}
|
||||
onClick={() => navigate(`/project/${projectId}`)}
|
||||
block={isMobile}
|
||||
style={{
|
||||
minWidth: 120,
|
||||
height: isMobile ? 44 : 40
|
||||
}}
|
||||
>
|
||||
进入项目
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: '#f5f7fa',
|
||||
background: currentStep === 'generating'
|
||||
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||||
: '#f5f7fa',
|
||||
}}>
|
||||
{/* 顶部标题栏 - 固定不滚动 */}
|
||||
<div style={{
|
||||
@@ -529,16 +307,17 @@ export default function ProjectWizardNew() {
|
||||
padding: isMobile ? '16px 12px' : '24px 24px',
|
||||
}}>
|
||||
{currentStep === 'form' && renderForm()}
|
||||
{currentStep === 'generating' && renderGenerating()}
|
||||
{currentStep === 'complete' && renderComplete()}
|
||||
{currentStep === 'generating' && generationConfig && (
|
||||
<AIProjectGenerator
|
||||
config={generationConfig}
|
||||
storagePrefix="wizard"
|
||||
onComplete={handleComplete}
|
||||
onBack={handleBack}
|
||||
isMobile={isMobile}
|
||||
resumeProjectId={resumeProjectId || undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SSE加载覆盖层 */}
|
||||
<SSELoadingOverlay
|
||||
loading={loading}
|
||||
progress={progress}
|
||||
message={progressMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Character[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [editingRelationship, setEditingRelationship] = useState<Relationship | null>(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) => (
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
onClick={() => handleDeleteRelationship(record.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEditRelationship(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
onClick={() => handleDeleteRelationship(record.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
width: 80,
|
||||
width: 140,
|
||||
fixed: isMobile ? ('right' as const) : undefined,
|
||||
},
|
||||
];
|
||||
@@ -345,10 +400,12 @@ export default function Relationships() {
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="添加关系"
|
||||
title={isEditMode ? '编辑关系' : '添加关系'}
|
||||
open={isModalOpen}
|
||||
onCancel={() => {
|
||||
setIsModalOpen(false);
|
||||
setIsEditMode(false);
|
||||
setEditingRelationship(null);
|
||||
form.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
@@ -360,7 +417,7 @@ export default function Relationships() {
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleCreateRelationship}
|
||||
onFinish={isEditMode ? handleUpdateRelationship : handleCreateRelationship}
|
||||
>
|
||||
<Form.Item
|
||||
name="character_from_id"
|
||||
@@ -370,6 +427,7 @@ export default function Relationships() {
|
||||
<Select
|
||||
placeholder="选择角色"
|
||||
showSearch
|
||||
disabled={isEditMode}
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
@@ -404,6 +462,7 @@ export default function Relationships() {
|
||||
<Select
|
||||
placeholder="选择角色"
|
||||
showSearch
|
||||
disabled={isEditMode}
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
@@ -450,9 +509,14 @@ export default function Relationships() {
|
||||
|
||||
<Form.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => setIsModalOpen(false)}>取消</Button>
|
||||
<Button onClick={() => {
|
||||
setIsModalOpen(false);
|
||||
setIsEditMode(false);
|
||||
setEditingRelationship(null);
|
||||
form.resetFields();
|
||||
}}>取消</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
创建
|
||||
{isEditMode ? '更新' : '创建'}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
|
||||
@@ -109,6 +109,7 @@ export default function UserManagement() {
|
||||
</div>
|
||||
),
|
||||
width: 500,
|
||||
centered: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -191,6 +192,7 @@ export default function UserManagement() {
|
||||
</div>
|
||||
),
|
||||
width: 500,
|
||||
centered: true,
|
||||
});
|
||||
|
||||
setResetPasswordModalVisible(false);
|
||||
|
||||
Reference in New Issue
Block a user