update: 1.新增职业管理模块和角色职业关联 2.章节分析自动更新角色职业状态 3.优化章节生成的角色信息构建 4.批量生成强制开启同步分析 5.章节内容批量生成增加系统提示

This commit is contained in:
xiamuceer
2025-12-22 19:53:31 +08:00
parent 6886d903fe
commit b2dec41464
25 changed files with 4635 additions and 89 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
<div align="center"> <div align="center">
![Version](https://img.shields.io/badge/version-1.1.3-blue.svg) ![Version](https://img.shields.io/badge/version-1.1.4-blue.svg)
![Python](https://img.shields.io/badge/python-3.11-blue.svg) ![Python](https://img.shields.io/badge/python-3.11-blue.svg)
![FastAPI](https://img.shields.io/badge/FastAPI-0.109.0-green.svg) ![FastAPI](https://img.shields.io/badge/FastAPI-0.109.0-green.svg)
![React](https://img.shields.io/badge/react-18.3.1-blue.svg) ![React](https://img.shields.io/badge/react-18.3.1-blue.svg)
@@ -77,10 +77,10 @@
- [x] **思维链与章节关系图谱** - 可视化章节逻辑关系 - [x] **思维链与章节关系图谱** - 可视化章节逻辑关系
- [x] **根据分析一键重写** - 根据分析建议重新生成 - [x] **根据分析一键重写** - 根据分析建议重新生成
- [x] **Linux DO 自动创建账号** - OAuth 登录自动生成账号 - [x] **Linux DO 自动创建账号** - OAuth 登录自动生成账号
- [x] **职业等级体系** - 自定义职业和等级系统,支持修仙境界、魔法等级等多种体系
### 📝 规划中功能 ### 📝 规划中功能
- [ ] **职业等级体系** - 自定义职业和等级系统,支持修仙境界、魔法等级等多种体系
- [ ] **角色/组织卡片导入导出** - 单独导出角色和组织卡片,支持跨项目数据共享 - [ ] **角色/组织卡片导入导出** - 单独导出角色和组织卡片,支持跨项目数据共享
- [ ] **伏笔管理** - 智能追踪剧情伏笔,提醒未回收线索,可视化伏笔时间线 - [ ] **伏笔管理** - 智能追踪剧情伏笔,提醒未回收线索,可视化伏笔时间线
- [ ] **提示词工坊** - 社区驱动的 Prompt 模板分享平台,一键导入优质提示词 - [ ] **提示词工坊** - 社区驱动的 Prompt 模板分享平台,一键导入优质提示词
+1 -1
View File
@@ -8,7 +8,7 @@
# 应用配置 # 应用配置
# ========================================== # ==========================================
APP_NAME=MuMuAINovel APP_NAME=MuMuAINovel
APP_VERSION=1.1.3 APP_VERSION=1.1.4
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=8000 APP_PORT=8000
DEBUG=false DEBUG=false
+909
View File
@@ -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": "副职业删除成功"}
+322 -20
View File
@@ -14,6 +14,7 @@ from app.models.chapter import Chapter
from app.models.project import Project from app.models.project import Project
from app.models.outline import Outline from app.models.outline import Outline
from app.models.character import Character from app.models.character import Character
from app.models.career import Career, CharacterCareer
from app.models.generation_history import GenerationHistory from app.models.generation_history import GenerationHistory
from app.models.writing_style import WritingStyle from app.models.writing_style import WritingStyle
from app.models.analysis_task import AnalysisTask from app.models.analysis_task import AnalysisTask
@@ -665,6 +666,114 @@ async def build_smart_chapter_context(
return context_parts 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="检查章节是否可以生成") @router.get("/{chapter_id}/can-generate", summary="检查章节是否可以生成")
async def check_can_generate( async def check_can_generate(
chapter_id: str, chapter_id: str,
@@ -716,7 +825,7 @@ async def analyze_chapter_background(
project_id: str, project_id: str,
task_id: str, task_id: str,
ai_service: AIService ai_service: AIService
): ) -> bool:
""" """
后台异步分析章节(支持并发,使用锁保护数据库写入) 后台异步分析章节(支持并发,使用锁保护数据库写入)
@@ -726,6 +835,9 @@ async def analyze_chapter_background(
project_id: 项目ID project_id: 项目ID
task_id: 任务ID task_id: 任务ID
ai_service: AI服务实例 ai_service: AI服务实例
Returns:
bool: True表示分析成功,False表示分析失败
""" """
db_session = None db_session = None
write_lock = await get_db_write_lock(user_id) write_lock = await get_db_write_lock(user_id)
@@ -942,6 +1054,37 @@ async def analyze_chapter_background(
) )
logger.info(f"✅ 添加{added_count}条记忆到向量库") 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 update_success = False
for retry in range(3): for retry in range(3):
@@ -965,6 +1108,9 @@ async def analyze_chapter_background(
if not update_success: if not update_success:
logger.warning(f"⚠️ 章节分析完成但状态更新失败: {chapter_id}") logger.warning(f"⚠️ 章节分析完成但状态更新失败: {chapter_id}")
# 返回成功状态
return True
except Exception as e: except Exception as e:
logger.error(f"❌ 后台分析异常: {str(e)}", exc_info=True) logger.error(f"❌ 后台分析异常: {str(e)}", exc_info=True)
# 确保任务状态被更新为failed(写操作,需要锁) # 确保任务状态被更新为failed(写操作,需要锁)
@@ -995,6 +1141,10 @@ async def analyze_chapter_background(
await asyncio.sleep(0.1) # 短暂等待后重试 await asyncio.sleep(0.1) # 短暂等待后重试
else: else:
logger.error(f"❌ 任务状态更新失败,已达到最大重试次数: {task_id}") logger.error(f"❌ 任务状态更新失败,已达到最大重试次数: {task_id}")
# 返回失败状态
return False
finally: finally:
if db_session: if db_session:
await db_session.close() await db_session.close()
@@ -1108,15 +1258,41 @@ async def generate_chapter_content_stream(
for o in all_outlines for o in all_outlines
]) ])
# 获取角色信息 # 获取角色信息(包含职业信息)
characters_result = await db_session.execute( characters_result = await db_session.execute(
select(Character).where(Character.project_id == current_chapter.project_id) select(Character).where(Character.project_id == current_chapter.project_id)
) )
characters = characters_result.scalars().all() 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 = "" style_content = ""
@@ -2325,6 +2501,16 @@ async def execute_batch_generation_in_order(
if task.enable_analysis: if task.enable_analysis:
logger.info(f"🔍 开始同步分析章节: 第{chapter.chapter_number}") logger.info(f"🔍 开始同步分析章节: 第{chapter.chapter_number}")
# 分析重试机制(最多3次)
analysis_retry_count = 0
analysis_success = False
last_analysis_error = None
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: async with write_lock:
analysis_task = AnalysisTask( analysis_task = AnalysisTask(
chapter_id=chapter_id, chapter_id=chapter_id,
@@ -2337,8 +2523,8 @@ async def execute_batch_generation_in_order(
await db_session.commit() await db_session.commit()
await db_session.refresh(analysis_task) await db_session.refresh(analysis_task)
# 同步执行分析(等待完成) # 同步执行分析,直接使用返回值判断成功/失败
await analyze_chapter_background( analysis_result = await analyze_chapter_background(
chapter_id=chapter_id, chapter_id=chapter_id,
user_id=user_id, user_id=user_id,
project_id=task.project_id, project_id=task.project_id,
@@ -2346,7 +2532,52 @@ async def execute_batch_generation_in_order(
ai_service=ai_service ai_service=ai_service
) )
logger.info(f"✅ 章节分析完成: 第{chapter.chapter_number}") # 直接根据返回值判断
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 chapter_success = True
@@ -2361,7 +2592,8 @@ async def execute_batch_generation_in_order(
except Exception as e: except Exception as e:
last_error = str(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 retry_count += 1
@@ -2394,7 +2626,13 @@ async def execute_batch_generation_in_order(
task.current_retry_count = 0 task.current_retry_count = 0
await db_session.commit() await db_session.commit()
# ⚠️ 如果启用了同步分析,任何错误都应该中断任务
# 因为章节生成或分析失败会影响后续章节的职业更新和剧情连贯性
if task.enable_analysis:
logger.error(f"🛑 批量生成中断: 因启用同步分析,任何错误都会中断任务以确保职业信息和剧情连贯性")
else:
logger.error(f"🛑 批量生成终止于第{chapter.chapter_number}") logger.error(f"🛑 批量生成终止于第{chapter.chapter_number}")
return return
# 全部完成 # 全部完成
@@ -2469,15 +2707,41 @@ async def generate_single_chapter_for_batch(
for o in all_outlines for o in all_outlines
]) ])
# 获取角色信息 # 获取角色信息(包含职业信息)
characters_result = await db_session.execute( characters_result = await db_session.execute(
select(Character).where(Character.project_id == chapter.project_id) select(Character).where(Character.project_id == chapter.project_id)
) )
characters = characters_result.scalars().all() 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 = "" style_content = ""
@@ -2721,12 +2985,53 @@ async def regenerate_chapter_stream(
) )
project = project_result.scalar_one_or_none() project = project_result.scalar_one_or_none()
# 获取角色信息 # 获取角色信息(包含职业信息)
characters_result = await temp_db.execute( characters_result = await temp_db.execute(
select(Character).where(Character.project_id == chapter.project_id) select(Character).where(Character.project_id == chapter.project_id)
) )
characters = characters_result.scalars().all() 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( outline_result = await temp_db.execute(
select(Outline) select(Outline)
@@ -2779,10 +3084,7 @@ async def regenerate_chapter_stream(
'time_period': project.world_time_period if project else '未设定', 'time_period': project.world_time_period if project else '未设定',
'location': project.world_location if project else '未设定', 'location': project.world_location if project else '未设定',
'atmosphere': project.world_atmosphere if project else '未设定', 'atmosphere': project.world_atmosphere if project else '未设定',
'characters_info': "\n".join([ 'characters_info': characters_info_with_careers,
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 '暂无角色信息',
'chapter_outline': outline.content if outline else chapter.summary or '暂无大纲', 'chapter_outline': outline.content if outline else chapter.summary or '暂无大纲',
'previous_context': '' # 可以后续扩展添加前置章节上下文 'previous_context': '' # 可以后续扩展添加前置章节上下文
} }
+440 -7
View File
@@ -85,7 +85,7 @@ async def get_characters(
) )
characters = result.scalars().all() characters = result.scalars().all()
# 为组织类型的角色填充Organization表的额外字段 # 为组织类型的角色填充Organization表的额外字段,并添加职业信息
enriched_characters = [] enriched_characters = []
for char in characters: for char in characters:
char_dict = { char_dict = {
@@ -110,7 +110,10 @@ async def get_characters(
"power_level": None, "power_level": None,
"location": None, "location": None,
"motto": 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: if char.is_organization:
@@ -156,7 +159,7 @@ async def get_project_characters(
) )
characters = result.scalars().all() characters = result.scalars().all()
# 为组织类型的角色填充Organization表的额外字段 # 为组织类型的角色填充Organization表的额外字段,并添加职业信息
enriched_characters = [] enriched_characters = []
for char in characters: for char in characters:
char_dict = { char_dict = {
@@ -181,7 +184,10 @@ async def get_project_characters(
"power_level": None, "power_level": None,
"location": None, "location": None,
"motto": 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: if char.is_organization:
@@ -232,6 +238,8 @@ async def update_character(
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""更新角色信息""" """更新角色信息"""
from app.models.career import CharacterCareer, Career
result = await db.execute( result = await db.execute(
select(Character).where(Character.id == character_id) select(Character).where(Character.id == character_id)
) )
@@ -260,6 +268,139 @@ async def update_character(
if 'color' in update_data: if 'color' in update_data:
org_fields['color'] = update_data.pop('color') 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 表字段 # 更新 Character 表字段
for field, value in update_data.items(): for field, value in update_data.items():
setattr(character, field, value) setattr(character, field, value)
@@ -290,7 +431,51 @@ async def update_character(
await db.refresh(character) await db.refresh(character)
logger.info(f"更新角色/组织成功:{character.name} (ID: {character_id})") 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="删除角色") @router.delete("/{character_id}", summary="删除角色")
@@ -330,7 +515,10 @@ async def create_character(
- 可以创建普通角色(is_organization=False - 可以创建普通角色(is_organization=False
- 也可以创建组织(is_organization=True - 也可以创建组织(is_organization=True
- 如果创建组织且提供了组织额外字段,会自动创建Organization详情记录 - 如果创建组织且提供了组织额外字段,会自动创建Organization详情记录
- 支持设置主职业和副职业
""" """
from app.models.career import CharacterCareer, Career
# 验证用户权限 # 验证用户权限
user_id = getattr(request.state, 'user_id', None) user_id = getattr(request.state, 'user_id', None)
await verify_project_access(character_data.project_id, user_id, db) 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_purpose=character_data.organization_purpose,
organization_members=character_data.organization_members, organization_members=character_data.organization_members,
traits=character_data.traits, 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) db.add(character)
await db.flush() # 获取character.id await db.flush() # 获取character.id
logger.info(f"✅ 手动创建角色成功:{character.name} (ID: {character.id}, 是否组织: {character.is_organization})") 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详情记录 # 如果是组织,且提供了组织额外字段,自动创建Organization详情记录
if character.is_organization and ( if character.is_organization and (
character_data.power_level is not None or character_data.power_level is not None or
@@ -438,6 +691,50 @@ async def generate_character_stream(
if organization_list: if organization_list:
existing_chars_info += "\n\n已有组织:\n" + "\n".join(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""" project_context = f"""
项目信息: 项目信息:
@@ -449,6 +746,7 @@ async def generate_character_stream(
- 氛围基调:{project.world_atmosphere or '未设定'} - 氛围基调:{project.world_atmosphere or '未设定'}
- 世界规则:{project.world_rules or '未设定'} - 世界规则:{project.world_rules or '未设定'}
{existing_chars_info} {existing_chars_info}
{careers_info}
""" """
user_input = f""" 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 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) 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( character = Character(
project_id=request.project_id, 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_type=character_data.get("organization_type") if is_organization else None,
organization_purpose=character_data.get("organization_purpose") 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, 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) db.add(character)
await db.flush() await db.flush()
logger.info(f"✅ 角色创建成功:{character.name} (ID: {character.id})") 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详情 # 如果是组织,创建Organization详情
if is_organization: if is_organization:
yield await SSEResponse.send_progress("创建组织详情...", 85) yield await SSEResponse.send_progress("创建组织详情...", 85)
+245 -7
View File
@@ -11,6 +11,7 @@ from app.models.project import Project
from app.models.character import Character from app.models.character import Character
from app.models.outline import Outline from app.models.outline import Outline
from app.models.chapter import Chapter 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.relationship import CharacterRelationship, Organization, OrganizationMember, RelationshipType
from app.models.writing_style import WritingStyle from app.models.writing_style import WritingStyle
from app.models.project_default_style import ProjectDefaultStyle from app.models.project_default_style import ProjectDefaultStyle
@@ -239,6 +240,121 @@ async def world_building_generator(
project.wizard_step = 1 project.wizard_step = 1
await db.commit() 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 db_committed = True
# 发送最终结果 # 发送最终结果
@@ -381,6 +497,40 @@ async def characters_generator(
logger.warning(f"MCP工具调用失败(降级处理): {e}") logger.warning(f"MCP工具调用失败(降级处理): {e}")
yield await SSEResponse.send_progress("⚠️ MCP工具暂时不可用,使用基础模式", 12) 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个,平衡效率和成功率 # 优化的分批策略:每批生成3个,平衡效率和成功率
BATCH_SIZE = 3 # 每批生成3个角色 BATCH_SIZE = 3 # 每批生成3个角色
MAX_RETRIES = 3 # 每批最多重试3次 MAX_RETRIES = 3 # 每批最多重试3次
@@ -445,7 +595,7 @@ async def characters_generator(
rules=world_context.get("rules", ""), rules=world_context.get("rules", ""),
theme=theme or project.theme or "", theme=theme or project.theme or "",
genre=genre or project.genre or "", genre=genre or project.genre or "",
requirements=batch_requirements requirements=batch_requirements + careers_context # 添加职业上下文
) )
# 如果有MCP参考资料,增强提示词 # 如果有MCP参考资料,增强提示词
@@ -626,14 +776,102 @@ async def characters_generator(
await db.flush() # 获取所有角色的ID 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: for character, _ in created_characters:
await db.refresh(character) await db.refresh(character)
character_name_to_obj[character.name] = character character_name_to_obj[character.name] = character
logger.info(f"向导创建角色:{character.name} (ID: {character.id}, 是否组织: {character.is_organization})") logger.info(f"向导创建角色:{character.name} (ID: {character.id}, 是否组织: {character.is_organization})")
# 为is_organization=True的角色创建Organization记录 # 第三阶段:为is_organization=True的角色创建Organization记录
yield await SSEResponse.send_progress("创建组织记录...", 87) yield await SSEResponse.send_progress("创建组织记录...", 88)
organization_name_to_obj = {} # 组织名称到Organization对象的映射 organization_name_to_obj = {} # 组织名称到Organization对象的映射
for character, char_data in created_characters: for character, char_data in created_characters:
@@ -669,8 +907,8 @@ async def characters_generator(
for character, _ in created_characters: for character, _ in created_characters:
await db.refresh(character) await db.refresh(character)
# 第阶段:创建角色间的关系 # 第阶段:创建角色间的关系
yield await SSEResponse.send_progress("创建角色关系...", 90) yield await SSEResponse.send_progress("创建角色关系...", 91)
relationships_created = 0 relationships_created = 0
for character, char_data in created_characters: for character, char_data in created_characters:
@@ -737,8 +975,8 @@ async def characters_generator(
logger.warning(f" ❌ 向导创建关系失败:{character.name} - {str(e)}") logger.warning(f" ❌ 向导创建关系失败:{character.name} - {str(e)}")
continue continue
# 第阶段:创建组织成员关系 # 第阶段:创建组织成员关系
yield await SSEResponse.send_progress("创建组织成员关系...", 93) yield await SSEResponse.send_progress("创建组织成员关系...", 94)
members_created = 0 members_created = 0
for character, char_data in created_characters: for character, char_data in created_characters:
+2 -1
View File
@@ -143,7 +143,7 @@ from app.api import (
wizard_stream, relationships, organizations, wizard_stream, relationships, organizations,
auth, users, settings, writing_styles, memories, auth, users, settings, writing_styles, memories,
mcp_plugins, admin, inspiration, prompt_templates, mcp_plugins, admin, inspiration, prompt_templates,
changelog changelog, careers
) )
app.include_router(auth.router, prefix="/api") 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(inspiration.router, prefix="/api")
app.include_router(outlines.router, prefix="/api") app.include_router(outlines.router, prefix="/api")
app.include_router(characters.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(chapters.router, prefix="/api")
app.include_router(relationships.router, prefix="/api") app.include_router(relationships.router, prefix="/api")
app.include_router(organizations.router, prefix="/api") app.include_router(organizations.router, prefix="/api")
+77
View File
@@ -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"<Career(id={self.id}, name={self.name}, type={self.type})>"
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"<CharacterCareer(character_id={self.character_id}, career_id={self.career_id}, type={self.career_type})>"
+6 -1
View File
@@ -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 sqlalchemy.sql import func
from app.database import Base from app.database import Base
import uuid import uuid
@@ -32,6 +32,11 @@ class Character(Base):
organization_purpose = Column(String(500), comment="组织目的") organization_purpose = Column(String(500), comment="组织目的")
organization_members = Column(Text, comment="组织成员(JSON)") 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") avatar_url = Column(String(500), comment="头像URL")
traits = Column(Text, comment="特征标签(JSON)") traits = Column(Text, comment="特征标签(JSON)")
+155
View File
@@ -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="备注")
+15
View File
@@ -45,6 +45,11 @@ class CharacterCreate(BaseModel):
motto: Optional[str] = Field(None, description="组织格言/口号") motto: Optional[str] = Field(None, description="组织格言/口号")
color: 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): class CharacterUpdate(BaseModel):
"""更新角色的请求模型""" """更新角色的请求模型"""
@@ -68,6 +73,11 @@ class CharacterUpdate(BaseModel):
motto: Optional[str] = Field(None, description="组织格言/口号") motto: Optional[str] = Field(None, description="组织格言/口号")
color: 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): class CharacterResponse(CharacterBase):
"""角色响应模型""" """角色响应模型"""
@@ -83,6 +93,11 @@ class CharacterResponse(CharacterBase):
motto: Optional[str] = Field(None, description="组织格言/口号") motto: Optional[str] = Field(None, description="组织格言/口号")
color: 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: class Config:
from_attributes = True from_attributes = True
+123 -2
View File
@@ -297,6 +297,39 @@ class AutoCharacterService:
) -> Dict[str, Any]: ) -> 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( template = await PromptService.get_template(
"AUTO_CHARACTER_GENERATION", "AUTO_CHARACTER_GENERATION",
@@ -315,7 +348,7 @@ class AutoCharacterService:
location=project.world_location or "未设定", location=project.world_location or "未设定",
atmosphere=project.world_atmosphere or "未设定", atmosphere=project.world_atmosphere or "未设定",
rules=project.world_rules or "未设定", rules=project.world_rules or "未设定",
existing_characters=existing_chars_summary, existing_characters=existing_chars_summary + careers_info,
plot_context="根据剧情需要引入的新角色", plot_context="根据剧情需要引入的新角色",
character_specification=json.dumps(spec, ensure_ascii=False, indent=2), character_specification=json.dumps(spec, ensure_ascii=False, indent=2),
mcp_references="" # 暂时不使用MCP增强 mcp_references="" # 暂时不使用MCP增强
@@ -367,6 +400,66 @@ class AutoCharacterService:
is_organization = character_data.get("is_organization", False) 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( character = Character(
project_id=project_id, project_id=project_id,
@@ -381,12 +474,40 @@ class AutoCharacterService:
relationships=character_data.get("relationships_text", ""), relationships=character_data.get("relationships_text", ""),
organization_type=character_data.get("organization_type") if is_organization else None, 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_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) db.add(character)
await db.flush() 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记录 # 如果是组织,创建Organization记录
if is_organization: if is_organization:
org = Organization( org = Organization(
+234
View File
@@ -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()
@@ -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
+139
View File
@@ -745,6 +745,15 @@ class PromptService:
- 特殊技能或知识 - 特殊技能或知识
- 符合世界观设定 - 符合世界观设定
7. **职业信息**(重要 - 如果项目上下文中包含职业列表):
- 仔细查看项目上下文中的"可用主职业""可用副职业"列表
- 主职业:必须从"可用主职业"列表中选择一个最符合角色设定的职业,填写其职业名称(name字段)
- 主职业阶段:根据职业的阶段信息和角色实力,设定合理的当前阶段(1到职业的max_stage)
- 副职业:可以从"可用副职业"列表中选择0-2个,每个包含职业名称和阶段
- 如果项目没有职业列表,则不需要填写career_info字段
- 职业选择必须与角色的背景故事、能力特点和故事定位高度契合
- ⚠️ 重要:请填写职业的名称而非ID,系统会自动匹配
**重要格式要求:** **重要格式要求:**
1. 只返回纯JSON格式,不要包含任何markdown标记、代码块标记或其他说明文字 1. 只返回纯JSON格式,不要包含任何markdown标记、代码块标记或其他说明文字
2. JSON字符串值的内容描述中严禁使用任何特殊符号(包括中文引号、英文引号、方括号、书名号等) 2. JSON字符串值的内容描述中严禁使用任何特殊符号(包括中文引号、英文引号、方括号、书名号等)
@@ -781,7 +790,18 @@ class PromptService:
"joined_at": "加入时间(可选)", "joined_at": "加入时间(可选)",
"status": "active" "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) ### 6. 关键情节点 (Plot Points)
列出3-5个核心情节点: 列出3-5个核心情节点:
@@ -1054,6 +1080,13 @@ class PromptService:
2. keyword必须是从章节原文中逐字复制的文本,长度8-25字 2. keyword必须是从章节原文中逐字复制的文本,长度8-25字
3. keyword用于在前端标注文本位置,所以必须能在原文中精确找到 3. keyword用于在前端标注文本位置,所以必须能在原文中精确找到
4. 不要使用概括性语句或改写后的文字作为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,不要其他说明。""" 只返回JSON,不要其他说明。"""
@@ -1533,6 +1566,7 @@ class PromptService:
3. 性格、背景要有深度和独特性 3. 性格、背景要有深度和独特性
4. 外貌描写要具体生动 4. 外貌描写要具体生动
5. 特长和能力要符合角色定位 5. 特长和能力要符合角色定位
6. **如果【已有角色】中包含职业列表,必须为角色设定职业**(参考下方职业信息要求)
**关系建立指导(非常重要):** **关系建立指导(非常重要):**
- 仔细审视【已有角色】列表,思考新角色与哪些现有角色有联系 - 仔细审视【已有角色】列表,思考新角色与哪些现有角色有联系
@@ -1546,6 +1580,15 @@ class PromptService:
2. JSON字符串值中严禁使用特殊符号(引号、方括号、书名号等) 2. JSON字符串值中严禁使用特殊符号(引号、方括号、书名号等)
3. 所有专有名词直接书写,不使用任何符号包裹 3. 所有专有名词直接书写,不使用任何符号包裹
【职业信息要求(重要)】
如果【已有角色】部分包含"可用主职业列表""可用副职业列表",则必须:
- 仔细查看可用的主职业和副职业列表
- 根据角色的背景、能力、故事定位,选择最合适的职业
- 主职业:从"可用主职业列表"中选择一个,填写职业名称(name字段)
- 主职业阶段:根据职业的阶段信息和角色实力,设定合理的当前阶段
- 副职业:可选择0-2个副职业,每个包含职业名称和阶段
- ⚠️ 重要:必须填写职业的名称而非ID,系统会自动匹配
请严格按照以下JSON格式返回: 请严格按照以下JSON格式返回:
{{ {{
"name": "角色姓名", "name": "角色姓名",
@@ -1574,7 +1617,18 @@ class PromptService:
"rank": 5, "rank": 5,
"loyalty": 80 "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```这样的标记。""" 只返回纯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 @staticmethod
def format_prompt(template: str, **kwargs) -> str: def format_prompt(template: str, **kwargs) -> str:
""" """
@@ -2043,6 +2176,12 @@ class PromptService:
"description": "根据剧情需求自动生成新角色的完整设定", "description": "根据剧情需求自动生成新角色的完整设定",
"parameters": ["title", "genre", "theme", "time_period", "location", "atmosphere", "rules", "parameters": ["title", "genre", "theme", "time_period", "location", "atmosphere", "rules",
"existing_characters", "plot_context", "character_specification", "mcp_references"] "existing_characters", "plot_context", "character_specification", "mcp_references"]
},
"CAREER_SYSTEM_GENERATION": {
"name": "职业体系生成",
"category": "世界构建",
"description": "根据世界观自动生成完整的职业体系,包括主职业和副职业",
"parameters": ["title", "genre", "theme", "time_period", "location", "atmosphere", "rules"]
} }
} }
+256
View File
@@ -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 $$;
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "1.1.3", "version": "1.1.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+2
View File
@@ -8,6 +8,7 @@ import ProjectDetail from './pages/ProjectDetail';
import WorldSetting from './pages/WorldSetting'; import WorldSetting from './pages/WorldSetting';
import Outline from './pages/Outline'; import Outline from './pages/Outline';
import Characters from './pages/Characters'; import Characters from './pages/Characters';
import Careers from './pages/Careers';
import Relationships from './pages/Relationships'; import Relationships from './pages/Relationships';
import Organizations from './pages/Organizations'; import Organizations from './pages/Organizations';
import Chapters from './pages/Chapters'; import Chapters from './pages/Chapters';
@@ -51,6 +52,7 @@ function App() {
<Route path="/project/:projectId" element={<ProtectedRoute><ProjectDetail /></ProtectedRoute>}> <Route path="/project/:projectId" element={<ProtectedRoute><ProjectDetail /></ProtectedRoute>}>
<Route index element={<Navigate to="world-setting" replace />} /> <Route index element={<Navigate to="world-setting" replace />} />
<Route path="world-setting" element={<WorldSetting />} /> <Route path="world-setting" element={<WorldSetting />} />
<Route path="careers" element={<Careers />} />
<Route path="outline" element={<Outline />} /> <Route path="outline" element={<Outline />} />
<Route path="characters" element={<Characters />} /> <Route path="characters" element={<Characters />} />
<Route path="relationships" element={<Relationships />} /> <Route path="relationships" element={<Relationships />} />
+46 -7
View File
@@ -32,6 +32,7 @@ type GenerationStep = 'pending' | 'processing' | 'completed' | 'error';
interface GenerationSteps { interface GenerationSteps {
worldBuilding: GenerationStep; worldBuilding: GenerationStep;
careers: GenerationStep;
characters: GenerationStep; characters: GenerationStep;
outline: GenerationStep; outline: GenerationStep;
} }
@@ -55,6 +56,7 @@ export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
const [errorDetails, setErrorDetails] = useState<string>(''); const [errorDetails, setErrorDetails] = useState<string>('');
const [generationSteps, setGenerationSteps] = useState<GenerationSteps>({ const [generationSteps, setGenerationSteps] = useState<GenerationSteps>({
worldBuilding: 'pending', worldBuilding: 'pending',
careers: 'pending',
characters: 'pending', characters: 'pending',
outline: 'pending' outline: 'pending'
}); });
@@ -126,12 +128,12 @@ export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
if (wizardStep === 0) { if (wizardStep === 0) {
// 从世界观开始 // 从世界观开始
message.info('从世界观步骤开始生成...'); message.info('从世界观步骤开始生成...');
setGenerationSteps({ worldBuilding: 'processing', characters: 'pending', outline: 'pending' }); setGenerationSteps({ worldBuilding: 'processing', careers: 'pending', characters: 'pending', outline: 'pending' });
await resumeFromWorldBuilding(data); await resumeFromWorldBuilding(data);
} else if (wizardStep === 1) { } else if (wizardStep === 1) {
// 世界观已完成,从角色开始 // 世界观已完成,从角色开始
message.info('世界观已完成,从角色步骤继续...'); message.info('世界观已完成,从角色步骤继续...');
setGenerationSteps({ worldBuilding: 'completed', characters: 'processing', outline: 'pending' }); setGenerationSteps({ worldBuilding: 'completed', careers: 'completed', characters: 'processing', outline: 'pending' });
// 获取世界观数据 // 获取世界观数据
const worldResult = { const worldResult = {
@@ -148,7 +150,7 @@ export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
} else if (wizardStep === 2) { } else if (wizardStep === 2) {
// 世界观和角色已完成,从大纲开始 // 世界观和角色已完成,从大纲开始
message.info('世界观和角色已完成,从大纲步骤继续...'); message.info('世界观和角色已完成,从大纲步骤继续...');
setGenerationSteps({ worldBuilding: 'completed', characters: 'completed', outline: 'processing' }); setGenerationSteps({ worldBuilding: 'completed', careers: 'completed', characters: 'completed', outline: 'processing' });
setProgress(66); setProgress(66);
await resumeFromOutline(data, projectIdParam); await resumeFromOutline(data, projectIdParam);
} else { } else {
@@ -334,13 +336,31 @@ export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
}, },
{ {
onProgress: (msg, prog) => { onProgress: (msg, prog) => {
setProgress(Math.floor(prog / 3)); // 世界观生成占0%-20%,职业生成占20%-30%
const baseProgress = Math.floor(prog / 5);
setProgress(baseProgress);
setProgressMessage(msg); 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) => { onResult: (result) => {
setProjectId(result.project_id); setProjectId(result.project_id);
setWorldBuildingResult(result); setWorldBuildingResult(result);
setGenerationSteps(prev => ({ ...prev, worldBuilding: 'completed' })); setGenerationSteps(prev => ({ ...prev, worldBuilding: 'completed' }));
// 职业体系状态已在onProgress中更新
}, },
onError: (error) => { onError: (error) => {
console.error('世界观生成失败:', error); console.error('世界观生成失败:', error);
@@ -383,7 +403,8 @@ export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
}, },
{ {
onProgress: (msg, prog) => { onProgress: (msg, prog) => {
setProgress(33 + Math.floor(prog / 3)); // 角色生成占40%-70%
setProgress(40 + Math.floor(prog * 0.3));
setProgressMessage(msg); setProgressMessage(msg);
}, },
onResult: (result) => { onResult: (result) => {
@@ -416,7 +437,8 @@ export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
}, },
{ {
onProgress: (msg, prog) => { onProgress: (msg, prog) => {
setProgress(66 + Math.floor(prog / 3)); // 大纲生成占70%-100%
setProgress(70 + Math.floor(prog * 0.3));
setProgressMessage(msg); setProgressMessage(msg);
}, },
onResult: () => { onResult: () => {
@@ -511,8 +533,23 @@ export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
}, },
{ {
onProgress: (msg, prog) => { onProgress: (msg, prog) => {
setProgress(Math.floor(prog / 3)); const baseProgress = Math.floor(prog / 5);
setProgress(baseProgress);
setProgressMessage(msg); 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) => { onResult: (result) => {
setProjectId(result.project_id); setProjectId(result.project_id);
@@ -755,6 +792,7 @@ export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
}; };
const hasError = generationSteps.worldBuilding === 'error' || const hasError = generationSteps.worldBuilding === 'error' ||
generationSteps.careers === 'error' ||
generationSteps.characters === 'error' || generationSteps.characters === 'error' ||
generationSteps.outline === 'error'; generationSteps.outline === 'error';
@@ -843,6 +881,7 @@ export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
> >
{[ {[
{ key: 'worldBuilding', label: '生成世界观', step: generationSteps.worldBuilding }, { key: 'worldBuilding', label: '生成世界观', step: generationSteps.worldBuilding },
{ key: 'careers', label: '生成职业体系', step: generationSteps.careers },
{ key: 'characters', label: '生成角色', step: generationSteps.characters }, { key: 'characters', label: '生成角色', step: generationSteps.characters },
{ key: 'outline', label: '生成大纲', step: generationSteps.outline }, { key: 'outline', label: '生成大纲', step: generationSteps.outline },
].map(({ key, label, step }) => { ].map(({ key, label, step }) => {
@@ -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<Props> = ({
characterId,
projectId,
editable = false,
onUpdate
}) => {
const [mainCareer, setMainCareer] = useState<CareerDetail | null>(null);
const [subCareers, setSubCareers] = useState<CareerDetail[]>([]);
const [allCareers, setAllCareers] = useState<Career[]>([]);
const [loading, setLoading] = useState(true);
const [isMainModalOpen, setIsMainModalOpen] = useState(false);
const [isSubModalOpen, setIsSubModalOpen] = useState(false);
const [isProgressModalOpen, setIsProgressModalOpen] = useState(false);
const [selectedCareer, setSelectedCareer] = useState<CareerDetail | null>(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) => (
<div key={career.id} style={{ marginBottom: 16 }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Space>
<TrophyOutlined style={{ color: isMain ? '#1890ff' : '#8c8c8c' }} />
<Text strong={isMain}>{career.career_name}</Text>
{isMain && <Tag color="blue"></Tag>}
</Space>
{editable && (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => openEditProgress(career)} />
{!isMain && (
<Button
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => handleRemoveSubCareer(career.career_id)}
/>
)}
</Space>
)}
</Space>
<div style={{ marginLeft: 24, marginTop: 8 }}>
<Text type="secondary">
{career.stage_name}{career.current_stage}/{career.max_stage}
</Text>
{career.stage_description && (
<Paragraph type="secondary" style={{ fontSize: 12, marginTop: 4 }}>
{career.stage_description}
</Paragraph>
)}
<Progress
percent={career.stage_progress}
size="small"
style={{ marginTop: 8 }}
format={(percent) => `${percent}%`}
/>
{career.started_at && (
<Text type="secondary" style={{ fontSize: 12 }}>
{career.started_at}
</Text>
)}
{career.notes && (
<Paragraph type="secondary" style={{ fontSize: 12, marginTop: 4 }}>
{career.notes}
</Paragraph>
)}
</div>
</div>
);
if (loading) {
return <Card loading />;
}
return (
<>
<Card
title={
<Space>
<TrophyOutlined />
</Space>
}
extra={
editable && !mainCareer && (
<Button
size="small"
icon={<PlusOutlined />}
onClick={() => {
mainForm.resetFields();
setIsMainModalOpen(true);
}}
>
</Button>
)
}
>
{mainCareer ? (
<>
{renderCareerInfo(mainCareer, true)}
{subCareers.length > 0 && (
<>
<Divider />
<Text type="secondary"></Text>
<div style={{ marginTop: 8 }}>
{subCareers.map(career => renderCareerInfo(career, false))}
</div>
</>
)}
{editable && subCareers.length < 5 && (
<div style={{ textAlign: 'center', marginTop: 16 }}>
<Button
size="small"
icon={<PlusOutlined />}
onClick={() => {
subForm.resetFields();
setIsSubModalOpen(true);
}}
>
</Button>
</div>
)}
</>
) : (
<Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '20px 0' }}>
</Text>
)}
</Card>
{/* 设置主职业 */}
<Modal
title="设置主职业"
open={isMainModalOpen}
onCancel={() => setIsMainModalOpen(false)}
footer={null}
>
<Form form={mainForm} layout="vertical" onFinish={handleSetMainCareer}>
<Form.Item label="选择主职业" name="career_id" rules={[{ required: true }]}>
<Select placeholder="选择职业">
{allCareers.filter(c => c.type === 'main').map(career => (
<Select.Option key={career.id} value={career.id}>
{career.name}{career.max_stage}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label="当前阶段" name="current_stage" initialValue={1}>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="开始时间" name="started_at">
<Input placeholder="如:修仙历3000年" />
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setIsMainModalOpen(false)}></Button>
<Button type="primary" htmlType="submit"></Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* 添加副职业 */}
<Modal
title="添加副职业"
open={isSubModalOpen}
onCancel={() => setIsSubModalOpen(false)}
footer={null}
>
<Form form={subForm} layout="vertical" onFinish={handleAddSubCareer}>
<Form.Item label="选择副职业" name="career_id" rules={[{ required: true }]}>
<Select placeholder="选择职业">
{allCareers.filter(c => c.type === 'sub').map(career => (
<Select.Option key={career.id} value={career.id}>
{career.name}{career.max_stage}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label="当前阶段" name="current_stage" initialValue={1}>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="开始时间" name="started_at">
<Input placeholder="如:修仙历3000年" />
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setIsSubModalOpen(false)}></Button>
<Button type="primary" htmlType="submit"></Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* 更新职业进度 */}
<Modal
title="更新职业阶段"
open={isProgressModalOpen}
onCancel={() => setIsProgressModalOpen(false)}
footer={null}
>
{selectedCareer && (
<Form form={progressForm} layout="vertical" onFinish={handleUpdateProgress}>
<Text>{selectedCareer.career_name}</Text>
<Divider style={{ margin: '12px 0' }} />
<Form.Item label="当前阶段" name="current_stage" rules={[{ required: true }]}>
<InputNumber min={1} max={selectedCareer.max_stage} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="阶段进度(0-100" name="stage_progress" rules={[{ required: true }]}>
<InputNumber min={0} max={100} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="到达时间" name="reached_current_stage_at">
<Input placeholder="如:修仙历3001年" />
</Form.Item>
<Form.Item label="备注" name="notes">
<TextArea rows={2} placeholder="如:突破至金丹期" />
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setIsProgressModalOpen(false)}></Button>
<Button type="primary" htmlType="submit"></Button>
</Space>
</Form.Item>
</Form>
)}
</Modal>
</>
);
};
export default CharacterCareerCard;
+433
View File
@@ -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<Career[]>([]);
const [subCareers, setSubCareers] = useState<Career[]>([]);
const [, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isAIModalOpen, setIsAIModalOpen] = useState(false);
const [editingCareer, setEditingCareer] = useState<Career | null>(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) => (
<Card
key={career.id}
title={
<Space>
<TrophyOutlined />
{career.name}
<Tag color={career.source === 'ai' ? 'blue' : 'default'}>
{career.source === 'ai' ? 'AI生成' : '手动创建'}
</Tag>
{career.category && <Tag>{career.category}</Tag>}
</Space>
}
extra={
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => handleOpenModal(career)} />
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(career.id)} />
</Space>
}
style={{ marginBottom: 16 }}
>
<Paragraph ellipsis={{ rows: 2 }}>{career.description || '暂无描述'}</Paragraph>
<Divider style={{ margin: '12px 0' }} />
<Text strong>{career.max_stage}</Text>
<div style={{ maxHeight: 120, overflowY: 'auto', marginTop: 8 }}>
{career.stages.slice(0, 5).map(stage => (
<div key={stage.level} style={{ marginLeft: 16, marginBottom: 4 }}>
<Text type="secondary">{stage.level}. {stage.name}</Text>
{stage.description && <Text type="secondary" style={{ fontSize: 12 }}> - {stage.description}</Text>}
</div>
))}
{career.stages.length > 5 && (
<Text type="secondary" style={{ marginLeft: 16 }}>...{career.stages.length - 5}</Text>
)}
</div>
{career.special_abilities && (
<>
<Divider style={{ margin: '12px 0' }} />
<Text strong></Text>
<Paragraph ellipsis={{ rows: 2 }} style={{ marginTop: 4 }}>{career.special_abilities}</Paragraph>
</>
)}
</Card>
);
const tabItems = [
{
key: 'main',
label: `主职业 (${mainCareers.length})`,
children: mainCareers.length > 0 ? (
<div>{mainCareers.map(renderCareerCard)}</div>
) : (
<Empty description="还没有主职业" />
)
},
{
key: 'sub',
label: `副职业 (${subCareers.length})`,
children: subCareers.length > 0 ? (
<div>{subCareers.map(renderCareerCard)}</div>
) : (
<Empty description="还没有副职业" />
)
}
];
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
{/* 固定头部 */}
<div style={{
padding: '16px 16px 0 16px',
flexShrink: 0
}}>
<div style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: '12px'
}}>
<Title level={3} style={{ margin: 0 }}></Title>
<Space wrap>
<Button
type="dashed"
icon={<ThunderboltOutlined />}
onClick={() => {
aiForm.resetFields();
setIsAIModalOpen(true);
}}
>
AI生成新职业
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => handleOpenModal()}
>
</Button>
</Space>
</div>
</div>
{/* 可滚动的内容区域 */}
<div style={{
flex: 1,
overflow: 'auto',
padding: '0 16px 16px 16px'
}}>
<Tabs items={tabItems} />
</div>
{/* 创建/编辑对话框 */}
<Modal
title={editingCareer ? '编辑职业' : '新增职业'}
open={isModalOpen}
onCancel={() => {
setIsModalOpen(false);
form.resetFields();
}}
footer={null}
width={700}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Row gutter={16}>
<Col span={16}>
<Form.Item label="职业名称" name="name" rules={[{ required: true }]}>
<Input placeholder="如:剑修、炼丹师" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="类型" name="type" rules={[{ required: true }]} initialValue="main">
<Select>
<Select.Option value="main"></Select.Option>
<Select.Option value="sub"></Select.Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item label="职业描述" name="description">
<TextArea rows={2} placeholder="描述这个职业..." />
</Form.Item>
<Form.Item label="职业分类" name="category">
<Input placeholder="如:战斗系、生产系、辅助系" />
</Form.Item>
<Form.Item label="职业阶段" name="stages" tooltip="每行一个阶段,格式:1. 阶段名 - 描述">
<TextArea
rows={8}
placeholder="示例:&#10;1. 炼气期 - 初窥门径&#10;2. 筑基期 - 根基稳固&#10;3. 金丹期 - 凝结金丹"
/>
</Form.Item>
<Form.Item label="职业要求" name="requirements">
<TextArea rows={2} placeholder="需要什么条件才能修炼..." />
</Form.Item>
<Form.Item label="特殊能力" name="special_abilities">
<TextArea rows={2} placeholder="这个职业的特殊能力..." />
</Form.Item>
<Form.Item label="世界观规则" name="worldview_rules">
<TextArea rows={2} placeholder="如何融入世界观..." />
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setIsModalOpen(false)}></Button>
<Button type="primary" htmlType="submit">
{editingCareer ? '更新' : '创建'}
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* AI生成对话框 */}
<Modal
title="AI生成新职业(增量式)"
open={isAIModalOpen}
onCancel={() => setIsAIModalOpen(false)}
footer={null}
>
<Form form={aiForm} layout="vertical" onFinish={handleAIGenerate}>
<Paragraph type="secondary">
AI将分析当前世界观和已有职业
<br />
💡
</Paragraph>
<Divider style={{ margin: '12px 0' }} />
<Form.Item label="本次新增主职业数量" name="main_career_count" initialValue={3}>
<InputNumber min={1} max={10} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="本次新增副职业数量" name="sub_career_count" initialValue={5}>
<InputNumber min={0} max={15} style={{ width: '100%' }} />
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setIsAIModalOpen(false)}></Button>
<Button type="primary" icon={<ThunderboltOutlined />} htmlType="submit">
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* AI生成进度 */}
<SSEProgressModal
visible={aiGenerating}
progress={aiProgress}
message={aiMessage}
title="AI生成新职业中..."
onCancel={() => setAiGenerating(false)}
/>
</div>
);
}
+123 -13
View File
@@ -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; if (!currentProject) return null;
// 获取人称的中文显示文本 // 获取人称的中文显示文本
@@ -282,7 +324,24 @@ export default function Chapters() {
c => c.chapter_number < chapter.chapter_number 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 => { const getGenerateDisabledReason = (chapter: Chapter): string => {
@@ -294,6 +353,7 @@ export default function Chapters() {
c => c.chapter_number < chapter.chapter_number c => c.chapter_number < chapter.chapter_number
); );
// 首先检查是否有未完成内容的章节
const incompleteChapters = previousChapters.filter( const incompleteChapters = previousChapters.filter(
c => !c.content || c.content.trim() === '' c => !c.content || c.content.trim() === ''
); );
@@ -303,6 +363,36 @@ export default function Chapters() {
return `需要先完成前置章节:第 ${numbers}`; 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 ''; return '';
}; };
@@ -638,7 +728,7 @@ export default function Chapters() {
const requestBody: any = { const requestBody: any = {
start_chapter_number: values.startChapterNumber, start_chapter_number: values.startChapterNumber,
count: values.count, count: values.count,
enable_analysis: values.enableAnalysis, enable_analysis: true,
style_id: styleId, style_id: styleId,
target_word_count: wordCount, target_word_count: wordCount,
}; };
@@ -678,6 +768,13 @@ export default function Chapters() {
message.success(`批量生成任务已创建,预计需要 ${result.estimated_time_minutes} 分钟`); message.success(`批量生成任务已创建,预计需要 ${result.estimated_time_minutes} 分钟`);
// 🔔 触发浏览器通知(任务开始)
showBrowserNotification(
'批量生成已启动',
`开始生成 ${result.chapters_to_generate.length} 章,预计需要 ${result.estimated_time_minutes} 分钟`,
'info'
);
// 开始轮询任务状态 // 开始轮询任务状态
startBatchPolling(result.batch_id); startBatchPolling(result.batch_id);
@@ -740,8 +837,20 @@ export default function Chapters() {
if (status.status === 'completed') { if (status.status === 'completed') {
message.success(`批量生成完成!成功生成 ${status.completed}`); message.success(`批量生成完成!成功生成 ${status.completed}`);
// 🔔 触发浏览器通知
showBrowserNotification(
'批量生成完成',
`${currentProject?.title || '项目'}》成功生成 ${status.completed} 章节`,
'success'
);
} else if (status.status === 'failed') { } else if (status.status === 'failed') {
message.error(`批量生成失败:${status.error_message || '未知错误'}`); message.error(`批量生成失败:${status.error_message || '未知错误'}`);
// 🔔 触发浏览器通知
showBrowserNotification(
'批量生成失败',
status.error_message || '未知错误',
'error'
);
} else if (status.status === 'cancelled') { } else if (status.status === 'cancelled') {
message.warning('批量生成已取消'); message.warning('批量生成已取消');
} }
@@ -2199,7 +2308,7 @@ export default function Chapters() {
initialValues={{ initialValues={{
startChapterNumber: sortedChapters.find(ch => !ch.content || ch.content.trim() === '')?.chapter_number || 1, startChapterNumber: sortedChapters.find(ch => !ch.content || ch.content.trim() === '')?.chapter_number || 1,
count: 5, count: 5,
enableAnalysis: false, enableAnalysis: true, // 强制启用同步分析
styleId: selectedStyleId, styleId: selectedStyleId,
targetWordCount: 3000, targetWordCount: 3000,
model: selectedModel, model: selectedModel,
@@ -2323,19 +2432,20 @@ export default function Chapters() {
<Form.Item <Form.Item
label="同步分析" label="同步分析"
name="enableAnalysis" name="enableAnalysis"
tooltip="开启后每章生成完立即分析,会增加约50%耗时,但能提升后续章节质量" tooltip="批量生成必须开启同步分析,确保角色职业信息和剧情状态的连贯性"
> >
<Radio.Group> <Radio.Group disabled>
<Radio value={false}>
<Space direction="vertical" size={0}>
<span></span>
<span style={{ fontSize: 12, color: '#666' }}></span>
</Space>
</Radio>
<Radio value={true}> <Radio value={true}>
<Space direction="vertical" size={0}> <Space direction="vertical" size={0}>
<span></span> <span style={{ fontSize: 12, color: '#52c41a' }}>
<span style={{ fontSize: 12, color: '#ff9800' }}>50%</span>
</span>
<span style={{ fontSize: 12, color: '#52c41a' }}>
</span>
<span style={{ fontSize: 12, color: '#ff9800' }}>
50%
</span>
</Space> </Space>
</Radio> </Radio>
</Radio.Group> </Radio.Group>
+273 -6
View File
@@ -6,13 +6,21 @@ import { useCharacterSync } from '../store/hooks';
import { characterGridConfig } from '../components/CardStyles'; import { characterGridConfig } from '../components/CardStyles';
import { CharacterCard } from '../components/CharacterCard'; import { CharacterCard } from '../components/CharacterCard';
import { SSELoadingOverlay } from '../components/SSELoadingOverlay'; import { SSELoadingOverlay } from '../components/SSELoadingOverlay';
import type { Character, CharacterUpdate } from '../types'; import type { Character } from '../types';
import { characterApi } from '../services/api'; import { characterApi } from '../services/api';
import { SSEPostClient } from '../utils/sseClient'; import { SSEPostClient } from '../utils/sseClient';
import axios from 'axios';
const { Title } = Typography; const { Title } = Typography;
const { TextArea } = Input; 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() { export default function Characters() {
const { currentProject, characters } = useStore(); const { currentProject, characters } = useStore();
@@ -28,6 +36,8 @@ export default function Characters() {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [createType, setCreateType] = useState<'character' | 'organization'>('character'); const [createType, setCreateType] = useState<'character' | 'organization'>('character');
const [editingCharacter, setEditingCharacter] = useState<Character | null>(null); const [editingCharacter, setEditingCharacter] = useState<Character | null>(null);
const [mainCareers, setMainCareers] = useState<Career[]>([]);
const [subCareers, setSubCareers] = useState<Career[]>([]);
const { const {
refreshCharacters, refreshCharacters,
@@ -37,11 +47,26 @@ export default function Characters() {
useEffect(() => { useEffect(() => {
if (currentProject?.id) { if (currentProject?.id) {
refreshCharacters(); refreshCharacters();
fetchCareers();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentProject?.id]); }, [currentProject?.id]);
const [modal, contextHolder] = Modal.useModal(); 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; if (!currentProject) return null;
const handleDeleteCharacter = async (id: string) => { const handleDeleteCharacter = async (id: string) => {
@@ -170,6 +195,17 @@ export default function Characters() {
createData.appearance = values.appearance; createData.appearance = values.appearance;
createData.relationships = values.relationships; createData.relationships = values.relationships;
createData.background = values.background; 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 { } else {
// 组织字段 // 组织字段
createData.organization_type = values.organization_type; createData.organization_type = values.organization_type;
@@ -195,21 +231,45 @@ export default function Characters() {
const handleEditCharacter = (character: Character) => { const handleEditCharacter = (character: Character) => {
setEditingCharacter(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); setIsEditModalOpen(true);
}; };
const handleUpdateCharacter = async (values: CharacterUpdate) => { const handleUpdateCharacter = async (values: any) => {
if (!editingCharacter) return; if (!editingCharacter) return;
try { 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('更新成功'); message.success('更新成功');
setIsEditModalOpen(false); setIsEditModalOpen(false);
editForm.resetFields(); editForm.resetFields();
setEditingCharacter(null); setEditingCharacter(null);
await refreshCharacters(); await refreshCharacters();
} catch { } catch (error) {
console.error('更新失败:', error);
message.error('更新失败'); message.error('更新失败');
} }
}; };
@@ -657,6 +717,109 @@ export default function Characters() {
<TextArea rows={3} placeholder={`描述${editingCharacter?.is_organization ? '组织' : '角色'}的背景故事...`} /> <TextArea rows={3} placeholder={`描述${editingCharacter?.is_organization ? '组织' : '角色'}的背景故事...`} />
</Form.Item> </Form.Item>
{!editingCharacter?.is_organization && (mainCareers.length > 0 || subCareers.length > 0) && (
<>
<Divider></Divider>
{mainCareers.length > 0 && (
<Row gutter={16}>
<Col span={16}>
<Form.Item label="主职业" name="main_career_id" tooltip="角色的主要修炼职业">
<Select placeholder="选择主职业" allowClear>
{mainCareers.map(career => (
<Select.Option key={career.id} value={career.id}>
{career.name}{career.max_stage}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="当前阶段" name="main_career_stage" tooltip="主职业当前修炼到的阶段">
<InputNumber
min={1}
max={editForm.getFieldValue('main_career_id') ?
mainCareers.find(c => c.id === editForm.getFieldValue('main_career_id'))?.max_stage || 10
: 10}
style={{ width: '100%' }}
placeholder="阶段"
/>
</Form.Item>
</Col>
</Row>
)}
{subCareers.length > 0 && (
<Form.List name="sub_career_data">
{(fields, { add, remove }) => (
<>
<div style={{ marginBottom: 8 }}>
<Typography.Text strong></Typography.Text>
</div>
<div style={{ maxHeight: '100px', overflowY: 'auto', overflowX: 'hidden', marginBottom: 8, paddingRight: 8 }}>
{fields.map((field) => (
<Row key={field.key} gutter={8} style={{ marginBottom: 8 }}>
<Col span={16}>
<Form.Item
{...field}
name={[field.name, 'career_id']}
rules={[{ required: true, message: '请选择副职业' }]}
style={{ marginBottom: 0 }}
>
<Select placeholder="选择副职业">
{subCareers.map(career => (
<Select.Option key={career.id} value={career.id}>
{career.name}{career.max_stage}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item
{...field}
name={[field.name, 'stage']}
rules={[{ required: true, message: '请输入阶段' }]}
style={{ marginBottom: 0 }}
>
<InputNumber
min={1}
max={(() => {
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%' }}
/>
</Form.Item>
</Col>
<Col span={2}>
<Button
type="text"
danger
onClick={() => remove(field.name)}
style={{ width: '100%' }}
>
</Button>
</Col>
</Row>
))}
</div>
<Button
type="dashed"
onClick={() => add({ career_id: undefined, stage: 1 })}
block
style={{ marginTop: 8 }}
>
+
</Button>
</>
)}
</Form.List>
)}
</>
)}
<Form.Item> <Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}> <Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => { <Button onClick={() => {
@@ -747,6 +910,110 @@ export default function Characters() {
<Form.Item label="角色背景" name="background"> <Form.Item label="角色背景" name="background">
<TextArea rows={3} placeholder="描述角色的背景故事..." /> <TextArea rows={3} placeholder="描述角色的背景故事..." />
</Form.Item> </Form.Item>
{/* 职业信息 */}
{(mainCareers.length > 0 || subCareers.length > 0) && (
<>
<Divider></Divider>
{mainCareers.length > 0 && (
<Row gutter={16}>
<Col span={16}>
<Form.Item label="主职业" name="main_career_id" tooltip="角色的主要修炼职业">
<Select placeholder="选择主职业" allowClear>
{mainCareers.map(career => (
<Select.Option key={career.id} value={career.id}>
{career.name}{career.max_stage}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="当前阶段" name="main_career_stage" tooltip="主职业当前修炼到的阶段">
<InputNumber
min={1}
max={createForm.getFieldValue('main_career_id') ?
mainCareers.find(c => c.id === createForm.getFieldValue('main_career_id'))?.max_stage || 10
: 10}
style={{ width: '100%' }}
placeholder="阶段"
/>
</Form.Item>
</Col>
</Row>
)}
{subCareers.length > 0 && (
<Form.List name="sub_career_data">
{(fields, { add, remove }) => (
<>
<div style={{ marginBottom: 8 }}>
<Typography.Text strong></Typography.Text>
</div>
<div style={{ maxHeight: '100px', overflowY: 'auto', overflowX: 'hidden', marginBottom: 8, paddingRight: 8 }}>
{fields.map((field) => (
<Row key={field.key} gutter={8} style={{ marginBottom: 8 }}>
<Col span={16}>
<Form.Item
{...field}
name={[field.name, 'career_id']}
rules={[{ required: true, message: '请选择副职业' }]}
style={{ marginBottom: 0 }}
>
<Select placeholder="选择副职业">
{subCareers.map(career => (
<Select.Option key={career.id} value={career.id}>
{career.name}{career.max_stage}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item
{...field}
name={[field.name, 'stage']}
rules={[{ required: true, message: '请输入阶段' }]}
style={{ marginBottom: 0 }}
>
<InputNumber
min={1}
max={(() => {
const careerId = createForm.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%' }}
/>
</Form.Item>
</Col>
<Col span={2}>
<Button
type="text"
danger
onClick={() => remove(field.name)}
style={{ width: '100%' }}
>
</Button>
</Col>
</Row>
))}
</div>
<Button
type="dashed"
onClick={() => add({ career_id: undefined, stage: 1 })}
block
style={{ marginTop: 8 }}
>
+
</Button>
</>
)}
</Form.List>
)}
</>
)}
</> </>
) : ( ) : (
<> <>
+7
View File
@@ -15,6 +15,7 @@ import {
EditOutlined, EditOutlined,
FundOutlined, FundOutlined,
HeartOutlined, HeartOutlined,
TrophyOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useStore } from '../store'; import { useStore } from '../store';
import { useCharacterSync, useOutlineSync, useChapterSync } from '../store/hooks'; import { useCharacterSync, useOutlineSync, useChapterSync } from '../store/hooks';
@@ -99,6 +100,11 @@ export default function ProjectDetail() {
icon: <GlobalOutlined />, icon: <GlobalOutlined />,
label: <Link to={`/project/${projectId}/world-setting`}></Link>, label: <Link to={`/project/${projectId}/world-setting`}></Link>,
}, },
{
key: 'careers',
icon: <TrophyOutlined />,
label: <Link to={`/project/${projectId}/careers`}></Link>,
},
{ {
key: 'characters', key: 'characters',
icon: <TeamOutlined />, icon: <TeamOutlined />,
@@ -150,6 +156,7 @@ export default function ProjectDetail() {
const selectedKey = useMemo(() => { const selectedKey = useMemo(() => {
const path = location.pathname; const path = location.pathname;
if (path.includes('/world-setting')) return 'world-setting'; if (path.includes('/world-setting')) return 'world-setting';
if (path.includes('/careers')) return 'careers';
if (path.includes('/relationships')) return 'relationships'; if (path.includes('/relationships')) return 'relationships';
if (path.includes('/organizations')) return 'organizations'; if (path.includes('/organizations')) return 'organizations';
if (path.includes('/outline')) return 'outline'; if (path.includes('/outline')) return 'outline';
+7
View File
@@ -217,6 +217,13 @@ export interface Character {
location?: string; location?: string;
motto?: string; motto?: string;
color?: string; color?: string;
// 职业相关字段
main_career_id?: string;
main_career_stage?: number;
sub_careers?: Array<{
career_id: string;
stage: number;
}>;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }