diff --git a/backend/app/api/characters.py b/backend/app/api/characters.py index a47287d..b6ab8d6 100644 --- a/backend/app/api/characters.py +++ b/backend/app/api/characters.py @@ -960,6 +960,153 @@ async def generate_character_stream( db.add(organization) await db.flush() + # 处理结构化关系数据(仅针对非组织角色) + if not is_organization: + relationships_data = character_data.get("relationships", []) + if relationships_data and isinstance(relationships_data, list): + logger.info(f"📊 开始处理 {len(relationships_data)} 条关系数据") + created_rels = 0 + + for rel in relationships_data: + try: + target_name = rel.get("target_character_name") + if not target_name: + logger.debug(f" ⚠️ 关系缺少target_character_name,跳过") + continue + + target_result = await db.execute( + select(Character).where( + Character.project_id == request.project_id, + Character.name == target_name + ) + ) + target_char = target_result.scalar_one_or_none() + + if target_char: + # 检查是否已存在相同关系 + existing_rel = await db.execute( + select(CharacterRelationship).where( + CharacterRelationship.project_id == request.project_id, + CharacterRelationship.character_from_id == character.id, + CharacterRelationship.character_to_id == target_char.id + ) + ) + if existing_rel.scalar_one_or_none(): + logger.debug(f" ℹ️ 关系已存在:{character.name} -> {target_name}") + continue + + relationship = CharacterRelationship( + project_id=request.project_id, + character_from_id=character.id, + character_to_id=target_char.id, + relationship_name=rel.get("relationship_type", "未知关系"), + intimacy_level=rel.get("intimacy_level", 50), + description=rel.get("description", ""), + started_at=rel.get("started_at"), + source="ai" + ) + + # 匹配预定义关系类型 + rel_type_result = await db.execute( + select(RelationshipType).where( + RelationshipType.name == rel.get("relationship_type") + ) + ) + rel_type = rel_type_result.scalar_one_or_none() + if rel_type: + relationship.relationship_type_id = rel_type.id + + db.add(relationship) + created_rels += 1 + logger.info(f" ✅ 创建关系:{character.name} -> {target_name} ({rel.get('relationship_type')})") + else: + logger.warning(f" ⚠️ 目标角色不存在:{target_name}") + + except Exception as rel_error: + logger.warning(f" ❌ 创建关系失败:{str(rel_error)}") + continue + + logger.info(f"✅ 成功创建 {created_rels} 条关系记录") + + # 处理组织成员关系(仅针对非组织角色) + if not is_organization: + org_memberships = character_data.get("organization_memberships", []) + if org_memberships and isinstance(org_memberships, list): + logger.info(f"🏢 开始处理 {len(org_memberships)} 条组织成员关系") + created_members = 0 + + for membership in org_memberships: + try: + org_name = membership.get("organization_name") + if not org_name: + logger.debug(f" ⚠️ 组织成员关系缺少organization_name,跳过") + continue + + org_char_result = await db.execute( + select(Character).where( + Character.project_id == request.project_id, + Character.name == org_name, + Character.is_organization == True + ) + ) + org_char = org_char_result.scalar_one_or_none() + + if org_char: + # 获取或创建Organization记录 + org_result = await db.execute( + select(Organization).where(Organization.character_id == org_char.id) + ) + org = org_result.scalar_one_or_none() + + if not org: + # 如果组织Character存在但Organization不存在,自动创建 + org = Organization( + character_id=org_char.id, + project_id=request.project_id, + member_count=0 + ) + db.add(org) + await db.flush() + logger.info(f" ℹ️ 自动创建缺失的组织详情:{org_name}") + + # 检查是否已存在成员关系 + existing_member = await db.execute( + select(OrganizationMember).where( + OrganizationMember.organization_id == org.id, + OrganizationMember.character_id == character.id + ) + ) + if existing_member.scalar_one_or_none(): + logger.debug(f" ℹ️ 成员关系已存在:{character.name} -> {org_name}") + continue + + # 创建成员关系 + member = OrganizationMember( + organization_id=org.id, + character_id=character.id, + position=membership.get("position", "成员"), + rank=membership.get("rank", 0), + loyalty=membership.get("loyalty", 50), + joined_at=membership.get("joined_at"), + status=membership.get("status", "active"), + source="ai" + ) + db.add(member) + + # 更新组织成员计数 + org.member_count += 1 + + created_members += 1 + logger.info(f" ✅ 添加成员:{character.name} -> {org_name} ({membership.get('position')})") + else: + logger.warning(f" ⚠️ 组织不存在:{org_name}") + + except Exception as org_error: + logger.warning(f" ❌ 添加组织成员失败:{str(org_error)}") + continue + + logger.info(f"✅ 成功创建 {created_members} 条组织成员记录") + yield await SSEResponse.send_progress("保存生成历史...", 95) # 记录生成历史 diff --git a/backend/app/api/outlines.py b/backend/app/api/outlines.py index bba371c..16c4010 100644 --- a/backend/app/api/outlines.py +++ b/backend/app/api/outlines.py @@ -22,7 +22,10 @@ from app.schemas.outline import ( BatchOutlineExpansionRequest, BatchOutlineExpansionResponse, CreateChaptersFromPlansRequest, - CreateChaptersFromPlansResponse + CreateChaptersFromPlansResponse, + CharacterPredictionRequest, + PredictedCharacter, + CharacterPredictionResponse ) from app.services.ai_service import AIService from app.services.prompt_service import prompt_service, PromptService @@ -359,6 +362,107 @@ async def delete_outline( } + +@router.post("/predict-characters", summary="预测续写所需角色") +async def predict_characters( + request_data: CharacterPredictionRequest, + http_request: Request, + db: AsyncSession = Depends(get_db), + user_ai_service: AIService = Depends(get_user_ai_service) +): + """ + 预测续写大纲时可能需要的新角色 + + 用于角色确认机制的第一步:在生成大纲前预测角色需求 + """ + # 验证用户权限 + user_id = getattr(http_request.state, 'user_id', None) + project = await verify_project_access(request_data.project_id, user_id, db) + + try: + # 获取现有大纲 + existing_result = await db.execute( + select(Outline) + .where(Outline.project_id == request_data.project_id) + .order_by(Outline.order_index) + ) + existing_outlines = existing_result.scalars().all() + + if not existing_outlines: + return CharacterPredictionResponse( + needs_new_characters=False, + reason="项目尚无大纲,无法预测角色需求", + character_count=0, + predicted_characters=[] + ) + + # 获取现有角色 + characters_result = await db.execute( + select(Character).where(Character.project_id == request_data.project_id) + ) + characters = characters_result.scalars().all() + + # 构建已有章节概览 + all_chapters_brief = "" + if len(existing_outlines) > 20: + recent_20 = existing_outlines[-20:] + all_chapters_brief = "\n".join([ + f"第{o.order_index}章《{o.title}》" + for o in recent_20 + ]) + else: + all_chapters_brief = "\n".join([ + f"第{o.order_index}章《{o.title}》" + for o in existing_outlines + ]) + + # 调用自动角色服务进行预测 + from app.services.auto_character_service import get_auto_character_service + + auto_char_service = get_auto_character_service(user_ai_service) + + # 使用预测模式(不创建角色,仅分析) + last_chapter_number = existing_outlines[-1].order_index + auto_result = await auto_char_service.analyze_and_create_characters( + project_id=request_data.project_id, + outline_content="", # 预测模式不需要大纲内容 + existing_characters=list(characters), + db=db, + user_id=user_id, + enable_mcp=request_data.enable_mcp, + all_chapters_brief=all_chapters_brief, + start_chapter=last_chapter_number + 1, + chapter_count=request_data.chapter_count, + plot_stage=request_data.plot_stage, + story_direction=request_data.story_direction, + preview_only=True # 新增参数:仅预测不创建 + ) + + # 构建预测响应 + predicted_characters = [] + for char_data in auto_result.get("predicted_characters", []): + predicted_characters.append(PredictedCharacter( + name=char_data.get("name"), + role_description=char_data.get("role_description", ""), + suggested_role_type=char_data.get("suggested_role_type", "supporting"), + importance=char_data.get("importance", "medium"), + appearance_chapter=char_data.get("appearance_chapter", last_chapter_number + 1), + key_abilities=char_data.get("key_abilities", []), + plot_function=char_data.get("plot_function", ""), + relationship_suggestions=char_data.get("relationship_suggestions", []) + )) + + return CharacterPredictionResponse( + needs_new_characters=auto_result.get("needs_new_characters", False), + reason=auto_result.get("reason", ""), + character_count=len(predicted_characters), + predicted_characters=predicted_characters + ) + + except Exception as e: + logger.error(f"角色预测失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"角色预测失败: {str(e)}") + @router.post("/generate", response_model=OutlineListResponse, summary="AI生成/续写大纲") async def generate_outline( request: OutlineGenerateRequest, @@ -696,8 +800,8 @@ async def _continue_outline( user_ai_service: AIService, user_id: str = "system" ) -> OutlineListResponse: - """续写大纲 - 分批生成,每批5章(记忆+MCP增强版)""" - logger.info(f"续写大纲 - 项目: {project.id}, 已有: {len(existing_outlines)} 章, enable_mcp: {request.enable_mcp}") + """续写大纲 - 分批生成,每批5章(记忆+MCP+自动角色引入增强版)""" + logger.info(f"续写大纲 - 项目: {project.id}, 已有: {len(existing_outlines)} 章, enable_mcp: {request.enable_mcp}, enable_auto_characters: {request.enable_auto_characters}") # 分析已有大纲 current_chapter_count = len(existing_outlines) @@ -729,6 +833,136 @@ async def _continue_outline( } stage_instruction = stage_instructions.get(request.plot_stage, "") + # 🎭 【方案A】先角色后大纲:在生成大纲前预测并创建角色 + if request.enable_auto_characters: + # 检查是否有用户确认的角色列表 + if request.confirmed_characters: + # 直接使用用户确认的角色列表创建角色 + try: + from app.services.auto_character_service import get_auto_character_service + + logger.info(f"🎭 【确认模式】用户提供了 {len(request.confirmed_characters)} 个确认的角色,直接创建") + + auto_char_service = get_auto_character_service(user_ai_service) + + for char_data in request.confirmed_characters: + try: + # 生成角色详细信息 + character_data = await auto_char_service._generate_character_details( + spec=char_data, + project=project, + existing_characters=list(characters), + db=db, + user_id=user_id, + enable_mcp=request.enable_mcp + ) + + # 创建角色记录 + character = await auto_char_service._create_character_record( + project_id=project.id, + character_data=character_data, + db=db + ) + + # 建立关系 + relationships_data = character_data.get("relationships") or character_data.get("relationships_array", []) + if relationships_data: + await auto_char_service._create_relationships( + new_character=character, + relationship_specs=relationships_data, + existing_characters=list(characters), + project_id=project.id, + db=db + ) + + characters.append(character) + logger.info(f"✅ 创建确认的角色: {character.name}") + + except Exception as e: + logger.error(f"创建确认的角色失败: {e}", exc_info=True) + continue + + # 提交角色到数据库 + await db.commit() + + # 更新角色信息(供后续大纲生成使用) + characters_info = "\n".join([ + f"- {char.name} ({'组织' if char.is_organization else '角色'}, {char.role_type}): " + f"{char.personality[:100] if char.personality else '暂无描述'}" + for char in characters + ]) + + logger.info(f"✅ 【确认模式】成功创建 {len(request.confirmed_characters)} 个用户确认的角色") + + except Exception as e: + logger.error(f"⚠️ 【确认模式】创建确认角色失败: {e}", exc_info=True) + else: + # 🔮 预测模式:仅预测角色,不自动创建,需要用户确认 + # 抛出特殊异常,在非SSE接口中会被捕获并返回449状态码 + # 在SSE接口中会被特殊处理 + try: + from app.services.auto_character_service import get_auto_character_service + + logger.info(f"🔮 【预测模式】在生成大纲前预测是否需要新角色(需要用户确认)") + + # 构建已有章节概览 + all_chapters_brief_for_analysis = "" + if len(existing_outlines) > 20: + recent_20 = existing_outlines[-20:] + all_chapters_brief_for_analysis = "\n".join([ + f"第{o.order_index}章《{o.title}》" + for o in recent_20 + ]) + else: + all_chapters_brief_for_analysis = "\n".join([ + f"第{o.order_index}章《{o.title}》" + for o in existing_outlines + ]) + + # 调用自动角色服务(✅ 设置 preview_only=True,仅预测不创建) + auto_char_service = get_auto_character_service(user_ai_service) + auto_result = await auto_char_service.analyze_and_create_characters( + project_id=project.id, + outline_content="", # 预测模式不需要大纲内容 + existing_characters=list(characters), + db=db, + user_id=user_id, + enable_mcp=request.enable_mcp, + all_chapters_brief=all_chapters_brief_for_analysis, + start_chapter=last_chapter_number + 1, + chapter_count=total_chapters_to_generate, + plot_stage=request.plot_stage, + story_direction=request.story_direction or "自然延续", + preview_only=True # ✅ 关键修复:设置为True,仅预测不创建 + ) + + # 检查是否需要新角色 + if auto_result.get("needs_new_characters") and auto_result.get("predicted_characters"): + predicted_count = len(auto_result["predicted_characters"]) + logger.warning( + f"⚠️ 【预测模式】AI预测需要 {predicted_count} 个新角色,需要用户确认!" + ) + + # 🚨 抛出特殊异常,包含预测的角色信息 + raise HTTPException( + status_code=449, # 449 Retry With + detail={ + "code": "CHARACTER_CONFIRMATION_REQUIRED", + "message": "续写需要引入新角色,请先确认角色信息", + "predicted_characters": auto_result["predicted_characters"], + "reason": auto_result.get("reason", "剧情发展需要新角色"), + "chapter_range": f"第{last_chapter_number + 1}-{last_chapter_number + total_chapters_to_generate}章" + } + ) + else: + logger.info(f"✅ 【预测模式】AI判断无需引入新角色,继续生成大纲") + + except HTTPException: + raise + except Exception as e: + logger.error(f"⚠️ 【方案A】预测性角色引入失败: {e}", exc_info=True) + # 不阻断大纲生成流程 + # 批量生成 all_new_outlines = [] current_start_chapter = last_chapter_number + 1 @@ -914,6 +1148,7 @@ async def _continue_outline( current_start_chapter += current_batch_size logger.info(f"第{batch_num + 1}批生成完成,本批生成{len(batch_outlines)}章") + # 返回所有大纲(包括旧的和新的) final_result = await db.execute( @@ -1349,6 +1584,156 @@ async def continue_outline_generator( } stage_instruction = stage_instructions.get(data.get("plot_stage", "development"), "") + # 🎭 【方案A】先角色后大纲:在生成大纲前预测并创建角色 + enable_auto_characters = data.get("enable_auto_characters", True) + confirmed_characters = data.get("confirmed_characters") + + if enable_auto_characters: + # 检查是否有用户确认的角色列表 + if confirmed_characters: + # 直接使用用户确认的角色列表创建角色 + try: + yield await SSEResponse.send_progress( + f"🎭 【确认模式】创建 {len(confirmed_characters)} 个用户确认的角色...", + 27 + ) + + from app.services.auto_character_service import get_auto_character_service + + logger.info(f"🎭 【确认模式】用户提供了 {len(confirmed_characters)} 个确认的角色,直接创建") + + auto_char_service = get_auto_character_service(user_ai_service) + + created_count = 0 + for char_data in confirmed_characters: + try: + # 生成角色详细信息 + character_data = await auto_char_service._generate_character_details( + spec=char_data, + project=project, + existing_characters=list(characters), + db=db, + user_id=user_id, + enable_mcp=data.get("enable_mcp", True) + ) + + # 创建角色记录 + character = await auto_char_service._create_character_record( + project_id=project_id, + character_data=character_data, + db=db + ) + + # 建立关系 + relationships_data = character_data.get("relationships") or character_data.get("relationships_array", []) + if relationships_data: + await auto_char_service._create_relationships( + new_character=character, + relationship_specs=relationships_data, + existing_characters=list(characters), + project_id=project_id, + db=db + ) + + characters.append(character) + created_count += 1 + logger.info(f"✅ 创建确认的角色: {character.name}") + + except Exception as e: + logger.error(f"创建确认的角色失败: {e}", exc_info=True) + continue + + # 提交角色到数据库 + await db.commit() + + yield await SSEResponse.send_progress( + f"✅ 【确认模式】成功创建 {created_count} 个角色", + 28 + ) + logger.info(f"✅ 【确认模式】成功创建 {created_count} 个用户确认的角色") + + except Exception as e: + logger.error(f"⚠️ 【确认模式】创建确认角色失败: {e}", exc_info=True) + yield await SSEResponse.send_progress( + f"⚠️ 角色创建失败,继续生成大纲", + 28 + ) + else: + # 🔮 预测模式:仅预测角色,不自动创建,需要用户确认 + try: + yield await SSEResponse.send_progress( + "🔮 【预测模式】检测是否需要新角色(需用户确认)...", + 27 + ) + + from app.services.auto_character_service import get_auto_character_service + + logger.info(f"🔮 【预测模式】在生成大纲前预测是否需要新角色") + + # 构建已有章节概览 + all_chapters_brief_for_analysis = "" + if len(existing_outlines) > 20: + recent_20 = existing_outlines[-20:] + all_chapters_brief_for_analysis = "\n".join([ + f"第{o.order_index}章《{o.title}》" + for o in recent_20 + ]) + else: + all_chapters_brief_for_analysis = "\n".join([ + f"第{o.order_index}章《{o.title}》" + for o in existing_outlines + ]) + + # 调用自动角色服务(✅ 设置 preview_only=True) + auto_char_service = get_auto_character_service(user_ai_service) + auto_result = await auto_char_service.analyze_and_create_characters( + project_id=project_id, + outline_content="", # 预测模式不需要大纲内容 + existing_characters=list(characters), + db=db, + user_id=user_id, + enable_mcp=data.get("enable_mcp", True), + all_chapters_brief=all_chapters_brief_for_analysis, + start_chapter=last_chapter_number + 1, + chapter_count=total_chapters_to_generate, + plot_stage=data.get("plot_stage", "development"), + story_direction=data.get("story_direction", "自然延续"), + preview_only=True # ✅ 关键修复:仅预测不创建 + ) + + # 检查是否需要新角色 + if auto_result.get("needs_new_characters") and auto_result.get("predicted_characters"): + predicted_count = len(auto_result["predicted_characters"]) + logger.warning( + f"⚠️ 【预测模式】AI预测需要 {predicted_count} 个新角色,需要用户确认!" + ) + + # 🚨 使用专用事件类型通知前端需要角色确认 + yield await SSEResponse.send_event( + event="character_confirmation_required", + data={ + "message": "续写需要引入新角色,请先确认角色信息", + "predicted_characters": auto_result["predicted_characters"], + "reason": auto_result.get("reason", "剧情发展需要新角色"), + "chapter_range": f"第{last_chapter_number + 1}-{last_chapter_number + total_chapters_to_generate}章" + } + ) + return + else: + yield await SSEResponse.send_progress( + "✅ 【预测模式】无需引入新角色,继续生成大纲", + 28 + ) + logger.info(f"✅ 【预测模式】AI判断无需引入新角色") + + except Exception as e: + logger.error(f"⚠️ 【方案A】预测性角色引入失败: {e}", exc_info=True) + yield await SSEResponse.send_progress( + f"⚠️ 角色预测失败,继续生成大纲", + 28 + ) + # 不阻断大纲生成流程 + # 批量生成 all_new_outlines = [] current_start_chapter = last_chapter_number + 1 diff --git a/backend/app/schemas/outline.py b/backend/app/schemas/outline.py index bf7518c..d56b483 100644 --- a/backend/app/schemas/outline.py +++ b/backend/app/schemas/outline.py @@ -1,9 +1,40 @@ """大纲相关的Pydantic模型""" from pydantic import BaseModel, Field -from typing import Optional +from typing import Optional, List, Dict, Any from datetime import datetime +# 角色预测相关Schema +class CharacterPredictionRequest(BaseModel): + """角色预测请求""" + project_id: str + start_chapter: int + chapter_count: int = 3 + plot_stage: str = "development" + story_direction: Optional[str] = "自然延续" + enable_mcp: bool = True + + +class PredictedCharacter(BaseModel): + """预测的角色信息""" + name: Optional[str] = None + role_description: str + suggested_role_type: str + importance: str + appearance_chapter: int + key_abilities: List[str] = [] + plot_function: str + relationship_suggestions: List[Dict[str, str]] = [] + + +class CharacterPredictionResponse(BaseModel): + """角色预测响应""" + needs_new_characters: bool + reason: str + character_count: int + predicted_characters: List[PredictedCharacter] + + class OutlineBase(BaseModel): """大纲基础模型""" title: str = Field(..., description="章节标题") @@ -62,6 +93,8 @@ class OutlineGenerateRequest(BaseModel): plot_stage: str = Field("development", description="情节阶段: development(发展), climax(高潮), ending(结局)") keep_existing: bool = Field(False, description="是否保留现有大纲(续写时)") enable_mcp: bool = Field(True, description="是否启用MCP工具增强(搜索情节设计参考)") + enable_auto_characters: bool = Field(True, description="是否启用自动角色引入(根据剧情推进自动创建新角色)") + confirmed_characters: Optional[List[Dict[str, Any]]] = Field(None, description="用户确认的角色列表(跳过预测直接创建)") class ChapterOutlineGenerateRequest(BaseModel): diff --git a/backend/app/services/auto_character_service.py b/backend/app/services/auto_character_service.py new file mode 100644 index 0000000..e4cbc89 --- /dev/null +++ b/backend/app/services/auto_character_service.py @@ -0,0 +1,509 @@ +"""自动角色引入服务 - 在续写大纲时根据剧情推进自动引入新角色""" +from typing import List, Dict, Any, Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +import json + +from app.models.character import Character +from app.models.relationship import CharacterRelationship, Organization, OrganizationMember, RelationshipType +from app.models.project import Project +from app.services.ai_service import AIService +from app.services.prompt_service import PromptService +from app.logger import get_logger + +logger = get_logger(__name__) + + +class AutoCharacterService: + """自动角色引入服务""" + + def __init__(self, ai_service: AIService): + self.ai_service = ai_service + + async def analyze_and_create_characters( + self, + project_id: str, + outline_content: str, + existing_characters: List[Character], + db: AsyncSession, + user_id: str = None, + enable_mcp: bool = True, + all_chapters_brief: str = "", + start_chapter: int = 1, + chapter_count: int = 3, + plot_stage: str = "发展", + story_direction: str = "继续推进主线剧情", + preview_only: bool = False + ) -> Dict[str, Any]: + """ + 预测性分析并创建需要的新角色(方案A:先角色后大纲) + + Args: + project_id: 项目ID + outline_content: 当前批次大纲内容(用于向后兼容,实际不使用) + existing_characters: 现有角色列表 + db: 数据库会话 + user_id: 用户ID(用于MCP和自定义提示词) + enable_mcp: 是否启用MCP增强 + all_chapters_brief: 已有章节概览 + start_chapter: 起始章节号 + chapter_count: 续写章节数 + plot_stage: 剧情阶段 + story_direction: 故事发展方向 + preview_only: 仅预测不创建(用于角色确认机制) + + Returns: + { + "new_characters": [角色对象列表], # preview_only=True时为空 + "relationships_created": [关系对象列表], # preview_only=True时为空 + "character_count": 新增角色数量, + "analysis_result": AI分析结果, + "predicted_characters": [预测的角色数据] # 仅preview_only=True时返回 + "needs_new_characters": bool, + "reason": str + } + """ + logger.info(f"🎭 【方案A】预测性分析:检测是否需要引入新角色...") + logger.info(f" - 项目ID: {project_id}") + logger.info(f" - 续写计划: 第{start_chapter}章起,共{chapter_count}章") + logger.info(f" - 剧情阶段: {plot_stage}") + logger.info(f" - 发展方向: {story_direction}") + logger.info(f" - 现有角色数: {len(existing_characters)}") + + # 1. 获取项目信息 + project_result = await db.execute( + select(Project).where(Project.id == project_id) + ) + project = project_result.scalar_one_or_none() + if not project: + raise ValueError("项目不存在") + + # 2. 构建现有角色信息摘要 + existing_chars_summary = self._build_character_summary(existing_characters) + + # 3. AI预测性分析是否需要新角色 + analysis_result = await self._analyze_character_needs( + project=project, + outline_content=outline_content, # 保留参数向后兼容 + existing_chars_summary=existing_chars_summary, + db=db, + user_id=user_id, + enable_mcp=enable_mcp, + all_chapters_brief=all_chapters_brief, + start_chapter=start_chapter, + chapter_count=chapter_count, + plot_stage=plot_stage, + story_direction=story_direction + ) + + # 4. 判断是否需要创建角色 + if not analysis_result or not analysis_result.get("needs_new_characters"): + logger.info("✅ AI判断:当前剧情不需要引入新角色") + return { + "new_characters": [], + "relationships_created": [], + "character_count": 0, + "analysis_result": analysis_result, + "predicted_characters": [], + "needs_new_characters": False, + "reason": analysis_result.get("reason", "当前剧情不需要新角色") + } + + # 5. 如果是预览模式,仅返回预测结果,不创建角色 + if preview_only: + character_specs = analysis_result.get("character_specifications", []) + logger.info(f"🔮 预览模式:预测到 {len(character_specs)} 个角色,不创建数据库记录") + return { + "new_characters": [], + "relationships_created": [], + "character_count": 0, + "analysis_result": analysis_result, + "predicted_characters": character_specs, + "needs_new_characters": True, + "reason": analysis_result.get("reason", "预测需要新角色") + } + + # 6. 批量生成新角色(非预览模式) + new_characters = [] + relationships_created = [] + + character_specs = analysis_result.get("character_specifications", []) + logger.info(f"🎯 AI建议引入 {len(character_specs)} 个新角色") + + for idx, spec in enumerate(character_specs): + try: + spec_name = spec.get('name', spec.get('role_description', '未命名')) + logger.info(f" [{idx+1}/{len(character_specs)}] 生成角色规格: {spec_name}") + logger.debug(f" 角色规格内容: {json.dumps(spec, ensure_ascii=False)}") + + # 生成角色详细信息 + character_data = await self._generate_character_details( + spec=spec, + project=project, + existing_characters=existing_characters + new_characters, # 包含新创建的 + db=db, + user_id=user_id, + enable_mcp=enable_mcp + ) + + logger.debug(f" AI生成的角色数据: {json.dumps(character_data, ensure_ascii=False)[:200]}") + + # 创建角色记录 + character = await self._create_character_record( + project_id=project_id, + character_data=character_data, + db=db + ) + + new_characters.append(character) + logger.info(f" ✅ 创建新角色: {character.name} ({character.role_type}), ID: {character.id}") + + # 建立关系(兼容两种字段名) + relationships_data = character_data.get("relationships") or character_data.get("relationships_array", []) + logger.info(f" 🔍 检查关系数据:") + logger.info(f" - relationships字段: {character_data.get('relationships')}") + logger.info(f" - relationships_array字段: {character_data.get('relationships_array')}") + logger.info(f" - 最终使用的数据: {relationships_data}") + logger.info(f" - 关系数量: {len(relationships_data) if relationships_data else 0}") + + if relationships_data: + logger.info(f" 🔗 开始创建 {len(relationships_data)} 条关系...") + for idx, rel in enumerate(relationships_data): + logger.info(f" [{idx+1}] {rel.get('target_character_name')} - {rel.get('relationship_type')}") + else: + logger.warning(f" ⚠️ AI返回的角色数据中没有关系信息!") + logger.warning(f" 完整的character_data keys: {list(character_data.keys())}") + + rels = await self._create_relationships( + new_character=character, + relationship_specs=relationships_data, + existing_characters=existing_characters + new_characters, + project_id=project_id, + db=db + ) + + relationships_created.extend(rels) + logger.info(f" ✅ 实际创建了 {len(rels)} 条关系记录") + + except Exception as e: + logger.error(f" ❌ 创建角色失败: {e}", exc_info=True) + continue + + # 7. 提交事务(注意:这里只flush,让调用方commit) + await db.flush() + + logger.info(f"🎉 自动角色引入完成: 新增{len(new_characters)}个角色, {len(relationships_created)}条关系") + + return { + "new_characters": new_characters, + "relationships_created": relationships_created, + "character_count": len(new_characters), + "analysis_result": analysis_result + } + + def _build_character_summary(self, characters: List[Character]) -> str: + """构建现有角色摘要""" + if not characters: + return "暂无角色" + + summary = [] + for char in characters: + char_type = "组织" if char.is_organization else "角色" + role_desc = char.role_type or "未知" + personality = (char.personality or "")[:50] + summary.append(f"- {char.name} ({char_type}, {role_desc}): {personality}") + + return "\n".join(summary[:20]) # 最多显示20个 + + async def _analyze_character_needs( + self, + project: Project, + outline_content: str, + existing_chars_summary: str, + db: AsyncSession, + user_id: str, + enable_mcp: bool, + all_chapters_brief: str = "", + start_chapter: int = 1, + chapter_count: int = 3, + plot_stage: str = "发展", + story_direction: str = "继续推进主线剧情" + ) -> Dict[str, Any]: + """AI预测性分析是否需要新角色(方案A)""" + + # 构建分析提示词 + template = await PromptService.get_template( + "AUTO_CHARACTER_ANALYSIS", + user_id, + db + ) + + # 使用新的预测性分析参数 + prompt = PromptService.format_prompt( + template, + title=project.title, + theme=project.theme or "未设定", + genre=project.genre or "未设定", + time_period=project.world_time_period or "未设定", + location=project.world_location or "未设定", + atmosphere=project.world_atmosphere or "未设定", + existing_characters=existing_chars_summary, + all_chapters_brief=all_chapters_brief, + start_chapter=start_chapter, + chapter_count=chapter_count, + plot_stage=plot_stage, + story_direction=story_direction + ) + + try: + # 调用AI分析 + if enable_mcp and user_id: + result = await self.ai_service.generate_text_with_mcp( + prompt=prompt, + user_id=user_id, + db_session=db, + enable_mcp=True, + max_tool_rounds=1 + ) + content = result.get("content", "") + else: + result = await self.ai_service.generate_text(prompt=prompt) + content = result.get("content", "") if isinstance(result, dict) else result + + # 清理并解析JSON + cleaned = content.strip() + if cleaned.startswith("```json"): + cleaned = cleaned[7:] + if cleaned.startswith("```"): + cleaned = cleaned[3:] + if cleaned.endswith("```"): + cleaned = cleaned[:-3] + cleaned = cleaned.strip() + + analysis = json.loads(cleaned) + logger.info(f" ✅ AI分析完成: needs_new_characters={analysis.get('needs_new_characters')}") + + return analysis + + except json.JSONDecodeError as e: + logger.error(f" ❌ 角色需求分析JSON解析失败: {e}") + logger.error(f" 响应内容: {content[:500]}") + return {"needs_new_characters": False} + except Exception as e: + logger.error(f" ❌ 角色需求分析失败: {e}") + return {"needs_new_characters": False} + + async def _generate_character_details( + self, + spec: Dict[str, Any], + project: Project, + existing_characters: List[Character], + db: AsyncSession, + user_id: str, + enable_mcp: bool + ) -> Dict[str, Any]: + """生成角色详细信息""" + + # 构建角色生成提示词 + template = await PromptService.get_template( + "AUTO_CHARACTER_GENERATION", + user_id, + db + ) + + existing_chars_summary = self._build_character_summary(existing_characters) + + prompt = PromptService.format_prompt( + template, + title=project.title, + genre=project.genre or "未设定", + theme=project.theme or "未设定", + time_period=project.world_time_period or "未设定", + location=project.world_location or "未设定", + atmosphere=project.world_atmosphere or "未设定", + rules=project.world_rules or "未设定", + existing_characters=existing_chars_summary, + plot_context="根据剧情需要引入的新角色", + character_specification=json.dumps(spec, ensure_ascii=False, indent=2), + mcp_references="" # 暂时不使用MCP增强 + ) + + # 调用AI生成 + try: + if enable_mcp and user_id: + result = await self.ai_service.generate_text_with_mcp( + prompt=prompt, + user_id=user_id, + db_session=db, + enable_mcp=True, + max_tool_rounds=1 + ) + content = result.get("content", "") + else: + result = await self.ai_service.generate_text(prompt=prompt) + content = result.get("content", "") if isinstance(result, dict) else result + + # 解析JSON + cleaned = content.strip() + if cleaned.startswith("```json"): + cleaned = cleaned[7:] + if cleaned.startswith("```"): + cleaned = cleaned[3:] + if cleaned.endswith("```"): + cleaned = cleaned[:-3] + + character_data = json.loads(cleaned.strip()) + char_name = character_data.get('name', '未知') + logger.info(f" ✅ 角色详情生成成功: {char_name}") + logger.debug(f" 角色数据字段: {list(character_data.keys())}") + + # 确保关键字段存在 + if 'name' not in character_data or not character_data['name']: + logger.warning(f" ⚠️ AI返回的角色数据缺少name字段,使用规格中的信息") + character_data['name'] = spec.get('name', f"新角色{spec.get('role_description', '')[:10]}") + + return character_data + + except Exception as e: + logger.error(f" ❌ 生成角色详情失败: {e}") + raise + + async def _create_character_record( + self, + project_id: str, + character_data: Dict[str, Any], + db: AsyncSession + ) -> Character: + """创建角色数据库记录""" + + is_organization = character_data.get("is_organization", False) + + # 创建角色 + character = Character( + project_id=project_id, + name=character_data.get("name", "未命名角色"), + age=str(character_data.get("age", "")), + gender=character_data.get("gender"), + is_organization=is_organization, + role_type=character_data.get("role_type", "supporting"), + personality=character_data.get("personality", ""), + background=character_data.get("background", ""), + appearance=character_data.get("appearance", ""), + relationships=character_data.get("relationships_text", ""), + organization_type=character_data.get("organization_type") if is_organization else None, + organization_purpose=character_data.get("organization_purpose") if is_organization else None, + traits=json.dumps(character_data.get("traits", []), ensure_ascii=False) if character_data.get("traits") else None + ) + + db.add(character) + await db.flush() + + # 如果是组织,创建Organization记录 + if is_organization: + org = Organization( + character_id=character.id, + project_id=project_id, + member_count=0, + power_level=character_data.get("power_level", 50), + location=character_data.get("location"), + motto=character_data.get("motto"), + color=character_data.get("color") + ) + db.add(org) + await db.flush() + logger.info(f" ✅ 创建组织详情: {character.name}") + + return character + + async def _create_relationships( + self, + new_character: Character, + relationship_specs: List[Dict[str, Any]], + existing_characters: List[Character], + project_id: str, + db: AsyncSession + ) -> List[CharacterRelationship]: + """创建角色关系""" + + if not relationship_specs: + return [] + + relationships = [] + + for rel_spec in relationship_specs: + try: + target_name = rel_spec.get("target_character_name") + if not target_name: + continue + + # 查找目标角色 + target_char = next( + (c for c in existing_characters if c.name == target_name), + None + ) + + if not target_char: + logger.warning(f" ⚠️ 目标角色不存在: {target_name}") + continue + + # 检查关系是否已存在 + existing_rel = await db.execute( + select(CharacterRelationship).where( + CharacterRelationship.project_id == project_id, + CharacterRelationship.character_from_id == new_character.id, + CharacterRelationship.character_to_id == target_char.id + ) + ) + if existing_rel.scalar_one_or_none(): + logger.debug(f" ℹ️ 关系已存在: {new_character.name} -> {target_name}") + continue + + # 创建关系 + relationship = CharacterRelationship( + project_id=project_id, + character_from_id=new_character.id, + character_to_id=target_char.id, + relationship_name=rel_spec.get("relationship_type", "未知关系"), + intimacy_level=rel_spec.get("intimacy_level", 50), + description=rel_spec.get("description", ""), + status=rel_spec.get("status", "active"), + source="auto" # 标记为自动生成 + ) + + # 尝试匹配预定义关系类型 + rel_type_name = rel_spec.get("relationship_type") + if rel_type_name: + rel_type_result = await db.execute( + select(RelationshipType).where( + RelationshipType.name == rel_type_name + ) + ) + rel_type = rel_type_result.scalar_one_or_none() + if rel_type: + relationship.relationship_type_id = rel_type.id + + db.add(relationship) + relationships.append(relationship) + + logger.info( + f" ✅ 创建关系: {new_character.name} -> {target_name} " + f"({rel_spec.get('relationship_type', '未知')})" + ) + + except Exception as e: + logger.warning(f" ❌ 创建关系失败: {e}") + continue + + return relationships + + +# 全局实例缓存 +_auto_character_service_instance: Optional[AutoCharacterService] = None + + +def get_auto_character_service(ai_service: AIService) -> AutoCharacterService: + """获取自动角色服务实例(单例模式)""" + global _auto_character_service_instance + if _auto_character_service_instance is None: + _auto_character_service_instance = AutoCharacterService(ai_service) + return _auto_character_service_instance \ No newline at end of file diff --git a/backend/app/services/prompt_service.py b/backend/app/services/prompt_service.py index 9e12738..ea9d48a 100644 --- a/backend/app/services/prompt_service.py +++ b/backend/app/services/prompt_service.py @@ -1437,6 +1437,193 @@ class PromptService: 3. 每个plot_summary必须是200-300字的详细描述 4. 所有内容描述中严禁使用任何特殊符号""" + # 自动角色引入 - 预测性分析提示词(方案A) + AUTO_CHARACTER_ANALYSIS = """你是专业的小说角色设计顾问。请根据即将续写的剧情方向,预测是否需要引入新角色。 + +【项目信息】 +- 书名:{title} +- 类型:{genre} +- 主题:{theme} + +【世界观】 +- 时间背景:{time_period} +- 地理位置:{location} +- 氛围基调:{atmosphere} + +【已有角色】 +{existing_characters} + +【已有章节概览】 +{all_chapters_brief} + +【续写计划】 +- 起始章节:第{start_chapter}章 +- 续写数量:{chapter_count}章 +- 剧情阶段:{plot_stage} +- 发展方向:{story_direction} + +【预测性分析任务】 +请预测在接下来的{chapter_count}章中,根据剧情发展方向和阶段,是否需要引入新角色。 + +**分析要点:** +1. **剧情需求预测**:根据发展方向,哪些场景、冲突需要新角色参与 +2. **角色充分性**:现有角色是否足以支撑即将发生的剧情 +3. **引入时机**:新角色应该在哪个章节登场最合适 +4. **重要性判断**:新角色对后续剧情的影响程度 + +**预测依据:** +- 剧情阶段的典型角色需求(如:高潮阶段可能需要强力对手) +- 故事发展方向的逻辑需要(如:进入新地点需要当地角色) +- 冲突升级的角色需求(如:更强的反派、意外的盟友) +- 世界观扩展的需要(如:新组织、新势力的代表) + +**如果需要新角色,请详细说明:** +- 角色定位和作用 +- 建议的角色类型和重要性 +- 预计登场时机 +- 与现有角色的潜在关系 + +**输出格式(纯JSON):** +{{ + "needs_new_characters": true, + "reason": "预测分析原因(150-200字),说明为什么即将的剧情需要新角色", + "character_count": 2, + "character_specifications": [ + {{ + "name": "建议的角色名字(可选,如果有明确想法)", + "role_description": "角色在剧情中的定位和作用(100-150字)", + "suggested_role_type": "supporting/antagonist/protagonist", + "importance": "high/medium/low", + "appearance_chapter": {start_chapter}, + "key_abilities": ["能力1", "能力2"], + "plot_function": "在剧情中的具体功能(如:作为主要对手、提供关键信息等)", + "relationship_suggestions": [ + {{ + "target_character": "现有角色名", + "relationship_type": "建议的关系类型", + "reason": "为什么建立这种关系" + }} + ] + }} + ] +}} + +或者如果不需要新角色: +{{ + "needs_new_characters": false, + "reason": "现有角色足以支撑即将的剧情发展,说明理由" +}} + +**重要提示:** +- 这是预测性分析,不是基于已生成内容的事后分析 +- 要考虑剧情的自然发展和节奏 +- 不要为了引入角色而引入,确保必要性 +- 优先考虑角色的长期作用,而非一次性功能 + +只返回纯JSON,不要有markdown标记或其他文字。""" + + # 自动角色引入 - 生成提示词 + AUTO_CHARACTER_GENERATION = """你是专业的角色设定师。请根据以下信息,为小说生成新角色的完整设定。 + +【项目信息】 +- 书名:{title} +- 类型:{genre} +- 主题:{theme} + +【世界观】 +- 时间背景:{time_period} +- 地理位置:{location} +- 氛围基调:{atmosphere} +- 世界规则:{rules} + +【已有角色】 +{existing_characters} + +【剧情上下文】 +{plot_context} + +【角色规格要求】 +{character_specification} + +【MCP工具参考】 +{mcp_references} + +【生成要求】 +1. 角色必须符合剧情需求和世界观设定 +2. **必须分析新角色与已有角色的关系**,至少建立1-3个有意义的关系 +3. 性格、背景要有深度和独特性 +4. 外貌描写要具体生动 +5. 特长和能力要符合角色定位 + +**关系建立指导(非常重要):** +- 仔细审视【已有角色】列表,思考新角色与哪些现有角色有联系 +- 根据剧情需求,建立合理的角色关系(如:主角的新朋友、反派的手下、某角色的亲属等) +- 每个关系都要有明确的类型、亲密度和描述 +- 关系应该服务于剧情发展,推动故事前进 +- 如果新角色是组织成员,记得填写organization_memberships + +**重要格式要求:** +1. 只返回纯JSON格式,不要包含任何markdown标记或其他说明文字 +2. JSON字符串值中严禁使用特殊符号(引号、方括号、书名号等) +3. 所有专有名词直接书写,不使用任何符号包裹 + +请严格按照以下JSON格式返回: +{{ + "name": "角色姓名", + "age": 25, + "gender": "男/女/其他", + "role_type": "supporting", + "personality": "性格特点的详细描述(100-200字)", + "background": "背景故事的详细描述(100-200字)", + "appearance": "外貌描述(50-100字)", + "traits": ["特长1", "特长2", "特长3"], + "relationships_text": "用自然语言描述该角色与其他角色的关系网络", + + "relationships": [ + {{ + "target_character_name": "已存在的角色名称", + "relationship_type": "关系类型(如:朋友、师父、敌人、父亲等)", + "intimacy_level": 75, + "description": "关系的具体描述,说明他们如何认识、关系如何发展", + "status": "active" + }} + ], + "organization_memberships": [ + {{ + "organization_name": "已存在的组织名称", + "position": "职位", + "rank": 5, + "loyalty": 80 + }} + ] +}} + +**关系类型参考(从中选择或自定义):** +- 家族关系:父亲、母亲、兄弟、姐妹、子女、配偶、恋人、亲戚 +- 社交关系:师父、徒弟、朋友、挚友、同学、同事、邻居、知己、酒友 +- 职业关系:上司、下属、合作伙伴、客户、雇主、员工 +- 敌对关系:敌人、仇人、竞争对手、宿敌、死敌 + +**重要说明:** +1. **relationships数组必填**:至少要有1-3个与已有角色的关系(除非确实没有合理的关联) +2. **target_character_name必须精确匹配**:只能引用【已有角色】列表中的角色名称 +3. organization_memberships只能引用已存在的组织名称 +4. intimacy_level是-100到100的整数: + - 80-100:至亲、挚友、深爱 + - 50-79:亲密、友好 + - 0-49:一般、普通 + - -1到-49:不和、敌视 + - -50到-100:仇恨、死敌 +5. loyalty是0-100的整数(仅用于组织成员) +6. status默认为"active",表示当前关系状态 + +**关系建立示例:** +- 如果新角色是主角的新队友,应该与主角建立"队友"或"朋友"关系 +- 如果新角色是反派的手下,应该与反派建立"上司-下属"关系 +- 如果新角色与某角色有血缘,应该建立家族关系 + +只返回纯JSON对象,不要有```json```这样的标记。""" + @staticmethod def format_prompt(template: str, **kwargs) -> str: """ @@ -2306,6 +2493,20 @@ class PromptService: "category": "MCP增强", "description": "使用MCP工具搜索资料辅助角色设计", "parameters": ["title", "genre", "theme", "time_period", "location"] + }, + "AUTO_CHARACTER_ANALYSIS": { + "name": "自动角色分析", + "category": "自动角色引入", + "description": "分析新生成的大纲,判断是否需要引入新角色", + "parameters": ["title", "genre", "theme", "time_period", "location", "atmosphere", + "existing_characters", "new_outlines", "start_chapter", "end_chapter"] + }, + "AUTO_CHARACTER_GENERATION": { + "name": "自动角色生成", + "category": "自动角色引入", + "description": "根据剧情需求自动生成新角色的完整设定", + "parameters": ["title", "genre", "theme", "time_period", "location", "atmosphere", "rules", + "existing_characters", "plot_context", "character_specification", "mcp_references"] } } diff --git a/backend/app/utils/sse_response.py b/backend/app/utils/sse_response.py index 3a79fa2..150c105 100644 --- a/backend/app/utils/sse_response.py +++ b/backend/app/utils/sse_response.py @@ -76,6 +76,17 @@ class SSEResponse: "data": data }) + @staticmethod + async def send_event(event: str, data: Dict[str, Any]) -> str: + """ + 发送自定义事件类型的SSE消息 + + Args: + event: 事件类型名称 + data: 事件数据 + """ + return SSEResponse.format_sse(data, event=event) + @staticmethod async def send_error(error: str, code: int = 500) -> str: """ diff --git a/frontend/public/10.png b/frontend/public/10.png new file mode 100644 index 0000000..fc7a5a1 Binary files /dev/null and b/frontend/public/10.png differ diff --git a/frontend/public/20.png b/frontend/public/20.png new file mode 100644 index 0000000..3a7a33b Binary files /dev/null and b/frontend/public/20.png differ diff --git a/frontend/public/5.png b/frontend/public/5.png new file mode 100644 index 0000000..4ecec34 Binary files /dev/null and b/frontend/public/5.png differ diff --git a/frontend/public/50.png b/frontend/public/50.png new file mode 100644 index 0000000..74b1de0 Binary files /dev/null and b/frontend/public/50.png differ diff --git a/frontend/public/xx.png b/frontend/public/xx.png new file mode 100644 index 0000000..28f8291 Binary files /dev/null and b/frontend/public/xx.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 59741ec..497c2a8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ import Settings from './pages/Settings'; import MCPPlugins from './pages/MCPPlugins'; import UserManagement from './pages/UserManagement'; import PromptTemplates from './pages/PromptTemplates'; +import Sponsor from './pages/Sponsor'; // import Polish from './pages/Polish'; import Login from './pages/Login'; import AuthCallback from './pages/AuthCallback'; @@ -37,7 +38,7 @@ function App() { } /> } /> - + <>} /> <>} /> } /> @@ -57,6 +58,7 @@ function App() { } /> } /> } /> + } /> {/* } /> */} diff --git a/frontend/src/pages/Outline.tsx b/frontend/src/pages/Outline.tsx index 85622a7..517124d 100644 --- a/frontend/src/pages/Outline.tsx +++ b/frontend/src/pages/Outline.tsx @@ -9,6 +9,30 @@ import { SSEProgressModal } from '../components/SSEProgressModal'; import { outlineApi, chapterApi, projectApi } from '../services/api'; import type { OutlineExpansionResponse, BatchOutlineExpansionResponse } from '../types'; +// 角色预测数据类型 +interface PredictedCharacter { + name?: string; + role_description: string; + suggested_role_type: string; + importance: string; + appearance_chapter: number; + key_abilities: string[]; + plot_function: string; + relationship_suggestions: Array<{ + target_character_name: string; + relationship_type: string; + description?: string; + }>; +} + +interface CharacterConfirmationData { + code: string; + message: string; + predicted_characters: PredictedCharacter[]; + reason: string; + chapter_range: string; +} + const { TextArea } = Input; export default function Outline() { @@ -21,19 +45,25 @@ export default function Outline() { const [manualCreateForm] = Form.useForm(); const [isMobile, setIsMobile] = useState(window.innerWidth <= 768); const [isExpanding, setIsExpanding] = useState(false); - + // ✅ 新增:记录每个大纲的展开状态 const [outlineExpandStatus, setOutlineExpandStatus] = useState>({}); - + + // 角色确认相关状态 + const [characterConfirmData, setCharacterConfirmData] = useState(null); + const [characterConfirmVisible, setCharacterConfirmVisible] = useState(false); + const [pendingGenerateData, setPendingGenerateData] = useState(null); + const [selectedCharacterIndices, setSelectedCharacterIndices] = useState([]); + // 缓存批量展开的规划数据,避免重复AI调用 const [cachedBatchExpansionResponse, setCachedBatchExpansionResponse] = useState(null); - + // 批量展开预览的状态 const [batchPreviewVisible, setBatchPreviewVisible] = useState(false); const [batchPreviewData, setBatchPreviewData] = useState(null); const [selectedOutlineIdx, setSelectedOutlineIdx] = useState(0); const [selectedChapterIdx, setSelectedChapterIdx] = useState(0); - + // SSE进度状态 const [sseProgress, setSSEProgress] = useState(0); const [sseMessage, setSSEMessage] = useState(''); @@ -67,7 +97,7 @@ export default function Outline() { useEffect(() => { const loadExpandStatus = async () => { if (outlines.length === 0) return; - + const statusMap: Record = {}; for (const outline of outlines) { try { @@ -80,10 +110,19 @@ export default function Outline() { } setOutlineExpandStatus(statusMap); }; - + loadExpandStatus(); }, [outlines]); + // 当角色确认数据变化时,初始化选中状态(默认全选) + useEffect(() => { + if (characterConfirmData) { + setSelectedCharacterIndices( + characterConfirmData.predicted_characters.map((_, idx) => idx) + ); + } + }, [characterConfirmData]); + // 移除事件监听,避免无限循环 // Hook 内部已经更新了 store,不需要再次刷新 @@ -164,26 +203,27 @@ export default function Outline() { story_direction?: string; plot_stage?: 'development' | 'climax' | 'ending'; keep_existing?: boolean; + enable_auto_characters?: boolean; } const handleGenerate = async (values: GenerateFormValues) => { try { setIsGenerating(true); - + // 添加详细的调试日志 console.log('=== 大纲生成调试信息 ==='); console.log('1. Form values 原始数据:', values); console.log('2. values.model:', values.model); console.log('3. values.provider:', values.provider); - + // 关闭生成表单Modal Modal.destroyAll(); - + // 显示进度Modal setSSEProgress(0); setSSEMessage('正在连接AI服务...'); setSSEModalVisible(true); - + // 准备请求数据 const requestData: any = { project_id: currentProject.id, @@ -195,9 +235,10 @@ export default function Outline() { requirements: values.requirements, mode: values.mode || 'auto', story_direction: values.story_direction, - plot_stage: values.plot_stage || 'development' + plot_stage: values.plot_stage || 'development', + enable_auto_characters: values.enable_auto_characters !== undefined ? values.enable_auto_characters : true }; - + // 只有在用户选择了模型时才添加model参数 if (values.model) { requestData.model = values.model; @@ -205,16 +246,16 @@ export default function Outline() { } else { console.log('4. values.model为空,不添加到请求'); } - + // 添加provider参数(如果有) if (values.provider) { requestData.provider = values.provider; console.log('5. 添加provider到请求:', values.provider); } - + console.log('6. 最终请求数据:', JSON.stringify(requestData, null, 2)); console.log('========================='); - + // 使用SSE客户端 const apiUrl = `/api/outlines/generate-stream`; const client = new SSEPostClient(apiUrl, requestData, { @@ -225,7 +266,22 @@ export default function Outline() { onResult: (data: any) => { console.log('生成完成,结果:', data); }, + onCharacterConfirmation: (data: any) => { + // ✨ 新增:处理角色确认事件 + console.log('收到角色确认请求:', data); + // 关闭SSE进度Modal + setSSEModalVisible(false); + setIsGenerating(false); + + // 保存待处理的生成数据 + setPendingGenerateData(requestData); + + // 显示角色确认对话框 + setCharacterConfirmData(data); + setCharacterConfirmVisible(true); + }, onError: (error: string) => { + // 现在只处理真正的错误 message.error(`生成失败: ${error}`); setSSEModalVisible(false); setIsGenerating(false); @@ -238,10 +294,10 @@ export default function Outline() { refreshOutlines(); } }); - + // 开始连接 client.connect(); - + } catch (error) { console.error('AI生成失败:', error); message.error('AI生成失败'); @@ -253,15 +309,15 @@ export default function Outline() { const showGenerateModal = async () => { const hasOutlines = outlines.length > 0; const initialMode = hasOutlines ? 'continue' : 'new'; - + // 直接加载可用模型列表 const settingsResponse = await fetch('/api/settings'); const settings = await settingsResponse.json(); const { api_key, api_base_url, api_provider } = settings; - - let loadedModels: Array<{value: string, label: string}> = []; + + let loadedModels: Array<{ value: string, label: string }> = []; let defaultModel: string | undefined = undefined; - + if (api_key && api_base_url) { try { const modelsResponse = await fetch( @@ -278,7 +334,7 @@ export default function Outline() { console.log('获取模型列表失败,将使用默认模型'); } } - + Modal.confirm({ title: hasOutlines ? ( @@ -301,6 +357,7 @@ export default function Outline() { keep_existing: true, theme: currentProject.theme || '', model: defaultModel, // 添加默认模型 + enable_auto_characters: false, // 默认禁用自动角色引入 }} > {hasOutlines && ( @@ -324,12 +381,12 @@ export default function Outline() { {({ getFieldValue }) => { const mode = getFieldValue('mode'); const isContinue = mode === 'continue' || (mode === 'auto' && hasOutlines); - + // 续写模式不显示主题输入,使用项目原有主题 if (isContinue) { return null; } - + // 全新生成模式需要输入主题 return ( { const mode = getFieldValue('mode'); const isContinue = mode === 'continue' || (mode === 'auto' && hasOutlines); - + return ( <> {isContinue && ( @@ -408,11 +465,25 @@ export default function Outline() {