diff --git a/.gitignore b/.gitignore index 1fa8cde..6cab4da 100644 --- a/.gitignore +++ b/.gitignore @@ -106,6 +106,7 @@ dmypy.json BUILD_GUIDE.md launcher.py launcher.spec +mumuainovel.md data/ diff --git a/README.md b/README.md index 52a637b..e67f8b0 100644 --- a/README.md +++ b/README.md @@ -351,6 +351,7 @@ MuMuAINovel/ - 提交 [Issue](https://github.com/xiamuceer-j/MuMuAINovel/issues) - Linux DO [讨论](https://linux.do/t/topic/1106333) - 加入QQ群 [QQ群](frontend/public/qq.jpg) +- 加入WX群 [WX群](frontend/public/WX.jpg) --- diff --git a/backend/app/api/chapters.py b/backend/app/api/chapters.py index 2fbb3c6..1efb5ca 100644 --- a/backend/app/api/chapters.py +++ b/backend/app/api/chapters.py @@ -28,7 +28,8 @@ from app.schemas.chapter import ( ChapterGenerateRequest, BatchGenerateRequest, BatchGenerateResponse, - BatchGenerateStatusResponse + BatchGenerateStatusResponse, + ExpansionPlanUpdate ) from app.schemas.regeneration import ( ChapterRegenerateRequest, @@ -1008,6 +1009,10 @@ async def generate_chapter_content_stream( yield f"data: {json.dumps({'type': 'error', 'error': '项目不存在'}, ensure_ascii=False)}\n\n" return + # 获取项目的大纲模式 + outline_mode = project.outline_mode if project else 'one-to-many' + logger.info(f"📋 项目大纲模式: {outline_mode}") + # 获取对应的大纲 outline_result = await db_session.execute( select(Outline) @@ -1188,6 +1193,48 @@ async def generate_chapter_content_stream( logger.warning(f"⚠️ MCP工具调用失败,降级为基础模式: {str(e)}") yield f"data: {json.dumps({'type': 'progress', 'message': '⚠️ MCP工具暂时不可用,使用基础模式', 'progress': 32}, ensure_ascii=False)}\n\n" + # 📋 根据大纲模式构建差异化的章节大纲上下文 + chapter_outline_content = "" + if outline_mode == 'one-to-one': + # 一对一模式:使用大纲的 content + chapter_outline_content = outline.content if outline else current_chapter.summary or '暂无大纲' + logger.info(f"✏️ 一对一模式:使用大纲内容作为章节指导") + else: + # 一对多模式:优先使用 expansion_plan 的详细规划 + if current_chapter.expansion_plan: + try: + plan = json.loads(current_chapter.expansion_plan) + chapter_outline_content = f"""【本章详细规划】 +剧情摘要:{plan.get('plot_summary', '无')} + +关键事件: +{chr(10).join(f'- {event}' for event in plan.get('key_events', []))} + +角色焦点:{', '.join(plan.get('character_focus', []))} + +情感基调:{plan.get('emotional_tone', '未设定')} + +叙事目标:{plan.get('narrative_goal', '未设定')} + +冲突类型:{plan.get('conflict_type', '未设定')}""" + + # 可选:附加章节 summary + if current_chapter.summary and current_chapter.summary.strip(): + chapter_outline_content += f"\n\n【章节补充说明】\n{current_chapter.summary}" + + # 可选:附加大纲的背景信息 + if outline: + chapter_outline_content += f"\n\n【大纲节点背景】\n{outline.content}" + + logger.info(f"✏️ 一对多模式:使用expansion_plan详细规划({len(chapter_outline_content)}字符)") + except json.JSONDecodeError as e: + logger.warning(f"⚠️ expansion_plan解析失败: {e},回退到大纲内容") + chapter_outline_content = outline.content if outline else current_chapter.summary or '暂无大纲' + else: + # 没有expansion_plan,使用大纲内容 + chapter_outline_content = outline.content if outline else current_chapter.summary or '暂无大纲' + logger.warning(f"⚠️ 一对多模式但无expansion_plan,使用大纲内容") + # 根据是否有前置内容选择不同的提示词,并应用写作风格、记忆增强和MCP参考资料 if previous_content: prompt = prompt_service.get_chapter_generation_with_context_prompt( @@ -1204,11 +1251,12 @@ async def generate_chapter_content_stream( previous_content=previous_content, chapter_number=current_chapter.chapter_number, chapter_title=current_chapter.title, - chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲', + chapter_outline=chapter_outline_content, style_content=style_content, target_word_count=target_word_count, memory_context=memory_context, - mcp_references=mcp_reference_materials + mcp_references=mcp_reference_materials, + outline_mode=outline_mode ) else: prompt = prompt_service.get_chapter_generation_prompt( @@ -1224,11 +1272,12 @@ async def generate_chapter_content_stream( outlines_context=outlines_context, chapter_number=current_chapter.chapter_number, chapter_title=current_chapter.title, - chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲', + chapter_outline=chapter_outline_content, style_content=style_content, target_word_count=target_word_count, memory_context=memory_context, - mcp_references=mcp_reference_materials + mcp_references=mcp_reference_materials, + outline_mode=outline_mode ) if mcp_reference_materials: @@ -1238,11 +1287,39 @@ async def generate_chapter_content_stream( # 流式生成内容 full_content = "" + chunk_count = 0 + last_progress = 0 + async for chunk in user_ai_service.generate_text_stream(prompt=prompt): full_content += chunk + chunk_count += 1 + + # 发送内容块 yield f"data: {json.dumps({'type': 'content', 'content': chunk}, ensure_ascii=False)}\n\n" + + # 每50个chunk发送一次进度更新(估算) + if chunk_count % 50 == 0: + current_word_count = len(full_content) + # 根据目标字数估算进度(35%起步,最高95%,为后续保存留5%) + estimated_progress = min(95, 35 + int((current_word_count / target_word_count) * 60)) + + # 只在进度变化时发送 + if estimated_progress > last_progress: + progress_data = { + 'type': 'progress', + 'progress': estimated_progress, + 'message': f'正在创作中... 已生成 {current_word_count} 字', + 'word_count': current_word_count, + 'status': 'processing' + } + yield f"data: {json.dumps(progress_data, ensure_ascii=False)}\n\n" + last_progress = estimated_progress + await asyncio.sleep(0) # 让出控制权 + # 发送保存进度 + yield f"data: {json.dumps({'type': 'progress', 'progress': 98, 'message': '正在保存章节...', 'status': 'processing'}, ensure_ascii=False)}\n\n" + # 更新章节内容到数据库 old_word_count = current_chapter.word_count or 0 current_chapter.content = full_content @@ -1297,6 +1374,9 @@ async def generate_chapter_content_stream( ai_service=user_ai_service ) + # 发送最终进度100% + yield f"data: {json.dumps({'type': 'progress', 'progress': 100, 'message': '创作完成!', 'word_count': new_word_count, 'status': 'success'}, ensure_ascii=False)}\n\n" + # 发送完成事件(包含分析任务ID) completion_data = { 'type': 'done', @@ -2216,6 +2296,10 @@ async def generate_single_chapter_for_batch( if not project: raise Exception("项目不存在") + # 获取项目的大纲模式 + outline_mode = project.outline_mode if project else 'one-to-many' + logger.info(f"📋 批量生成 - 项目大纲模式: {outline_mode}") + # 获取对应的大纲 outline_result = await db_session.execute( select(Outline) @@ -2285,6 +2369,48 @@ async def generate_single_chapter_for_batch( character_names=[c.name for c in characters] if characters else None ) + # 📋 根据大纲模式构建差异化的章节大纲上下文 + chapter_outline_content = "" + if outline_mode == 'one-to-one': + # 一对一模式:使用大纲的 content + chapter_outline_content = outline.content if outline else chapter.summary or '暂无大纲' + logger.info(f"✏️ 批量生成 - 一对一模式:使用大纲内容") + else: + # 一对多模式:优先使用 expansion_plan 的详细规划 + if chapter.expansion_plan: + try: + plan = json.loads(chapter.expansion_plan) + chapter_outline_content = f"""【本章详细规划】 +剧情摘要:{plan.get('plot_summary', '无')} + +关键事件: +{chr(10).join(f'- {event}' for event in plan.get('key_events', []))} + +角色焦点:{', '.join(plan.get('character_focus', []))} + +情感基调:{plan.get('emotional_tone', '未设定')} + +叙事目标:{plan.get('narrative_goal', '未设定')} + +冲突类型:{plan.get('conflict_type', '未设定')}""" + + # 可选:附加章节 summary + if chapter.summary and chapter.summary.strip(): + chapter_outline_content += f"\n\n【章节补充说明】\n{chapter.summary}" + + # 可选:附加大纲的背景信息 + if outline: + chapter_outline_content += f"\n\n【大纲节点背景】\n{outline.content}" + + logger.info(f"✏️ 批量生成 - 一对多模式:使用expansion_plan详细规划") + except json.JSONDecodeError as e: + logger.warning(f"⚠️ expansion_plan解析失败: {e},回退到大纲内容") + chapter_outline_content = outline.content if outline else chapter.summary or '暂无大纲' + else: + # 没有expansion_plan,使用大纲内容 + chapter_outline_content = outline.content if outline else chapter.summary or '暂无大纲' + logger.warning(f"⚠️ 批量生成 - 一对多模式但无expansion_plan,使用大纲内容") + # 生成提示词 if previous_content: prompt = prompt_service.get_chapter_generation_with_context_prompt( @@ -2301,10 +2427,11 @@ async def generate_single_chapter_for_batch( previous_content=previous_content, chapter_number=chapter.chapter_number, chapter_title=chapter.title, - chapter_outline=outline.content if outline else chapter.summary or '暂无大纲', + chapter_outline=chapter_outline_content, style_content=style_content, target_word_count=target_word_count, - memory_context=memory_context + memory_context=memory_context, + outline_mode=outline_mode ) else: prompt = prompt_service.get_chapter_generation_prompt( @@ -2320,10 +2447,11 @@ async def generate_single_chapter_for_batch( outlines_context=outlines_context, chapter_number=chapter.chapter_number, chapter_title=chapter.title, - chapter_outline=outline.content if outline else chapter.summary or '暂无大纲', + chapter_outline=chapter_outline_content, style_content=style_content, target_word_count=target_word_count, - memory_context=memory_context + memory_context=memory_context, + outline_mode=outline_mode ) # 非流式生成内容 @@ -2643,3 +2771,64 @@ async def get_regeneration_tasks( ] } + +@router.put("/{chapter_id}/expansion-plan", response_model=dict, summary="更新章节规划信息") +async def update_chapter_expansion_plan( + chapter_id: str, + expansion_plan: ExpansionPlanUpdate, + request: Request, + db: AsyncSession = Depends(get_db) +): + """ + 更新章节的展开规划信息 + + Args: + chapter_id: 章节ID + expansion_plan: 规划信息更新数据 + + Returns: + 更新后的章节规划信息 + """ + # 获取章节 + result = await db.execute( + select(Chapter).where(Chapter.id == chapter_id) + ) + chapter = result.scalar_one_or_none() + + if not chapter: + raise HTTPException(status_code=404, detail="章节不存在") + + # 验证用户权限 + user_id = getattr(request.state, 'user_id', None) + await verify_project_access(chapter.project_id, user_id, db) + + # 准备更新数据(排除None值) + plan_data = expansion_plan.model_dump(exclude_unset=True, exclude_none=True) + + # 如果已有规划,合并更新;否则创建新规划 + if chapter.expansion_plan: + try: + existing_plan = json.loads(chapter.expansion_plan) + # 合并更新 + existing_plan.update(plan_data) + chapter.expansion_plan = json.dumps(existing_plan, ensure_ascii=False) + except json.JSONDecodeError: + logger.warning(f"章节 {chapter_id} 的expansion_plan格式错误,将覆盖") + chapter.expansion_plan = json.dumps(plan_data, ensure_ascii=False) + else: + chapter.expansion_plan = json.dumps(plan_data, ensure_ascii=False) + + await db.commit() + await db.refresh(chapter) + + logger.info(f"章节规划更新成功: {chapter_id}") + + # 返回更新后的规划数据 + updated_plan = json.loads(chapter.expansion_plan) if chapter.expansion_plan else None + + return { + "id": chapter.id, + "expansion_plan": updated_plan, + "message": "规划信息更新成功" + } + diff --git a/backend/app/api/outlines.py b/backend/app/api/outlines.py index b1cb953..87552ea 100644 --- a/backend/app/api/outlines.py +++ b/backend/app/api/outlines.py @@ -236,18 +236,29 @@ async def delete_outline( # 验证用户权限 user_id = getattr(request.state, 'user_id', None) - await verify_project_access(outline.project_id, user_id, db) + project = await verify_project_access(outline.project_id, user_id, db) project_id = outline.project_id deleted_order = outline.order_index - # 删除该大纲对应的所有章节(通过outline_id关联) - delete_result = await db.execute( - delete(Chapter).where(Chapter.outline_id == outline_id) - ) - deleted_chapters_count = delete_result.rowcount - - logger.info(f"删除大纲 {outline_id},同时删除了 {deleted_chapters_count} 个关联章节") + # 根据项目模式删除对应的章节 + if project.outline_mode == 'one-to-one': + # one-to-one模式:通过chapter_number删除对应章节 + delete_result = await db.execute( + delete(Chapter).where( + Chapter.project_id == project_id, + Chapter.chapter_number == outline.order_index + ) + ) + deleted_chapters_count = delete_result.rowcount + logger.info(f"一对一模式:删除大纲 {outline_id}(序号{outline.order_index}),同时删除了第{outline.order_index}章({deleted_chapters_count}个章节)") + else: + # one-to-many模式:通过outline_id删除关联章节 + delete_result = await db.execute( + delete(Chapter).where(Chapter.outline_id == outline_id) + ) + deleted_chapters_count = delete_result.rowcount + logger.info(f"一对多模式:删除大纲 {outline_id},同时删除了 {deleted_chapters_count} 个关联章节") # 删除大纲 await db.delete(outline) @@ -264,6 +275,21 @@ async def delete_outline( for o in subsequent_outlines: o.order_index -= 1 + # 如果是one-to-one模式,还需要重新排序后续章节的chapter_number + if project.outline_mode == 'one-to-one': + chapters_result = await db.execute( + select(Chapter).where( + Chapter.project_id == project_id, + Chapter.chapter_number > deleted_order + ).order_by(Chapter.chapter_number) + ) + subsequent_chapters = chapters_result.scalars().all() + + for ch in subsequent_chapters: + ch.chapter_number -= 1 + + logger.info(f"一对一模式:重新排序了 {len(subsequent_chapters)} 个后续章节") + await db.commit() return { @@ -852,7 +878,17 @@ async def _save_outlines( db: AsyncSession, start_index: int = 1 ) -> List[Outline]: - """保存大纲到数据库(不自动创建章节)""" + """ + 保存大纲到数据库 + + 如果项目为one-to-one模式,同时自动创建对应的章节 + """ + # 获取项目信息以确定outline_mode + project_result = await db.execute( + select(Project).where(Project.id == project_id) + ) + project = project_result.scalar_one_or_none() + outlines = [] for idx, chapter_data in enumerate(outline_data): @@ -879,6 +915,28 @@ async def _save_outlines( db.add(outline) outlines.append(outline) + # 如果是one-to-one模式,自动创建章节 + if project and project.outline_mode == 'one-to-one': + await db.flush() # 确保大纲有ID + + for outline in outlines: + await db.refresh(outline) + + # 为每个大纲创建对应的章节 + chapter = Chapter( + project_id=project_id, + title=outline.title, + summary=outline.content, + chapter_number=outline.order_index, + sub_index=1, + outline_id=None, # one-to-one模式不关联outline_id + status='pending', + content="" + ) + db.add(chapter) + + logger.info(f"一对一模式:为{len(outlines)}个大纲自动创建了对应的章节") + return outlines @@ -1646,6 +1704,104 @@ async def expand_outline_generator( yield await SSEResponse.send_error(f"展开失败: {str(e)}") +@router.post("/{outline_id}/create-single-chapter", summary="一对一创建章节(传统模式)") +async def create_single_chapter_from_outline( + outline_id: str, + request: Request, + db: AsyncSession = Depends(get_db) +): + """ + 传统模式:一个大纲对应创建一个章节 + + 适用场景: + - 项目的outline_mode为'one-to-one' + - 直接将大纲内容作为章节摘要 + - 不调用AI,不展开 + + 流程: + 1. 验证项目模式为one-to-one + 2. 检查该大纲是否已创建章节 + 3. 创建章节记录(outline_id=NULL,chapter_number=outline.order_index) + + 返回:创建的章节信息 + """ + # 验证用户权限 + user_id = getattr(request.state, 'user_id', None) + + # 获取大纲 + result = await db.execute( + select(Outline).where(Outline.id == outline_id) + ) + outline = result.scalar_one_or_none() + + if not outline: + raise HTTPException(status_code=404, detail="大纲不存在") + + # 验证项目权限并获取项目信息 + project = await verify_project_access(outline.project_id, user_id, db) + + # 验证项目模式 + if project.outline_mode != 'one-to-one': + raise HTTPException( + status_code=400, + detail=f"当前项目为{project.outline_mode}模式,不支持一对一创建。请使用展开功能。" + ) + + # 检查该大纲对应的章节是否已存在 + existing_chapter_result = await db.execute( + select(Chapter).where( + Chapter.project_id == outline.project_id, + Chapter.chapter_number == outline.order_index, + Chapter.sub_index == 1 + ) + ) + existing_chapter = existing_chapter_result.scalar_one_or_none() + + if existing_chapter: + raise HTTPException( + status_code=400, + detail=f"第{outline.order_index}章已存在,不能重复创建" + ) + + try: + # 创建章节(outline_id=NULL表示一对一模式) + new_chapter = Chapter( + project_id=outline.project_id, + title=outline.title, + summary=outline.content, # 使用大纲内容作为摘要 + chapter_number=outline.order_index, + sub_index=1, # 一对一模式固定为1 + outline_id=None, # 传统模式不关联outline_id + status='pending' + ) + + db.add(new_chapter) + await db.commit() + await db.refresh(new_chapter) + + logger.info(f"一对一模式:为大纲 {outline.title} 创建章节 {new_chapter.chapter_number}") + + return { + "message": "章节创建成功", + "chapter": { + "id": new_chapter.id, + "project_id": new_chapter.project_id, + "title": new_chapter.title, + "summary": new_chapter.summary, + "chapter_number": new_chapter.chapter_number, + "sub_index": new_chapter.sub_index, + "outline_id": new_chapter.outline_id, + "status": new_chapter.status, + "created_at": new_chapter.created_at.isoformat() if new_chapter.created_at else None + } + } + + except Exception as e: + logger.error(f"一对一创建章节失败: {str(e)}", exc_info=True) + await db.rollback() + raise HTTPException(status_code=500, detail=f"创建章节失败: {str(e)}") + + @router.post("/{outline_id}/expand", response_model=OutlineExpansionResponse, summary="展开单个大纲为多章") async def expand_outline_to_chapters( outline_id: str, @@ -1681,8 +1837,15 @@ async def expand_outline_to_chapters( if not outline: raise HTTPException(status_code=404, detail="大纲不存在") - # 验证项目权限 - await verify_project_access(outline.project_id, user_id, db) + # 验证项目权限并获取项目信息 + project = await verify_project_access(outline.project_id, user_id, db) + + # 验证项目模式 + if project.outline_mode != 'one-to-many': + raise HTTPException( + status_code=400, + detail=f"当前项目为{project.outline_mode}模式,不支持展开功能。请使用一对一创建。" + ) try: # 创建展开服务实例 diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py index 05a946e..02eec41 100644 --- a/backend/app/api/projects.py +++ b/backend/app/api/projects.py @@ -354,9 +354,8 @@ async def export_project_chapters( txt_content.append("\n" + "=" * 80 + "\n\n") for chapter in chapters: - # 处理子章节序号显示 - chapter_display = f"{chapter.chapter_number}-{chapter.sub_index}" if chapter.sub_index and chapter.sub_index > 1 else str(chapter.chapter_number) - txt_content.append(f"第 {chapter_display} 章 {chapter.title}") + # 只显示主章节号,不显示子索引 + txt_content.append(f"第 {chapter.chapter_number} 章 {chapter.title}") txt_content.append("-" * 80) txt_content.append("") # 空行 diff --git a/backend/app/api/wizard_stream.py b/backend/app/api/wizard_stream.py index b3a0ab3..8e91a03 100644 --- a/backend/app/api/wizard_stream.py +++ b/backend/app/api/wizard_stream.py @@ -47,6 +47,7 @@ async def world_building_generator( target_words = data.get("target_words") chapter_count = data.get("chapter_count") character_count = data.get("character_count") + outline_mode = data.get("outline_mode", "one-to-many") # 大纲模式,默认一对多 provider = data.get("provider") model = data.get("model") enable_mcp = data.get("enable_mcp", True) # 默认启用MCP @@ -215,6 +216,7 @@ async def world_building_generator( target_words=target_words, chapter_count=chapter_count, character_count=character_count, + outline_mode=outline_mode, # 设置大纲模式 wizard_status="incomplete", wizard_step=1, status="planning" @@ -1017,39 +1019,82 @@ async def outline_generator( logger.info(f"✅ 成功创建{len(created_outlines)}个大纲节点") - # 向导流程中不展开大纲,避免等待时间过长 - # 用户可以在大纲页面手动展开需要的大纲节点 - yield await SSEResponse.send_progress("跳过大纲展开,加快创建速度...", 85) + # 根据项目的大纲模式决定是否自动创建章节 + created_chapters = [] + if project.outline_mode == 'one-to-one': + # 一对一模式:自动为每个大纲创建对应的章节 + yield await SSEResponse.send_progress("一对一模式:自动创建章节...", 50) + + for outline in created_outlines: + chapter = Chapter( + project_id=project_id, + title=outline.title, + content="", # 空内容,等待用户生成 + outline_id=None, # 一对一模式下不关联outline_id + chapter_number=outline.order_index, # 使用chapter_number而不是order_index + status="pending" + ) + db.add(chapter) + created_chapters.append(chapter) + + await db.flush() + for chapter in created_chapters: + await db.refresh(chapter) + + logger.info(f"✅ 一对一模式:自动创建了{len(created_chapters)}个章节") + yield await SSEResponse.send_progress(f"已自动创建{len(created_chapters)}个章节", 85) + else: + # 一对多模式:跳过自动创建,用户可手动展开 + yield await SSEResponse.send_progress("细化模式:跳过自动创建章节", 85) + logger.info(f"📝 细化模式:跳过章节创建,用户可在大纲页面手动展开") # 更新项目信息 - project.chapter_count = 0 # 向导阶段不创建章节 + project.chapter_count = len(created_chapters) # 记录实际创建的章节数 project.narrative_perspective = narrative_perspective project.target_words = target_words project.status = "writing" project.wizard_status = "completed" - project.wizard_step = 3 + project.wizard_step = 3 await db.commit() db_committed = True logger.info(f"📊 向导大纲生成完成:") logger.info(f" - 创建大纲节点:{len(created_outlines)} 个") - logger.info(f" - 提示:可在大纲页面手动展开为章节") + logger.info(f" - 创建章节:{len(created_chapters)} 个") + logger.info(f" - 大纲模式:{project.outline_mode}") + + # 构建结果消息 + if project.outline_mode == 'one-to-one': + result_message = f"成功生成{len(created_outlines)}个大纲节点并自动创建{len(created_chapters)}个章节(传统模式)" + result_note = "已自动创建章节,可直接生成内容" + else: + result_message = f"成功生成{len(created_outlines)}个大纲节点(细化模式,可在大纲页面手动展开)" + result_note = "可在大纲页面展开为多个章节" # 发送结果 yield await SSEResponse.send_result({ - "message": f"成功生成{len(created_outlines)}个大纲节点(未展开章节,可在大纲页面手动展开)", + "message": result_message, "outline_count": len(created_outlines), - "chapter_count": 0, + "chapter_count": len(created_chapters), + "outline_mode": project.outline_mode, "outlines": [ { "id": outline.id, "order_index": outline.order_index, "title": outline.title, "content": outline.content[:100] + "..." if len(outline.content) > 100 else outline.content, - "note": "可在大纲页面展开为章节" + "note": result_note } for outline in created_outlines - ] + ], + "chapters": [ + { + "id": chapter.id, + "chapter_number": chapter.chapter_number, + "title": chapter.title, + "status": chapter.status + } for chapter in created_chapters + ] if created_chapters else [] }) yield await SSEResponse.send_progress("完成!", 100, "success") diff --git a/backend/app/models/project.py b/backend/app/models/project.py index b506811..610dad2 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -1,5 +1,5 @@ """项目数据模型""" -from sqlalchemy import Column, String, Text, DateTime, Integer +from sqlalchemy import Column, String, Text, DateTime, Integer, CheckConstraint from sqlalchemy.sql import func from app.database import Base import uuid @@ -20,6 +20,7 @@ class Project(Base): status = Column(String(20), default="planning", comment="创作状态") wizard_status = Column(String(20), default="incomplete", comment="向导完成状态: incomplete/completed") wizard_step = Column(Integer, default=0, comment="向导当前步骤: 0-4") + outline_mode = Column(String(20), nullable=False, default="one-to-many", comment="大纲章节模式: one-to-one(传统模式) 或 one-to-many(细化模式)") # 世界构建字段 world_time_period = Column(Text, comment="时间背景") @@ -35,5 +36,12 @@ class Project(Base): created_at = Column(DateTime, server_default=func.now(), comment="创建时间") updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + __table_args__ = ( + CheckConstraint( + "outline_mode IN ('one-to-one', 'one-to-many')", + name='check_outline_mode' + ), + ) + def __repr__(self): return f"" \ No newline at end of file diff --git a/backend/app/schemas/chapter.py b/backend/app/schemas/chapter.py index 7c31668..3054437 100644 --- a/backend/app/schemas/chapter.py +++ b/backend/app/schemas/chapter.py @@ -1,6 +1,6 @@ """章节相关的Pydantic模型""" from pydantic import BaseModel, Field -from typing import Optional +from typing import Optional, List, Dict, Any from datetime import datetime @@ -118,4 +118,48 @@ class BatchGenerateStatusResponse(BaseModel): created_at: Optional[str] = None started_at: Optional[str] = None completed_at: Optional[str] = None - error_message: Optional[str] = None \ No newline at end of file + error_message: Optional[str] = None + + +class SceneData(BaseModel): + """场景数据模型""" + location: str = Field(..., description="场景地点") + characters: List[str] = Field(..., description="参与角色列表") + purpose: str = Field(..., description="场景目的") + + +class ExpansionPlanUpdate(BaseModel): + """章节规划更新模型""" + key_events: Optional[List[str]] = Field(None, description="关键事件列表") + character_focus: Optional[List[str]] = Field(None, description="涉及角色列表") + emotional_tone: Optional[str] = Field(None, description="情感基调") + narrative_goal: Optional[str] = Field(None, description="叙事目标") + conflict_type: Optional[str] = Field(None, description="冲突类型") + estimated_words: Optional[int] = Field(None, description="预估字数", ge=500, le=10000) + scenes: Optional[List[SceneData]] = Field(None, description="场景列表") + + class Config: + json_schema_extra = { + "example": { + "key_events": ["主角遇到挑战", "关键决策时刻"], + "character_focus": ["张三", "李四"], + "emotional_tone": "紧张激烈", + "narrative_goal": "推进主线剧情", + "conflict_type": "内心冲突", + "estimated_words": 3000, + "scenes": [ + { + "location": "城市广场", + "characters": ["张三", "李四"], + "purpose": "初次相遇" + } + ] + } + } + + +class ExpansionPlanResponse(BaseModel): + """章节规划响应模型""" + id: str = Field(..., description="章节ID") + expansion_plan: Optional[Dict[str, Any]] = Field(None, description="规划数据") + message: str = Field(..., description="响应消息") \ No newline at end of file diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index 8625f01..78806e5 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -1,6 +1,6 @@ """项目相关的Pydantic模型""" from pydantic import BaseModel, Field -from typing import Optional +from typing import Optional, Literal from datetime import datetime @@ -11,6 +11,10 @@ class ProjectBase(BaseModel): theme: Optional[str] = Field(None, description="主题") genre: Optional[str] = Field(None, description="小说类型") target_words: Optional[int] = Field(None, description="目标字数") + outline_mode: Literal["one-to-one", "one-to-many"] = Field( + default="one-to-many", + description="大纲章节模式: one-to-one(传统模式,1大纲→1章节) 或 one-to-many(细化模式,1大纲→N章节)" + ) class ProjectCreate(ProjectBase): @@ -51,6 +55,7 @@ class ProjectResponse(ProjectBase): chapter_count: Optional[int] = None narrative_perspective: Optional[str] = None character_count: Optional[int] = None + outline_mode: str # 显式声明以确保响应中包含 created_at: datetime updated_at: datetime @@ -73,6 +78,10 @@ class ProjectWizardRequest(BaseModel): narrative_perspective: str = Field(..., description="叙事视角") character_count: int = Field(5, ge=5, description="角色数量(至少5个)") target_words: Optional[int] = Field(None, description="目标字数") + outline_mode: Literal["one-to-one", "one-to-many"] = Field( + default="one-to-many", + description="大纲章节模式" + ) class WorldBuildingResponse(BaseModel): diff --git a/backend/app/services/prompt_service.py b/backend/app/services/prompt_service.py index 31187e7..8d8de78 100644 --- a/backend/app/services/prompt_service.py +++ b/backend/app/services/prompt_service.py @@ -207,14 +207,16 @@ class PromptService: 4. **无特殊符号**:文本中不使用引号、方括号等特殊符号包裹内容 5. **丰富细节**:每个字段提供充实的原创内容,避免模板化表达 -# 反面示例(避免这样的设定) -❌ 不好的设定:故事设定在大崩解后的XX纪元、新世界秩序、文明重启... -✅ 好的设定:故事设定在2024年的深圳,互联网创业浪潮下的年轻人... +请根据输入的类型和主题,生成**规模适当、风格匹配**的世界观设定。 -❌ 不好的设定:升华纪元、共鸣指数、灵光纯度...(现代都市题材不要用这些) -✅ 好的设定:通过高考分数、学历背景、家庭条件来衡量个人价值...(符合现实) +# JSON格式示例 -请根据输入的类型和主题,生成**规模适当、风格匹配**的世界观设定。""" +{{ + "time_period": "时间背景与社会状态的详细描述(300-500字)", + "location": "空间环境与地理特征的详细描述(300-500字)", + "atmosphere": "感官体验与情感基调的详细描述(300-500字)", + "rules": "世界规则与社会结构的详细描述(300-500字)" +}}""" # 批量角色生成提示词 CHARACTERS_BATCH_GENERATION = """你是一位专业的角色设定师。请根据以下世界观和要求,生成{count}个立体丰满的角色和组织: @@ -1019,7 +1021,8 @@ class PromptService: chapter_outline: str, style_content: str = "", target_word_count: int = 3000, memory_context: dict = None, - mcp_references: str = "") -> str: + mcp_references: str = "", + outline_mode: str = "one-to-many") -> str: """ 获取章节完整创作提示词 @@ -1028,6 +1031,7 @@ class PromptService: target_word_count: 目标字数,默认3000字 memory_context: 记忆上下文(可选) mcp_references: MCP工具搜索的参考资料(可选) + outline_mode: 大纲模式 (one-to-one/one-to-many) """ # 计算最大字数(目标字数+1000) max_word_count = target_word_count + 1000 @@ -1050,6 +1054,13 @@ class PromptService: mcp_text += mcp_references mcp_text += "\n" + # 根据大纲模式添加创作指导 + mode_instruction = "" + if outline_mode == 'one-to-one': + mode_instruction = "\n\n【创作模式说明】\n本章采用一对一模式:一个大纲节点对应一个章节。请充分展开大纲中的情节,注重叙事的完整性和丰满度。\n" + else: + mode_instruction = "\n\n【创作模式说明】\n本章采用细纲模式:本章是大纲节点的细化展开之一。请严格遵循上述详细规划中的剧情点、角色焦点和情感基调,确保与整体规划保持一致。\n" + base_prompt = cls.format_prompt( cls.CHAPTER_GENERATION, title=title, @@ -1079,7 +1090,13 @@ class PromptService: if insert_text: base_prompt = base_prompt.replace( "本章信息:", - insert_text + "\n\n本章信息:" + insert_text + mode_instruction + "\n\n本章信息:" + ) + else: + # 没有记忆和MCP时也要插入模式说明 + base_prompt = base_prompt.replace( + "本章信息:", + mode_instruction + "\n\n本章信息:" ) # 如果有风格要求,应用到提示词中 @@ -1098,7 +1115,8 @@ class PromptService: style_content: str = "", target_word_count: int = 3000, memory_context: dict = None, - mcp_references: str = "") -> str: + mcp_references: str = "", + outline_mode: str = "one-to-many") -> str: """ 获取章节完整创作提示词(带前置章节上下文和记忆增强) @@ -1107,6 +1125,7 @@ class PromptService: target_word_count: 目标字数,默认3000字 memory_context: 记忆上下文(可选) mcp_references: MCP工具搜索的参考资料(可选) + outline_mode: 大纲模式 (one-to-one/one-to-many) """ # 计算最大字数(目标字数+1000) max_word_count = target_word_count + 1000 @@ -1128,6 +1147,13 @@ class PromptService: memory_text += "以下是通过MCP工具搜索到的相关参考资料,可用于丰富情节和细节:\n\n" memory_text += mcp_references + # 根据大纲模式添加创作指导 + mode_instruction = "" + if outline_mode == 'one-to-one': + mode_instruction = "\n\n【创作模式说明】\n本章采用一对一模式:一个大纲节点对应一个章节。请在承接前文的基础上,充分展开大纲中的情节,保持叙事的完整性。\n" + else: + mode_instruction = "\n\n【创作模式说明】\n本章采用细纲模式:本章是大纲节点的细化展开之一。请严格遵循上述详细规划(expansion_plan)中的剧情点、角色焦点、情感基调和叙事目标,确保与整体规划保持一致,同时自然衔接前文内容。\n" + base_prompt = cls.format_prompt( cls.CHAPTER_GENERATION_WITH_CONTEXT, title=title, @@ -1149,6 +1175,12 @@ class PromptService: memory_context=memory_text ) + # 插入模式说明 + base_prompt = base_prompt.replace( + "本章信息:", + mode_instruction + "\n本章信息:" + ) + # 如果有风格要求,应用到提示词中 if style_content: return WritingStyleManager.apply_style_to_prompt(base_prompt, style_content) diff --git a/backend/scripts/migration_add_outline_mode.sql b/backend/scripts/migration_add_outline_mode.sql new file mode 100644 index 0000000..24c0e88 --- /dev/null +++ b/backend/scripts/migration_add_outline_mode.sql @@ -0,0 +1,25 @@ +-- Migration: Add outline_mode to projects table +-- Description: 为项目表添加大纲模式字段,支持一对一和一对多两种模式 +-- Date: 2025-11-27 + +-- 1. 添加 outline_mode 字段 +ALTER TABLE projects +ADD COLUMN outline_mode VARCHAR(20) NOT NULL DEFAULT 'one-to-many'; + +-- 2. 添加检查约束,确保只能是两个有效值之一 +ALTER TABLE projects +ADD CONSTRAINT check_outline_mode +CHECK (outline_mode IN ('one-to-one', 'one-to-many')); + +-- 3. 创建索引以提高查询性能 +CREATE INDEX idx_projects_outline_mode ON projects(outline_mode); + +-- 4. 为现有项目设置默认模式为一对多(细化模式) +-- 这是因为现有项目大多使用展开功能 +UPDATE projects SET outline_mode = 'one-to-many' WHERE outline_mode IS NULL; + +-- 5. 添加注释 +COMMENT ON COLUMN projects.outline_mode IS '大纲章节模式: one-to-one(传统模式,1大纲→1章节) 或 one-to-many(细化模式,1大纲→N章节)'; + +-- 验证迁移结果 +-- SELECT id, title, outline_mode FROM projects LIMIT 10; \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index eb63205..37e1f44 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.0.5", + "version": "1.0.6", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/public/WX.png b/frontend/public/WX.png new file mode 100644 index 0000000..5adfc91 Binary files /dev/null and b/frontend/public/WX.png differ diff --git a/frontend/src/components/AIProjectGenerator.tsx b/frontend/src/components/AIProjectGenerator.tsx index 5798863..c678e39 100644 --- a/frontend/src/components/AIProjectGenerator.tsx +++ b/frontend/src/components/AIProjectGenerator.tsx @@ -16,6 +16,7 @@ export interface GenerationConfig { target_words: number; chapter_count: number; character_count: number; + outline_mode?: 'one-to-one' | 'one-to-many'; // 大纲章节模式 } interface AIProjectGeneratorProps { @@ -183,6 +184,7 @@ export const AIProjectGenerator: React.FC = ({ target_words: data.target_words, chapter_count: data.chapter_count, character_count: data.character_count, + outline_mode: data.outline_mode || 'one-to-many', // 传递大纲模式 }, { onProgress: (msg, prog) => { @@ -328,6 +330,7 @@ export const AIProjectGenerator: React.FC = ({ target_words: data.target_words, chapter_count: data.chapter_count, character_count: data.character_count, + outline_mode: data.outline_mode || 'one-to-many', // 传递大纲模式 }, { onProgress: (msg, prog) => { @@ -504,6 +507,7 @@ export const AIProjectGenerator: React.FC = ({ target_words: generationData.target_words, chapter_count: generationData.chapter_count, character_count: generationData.character_count, + outline_mode: generationData.outline_mode || 'one-to-many', // 传递大纲模式 }, { onProgress: (msg, prog) => { diff --git a/frontend/src/components/AnnouncementModal.tsx b/frontend/src/components/AnnouncementModal.tsx index d5b8583..b0811e5 100644 --- a/frontend/src/components/AnnouncementModal.tsx +++ b/frontend/src/components/AnnouncementModal.tsx @@ -5,14 +5,17 @@ interface AnnouncementModalProps { visible: boolean; onClose: () => void; onDoNotShowToday: () => void; + onNeverShow: () => void; } -export default function AnnouncementModal({ visible, onClose, onDoNotShowToday }: AnnouncementModalProps) { - const [imageError, setImageError] = useState(false); +export default function AnnouncementModal({ visible, onClose, onDoNotShowToday, onNeverShow }: AnnouncementModalProps) { + const [qqImageError, setQqImageError] = useState(false); + const [wxImageError, setWxImageError] = useState(false); useEffect(() => { if (visible) { - setImageError(false); + setQqImageError(false); + setWxImageError(false); } }, [visible]); @@ -21,6 +24,11 @@ export default function AnnouncementModal({ visible, onClose, onDoNotShowToday } onClose(); }; + const handleNeverShow = () => { + onNeverShow(); + onClose(); + }; + return ( - - } - width={600} + width={800} centered styles={{ body: { @@ -65,44 +73,120 @@ export default function AnnouncementModal({ visible, onClose, onDoNotShowToday }
  • 📚 分享创作经验和灵感
  • - 扫描下方二维码加入QQ交流群: + 扫描下方二维码加入交流群:

    - {!imageError ? ( +
    + {/* QQ 二维码 */}
    - QQ交流群二维码 setImageError(true)} - /> -
    - ) : ( -
    -

    二维码加载失败

    -

    - 请确保 qq.jpg 文件位于 frontend/public/ 目录下 +

    + QQ交流群

    + {!qqImageError ? ( +
    + QQ交流群二维码 setQqImageError(true)} + /> +
    + ) : ( +
    +

    二维码加载失败

    +
    + )}
    - )} + + {/* 微信二维码 */} +
    +

    + 微信交流群 +

    + {!wxImageError ? ( +
    + 微信交流群二维码 setWxImageError(true)} + /> +
    + ) : ( +
    +

    二维码加载失败

    +
    + )} +
    +
    - 💡 提示:点击"今天内不再提示"可在今天内不再显示此公告 + 💡 提示:选择"今日内不再展示"当天不再显示,选择"永不再展示"将永久隐藏此公告
    diff --git a/frontend/src/components/AppFooter.tsx b/frontend/src/components/AppFooter.tsx index cb56267..636b818 100644 --- a/frontend/src/components/AppFooter.tsx +++ b/frontend/src/components/AppFooter.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; -import { Typography, Space, Divider, Badge, Tooltip } from 'antd'; -import { GithubOutlined, CopyrightOutlined, HeartFilled, ClockCircleOutlined } from '@ant-design/icons'; +import { Typography, Space, Divider, Badge, Tooltip, Button } from 'antd'; +import { GithubOutlined, CopyrightOutlined, HeartFilled, ClockCircleOutlined, GiftOutlined } from '@ant-design/icons'; import { VERSION_INFO, getVersionString } from '../config/version'; import { checkLatestVersion } from '../services/versionService'; @@ -88,6 +88,26 @@ export default function AppFooter() { + + + {/* 赞助按钮 */} + + {/* 许可证 */} Promise; + onCancel: () => void; +} + +export default function ExpansionPlanEditor({ + visible, + planData, + projectId, + onSave, + onCancel +}: ExpansionPlanEditorProps) { + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + + // 关键事件标签输入 + const [keyEventInput, setKeyEventInput] = useState(''); + const [keyEvents, setKeyEvents] = useState([]); + + // 角色列表和选择 + const [availableCharacters, setAvailableCharacters] = useState([]); + const [characters, setCharacters] = useState([]); + const [loadingCharacters, setLoadingCharacters] = useState(false); + + // 加载项目角色列表 + useEffect(() => { + if (visible && projectId) { + loadCharacters(); + } + }, [visible, projectId]); + + const loadCharacters = async () => { + try { + setLoadingCharacters(true); + setAvailableCharacters([]); // 重置为空数组 + const response = await characterApi.getCharacters(projectId); + console.log('加载到的角色数据:', response); + + // API返回的是 {total, items} 格式,需要提取items + let chars: Character[] = []; + if (Array.isArray(response)) { + chars = response; + } else if (response && typeof response === 'object' && 'items' in response && Array.isArray((response as any).items)) { + chars = (response as any).items; + } else { + console.error('角色API返回格式异常:', response); + message.warning('角色数据格式异常'); + } + + setAvailableCharacters(chars); + console.log('设置的角色列表:', chars); + } catch (error: any) { + console.error('加载角色列表失败:', error); + setAvailableCharacters([]); + message.error('加载角色列表失败: ' + (error?.message || '未知错误')); + } finally { + setLoadingCharacters(false); + } + }; + + // 当planData变化时更新状态 + useEffect(() => { + if (planData) { + setKeyEvents(planData.key_events || []); + setCharacters(planData.character_focus || []); + form.setFieldsValue({ + emotional_tone: planData.emotional_tone, + narrative_goal: planData.narrative_goal, + conflict_type: planData.conflict_type, + estimated_words: planData.estimated_words + }); + } else { + // 重置状态 + setKeyEvents([]); + setCharacters([]); + form.resetFields(); + } + }, [planData, form, visible]); + + const handleAddKeyEvent = () => { + if (keyEventInput.trim()) { + setKeyEvents([...keyEvents, keyEventInput.trim()]); + setKeyEventInput(''); + } + }; + + const handleAddCharacter = (characterName: string) => { + if (characterName && !characters.includes(characterName)) { + setCharacters([...characters, characterName]); + } + }; + + const handleSubmit = async () => { + try { + setLoading(true); + const values = await form.validateFields(); + + // 验证至少有一个关键事件 + if (keyEvents.length === 0) { + message.warning('请至少添加一个关键事件'); + setLoading(false); + return; + } + + // 验证至少有一个角色 + if (characters.length === 0) { + message.warning('请至少添加一个涉及角色'); + setLoading(false); + return; + } + + const updatedPlan: ExpansionPlanData = { + key_events: keyEvents, + character_focus: characters, + emotional_tone: values.emotional_tone, + narrative_goal: values.narrative_goal, + conflict_type: values.conflict_type, + estimated_words: values.estimated_words, + scenes: planData?.scenes || null + }; + + await onSave(updatedPlan); + // message.success('规划信息保存成功'); + } catch (error) { + console.error('保存失败:', error); + message.error('保存失败,请重试'); + } finally { + setLoading(false); + } + }; + + const handleCancel = () => { + form.resetFields(); + setKeyEvents([]); + setCharacters([]); + setKeyEventInput(''); + onCancel(); + }; + + return ( + + 取消 + , + + ]} + > +
    + {/* 关键事件 */} + + + + setKeyEventInput(e.target.value)} + onPressEnter={handleAddKeyEvent} + /> + + + + {keyEvents.map((event, idx) => ( + { + e.preventDefault(); + setKeyEvents(keyEvents.filter((_, i) => i !== idx)); + }} + color="purple" + style={{ marginBottom: 8 }} + > + #{idx + 1} + {event} + + ))} + + + + + {/* 涉及角色 */} + + + + + + {/* 冲突类型 */} + + + + + {/* 预估字数 */} + + `${value} 字`} + parser={(value) => value?.replace(' 字', '') as any} + /> + + + {/* 叙事目标 */} + +