diff --git a/README.md b/README.md
index 2cf464a..fb43634 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-
+



@@ -77,10 +77,10 @@
- [x] **思维链与章节关系图谱** - 可视化章节逻辑关系
- [x] **根据分析一键重写** - 根据分析建议重新生成
- [x] **Linux DO 自动创建账号** - OAuth 登录自动生成账号
+- [x] **职业等级体系** - 自定义职业和等级系统,支持修仙境界、魔法等级等多种体系
### 📝 规划中功能
-- [ ] **职业等级体系** - 自定义职业和等级系统,支持修仙境界、魔法等级等多种体系
- [ ] **角色/组织卡片导入导出** - 单独导出角色和组织卡片,支持跨项目数据共享
- [ ] **伏笔管理** - 智能追踪剧情伏笔,提醒未回收线索,可视化伏笔时间线
- [ ] **提示词工坊** - 社区驱动的 Prompt 模板分享平台,一键导入优质提示词
diff --git a/backend/.env.example b/backend/.env.example
index 0d6bbc9..74a83e6 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -8,7 +8,7 @@
# 应用配置
# ==========================================
APP_NAME=MuMuAINovel
-APP_VERSION=1.1.3
+APP_VERSION=1.1.4
APP_HOST=0.0.0.0
APP_PORT=8000
DEBUG=false
diff --git a/backend/app/api/careers.py b/backend/app/api/careers.py
new file mode 100644
index 0000000..1b0d6e7
--- /dev/null
+++ b/backend/app/api/careers.py
@@ -0,0 +1,909 @@
+
+"""职业管理API"""
+from fastapi import APIRouter, Depends, HTTPException, Request
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func, and_
+import json
+from typing import AsyncGenerator
+
+from app.database import get_db
+from app.utils.sse_response import SSEResponse, create_sse_response
+from app.models.career import Career, CharacterCareer
+from app.models.character import Character
+from app.models.project import Project
+from app.schemas.career import (
+ CareerCreate,
+ CareerUpdate,
+ CareerResponse,
+ CareerListResponse,
+ CareerGenerateRequest,
+ CharacterCareerResponse,
+ CharacterCareerDetail,
+ SetMainCareerRequest,
+ AddSubCareerRequest,
+ UpdateCareerStageRequest,
+ CareerStage
+)
+from app.services.ai_service import AIService
+from app.logger import get_logger
+from app.api.settings import get_user_ai_service
+
+router = APIRouter(prefix="/careers", tags=["职业管理"])
+logger = get_logger(__name__)
+
+
+async def verify_project_access(project_id: str, user_id: str, db: AsyncSession) -> Project:
+ """验证用户是否有权访问指定项目"""
+ if not user_id:
+ raise HTTPException(status_code=401, detail="未登录")
+
+ result = await db.execute(
+ select(Project).where(
+ Project.id == project_id,
+ Project.user_id == user_id
+ )
+ )
+ project = result.scalar_one_or_none()
+
+ if not project:
+ logger.warning(f"项目访问被拒绝: project_id={project_id}, user_id={user_id}")
+ raise HTTPException(status_code=404, detail="项目不存在或无权访问")
+
+ return project
+
+
+@router.get("", response_model=CareerListResponse, summary="获取职业列表")
+async def get_careers(
+ project_id: str,
+ request: Request,
+ db: AsyncSession = Depends(get_db)
+):
+ """获取指定项目的所有职业"""
+ user_id = getattr(request.state, 'user_id', None)
+ await verify_project_access(project_id, user_id, db)
+
+ # 获取总数
+ count_result = await db.execute(
+ select(func.count(Career.id)).where(Career.project_id == project_id)
+ )
+ total = count_result.scalar_one()
+
+ # 获取职业列表
+ result = await db.execute(
+ select(Career)
+ .where(Career.project_id == project_id)
+ .order_by(Career.type, Career.created_at.desc())
+ )
+ careers = result.scalars().all()
+
+ # 分类返回
+ main_careers = []
+ sub_careers = []
+
+ for career in careers:
+ # 解析JSON字段
+ stages = json.loads(career.stages) if career.stages else []
+ attribute_bonuses = json.loads(career.attribute_bonuses) if career.attribute_bonuses else None
+
+ career_dict = {
+ "id": career.id,
+ "project_id": career.project_id,
+ "name": career.name,
+ "type": career.type,
+ "description": career.description,
+ "category": career.category,
+ "stages": stages,
+ "max_stage": career.max_stage,
+ "requirements": career.requirements,
+ "special_abilities": career.special_abilities,
+ "worldview_rules": career.worldview_rules,
+ "attribute_bonuses": attribute_bonuses,
+ "source": career.source,
+ "created_at": career.created_at,
+ "updated_at": career.updated_at
+ }
+
+ if career.type == "main":
+ main_careers.append(career_dict)
+ else:
+ sub_careers.append(career_dict)
+
+ return CareerListResponse(
+ total=total,
+ main_careers=main_careers,
+ sub_careers=sub_careers
+ )
+
+
+@router.post("", response_model=CareerResponse, summary="创建职业")
+async def create_career(
+ career_data: CareerCreate,
+ request: Request,
+ db: AsyncSession = Depends(get_db)
+):
+ """手动创建职业"""
+ user_id = getattr(request.state, 'user_id', None)
+ await verify_project_access(career_data.project_id, user_id, db)
+
+ try:
+ # 转换stages为JSON字符串
+ stages_json = json.dumps([stage.model_dump() for stage in career_data.stages], ensure_ascii=False)
+ attribute_bonuses_json = json.dumps(career_data.attribute_bonuses, ensure_ascii=False) if career_data.attribute_bonuses else None
+
+ # 创建职业
+ career = Career(
+ project_id=career_data.project_id,
+ name=career_data.name,
+ type=career_data.type,
+ description=career_data.description,
+ category=career_data.category,
+ stages=stages_json,
+ max_stage=career_data.max_stage,
+ requirements=career_data.requirements,
+ special_abilities=career_data.special_abilities,
+ worldview_rules=career_data.worldview_rules,
+ attribute_bonuses=attribute_bonuses_json,
+ source=career_data.source
+ )
+ db.add(career)
+ await db.commit()
+ await db.refresh(career)
+
+ logger.info(f"✅ 创建职业成功:{career.name} (ID: {career.id}, 类型: {career.type})")
+
+ return CareerResponse(
+ id=career.id,
+ project_id=career.project_id,
+ name=career.name,
+ type=career.type,
+ description=career.description,
+ category=career.category,
+ stages=career_data.stages,
+ max_stage=career.max_stage,
+ requirements=career.requirements,
+ special_abilities=career.special_abilities,
+ worldview_rules=career.worldview_rules,
+ attribute_bonuses=career_data.attribute_bonuses,
+ source=career.source,
+ created_at=career.created_at,
+ updated_at=career.updated_at
+ )
+
+ except Exception as e:
+ logger.error(f"创建职业失败: {str(e)}")
+ raise HTTPException(status_code=500, detail=f"创建职业失败: {str(e)}")
+
+
+@router.get("/generate-system", summary="AI生成新职业(增量式,流式)")
+async def generate_career_system(
+ project_id: str,
+ main_career_count: int = 3,
+ sub_career_count: int = 6,
+ enable_mcp: bool = False,
+ http_request: Request = None,
+ db: AsyncSession = Depends(get_db),
+ user_ai_service: AIService = Depends(get_user_ai_service)
+):
+ """
+ 使用AI生成新职业(增量式,基于已有职业补充,支持SSE流式进度显示)
+
+ 通过Server-Sent Events返回实时进度信息
+ """
+ async def generate() -> AsyncGenerator[str, None]:
+ try:
+ # 验证用户权限和项目是否存在
+ user_id = getattr(http_request.state, 'user_id', None)
+ project = await verify_project_access(project_id, user_id, db)
+
+ yield await SSEResponse.send_progress("开始生成新职业...", 0)
+
+ # 获取已有职业列表
+ yield await SSEResponse.send_progress("分析已有职业...", 5)
+
+ existing_careers_result = await db.execute(
+ select(Career).where(Career.project_id == project_id)
+ )
+ existing_careers = existing_careers_result.scalars().all()
+
+ # 构建已有职业摘要
+ existing_main_careers = []
+ existing_sub_careers = []
+ for career in existing_careers:
+ career_summary = f"- {career.name}({career.category or '未分类'},{career.max_stage}阶)"
+ if career.description:
+ career_summary += f": {career.description[:50]}"
+
+ if career.type == "main":
+ existing_main_careers.append(career_summary)
+ else:
+ existing_sub_careers.append(career_summary)
+
+ existing_careers_text = ""
+ if existing_main_careers:
+ existing_careers_text += f"\n已有主职业({len(existing_main_careers)}个):\n" + "\n".join(existing_main_careers)
+ if existing_sub_careers:
+ existing_careers_text += f"\n\n已有副职业({len(existing_sub_careers)}个):\n" + "\n".join(existing_sub_careers)
+
+ if not existing_careers_text:
+ existing_careers_text = "\n当前还没有任何职业,这是第一次创建职业体系。"
+
+ # 构建项目上下文
+ yield await SSEResponse.send_progress("分析项目世界观...", 15)
+
+ project_context = f"""
+项目信息:
+- 书名:{project.title}
+- 类型:{project.genre or '未设定'}
+- 主题:{project.theme or '未设定'}
+- 时间背景:{project.world_time_period or '未设定'}
+- 地理位置:{project.world_location or '未设定'}
+- 氛围基调:{project.world_atmosphere or '未设定'}
+- 世界规则:{project.world_rules or '未设定'}
+"""
+
+ user_requirements = f"""
+已有职业情况:{existing_careers_text}
+
+生成要求(增量式):
+- 本次新增主职业:{main_career_count}个
+- 本次新增副职业:{sub_career_count}个
+- ⚠️ 重要:请生成与已有职业**不重复**的新职业,形成互补体系
+- 新职业应填补已有职业体系的空缺,丰富职业多样性
+- 主职业必须严格符合世界观规则,体现核心能力体系
+- 副职业可以更加自由灵活,包含生产、辅助、特殊类型
+"""
+
+ yield await SSEResponse.send_progress("构建AI提示词...", 20)
+
+ # 构建提示词
+ prompt = f"""{project_context}
+
+{user_requirements}
+
+请为这个小说项目生成新的补充职业(增量式)。要求:
+1. **仔细分析已有职业**,避免生成重复或相似的职业
+2. **填补职业体系的空缺**,让职业体系更加完善和多样化
+3. 如果已有职业较少,可以生成核心基础职业
+4. 如果已有职业较多,可以生成特色化、专精化的职业
+
+返回JSON格式,结构如下:
+
+{{
+ "main_careers": [
+ {{
+ "name": "职业名称",
+ "description": "职业描述",
+ "category": "职业分类(如:战斗系、法术系等)",
+ "stages": [
+ {{"level": 1, "name": "阶段名称", "description": "阶段描述"}},
+ {{"level": 2, "name": "阶段名称", "description": "阶段描述"}},
+ ...
+ ],
+ "max_stage": 10,
+ "requirements": "职业要求",
+ "special_abilities": "特殊能力",
+ "worldview_rules": "世界观规则关联",
+ "attribute_bonuses": {{"strength": "+10%", "intelligence": "+5%"}}
+ }}
+ ],
+ "sub_careers": [
+ {{
+ "name": "副职业名称",
+ "description": "职业描述",
+ "category": "生产系/辅助系/特殊系",
+ "stages": [...],
+ "max_stage": 5,
+ "requirements": "职业要求",
+ "special_abilities": "特殊能力"
+ }}
+ ]
+}}
+
+注意事项:
+1. **避免重复**:生成的职业名称和定位不能与已有职业重复
+2. **互补性**:新职业应与已有职业形成互补,丰富职业体系
+3. 主职业的阶段设定要详细,体现明确的成长路径
+4. 阶段名称要符合世界观特色
+5. 副职业可以相对简化,但要有独特性
+6. 所有职业都要符合项目的整体世界观设定
+7. 只返回纯JSON,不要添加任何解释文字
+"""
+
+ yield await SSEResponse.send_progress("调用AI生成新职业...", 30)
+ logger.info(f"🎯 开始为项目 {project_id} 生成新职业(增量式,已有{len(existing_careers)}个职业)")
+
+ try:
+ # 调用AI生成
+ 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)}")
+ yield await SSEResponse.send_error(f"AI服务调用失败:{str(ai_error)}")
+ return
+
+ if not ai_response or not ai_response.strip():
+ yield await SSEResponse.send_error("AI服务返回空响应")
+ return
+
+ yield await SSEResponse.send_progress("解析AI响应...", 50)
+
+ # 清洗并解析JSON
+ try:
+ cleaned_response = user_ai_service._clean_json_response(ai_response)
+ career_data = json.loads(cleaned_response)
+ logger.info(f"✅ 职业体系JSON解析成功")
+ except json.JSONDecodeError as e:
+ logger.error(f"❌ 职业体系JSON解析失败: {e}")
+ logger.error(f" 原始响应预览: {ai_response[:200]}")
+ yield await SSEResponse.send_error(f"AI返回的内容无法解析为JSON:{str(e)}")
+ return
+
+ yield await SSEResponse.send_progress("保存主职业...", 60)
+
+ # 保存主职业
+ main_careers_created = []
+ for idx, career_info in enumerate(career_data.get("main_careers", [])):
+ try:
+ stages_json = json.dumps(career_info.get("stages", []), ensure_ascii=False)
+ attribute_bonuses = career_info.get("attribute_bonuses")
+ attribute_bonuses_json = json.dumps(attribute_bonuses, ensure_ascii=False) if attribute_bonuses else None
+
+ career = Career(
+ project_id=project_id,
+ name=career_info.get("name", f"未命名主职业{idx+1}"),
+ type="main",
+ description=career_info.get("description"),
+ category=career_info.get("category"),
+ stages=stages_json,
+ max_stage=career_info.get("max_stage", 10),
+ requirements=career_info.get("requirements"),
+ special_abilities=career_info.get("special_abilities"),
+ worldview_rules=career_info.get("worldview_rules"),
+ attribute_bonuses=attribute_bonuses_json,
+ source="ai"
+ )
+ db.add(career)
+ await db.flush()
+ main_careers_created.append(career.name)
+ logger.info(f" ✅ 创建主职业:{career.name}")
+ except Exception as e:
+ logger.error(f" ❌ 创建主职业失败:{str(e)}")
+ continue
+
+ yield await SSEResponse.send_progress("保存副职业...", 80)
+
+ # 保存副职业
+ sub_careers_created = []
+ for idx, career_info in enumerate(career_data.get("sub_careers", [])):
+ try:
+ stages_json = json.dumps(career_info.get("stages", []), ensure_ascii=False)
+ attribute_bonuses = career_info.get("attribute_bonuses")
+ attribute_bonuses_json = json.dumps(attribute_bonuses, ensure_ascii=False) if attribute_bonuses else None
+
+ career = Career(
+ project_id=project_id,
+ name=career_info.get("name", f"未命名副职业{idx+1}"),
+ type="sub",
+ description=career_info.get("description"),
+ category=career_info.get("category"),
+ stages=stages_json,
+ max_stage=career_info.get("max_stage", 5),
+ requirements=career_info.get("requirements"),
+ special_abilities=career_info.get("special_abilities"),
+ worldview_rules=career_info.get("worldview_rules"),
+ attribute_bonuses=attribute_bonuses_json,
+ source="ai"
+ )
+ db.add(career)
+ await db.flush()
+ sub_careers_created.append(career.name)
+ logger.info(f" ✅ 创建副职业:{career.name}")
+ except Exception as e:
+ logger.error(f" ❌ 创建副职业失败:{str(e)}")
+ continue
+
+ await db.commit()
+
+ total_main = len(existing_main_careers) + len(main_careers_created)
+ total_sub = len(existing_sub_careers) + len(sub_careers_created)
+
+ logger.info(f"🎉 新职业生成完成:新增主职业{len(main_careers_created)}个,新增副职业{len(sub_careers_created)}个")
+ logger.info(f" 职业体系总数:主职业{total_main}个,副职业{total_sub}个")
+
+ yield await SSEResponse.send_progress(f"新职业生成完成!(主职业{total_main}个,副职业{total_sub}个)", 100, "success")
+
+ # 发送结果数据
+ yield await SSEResponse.send_result({
+ "main_careers_count": len(main_careers_created),
+ "sub_careers_count": len(sub_careers_created),
+ "main_careers": main_careers_created,
+ "sub_careers": sub_careers_created
+ })
+
+ yield await SSEResponse.send_done()
+
+ except HTTPException as he:
+ logger.error(f"HTTP异常: {he.detail}")
+ yield await SSEResponse.send_error(he.detail, he.status_code)
+ except Exception as e:
+ logger.error(f"生成职业体系失败: {str(e)}")
+ yield await SSEResponse.send_error(f"生成新职业失败: {str(e)}")
+
+ return create_sse_response(generate())
+
+
+@router.put("/{career_id}", response_model=CareerResponse, summary="更新职业")
+async def update_career(
+ career_id: str,
+ career_update: CareerUpdate,
+ request: Request,
+ db: AsyncSession = Depends(get_db)
+):
+ """更新职业信息"""
+ result = await db.execute(
+ select(Career).where(Career.id == career_id)
+ )
+ career = result.scalar_one_or_none()
+
+ if not career:
+ raise HTTPException(status_code=404, detail="职业不存在")
+
+ # 验证用户权限
+ user_id = getattr(request.state, 'user_id', None)
+ await verify_project_access(career.project_id, user_id, db)
+
+ # 更新字段
+ update_data = career_update.model_dump(exclude_unset=True)
+
+ for field, value in update_data.items():
+ if field == "stages" and value is not None:
+ # 转换为JSON字符串
+ setattr(career, field, json.dumps([stage.model_dump() for stage in value], ensure_ascii=False))
+ elif field == "attribute_bonuses" and value is not None:
+ # 转换为JSON字符串
+ setattr(career, field, json.dumps(value, ensure_ascii=False))
+ else:
+ setattr(career, field, value)
+
+ await db.commit()
+ await db.refresh(career)
+
+ logger.info(f"✅ 更新职业成功:{career.name} (ID: {career_id})")
+
+ # 解析JSON返回
+ stages = json.loads(career.stages) if career.stages else []
+ attribute_bonuses = json.loads(career.attribute_bonuses) if career.attribute_bonuses else None
+
+ return CareerResponse(
+ id=career.id,
+ project_id=career.project_id,
+ name=career.name,
+ type=career.type,
+ description=career.description,
+ category=career.category,
+ stages=stages,
+ max_stage=career.max_stage,
+ requirements=career.requirements,
+ special_abilities=career.special_abilities,
+ worldview_rules=career.worldview_rules,
+ attribute_bonuses=attribute_bonuses,
+ source=career.source,
+ created_at=career.created_at,
+ updated_at=career.updated_at
+ )
+
+
+@router.delete("/{career_id}", summary="删除职业")
+async def delete_career(
+ career_id: str,
+ request: Request,
+ db: AsyncSession = Depends(get_db)
+):
+ """删除职业"""
+ result = await db.execute(
+ select(Career).where(Career.id == career_id)
+ )
+ career = result.scalar_one_or_none()
+
+ if not career:
+ raise HTTPException(status_code=404, detail="职业不存在")
+
+ # 验证用户权限
+ user_id = getattr(request.state, 'user_id', None)
+ await verify_project_access(career.project_id, user_id, db)
+
+ # 检查是否有角色使用该职业
+ char_career_result = await db.execute(
+ select(func.count(CharacterCareer.id)).where(CharacterCareer.career_id == career_id)
+ )
+ usage_count = char_career_result.scalar_one()
+
+ if usage_count > 0:
+ raise HTTPException(
+ status_code=400,
+ detail=f"该职业被{usage_count}个角色使用,无法删除。请先移除角色的职业关联。"
+ )
+
+ await db.delete(career)
+ await db.commit()
+
+ logger.info(f"✅ 删除职业成功:{career.name} (ID: {career_id})")
+
+ return {"message": "职业删除成功"}
+
+
+@router.get("/{career_id}", response_model=CareerResponse, summary="获取职业详情")
+async def get_career(
+ career_id: str,
+ request: Request,
+ db: AsyncSession = Depends(get_db)
+):
+ """根据ID获取职业详情"""
+ result = await db.execute(
+ select(Career).where(Career.id == career_id)
+ )
+ career = result.scalar_one_or_none()
+
+ if not career:
+ raise HTTPException(status_code=404, detail="职业不存在")
+
+ # 验证用户权限
+ user_id = getattr(request.state, 'user_id', None)
+ await verify_project_access(career.project_id, user_id, db)
+
+ # 解析JSON字段
+ stages = json.loads(career.stages) if career.stages else []
+ attribute_bonuses = json.loads(career.attribute_bonuses) if career.attribute_bonuses else None
+
+ return CareerResponse(
+ id=career.id,
+ project_id=career.project_id,
+ name=career.name,
+ type=career.type,
+ description=career.description,
+ category=career.category,
+ stages=stages,
+ max_stage=career.max_stage,
+ requirements=career.requirements,
+ special_abilities=career.special_abilities,
+ worldview_rules=career.worldview_rules,
+ attribute_bonuses=attribute_bonuses,
+ source=career.source,
+ created_at=career.created_at,
+ updated_at=career.updated_at
+ )
+
+
+# ===== 角色职业关联API =====
+
+@router.get("/character/{character_id}/careers", response_model=CharacterCareerResponse, summary="获取角色的职业信息")
+async def get_character_careers(
+ character_id: str,
+ request: Request,
+ db: AsyncSession = Depends(get_db)
+):
+ """获取角色的所有职业信息(主职业和副职业)"""
+ # 验证角色存在
+ char_result = await db.execute(
+ select(Character).where(Character.id == character_id)
+ )
+ character = char_result.scalar_one_or_none()
+
+ if not character:
+ raise HTTPException(status_code=404, detail="角色不存在")
+
+ # 验证用户权限
+ user_id = getattr(request.state, 'user_id', None)
+ await verify_project_access(character.project_id, user_id, db)
+
+ # 获取角色的所有职业关联
+ result = await db.execute(
+ select(CharacterCareer, Career)
+ .join(Career, CharacterCareer.career_id == Career.id)
+ .where(CharacterCareer.character_id == character_id)
+ .order_by(CharacterCareer.career_type.desc()) # main排在前
+ )
+ career_relations = result.all()
+
+ main_career = None
+ sub_careers = []
+
+ for char_career, career in career_relations:
+ # 解析职业的阶段信息
+ stages = json.loads(career.stages) if career.stages else []
+
+ # 找到当前阶段信息
+ stage_name = "未知阶段"
+ stage_description = None
+ for stage in stages:
+ if stage.get("level") == char_career.current_stage:
+ stage_name = stage.get("name", f"第{char_career.current_stage}阶段")
+ stage_description = stage.get("description")
+ break
+
+ career_detail = CharacterCareerDetail(
+ id=char_career.id,
+ character_id=char_career.character_id,
+ career_id=char_career.career_id,
+ career_name=career.name,
+ career_type=char_career.career_type,
+ current_stage=char_career.current_stage,
+ stage_name=stage_name,
+ stage_description=stage_description,
+ stage_progress=char_career.stage_progress,
+ max_stage=career.max_stage,
+ started_at=char_career.started_at,
+ reached_current_stage_at=char_career.reached_current_stage_at,
+ notes=char_career.notes,
+ created_at=char_career.created_at,
+ updated_at=char_career.updated_at
+ )
+
+ if char_career.career_type == "main":
+ main_career = career_detail
+ else:
+ sub_careers.append(career_detail)
+
+ return CharacterCareerResponse(
+ main_career=main_career,
+ sub_careers=sub_careers
+ )
+
+
+@router.post("/character/{character_id}/careers/main", summary="设置角色主职业")
+async def set_main_career(
+ character_id: str,
+ career_request: SetMainCareerRequest,
+ request: Request,
+ db: AsyncSession = Depends(get_db)
+):
+ """设置或更换角色的主职业"""
+ # 验证角色存在
+ char_result = await db.execute(
+ select(Character).where(Character.id == character_id)
+ )
+ character = char_result.scalar_one_or_none()
+
+ if not character:
+ raise HTTPException(status_code=404, detail="角色不存在")
+
+ # 验证用户权限
+ user_id = getattr(request.state, 'user_id', None)
+ await verify_project_access(character.project_id, user_id, db)
+
+ # 验证职业存在且为主职业类型
+ career_result = await db.execute(
+ select(Career).where(
+ Career.id == career_request.career_id,
+ Career.project_id == character.project_id
+ )
+ )
+ career = career_result.scalar_one_or_none()
+
+ if not career:
+ raise HTTPException(status_code=404, detail="职业不存在")
+
+ if career.type != "main":
+ raise HTTPException(status_code=400, detail="该职业不是主职业类型,无法设置为主职业")
+
+ # 验证阶段有效性
+ if career_request.current_stage > career.max_stage:
+ raise HTTPException(
+ status_code=400,
+ detail=f"阶段超出范围,该职业最大阶段为{career.max_stage}"
+ )
+
+ # 检查是否已有主职业
+ existing_main = await db.execute(
+ select(CharacterCareer).where(
+ CharacterCareer.character_id == character_id,
+ CharacterCareer.career_type == "main"
+ )
+ )
+ current_main = existing_main.scalar_one_or_none()
+
+ if current_main:
+ # 删除旧的主职业
+ await db.delete(current_main)
+ logger.info(f" 移除旧主职业关联: {current_main.career_id}")
+
+ # 创建新的主职业关联
+ char_career = CharacterCareer(
+ character_id=character_id,
+ career_id=career_request.career_id,
+ career_type="main",
+ current_stage=career_request.current_stage,
+ stage_progress=0,
+ started_at=career_request.started_at,
+ reached_current_stage_at=career_request.started_at
+ )
+ db.add(char_career)
+ await db.commit()
+
+ logger.info(f"✅ 设置主职业成功:角色{character.name} -> {career.name}(第{career_request.current_stage}阶段)")
+
+ return {"message": "主职业设置成功", "career_name": career.name}
+
+
+@router.post("/character/{character_id}/careers/sub", summary="添加角色副职业")
+async def add_sub_career(
+ character_id: str,
+ career_request: AddSubCareerRequest,
+ request: Request,
+ db: AsyncSession = Depends(get_db)
+):
+ """为角色添加副职业"""
+ # 验证角色存在
+ char_result = await db.execute(
+ select(Character).where(Character.id == character_id)
+ )
+ character = char_result.scalar_one_or_none()
+
+ if not character:
+ raise HTTPException(status_code=404, detail="角色不存在")
+
+ # 验证用户权限
+ user_id = getattr(request.state, 'user_id', None)
+ await verify_project_access(character.project_id, user_id, db)
+
+ # 验证职业存在且为副职业类型
+ career_result = await db.execute(
+ select(Career).where(
+ Career.id == career_request.career_id,
+ Career.project_id == character.project_id
+ )
+ )
+ career = career_result.scalar_one_or_none()
+
+ if not career:
+ raise HTTPException(status_code=404, detail="职业不存在")
+
+ if career.type != "sub":
+ raise HTTPException(status_code=400, detail="该职业不是副职业类型,无法添加为副职业")
+
+ # 验证阶段有效性
+ if career_request.current_stage > career.max_stage:
+ raise HTTPException(
+ status_code=400,
+ detail=f"阶段超出范围,该职业最大阶段为{career.max_stage}"
+ )
+
+ # 检查是否已存在
+ existing_check = await db.execute(
+ select(CharacterCareer).where(
+ CharacterCareer.character_id == character_id,
+ CharacterCareer.career_id == career_request.career_id
+ )
+ )
+ if existing_check.scalar_one_or_none():
+ raise HTTPException(status_code=400, detail="该角色已拥有此副职业")
+
+ # 检查副职业数量限制(可选,这里设置为最多5个)
+ sub_count_result = await db.execute(
+ select(func.count(CharacterCareer.id)).where(
+ CharacterCareer.character_id == character_id,
+ CharacterCareer.career_type == "sub"
+ )
+ )
+ sub_count = sub_count_result.scalar_one()
+
+ if sub_count >= 5:
+ raise HTTPException(status_code=400, detail="副职业数量已达上限(最多5个)")
+
+ # 创建副职业关联
+ char_career = CharacterCareer(
+ character_id=character_id,
+ career_id=career_request.career_id,
+ career_type="sub",
+ current_stage=career_request.current_stage,
+ stage_progress=0,
+ started_at=career_request.started_at,
+ reached_current_stage_at=career_request.started_at
+ )
+ db.add(char_career)
+ await db.commit()
+
+ logger.info(f"✅ 添加副职业成功:角色{character.name} -> {career.name}(第{career_request.current_stage}阶段)")
+
+ return {"message": "副职业添加成功", "career_name": career.name}
+
+
+@router.put("/character/{character_id}/careers/{career_id}/stage", summary="更新职业阶段")
+async def update_career_stage(
+ character_id: str,
+ career_id: str,
+ stage_request: UpdateCareerStageRequest,
+ request: Request,
+ db: AsyncSession = Depends(get_db)
+):
+ """更新角色在某个职业的阶段"""
+ # 验证角色职业关联存在
+ result = await db.execute(
+ select(CharacterCareer, Career, Character)
+ .join(Career, CharacterCareer.career_id == Career.id)
+ .join(Character, CharacterCareer.character_id == Character.id)
+ .where(
+ CharacterCareer.character_id == character_id,
+ CharacterCareer.career_id == career_id
+ )
+ )
+ relation_data = result.one_or_none()
+
+ if not relation_data:
+ raise HTTPException(status_code=404, detail="角色职业关联不存在")
+
+ char_career, career, character = relation_data
+
+ # 验证用户权限
+ user_id = getattr(request.state, 'user_id', None)
+ await verify_project_access(character.project_id, user_id, db)
+
+ # 验证新阶段有效性
+ if stage_request.current_stage > career.max_stage:
+ raise HTTPException(
+ status_code=400,
+ detail=f"阶段超出范围,该职业最大阶段为{career.max_stage}"
+ )
+
+ # 验证阶段递增规则(不能倒退,除非降级)
+ if stage_request.current_stage < char_career.current_stage:
+ logger.warning(f"⚠️ 角色{character.name}的职业{career.name}阶段降低:{char_career.current_stage} -> {stage_request.current_stage}")
+
+ # 更新阶段信息
+ char_career.current_stage = stage_request.current_stage
+ char_career.stage_progress = stage_request.stage_progress
+ if stage_request.reached_current_stage_at:
+ char_career.reached_current_stage_at = stage_request.reached_current_stage_at
+ if stage_request.notes is not None:
+ char_career.notes = stage_request.notes
+
+ await db.commit()
+
+ logger.info(f"✅ 更新职业阶段成功:{character.name}的{career.name} -> 第{stage_request.current_stage}阶段")
+
+ return {
+ "message": "职业阶段更新成功",
+ "career_name": career.name,
+ "new_stage": stage_request.current_stage
+ }
+
+
+@router.delete("/character/{character_id}/careers/{career_id}", summary="删除角色副职业")
+async def remove_sub_career(
+ character_id: str,
+ career_id: str,
+ request: Request,
+ db: AsyncSession = Depends(get_db)
+):
+ """删除角色的副职业"""
+ # 验证角色职业关联存在
+ result = await db.execute(
+ select(CharacterCareer, Character)
+ .join(Character, CharacterCareer.character_id == Character.id)
+ .where(
+ CharacterCareer.character_id == character_id,
+ CharacterCareer.career_id == career_id
+ )
+ )
+ relation_data = result.one_or_none()
+
+ if not relation_data:
+ raise HTTPException(status_code=404, detail="角色职业关联不存在")
+
+ char_career, character = relation_data
+
+ # 验证用户权限
+ user_id = getattr(request.state, 'user_id', None)
+ await verify_project_access(character.project_id, user_id, db)
+
+ # 不允许删除主职业
+ if char_career.career_type == "main":
+ raise HTTPException(status_code=400, detail="无法删除主职业,只能更换")
+
+ await db.delete(char_career)
+ await db.commit()
+
+ logger.info(f"✅ 删除副职业成功:角色{character.name}移除职业{career_id}")
+
+ return {"message": "副职业删除成功"}
\ No newline at end of file
diff --git a/backend/app/api/chapters.py b/backend/app/api/chapters.py
index 9b2110e..123705d 100644
--- a/backend/app/api/chapters.py
+++ b/backend/app/api/chapters.py
@@ -14,6 +14,7 @@ from app.models.chapter import Chapter
from app.models.project import Project
from app.models.outline import Outline
from app.models.character import Character
+from app.models.career import Career, CharacterCareer
from app.models.generation_history import GenerationHistory
from app.models.writing_style import WritingStyle
from app.models.analysis_task import AnalysisTask
@@ -665,6 +666,114 @@ async def build_smart_chapter_context(
return context_parts
+async def build_characters_info_with_careers(
+ db: AsyncSession,
+ project_id: str,
+ characters: list[Character],
+ filter_character_names: Optional[list[str]] = None
+) -> str:
+ """
+ 构建包含职业信息的角色上下文
+
+ Args:
+ db: 数据库会话
+ project_id: 项目ID
+ characters: 角色列表
+ filter_character_names: 可选,筛选特定角色名称列表(用于1-1模式的structure.characters或1-n模式的expansion_plan.character_focus)
+
+ Returns:
+ 格式化的角色信息字符串,包含职业信息
+ """
+ if not characters:
+ return '暂无角色信息'
+
+ # 如果提供了筛选名单,只保留匹配的角色
+ if filter_character_names:
+ filtered_characters = [c for c in characters if c.name in filter_character_names]
+ if not filtered_characters:
+ logger.warning(f"筛选后无匹配角色,使用全部角色。筛选名单: {filter_character_names}")
+ filtered_characters = characters
+ else:
+ logger.info(f"根据筛选名单保留 {len(filtered_characters)}/{len(characters)} 个角色: {[c.name for c in filtered_characters]}")
+ characters = filtered_characters
+
+ # 获取所有职业信息(一次性查询,提高效率)
+ careers_result = await db.execute(
+ select(Career).where(Career.project_id == project_id)
+ )
+ careers_map = {c.id: c for c in careers_result.scalars().all()}
+
+ # 获取所有角色的职业关联(一次性查询)
+ character_ids = [c.id for c in characters]
+ if not character_ids:
+ return '暂无角色信息'
+
+ character_careers_result = await db.execute(
+ select(CharacterCareer).where(CharacterCareer.character_id.in_(character_ids))
+ )
+ character_careers = character_careers_result.scalars().all()
+
+ # 构建角色ID到职业信息的映射
+ char_career_map = {}
+ for cc in character_careers:
+ if cc.character_id not in char_career_map:
+ char_career_map[cc.character_id] = {'main': None, 'sub': []}
+
+ career = careers_map.get(cc.career_id)
+ if not career:
+ continue
+
+ career_info = {
+ 'name': career.name,
+ 'stage': cc.current_stage,
+ 'max_stage': career.max_stage,
+ 'stage_progress': cc.stage_progress
+ }
+
+ if cc.career_type == 'main':
+ char_career_map[cc.character_id]['main'] = career_info
+ else:
+ char_career_map[cc.character_id]['sub'].append(career_info)
+
+ # 构建角色信息字符串
+ characters_info_parts = []
+ for c in characters:
+ # 基本信息
+ entity_type = '组织' if c.is_organization else '角色'
+ base_info = f"- {c.name}({entity_type}, {c.role_type})"
+
+ # 职业信息
+ career_info_str = ""
+ if c.id in char_career_map:
+ career_data = char_career_map[c.id]
+
+ # 主职业
+ if career_data['main']:
+ main = career_data['main']
+ stage_desc = f"{main['stage']}/{main['max_stage']}阶"
+ career_info_str += f" | 主职业: {main['name']}({stage_desc})"
+
+ # 副职业
+ if career_data['sub']:
+ sub_list = []
+ for sub in career_data['sub']:
+ stage_desc = f"{sub['stage']}/{sub['max_stage']}阶"
+ sub_list.append(f"{sub['name']}({stage_desc})")
+ career_info_str += f" | 副职业: {', '.join(sub_list)}"
+
+ # 性格描述
+ personality_str = ""
+ if c.personality:
+ personality_preview = c.personality[:100] if len(c.personality) > 100 else c.personality
+ personality_str = f": {personality_preview}"
+
+ # 组合完整信息
+ full_info = base_info + career_info_str + personality_str
+ characters_info_parts.append(full_info)
+
+ return "\n".join(characters_info_parts)
+
+
@router.get("/{chapter_id}/can-generate", summary="检查章节是否可以生成")
async def check_can_generate(
chapter_id: str,
@@ -716,7 +825,7 @@ async def analyze_chapter_background(
project_id: str,
task_id: str,
ai_service: AIService
-):
+) -> bool:
"""
后台异步分析章节(支持并发,使用锁保护数据库写入)
@@ -726,6 +835,9 @@ async def analyze_chapter_background(
project_id: 项目ID
task_id: 任务ID
ai_service: AI服务实例
+
+ Returns:
+ bool: True表示分析成功,False表示分析失败
"""
db_session = None
write_lock = await get_db_write_lock(user_id)
@@ -942,6 +1054,37 @@ async def analyze_chapter_background(
)
logger.info(f"✅ 添加{added_count}条记忆到向量库")
+ # 💼 更新角色职业(根据分析结果)
+ if analysis_result.get('character_states'):
+ try:
+ from app.services.career_update_service import CareerUpdateService
+
+ logger.info(f"💼 开始根据分析结果更新角色职业...")
+ career_update_result = await CareerUpdateService.update_careers_from_analysis(
+ db=db_session,
+ project_id=project_id,
+ character_states=analysis_result.get('character_states', []),
+ chapter_id=chapter_id,
+ chapter_number=chapter.chapter_number
+ )
+
+ if career_update_result['updated_count'] > 0:
+ logger.info(
+ f"✅ 更新了 {career_update_result['updated_count']} 个角色的职业信息: "
+ f"{', '.join(career_update_result['updated_characters'])}"
+ )
+ if career_update_result['changes']:
+ for change in career_update_result['changes']:
+ logger.info(f" - {change}")
+ else:
+ logger.info("ℹ️ 本章节无角色职业变化")
+
+ except Exception as career_error:
+ # 职业更新失败不应影响整个分析流程
+ logger.error(f"⚠️ 更新角色职业失败: {str(career_error)}", exc_info=True)
+ else:
+ logger.debug("📋 分析结果中无角色状态信息,跳过职业更新")
+
# 最终更新任务状态(写操作,需要锁)- 增加重试机制
update_success = False
for retry in range(3):
@@ -965,6 +1108,9 @@ async def analyze_chapter_background(
if not update_success:
logger.warning(f"⚠️ 章节分析完成但状态更新失败: {chapter_id}")
+ # 返回成功状态
+ return True
+
except Exception as e:
logger.error(f"❌ 后台分析异常: {str(e)}", exc_info=True)
# 确保任务状态被更新为failed(写操作,需要锁)
@@ -995,6 +1141,10 @@ async def analyze_chapter_background(
await asyncio.sleep(0.1) # 短暂等待后重试
else:
logger.error(f"❌ 任务状态更新失败,已达到最大重试次数: {task_id}")
+
+ # 返回失败状态
+ return False
+
finally:
if db_session:
await db_session.close()
@@ -1108,15 +1258,41 @@ async def generate_chapter_content_stream(
for o in all_outlines
])
- # 获取角色信息
+ # 获取角色信息(包含职业信息)
characters_result = await db_session.execute(
select(Character).where(Character.project_id == current_chapter.project_id)
)
characters = characters_result.scalars().all()
- characters_info = "\n".join([
- f"- {c.name}({'组织' if c.is_organization else '角色'}, {c.role_type}): {c.personality[:100] if c.personality else ''}"
- for c in characters
- ])
+
+ # 📝 根据大纲模式智能筛选相关角色
+ filter_character_names = None
+ if outline_mode == 'one-to-one':
+ # 1-1模式:从outline.structure中提取characters字段
+ if outline and outline.structure:
+ try:
+ structure = json.loads(outline.structure)
+ filter_character_names = structure.get('characters', [])
+ if filter_character_names:
+ logger.info(f"📋 1-1模式:从structure提取角色列表 {filter_character_names}")
+ except json.JSONDecodeError:
+ logger.warning(f"⚠️ outline.structure解析失败,使用全部角色")
+ else:
+ # 1-n模式:从chapter.expansion_plan中提取character_focus字段
+ if current_chapter.expansion_plan:
+ try:
+ plan = json.loads(current_chapter.expansion_plan)
+ filter_character_names = plan.get('character_focus', [])
+ if filter_character_names:
+ logger.info(f"📋 1-n模式:从expansion_plan提取角色焦点 {filter_character_names}")
+ except json.JSONDecodeError:
+ logger.warning(f"⚠️ expansion_plan解析失败,使用全部角色")
+
+ characters_info = await build_characters_info_with_careers(
+ db=db_session,
+ project_id=current_chapter.project_id,
+ characters=characters,
+ filter_character_names=filter_character_names
+ )
# 获取写作风格
style_content = ""
@@ -2325,28 +2501,83 @@ async def execute_batch_generation_in_order(
if task.enable_analysis:
logger.info(f"🔍 开始同步分析章节: 第{chapter.chapter_number}章")
- async with write_lock:
- analysis_task = AnalysisTask(
- chapter_id=chapter_id,
- user_id=user_id,
- project_id=task.project_id,
- status='pending',
- progress=0
- )
- db_session.add(analysis_task)
- await db_session.commit()
- await db_session.refresh(analysis_task)
+ # 分析重试机制(最多3次)
+ analysis_retry_count = 0
+ analysis_success = False
+ last_analysis_error = None
- # 同步执行分析(等待完成)
- await analyze_chapter_background(
- chapter_id=chapter_id,
- user_id=user_id,
- project_id=task.project_id,
- task_id=analysis_task.id,
- ai_service=ai_service
- )
-
- logger.info(f"✅ 章节分析完成: 第{chapter.chapter_number}章")
+ while analysis_retry_count < 3 and not analysis_success:
+ try:
+ if analysis_retry_count > 0:
+ logger.info(f"🔄 重试分析章节 (第{analysis_retry_count}次): 第{chapter.chapter_number}章")
+
+ async with write_lock:
+ analysis_task = AnalysisTask(
+ chapter_id=chapter_id,
+ user_id=user_id,
+ project_id=task.project_id,
+ status='pending',
+ progress=0
+ )
+ db_session.add(analysis_task)
+ await db_session.commit()
+ await db_session.refresh(analysis_task)
+
+ # 同步执行分析,直接使用返回值判断成功/失败
+ analysis_result = await analyze_chapter_background(
+ chapter_id=chapter_id,
+ user_id=user_id,
+ project_id=task.project_id,
+ task_id=analysis_task.id,
+ ai_service=ai_service
+ )
+
+ # 直接根据返回值判断
+ if not analysis_result:
+ last_analysis_error = "分析函数返回失败"
+ logger.error(f"❌ 章节分析失败: 第{chapter.chapter_number}章")
+ raise Exception(f"章节分析失败")
+
+ # 分析成功
+ analysis_success = True
+ logger.info(f"✅ 章节分析成功: 第{chapter.chapter_number}章")
+
+ except Exception as analysis_error:
+ last_analysis_error = str(analysis_error)
+ analysis_retry_count += 1
+
+ if analysis_retry_count < 3:
+ # 还有重试机会,等待后重试
+ wait_time = min(2 ** analysis_retry_count, 10)
+ logger.warning(f"⏳ 分析失败,等待 {wait_time} 秒后重试...")
+ await asyncio.sleep(wait_time)
+ else:
+ # 达到最大重试次数,必须终止整个批量任务
+ logger.error(f"❌ 章节分析失败,已达最大重试次数(3次): 第{chapter.chapter_number}章")
+
+ # 记录失败信息
+ failed_info = {
+ 'chapter_id': chapter_id,
+ 'chapter_number': chapter.chapter_number,
+ 'title': chapter.title,
+ 'error': f"分析失败(重试3次): {last_analysis_error}",
+ 'retry_count': 3
+ }
+
+ async with write_lock:
+ if task.failed_chapters is None:
+ task.failed_chapters = []
+ task.failed_chapters.append(failed_info)
+
+ # 标记任务失败并终止
+ task.status = 'failed'
+ task.error_message = f"第{chapter.chapter_number}章分析失败(重试3次): {last_analysis_error}"[:500]
+ task.completed_at = datetime.now()
+ task.current_retry_count = 0
+ await db_session.commit()
+
+ logger.error(f"🛑 批量生成中断: 第{chapter.chapter_number}章分析失败")
+ return # 立即终止整个批量生成任务
# 标记成功
chapter_success = True
@@ -2361,7 +2592,8 @@ async def execute_batch_generation_in_order(
except Exception as e:
last_error = str(e)
- logger.error(f"❌ 章节生成失败: 第{chapter.chapter_number if chapter else '?'}章, 错误: {last_error}")
+ error_msg = f"第{chapter.chapter_number if chapter else '?'}章出错: {last_error}"
+ logger.error(f"❌ {error_msg}")
retry_count += 1
@@ -2394,7 +2626,13 @@ async def execute_batch_generation_in_order(
task.current_retry_count = 0
await db_session.commit()
- logger.error(f"🛑 批量生成终止于第{chapter.chapter_number}章")
+ # ⚠️ 如果启用了同步分析,任何错误都应该中断任务
+ # 因为章节生成或分析失败会影响后续章节的职业更新和剧情连贯性
+ if task.enable_analysis:
+ logger.error(f"🛑 批量生成中断: 因启用同步分析,任何错误都会中断任务以确保职业信息和剧情连贯性")
+ else:
+ logger.error(f"🛑 批量生成终止于第{chapter.chapter_number}章")
+
return
# 全部完成
@@ -2469,15 +2707,41 @@ async def generate_single_chapter_for_batch(
for o in all_outlines
])
- # 获取角色信息
+ # 获取角色信息(包含职业信息)
characters_result = await db_session.execute(
select(Character).where(Character.project_id == chapter.project_id)
)
characters = characters_result.scalars().all()
- characters_info = "\n".join([
- f"- {c.name}({'组织' if c.is_organization else '角色'}, {c.role_type}): {c.personality[:100] if c.personality else ''}"
- for c in characters
- ])
+
+ # 📝 根据大纲模式智能筛选相关角色(批量生成)
+ filter_character_names = None
+ if outline_mode == 'one-to-one':
+ # 1-1模式:从outline.structure中提取characters字段
+ if outline and outline.structure:
+ try:
+ structure = json.loads(outline.structure)
+ filter_character_names = structure.get('characters', [])
+ if filter_character_names:
+ logger.info(f"📋 批量生成 - 1-1模式:从structure提取角色列表 {filter_character_names}")
+ except json.JSONDecodeError:
+ logger.warning(f"⚠️ 批量生成 - outline.structure解析失败,使用全部角色")
+ else:
+ # 1-n模式:从chapter.expansion_plan中提取character_focus字段
+ if chapter.expansion_plan:
+ try:
+ plan = json.loads(chapter.expansion_plan)
+ filter_character_names = plan.get('character_focus', [])
+ if filter_character_names:
+ logger.info(f"📋 批量生成 - 1-n模式:从expansion_plan提取角色焦点 {filter_character_names}")
+ except json.JSONDecodeError:
+ logger.warning(f"⚠️ 批量生成 - expansion_plan解析失败,使用全部角色")
+
+ characters_info = await build_characters_info_with_careers(
+ db=db_session,
+ project_id=chapter.project_id,
+ characters=characters,
+ filter_character_names=filter_character_names
+ )
# 获取写作风格
style_content = ""
@@ -2721,12 +2985,53 @@ async def regenerate_chapter_stream(
)
project = project_result.scalar_one_or_none()
- # 获取角色信息
+ # 获取角色信息(包含职业信息)
characters_result = await temp_db.execute(
select(Character).where(Character.project_id == chapter.project_id)
)
characters = characters_result.scalars().all()
+ # 📝 根据大纲模式智能筛选相关角色(重新生成)
+ outline_mode_result = await temp_db.execute(
+ select(Project.outline_mode).where(Project.id == chapter.project_id)
+ )
+ outline_mode = outline_mode_result.scalar_one_or_none() or 'one-to-many'
+
+ filter_character_names = None
+ if outline_mode == 'one-to-one':
+ # 1-1模式:从outline.structure中提取characters字段
+ outline_result_temp = await temp_db.execute(
+ select(Outline.structure)
+ .where(Outline.project_id == chapter.project_id)
+ .where(Outline.order_index == chapter.chapter_number)
+ )
+ outline_structure = outline_result_temp.scalar_one_or_none()
+ if outline_structure:
+ try:
+ structure = json.loads(outline_structure)
+ filter_character_names = structure.get('characters', [])
+ if filter_character_names:
+ logger.info(f"📋 重新生成 - 1-1模式:从structure提取角色列表 {filter_character_names}")
+ except json.JSONDecodeError:
+ logger.warning(f"⚠️ 重新生成 - outline.structure解析失败,使用全部角色")
+ else:
+ # 1-n模式:从chapter.expansion_plan中提取character_focus字段
+ if chapter.expansion_plan:
+ try:
+ plan = json.loads(chapter.expansion_plan)
+ filter_character_names = plan.get('character_focus', [])
+ if filter_character_names:
+ logger.info(f"📋 重新生成 - 1-n模式:从expansion_plan提取角色焦点 {filter_character_names}")
+ except json.JSONDecodeError:
+ logger.warning(f"⚠️ 重新生成 - expansion_plan解析失败,使用全部角色")
+
+ characters_info_with_careers = await build_characters_info_with_careers(
+ db=temp_db,
+ project_id=chapter.project_id,
+ characters=characters,
+ filter_character_names=filter_character_names
+ )
+
# 获取章节大纲
outline_result = await temp_db.execute(
select(Outline)
@@ -2779,10 +3084,7 @@ async def regenerate_chapter_stream(
'time_period': project.world_time_period if project else '未设定',
'location': project.world_location if project else '未设定',
'atmosphere': project.world_atmosphere if project else '未设定',
- 'characters_info': "\n".join([
- f"- {c.name}({'组织' if c.is_organization else '角色'}, {c.role_type}): {c.personality[:100] if c.personality else ''}"
- for c in characters
- ]) if characters else '暂无角色信息',
+ 'characters_info': characters_info_with_careers,
'chapter_outline': outline.content if outline else chapter.summary or '暂无大纲',
'previous_context': '' # 可以后续扩展添加前置章节上下文
}
diff --git a/backend/app/api/characters.py b/backend/app/api/characters.py
index fe7921d..598efc4 100644
--- a/backend/app/api/characters.py
+++ b/backend/app/api/characters.py
@@ -85,7 +85,7 @@ async def get_characters(
)
characters = result.scalars().all()
- # 为组织类型的角色填充Organization表的额外字段
+ # 为组织类型的角色填充Organization表的额外字段,并添加职业信息
enriched_characters = []
for char in characters:
char_dict = {
@@ -110,7 +110,10 @@ async def get_characters(
"power_level": None,
"location": None,
"motto": None,
- "color": None
+ "color": None,
+ "main_career_id": char.main_career_id,
+ "main_career_stage": char.main_career_stage,
+ "sub_careers": json.loads(char.sub_careers) if char.sub_careers else None
}
if char.is_organization:
@@ -156,7 +159,7 @@ async def get_project_characters(
)
characters = result.scalars().all()
- # 为组织类型的角色填充Organization表的额外字段
+ # 为组织类型的角色填充Organization表的额外字段,并添加职业信息
enriched_characters = []
for char in characters:
char_dict = {
@@ -181,7 +184,10 @@ async def get_project_characters(
"power_level": None,
"location": None,
"motto": None,
- "color": None
+ "color": None,
+ "main_career_id": char.main_career_id,
+ "main_career_stage": char.main_career_stage,
+ "sub_careers": json.loads(char.sub_careers) if char.sub_careers else None
}
if char.is_organization:
@@ -232,6 +238,8 @@ async def update_character(
db: AsyncSession = Depends(get_db)
):
"""更新角色信息"""
+ from app.models.career import CharacterCareer, Career
+
result = await db.execute(
select(Character).where(Character.id == character_id)
)
@@ -260,6 +268,139 @@ async def update_character(
if 'color' in update_data:
org_fields['color'] = update_data.pop('color')
+ # 处理主职业和副职业更新
+ main_career_id = update_data.pop('main_career_id', None)
+ main_career_stage = update_data.pop('main_career_stage', None)
+ sub_careers_json = update_data.pop('sub_careers', None)
+
+ if main_career_id is not None:
+ # 验证职业存在
+ if main_career_id: # 不为空
+ career_result = await db.execute(
+ select(Career).where(
+ Career.id == main_career_id,
+ Career.project_id == character.project_id,
+ Career.type == 'main'
+ )
+ )
+ career = career_result.scalar_one_or_none()
+
+ if not career:
+ raise HTTPException(status_code=400, detail="主职业不存在或类型错误")
+
+ # 验证阶段有效性
+ if main_career_stage and main_career_stage > career.max_stage:
+ raise HTTPException(status_code=400, detail=f"阶段超出范围,该职业最大阶段为{career.max_stage}")
+
+ # 更新或创建CharacterCareer关联
+ char_career_result = await db.execute(
+ select(CharacterCareer).where(
+ CharacterCareer.character_id == character_id,
+ CharacterCareer.career_type == 'main'
+ )
+ )
+ char_career = char_career_result.scalar_one_or_none()
+
+ if char_career:
+ # 更新现有关联
+ char_career.career_id = main_career_id
+ if main_career_stage:
+ char_career.current_stage = main_career_stage
+ logger.info(f"更新主职业关联:{character.name} -> {career.name}")
+ else:
+ # 创建新关联
+ char_career = CharacterCareer(
+ character_id=character_id,
+ career_id=main_career_id,
+ career_type='main',
+ current_stage=main_career_stage or 1,
+ stage_progress=0
+ )
+ db.add(char_career)
+ logger.info(f"创建主职业关联:{character.name} -> {career.name}")
+
+ # 更新Character表的冗余字段
+ character.main_career_id = main_career_id
+ character.main_career_stage = main_career_stage or char_career.current_stage
+ else:
+ # 清空主职业
+ char_career_result = await db.execute(
+ select(CharacterCareer).where(
+ CharacterCareer.character_id == character_id,
+ CharacterCareer.career_type == 'main'
+ )
+ )
+ char_career = char_career_result.scalar_one_or_none()
+ if char_career:
+ await db.delete(char_career)
+ logger.info(f"移除主职业关联:{character.name}")
+
+ character.main_career_id = None
+ character.main_career_stage = None
+ elif main_career_stage is not None and character.main_career_id:
+ # 只更新阶段
+ char_career_result = await db.execute(
+ select(CharacterCareer).where(
+ CharacterCareer.character_id == character_id,
+ CharacterCareer.career_type == 'main'
+ )
+ )
+ char_career = char_career_result.scalar_one_or_none()
+ if char_career:
+ char_career.current_stage = main_career_stage
+ character.main_career_stage = main_career_stage
+ logger.info(f"更新主职业阶段:{character.name} -> 阶段{main_career_stage}")
+
+ # 处理副职业更新
+ if sub_careers_json is not None:
+ # 解析副职业JSON
+ try:
+ sub_careers_data = json.loads(sub_careers_json) if isinstance(sub_careers_json, str) else sub_careers_json
+ except:
+ sub_careers_data = []
+
+ # 删除现有的所有副职业关联
+ existing_subs = await db.execute(
+ select(CharacterCareer).where(
+ CharacterCareer.character_id == character_id,
+ CharacterCareer.career_type == 'sub'
+ )
+ )
+ for sub_career in existing_subs.scalars():
+ await db.delete(sub_career)
+
+ # 创建新的副职业关联
+ for sub_data in sub_careers_data[:2]: # 最多2个副职业
+ career_id = sub_data.get('career_id')
+ if not career_id:
+ continue
+
+ # 验证副职业存在
+ career_result = await db.execute(
+ select(Career).where(
+ Career.id == career_id,
+ Career.project_id == character.project_id,
+ Career.type == 'sub'
+ )
+ )
+ career = career_result.scalar_one_or_none()
+
+ if career:
+ # 创建副职业关联
+ char_career = CharacterCareer(
+ character_id=character_id,
+ career_id=career_id,
+ career_type='sub',
+ current_stage=sub_data.get('stage', 1),
+ stage_progress=0
+ )
+ db.add(char_career)
+ logger.info(f"添加副职业关联:{character.name} -> {career.name}")
+
+ # 更新Character表的sub_careers冗余字段
+ character.sub_careers = sub_careers_json if isinstance(sub_careers_json, str) else json.dumps(sub_careers_data, ensure_ascii=False)
+ logger.info(f"更新副职业信息:{character.name}")
+
# 更新 Character 表字段
for field, value in update_data.items():
setattr(character, field, value)
@@ -290,7 +431,51 @@ async def update_character(
await db.refresh(character)
logger.info(f"更新角色/组织成功:{character.name} (ID: {character_id})")
- return character
+
+ # 构建响应,确保sub_careers是list类型
+ response_data = {
+ "id": character.id,
+ "project_id": character.project_id,
+ "name": character.name,
+ "age": character.age,
+ "gender": character.gender,
+ "is_organization": character.is_organization,
+ "role_type": character.role_type,
+ "personality": character.personality,
+ "background": character.background,
+ "appearance": character.appearance,
+ "relationships": character.relationships,
+ "organization_type": character.organization_type,
+ "organization_purpose": character.organization_purpose,
+ "organization_members": character.organization_members,
+ "traits": character.traits,
+ "avatar_url": character.avatar_url,
+ "created_at": character.created_at,
+ "updated_at": character.updated_at,
+ "main_career_id": character.main_career_id,
+ "main_career_stage": character.main_career_stage,
+ "sub_careers": json.loads(character.sub_careers) if character.sub_careers else None,
+ "power_level": None,
+ "location": None,
+ "motto": None,
+ "color": None
+ }
+
+ # 如果是组织,添加组织额外字段
+ if character.is_organization:
+ org_result = await db.execute(
+ select(Organization).where(Organization.character_id == character_id)
+ )
+ org = org_result.scalar_one_or_none()
+ if org:
+ response_data.update({
+ "power_level": org.power_level,
+ "location": org.location,
+ "motto": org.motto,
+ "color": org.color
+ })
+
+ return response_data
@router.delete("/{character_id}", summary="删除角色")
@@ -330,7 +515,10 @@ async def create_character(
- 可以创建普通角色(is_organization=False)
- 也可以创建组织(is_organization=True)
- 如果创建组织且提供了组织额外字段,会自动创建Organization详情记录
+ - 支持设置主职业和副职业
"""
+ from app.models.career import CharacterCareer, Career
+
# 验证用户权限
user_id = getattr(request.state, 'user_id', None)
await verify_project_access(character_data.project_id, user_id, db)
@@ -352,13 +540,78 @@ async def create_character(
organization_purpose=character_data.organization_purpose,
organization_members=character_data.organization_members,
traits=character_data.traits,
- avatar_url=character_data.avatar_url
+ avatar_url=character_data.avatar_url,
+ main_career_id=character_data.main_career_id,
+ main_career_stage=character_data.main_career_stage,
+ sub_careers=character_data.sub_careers
)
db.add(character)
await db.flush() # 获取character.id
logger.info(f"✅ 手动创建角色成功:{character.name} (ID: {character.id}, 是否组织: {character.is_organization})")
+ # 处理主职业关联
+ if character_data.main_career_id and not character.is_organization:
+ # 验证职业存在
+ career_result = await db.execute(
+ select(Career).where(
+ Career.id == character_data.main_career_id,
+ Career.project_id == character_data.project_id,
+ Career.type == 'main'
+ )
+ )
+ career = career_result.scalar_one_or_none()
+
+ if career:
+ # 创建主职业关联
+ char_career = CharacterCareer(
+ character_id=character.id,
+ career_id=character_data.main_career_id,
+ career_type='main',
+ current_stage=character_data.main_career_stage or 1,
+ stage_progress=0
+ )
+ db.add(char_career)
+ logger.info(f"✅ 创建主职业关联:{character.name} -> {career.name}")
+ else:
+ logger.warning(f"⚠️ 主职业ID不存在或类型错误: {character_data.main_career_id}")
+
+ # 处理副职业关联
+ if character_data.sub_careers and not character.is_organization:
+ try:
+ sub_careers_data = json.loads(character_data.sub_careers) if isinstance(character_data.sub_careers, str) else character_data.sub_careers
+
+ for sub_data in sub_careers_data[:2]: # 最多2个副职业
+ career_id = sub_data.get('career_id')
+ if not career_id:
+ continue
+
+ # 验证副职业存在
+ career_result = await db.execute(
+ select(Career).where(
+ Career.id == career_id,
+ Career.project_id == character_data.project_id,
+ Career.type == 'sub'
+ )
+ )
+ career = career_result.scalar_one_or_none()
+
+ if career:
+ # 创建副职业关联
+ char_career = CharacterCareer(
+ character_id=character.id,
+ career_id=career_id,
+ career_type='sub',
+ current_stage=sub_data.get('stage', 1),
+ stage_progress=0
+ )
+ db.add(char_career)
+ logger.info(f"✅ 创建副职业关联:{character.name} -> {career.name}")
+ else:
+ logger.warning(f"⚠️ 副职业ID不存在或类型错误: {career_id}")
+ except Exception as e:
+ logger.warning(f"⚠️ 解析副职业数据失败: {e}")
+
# 如果是组织,且提供了组织额外字段,自动创建Organization详情记录
if character.is_organization and (
character_data.power_level is not None or
@@ -438,6 +691,50 @@ async def generate_character_stream(
if organization_list:
existing_chars_info += "\n\n已有组织:\n" + "\n".join(organization_list)
+ # 🎯 获取项目职业列表
+ from app.models.career import Career
+ careers_result = await db.execute(
+ select(Career)
+ .where(Career.project_id == request.project_id)
+ .order_by(Career.type, Career.name)
+ )
+ careers = careers_result.scalars().all()
+
+ # 构建职业信息摘要
+ careers_info = ""
+ if careers:
+ main_careers = [c for c in careers if c.type == 'main']
+ sub_careers = [c for c in careers if c.type == 'sub']
+
+ if main_careers:
+ careers_info += "\n\n可用主职业列表(请在career_info中填写职业名称,系统会自动匹配ID):\n"
+ for career in main_careers:
+ # 解析阶段信息
+ import json as json_lib
+ try:
+ stages = json_lib.loads(career.stages) if career.stages else []
+ stage_names = [s.get('name', f'阶段{s.get("level")}') for s in stages[:3]] # 只显示前3个阶段
+ stage_info = " → ".join(stage_names)
+ if len(stages) > 3:
+ stage_info += " → ..."
+ except:
+ stage_info = f"共{career.max_stage}个阶段"
+
+ careers_info += f"- 名称: {career.name}"
+ if career.description:
+ careers_info += f", 描述: {career.description[:50]}"
+ careers_info += f", 阶段: {stage_info}\n"
+
+ if sub_careers:
+ careers_info += "\n可用副职业列表(请在career_info中填写职业名称,系统会自动匹配ID):\n"
+ for career in sub_careers[:5]: # 最多显示5个副职业
+ careers_info += f"- 名称: {career.name}"
+ if career.description:
+ careers_info += f", 描述: {career.description[:50]}"
+ careers_info += "\n"
+ else:
+ careers_info = "\n\n⚠️ 项目中暂无职业设定"
+
# 构建项目上下文
project_context = f"""
项目信息:
@@ -449,6 +746,7 @@ async def generate_character_stream(
- 氛围基调:{project.world_atmosphere or '未设定'}
- 世界规则:{project.world_rules or '未设定'}
{existing_chars_info}
+{careers_info}
"""
user_input = f"""
@@ -544,6 +842,62 @@ async def generate_character_stream(
traits_json = json.dumps(character_data.get("traits", []), ensure_ascii=False) if character_data.get("traits") else None
is_organization = character_data.get("is_organization", False)
+ # 提取职业信息(支持通过名称匹配)
+ career_info = character_data.get("career_info", {})
+ raw_main_career_name = career_info.get("main_career_name") if career_info else None
+ main_career_stage = career_info.get("main_career_stage", 1) if career_info else None
+ raw_sub_careers_data = career_info.get("sub_careers", []) if career_info else []
+
+ # 调试日志:输出职业信息
+ logger.info(f"🔍 提取职业信息 - career_info: {career_info}")
+ logger.info(f"🔍 raw_main_career_name: {raw_main_career_name}, main_career_stage: {main_career_stage}")
+ logger.info(f"🔍 raw_sub_careers_data类型: {type(raw_sub_careers_data)}, 内容: {raw_sub_careers_data}")
+
+ # 🔧 通过职业名称匹配数据库中的职业ID
+ from app.models.career import Career
+ main_career_id = None
+ sub_careers_data = []
+
+ # 匹配主职业名称
+ if raw_main_career_name and not is_organization:
+ career_check = await db.execute(
+ select(Career).where(
+ Career.name == raw_main_career_name,
+ Career.project_id == request.project_id,
+ Career.type == 'main'
+ )
+ )
+ matched_career = career_check.scalar_one_or_none()
+ if matched_career:
+ main_career_id = matched_career.id
+ logger.info(f"✅ 主职业名称匹配成功: {raw_main_career_name} -> ID: {main_career_id}")
+ else:
+ logger.warning(f"⚠️ AI返回的主职业名称未找到: {raw_main_career_name}")
+
+ # 匹配副职业名称
+ if raw_sub_careers_data and not is_organization and isinstance(raw_sub_careers_data, list):
+ for sub_data in raw_sub_careers_data[:2]:
+ if isinstance(sub_data, dict):
+ career_name = sub_data.get('career_name')
+ if career_name:
+ career_check = await db.execute(
+ select(Career).where(
+ Career.name == career_name,
+ Career.project_id == request.project_id,
+ Career.type == 'sub'
+ )
+ )
+ matched_career = career_check.scalar_one_or_none()
+ if matched_career:
+ # 转换为包含ID的格式
+ sub_careers_data.append({
+ 'career_id': matched_career.id,
+ 'stage': sub_data.get('stage', 1)
+ })
+ logger.info(f"✅ 副职业名称匹配成功: {career_name} -> ID: {matched_career.id}")
+ else:
+ logger.warning(f"⚠️ AI返回的副职业名称未找到: {career_name}")
+
# 创建角色
character = Character(
project_id=request.project_id,
@@ -559,13 +913,92 @@ async def generate_character_stream(
organization_type=character_data.get("organization_type") if is_organization else None,
organization_purpose=character_data.get("organization_purpose") if is_organization else None,
organization_members=json.dumps(character_data.get("organization_members", []), ensure_ascii=False) if is_organization else None,
- traits=traits_json
+ traits=traits_json,
+ main_career_id=main_career_id,
+ main_career_stage=main_career_stage if main_career_id else None,
+ sub_careers=json.dumps(sub_careers_data, ensure_ascii=False) if sub_careers_data else None
)
db.add(character)
await db.flush()
logger.info(f"✅ 角色创建成功:{character.name} (ID: {character.id})")
+ # 处理主职业关联
+ if main_career_id and not is_organization:
+ from app.models.career import CharacterCareer, Career
+
+ career_result = await db.execute(
+ select(Career).where(
+ Career.id == main_career_id,
+ Career.project_id == request.project_id,
+ Career.type == 'main'
+ )
+ )
+ career = career_result.scalar_one_or_none()
+
+ if career:
+ char_career = CharacterCareer(
+ character_id=character.id,
+ career_id=main_career_id,
+ career_type='main',
+ current_stage=main_career_stage,
+ stage_progress=0
+ )
+ db.add(char_career)
+ logger.info(f"✅ AI生成角色-创建主职业关联:{character.name} -> {career.name}")
+ else:
+ logger.warning(f"⚠️ AI返回的主职业ID不存在: {main_career_id}")
+
+ # 处理副职业关联
+ if sub_careers_data and not is_organization:
+ from app.models.career import CharacterCareer, Career
+
+ logger.info(f"🔍 开始处理副职业关联,数据: {sub_careers_data}")
+
+ # 确保sub_careers_data是列表
+ if not isinstance(sub_careers_data, list):
+ logger.warning(f"⚠️ sub_careers_data不是列表类型: {type(sub_careers_data)}")
+ sub_careers_data = []
+
+ for idx, sub_data in enumerate(sub_careers_data[:2]): # 最多2个副职业
+ logger.info(f"🔍 处理第{idx+1}个副职业,数据: {sub_data}, 类型: {type(sub_data)}")
+
+ # 兼容不同的数据格式
+ if isinstance(sub_data, dict):
+ career_id = sub_data.get('career_id')
+ stage = sub_data.get('stage', 1)
+ else:
+ logger.warning(f"⚠️ 副职业数据格式错误,应为dict: {sub_data}")
+ continue
+
+ if not career_id:
+ logger.warning(f"⚠️ 副职业数据缺少career_id字段")
+ continue
+
+ logger.info(f"🔍 查询副职业: career_id={career_id}, project_id={request.project_id}")
+
+ career_result = await db.execute(
+ select(Career).where(
+ Career.id == career_id,
+ Career.project_id == request.project_id,
+ Career.type == 'sub'
+ )
+ )
+ career = career_result.scalar_one_or_none()
+
+ if career:
+ char_career = CharacterCareer(
+ character_id=character.id,
+ career_id=career_id,
+ career_type='sub',
+ current_stage=stage,
+ stage_progress=0
+ )
+ db.add(char_career)
+ logger.info(f"✅ AI生成角色-创建副职业关联:{character.name} -> {career.name} (阶段{stage})")
+ else:
+ logger.warning(f"⚠️ AI返回的副职业ID不存在: {career_id} (项目ID: {request.project_id})")
+
# 如果是组织,创建Organization详情
if is_organization:
yield await SSEResponse.send_progress("创建组织详情...", 85)
diff --git a/backend/app/api/wizard_stream.py b/backend/app/api/wizard_stream.py
index 7fec2d6..999fdea 100644
--- a/backend/app/api/wizard_stream.py
+++ b/backend/app/api/wizard_stream.py
@@ -11,6 +11,7 @@ from app.models.project import Project
from app.models.character import Character
from app.models.outline import Outline
from app.models.chapter import Chapter
+from app.models.career import Career, CharacterCareer
from app.models.relationship import CharacterRelationship, Organization, OrganizationMember, RelationshipType
from app.models.writing_style import WritingStyle
from app.models.project_default_style import ProjectDefaultStyle
@@ -239,6 +240,121 @@ async def world_building_generator(
project.wizard_step = 1
await db.commit()
+ # ===== 自动生成职业体系 =====
+ yield await SSEResponse.send_progress("🎯 开始生成职业体系框架...", 75)
+ logger.info(f"🎯 世界观已完成,开始为项目 {project.id} 自动生成职业体系")
+
+ try:
+ # 获取职业生成提示词模板(支持用户自定义)
+ template = await PromptService.get_template("CAREER_SYSTEM_GENERATION", user_id, db)
+ career_prompt = PromptService.format_prompt(
+ template,
+ title=project.title,
+ genre=genre or '未设定',
+ theme=theme or '未设定',
+ time_period=world_data.get('time_period', '未设定'),
+ location=world_data.get('location', '未设定'),
+ atmosphere=world_data.get('atmosphere', '未设定'),
+ rules=world_data.get('rules', '未设定')
+ )
+
+ yield await SSEResponse.send_progress("正在生成职业体系...", 78)
+
+ # 调用AI生成职业
+ result = await user_ai_service.generate_text(prompt=career_prompt)
+ career_response = result.get('content', '') if isinstance(result, dict) else result
+
+ if not career_response or not career_response.strip():
+ logger.warning("⚠️ AI返回空职业体系,跳过职业生成")
+ yield await SSEResponse.send_progress("职业体系生成跳过(AI返回为空)", 85)
+ else:
+ yield await SSEResponse.send_progress("解析职业体系数据...", 82)
+
+ # 清洗并解析JSON
+ try:
+ cleaned_response = user_ai_service._clean_json_response(career_response)
+ career_data = json.loads(cleaned_response)
+ logger.info(f"✅ 职业体系JSON解析成功")
+
+ # 保存主职业
+ main_careers_created = []
+ for idx, career_info in enumerate(career_data.get("main_careers", [])):
+ try:
+ stages_json = json.dumps(career_info.get("stages", []), ensure_ascii=False)
+ attribute_bonuses = career_info.get("attribute_bonuses")
+ attribute_bonuses_json = json.dumps(attribute_bonuses, ensure_ascii=False) if attribute_bonuses else None
+
+ career = Career(
+ project_id=project.id,
+ name=career_info.get("name", f"未命名主职业{idx+1}"),
+ type="main",
+ description=career_info.get("description"),
+ category=career_info.get("category"),
+ stages=stages_json,
+ max_stage=career_info.get("max_stage", 10),
+ requirements=career_info.get("requirements"),
+ special_abilities=career_info.get("special_abilities"),
+ worldview_rules=career_info.get("worldview_rules"),
+ attribute_bonuses=attribute_bonuses_json,
+ source="ai"
+ )
+ db.add(career)
+ await db.flush()
+ main_careers_created.append(career.name)
+ logger.info(f" ✅ 创建主职业:{career.name}")
+ except Exception as e:
+ logger.error(f" ❌ 创建主职业失败:{str(e)}")
+ continue
+
+ # 保存副职业
+ sub_careers_created = []
+ for idx, career_info in enumerate(career_data.get("sub_careers", [])):
+ try:
+ stages_json = json.dumps(career_info.get("stages", []), ensure_ascii=False)
+ attribute_bonuses = career_info.get("attribute_bonuses")
+ attribute_bonuses_json = json.dumps(attribute_bonuses, ensure_ascii=False) if attribute_bonuses else None
+
+ career = Career(
+ project_id=project.id,
+ name=career_info.get("name", f"未命名副职业{idx+1}"),
+ type="sub",
+ description=career_info.get("description"),
+ category=career_info.get("category"),
+ stages=stages_json,
+ max_stage=career_info.get("max_stage", 5),
+ requirements=career_info.get("requirements"),
+ special_abilities=career_info.get("special_abilities"),
+ worldview_rules=career_info.get("worldview_rules"),
+ attribute_bonuses=attribute_bonuses_json,
+ source="ai"
+ )
+ db.add(career)
+ await db.flush()
+ sub_careers_created.append(career.name)
+ logger.info(f" ✅ 创建副职业:{career.name}")
+ except Exception as e:
+ logger.error(f" ❌ 创建副职业失败:{str(e)}")
+ continue
+
+ await db.commit()
+
+ logger.info(f"🎉 职业体系生成完成:主职业{len(main_careers_created)}个,副职业{len(sub_careers_created)}个")
+ yield await SSEResponse.send_progress(
+ f"✅ 职业体系生成完成(主{len(main_careers_created)}+副{len(sub_careers_created)})",
+ 90
+ )
+
+ except json.JSONDecodeError as e:
+ logger.error(f"❌ 职业体系JSON解析失败: {e}")
+ yield await SSEResponse.send_progress("⚠️ 职业体系解析失败,已跳过", 85)
+ except Exception as e:
+ logger.error(f"❌ 职业体系保存失败: {e}")
+ yield await SSEResponse.send_progress("⚠️ 职业体系保存失败,已跳过", 85)
+
+ except Exception as e:
+ logger.error(f"❌ 职业体系生成异常: {e}")
+ yield await SSEResponse.send_progress("⚠️ 职业体系生成失败,已跳过(不影响项目创建)", 85)
+
db_committed = True
# 发送最终结果
@@ -381,6 +497,40 @@ async def characters_generator(
logger.warning(f"MCP工具调用失败(降级处理): {e}")
yield await SSEResponse.send_progress("⚠️ MCP工具暂时不可用,使用基础模式", 12)
+ # 获取项目的职业列表,用于角色职业分配
+ yield await SSEResponse.send_progress("加载职业体系...", 13)
+ career_result = await db.execute(
+ select(Career).where(Career.project_id == project_id).order_by(Career.type, Career.id)
+ )
+ careers = career_result.scalars().all()
+
+ main_careers = [c for c in careers if c.type == "main"]
+ sub_careers = [c for c in careers if c.type == "sub"]
+
+ # 构建职业上下文
+ careers_context = ""
+ if main_careers or sub_careers:
+ careers_context = "\n\n【职业体系】\n"
+ if main_careers:
+ careers_context += "主职业:\n"
+ for career in main_careers:
+ careers_context += f"- {career.name}: {career.description or '暂无描述'}\n"
+ if sub_careers:
+ careers_context += "\n副职业:\n"
+ for career in sub_careers:
+ careers_context += f"- {career.name}: {career.description or '暂无描述'}\n"
+
+ careers_context += "\n请为每个角色分配职业:\n"
+ careers_context += "- 每个角色必须有1个主职业(从上述主职业中选择)\n"
+ careers_context += "- 每个角色可以有0-2个副职业(从上述副职业中选择,可选)\n"
+ careers_context += "- 主职业初始阶段建议为1-3\n"
+ careers_context += "- 副职业初始阶段建议为1-2\n"
+ careers_context += "- 请在返回的JSON中包含 career_assignment 字段:\n"
+ careers_context += ' {"main_career": "职业名称", "main_stage": 2, "sub_careers": [{"career": "副职业名称", "stage": 1}]}\n'
+ logger.info(f"✅ 加载了{len(main_careers)}个主职业和{len(sub_careers)}个副职业")
+ else:
+ logger.warning("⚠️ 项目没有职业体系,跳过职业分配")
+
# 优化的分批策略:每批生成3个,平衡效率和成功率
BATCH_SIZE = 3 # 每批生成3个角色
MAX_RETRIES = 3 # 每批最多重试3次
@@ -445,7 +595,7 @@ async def characters_generator(
rules=world_context.get("rules", ""),
theme=theme or project.theme or "",
genre=genre or project.genre or "",
- requirements=batch_requirements
+ requirements=batch_requirements + careers_context # 添加职业上下文
)
# 如果有MCP参考资料,增强提示词
@@ -626,14 +776,102 @@ async def characters_generator(
await db.flush() # 获取所有角色的ID
+ # 第二阶段:为角色分配职业并创建CharacterCareer关联
+ if main_careers or sub_careers:
+ yield await SSEResponse.send_progress("分配角色职业...", 86)
+ careers_assigned = 0
+
+ # 构建职业名称到对象的映射
+ career_name_to_obj = {c.name: c for c in careers}
+
+ for character, char_data in created_characters:
+ # 跳过组织
+ if character.is_organization:
+ continue
+
+ try:
+ career_assignment = char_data.get("career_assignment", {})
+
+ # 分配主职业
+ main_career_name = career_assignment.get("main_career")
+ main_career_stage = career_assignment.get("main_stage", 1)
+
+ if main_career_name and main_career_name in career_name_to_obj:
+ main_career = career_name_to_obj[main_career_name]
+
+ # 创建CharacterCareer关联
+ char_career = CharacterCareer(
+ character_id=character.id,
+ career_id=main_career.id,
+ career_type="main",
+ current_stage=min(main_career_stage, main_career.max_stage),
+ stage_progress=0
+ )
+ db.add(char_career)
+
+ # 更新Character冗余字段
+ character.main_career_id = main_career.id
+ character.main_career_stage = char_career.current_stage
+
+ careers_assigned += 1
+ logger.info(f" ✅ 分配主职业:{character.name} -> {main_career.name} (阶段{char_career.current_stage})")
+ else:
+ if main_career_name:
+ logger.warning(f" ⚠️ 主职业不存在:{character.name} -> {main_career_name}")
+
+ # 分配副职业
+ sub_career_assignments = career_assignment.get("sub_careers", [])
+ sub_career_list = []
+
+ for sub_assign in sub_career_assignments[:2]: # 最多2个副职业
+ sub_career_name = sub_assign.get("career")
+ sub_career_stage = sub_assign.get("stage", 1)
+
+ if sub_career_name and sub_career_name in career_name_to_obj:
+ sub_career = career_name_to_obj[sub_career_name]
+
+ # 创建CharacterCareer关联
+ char_career = CharacterCareer(
+ character_id=character.id,
+ career_id=sub_career.id,
+ career_type="sub",
+ current_stage=min(sub_career_stage, sub_career.max_stage),
+ stage_progress=0
+ )
+ db.add(char_career)
+
+ # 添加到副职业列表
+ sub_career_list.append({
+ "career_id": sub_career.id,
+ "stage": char_career.current_stage
+ })
+
+ careers_assigned += 1
+ logger.info(f" ✅ 分配副职业:{character.name} -> {sub_career.name} (阶段{char_career.current_stage})")
+ else:
+ if sub_career_name:
+ logger.warning(f" ⚠️ 副职业不存在:{character.name} -> {sub_career_name}")
+
+ # 更新Character冗余字段
+ if sub_career_list:
+ character.sub_careers = json.dumps(sub_career_list, ensure_ascii=False)
+
+ except Exception as e:
+ logger.warning(f" ❌ 分配职业失败:{character.name} - {str(e)}")
+ continue
+
+ await db.flush()
+ logger.info(f"💼 职业分配完成:共分配{careers_assigned}个职业")
+ yield await SSEResponse.send_progress(f"已分配{careers_assigned}个职业", 87)
+
# 刷新并建立名称映射
for character, _ in created_characters:
await db.refresh(character)
character_name_to_obj[character.name] = character
logger.info(f"向导创建角色:{character.name} (ID: {character.id}, 是否组织: {character.is_organization})")
- # 为is_organization=True的角色创建Organization记录
- yield await SSEResponse.send_progress("创建组织记录...", 87)
+ # 第三阶段:为is_organization=True的角色创建Organization记录
+ yield await SSEResponse.send_progress("创建组织记录...", 88)
organization_name_to_obj = {} # 组织名称到Organization对象的映射
for character, char_data in created_characters:
@@ -669,8 +907,8 @@ async def characters_generator(
for character, _ in created_characters:
await db.refresh(character)
- # 第三阶段:创建角色间的关系
- yield await SSEResponse.send_progress("创建角色关系...", 90)
+ # 第四阶段:创建角色间的关系
+ yield await SSEResponse.send_progress("创建角色关系...", 91)
relationships_created = 0
for character, char_data in created_characters:
@@ -737,8 +975,8 @@ async def characters_generator(
logger.warning(f" ❌ 向导创建关系失败:{character.name} - {str(e)}")
continue
- # 第四阶段:创建组织成员关系
- yield await SSEResponse.send_progress("创建组织成员关系...", 93)
+ # 第五阶段:创建组织成员关系
+ yield await SSEResponse.send_progress("创建组织成员关系...", 94)
members_created = 0
for character, char_data in created_characters:
diff --git a/backend/app/main.py b/backend/app/main.py
index 069195e..3f4d8b2 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -143,7 +143,7 @@ from app.api import (
wizard_stream, relationships, organizations,
auth, users, settings, writing_styles, memories,
mcp_plugins, admin, inspiration, prompt_templates,
- changelog
+ changelog, careers
)
app.include_router(auth.router, prefix="/api")
@@ -156,6 +156,7 @@ app.include_router(wizard_stream.router, prefix="/api")
app.include_router(inspiration.router, prefix="/api")
app.include_router(outlines.router, prefix="/api")
app.include_router(characters.router, prefix="/api")
+app.include_router(careers.router, prefix="/api") # 职业管理API
app.include_router(chapters.router, prefix="/api")
app.include_router(relationships.router, prefix="/api")
app.include_router(organizations.router, prefix="/api")
diff --git a/backend/app/models/career.py b/backend/app/models/career.py
new file mode 100644
index 0000000..0720d8b
--- /dev/null
+++ b/backend/app/models/career.py
@@ -0,0 +1,77 @@
+"""职业数据模型"""
+from sqlalchemy import Column, String, Text, DateTime, Integer, ForeignKey, Index
+from sqlalchemy.sql import func
+from app.database import Base
+import uuid
+
+
+class Career(Base):
+ """职业表"""
+ __tablename__ = "careers"
+
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
+ project_id = Column(String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
+
+ # 基本信息
+ name = Column(String(100), nullable=False, comment="职业名称")
+ type = Column(String(20), nullable=False, comment="职业类型: main(主职业)/sub(副职业)")
+ description = Column(Text, comment="职业描述")
+ category = Column(String(50), comment="职业分类(如:战斗系、生产系、辅助系)")
+
+ # 阶段设定
+ stages = Column(Text, nullable=False, comment="职业阶段列表(JSON): [{level:1, name:'', description:''}, ...]")
+ max_stage = Column(Integer, nullable=False, default=10, comment="最大阶段数")
+
+ # 职业特性
+ requirements = Column(Text, comment="职业要求/限制")
+ special_abilities = Column(Text, comment="特殊能力描述")
+ worldview_rules = Column(Text, comment="世界观规则关联")
+
+ # 职业属性加成(可选,JSON格式)
+ attribute_bonuses = Column(Text, comment="属性加成(JSON): {strength: '+10%', intelligence: '+5%'}")
+
+ # 元数据
+ source = Column(String(20), default='ai', comment="来源: ai/manual")
+ created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
+
+ __table_args__ = (
+ Index('idx_project_id', 'project_id'),
+ Index('idx_type', 'type'),
+ )
+
+ def __repr__(self):
+ return f"
"
+
+
+class CharacterCareer(Base):
+ """角色职业关联表"""
+ __tablename__ = "character_careers"
+
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
+ character_id = Column(String(36), ForeignKey("characters.id", ondelete="CASCADE"), nullable=False)
+ career_id = Column(String(36), ForeignKey("careers.id", ondelete="CASCADE"), nullable=False)
+ career_type = Column(String(20), nullable=False, comment="main(主职业)/sub(副职业)")
+
+ # 阶段进度
+ current_stage = Column(Integer, nullable=False, default=1, comment="当前阶段(对应职业中的数值)")
+ stage_progress = Column(Integer, default=0, comment="阶段内进度(0-100)")
+
+ # 时间记录
+ started_at = Column(String(100), comment="开始修炼时间(小说时间线)")
+ reached_current_stage_at = Column(String(100), comment="到达当前阶段时间")
+
+ # 备注
+ notes = Column(Text, comment="备注(如:修炼心得、特殊事件)")
+
+ created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
+
+ __table_args__ = (
+ Index('idx_character_id', 'character_id'),
+ Index('idx_career_type', 'career_type'),
+ Index('idx_character_career', 'character_id', 'career_id', unique=True),
+ )
+
+ def __repr__(self):
+ return f""
\ No newline at end of file
diff --git a/backend/app/models/character.py b/backend/app/models/character.py
index d6c0115..f6dc3c9 100644
--- a/backend/app/models/character.py
+++ b/backend/app/models/character.py
@@ -1,5 +1,5 @@
"""角色数据模型"""
-from sqlalchemy import Column, String, Text, DateTime, ForeignKey, Boolean
+from sqlalchemy import Column, String, Text, DateTime, ForeignKey, Boolean, Integer
from sqlalchemy.sql import func
from app.database import Base
import uuid
@@ -32,6 +32,11 @@ class Character(Base):
organization_purpose = Column(String(500), comment="组织目的")
organization_members = Column(Text, comment="组织成员(JSON)")
+ # 职业相关字段(冗余字段,用于提升查询性能)
+ main_career_id = Column(String(36), ForeignKey("careers.id", ondelete="SET NULL"), comment="主职业ID")
+ main_career_stage = Column(Integer, comment="主职业当前阶段")
+ sub_careers = Column(Text, comment="副职业列表(JSON): [{\"career_id\": \"xxx\", \"stage\": 3}, ...]")
+
# 其他
avatar_url = Column(String(500), comment="头像URL")
traits = Column(Text, comment="特征标签(JSON)")
diff --git a/backend/app/schemas/career.py b/backend/app/schemas/career.py
new file mode 100644
index 0000000..3603c6c
--- /dev/null
+++ b/backend/app/schemas/career.py
@@ -0,0 +1,155 @@
+"""职业相关的Pydantic模型"""
+from pydantic import BaseModel, Field
+from typing import Optional, List, Dict, Any
+from datetime import datetime
+
+
+class CareerStage(BaseModel):
+ """职业阶段模型"""
+ level: int = Field(..., description="阶段等级")
+ name: str = Field(..., description="阶段名称")
+ description: Optional[str] = Field(None, description="阶段描述")
+
+
+class CareerBase(BaseModel):
+ """职业基础模型"""
+ name: str = Field(..., description="职业名称")
+ type: str = Field(..., description="职业类型: main(主职业)/sub(副职业)")
+ description: Optional[str] = Field(None, description="职业描述")
+ category: Optional[str] = Field(None, description="职业分类")
+ stages: List[CareerStage] = Field(..., description="职业阶段列表")
+ max_stage: int = Field(10, description="最大阶段数")
+ requirements: Optional[str] = Field(None, description="职业要求/限制")
+ special_abilities: Optional[str] = Field(None, description="特殊能力描述")
+ worldview_rules: Optional[str] = Field(None, description="世界观规则关联")
+ attribute_bonuses: Optional[Dict[str, str]] = Field(None, description="属性加成")
+
+
+class CareerCreate(CareerBase):
+ """创建职业的请求模型"""
+ project_id: str = Field(..., description="项目ID")
+ source: str = Field("manual", description="来源: ai/manual")
+
+
+class CareerUpdate(BaseModel):
+ """更新职业的请求模型"""
+ name: Optional[str] = None
+ type: Optional[str] = None
+ description: Optional[str] = None
+ category: Optional[str] = None
+ stages: Optional[List[CareerStage]] = None
+ max_stage: Optional[int] = None
+ requirements: Optional[str] = None
+ special_abilities: Optional[str] = None
+ worldview_rules: Optional[str] = None
+ attribute_bonuses: Optional[Dict[str, str]] = None
+
+
+class CareerResponse(BaseModel):
+ """职业响应模型"""
+ id: str
+ project_id: str
+ name: str
+ type: str
+ description: Optional[str] = None
+ category: Optional[str] = None
+ stages: List[CareerStage]
+ max_stage: int
+ requirements: Optional[str] = None
+ special_abilities: Optional[str] = None
+ worldview_rules: Optional[str] = None
+ attribute_bonuses: Optional[Dict[str, str]] = None
+ source: str
+ created_at: datetime
+ updated_at: datetime
+
+ class Config:
+ from_attributes = True
+
+
+class CareerListResponse(BaseModel):
+ """职业列表响应模型"""
+ total: int
+ main_careers: List[CareerResponse] = Field(default_factory=list, description="主职业列表")
+ sub_careers: List[CareerResponse] = Field(default_factory=list, description="副职业列表")
+
+
+class CareerGenerateRequest(BaseModel):
+ """AI生成职业体系的请求模型"""
+ project_id: str = Field(..., description="项目ID")
+ main_career_count: int = Field(5, description="主职业数量", ge=1, le=20)
+ sub_career_count: int = Field(8, description="副职业数量", ge=0, le=30)
+ enable_mcp: bool = Field(False, description="是否启用MCP工具增强")
+
+
+# ===== 角色职业关联相关 =====
+
+class CharacterCareerBase(BaseModel):
+ """角色职业关联基础模型"""
+ career_id: str = Field(..., description="职业ID")
+ career_type: str = Field(..., description="main(主职业)/sub(副职业)")
+ current_stage: int = Field(1, description="当前阶段", ge=1)
+ stage_progress: int = Field(0, description="阶段内进度(0-100)", ge=0, le=100)
+ started_at: Optional[str] = Field(None, description="开始修炼时间")
+ reached_current_stage_at: Optional[str] = Field(None, description="到达当前阶段时间")
+ notes: Optional[str] = Field(None, description="备注")
+
+
+class CharacterCareerCreate(CharacterCareerBase):
+ """创建角色职业关联的请求模型"""
+ character_id: str = Field(..., description="角色ID")
+
+
+class CharacterCareerUpdate(BaseModel):
+ """更新角色职业关联的请求模型"""
+ current_stage: Optional[int] = Field(None, ge=1)
+ stage_progress: Optional[int] = Field(None, ge=0, le=100)
+ reached_current_stage_at: Optional[str] = None
+ notes: Optional[str] = None
+
+
+class CharacterCareerDetail(BaseModel):
+ """角色职业详情模型(包含职业信息)"""
+ id: str
+ character_id: str
+ career_id: str
+ career_name: str = Field(..., description="职业名称")
+ career_type: str
+ current_stage: int
+ stage_name: str = Field(..., description="当前阶段名称")
+ stage_description: Optional[str] = Field(None, description="当前阶段描述")
+ stage_progress: int
+ max_stage: int = Field(..., description="该职业的最大阶段")
+ started_at: Optional[str] = None
+ reached_current_stage_at: Optional[str] = None
+ notes: Optional[str] = None
+ created_at: datetime
+ updated_at: datetime
+
+
+class CharacterCareerResponse(BaseModel):
+ """角色职业响应模型"""
+ main_career: Optional[CharacterCareerDetail] = Field(None, description="主职业")
+ sub_careers: List[CharacterCareerDetail] = Field(default_factory=list, description="副职业列表")
+
+
+class SetMainCareerRequest(BaseModel):
+ """设置主职业请求模型"""
+ career_id: str = Field(..., description="职业ID")
+ current_stage: int = Field(1, description="当前阶段", ge=1)
+ started_at: Optional[str] = Field(None, description="开始修炼时间")
+
+
+class AddSubCareerRequest(BaseModel):
+ """添加副职业请求模型"""
+ career_id: str = Field(..., description="职业ID")
+ current_stage: int = Field(1, description="当前阶段", ge=1)
+ started_at: Optional[str] = Field(None, description="开始修炼时间")
+
+
+class UpdateCareerStageRequest(BaseModel):
+ """更新职业阶段请求模型"""
+ current_stage: int = Field(..., description="新的阶段", ge=1)
+ stage_progress: int = Field(0, description="阶段进度", ge=0, le=100)
+ reached_current_stage_at: Optional[str] = Field(None, description="到达时间")
+ notes: Optional[str] = Field(None, description="备注")
\ No newline at end of file
diff --git a/backend/app/schemas/character.py b/backend/app/schemas/character.py
index 0ee4f8e..cb5df43 100644
--- a/backend/app/schemas/character.py
+++ b/backend/app/schemas/character.py
@@ -44,6 +44,11 @@ class CharacterCreate(BaseModel):
location: Optional[str] = Field(None, description="组织所在地")
motto: Optional[str] = Field(None, description="组织格言/口号")
color: Optional[str] = Field(None, description="组织代表颜色")
+
+ # 职业字段
+ main_career_id: Optional[str] = Field(None, description="主职业ID")
+ main_career_stage: Optional[int] = Field(None, description="主职业阶段")
+ sub_careers: Optional[str] = Field(None, description="副职业列表JSON字符串")
class CharacterUpdate(BaseModel):
@@ -67,6 +72,11 @@ class CharacterUpdate(BaseModel):
location: Optional[str] = Field(None, description="组织所在地")
motto: Optional[str] = Field(None, description="组织格言/口号")
color: Optional[str] = Field(None, description="组织代表颜色")
+
+ # 职业字段(会同步到CharacterCareer表)
+ main_career_id: Optional[str] = Field(None, description="主职业ID")
+ main_career_stage: Optional[int] = Field(None, description="主职业阶段")
+ sub_careers: Optional[str] = Field(None, description="副职业列表JSON字符串")
class CharacterResponse(CharacterBase):
@@ -83,6 +93,11 @@ class CharacterResponse(CharacterBase):
motto: Optional[str] = Field(None, description="组织格言/口号")
color: Optional[str] = Field(None, description="组织代表颜色")
+ # 职业信息字段
+ main_career_id: Optional[str] = Field(None, description="主职业ID")
+ main_career_stage: Optional[int] = Field(None, description="主职业阶段")
+ sub_careers: Optional[List[Dict[str, Any]]] = Field(None, description="副职业列表")
+
class Config:
from_attributes = True
diff --git a/backend/app/services/auto_character_service.py b/backend/app/services/auto_character_service.py
index 714abd0..6511aa8 100644
--- a/backend/app/services/auto_character_service.py
+++ b/backend/app/services/auto_character_service.py
@@ -297,6 +297,39 @@ class AutoCharacterService:
) -> Dict[str, Any]:
"""生成角色详细信息"""
+ # 🎯 获取项目职业列表
+ from app.models.career import Career
+ careers_result = await db.execute(
+ select(Career)
+ .where(Career.project_id == project.id)
+ .order_by(Career.type, Career.name)
+ )
+ careers = careers_result.scalars().all()
+
+ # 构建职业信息摘要(包含最高阶段信息)
+ careers_info = ""
+ if careers:
+ main_careers = [c for c in careers if c.type == 'main']
+ sub_careers = [c for c in careers if c.type == 'sub']
+
+ if main_careers:
+ careers_info += "\n\n可用主职业列表(请在career_info中填写职业名称和阶段):\n"
+ for career in main_careers:
+ careers_info += f"- 名称: {career.name}, 最高阶段: {career.max_stage}阶"
+ if career.description:
+ careers_info += f", 描述: {career.description[:50]}"
+ careers_info += "\n"
+
+ if sub_careers:
+ careers_info += "\n可用副职业列表(请在career_info中填写职业名称和阶段):\n"
+ for career in sub_careers[:5]:
+ careers_info += f"- 名称: {career.name}, 最高阶段: {career.max_stage}阶"
+ if career.description:
+ careers_info += f", 描述: {career.description[:50]}"
+ careers_info += "\n"
+
+ careers_info += "\n⚠️ 重要提示:生成角色时,职业阶段不能超过该职业的最高阶段!\n"
+
# 构建角色生成提示词
template = await PromptService.get_template(
"AUTO_CHARACTER_GENERATION",
@@ -315,7 +348,7 @@ class AutoCharacterService:
location=project.world_location or "未设定",
atmosphere=project.world_atmosphere or "未设定",
rules=project.world_rules or "未设定",
- existing_characters=existing_chars_summary,
+ existing_characters=existing_chars_summary + careers_info,
plot_context="根据剧情需要引入的新角色",
character_specification=json.dumps(spec, ensure_ascii=False, indent=2),
mcp_references="" # 暂时不使用MCP增强
@@ -367,6 +400,66 @@ class AutoCharacterService:
is_organization = character_data.get("is_organization", False)
+ # 提取职业信息(支持通过名称匹配)
+ career_info = character_data.get("career_info", {})
+ raw_main_career_name = career_info.get("main_career_name") if career_info else None
+ main_career_stage = career_info.get("main_career_stage", 1) if career_info else None
+ raw_sub_careers_data = career_info.get("sub_careers", []) if career_info else []
+
+ # 🔧 通过职业名称匹配数据库中的职业ID
+ from app.models.career import Career, CharacterCareer
+ main_career_id = None
+ sub_careers_data = []
+
+ # 匹配主职业名称
+ if raw_main_career_name and not is_organization:
+ career_check = await db.execute(
+ select(Career).where(
+ Career.name == raw_main_career_name,
+ Career.project_id == project_id,
+ Career.type == 'main'
+ )
+ )
+ matched_career = career_check.scalar_one_or_none()
+ if matched_career:
+ main_career_id = matched_career.id
+ # ✅ 验证阶段不超过最高阶段
+ if main_career_stage and main_career_stage > matched_career.max_stage:
+ logger.warning(f" ⚠️ AI返回的主职业阶段({main_career_stage})超过最高阶段({matched_career.max_stage}),自动修正为最高阶段")
+ main_career_stage = matched_career.max_stage
+ logger.info(f" ✅ 主职业名称匹配成功: {raw_main_career_name} -> ID: {main_career_id}, 阶段: {main_career_stage}/{matched_career.max_stage}")
+ else:
+ logger.warning(f" ⚠️ AI返回的主职业名称未找到: {raw_main_career_name}")
+
+ # 匹配副职业名称
+ if raw_sub_careers_data and not is_organization and isinstance(raw_sub_careers_data, list):
+ for sub_data in raw_sub_careers_data[:2]:
+ if isinstance(sub_data, dict):
+ career_name = sub_data.get('career_name')
+ if career_name:
+ career_check = await db.execute(
+ select(Career).where(
+ Career.name == career_name,
+ Career.project_id == project_id,
+ Career.type == 'sub'
+ )
+ )
+ matched_career = career_check.scalar_one_or_none()
+ if matched_career:
+ sub_stage = sub_data.get('stage', 1)
+ # ✅ 验证阶段不超过最高阶段
+ if sub_stage > matched_career.max_stage:
+ logger.warning(f" ⚠️ AI返回的副职业阶段({sub_stage})超过最高阶段({matched_career.max_stage}),自动修正为最高阶段")
+ sub_stage = matched_career.max_stage
+
+ sub_careers_data.append({
+ 'career_id': matched_career.id,
+ 'stage': sub_stage
+ })
+ logger.info(f" ✅ 副职业名称匹配成功: {career_name} -> ID: {matched_career.id}, 阶段: {sub_stage}/{matched_career.max_stage}")
+ else:
+ logger.warning(f" ⚠️ AI返回的副职业名称未找到: {career_name}")
+
# 创建角色
character = Character(
project_id=project_id,
@@ -381,12 +474,40 @@ class AutoCharacterService:
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
+ traits=json.dumps(character_data.get("traits", []), ensure_ascii=False) if character_data.get("traits") else None,
+ main_career_id=main_career_id,
+ main_career_stage=main_career_stage if main_career_id else None,
+ sub_careers=json.dumps(sub_careers_data, ensure_ascii=False) if sub_careers_data else None
)
db.add(character)
await db.flush()
+ # 处理主职业关联
+ if main_career_id and not is_organization:
+ char_career = CharacterCareer(
+ character_id=character.id,
+ career_id=main_career_id,
+ career_type='main',
+ current_stage=main_career_stage,
+ stage_progress=0
+ )
+ db.add(char_career)
+ logger.info(f" ✅ 创建主职业关联: {character.name} -> {raw_main_career_name}")
+
+ # 处理副职业关联
+ if sub_careers_data and not is_organization:
+ for sub_data in sub_careers_data:
+ char_career = CharacterCareer(
+ character_id=character.id,
+ career_id=sub_data['career_id'],
+ career_type='sub',
+ current_stage=sub_data['stage'],
+ stage_progress=0
+ )
+ db.add(char_career)
+ logger.info(f" ✅ 创建副职业关联: {character.name}, 数量: {len(sub_careers_data)}")
+
# 如果是组织,创建Organization记录
if is_organization:
org = Organization(
diff --git a/backend/app/services/career_service.py b/backend/app/services/career_service.py
new file mode 100644
index 0000000..db3f894
--- /dev/null
+++ b/backend/app/services/career_service.py
@@ -0,0 +1,234 @@
+"""职业生成服务"""
+from typing import Dict, Any, List
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+import json
+
+from app.models.project import Project
+from app.models.career import Career
+from app.services.ai_service import AIService
+from app.logger import get_logger
+
+logger = get_logger(__name__)
+
+
+class CareerService:
+ """职业相关业务逻辑服务"""
+
+ @staticmethod
+ async def get_career_generation_prompt(
+ project: Project,
+ main_career_count: int = 2,
+ sub_career_count: int = 6
+ ) -> str:
+ """
+ 构建职业体系生成的提示词
+
+ Args:
+ project: 项目对象
+ main_career_count: 主职业数量
+ sub_career_count: 副职业数量
+
+ Returns:
+ 完整的提示词
+ """
+ project_context = f"""
+项目信息:
+- 书名:{project.title}
+- 类型:{project.genre or '未设定'}
+- 主题:{project.theme or '未设定'}
+- 时间背景:{project.world_time_period or '未设定'}
+- 地理位置:{project.world_location or '未设定'}
+- 氛围基调:{project.world_atmosphere or '未设定'}
+- 世界规则:{project.world_rules or '未设定'}
+"""
+
+ user_requirements = f"""
+生成要求:
+- 主职业数量:{main_career_count}个
+- 副职业数量:{sub_career_count}个
+- 主职业必须严格符合世界观规则,体现核心能力体系
+- 副职业可以更加自由灵活,包含生产、辅助、特殊类型
+"""
+
+ prompt = f"""{project_context}
+
+{user_requirements}
+
+请为这个小说项目生成完整的职业体系。返回JSON格式,结构如下:
+
+{{
+ "main_careers": [
+ {{
+ "name": "职业名称",
+ "description": "职业描述(100-200字)",
+ "category": "职业分类(如:战斗系、法术系、体修系等)",
+ "stages": [
+ {{"level": 1, "name": "阶段名称", "description": "阶段描述"}},
+ {{"level": 2, "name": "阶段名称", "description": "阶段描述"}},
+ ...(共10个阶段)
+ ],
+ "max_stage": 10,
+ "requirements": "职业要求(如:需要特定天赋、资质等)",
+ "special_abilities": "特殊能力描述",
+ "worldview_rules": "世界观规则关联(说明该职业如何融入世界观)",
+ "attribute_bonuses": {{"strength": "+10%", "intelligence": "+5%"}}
+ }}
+ ],
+ "sub_careers": [
+ {{
+ "name": "副职业名称",
+ "description": "职业描述",
+ "category": "生产系/辅助系/特殊系",
+ "stages": [
+ {{"level": 1, "name": "阶段名称", "description": "阶段描述"}},
+ ...(5-8个阶段)
+ ],
+ "max_stage": 5,
+ "requirements": "职业要求",
+ "special_abilities": "特殊能力"
+ }}
+ ]
+}}
+
+重要注意事项:
+1. 主职业的阶段设定要详细,体现明确的成长路径,阶段名称要有特色
+2. 根据小说类型选择合适的职业:
+ - 修仙类:剑修、体修、法修、符修等,阶段如:炼气、筑基、金丹、元婴...
+ - 玄幻类:战士、法师、刺客等,阶段如:见习、初级、中级、高级...
+ - 都市异能:异能者分类,阶段如:觉醒、初阶、中阶、高阶...
+ - 科幻未来:基因战士、机甲师等,阶段如:E级、D级、C级、B级...
+3. 副职业要有实用性和趣味性,如:炼丹师、炼器师、阵法师、驯兽师、医师等
+4. 所有职业都要符合项目的整体世界观设定
+5. 阶段描述要简洁明了,体现该阶段的核心特征
+6. **只返回纯JSON对象,不要添加任何解释文字或markdown标记**
+"""
+
+ return prompt
+
+ @staticmethod
+ async def parse_and_save_careers(
+ career_data: Dict[str, Any],
+ project_id: str,
+ db: AsyncSession
+ ) -> Dict[str, List[str]]:
+ """
+ 解析AI返回的职业数据并保存到数据库
+
+ Args:
+ career_data: AI返回的职业数据(已解析为dict)
+ project_id: 项目ID
+ db: 数据库会话
+
+ Returns:
+ {"main_careers": [...], "sub_careers": [...]} 创建的职业名称列表
+ """
+ result = {
+ "main_careers": [],
+ "sub_careers": []
+ }
+
+ # 保存主职业
+ for idx, career_info in enumerate(career_data.get("main_careers", [])):
+ try:
+ stages_json = json.dumps(career_info.get("stages", []), ensure_ascii=False)
+ attribute_bonuses = career_info.get("attribute_bonuses")
+ attribute_bonuses_json = json.dumps(attribute_bonuses, ensure_ascii=False) if attribute_bonuses else None
+
+ career = Career(
+ project_id=project_id,
+ name=career_info.get("name", f"未命名主职业{idx+1}"),
+ type="main",
+ description=career_info.get("description"),
+ category=career_info.get("category"),
+ stages=stages_json,
+ max_stage=career_info.get("max_stage", 10),
+ requirements=career_info.get("requirements"),
+ special_abilities=career_info.get("special_abilities"),
+ worldview_rules=career_info.get("worldview_rules"),
+ attribute_bonuses=attribute_bonuses_json,
+ source="ai"
+ )
+ db.add(career)
+ await db.flush()
+ result["main_careers"].append(career.name)
+ logger.info(f" ✅ 创建主职业:{career.name}")
+ except Exception as e:
+ logger.error(f" ❌ 创建主职业失败:{str(e)}")
+ continue
+
+ # 保存副职业
+ for idx, career_info in enumerate(career_data.get("sub_careers", [])):
+ try:
+ stages_json = json.dumps(career_info.get("stages", []), ensure_ascii=False)
+ attribute_bonuses = career_info.get("attribute_bonuses")
+ attribute_bonuses_json = json.dumps(attribute_bonuses, ensure_ascii=False) if attribute_bonuses else None
+
+ career = Career(
+ project_id=project_id,
+ name=career_info.get("name", f"未命名副职业{idx+1}"),
+ type="sub",
+ description=career_info.get("description"),
+ category=career_info.get("category"),
+ stages=stages_json,
+ max_stage=career_info.get("max_stage", 5),
+ requirements=career_info.get("requirements"),
+ special_abilities=career_info.get("special_abilities"),
+ worldview_rules=career_info.get("worldview_rules"),
+ attribute_bonuses=attribute_bonuses_json,
+ source="ai"
+ )
+ db.add(career)
+ await db.flush()
+ result["sub_careers"].append(career.name)
+ logger.info(f" ✅ 创建副职业:{career.name}")
+ except Exception as e:
+ logger.error(f" ❌ 创建副职业失败:{str(e)}")
+ continue
+
+ await db.commit()
+
+ return result
+
+ @staticmethod
+ async def get_project_careers_summary(project_id: str, db: AsyncSession) -> Dict[str, Any]:
+ """
+ 获取项目职业体系摘要
+
+ Args:
+ project_id: 项目ID
+ db: 数据库会话
+
+ Returns:
+ 职业体系摘要信息
+ """
+ result = await db.execute(
+ select(Career).where(Career.project_id == project_id)
+ )
+ careers = result.scalars().all()
+
+ main_careers = []
+ sub_careers = []
+
+ for career in careers:
+ career_info = {
+ "id": career.id,
+ "name": career.name,
+ "category": career.category,
+ "max_stage": career.max_stage
+ }
+
+ if career.type == "main":
+ main_careers.append(career_info)
+ else:
+ sub_careers.append(career_info)
+
+ return {
+ "main_careers": main_careers,
+ "sub_careers": sub_careers,
+ "total_count": len(careers)
+ }
+
+
+# 创建全局服务实例
+career_service = CareerService()
\ No newline at end of file
diff --git a/backend/app/services/career_update_service.py b/backend/app/services/career_update_service.py
new file mode 100644
index 0000000..7f5396e
--- /dev/null
+++ b/backend/app/services/career_update_service.py
@@ -0,0 +1,398 @@
+"""职业更新服务 - 根据章节分析自动更新角色职业信息"""
+from typing import Dict, Any, List, Optional
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+from app.models.character import Character
+from app.models.career import Career, CharacterCareer
+from app.logger import get_logger
+
+logger = get_logger(__name__)
+
+
+class CareerUpdateService:
+ """职业更新服务 - 根据章节分析结果自动更新角色职业"""
+
+ @staticmethod
+ async def update_careers_from_analysis(
+ db: AsyncSession,
+ project_id: str,
+ character_states: List[Dict[str, Any]],
+ chapter_id: str,
+ chapter_number: int
+ ) -> Dict[str, Any]:
+ """
+ 根据章节分析结果更新角色职业
+
+ Args:
+ db: 数据库会话
+ project_id: 项目ID
+ character_states: 角色状态变化列表(来自PlotAnalysis)
+ chapter_id: 章节ID
+ chapter_number: 章节编号
+
+ Returns:
+ 更新结果字典,包含更新数量和变更日志
+ """
+ if not character_states:
+ logger.info("📋 角色状态列表为空,跳过职业更新")
+ return {"updated_count": 0, "changes": []}
+
+ updated_count = 0
+ changes_log = []
+
+ logger.info(f"🔍 开始分析第{chapter_number}章的角色职业变化...")
+
+ for char_state in character_states:
+ char_name = char_state.get('character_name')
+ career_changes = char_state.get('career_changes', {})
+
+ # 如果没有职业变化信息,跳过
+ if not career_changes or not isinstance(career_changes, dict):
+ continue
+
+ # 检查是否有实质性的职业变化
+ main_stage_change = career_changes.get('main_career_stage_change', 0)
+ sub_career_changes = career_changes.get('sub_career_changes', [])
+ new_careers = career_changes.get('new_careers', [])
+
+ if main_stage_change == 0 and not sub_career_changes and not new_careers:
+ continue
+
+ logger.info(f" 👤 检测到角色 [{char_name}] 有职业变化")
+
+ # 1. 查询角色
+ char_result = await db.execute(
+ select(Character).where(
+ Character.name == char_name,
+ Character.project_id == project_id
+ )
+ )
+ character = char_result.scalar_one_or_none()
+
+ if not character:
+ logger.warning(f" ⚠️ 角色不存在: {char_name},跳过")
+ continue
+
+ # 2. 更新主职业阶段
+ if main_stage_change != 0 and character.main_career_id:
+ success = await CareerUpdateService._update_main_career_stage(
+ db=db,
+ character=character,
+ stage_change=main_stage_change,
+ chapter_number=chapter_number,
+ career_changes=career_changes,
+ changes_log=changes_log
+ )
+ if success:
+ updated_count += 1
+
+ # 3. 更新副职业(如果有)
+ if sub_career_changes and isinstance(sub_career_changes, list):
+ for sub_change in sub_career_changes:
+ success = await CareerUpdateService._update_sub_career_stage(
+ db=db,
+ character=character,
+ project_id=project_id,
+ sub_change=sub_change,
+ chapter_number=chapter_number,
+ changes_log=changes_log
+ )
+ if success:
+ updated_count += 1
+
+ # 4. 添加新职业(如果有)
+ if new_careers and isinstance(new_careers, list):
+ for new_career_name in new_careers:
+ success = await CareerUpdateService._add_new_career(
+ db=db,
+ character=character,
+ project_id=project_id,
+ career_name=new_career_name,
+ chapter_number=chapter_number,
+ changes_log=changes_log
+ )
+ if success:
+ updated_count += 1
+
+ # 提交所有更改
+ if updated_count > 0:
+ await db.commit()
+ logger.info(f"✅ 职业更新完成: 共更新了 {updated_count} 个角色的职业信息")
+ else:
+ logger.info("📋 本章没有角色职业变化")
+
+ return {
+ "updated_count": updated_count,
+ "changes": changes_log
+ }
+
+ @staticmethod
+ async def _update_main_career_stage(
+ db: AsyncSession,
+ character: Character,
+ stage_change: int,
+ chapter_number: int,
+ career_changes: Dict[str, Any],
+ changes_log: List[Dict[str, Any]]
+ ) -> bool:
+ """更新主职业阶段"""
+ try:
+ # 查询主职业关联
+ char_career_result = await db.execute(
+ select(CharacterCareer).where(
+ CharacterCareer.character_id == character.id,
+ CharacterCareer.career_type == 'main'
+ )
+ )
+ char_career = char_career_result.scalar_one_or_none()
+
+ if not char_career:
+ logger.warning(f" ⚠️ {character.name} 没有主职业关联记录")
+ return False
+
+ # 查询职业信息
+ career_result = await db.execute(
+ select(Career).where(Career.id == char_career.career_id)
+ )
+ career = career_result.scalar_one_or_none()
+
+ if not career:
+ logger.warning(f" ⚠️ 职业ID {char_career.career_id} 不存在")
+ return False
+
+ # 计算新阶段(不超过最大阶段,不低于1)
+ old_stage = char_career.current_stage
+ new_stage = min(max(1, old_stage + stage_change), career.max_stage)
+
+ # 如果没有实际变化,跳过
+ if new_stage == old_stage:
+ logger.info(f" 📊 {character.name} 的 {career.name} 已达到边界,无法变更")
+ return False
+
+ # 更新CharacterCareer表
+ char_career.current_stage = new_stage
+
+ # 同步更新Character表的冗余字段
+ character.main_career_stage = new_stage
+
+ # 记录变更日志
+ change_desc = f"{'晋升' if stage_change > 0 else '降级'}"
+ breakthrough_desc = career_changes.get('career_breakthrough', '')
+
+ changes_log.append({
+ 'character': character.name,
+ 'career': career.name,
+ 'career_type': 'main',
+ 'old_stage': old_stage,
+ 'new_stage': new_stage,
+ 'change': stage_change,
+ 'chapter': chapter_number,
+ 'description': breakthrough_desc
+ })
+
+ logger.info(
+ f" ✨ {character.name} 的主职业 [{career.name}] "
+ f"{old_stage}阶 → {new_stage}阶 ({change_desc})"
+ )
+ if breakthrough_desc:
+ logger.info(f" 突破描述: {breakthrough_desc[:50]}...")
+
+ return True
+
+ except Exception as e:
+ logger.error(f" ❌ 更新主职业失败: {str(e)}")
+ return False
+
+ @staticmethod
+ async def _update_sub_career_stage(
+ db: AsyncSession,
+ character: Character,
+ project_id: str,
+ sub_change: Dict[str, Any],
+ chapter_number: int,
+ changes_log: List[Dict[str, Any]]
+ ) -> bool:
+ """更新副职业阶段"""
+ try:
+ career_name = sub_change.get('career_name')
+ stage_change = sub_change.get('stage_change', 0)
+
+ if not career_name or stage_change == 0:
+ return False
+
+ # 1. 查询职业(通过名称)
+ career_result = await db.execute(
+ select(Career).where(
+ Career.name == career_name,
+ Career.project_id == project_id,
+ Career.type == 'sub'
+ )
+ )
+ career = career_result.scalar_one_or_none()
+
+ if not career:
+ logger.warning(f" ⚠️ 副职业 [{career_name}] 不存在")
+ return False
+
+ # 2. 查询角色-职业关联
+ char_career_result = await db.execute(
+ select(CharacterCareer).where(
+ CharacterCareer.character_id == character.id,
+ CharacterCareer.career_id == career.id,
+ CharacterCareer.career_type == 'sub'
+ )
+ )
+ char_career = char_career_result.scalar_one_or_none()
+
+ if not char_career:
+ logger.warning(f" ⚠️ {character.name} 没有 [{career_name}] 副职业")
+ return False
+
+ # 3. 计算新阶段
+ old_stage = char_career.current_stage
+ new_stage = min(max(1, old_stage + stage_change), career.max_stage)
+
+ if new_stage == old_stage:
+ return False
+
+ # 4. 更新阶段
+ char_career.current_stage = new_stage
+
+ # 5. 同步更新Character表的sub_careers JSON字段
+ import json
+ sub_careers = json.loads(character.sub_careers) if character.sub_careers else []
+ for sc in sub_careers:
+ if sc.get('career_id') == career.id:
+ sc['stage'] = new_stage
+ break
+ character.sub_careers = json.dumps(sub_careers, ensure_ascii=False)
+
+ # 6. 记录变更
+ changes_log.append({
+ 'character': character.name,
+ 'career': career.name,
+ 'career_type': 'sub',
+ 'old_stage': old_stage,
+ 'new_stage': new_stage,
+ 'change': stage_change,
+ 'chapter': chapter_number
+ })
+
+ logger.info(
+ f" ✨ {character.name} 的副职业 [{career.name}] "
+ f"{old_stage}阶 → {new_stage}阶"
+ )
+
+ return True
+
+ except Exception as e:
+ logger.error(f" ❌ 更新副职业失败: {str(e)}")
+ return False
+
+ @staticmethod
+ async def _add_new_career(
+ db: AsyncSession,
+ character: Character,
+ project_id: str,
+ career_name: str,
+ chapter_number: int,
+ changes_log: List[Dict[str, Any]]
+ ) -> bool:
+ """为角色添加新职业"""
+ try:
+ # 1. 查询职业
+ career_result = await db.execute(
+ select(Career).where(
+ Career.name == career_name,
+ Career.project_id == project_id
+ )
+ )
+ career = career_result.scalar_one_or_none()
+
+ if not career:
+ logger.warning(f" ⚠️ 职业 [{career_name}] 不存在,无法添加")
+ return False
+
+ # 2. 检查是否已存在
+ existing_result = await db.execute(
+ select(CharacterCareer).where(
+ CharacterCareer.character_id == character.id,
+ CharacterCareer.career_id == career.id
+ )
+ )
+ if existing_result.scalar_one_or_none():
+ logger.info(f" 📋 {character.name} 已拥有 [{career_name}],跳过")
+ return False
+
+ # 3. 根据职业类型添加
+ if career.type == 'main':
+ # 检查是否已有主职业
+ if character.main_career_id:
+ logger.warning(f" ⚠️ {character.name} 已有主职业,无法添加新主职业")
+ return False
+
+ # 添加主职业
+ import uuid
+ new_char_career = CharacterCareer(
+ id=str(uuid.uuid4()),
+ character_id=character.id,
+ career_id=career.id,
+ career_type='main',
+ current_stage=1
+ )
+ db.add(new_char_career)
+
+ # 更新Character表
+ character.main_career_id = career.id
+ character.main_career_stage = 1
+
+ logger.info(f" ✨ {character.name} 获得新主职业 [{career_name}]")
+
+ else: # sub职业
+ # 检查副职业数量(最多2个)
+ sub_count_result = await db.execute(
+ select(CharacterCareer).where(
+ CharacterCareer.character_id == character.id,
+ CharacterCareer.career_type == 'sub'
+ )
+ )
+ if len(sub_count_result.scalars().all()) >= 2:
+ logger.warning(f" ⚠️ {character.name} 的副职业已达上限(2个)")
+ return False
+
+ # 添加副职业
+ import uuid
+ new_char_career = CharacterCareer(
+ id=str(uuid.uuid4()),
+ character_id=character.id,
+ career_id=career.id,
+ career_type='sub',
+ current_stage=1
+ )
+ db.add(new_char_career)
+
+ # 更新Character表的sub_careers JSON
+ import json
+ sub_careers = json.loads(character.sub_careers) if character.sub_careers else []
+ sub_careers.append({
+ 'career_id': career.id,
+ 'stage': 1
+ })
+ character.sub_careers = json.dumps(sub_careers, ensure_ascii=False)
+
+ logger.info(f" ✨ {character.name} 获得新副职业 [{career_name}]")
+
+ # 记录变更
+ changes_log.append({
+ 'character': character.name,
+ 'career': career.name,
+ 'career_type': career.type,
+ 'action': 'new',
+ 'chapter': chapter_number
+ })
+
+ return True
+
+ except Exception as e:
+ logger.error(f" ❌ 添加新职业失败: {str(e)}")
+ return False
\ No newline at end of file
diff --git a/backend/app/services/prompt_service.py b/backend/app/services/prompt_service.py
index 103b5d4..9768a1c 100644
--- a/backend/app/services/prompt_service.py
+++ b/backend/app/services/prompt_service.py
@@ -745,6 +745,15 @@ class PromptService:
- 特殊技能或知识
- 符合世界观设定
+7. **职业信息**(重要 - 如果项目上下文中包含职业列表):
+ - 仔细查看项目上下文中的"可用主职业"和"可用副职业"列表
+ - 主职业:必须从"可用主职业"列表中选择一个最符合角色设定的职业,填写其职业名称(name字段)
+ - 主职业阶段:根据职业的阶段信息和角色实力,设定合理的当前阶段(1到职业的max_stage)
+ - 副职业:可以从"可用副职业"列表中选择0-2个,每个包含职业名称和阶段
+ - 如果项目没有职业列表,则不需要填写career_info字段
+ - 职业选择必须与角色的背景故事、能力特点和故事定位高度契合
+ - ⚠️ 重要:请填写职业的名称而非ID,系统会自动匹配
+
**重要格式要求:**
1. 只返回纯JSON格式,不要包含任何markdown标记、代码块标记或其他说明文字
2. JSON字符串值的内容描述中严禁使用任何特殊符号(包括中文引号、英文引号、方括号、书名号等)
@@ -781,7 +790,18 @@ class PromptService:
"joined_at": "加入时间(可选)",
"status": "active"
}}
- ]
+ ],
+
+ "career_info": {{
+ "main_career_name": "从项目上下文的可用主职业列表中复制的职业名称",
+ "main_career_stage": 5,
+ "sub_careers": [
+ {{
+ "career_name": "从项目上下文的可用副职业列表中复制的职业名称",
+ "stage": 3
+ }}
+ ]
+ }}
}}
**关系类型参考(请从中选择或自定义):**
@@ -947,6 +967,12 @@ class PromptService:
- 关系变化
- 关键行动和决策
- 成长或退步
+- **💼 职业变化(重要 - 新增)**:
+ - 如果角色在本章有职业相关的进展或突破,请详细分析
+ - 主职业阶段变化: 是否晋级、突破或降级(用整数表示变化量,如: +1表示晋升一阶段,-1表示退步一阶段,0表示无变化)
+ - 副职业变化: 是否学习新的副职业或副职业有所精进
+ - 职业突破描述: 具体的突破过程、原因和标志性事件
+ - 注意:只有当章节中明确描述了职业相关的成长、突破或变化时才填写此项
### 6. 关键情节点 (Plot Points)
列出3-5个核心情节点:
@@ -1054,6 +1080,13 @@ class PromptService:
2. keyword必须是从章节原文中逐字复制的文本,长度8-25字
3. keyword用于在前端标注文本位置,所以必须能在原文中精确找到
4. 不要使用概括性语句或改写后的文字作为keyword
+5. **职业变化字段说明**:
+ - career_changes是可选字段,只有当章节中明确描述了职业相关变化时才填写
+ - main_career_stage_change: 整数,表示主职业阶段变化量(+1=晋升一阶,-1=退步一阶,0=无变化)
+ - sub_career_changes: 数组,包含副职业的变化,每项包含career_name(职业名称)和stage_change(阶段变化量)
+ - new_careers: 数组,包含新获得的职业名称(如果有)
+ - career_breakthrough: 字符串,描述职业突破的具体过程和标志性事件
+ - 如果角色没有职业变化,可以不填写career_changes字段或设为空对象
只返回JSON,不要其他说明。"""
@@ -1533,6 +1566,7 @@ class PromptService:
3. 性格、背景要有深度和独特性
4. 外貌描写要具体生动
5. 特长和能力要符合角色定位
+6. **如果【已有角色】中包含职业列表,必须为角色设定职业**(参考下方职业信息要求)
**关系建立指导(非常重要):**
- 仔细审视【已有角色】列表,思考新角色与哪些现有角色有联系
@@ -1546,6 +1580,15 @@ class PromptService:
2. JSON字符串值中严禁使用特殊符号(引号、方括号、书名号等)
3. 所有专有名词直接书写,不使用任何符号包裹
+【职业信息要求(重要)】
+如果【已有角色】部分包含"可用主职业列表"或"可用副职业列表",则必须:
+- 仔细查看可用的主职业和副职业列表
+- 根据角色的背景、能力、故事定位,选择最合适的职业
+- 主职业:从"可用主职业列表"中选择一个,填写职业名称(name字段)
+- 主职业阶段:根据职业的阶段信息和角色实力,设定合理的当前阶段
+- 副职业:可选择0-2个副职业,每个包含职业名称和阶段
+- ⚠️ 重要:必须填写职业的名称而非ID,系统会自动匹配
+
请严格按照以下JSON格式返回:
{{
"name": "角色姓名",
@@ -1574,7 +1617,18 @@ class PromptService:
"rank": 5,
"loyalty": 80
}}
- ]
+ ],
+
+ "career_info": {{
+ "main_career_name": "从可用主职业列表中选择的职业名称",
+ "main_career_stage": 5,
+ "sub_careers": [
+ {{
+ "career_name": "从可用副职业列表中选择的职业名称",
+ "stage": 3
+ }}
+ ]
+ }}
}}
**关系类型参考(从中选择或自定义):**
@@ -1605,6 +1659,85 @@ class PromptService:
只返回纯JSON对象,不要有```json```这样的标记。"""
+ # 职业体系生成提示词
+ CAREER_SYSTEM_GENERATION = """你是专业的游戏/小说职业体系设计师。请根据以下世界观信息,设计一个完整且合理的职业体系。
+
+【项目信息】
+- 书名:{title}
+- 类型:{genre}
+- 主题:{theme}
+- 时间背景:{time_period}
+- 地理位置:{location}
+- 氛围基调:{atmosphere}
+- 世界规则:{rules}
+
+【设计要求】
+1. **主职业(main_careers)**:
+ - 根据世界观特点,决定需要多少个主职业
+ - 主职业是角色的核心发展方向,直接影响战斗力或核心能力
+ - 必须严格符合世界观规则,体现核心能力体系
+ - 每个主职业的阶段数量可以不同:根据职业的复杂度、重要性、修炼难度等因素,为不同职业设定不同的max_stage
+
+2. **副职业(sub_careers)**:
+ - 根据世界需要,决定需要多少个副职业
+ - 副职业包含生产、辅助、特殊技能类,丰富角色的能力维度
+ - 每个副职业的阶段数量可以不同:简单的副职业可能只有3-5个阶段,复杂的可能有6-10个阶段
+ - 不要让所有副职业都是相同的阶段数
+
+3. **阶段设计(stages)**:
+ - 每个职业的stages数组长度必须等于max_stage
+ - 阶段名称要符合世界观文化背景和时代特征
+ - 阶段描述要体现明确的能力提升和成长路径
+ - 重要:确保职业间的阶段数量有差异,体现职业的多样性
+
+【JSON格式】
+
+{{
+"main_careers": [
+{{
+ "name": "职业名称",
+ "description": "职业描述(100-150字),说明职业特点和定位",
+ "category": "职业分类(如:战斗系、法术系、体修系等)",
+ "stages": [
+ {{"level": 1, "name": "阶段1名称", "description": "阶段描述"}},
+ {{"level": 2, "name": "阶段2名称", "description": "阶段描述"}},
+ ...数组长度应等于max_stage...
+ ],
+ "max_stage": 根据职业复杂度自行决定的整数,
+ "requirements": "职业要求和前置条件",
+ "special_abilities": "职业特殊能力和特色",
+ "worldview_rules": "与世界观规则的关联",
+ "attribute_bonuses": {{"strength": "+10%", "intelligence": "+5%"}}
+}}
+],
+"sub_careers": [
+{{
+ "name": "副职业名称",
+ "description": "职业描述(80-120字)",
+ "category": "生产系/辅助系/特殊系",
+ "stages": [
+ {{"level": 1, "name": "阶段1名称", "description": "阶段描述"}},
+ ...数组长度应等于max_stage...
+ ],
+ "max_stage": 根据职业特性自行决定的整数,
+ "requirements": "职业要求",
+ "special_abilities": "特殊能力"
+}}
+]
+}}
+
+【重要提示】
+- 职业的数量、类型完全由你根据世界观自行决定,不要受任何数字限制
+- **阶段数量多样性(关键)**:
+ - 不同职业的max_stage必须不同,不要所有职业都是相同的阶段数
+ - 主职业的阶段数建议范围:5-15个阶段(根据职业重要性和复杂度灵活设定)
+ - 副职业的阶段数建议范围:3-10个阶段(根据职业特性灵活设定)
+ - 例如:剑修可能有12个阶段,炼丹师可能有8个阶段,体修可能有10个阶段
+- 确保职业体系与世界观高度契合,符合该世界的逻辑和文化
+- 只返回纯JSON,不要添加markdown标记或其他解释文字
+
+请让每个职业的阶段数有所不同,体现职业的独特性和多样性!"""
+
@staticmethod
def format_prompt(template: str, **kwargs) -> str:
"""
@@ -2043,6 +2176,12 @@ class PromptService:
"description": "根据剧情需求自动生成新角色的完整设定",
"parameters": ["title", "genre", "theme", "time_period", "location", "atmosphere", "rules",
"existing_characters", "plot_context", "character_specification", "mcp_references"]
+ },
+ "CAREER_SYSTEM_GENERATION": {
+ "name": "职业体系生成",
+ "category": "世界构建",
+ "description": "根据世界观自动生成完整的职业体系,包括主职业和副职业",
+ "parameters": ["title", "genre", "theme", "time_period", "location", "atmosphere", "rules"]
}
}
diff --git a/backend/scripts/create_career_tables.sql b/backend/scripts/create_career_tables.sql
new file mode 100644
index 0000000..4ff1168
--- /dev/null
+++ b/backend/scripts/create_career_tables.sql
@@ -0,0 +1,256 @@
+-- 职业体系模块数据库迁移脚本(PostgreSQL版本)
+-- 创建时间: 2025-12-20
+-- 说明: 添加职业表和角色职业关联表
+
+-- ===== 1. 创建职业表 =====
+CREATE TABLE IF NOT EXISTS careers (
+ id VARCHAR(36) PRIMARY KEY,
+ project_id VARCHAR(36) NOT NULL,
+
+ -- 基本信息
+ name VARCHAR(100) NOT NULL,
+ type VARCHAR(20) NOT NULL, -- 职业类型: main(主职业)/sub(副职业)
+ description TEXT, -- 职业描述
+ category VARCHAR(50), -- 职业分类(如:战斗系、生产系、辅助系)
+
+ -- 阶段设定
+ stages TEXT NOT NULL, -- 职业阶段列表(JSON): [{"level":1, "name":"", "description":""}, ...]
+ max_stage INT NOT NULL DEFAULT 10, -- 最大阶段数
+
+ -- 职业特性
+ requirements TEXT, -- 职业要求/限制
+ special_abilities TEXT, -- 特殊能力描述
+ worldview_rules TEXT, -- 世界观规则关联
+
+ -- 职业属性加成(可选,JSON格式)
+ attribute_bonuses TEXT, -- 属性加成(JSON): {"strength": "+10%", "intelligence": "+5%"}
+
+ -- 元数据
+ source VARCHAR(20) DEFAULT 'ai', -- 来源: ai/manual
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 创建时间
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 更新时间
+
+ -- 外键约束
+ CONSTRAINT fk_career_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
+);
+
+-- 创建索引
+CREATE INDEX IF NOT EXISTS idx_careers_project_id ON careers(project_id);
+CREATE INDEX IF NOT EXISTS idx_careers_type ON careers(type);
+
+-- 创建更新时间触发器
+CREATE OR REPLACE FUNCTION update_careers_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = CURRENT_TIMESTAMP;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trigger_careers_updated_at
+ BEFORE UPDATE ON careers
+ FOR EACH ROW
+ EXECUTE FUNCTION update_careers_updated_at();
+
+-- 添加表注释
+COMMENT ON TABLE careers IS '职业表';
+COMMENT ON COLUMN careers.name IS '职业名称';
+COMMENT ON COLUMN careers.type IS '职业类型: main(主职业)/sub(副职业)';
+COMMENT ON COLUMN careers.description IS '职业描述';
+COMMENT ON COLUMN careers.category IS '职业分类(如:战斗系、生产系、辅助系)';
+COMMENT ON COLUMN careers.stages IS '职业阶段列表(JSON)';
+COMMENT ON COLUMN careers.max_stage IS '最大阶段数';
+COMMENT ON COLUMN careers.requirements IS '职业要求/限制';
+COMMENT ON COLUMN careers.special_abilities IS '特殊能力描述';
+COMMENT ON COLUMN careers.worldview_rules IS '世界观规则关联';
+COMMENT ON COLUMN careers.attribute_bonuses IS '属性加成(JSON)';
+COMMENT ON COLUMN careers.source IS '来源: ai/manual';
+COMMENT ON COLUMN careers.created_at IS '创建时间';
+COMMENT ON COLUMN careers.updated_at IS '更新时间';
+
+-- ===== 2. 创建角色职业关联表 =====
+CREATE TABLE IF NOT EXISTS character_careers (
+ id VARCHAR(36) PRIMARY KEY,
+ character_id VARCHAR(36) NOT NULL,
+ career_id VARCHAR(36) NOT NULL,
+ career_type VARCHAR(20) NOT NULL, -- main(主职业)/sub(副职业)
+
+ -- 阶段进度
+ current_stage INT NOT NULL DEFAULT 1, -- 当前阶段(对应职业中的数值)
+ stage_progress INT DEFAULT 0, -- 阶段内进度(0-100)
+
+ -- 时间记录
+ started_at VARCHAR(100), -- 开始修炼时间(小说时间线)
+ reached_current_stage_at VARCHAR(100), -- 到达当前阶段时间
+
+ -- 备注
+ notes TEXT, -- 备注(如:修炼心得、特殊事件)
+
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 创建时间
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 更新时间
+
+ -- 外键约束
+ CONSTRAINT fk_charcareer_character FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE,
+ CONSTRAINT fk_charcareer_career FOREIGN KEY (career_id) REFERENCES careers(id) ON DELETE CASCADE,
+
+ -- 唯一约束:一个角色不能重复拥有同一个职业
+ CONSTRAINT uk_character_career UNIQUE (character_id, career_id)
+);
+
+-- 创建索引
+CREATE INDEX IF NOT EXISTS idx_character_careers_character_id ON character_careers(character_id);
+CREATE INDEX IF NOT EXISTS idx_character_careers_career_type ON character_careers(career_type);
+
+-- 创建更新时间触发器
+CREATE OR REPLACE FUNCTION update_character_careers_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = CURRENT_TIMESTAMP;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trigger_character_careers_updated_at
+ BEFORE UPDATE ON character_careers
+ FOR EACH ROW
+ EXECUTE FUNCTION update_character_careers_updated_at();
+
+-- 添加表注释
+COMMENT ON TABLE character_careers IS '角色职业关联表';
+COMMENT ON COLUMN character_careers.career_type IS 'main(主职业)/sub(副职业)';
+COMMENT ON COLUMN character_careers.current_stage IS '当前阶段(对应职业中的数值)';
+COMMENT ON COLUMN character_careers.stage_progress IS '阶段内进度(0-100)';
+COMMENT ON COLUMN character_careers.started_at IS '开始修炼时间(小说时间线)';
+COMMENT ON COLUMN character_careers.reached_current_stage_at IS '到达当前阶段时间';
+COMMENT ON COLUMN character_careers.notes IS '备注(如:修炼心得、特殊事件)';
+
+-- ===== 3. 扩展角色表(添加冗余字段,可选) =====
+-- 注意:这部分是可选的,用于提升查询性能
+-- 检查字段是否存在,如果不存在则添加
+
+DO $$
+BEGIN
+ -- 添加 main_career_id 字段
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_name='characters' AND column_name='main_career_id') THEN
+ ALTER TABLE characters ADD COLUMN main_career_id VARCHAR(36);
+ COMMENT ON COLUMN characters.main_career_id IS '主职业ID';
+ END IF;
+
+ -- 添加 main_career_stage 字段
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_name='characters' AND column_name='main_career_stage') THEN
+ ALTER TABLE characters ADD COLUMN main_career_stage INT;
+ COMMENT ON COLUMN characters.main_career_stage IS '主职业当前阶段';
+ END IF;
+
+ -- 添加 sub_careers 字段
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_name='characters' AND column_name='sub_careers') THEN
+ ALTER TABLE characters ADD COLUMN sub_careers TEXT;
+ COMMENT ON COLUMN characters.sub_careers IS '副职业列表(JSON): [{"career_id": "xxx", "stage": 3}, ...]';
+ END IF;
+END $$;
+
+-- 添加外键约束(如果需要)
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints
+ WHERE constraint_name='fk_main_career' AND table_name='characters') THEN
+ ALTER TABLE characters
+ ADD CONSTRAINT fk_main_career
+ FOREIGN KEY (main_career_id) REFERENCES careers(id) ON DELETE SET NULL;
+ END IF;
+END $$;
+
+-- ===== 4. 创建视图(可选,便于查询) =====
+CREATE OR REPLACE VIEW v_character_career_details AS
+SELECT
+ cc.id AS relation_id,
+ cc.character_id,
+ c.name AS character_name,
+ cc.career_id,
+ ca.name AS career_name,
+ ca.type AS career_type_name,
+ cc.career_type,
+ cc.current_stage,
+ ca.max_stage,
+ cc.stage_progress,
+ cc.started_at,
+ cc.reached_current_stage_at,
+ cc.notes,
+ ca.description AS career_description,
+ ca.category AS career_category,
+ ca.stages AS career_stages_json,
+ cc.created_at,
+ cc.updated_at
+FROM character_careers cc
+JOIN characters c ON cc.character_id = c.id
+JOIN careers ca ON cc.career_id = ca.id
+ORDER BY cc.career_type DESC, cc.created_at;
+
+COMMENT ON VIEW v_character_career_details IS '角色职业详细信息视图';
+
+-- ===== 5. 插入测试数据(可选) =====
+-- 这里可以插入一些示例职业数据用于测试
+-- 注意:project_id需要替换为实际存在的项目ID
+
+/*
+-- 示例:修仙类主职业
+INSERT INTO careers (id, project_id, name, type, description, category, stages, max_stage, requirements, special_abilities, worldview_rules, source)
+VALUES (
+ gen_random_uuid()::text,
+ 'YOUR_PROJECT_ID_HERE',
+ '剑修',
+ 'main',
+ '以剑入道,追求极致剑意,是修仙界最强大的战斗职业之一。',
+ '战斗系',
+ '[
+ {"level": 1, "name": "炼气期", "description": "初窥门径,凝聚剑气"},
+ {"level": 2, "name": "筑基期", "description": "根基稳固,剑气成形"},
+ {"level": 3, "name": "金丹期", "description": "凝结金丹,剑意初显"},
+ {"level": 4, "name": "元婴期", "description": "元婴成就,剑意大成"},
+ {"level": 5, "name": "化神期", "description": "化神蜕变,剑道通神"},
+ {"level": 6, "name": "炼虚期", "description": "炼虚合道,剑破虚空"},
+ {"level": 7, "name": "合体期", "description": "天人合一,剑心合道"},
+ {"level": 8, "name": "大乘期", "description": "大乘境界,剑开天地"},
+ {"level": 9, "name": "渡劫期", "description": "渡劫飞升,剑斩天劫"},
+ {"level": 10, "name": "仙人", "description": "飞升成仙,剑意永恒"}
+ ]',
+ 10,
+ '需要剑道天赋,坚韧不拔的意志',
+ '剑气纵横、剑意凌云、御剑飞行',
+ '符合修仙世界观,属于正统修炼体系',
+ 'ai'
+);
+
+-- 示例:副职业
+INSERT INTO careers (id, project_id, name, type, description, category, stages, max_stage, requirements, special_abilities, source)
+VALUES (
+ gen_random_uuid()::text,
+ 'YOUR_PROJECT_ID_HERE',
+ '炼丹师',
+ 'sub',
+ '精通丹药炼制,能够炼制各种增强修为、疗伤、辅助的丹药。',
+ '生产系',
+ '[
+ {"level": 1, "name": "学徒", "description": "初学炼丹,成功率较低"},
+ {"level": 2, "name": "初级炼丹师", "description": "可炼制基础丹药"},
+ {"level": 3, "name": "中级炼丹师", "description": "可炼制进阶丹药"},
+ {"level": 4, "name": "高级炼丹师", "description": "可炼制高级丹药"},
+ {"level": 5, "name": "宗师级炼丹师", "description": "炉火纯青,可炼制顶级丹药"}
+ ]',
+ 5,
+ '需要对火候的精准掌控和丰富的药材知识',
+ '丹药炼制、药性分析、丹劫应对',
+ 'ai'
+);
+*/
+
+-- ===== 完成提示 =====
+DO $$
+BEGIN
+ RAISE NOTICE '职业体系数据库表创建完成!';
+ RAISE NOTICE '职业表记录数: %', (SELECT COUNT(*) FROM careers);
+ RAISE NOTICE '角色职业关联表记录数: %', (SELECT COUNT(*) FROM character_careers);
+END $$;
\ No newline at end of file
diff --git a/frontend/package.json b/frontend/package.json
index bc46d88..c4176d5 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "1.1.3",
+ "version": "1.1.4",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 497c2a8..2900cf6 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -8,6 +8,7 @@ import ProjectDetail from './pages/ProjectDetail';
import WorldSetting from './pages/WorldSetting';
import Outline from './pages/Outline';
import Characters from './pages/Characters';
+import Careers from './pages/Careers';
import Relationships from './pages/Relationships';
import Organizations from './pages/Organizations';
import Chapters from './pages/Chapters';
@@ -51,6 +52,7 @@ function App() {
}>
} />
} />
+ } />
} />
} />
} />
diff --git a/frontend/src/components/AIProjectGenerator.tsx b/frontend/src/components/AIProjectGenerator.tsx
index 168d412..7889572 100644
--- a/frontend/src/components/AIProjectGenerator.tsx
+++ b/frontend/src/components/AIProjectGenerator.tsx
@@ -32,6 +32,7 @@ type GenerationStep = 'pending' | 'processing' | 'completed' | 'error';
interface GenerationSteps {
worldBuilding: GenerationStep;
+ careers: GenerationStep;
characters: GenerationStep;
outline: GenerationStep;
}
@@ -55,6 +56,7 @@ export const AIProjectGenerator: React.FC = ({
const [errorDetails, setErrorDetails] = useState('');
const [generationSteps, setGenerationSteps] = useState({
worldBuilding: 'pending',
+ careers: 'pending',
characters: 'pending',
outline: 'pending'
});
@@ -126,12 +128,12 @@ export const AIProjectGenerator: React.FC = ({
if (wizardStep === 0) {
// 从世界观开始
message.info('从世界观步骤开始生成...');
- setGenerationSteps({ worldBuilding: 'processing', characters: 'pending', outline: 'pending' });
+ setGenerationSteps({ worldBuilding: 'processing', careers: 'pending', characters: 'pending', outline: 'pending' });
await resumeFromWorldBuilding(data);
} else if (wizardStep === 1) {
// 世界观已完成,从角色开始
message.info('世界观已完成,从角色步骤继续...');
- setGenerationSteps({ worldBuilding: 'completed', characters: 'processing', outline: 'pending' });
+ setGenerationSteps({ worldBuilding: 'completed', careers: 'completed', characters: 'processing', outline: 'pending' });
// 获取世界观数据
const worldResult = {
@@ -148,7 +150,7 @@ export const AIProjectGenerator: React.FC = ({
} else if (wizardStep === 2) {
// 世界观和角色已完成,从大纲开始
message.info('世界观和角色已完成,从大纲步骤继续...');
- setGenerationSteps({ worldBuilding: 'completed', characters: 'completed', outline: 'processing' });
+ setGenerationSteps({ worldBuilding: 'completed', careers: 'completed', characters: 'completed', outline: 'processing' });
setProgress(66);
await resumeFromOutline(data, projectIdParam);
} else {
@@ -334,13 +336,31 @@ export const AIProjectGenerator: React.FC = ({
},
{
onProgress: (msg, prog) => {
- setProgress(Math.floor(prog / 3));
+ // 世界观生成占0%-20%,职业生成占20%-30%
+ const baseProgress = Math.floor(prog / 5);
+ setProgress(baseProgress);
setProgressMessage(msg);
+
+ // 检测职业体系生成阶段 - 必须包含"职业体系"才算职业阶段
+ if (msg.includes('职业体系')) {
+ if (msg.includes('开始') || msg.includes('生成')) {
+ // 职业开始时,世界观应该已完成
+ setGenerationSteps(prev => ({
+ ...prev,
+ worldBuilding: 'completed',
+ careers: 'processing'
+ }));
+ }
+ if (msg.includes('完成') || msg.includes('✅')) {
+ setGenerationSteps(prev => ({ ...prev, careers: 'completed' }));
+ }
+ }
},
onResult: (result) => {
setProjectId(result.project_id);
setWorldBuildingResult(result);
setGenerationSteps(prev => ({ ...prev, worldBuilding: 'completed' }));
+ // 职业体系状态已在onProgress中更新
},
onError: (error) => {
console.error('世界观生成失败:', error);
@@ -383,7 +403,8 @@ export const AIProjectGenerator: React.FC = ({
},
{
onProgress: (msg, prog) => {
- setProgress(33 + Math.floor(prog / 3));
+ // 角色生成占40%-70%
+ setProgress(40 + Math.floor(prog * 0.3));
setProgressMessage(msg);
},
onResult: (result) => {
@@ -416,7 +437,8 @@ export const AIProjectGenerator: React.FC = ({
},
{
onProgress: (msg, prog) => {
- setProgress(66 + Math.floor(prog / 3));
+ // 大纲生成占70%-100%
+ setProgress(70 + Math.floor(prog * 0.3));
setProgressMessage(msg);
},
onResult: () => {
@@ -511,8 +533,23 @@ export const AIProjectGenerator: React.FC = ({
},
{
onProgress: (msg, prog) => {
- setProgress(Math.floor(prog / 3));
+ const baseProgress = Math.floor(prog / 5);
+ setProgress(baseProgress);
setProgressMessage(msg);
+
+ // 检测职业体系生成阶段
+ if (msg.includes('职业体系')) {
+ if (msg.includes('开始') || msg.includes('生成')) {
+ setGenerationSteps(prev => ({
+ ...prev,
+ worldBuilding: 'completed',
+ careers: 'processing'
+ }));
+ }
+ if (msg.includes('完成') || msg.includes('✅')) {
+ setGenerationSteps(prev => ({ ...prev, careers: 'completed' }));
+ }
+ }
},
onResult: (result) => {
setProjectId(result.project_id);
@@ -755,6 +792,7 @@ export const AIProjectGenerator: React.FC = ({
};
const hasError = generationSteps.worldBuilding === 'error' ||
+ generationSteps.careers === 'error' ||
generationSteps.characters === 'error' ||
generationSteps.outline === 'error';
@@ -843,6 +881,7 @@ export const AIProjectGenerator: React.FC = ({
>
{[
{ key: 'worldBuilding', label: '生成世界观', step: generationSteps.worldBuilding },
+ { key: 'careers', label: '生成职业体系', step: generationSteps.careers },
{ key: 'characters', label: '生成角色', step: generationSteps.characters },
{ key: 'outline', label: '生成大纲', step: generationSteps.outline },
].map(({ key, label, step }) => {
diff --git a/frontend/src/components/CharacterCareerCard.tsx b/frontend/src/components/CharacterCareerCard.tsx
new file mode 100644
index 0000000..7fb11af
--- /dev/null
+++ b/frontend/src/components/CharacterCareerCard.tsx
@@ -0,0 +1,398 @@
+import { useState, useEffect } from 'react';
+import { Card, Button, Modal, Form, Select, InputNumber, Input, message, Progress, Tag, Space, Divider, Typography } from 'antd';
+import { EditOutlined, PlusOutlined, DeleteOutlined, TrophyOutlined } from '@ant-design/icons';
+import axios from 'axios';
+
+const { TextArea } = Input;
+const { Text, Paragraph } = Typography;
+
+const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
+
+interface CareerDetail {
+ id: string;
+ character_id: string;
+ career_id: string;
+ career_name: string;
+ career_type: 'main' | 'sub';
+ current_stage: number;
+ stage_name: string;
+ stage_description?: string;
+ stage_progress: number;
+ max_stage: number;
+ started_at?: string;
+ reached_current_stage_at?: string;
+ notes?: string;
+}
+
+interface Career {
+ id: string;
+ name: string;
+ type: 'main' | 'sub';
+ max_stage: number;
+}
+
+interface Props {
+ characterId: string;
+ projectId: string;
+ editable?: boolean;
+ onUpdate?: () => void;
+}
+
+export const CharacterCareerCard: React.FC = ({
+ characterId,
+ projectId,
+ editable = false,
+ onUpdate
+}) => {
+ const [mainCareer, setMainCareer] = useState(null);
+ const [subCareers, setSubCareers] = useState([]);
+ const [allCareers, setAllCareers] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const [isMainModalOpen, setIsMainModalOpen] = useState(false);
+ const [isSubModalOpen, setIsSubModalOpen] = useState(false);
+ const [isProgressModalOpen, setIsProgressModalOpen] = useState(false);
+ const [selectedCareer, setSelectedCareer] = useState(null);
+
+ const [mainForm] = Form.useForm();
+ const [subForm] = Form.useForm();
+ const [progressForm] = Form.useForm();
+
+ useEffect(() => {
+ fetchCharacterCareers();
+ if (editable) {
+ fetchAllCareers();
+ }
+ }, [characterId]);
+
+ const fetchCharacterCareers = async () => {
+ try {
+ setLoading(true);
+ const response = await axios.get(
+ `${API_BASE_URL}/api/careers/character/${characterId}/careers`,
+ { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } }
+ );
+ setMainCareer(response.data.main_career || null);
+ setSubCareers(response.data.sub_careers || []);
+ } catch (error: any) {
+ message.error(error.response?.data?.detail || '获取职业信息失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const fetchAllCareers = async () => {
+ try {
+ const response = await axios.get(`${API_BASE_URL}/api/careers`, {
+ params: { project_id: projectId },
+ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
+ });
+ const main = response.data.main_careers || [];
+ const sub = response.data.sub_careers || [];
+ setAllCareers([...main, ...sub]);
+ } catch (error: any) {
+ console.error('获取职业列表失败:', error);
+ }
+ };
+
+ const handleSetMainCareer = async (values: any) => {
+ try {
+ await axios.post(
+ `${API_BASE_URL}/api/careers/character/${characterId}/careers/main`,
+ values,
+ { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } }
+ );
+ message.success('主职业设置成功');
+ setIsMainModalOpen(false);
+ mainForm.resetFields();
+ fetchCharacterCareers();
+ onUpdate?.();
+ } catch (error: any) {
+ message.error(error.response?.data?.detail || '设置主职业失败');
+ }
+ };
+
+ const handleAddSubCareer = async (values: any) => {
+ try {
+ await axios.post(
+ `${API_BASE_URL}/api/careers/character/${characterId}/careers/sub`,
+ values,
+ { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } }
+ );
+ message.success('副职业添加成功');
+ setIsSubModalOpen(false);
+ subForm.resetFields();
+ fetchCharacterCareers();
+ onUpdate?.();
+ } catch (error: any) {
+ message.error(error.response?.data?.detail || '添加副职业失败');
+ }
+ };
+
+ const handleUpdateProgress = async (values: any) => {
+ if (!selectedCareer) return;
+
+ try {
+ await axios.put(
+ `${API_BASE_URL}/api/careers/character/${characterId}/careers/${selectedCareer.career_id}/stage`,
+ values,
+ { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } }
+ );
+ message.success('职业阶段更新成功');
+ setIsProgressModalOpen(false);
+ progressForm.resetFields();
+ fetchCharacterCareers();
+ onUpdate?.();
+ } catch (error: any) {
+ message.error(error.response?.data?.detail || '更新职业阶段失败');
+ }
+ };
+
+ const handleRemoveSubCareer = (careerId: string) => {
+ Modal.confirm({
+ title: '确认删除',
+ content: '确定要移除这个副职业吗?',
+ onOk: async () => {
+ try {
+ await axios.delete(
+ `${API_BASE_URL}/api/careers/character/${characterId}/careers/${careerId}`,
+ { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } }
+ );
+ message.success('副职业删除成功');
+ fetchCharacterCareers();
+ onUpdate?.();
+ } catch (error: any) {
+ message.error(error.response?.data?.detail || '删除副职业失败');
+ }
+ }
+ });
+ };
+
+ const openEditProgress = (career: CareerDetail) => {
+ setSelectedCareer(career);
+ progressForm.setFieldsValue({
+ current_stage: career.current_stage,
+ stage_progress: career.stage_progress,
+ reached_current_stage_at: career.reached_current_stage_at || '',
+ notes: career.notes || ''
+ });
+ setIsProgressModalOpen(true);
+ };
+
+ const renderCareerInfo = (career: CareerDetail, isMain: boolean = false) => (
+
+
+
+
+ {career.career_name}
+ {isMain && 主}
+
+ {editable && (
+
+ } onClick={() => openEditProgress(career)} />
+ {!isMain && (
+ }
+ onClick={() => handleRemoveSubCareer(career.career_id)}
+ />
+ )}
+
+ )}
+
+
+
+
+ {career.stage_name}(第{career.current_stage}/{career.max_stage}阶段)
+
+ {career.stage_description && (
+
+ {career.stage_description}
+
+ )}
+
+
+ );
+
+ if (loading) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+ 职业信息
+
+ }
+ extra={
+ editable && !mainCareer && (
+ }
+ onClick={() => {
+ mainForm.resetFields();
+ setIsMainModalOpen(true);
+ }}
+ >
+ 设置主职业
+
+ )
+ }
+ >
+ {mainCareer ? (
+ <>
+ {renderCareerInfo(mainCareer, true)}
+
+ {subCareers.length > 0 && (
+ <>
+
+ 副职业
+
+ {subCareers.map(career => renderCareerInfo(career, false))}
+
+ >
+ )}
+
+ {editable && subCareers.length < 5 && (
+
+ }
+ onClick={() => {
+ subForm.resetFields();
+ setIsSubModalOpen(true);
+ }}
+ >
+ 添加副职业
+
+
+ )}
+ >
+ ) : (
+
+ 暂无职业信息
+
+ )}
+
+
+ {/* 设置主职业 */}
+ setIsMainModalOpen(false)}
+ footer={null}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* 添加副职业 */}
+ setIsSubModalOpen(false)}
+ footer={null}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* 更新职业进度 */}
+ setIsProgressModalOpen(false)}
+ footer={null}
+ >
+ {selectedCareer && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ >
+ );
+};
+
+export default CharacterCareerCard;
\ No newline at end of file
diff --git a/frontend/src/pages/Careers.tsx b/frontend/src/pages/Careers.tsx
new file mode 100644
index 0000000..3b6b193
--- /dev/null
+++ b/frontend/src/pages/Careers.tsx
@@ -0,0 +1,433 @@
+import { useState, useEffect } from 'react';
+import { Button, Modal, Form, Input, Select, message, Row, Col, Empty, Tabs, Card, Tag, Space, Divider, Typography, InputNumber } from 'antd';
+import { ThunderboltOutlined, PlusOutlined, EditOutlined, DeleteOutlined, TrophyOutlined } from '@ant-design/icons';
+import { useParams } from 'react-router-dom';
+import axios from 'axios';
+import SSEProgressModal from '../components/SSEProgressModal';
+
+const { TextArea } = Input;
+const { Title, Text, Paragraph } = Typography;
+
+const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
+
+interface CareerStage {
+ level: number;
+ name: string;
+ description?: string;
+}
+
+interface Career {
+ id: string;
+ project_id: string;
+ name: string;
+ type: 'main' | 'sub';
+ description?: string;
+ category?: string;
+ stages: CareerStage[];
+ max_stage: number;
+ requirements?: string;
+ special_abilities?: string;
+ worldview_rules?: string;
+ source: string;
+}
+
+export default function Careers() {
+ const { projectId } = useParams<{ projectId: string }>();
+ const [mainCareers, setMainCareers] = useState([]);
+ const [subCareers, setSubCareers] = useState([]);
+ const [, setLoading] = useState(true);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [isAIModalOpen, setIsAIModalOpen] = useState(false);
+ const [editingCareer, setEditingCareer] = useState(null);
+ const [form] = Form.useForm();
+ const [aiForm] = Form.useForm();
+
+ // AI生成状态
+ const [aiGenerating, setAiGenerating] = useState(false);
+ const [aiProgress, setAiProgress] = useState(0);
+ const [aiMessage, setAiMessage] = useState('');
+
+ useEffect(() => {
+ if (projectId) {
+ fetchCareers();
+ }
+ }, [projectId]);
+
+ const fetchCareers = async () => {
+ try {
+ setLoading(true);
+ const response = await axios.get(`${API_BASE_URL}/api/careers`, {
+ params: { project_id: projectId },
+ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
+ });
+ setMainCareers(response.data.main_careers || []);
+ setSubCareers(response.data.sub_careers || []);
+ } catch (error: any) {
+ message.error(error.response?.data?.detail || '获取职业列表失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleOpenModal = (career?: Career) => {
+ if (career) {
+ setEditingCareer(career);
+ form.setFieldsValue({
+ ...career,
+ stages: career.stages.map(s => `${s.level}. ${s.name}${s.description ? ` - ${s.description}` : ''}`).join('\n')
+ });
+ } else {
+ setEditingCareer(null);
+ form.resetFields();
+ }
+ setIsModalOpen(true);
+ };
+
+ const handleSubmit = async (values: any) => {
+ try {
+ // 解析阶段数据
+ const stagesText = values.stages || '';
+ const stages: CareerStage[] = stagesText.split('\n')
+ .filter((line: string) => line.trim())
+ .map((line: string, index: number) => {
+ const match = line.match(/^(\d+)\.\s*([^-]+)(?:\s*-\s*(.*))?$/);
+ if (match) {
+ return {
+ level: parseInt(match[1]),
+ name: match[2].trim(),
+ description: match[3]?.trim() || ''
+ };
+ }
+ return {
+ level: index + 1,
+ name: line.trim(),
+ description: ''
+ };
+ });
+
+ const data = {
+ ...values,
+ stages,
+ max_stage: stages.length
+ };
+
+ if (editingCareer) {
+ await axios.put(`${API_BASE_URL}/api/careers/${editingCareer.id}`, data, {
+ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
+ });
+ message.success('职业更新成功');
+ } else {
+ await axios.post(`${API_BASE_URL}/api/careers`, {
+ ...data,
+ project_id: projectId,
+ source: 'manual'
+ }, {
+ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
+ });
+ message.success('职业创建成功');
+ }
+
+ setIsModalOpen(false);
+ form.resetFields();
+ fetchCareers();
+ } catch (error: any) {
+ message.error(error.response?.data?.detail || '操作失败');
+ }
+ };
+
+ const handleDelete = async (id: string) => {
+ Modal.confirm({
+ title: '确认删除',
+ content: '确定要删除这个职业吗?如果有角色使用了该职业,将无法删除。',
+ onOk: async () => {
+ try {
+ await axios.delete(`${API_BASE_URL}/api/careers/${id}`, {
+ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
+ });
+ message.success('职业删除成功');
+ fetchCareers();
+ } catch (error: any) {
+ message.error(error.response?.data?.detail || '删除失败');
+ }
+ }
+ });
+ };
+
+ const handleAIGenerate = async (values: any) => {
+ setIsAIModalOpen(false);
+ setAiGenerating(true);
+ setAiProgress(0);
+ setAiMessage('开始生成新职业...');
+
+ try {
+ const eventSource = new EventSource(
+ `${API_BASE_URL}/api/careers/generate-system?` +
+ new URLSearchParams({
+ project_id: projectId || '',
+ main_career_count: values.main_career_count.toString(),
+ sub_career_count: values.sub_career_count.toString(),
+ enable_mcp: 'false'
+ }).toString()
+ );
+
+ eventSource.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+
+ if (data.type === 'progress') {
+ setAiProgress(data.progress || 0);
+ setAiMessage(data.message || '');
+ } else if (data.type === 'done') {
+ eventSource.close();
+ setTimeout(() => {
+ setAiGenerating(false);
+ message.success('AI新职业生成完成!');
+ fetchCareers();
+ }, 1000);
+ } else if (data.type === 'error') {
+ eventSource.close();
+ setAiGenerating(false);
+ message.error(data.message || '生成失败');
+ }
+ } catch (e) {
+ console.error('解析SSE数据失败:', e);
+ }
+ };
+
+ eventSource.onerror = () => {
+ eventSource.close();
+ setAiGenerating(false);
+ message.error('连接中断,生成失败');
+ };
+ } catch (err: any) {
+ setAiGenerating(false);
+ message.error(err.message || '启动生成失败');
+ }
+ };
+
+ const renderCareerCard = (career: Career) => (
+
+
+ {career.name}
+
+ {career.source === 'ai' ? 'AI生成' : '手动创建'}
+
+ {career.category && {career.category}}
+
+ }
+ extra={
+
+ } onClick={() => handleOpenModal(career)} />
+ } onClick={() => handleDelete(career.id)} />
+
+ }
+ style={{ marginBottom: 16 }}
+ >
+ {career.description || '暂无描述'}
+
+ 阶段体系(共{career.max_stage}个):
+
+ {career.stages.slice(0, 5).map(stage => (
+
+ {stage.level}. {stage.name}
+ {stage.description && - {stage.description}}
+
+ ))}
+ {career.stages.length > 5 && (
+
...还有{career.stages.length - 5}个阶段
+ )}
+
+ {career.special_abilities && (
+ <>
+
+ 特殊能力:
+ {career.special_abilities}
+ >
+ )}
+
+ );
+
+ const tabItems = [
+ {
+ key: 'main',
+ label: `主职业 (${mainCareers.length})`,
+ children: mainCareers.length > 0 ? (
+ {mainCareers.map(renderCareerCard)}
+ ) : (
+
+ )
+ },
+ {
+ key: 'sub',
+ label: `副职业 (${subCareers.length})`,
+ children: subCareers.length > 0 ? (
+ {subCareers.map(renderCareerCard)}
+ ) : (
+
+ )
+ }
+ ];
+
+ return (
+
+ {/* 固定头部 */}
+
+
+
职业管理
+
+ }
+ onClick={() => {
+ aiForm.resetFields();
+ setIsAIModalOpen(true);
+ }}
+ >
+ AI生成新职业
+
+ }
+ onClick={() => handleOpenModal()}
+ >
+ 新增职业
+
+
+
+
+
+ {/* 可滚动的内容区域 */}
+
+
+
+
+ {/* 创建/编辑对话框 */}
+
{
+ setIsModalOpen(false);
+ form.resetFields();
+ }}
+ footer={null}
+ width={700}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* AI生成对话框 */}
+
setIsAIModalOpen(false)}
+ footer={null}
+ >
+
+
+
+
+
+
+
+
+
+ } htmlType="submit">
+ 开始生成
+
+
+
+
+
+
+ {/* AI生成进度 */}
+
setAiGenerating(false)}
+ />
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Chapters.tsx b/frontend/src/pages/Chapters.tsx
index a6ea7dd..bc9f57d 100644
--- a/frontend/src/pages/Chapters.tsx
+++ b/frontend/src/pages/Chapters.tsx
@@ -261,6 +261,48 @@ export default function Chapters() {
}
};
+ // 🔔 显示浏览器通知
+ const showBrowserNotification = (title: string, body: string, type: 'success' | 'error' | 'info' = 'info') => {
+ // 检查浏览器是否支持通知
+ if (!('Notification' in window)) {
+ console.log('浏览器不支持通知功能');
+ return;
+ }
+
+ // 检查通知权限
+ if (Notification.permission === 'granted') {
+ // 选择图标
+ const icon = type === 'success' ? '/logo.svg' : type === 'error' ? '/favicon.ico' : '/logo.svg';
+
+ const notification = new Notification(title, {
+ body,
+ icon,
+ badge: '/favicon.ico',
+ tag: 'batch-generation', // 相同tag会替换旧通知
+ requireInteraction: false, // 自动关闭
+ silent: false, // 播放提示音
+ });
+
+ // 点击通知时聚焦到窗口
+ notification.onclick = () => {
+ window.focus();
+ notification.close();
+ };
+
+ // 5秒后自动关闭
+ setTimeout(() => {
+ notification.close();
+ }, 5000);
+ } else if (Notification.permission !== 'denied') {
+ // 如果权限未被明确拒绝,尝试请求权限
+ Notification.requestPermission().then(permission => {
+ if (permission === 'granted') {
+ showBrowserNotification(title, body, type);
+ }
+ });
+ }
+ };
+
if (!currentProject) return null;
// 获取人称的中文显示文本
@@ -282,7 +324,24 @@ export default function Chapters() {
c => c.chapter_number < chapter.chapter_number
);
- return previousChapters.every(c => c.content && c.content.trim() !== '');
+ // 检查所有前置章节是否有内容
+ const allHaveContent = previousChapters.every(c => c.content && c.content.trim() !== '');
+ if (!allHaveContent) {
+ return false;
+ }
+
+ // 检查所有前置章节是否分析成功
+ const allAnalyzed = previousChapters.every(c => {
+ const task = analysisTasksMap[c.id];
+ // 如果没有分析任务或分析失败,则不允许生成
+ if (!task || !task.has_task) {
+ return false;
+ }
+ // 只有completed状态才算分析成功
+ return task.status === 'completed';
+ });
+
+ return allAnalyzed;
};
const getGenerateDisabledReason = (chapter: Chapter): string => {
@@ -294,6 +353,7 @@ export default function Chapters() {
c => c.chapter_number < chapter.chapter_number
);
+ // 首先检查是否有未完成内容的章节
const incompleteChapters = previousChapters.filter(
c => !c.content || c.content.trim() === ''
);
@@ -303,6 +363,36 @@ export default function Chapters() {
return `需要先完成前置章节:第 ${numbers} 章`;
}
+ // 检查是否有未分析或分析失败的章节
+ const unanalyzedChapters = previousChapters.filter(c => {
+ const task = analysisTasksMap[c.id];
+ if (!task || !task.has_task) {
+ return true; // 没有分析任务
+ }
+ return task.status !== 'completed'; // 分析未完成或失败
+ });
+
+ if (unanalyzedChapters.length > 0) {
+ const numbers = unanalyzedChapters.map(c => c.chapter_number).join('、');
+ const reasons = unanalyzedChapters.map(c => {
+ const task = analysisTasksMap[c.id];
+ if (!task || !task.has_task) {
+ return '未分析';
+ }
+ if (task.status === 'pending') {
+ return '等待分析';
+ }
+ if (task.status === 'running') {
+ return '分析中';
+ }
+ if (task.status === 'failed') {
+ return '分析失败';
+ }
+ return '状态未知';
+ });
+ return `需要先分析前置章节:第 ${numbers} 章 (${reasons.join('、')})`;
+ }
+
return '';
};
@@ -638,7 +728,7 @@ export default function Chapters() {
const requestBody: any = {
start_chapter_number: values.startChapterNumber,
count: values.count,
- enable_analysis: values.enableAnalysis,
+ enable_analysis: true,
style_id: styleId,
target_word_count: wordCount,
};
@@ -678,6 +768,13 @@ export default function Chapters() {
message.success(`批量生成任务已创建,预计需要 ${result.estimated_time_minutes} 分钟`);
+ // 🔔 触发浏览器通知(任务开始)
+ showBrowserNotification(
+ '批量生成已启动',
+ `开始生成 ${result.chapters_to_generate.length} 章,预计需要 ${result.estimated_time_minutes} 分钟`,
+ 'info'
+ );
+
// 开始轮询任务状态
startBatchPolling(result.batch_id);
@@ -740,8 +837,20 @@ export default function Chapters() {
if (status.status === 'completed') {
message.success(`批量生成完成!成功生成 ${status.completed} 章`);
+ // 🔔 触发浏览器通知
+ showBrowserNotification(
+ '批量生成完成',
+ `《${currentProject?.title || '项目'}》成功生成 ${status.completed} 章节`,
+ 'success'
+ );
} else if (status.status === 'failed') {
message.error(`批量生成失败:${status.error_message || '未知错误'}`);
+ // 🔔 触发浏览器通知
+ showBrowserNotification(
+ '批量生成失败',
+ status.error_message || '未知错误',
+ 'error'
+ );
} else if (status.status === 'cancelled') {
message.warning('批量生成已取消');
}
@@ -2199,7 +2308,7 @@ export default function Chapters() {
initialValues={{
startChapterNumber: sortedChapters.find(ch => !ch.content || ch.content.trim() === '')?.chapter_number || 1,
count: 5,
- enableAnalysis: false,
+ enableAnalysis: true, // 强制启用同步分析
styleId: selectedStyleId,
targetWordCount: 3000,
model: selectedModel,
@@ -2323,19 +2432,20 @@ export default function Chapters() {
-
-
-
- 不分析(推荐)
- 生成更快,后续可手动分析
-
-
+
- 同步分析
- 增加约50%耗时,提升质量
+
+ ✓ 确保职业信息自动更新
+
+
+ ✓ 保证剧情状态连贯
+
+
+ ⏱ 增加约50%耗时
+
diff --git a/frontend/src/pages/Characters.tsx b/frontend/src/pages/Characters.tsx
index a167af0..43f6714 100644
--- a/frontend/src/pages/Characters.tsx
+++ b/frontend/src/pages/Characters.tsx
@@ -6,13 +6,21 @@ import { useCharacterSync } from '../store/hooks';
import { characterGridConfig } from '../components/CardStyles';
import { CharacterCard } from '../components/CharacterCard';
import { SSELoadingOverlay } from '../components/SSELoadingOverlay';
-import type { Character, CharacterUpdate } from '../types';
+import type { Character } from '../types';
import { characterApi } from '../services/api';
import { SSEPostClient } from '../utils/sseClient';
+import axios from 'axios';
const { Title } = Typography;
-
const { TextArea } = Input;
+const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
+
+interface Career {
+ id: string;
+ name: string;
+ type: 'main' | 'sub';
+ max_stage: number;
+}
export default function Characters() {
const { currentProject, characters } = useStore();
@@ -28,6 +36,8 @@ export default function Characters() {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [createType, setCreateType] = useState<'character' | 'organization'>('character');
const [editingCharacter, setEditingCharacter] = useState(null);
+ const [mainCareers, setMainCareers] = useState([]);
+ const [subCareers, setSubCareers] = useState([]);
const {
refreshCharacters,
@@ -37,11 +47,26 @@ export default function Characters() {
useEffect(() => {
if (currentProject?.id) {
refreshCharacters();
+ fetchCareers();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentProject?.id]);
const [modal, contextHolder] = Modal.useModal();
+ const fetchCareers = async () => {
+ if (!currentProject?.id) return;
+ try {
+ const response = await axios.get(`${API_BASE_URL}/api/careers`, {
+ params: { project_id: currentProject.id },
+ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
+ });
+ setMainCareers(response.data.main_careers || []);
+ setSubCareers(response.data.sub_careers || []);
+ } catch (error) {
+ console.error('获取职业列表失败:', error);
+ }
+ };
+
if (!currentProject) return null;
const handleDeleteCharacter = async (id: string) => {
@@ -170,6 +195,17 @@ export default function Characters() {
createData.appearance = values.appearance;
createData.relationships = values.relationships;
createData.background = values.background;
+
+ // 职业字段
+ if (values.main_career_id) {
+ createData.main_career_id = values.main_career_id;
+ createData.main_career_stage = values.main_career_stage || 1;
+ }
+
+ // 处理副职业数据
+ if (values.sub_career_data && Array.isArray(values.sub_career_data) && values.sub_career_data.length > 0) {
+ createData.sub_careers = JSON.stringify(values.sub_career_data);
+ }
} else {
// 组织字段
createData.organization_type = values.organization_type;
@@ -195,21 +231,45 @@ export default function Characters() {
const handleEditCharacter = (character: Character) => {
setEditingCharacter(character);
- editForm.setFieldsValue(character);
+
+ // 提取副职业数据(包含职业ID和阶段)
+ const subCareerData = character.sub_careers?.map((sc: any) => ({
+ career_id: sc.career_id,
+ stage: sc.stage || 1
+ })) || [];
+
+ editForm.setFieldsValue({
+ ...character,
+ sub_career_data: subCareerData
+ });
setIsEditModalOpen(true);
};
- const handleUpdateCharacter = async (values: CharacterUpdate) => {
+ const handleUpdateCharacter = async (values: any) => {
if (!editingCharacter) return;
try {
- await characterApi.updateCharacter(editingCharacter.id, values);
+ const updateData: any = { ...values };
+
+ // 处理副职业数据
+ const subCareerData = updateData.sub_career_data;
+ delete updateData.sub_career_data;
+
+ // 转换为sub_careers格式
+ if (subCareerData && Array.isArray(subCareerData) && subCareerData.length > 0) {
+ updateData.sub_careers = JSON.stringify(subCareerData);
+ } else {
+ updateData.sub_careers = JSON.stringify([]);
+ }
+
+ await characterApi.updateCharacter(editingCharacter.id, updateData);
message.success('更新成功');
setIsEditModalOpen(false);
editForm.resetFields();
setEditingCharacter(null);
await refreshCharacters();
- } catch {
+ } catch (error) {
+ console.error('更新失败:', error);
message.error('更新失败');
}
};
@@ -657,6 +717,109 @@ export default function Characters() {
+ {!editingCharacter?.is_organization && (mainCareers.length > 0 || subCareers.length > 0) && (
+ <>
+ 职业信息
+ {mainCareers.length > 0 && (
+
+
+
+
+
+
+
+
+ c.id === editForm.getFieldValue('main_career_id'))?.max_stage || 10
+ : 10}
+ style={{ width: '100%' }}
+ placeholder="阶段"
+ />
+
+
+
+ )}
+ {subCareers.length > 0 && (
+
+ {(fields, { add, remove }) => (
+ <>
+
+ 副职业
+
+
+ {fields.map((field) => (
+
+
+
+
+
+
+
+
+ {
+ const careerId = editForm.getFieldValue(['sub_career_data', field.name, 'career_id']);
+ const career = subCareers.find(c => c.id === careerId);
+ return career?.max_stage || 10;
+ })()}
+ placeholder="阶段"
+ style={{ width: '100%' }}
+ />
+
+
+
+
+
+
+ ))}
+
+
+ >
+ )}
+
+ )}
+ >
+ )}
+