update:1.更新AI生成角色/组织实现自动建立关系 2.新增AI续写大纲智能引入角色功能
This commit is contained in:
@@ -960,6 +960,153 @@ async def generate_character_stream(
|
|||||||
db.add(organization)
|
db.add(organization)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
# 处理结构化关系数据(仅针对非组织角色)
|
||||||
|
if not is_organization:
|
||||||
|
relationships_data = character_data.get("relationships", [])
|
||||||
|
if relationships_data and isinstance(relationships_data, list):
|
||||||
|
logger.info(f"📊 开始处理 {len(relationships_data)} 条关系数据")
|
||||||
|
created_rels = 0
|
||||||
|
|
||||||
|
for rel in relationships_data:
|
||||||
|
try:
|
||||||
|
target_name = rel.get("target_character_name")
|
||||||
|
if not target_name:
|
||||||
|
logger.debug(f" ⚠️ 关系缺少target_character_name,跳过")
|
||||||
|
continue
|
||||||
|
|
||||||
|
target_result = await db.execute(
|
||||||
|
select(Character).where(
|
||||||
|
Character.project_id == request.project_id,
|
||||||
|
Character.name == target_name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
target_char = target_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if target_char:
|
||||||
|
# 检查是否已存在相同关系
|
||||||
|
existing_rel = await db.execute(
|
||||||
|
select(CharacterRelationship).where(
|
||||||
|
CharacterRelationship.project_id == request.project_id,
|
||||||
|
CharacterRelationship.character_from_id == character.id,
|
||||||
|
CharacterRelationship.character_to_id == target_char.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing_rel.scalar_one_or_none():
|
||||||
|
logger.debug(f" ℹ️ 关系已存在:{character.name} -> {target_name}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
relationship = CharacterRelationship(
|
||||||
|
project_id=request.project_id,
|
||||||
|
character_from_id=character.id,
|
||||||
|
character_to_id=target_char.id,
|
||||||
|
relationship_name=rel.get("relationship_type", "未知关系"),
|
||||||
|
intimacy_level=rel.get("intimacy_level", 50),
|
||||||
|
description=rel.get("description", ""),
|
||||||
|
started_at=rel.get("started_at"),
|
||||||
|
source="ai"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 匹配预定义关系类型
|
||||||
|
rel_type_result = await db.execute(
|
||||||
|
select(RelationshipType).where(
|
||||||
|
RelationshipType.name == rel.get("relationship_type")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rel_type = rel_type_result.scalar_one_or_none()
|
||||||
|
if rel_type:
|
||||||
|
relationship.relationship_type_id = rel_type.id
|
||||||
|
|
||||||
|
db.add(relationship)
|
||||||
|
created_rels += 1
|
||||||
|
logger.info(f" ✅ 创建关系:{character.name} -> {target_name} ({rel.get('relationship_type')})")
|
||||||
|
else:
|
||||||
|
logger.warning(f" ⚠️ 目标角色不存在:{target_name}")
|
||||||
|
|
||||||
|
except Exception as rel_error:
|
||||||
|
logger.warning(f" ❌ 创建关系失败:{str(rel_error)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"✅ 成功创建 {created_rels} 条关系记录")
|
||||||
|
|
||||||
|
# 处理组织成员关系(仅针对非组织角色)
|
||||||
|
if not is_organization:
|
||||||
|
org_memberships = character_data.get("organization_memberships", [])
|
||||||
|
if org_memberships and isinstance(org_memberships, list):
|
||||||
|
logger.info(f"🏢 开始处理 {len(org_memberships)} 条组织成员关系")
|
||||||
|
created_members = 0
|
||||||
|
|
||||||
|
for membership in org_memberships:
|
||||||
|
try:
|
||||||
|
org_name = membership.get("organization_name")
|
||||||
|
if not org_name:
|
||||||
|
logger.debug(f" ⚠️ 组织成员关系缺少organization_name,跳过")
|
||||||
|
continue
|
||||||
|
|
||||||
|
org_char_result = await db.execute(
|
||||||
|
select(Character).where(
|
||||||
|
Character.project_id == request.project_id,
|
||||||
|
Character.name == org_name,
|
||||||
|
Character.is_organization == True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
org_char = org_char_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if org_char:
|
||||||
|
# 获取或创建Organization记录
|
||||||
|
org_result = await db.execute(
|
||||||
|
select(Organization).where(Organization.character_id == org_char.id)
|
||||||
|
)
|
||||||
|
org = org_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not org:
|
||||||
|
# 如果组织Character存在但Organization不存在,自动创建
|
||||||
|
org = Organization(
|
||||||
|
character_id=org_char.id,
|
||||||
|
project_id=request.project_id,
|
||||||
|
member_count=0
|
||||||
|
)
|
||||||
|
db.add(org)
|
||||||
|
await db.flush()
|
||||||
|
logger.info(f" ℹ️ 自动创建缺失的组织详情:{org_name}")
|
||||||
|
|
||||||
|
# 检查是否已存在成员关系
|
||||||
|
existing_member = await db.execute(
|
||||||
|
select(OrganizationMember).where(
|
||||||
|
OrganizationMember.organization_id == org.id,
|
||||||
|
OrganizationMember.character_id == character.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing_member.scalar_one_or_none():
|
||||||
|
logger.debug(f" ℹ️ 成员关系已存在:{character.name} -> {org_name}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 创建成员关系
|
||||||
|
member = OrganizationMember(
|
||||||
|
organization_id=org.id,
|
||||||
|
character_id=character.id,
|
||||||
|
position=membership.get("position", "成员"),
|
||||||
|
rank=membership.get("rank", 0),
|
||||||
|
loyalty=membership.get("loyalty", 50),
|
||||||
|
joined_at=membership.get("joined_at"),
|
||||||
|
status=membership.get("status", "active"),
|
||||||
|
source="ai"
|
||||||
|
)
|
||||||
|
db.add(member)
|
||||||
|
|
||||||
|
# 更新组织成员计数
|
||||||
|
org.member_count += 1
|
||||||
|
|
||||||
|
created_members += 1
|
||||||
|
logger.info(f" ✅ 添加成员:{character.name} -> {org_name} ({membership.get('position')})")
|
||||||
|
else:
|
||||||
|
logger.warning(f" ⚠️ 组织不存在:{org_name}")
|
||||||
|
|
||||||
|
except Exception as org_error:
|
||||||
|
logger.warning(f" ❌ 添加组织成员失败:{str(org_error)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"✅ 成功创建 {created_members} 条组织成员记录")
|
||||||
|
|
||||||
yield await SSEResponse.send_progress("保存生成历史...", 95)
|
yield await SSEResponse.send_progress("保存生成历史...", 95)
|
||||||
|
|
||||||
# 记录生成历史
|
# 记录生成历史
|
||||||
|
|||||||
+388
-3
@@ -22,7 +22,10 @@ from app.schemas.outline import (
|
|||||||
BatchOutlineExpansionRequest,
|
BatchOutlineExpansionRequest,
|
||||||
BatchOutlineExpansionResponse,
|
BatchOutlineExpansionResponse,
|
||||||
CreateChaptersFromPlansRequest,
|
CreateChaptersFromPlansRequest,
|
||||||
CreateChaptersFromPlansResponse
|
CreateChaptersFromPlansResponse,
|
||||||
|
CharacterPredictionRequest,
|
||||||
|
PredictedCharacter,
|
||||||
|
CharacterPredictionResponse
|
||||||
)
|
)
|
||||||
from app.services.ai_service import AIService
|
from app.services.ai_service import AIService
|
||||||
from app.services.prompt_service import prompt_service, PromptService
|
from app.services.prompt_service import prompt_service, PromptService
|
||||||
@@ -359,6 +362,107 @@ async def delete_outline(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/predict-characters", summary="预测续写所需角色")
|
||||||
|
async def predict_characters(
|
||||||
|
request_data: CharacterPredictionRequest,
|
||||||
|
http_request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user_ai_service: AIService = Depends(get_user_ai_service)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
预测续写大纲时可能需要的新角色
|
||||||
|
|
||||||
|
用于角色确认机制的第一步:在生成大纲前预测角色需求
|
||||||
|
"""
|
||||||
|
# 验证用户权限
|
||||||
|
user_id = getattr(http_request.state, 'user_id', None)
|
||||||
|
project = await verify_project_access(request_data.project_id, user_id, db)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 获取现有大纲
|
||||||
|
existing_result = await db.execute(
|
||||||
|
select(Outline)
|
||||||
|
.where(Outline.project_id == request_data.project_id)
|
||||||
|
.order_by(Outline.order_index)
|
||||||
|
)
|
||||||
|
existing_outlines = existing_result.scalars().all()
|
||||||
|
|
||||||
|
if not existing_outlines:
|
||||||
|
return CharacterPredictionResponse(
|
||||||
|
needs_new_characters=False,
|
||||||
|
reason="项目尚无大纲,无法预测角色需求",
|
||||||
|
character_count=0,
|
||||||
|
predicted_characters=[]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取现有角色
|
||||||
|
characters_result = await db.execute(
|
||||||
|
select(Character).where(Character.project_id == request_data.project_id)
|
||||||
|
)
|
||||||
|
characters = characters_result.scalars().all()
|
||||||
|
|
||||||
|
# 构建已有章节概览
|
||||||
|
all_chapters_brief = ""
|
||||||
|
if len(existing_outlines) > 20:
|
||||||
|
recent_20 = existing_outlines[-20:]
|
||||||
|
all_chapters_brief = "\n".join([
|
||||||
|
f"第{o.order_index}章《{o.title}》"
|
||||||
|
for o in recent_20
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
all_chapters_brief = "\n".join([
|
||||||
|
f"第{o.order_index}章《{o.title}》"
|
||||||
|
for o in existing_outlines
|
||||||
|
])
|
||||||
|
|
||||||
|
# 调用自动角色服务进行预测
|
||||||
|
from app.services.auto_character_service import get_auto_character_service
|
||||||
|
|
||||||
|
auto_char_service = get_auto_character_service(user_ai_service)
|
||||||
|
|
||||||
|
# 使用预测模式(不创建角色,仅分析)
|
||||||
|
last_chapter_number = existing_outlines[-1].order_index
|
||||||
|
auto_result = await auto_char_service.analyze_and_create_characters(
|
||||||
|
project_id=request_data.project_id,
|
||||||
|
outline_content="", # 预测模式不需要大纲内容
|
||||||
|
existing_characters=list(characters),
|
||||||
|
db=db,
|
||||||
|
user_id=user_id,
|
||||||
|
enable_mcp=request_data.enable_mcp,
|
||||||
|
all_chapters_brief=all_chapters_brief,
|
||||||
|
start_chapter=last_chapter_number + 1,
|
||||||
|
chapter_count=request_data.chapter_count,
|
||||||
|
plot_stage=request_data.plot_stage,
|
||||||
|
story_direction=request_data.story_direction,
|
||||||
|
preview_only=True # 新增参数:仅预测不创建
|
||||||
|
)
|
||||||
|
|
||||||
|
# 构建预测响应
|
||||||
|
predicted_characters = []
|
||||||
|
for char_data in auto_result.get("predicted_characters", []):
|
||||||
|
predicted_characters.append(PredictedCharacter(
|
||||||
|
name=char_data.get("name"),
|
||||||
|
role_description=char_data.get("role_description", ""),
|
||||||
|
suggested_role_type=char_data.get("suggested_role_type", "supporting"),
|
||||||
|
importance=char_data.get("importance", "medium"),
|
||||||
|
appearance_chapter=char_data.get("appearance_chapter", last_chapter_number + 1),
|
||||||
|
key_abilities=char_data.get("key_abilities", []),
|
||||||
|
plot_function=char_data.get("plot_function", ""),
|
||||||
|
relationship_suggestions=char_data.get("relationship_suggestions", [])
|
||||||
|
))
|
||||||
|
|
||||||
|
return CharacterPredictionResponse(
|
||||||
|
needs_new_characters=auto_result.get("needs_new_characters", False),
|
||||||
|
reason=auto_result.get("reason", ""),
|
||||||
|
character_count=len(predicted_characters),
|
||||||
|
predicted_characters=predicted_characters
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"角色预测失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"角色预测失败: {str(e)}")
|
||||||
|
|
||||||
@router.post("/generate", response_model=OutlineListResponse, summary="AI生成/续写大纲")
|
@router.post("/generate", response_model=OutlineListResponse, summary="AI生成/续写大纲")
|
||||||
async def generate_outline(
|
async def generate_outline(
|
||||||
request: OutlineGenerateRequest,
|
request: OutlineGenerateRequest,
|
||||||
@@ -696,8 +800,8 @@ async def _continue_outline(
|
|||||||
user_ai_service: AIService,
|
user_ai_service: AIService,
|
||||||
user_id: str = "system"
|
user_id: str = "system"
|
||||||
) -> OutlineListResponse:
|
) -> OutlineListResponse:
|
||||||
"""续写大纲 - 分批生成,每批5章(记忆+MCP增强版)"""
|
"""续写大纲 - 分批生成,每批5章(记忆+MCP+自动角色引入增强版)"""
|
||||||
logger.info(f"续写大纲 - 项目: {project.id}, 已有: {len(existing_outlines)} 章, enable_mcp: {request.enable_mcp}")
|
logger.info(f"续写大纲 - 项目: {project.id}, 已有: {len(existing_outlines)} 章, enable_mcp: {request.enable_mcp}, enable_auto_characters: {request.enable_auto_characters}")
|
||||||
|
|
||||||
# 分析已有大纲
|
# 分析已有大纲
|
||||||
current_chapter_count = len(existing_outlines)
|
current_chapter_count = len(existing_outlines)
|
||||||
@@ -729,6 +833,136 @@ async def _continue_outline(
|
|||||||
}
|
}
|
||||||
stage_instruction = stage_instructions.get(request.plot_stage, "")
|
stage_instruction = stage_instructions.get(request.plot_stage, "")
|
||||||
|
|
||||||
|
# 🎭 【方案A】先角色后大纲:在生成大纲前预测并创建角色
|
||||||
|
if request.enable_auto_characters:
|
||||||
|
# 检查是否有用户确认的角色列表
|
||||||
|
if request.confirmed_characters:
|
||||||
|
# 直接使用用户确认的角色列表创建角色
|
||||||
|
try:
|
||||||
|
from app.services.auto_character_service import get_auto_character_service
|
||||||
|
|
||||||
|
logger.info(f"🎭 【确认模式】用户提供了 {len(request.confirmed_characters)} 个确认的角色,直接创建")
|
||||||
|
|
||||||
|
auto_char_service = get_auto_character_service(user_ai_service)
|
||||||
|
|
||||||
|
for char_data in request.confirmed_characters:
|
||||||
|
try:
|
||||||
|
# 生成角色详细信息
|
||||||
|
character_data = await auto_char_service._generate_character_details(
|
||||||
|
spec=char_data,
|
||||||
|
project=project,
|
||||||
|
existing_characters=list(characters),
|
||||||
|
db=db,
|
||||||
|
user_id=user_id,
|
||||||
|
enable_mcp=request.enable_mcp
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建角色记录
|
||||||
|
character = await auto_char_service._create_character_record(
|
||||||
|
project_id=project.id,
|
||||||
|
character_data=character_data,
|
||||||
|
db=db
|
||||||
|
)
|
||||||
|
|
||||||
|
# 建立关系
|
||||||
|
relationships_data = character_data.get("relationships") or character_data.get("relationships_array", [])
|
||||||
|
if relationships_data:
|
||||||
|
await auto_char_service._create_relationships(
|
||||||
|
new_character=character,
|
||||||
|
relationship_specs=relationships_data,
|
||||||
|
existing_characters=list(characters),
|
||||||
|
project_id=project.id,
|
||||||
|
db=db
|
||||||
|
)
|
||||||
|
|
||||||
|
characters.append(character)
|
||||||
|
logger.info(f"✅ 创建确认的角色: {character.name}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"创建确认的角色失败: {e}", exc_info=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 提交角色到数据库
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# 更新角色信息(供后续大纲生成使用)
|
||||||
|
characters_info = "\n".join([
|
||||||
|
f"- {char.name} ({'组织' if char.is_organization else '角色'}, {char.role_type}): "
|
||||||
|
f"{char.personality[:100] if char.personality else '暂无描述'}"
|
||||||
|
for char in characters
|
||||||
|
])
|
||||||
|
|
||||||
|
logger.info(f"✅ 【确认模式】成功创建 {len(request.confirmed_characters)} 个用户确认的角色")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"⚠️ 【确认模式】创建确认角色失败: {e}", exc_info=True)
|
||||||
|
else:
|
||||||
|
# 🔮 预测模式:仅预测角色,不自动创建,需要用户确认
|
||||||
|
# 抛出特殊异常,在非SSE接口中会被捕获并返回449状态码
|
||||||
|
# 在SSE接口中会被特殊处理
|
||||||
|
try:
|
||||||
|
from app.services.auto_character_service import get_auto_character_service
|
||||||
|
|
||||||
|
logger.info(f"🔮 【预测模式】在生成大纲前预测是否需要新角色(需要用户确认)")
|
||||||
|
|
||||||
|
# 构建已有章节概览
|
||||||
|
all_chapters_brief_for_analysis = ""
|
||||||
|
if len(existing_outlines) > 20:
|
||||||
|
recent_20 = existing_outlines[-20:]
|
||||||
|
all_chapters_brief_for_analysis = "\n".join([
|
||||||
|
f"第{o.order_index}章《{o.title}》"
|
||||||
|
for o in recent_20
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
all_chapters_brief_for_analysis = "\n".join([
|
||||||
|
f"第{o.order_index}章《{o.title}》"
|
||||||
|
for o in existing_outlines
|
||||||
|
])
|
||||||
|
|
||||||
|
# 调用自动角色服务(✅ 设置 preview_only=True,仅预测不创建)
|
||||||
|
auto_char_service = get_auto_character_service(user_ai_service)
|
||||||
|
auto_result = await auto_char_service.analyze_and_create_characters(
|
||||||
|
project_id=project.id,
|
||||||
|
outline_content="", # 预测模式不需要大纲内容
|
||||||
|
existing_characters=list(characters),
|
||||||
|
db=db,
|
||||||
|
user_id=user_id,
|
||||||
|
enable_mcp=request.enable_mcp,
|
||||||
|
all_chapters_brief=all_chapters_brief_for_analysis,
|
||||||
|
start_chapter=last_chapter_number + 1,
|
||||||
|
chapter_count=total_chapters_to_generate,
|
||||||
|
plot_stage=request.plot_stage,
|
||||||
|
story_direction=request.story_direction or "自然延续",
|
||||||
|
preview_only=True # ✅ 关键修复:设置为True,仅预测不创建
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查是否需要新角色
|
||||||
|
if auto_result.get("needs_new_characters") and auto_result.get("predicted_characters"):
|
||||||
|
predicted_count = len(auto_result["predicted_characters"])
|
||||||
|
logger.warning(
|
||||||
|
f"⚠️ 【预测模式】AI预测需要 {predicted_count} 个新角色,需要用户确认!"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 🚨 抛出特殊异常,包含预测的角色信息
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=449, # 449 Retry With
|
||||||
|
detail={
|
||||||
|
"code": "CHARACTER_CONFIRMATION_REQUIRED",
|
||||||
|
"message": "续写需要引入新角色,请先确认角色信息",
|
||||||
|
"predicted_characters": auto_result["predicted_characters"],
|
||||||
|
"reason": auto_result.get("reason", "剧情发展需要新角色"),
|
||||||
|
"chapter_range": f"第{last_chapter_number + 1}-{last_chapter_number + total_chapters_to_generate}章"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(f"✅ 【预测模式】AI判断无需引入新角色,继续生成大纲")
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"⚠️ 【方案A】预测性角色引入失败: {e}", exc_info=True)
|
||||||
|
# 不阻断大纲生成流程
|
||||||
|
|
||||||
# 批量生成
|
# 批量生成
|
||||||
all_new_outlines = []
|
all_new_outlines = []
|
||||||
current_start_chapter = last_chapter_number + 1
|
current_start_chapter = last_chapter_number + 1
|
||||||
@@ -915,6 +1149,7 @@ async def _continue_outline(
|
|||||||
|
|
||||||
logger.info(f"第{batch_num + 1}批生成完成,本批生成{len(batch_outlines)}章")
|
logger.info(f"第{batch_num + 1}批生成完成,本批生成{len(batch_outlines)}章")
|
||||||
|
|
||||||
|
|
||||||
# 返回所有大纲(包括旧的和新的)
|
# 返回所有大纲(包括旧的和新的)
|
||||||
final_result = await db.execute(
|
final_result = await db.execute(
|
||||||
select(Outline)
|
select(Outline)
|
||||||
@@ -1349,6 +1584,156 @@ async def continue_outline_generator(
|
|||||||
}
|
}
|
||||||
stage_instruction = stage_instructions.get(data.get("plot_stage", "development"), "")
|
stage_instruction = stage_instructions.get(data.get("plot_stage", "development"), "")
|
||||||
|
|
||||||
|
# 🎭 【方案A】先角色后大纲:在生成大纲前预测并创建角色
|
||||||
|
enable_auto_characters = data.get("enable_auto_characters", True)
|
||||||
|
confirmed_characters = data.get("confirmed_characters")
|
||||||
|
|
||||||
|
if enable_auto_characters:
|
||||||
|
# 检查是否有用户确认的角色列表
|
||||||
|
if confirmed_characters:
|
||||||
|
# 直接使用用户确认的角色列表创建角色
|
||||||
|
try:
|
||||||
|
yield await SSEResponse.send_progress(
|
||||||
|
f"🎭 【确认模式】创建 {len(confirmed_characters)} 个用户确认的角色...",
|
||||||
|
27
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.services.auto_character_service import get_auto_character_service
|
||||||
|
|
||||||
|
logger.info(f"🎭 【确认模式】用户提供了 {len(confirmed_characters)} 个确认的角色,直接创建")
|
||||||
|
|
||||||
|
auto_char_service = get_auto_character_service(user_ai_service)
|
||||||
|
|
||||||
|
created_count = 0
|
||||||
|
for char_data in confirmed_characters:
|
||||||
|
try:
|
||||||
|
# 生成角色详细信息
|
||||||
|
character_data = await auto_char_service._generate_character_details(
|
||||||
|
spec=char_data,
|
||||||
|
project=project,
|
||||||
|
existing_characters=list(characters),
|
||||||
|
db=db,
|
||||||
|
user_id=user_id,
|
||||||
|
enable_mcp=data.get("enable_mcp", True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建角色记录
|
||||||
|
character = await auto_char_service._create_character_record(
|
||||||
|
project_id=project_id,
|
||||||
|
character_data=character_data,
|
||||||
|
db=db
|
||||||
|
)
|
||||||
|
|
||||||
|
# 建立关系
|
||||||
|
relationships_data = character_data.get("relationships") or character_data.get("relationships_array", [])
|
||||||
|
if relationships_data:
|
||||||
|
await auto_char_service._create_relationships(
|
||||||
|
new_character=character,
|
||||||
|
relationship_specs=relationships_data,
|
||||||
|
existing_characters=list(characters),
|
||||||
|
project_id=project_id,
|
||||||
|
db=db
|
||||||
|
)
|
||||||
|
|
||||||
|
characters.append(character)
|
||||||
|
created_count += 1
|
||||||
|
logger.info(f"✅ 创建确认的角色: {character.name}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"创建确认的角色失败: {e}", exc_info=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 提交角色到数据库
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
yield await SSEResponse.send_progress(
|
||||||
|
f"✅ 【确认模式】成功创建 {created_count} 个角色",
|
||||||
|
28
|
||||||
|
)
|
||||||
|
logger.info(f"✅ 【确认模式】成功创建 {created_count} 个用户确认的角色")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"⚠️ 【确认模式】创建确认角色失败: {e}", exc_info=True)
|
||||||
|
yield await SSEResponse.send_progress(
|
||||||
|
f"⚠️ 角色创建失败,继续生成大纲",
|
||||||
|
28
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 🔮 预测模式:仅预测角色,不自动创建,需要用户确认
|
||||||
|
try:
|
||||||
|
yield await SSEResponse.send_progress(
|
||||||
|
"🔮 【预测模式】检测是否需要新角色(需用户确认)...",
|
||||||
|
27
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.services.auto_character_service import get_auto_character_service
|
||||||
|
|
||||||
|
logger.info(f"🔮 【预测模式】在生成大纲前预测是否需要新角色")
|
||||||
|
|
||||||
|
# 构建已有章节概览
|
||||||
|
all_chapters_brief_for_analysis = ""
|
||||||
|
if len(existing_outlines) > 20:
|
||||||
|
recent_20 = existing_outlines[-20:]
|
||||||
|
all_chapters_brief_for_analysis = "\n".join([
|
||||||
|
f"第{o.order_index}章《{o.title}》"
|
||||||
|
for o in recent_20
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
all_chapters_brief_for_analysis = "\n".join([
|
||||||
|
f"第{o.order_index}章《{o.title}》"
|
||||||
|
for o in existing_outlines
|
||||||
|
])
|
||||||
|
|
||||||
|
# 调用自动角色服务(✅ 设置 preview_only=True)
|
||||||
|
auto_char_service = get_auto_character_service(user_ai_service)
|
||||||
|
auto_result = await auto_char_service.analyze_and_create_characters(
|
||||||
|
project_id=project_id,
|
||||||
|
outline_content="", # 预测模式不需要大纲内容
|
||||||
|
existing_characters=list(characters),
|
||||||
|
db=db,
|
||||||
|
user_id=user_id,
|
||||||
|
enable_mcp=data.get("enable_mcp", True),
|
||||||
|
all_chapters_brief=all_chapters_brief_for_analysis,
|
||||||
|
start_chapter=last_chapter_number + 1,
|
||||||
|
chapter_count=total_chapters_to_generate,
|
||||||
|
plot_stage=data.get("plot_stage", "development"),
|
||||||
|
story_direction=data.get("story_direction", "自然延续"),
|
||||||
|
preview_only=True # ✅ 关键修复:仅预测不创建
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查是否需要新角色
|
||||||
|
if auto_result.get("needs_new_characters") and auto_result.get("predicted_characters"):
|
||||||
|
predicted_count = len(auto_result["predicted_characters"])
|
||||||
|
logger.warning(
|
||||||
|
f"⚠️ 【预测模式】AI预测需要 {predicted_count} 个新角色,需要用户确认!"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 🚨 使用专用事件类型通知前端需要角色确认
|
||||||
|
yield await SSEResponse.send_event(
|
||||||
|
event="character_confirmation_required",
|
||||||
|
data={
|
||||||
|
"message": "续写需要引入新角色,请先确认角色信息",
|
||||||
|
"predicted_characters": auto_result["predicted_characters"],
|
||||||
|
"reason": auto_result.get("reason", "剧情发展需要新角色"),
|
||||||
|
"chapter_range": f"第{last_chapter_number + 1}-{last_chapter_number + total_chapters_to_generate}章"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
yield await SSEResponse.send_progress(
|
||||||
|
"✅ 【预测模式】无需引入新角色,继续生成大纲",
|
||||||
|
28
|
||||||
|
)
|
||||||
|
logger.info(f"✅ 【预测模式】AI判断无需引入新角色")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"⚠️ 【方案A】预测性角色引入失败: {e}", exc_info=True)
|
||||||
|
yield await SSEResponse.send_progress(
|
||||||
|
f"⚠️ 角色预测失败,继续生成大纲",
|
||||||
|
28
|
||||||
|
)
|
||||||
|
# 不阻断大纲生成流程
|
||||||
|
|
||||||
# 批量生成
|
# 批量生成
|
||||||
all_new_outlines = []
|
all_new_outlines = []
|
||||||
current_start_chapter = last_chapter_number + 1
|
current_start_chapter = last_chapter_number + 1
|
||||||
|
|||||||
@@ -1,9 +1,40 @@
|
|||||||
"""大纲相关的Pydantic模型"""
|
"""大纲相关的Pydantic模型"""
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional
|
from typing import Optional, List, Dict, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
# 角色预测相关Schema
|
||||||
|
class CharacterPredictionRequest(BaseModel):
|
||||||
|
"""角色预测请求"""
|
||||||
|
project_id: str
|
||||||
|
start_chapter: int
|
||||||
|
chapter_count: int = 3
|
||||||
|
plot_stage: str = "development"
|
||||||
|
story_direction: Optional[str] = "自然延续"
|
||||||
|
enable_mcp: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class PredictedCharacter(BaseModel):
|
||||||
|
"""预测的角色信息"""
|
||||||
|
name: Optional[str] = None
|
||||||
|
role_description: str
|
||||||
|
suggested_role_type: str
|
||||||
|
importance: str
|
||||||
|
appearance_chapter: int
|
||||||
|
key_abilities: List[str] = []
|
||||||
|
plot_function: str
|
||||||
|
relationship_suggestions: List[Dict[str, str]] = []
|
||||||
|
|
||||||
|
|
||||||
|
class CharacterPredictionResponse(BaseModel):
|
||||||
|
"""角色预测响应"""
|
||||||
|
needs_new_characters: bool
|
||||||
|
reason: str
|
||||||
|
character_count: int
|
||||||
|
predicted_characters: List[PredictedCharacter]
|
||||||
|
|
||||||
|
|
||||||
class OutlineBase(BaseModel):
|
class OutlineBase(BaseModel):
|
||||||
"""大纲基础模型"""
|
"""大纲基础模型"""
|
||||||
title: str = Field(..., description="章节标题")
|
title: str = Field(..., description="章节标题")
|
||||||
@@ -62,6 +93,8 @@ class OutlineGenerateRequest(BaseModel):
|
|||||||
plot_stage: str = Field("development", description="情节阶段: development(发展), climax(高潮), ending(结局)")
|
plot_stage: str = Field("development", description="情节阶段: development(发展), climax(高潮), ending(结局)")
|
||||||
keep_existing: bool = Field(False, description="是否保留现有大纲(续写时)")
|
keep_existing: bool = Field(False, description="是否保留现有大纲(续写时)")
|
||||||
enable_mcp: bool = Field(True, description="是否启用MCP工具增强(搜索情节设计参考)")
|
enable_mcp: bool = Field(True, description="是否启用MCP工具增强(搜索情节设计参考)")
|
||||||
|
enable_auto_characters: bool = Field(True, description="是否启用自动角色引入(根据剧情推进自动创建新角色)")
|
||||||
|
confirmed_characters: Optional[List[Dict[str, Any]]] = Field(None, description="用户确认的角色列表(跳过预测直接创建)")
|
||||||
|
|
||||||
|
|
||||||
class ChapterOutlineGenerateRequest(BaseModel):
|
class ChapterOutlineGenerateRequest(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1,509 @@
|
|||||||
|
"""自动角色引入服务 - 在续写大纲时根据剧情推进自动引入新角色"""
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app.models.character import Character
|
||||||
|
from app.models.relationship import CharacterRelationship, Organization, OrganizationMember, RelationshipType
|
||||||
|
from app.models.project import Project
|
||||||
|
from app.services.ai_service import AIService
|
||||||
|
from app.services.prompt_service import PromptService
|
||||||
|
from app.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AutoCharacterService:
|
||||||
|
"""自动角色引入服务"""
|
||||||
|
|
||||||
|
def __init__(self, ai_service: AIService):
|
||||||
|
self.ai_service = ai_service
|
||||||
|
|
||||||
|
async def analyze_and_create_characters(
|
||||||
|
self,
|
||||||
|
project_id: str,
|
||||||
|
outline_content: str,
|
||||||
|
existing_characters: List[Character],
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: str = None,
|
||||||
|
enable_mcp: bool = True,
|
||||||
|
all_chapters_brief: str = "",
|
||||||
|
start_chapter: int = 1,
|
||||||
|
chapter_count: int = 3,
|
||||||
|
plot_stage: str = "发展",
|
||||||
|
story_direction: str = "继续推进主线剧情",
|
||||||
|
preview_only: bool = False
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
预测性分析并创建需要的新角色(方案A:先角色后大纲)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: 项目ID
|
||||||
|
outline_content: 当前批次大纲内容(用于向后兼容,实际不使用)
|
||||||
|
existing_characters: 现有角色列表
|
||||||
|
db: 数据库会话
|
||||||
|
user_id: 用户ID(用于MCP和自定义提示词)
|
||||||
|
enable_mcp: 是否启用MCP增强
|
||||||
|
all_chapters_brief: 已有章节概览
|
||||||
|
start_chapter: 起始章节号
|
||||||
|
chapter_count: 续写章节数
|
||||||
|
plot_stage: 剧情阶段
|
||||||
|
story_direction: 故事发展方向
|
||||||
|
preview_only: 仅预测不创建(用于角色确认机制)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"new_characters": [角色对象列表], # preview_only=True时为空
|
||||||
|
"relationships_created": [关系对象列表], # preview_only=True时为空
|
||||||
|
"character_count": 新增角色数量,
|
||||||
|
"analysis_result": AI分析结果,
|
||||||
|
"predicted_characters": [预测的角色数据] # 仅preview_only=True时返回
|
||||||
|
"needs_new_characters": bool,
|
||||||
|
"reason": str
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
logger.info(f"🎭 【方案A】预测性分析:检测是否需要引入新角色...")
|
||||||
|
logger.info(f" - 项目ID: {project_id}")
|
||||||
|
logger.info(f" - 续写计划: 第{start_chapter}章起,共{chapter_count}章")
|
||||||
|
logger.info(f" - 剧情阶段: {plot_stage}")
|
||||||
|
logger.info(f" - 发展方向: {story_direction}")
|
||||||
|
logger.info(f" - 现有角色数: {len(existing_characters)}")
|
||||||
|
|
||||||
|
# 1. 获取项目信息
|
||||||
|
project_result = await db.execute(
|
||||||
|
select(Project).where(Project.id == project_id)
|
||||||
|
)
|
||||||
|
project = project_result.scalar_one_or_none()
|
||||||
|
if not project:
|
||||||
|
raise ValueError("项目不存在")
|
||||||
|
|
||||||
|
# 2. 构建现有角色信息摘要
|
||||||
|
existing_chars_summary = self._build_character_summary(existing_characters)
|
||||||
|
|
||||||
|
# 3. AI预测性分析是否需要新角色
|
||||||
|
analysis_result = await self._analyze_character_needs(
|
||||||
|
project=project,
|
||||||
|
outline_content=outline_content, # 保留参数向后兼容
|
||||||
|
existing_chars_summary=existing_chars_summary,
|
||||||
|
db=db,
|
||||||
|
user_id=user_id,
|
||||||
|
enable_mcp=enable_mcp,
|
||||||
|
all_chapters_brief=all_chapters_brief,
|
||||||
|
start_chapter=start_chapter,
|
||||||
|
chapter_count=chapter_count,
|
||||||
|
plot_stage=plot_stage,
|
||||||
|
story_direction=story_direction
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. 判断是否需要创建角色
|
||||||
|
if not analysis_result or not analysis_result.get("needs_new_characters"):
|
||||||
|
logger.info("✅ AI判断:当前剧情不需要引入新角色")
|
||||||
|
return {
|
||||||
|
"new_characters": [],
|
||||||
|
"relationships_created": [],
|
||||||
|
"character_count": 0,
|
||||||
|
"analysis_result": analysis_result,
|
||||||
|
"predicted_characters": [],
|
||||||
|
"needs_new_characters": False,
|
||||||
|
"reason": analysis_result.get("reason", "当前剧情不需要新角色")
|
||||||
|
}
|
||||||
|
|
||||||
|
# 5. 如果是预览模式,仅返回预测结果,不创建角色
|
||||||
|
if preview_only:
|
||||||
|
character_specs = analysis_result.get("character_specifications", [])
|
||||||
|
logger.info(f"🔮 预览模式:预测到 {len(character_specs)} 个角色,不创建数据库记录")
|
||||||
|
return {
|
||||||
|
"new_characters": [],
|
||||||
|
"relationships_created": [],
|
||||||
|
"character_count": 0,
|
||||||
|
"analysis_result": analysis_result,
|
||||||
|
"predicted_characters": character_specs,
|
||||||
|
"needs_new_characters": True,
|
||||||
|
"reason": analysis_result.get("reason", "预测需要新角色")
|
||||||
|
}
|
||||||
|
|
||||||
|
# 6. 批量生成新角色(非预览模式)
|
||||||
|
new_characters = []
|
||||||
|
relationships_created = []
|
||||||
|
|
||||||
|
character_specs = analysis_result.get("character_specifications", [])
|
||||||
|
logger.info(f"🎯 AI建议引入 {len(character_specs)} 个新角色")
|
||||||
|
|
||||||
|
for idx, spec in enumerate(character_specs):
|
||||||
|
try:
|
||||||
|
spec_name = spec.get('name', spec.get('role_description', '未命名'))
|
||||||
|
logger.info(f" [{idx+1}/{len(character_specs)}] 生成角色规格: {spec_name}")
|
||||||
|
logger.debug(f" 角色规格内容: {json.dumps(spec, ensure_ascii=False)}")
|
||||||
|
|
||||||
|
# 生成角色详细信息
|
||||||
|
character_data = await self._generate_character_details(
|
||||||
|
spec=spec,
|
||||||
|
project=project,
|
||||||
|
existing_characters=existing_characters + new_characters, # 包含新创建的
|
||||||
|
db=db,
|
||||||
|
user_id=user_id,
|
||||||
|
enable_mcp=enable_mcp
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f" AI生成的角色数据: {json.dumps(character_data, ensure_ascii=False)[:200]}")
|
||||||
|
|
||||||
|
# 创建角色记录
|
||||||
|
character = await self._create_character_record(
|
||||||
|
project_id=project_id,
|
||||||
|
character_data=character_data,
|
||||||
|
db=db
|
||||||
|
)
|
||||||
|
|
||||||
|
new_characters.append(character)
|
||||||
|
logger.info(f" ✅ 创建新角色: {character.name} ({character.role_type}), ID: {character.id}")
|
||||||
|
|
||||||
|
# 建立关系(兼容两种字段名)
|
||||||
|
relationships_data = character_data.get("relationships") or character_data.get("relationships_array", [])
|
||||||
|
logger.info(f" 🔍 检查关系数据:")
|
||||||
|
logger.info(f" - relationships字段: {character_data.get('relationships')}")
|
||||||
|
logger.info(f" - relationships_array字段: {character_data.get('relationships_array')}")
|
||||||
|
logger.info(f" - 最终使用的数据: {relationships_data}")
|
||||||
|
logger.info(f" - 关系数量: {len(relationships_data) if relationships_data else 0}")
|
||||||
|
|
||||||
|
if relationships_data:
|
||||||
|
logger.info(f" 🔗 开始创建 {len(relationships_data)} 条关系...")
|
||||||
|
for idx, rel in enumerate(relationships_data):
|
||||||
|
logger.info(f" [{idx+1}] {rel.get('target_character_name')} - {rel.get('relationship_type')}")
|
||||||
|
else:
|
||||||
|
logger.warning(f" ⚠️ AI返回的角色数据中没有关系信息!")
|
||||||
|
logger.warning(f" 完整的character_data keys: {list(character_data.keys())}")
|
||||||
|
|
||||||
|
rels = await self._create_relationships(
|
||||||
|
new_character=character,
|
||||||
|
relationship_specs=relationships_data,
|
||||||
|
existing_characters=existing_characters + new_characters,
|
||||||
|
project_id=project_id,
|
||||||
|
db=db
|
||||||
|
)
|
||||||
|
|
||||||
|
relationships_created.extend(rels)
|
||||||
|
logger.info(f" ✅ 实际创建了 {len(rels)} 条关系记录")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f" ❌ 创建角色失败: {e}", exc_info=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 7. 提交事务(注意:这里只flush,让调用方commit)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
logger.info(f"🎉 自动角色引入完成: 新增{len(new_characters)}个角色, {len(relationships_created)}条关系")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"new_characters": new_characters,
|
||||||
|
"relationships_created": relationships_created,
|
||||||
|
"character_count": len(new_characters),
|
||||||
|
"analysis_result": analysis_result
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_character_summary(self, characters: List[Character]) -> str:
|
||||||
|
"""构建现有角色摘要"""
|
||||||
|
if not characters:
|
||||||
|
return "暂无角色"
|
||||||
|
|
||||||
|
summary = []
|
||||||
|
for char in characters:
|
||||||
|
char_type = "组织" if char.is_organization else "角色"
|
||||||
|
role_desc = char.role_type or "未知"
|
||||||
|
personality = (char.personality or "")[:50]
|
||||||
|
summary.append(f"- {char.name} ({char_type}, {role_desc}): {personality}")
|
||||||
|
|
||||||
|
return "\n".join(summary[:20]) # 最多显示20个
|
||||||
|
|
||||||
|
async def _analyze_character_needs(
|
||||||
|
self,
|
||||||
|
project: Project,
|
||||||
|
outline_content: str,
|
||||||
|
existing_chars_summary: str,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: str,
|
||||||
|
enable_mcp: bool,
|
||||||
|
all_chapters_brief: str = "",
|
||||||
|
start_chapter: int = 1,
|
||||||
|
chapter_count: int = 3,
|
||||||
|
plot_stage: str = "发展",
|
||||||
|
story_direction: str = "继续推进主线剧情"
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""AI预测性分析是否需要新角色(方案A)"""
|
||||||
|
|
||||||
|
# 构建分析提示词
|
||||||
|
template = await PromptService.get_template(
|
||||||
|
"AUTO_CHARACTER_ANALYSIS",
|
||||||
|
user_id,
|
||||||
|
db
|
||||||
|
)
|
||||||
|
|
||||||
|
# 使用新的预测性分析参数
|
||||||
|
prompt = PromptService.format_prompt(
|
||||||
|
template,
|
||||||
|
title=project.title,
|
||||||
|
theme=project.theme or "未设定",
|
||||||
|
genre=project.genre or "未设定",
|
||||||
|
time_period=project.world_time_period or "未设定",
|
||||||
|
location=project.world_location or "未设定",
|
||||||
|
atmosphere=project.world_atmosphere or "未设定",
|
||||||
|
existing_characters=existing_chars_summary,
|
||||||
|
all_chapters_brief=all_chapters_brief,
|
||||||
|
start_chapter=start_chapter,
|
||||||
|
chapter_count=chapter_count,
|
||||||
|
plot_stage=plot_stage,
|
||||||
|
story_direction=story_direction
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 调用AI分析
|
||||||
|
if enable_mcp and user_id:
|
||||||
|
result = await self.ai_service.generate_text_with_mcp(
|
||||||
|
prompt=prompt,
|
||||||
|
user_id=user_id,
|
||||||
|
db_session=db,
|
||||||
|
enable_mcp=True,
|
||||||
|
max_tool_rounds=1
|
||||||
|
)
|
||||||
|
content = result.get("content", "")
|
||||||
|
else:
|
||||||
|
result = await self.ai_service.generate_text(prompt=prompt)
|
||||||
|
content = result.get("content", "") if isinstance(result, dict) else result
|
||||||
|
|
||||||
|
# 清理并解析JSON
|
||||||
|
cleaned = content.strip()
|
||||||
|
if cleaned.startswith("```json"):
|
||||||
|
cleaned = cleaned[7:]
|
||||||
|
if cleaned.startswith("```"):
|
||||||
|
cleaned = cleaned[3:]
|
||||||
|
if cleaned.endswith("```"):
|
||||||
|
cleaned = cleaned[:-3]
|
||||||
|
cleaned = cleaned.strip()
|
||||||
|
|
||||||
|
analysis = json.loads(cleaned)
|
||||||
|
logger.info(f" ✅ AI分析完成: needs_new_characters={analysis.get('needs_new_characters')}")
|
||||||
|
|
||||||
|
return analysis
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f" ❌ 角色需求分析JSON解析失败: {e}")
|
||||||
|
logger.error(f" 响应内容: {content[:500]}")
|
||||||
|
return {"needs_new_characters": False}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f" ❌ 角色需求分析失败: {e}")
|
||||||
|
return {"needs_new_characters": False}
|
||||||
|
|
||||||
|
async def _generate_character_details(
|
||||||
|
self,
|
||||||
|
spec: Dict[str, Any],
|
||||||
|
project: Project,
|
||||||
|
existing_characters: List[Character],
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: str,
|
||||||
|
enable_mcp: bool
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""生成角色详细信息"""
|
||||||
|
|
||||||
|
# 构建角色生成提示词
|
||||||
|
template = await PromptService.get_template(
|
||||||
|
"AUTO_CHARACTER_GENERATION",
|
||||||
|
user_id,
|
||||||
|
db
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_chars_summary = self._build_character_summary(existing_characters)
|
||||||
|
|
||||||
|
prompt = PromptService.format_prompt(
|
||||||
|
template,
|
||||||
|
title=project.title,
|
||||||
|
genre=project.genre or "未设定",
|
||||||
|
theme=project.theme or "未设定",
|
||||||
|
time_period=project.world_time_period or "未设定",
|
||||||
|
location=project.world_location or "未设定",
|
||||||
|
atmosphere=project.world_atmosphere or "未设定",
|
||||||
|
rules=project.world_rules or "未设定",
|
||||||
|
existing_characters=existing_chars_summary,
|
||||||
|
plot_context="根据剧情需要引入的新角色",
|
||||||
|
character_specification=json.dumps(spec, ensure_ascii=False, indent=2),
|
||||||
|
mcp_references="" # 暂时不使用MCP增强
|
||||||
|
)
|
||||||
|
|
||||||
|
# 调用AI生成
|
||||||
|
try:
|
||||||
|
if enable_mcp and user_id:
|
||||||
|
result = await self.ai_service.generate_text_with_mcp(
|
||||||
|
prompt=prompt,
|
||||||
|
user_id=user_id,
|
||||||
|
db_session=db,
|
||||||
|
enable_mcp=True,
|
||||||
|
max_tool_rounds=1
|
||||||
|
)
|
||||||
|
content = result.get("content", "")
|
||||||
|
else:
|
||||||
|
result = await self.ai_service.generate_text(prompt=prompt)
|
||||||
|
content = result.get("content", "") if isinstance(result, dict) else result
|
||||||
|
|
||||||
|
# 解析JSON
|
||||||
|
cleaned = content.strip()
|
||||||
|
if cleaned.startswith("```json"):
|
||||||
|
cleaned = cleaned[7:]
|
||||||
|
if cleaned.startswith("```"):
|
||||||
|
cleaned = cleaned[3:]
|
||||||
|
if cleaned.endswith("```"):
|
||||||
|
cleaned = cleaned[:-3]
|
||||||
|
|
||||||
|
character_data = json.loads(cleaned.strip())
|
||||||
|
char_name = character_data.get('name', '未知')
|
||||||
|
logger.info(f" ✅ 角色详情生成成功: {char_name}")
|
||||||
|
logger.debug(f" 角色数据字段: {list(character_data.keys())}")
|
||||||
|
|
||||||
|
# 确保关键字段存在
|
||||||
|
if 'name' not in character_data or not character_data['name']:
|
||||||
|
logger.warning(f" ⚠️ AI返回的角色数据缺少name字段,使用规格中的信息")
|
||||||
|
character_data['name'] = spec.get('name', f"新角色{spec.get('role_description', '')[:10]}")
|
||||||
|
|
||||||
|
return character_data
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f" ❌ 生成角色详情失败: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _create_character_record(
|
||||||
|
self,
|
||||||
|
project_id: str,
|
||||||
|
character_data: Dict[str, Any],
|
||||||
|
db: AsyncSession
|
||||||
|
) -> Character:
|
||||||
|
"""创建角色数据库记录"""
|
||||||
|
|
||||||
|
is_organization = character_data.get("is_organization", False)
|
||||||
|
|
||||||
|
# 创建角色
|
||||||
|
character = Character(
|
||||||
|
project_id=project_id,
|
||||||
|
name=character_data.get("name", "未命名角色"),
|
||||||
|
age=str(character_data.get("age", "")),
|
||||||
|
gender=character_data.get("gender"),
|
||||||
|
is_organization=is_organization,
|
||||||
|
role_type=character_data.get("role_type", "supporting"),
|
||||||
|
personality=character_data.get("personality", ""),
|
||||||
|
background=character_data.get("background", ""),
|
||||||
|
appearance=character_data.get("appearance", ""),
|
||||||
|
relationships=character_data.get("relationships_text", ""),
|
||||||
|
organization_type=character_data.get("organization_type") if is_organization else None,
|
||||||
|
organization_purpose=character_data.get("organization_purpose") if is_organization else None,
|
||||||
|
traits=json.dumps(character_data.get("traits", []), ensure_ascii=False) if character_data.get("traits") else None
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(character)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# 如果是组织,创建Organization记录
|
||||||
|
if is_organization:
|
||||||
|
org = Organization(
|
||||||
|
character_id=character.id,
|
||||||
|
project_id=project_id,
|
||||||
|
member_count=0,
|
||||||
|
power_level=character_data.get("power_level", 50),
|
||||||
|
location=character_data.get("location"),
|
||||||
|
motto=character_data.get("motto"),
|
||||||
|
color=character_data.get("color")
|
||||||
|
)
|
||||||
|
db.add(org)
|
||||||
|
await db.flush()
|
||||||
|
logger.info(f" ✅ 创建组织详情: {character.name}")
|
||||||
|
|
||||||
|
return character
|
||||||
|
|
||||||
|
async def _create_relationships(
|
||||||
|
self,
|
||||||
|
new_character: Character,
|
||||||
|
relationship_specs: List[Dict[str, Any]],
|
||||||
|
existing_characters: List[Character],
|
||||||
|
project_id: str,
|
||||||
|
db: AsyncSession
|
||||||
|
) -> List[CharacterRelationship]:
|
||||||
|
"""创建角色关系"""
|
||||||
|
|
||||||
|
if not relationship_specs:
|
||||||
|
return []
|
||||||
|
|
||||||
|
relationships = []
|
||||||
|
|
||||||
|
for rel_spec in relationship_specs:
|
||||||
|
try:
|
||||||
|
target_name = rel_spec.get("target_character_name")
|
||||||
|
if not target_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 查找目标角色
|
||||||
|
target_char = next(
|
||||||
|
(c for c in existing_characters if c.name == target_name),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|
||||||
|
if not target_char:
|
||||||
|
logger.warning(f" ⚠️ 目标角色不存在: {target_name}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检查关系是否已存在
|
||||||
|
existing_rel = await db.execute(
|
||||||
|
select(CharacterRelationship).where(
|
||||||
|
CharacterRelationship.project_id == project_id,
|
||||||
|
CharacterRelationship.character_from_id == new_character.id,
|
||||||
|
CharacterRelationship.character_to_id == target_char.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing_rel.scalar_one_or_none():
|
||||||
|
logger.debug(f" ℹ️ 关系已存在: {new_character.name} -> {target_name}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 创建关系
|
||||||
|
relationship = CharacterRelationship(
|
||||||
|
project_id=project_id,
|
||||||
|
character_from_id=new_character.id,
|
||||||
|
character_to_id=target_char.id,
|
||||||
|
relationship_name=rel_spec.get("relationship_type", "未知关系"),
|
||||||
|
intimacy_level=rel_spec.get("intimacy_level", 50),
|
||||||
|
description=rel_spec.get("description", ""),
|
||||||
|
status=rel_spec.get("status", "active"),
|
||||||
|
source="auto" # 标记为自动生成
|
||||||
|
)
|
||||||
|
|
||||||
|
# 尝试匹配预定义关系类型
|
||||||
|
rel_type_name = rel_spec.get("relationship_type")
|
||||||
|
if rel_type_name:
|
||||||
|
rel_type_result = await db.execute(
|
||||||
|
select(RelationshipType).where(
|
||||||
|
RelationshipType.name == rel_type_name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rel_type = rel_type_result.scalar_one_or_none()
|
||||||
|
if rel_type:
|
||||||
|
relationship.relationship_type_id = rel_type.id
|
||||||
|
|
||||||
|
db.add(relationship)
|
||||||
|
relationships.append(relationship)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f" ✅ 创建关系: {new_character.name} -> {target_name} "
|
||||||
|
f"({rel_spec.get('relationship_type', '未知')})"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f" ❌ 创建关系失败: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return relationships
|
||||||
|
|
||||||
|
|
||||||
|
# 全局实例缓存
|
||||||
|
_auto_character_service_instance: Optional[AutoCharacterService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_auto_character_service(ai_service: AIService) -> AutoCharacterService:
|
||||||
|
"""获取自动角色服务实例(单例模式)"""
|
||||||
|
global _auto_character_service_instance
|
||||||
|
if _auto_character_service_instance is None:
|
||||||
|
_auto_character_service_instance = AutoCharacterService(ai_service)
|
||||||
|
return _auto_character_service_instance
|
||||||
@@ -1437,6 +1437,193 @@ class PromptService:
|
|||||||
3. 每个plot_summary必须是200-300字的详细描述
|
3. 每个plot_summary必须是200-300字的详细描述
|
||||||
4. 所有内容描述中严禁使用任何特殊符号"""
|
4. 所有内容描述中严禁使用任何特殊符号"""
|
||||||
|
|
||||||
|
# 自动角色引入 - 预测性分析提示词(方案A)
|
||||||
|
AUTO_CHARACTER_ANALYSIS = """你是专业的小说角色设计顾问。请根据即将续写的剧情方向,预测是否需要引入新角色。
|
||||||
|
|
||||||
|
【项目信息】
|
||||||
|
- 书名:{title}
|
||||||
|
- 类型:{genre}
|
||||||
|
- 主题:{theme}
|
||||||
|
|
||||||
|
【世界观】
|
||||||
|
- 时间背景:{time_period}
|
||||||
|
- 地理位置:{location}
|
||||||
|
- 氛围基调:{atmosphere}
|
||||||
|
|
||||||
|
【已有角色】
|
||||||
|
{existing_characters}
|
||||||
|
|
||||||
|
【已有章节概览】
|
||||||
|
{all_chapters_brief}
|
||||||
|
|
||||||
|
【续写计划】
|
||||||
|
- 起始章节:第{start_chapter}章
|
||||||
|
- 续写数量:{chapter_count}章
|
||||||
|
- 剧情阶段:{plot_stage}
|
||||||
|
- 发展方向:{story_direction}
|
||||||
|
|
||||||
|
【预测性分析任务】
|
||||||
|
请预测在接下来的{chapter_count}章中,根据剧情发展方向和阶段,是否需要引入新角色。
|
||||||
|
|
||||||
|
**分析要点:**
|
||||||
|
1. **剧情需求预测**:根据发展方向,哪些场景、冲突需要新角色参与
|
||||||
|
2. **角色充分性**:现有角色是否足以支撑即将发生的剧情
|
||||||
|
3. **引入时机**:新角色应该在哪个章节登场最合适
|
||||||
|
4. **重要性判断**:新角色对后续剧情的影响程度
|
||||||
|
|
||||||
|
**预测依据:**
|
||||||
|
- 剧情阶段的典型角色需求(如:高潮阶段可能需要强力对手)
|
||||||
|
- 故事发展方向的逻辑需要(如:进入新地点需要当地角色)
|
||||||
|
- 冲突升级的角色需求(如:更强的反派、意外的盟友)
|
||||||
|
- 世界观扩展的需要(如:新组织、新势力的代表)
|
||||||
|
|
||||||
|
**如果需要新角色,请详细说明:**
|
||||||
|
- 角色定位和作用
|
||||||
|
- 建议的角色类型和重要性
|
||||||
|
- 预计登场时机
|
||||||
|
- 与现有角色的潜在关系
|
||||||
|
|
||||||
|
**输出格式(纯JSON):**
|
||||||
|
{{
|
||||||
|
"needs_new_characters": true,
|
||||||
|
"reason": "预测分析原因(150-200字),说明为什么即将的剧情需要新角色",
|
||||||
|
"character_count": 2,
|
||||||
|
"character_specifications": [
|
||||||
|
{{
|
||||||
|
"name": "建议的角色名字(可选,如果有明确想法)",
|
||||||
|
"role_description": "角色在剧情中的定位和作用(100-150字)",
|
||||||
|
"suggested_role_type": "supporting/antagonist/protagonist",
|
||||||
|
"importance": "high/medium/low",
|
||||||
|
"appearance_chapter": {start_chapter},
|
||||||
|
"key_abilities": ["能力1", "能力2"],
|
||||||
|
"plot_function": "在剧情中的具体功能(如:作为主要对手、提供关键信息等)",
|
||||||
|
"relationship_suggestions": [
|
||||||
|
{{
|
||||||
|
"target_character": "现有角色名",
|
||||||
|
"relationship_type": "建议的关系类型",
|
||||||
|
"reason": "为什么建立这种关系"
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
或者如果不需要新角色:
|
||||||
|
{{
|
||||||
|
"needs_new_characters": false,
|
||||||
|
"reason": "现有角色足以支撑即将的剧情发展,说明理由"
|
||||||
|
}}
|
||||||
|
|
||||||
|
**重要提示:**
|
||||||
|
- 这是预测性分析,不是基于已生成内容的事后分析
|
||||||
|
- 要考虑剧情的自然发展和节奏
|
||||||
|
- 不要为了引入角色而引入,确保必要性
|
||||||
|
- 优先考虑角色的长期作用,而非一次性功能
|
||||||
|
|
||||||
|
只返回纯JSON,不要有markdown标记或其他文字。"""
|
||||||
|
|
||||||
|
# 自动角色引入 - 生成提示词
|
||||||
|
AUTO_CHARACTER_GENERATION = """你是专业的角色设定师。请根据以下信息,为小说生成新角色的完整设定。
|
||||||
|
|
||||||
|
【项目信息】
|
||||||
|
- 书名:{title}
|
||||||
|
- 类型:{genre}
|
||||||
|
- 主题:{theme}
|
||||||
|
|
||||||
|
【世界观】
|
||||||
|
- 时间背景:{time_period}
|
||||||
|
- 地理位置:{location}
|
||||||
|
- 氛围基调:{atmosphere}
|
||||||
|
- 世界规则:{rules}
|
||||||
|
|
||||||
|
【已有角色】
|
||||||
|
{existing_characters}
|
||||||
|
|
||||||
|
【剧情上下文】
|
||||||
|
{plot_context}
|
||||||
|
|
||||||
|
【角色规格要求】
|
||||||
|
{character_specification}
|
||||||
|
|
||||||
|
【MCP工具参考】
|
||||||
|
{mcp_references}
|
||||||
|
|
||||||
|
【生成要求】
|
||||||
|
1. 角色必须符合剧情需求和世界观设定
|
||||||
|
2. **必须分析新角色与已有角色的关系**,至少建立1-3个有意义的关系
|
||||||
|
3. 性格、背景要有深度和独特性
|
||||||
|
4. 外貌描写要具体生动
|
||||||
|
5. 特长和能力要符合角色定位
|
||||||
|
|
||||||
|
**关系建立指导(非常重要):**
|
||||||
|
- 仔细审视【已有角色】列表,思考新角色与哪些现有角色有联系
|
||||||
|
- 根据剧情需求,建立合理的角色关系(如:主角的新朋友、反派的手下、某角色的亲属等)
|
||||||
|
- 每个关系都要有明确的类型、亲密度和描述
|
||||||
|
- 关系应该服务于剧情发展,推动故事前进
|
||||||
|
- 如果新角色是组织成员,记得填写organization_memberships
|
||||||
|
|
||||||
|
**重要格式要求:**
|
||||||
|
1. 只返回纯JSON格式,不要包含任何markdown标记或其他说明文字
|
||||||
|
2. JSON字符串值中严禁使用特殊符号(引号、方括号、书名号等)
|
||||||
|
3. 所有专有名词直接书写,不使用任何符号包裹
|
||||||
|
|
||||||
|
请严格按照以下JSON格式返回:
|
||||||
|
{{
|
||||||
|
"name": "角色姓名",
|
||||||
|
"age": 25,
|
||||||
|
"gender": "男/女/其他",
|
||||||
|
"role_type": "supporting",
|
||||||
|
"personality": "性格特点的详细描述(100-200字)",
|
||||||
|
"background": "背景故事的详细描述(100-200字)",
|
||||||
|
"appearance": "外貌描述(50-100字)",
|
||||||
|
"traits": ["特长1", "特长2", "特长3"],
|
||||||
|
"relationships_text": "用自然语言描述该角色与其他角色的关系网络",
|
||||||
|
|
||||||
|
"relationships": [
|
||||||
|
{{
|
||||||
|
"target_character_name": "已存在的角色名称",
|
||||||
|
"relationship_type": "关系类型(如:朋友、师父、敌人、父亲等)",
|
||||||
|
"intimacy_level": 75,
|
||||||
|
"description": "关系的具体描述,说明他们如何认识、关系如何发展",
|
||||||
|
"status": "active"
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
"organization_memberships": [
|
||||||
|
{{
|
||||||
|
"organization_name": "已存在的组织名称",
|
||||||
|
"position": "职位",
|
||||||
|
"rank": 5,
|
||||||
|
"loyalty": 80
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
**关系类型参考(从中选择或自定义):**
|
||||||
|
- 家族关系:父亲、母亲、兄弟、姐妹、子女、配偶、恋人、亲戚
|
||||||
|
- 社交关系:师父、徒弟、朋友、挚友、同学、同事、邻居、知己、酒友
|
||||||
|
- 职业关系:上司、下属、合作伙伴、客户、雇主、员工
|
||||||
|
- 敌对关系:敌人、仇人、竞争对手、宿敌、死敌
|
||||||
|
|
||||||
|
**重要说明:**
|
||||||
|
1. **relationships数组必填**:至少要有1-3个与已有角色的关系(除非确实没有合理的关联)
|
||||||
|
2. **target_character_name必须精确匹配**:只能引用【已有角色】列表中的角色名称
|
||||||
|
3. organization_memberships只能引用已存在的组织名称
|
||||||
|
4. intimacy_level是-100到100的整数:
|
||||||
|
- 80-100:至亲、挚友、深爱
|
||||||
|
- 50-79:亲密、友好
|
||||||
|
- 0-49:一般、普通
|
||||||
|
- -1到-49:不和、敌视
|
||||||
|
- -50到-100:仇恨、死敌
|
||||||
|
5. loyalty是0-100的整数(仅用于组织成员)
|
||||||
|
6. status默认为"active",表示当前关系状态
|
||||||
|
|
||||||
|
**关系建立示例:**
|
||||||
|
- 如果新角色是主角的新队友,应该与主角建立"队友"或"朋友"关系
|
||||||
|
- 如果新角色是反派的手下,应该与反派建立"上司-下属"关系
|
||||||
|
- 如果新角色与某角色有血缘,应该建立家族关系
|
||||||
|
|
||||||
|
只返回纯JSON对象,不要有```json```这样的标记。"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def format_prompt(template: str, **kwargs) -> str:
|
def format_prompt(template: str, **kwargs) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -2306,6 +2493,20 @@ class PromptService:
|
|||||||
"category": "MCP增强",
|
"category": "MCP增强",
|
||||||
"description": "使用MCP工具搜索资料辅助角色设计",
|
"description": "使用MCP工具搜索资料辅助角色设计",
|
||||||
"parameters": ["title", "genre", "theme", "time_period", "location"]
|
"parameters": ["title", "genre", "theme", "time_period", "location"]
|
||||||
|
},
|
||||||
|
"AUTO_CHARACTER_ANALYSIS": {
|
||||||
|
"name": "自动角色分析",
|
||||||
|
"category": "自动角色引入",
|
||||||
|
"description": "分析新生成的大纲,判断是否需要引入新角色",
|
||||||
|
"parameters": ["title", "genre", "theme", "time_period", "location", "atmosphere",
|
||||||
|
"existing_characters", "new_outlines", "start_chapter", "end_chapter"]
|
||||||
|
},
|
||||||
|
"AUTO_CHARACTER_GENERATION": {
|
||||||
|
"name": "自动角色生成",
|
||||||
|
"category": "自动角色引入",
|
||||||
|
"description": "根据剧情需求自动生成新角色的完整设定",
|
||||||
|
"parameters": ["title", "genre", "theme", "time_period", "location", "atmosphere", "rules",
|
||||||
|
"existing_characters", "plot_context", "character_specification", "mcp_references"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,17 @@ class SSEResponse:
|
|||||||
"data": data
|
"data": data
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def send_event(event: str, data: Dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
发送自定义事件类型的SSE消息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: 事件类型名称
|
||||||
|
data: 事件数据
|
||||||
|
"""
|
||||||
|
return SSEResponse.format_sse(data, event=event)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def send_error(error: str, code: int = 500) -> str:
|
async def send_error(error: str, code: int = 500) -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 174 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
@@ -18,6 +18,7 @@ import Settings from './pages/Settings';
|
|||||||
import MCPPlugins from './pages/MCPPlugins';
|
import MCPPlugins from './pages/MCPPlugins';
|
||||||
import UserManagement from './pages/UserManagement';
|
import UserManagement from './pages/UserManagement';
|
||||||
import PromptTemplates from './pages/PromptTemplates';
|
import PromptTemplates from './pages/PromptTemplates';
|
||||||
|
import Sponsor from './pages/Sponsor';
|
||||||
// import Polish from './pages/Polish';
|
// import Polish from './pages/Polish';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import AuthCallback from './pages/AuthCallback';
|
import AuthCallback from './pages/AuthCallback';
|
||||||
@@ -57,6 +58,7 @@ function App() {
|
|||||||
<Route path="chapters" element={<Chapters />} />
|
<Route path="chapters" element={<Chapters />} />
|
||||||
<Route path="chapter-analysis" element={<ChapterAnalysis />} />
|
<Route path="chapter-analysis" element={<ChapterAnalysis />} />
|
||||||
<Route path="writing-styles" element={<WritingStyles />} />
|
<Route path="writing-styles" element={<WritingStyles />} />
|
||||||
|
<Route path="sponsor" element={<Sponsor />} />
|
||||||
{/* <Route path="polish" element={<Polish />} /> */}
|
{/* <Route path="polish" element={<Polish />} /> */}
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
+493
-158
@@ -9,6 +9,30 @@ import { SSEProgressModal } from '../components/SSEProgressModal';
|
|||||||
import { outlineApi, chapterApi, projectApi } from '../services/api';
|
import { outlineApi, chapterApi, projectApi } from '../services/api';
|
||||||
import type { OutlineExpansionResponse, BatchOutlineExpansionResponse } from '../types';
|
import type { OutlineExpansionResponse, BatchOutlineExpansionResponse } from '../types';
|
||||||
|
|
||||||
|
// 角色预测数据类型
|
||||||
|
interface PredictedCharacter {
|
||||||
|
name?: string;
|
||||||
|
role_description: string;
|
||||||
|
suggested_role_type: string;
|
||||||
|
importance: string;
|
||||||
|
appearance_chapter: number;
|
||||||
|
key_abilities: string[];
|
||||||
|
plot_function: string;
|
||||||
|
relationship_suggestions: Array<{
|
||||||
|
target_character_name: string;
|
||||||
|
relationship_type: string;
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterConfirmationData {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
predicted_characters: PredictedCharacter[];
|
||||||
|
reason: string;
|
||||||
|
chapter_range: string;
|
||||||
|
}
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
|
|
||||||
export default function Outline() {
|
export default function Outline() {
|
||||||
@@ -25,6 +49,12 @@ export default function Outline() {
|
|||||||
// ✅ 新增:记录每个大纲的展开状态
|
// ✅ 新增:记录每个大纲的展开状态
|
||||||
const [outlineExpandStatus, setOutlineExpandStatus] = useState<Record<string, boolean>>({});
|
const [outlineExpandStatus, setOutlineExpandStatus] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
// 角色确认相关状态
|
||||||
|
const [characterConfirmData, setCharacterConfirmData] = useState<CharacterConfirmationData | null>(null);
|
||||||
|
const [characterConfirmVisible, setCharacterConfirmVisible] = useState(false);
|
||||||
|
const [pendingGenerateData, setPendingGenerateData] = useState<any>(null);
|
||||||
|
const [selectedCharacterIndices, setSelectedCharacterIndices] = useState<number[]>([]);
|
||||||
|
|
||||||
// 缓存批量展开的规划数据,避免重复AI调用
|
// 缓存批量展开的规划数据,避免重复AI调用
|
||||||
const [cachedBatchExpansionResponse, setCachedBatchExpansionResponse] = useState<BatchOutlineExpansionResponse | null>(null);
|
const [cachedBatchExpansionResponse, setCachedBatchExpansionResponse] = useState<BatchOutlineExpansionResponse | null>(null);
|
||||||
|
|
||||||
@@ -84,6 +114,15 @@ export default function Outline() {
|
|||||||
loadExpandStatus();
|
loadExpandStatus();
|
||||||
}, [outlines]);
|
}, [outlines]);
|
||||||
|
|
||||||
|
// 当角色确认数据变化时,初始化选中状态(默认全选)
|
||||||
|
useEffect(() => {
|
||||||
|
if (characterConfirmData) {
|
||||||
|
setSelectedCharacterIndices(
|
||||||
|
characterConfirmData.predicted_characters.map((_, idx) => idx)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [characterConfirmData]);
|
||||||
|
|
||||||
// 移除事件监听,避免无限循环
|
// 移除事件监听,避免无限循环
|
||||||
// Hook 内部已经更新了 store,不需要再次刷新
|
// Hook 内部已经更新了 store,不需要再次刷新
|
||||||
|
|
||||||
@@ -164,6 +203,7 @@ export default function Outline() {
|
|||||||
story_direction?: string;
|
story_direction?: string;
|
||||||
plot_stage?: 'development' | 'climax' | 'ending';
|
plot_stage?: 'development' | 'climax' | 'ending';
|
||||||
keep_existing?: boolean;
|
keep_existing?: boolean;
|
||||||
|
enable_auto_characters?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGenerate = async (values: GenerateFormValues) => {
|
const handleGenerate = async (values: GenerateFormValues) => {
|
||||||
@@ -195,7 +235,8 @@ export default function Outline() {
|
|||||||
requirements: values.requirements,
|
requirements: values.requirements,
|
||||||
mode: values.mode || 'auto',
|
mode: values.mode || 'auto',
|
||||||
story_direction: values.story_direction,
|
story_direction: values.story_direction,
|
||||||
plot_stage: values.plot_stage || 'development'
|
plot_stage: values.plot_stage || 'development',
|
||||||
|
enable_auto_characters: values.enable_auto_characters !== undefined ? values.enable_auto_characters : true
|
||||||
};
|
};
|
||||||
|
|
||||||
// 只有在用户选择了模型时才添加model参数
|
// 只有在用户选择了模型时才添加model参数
|
||||||
@@ -225,7 +266,22 @@ export default function Outline() {
|
|||||||
onResult: (data: any) => {
|
onResult: (data: any) => {
|
||||||
console.log('生成完成,结果:', data);
|
console.log('生成完成,结果:', data);
|
||||||
},
|
},
|
||||||
|
onCharacterConfirmation: (data: any) => {
|
||||||
|
// ✨ 新增:处理角色确认事件
|
||||||
|
console.log('收到角色确认请求:', data);
|
||||||
|
// 关闭SSE进度Modal
|
||||||
|
setSSEModalVisible(false);
|
||||||
|
setIsGenerating(false);
|
||||||
|
|
||||||
|
// 保存待处理的生成数据
|
||||||
|
setPendingGenerateData(requestData);
|
||||||
|
|
||||||
|
// 显示角色确认对话框
|
||||||
|
setCharacterConfirmData(data);
|
||||||
|
setCharacterConfirmVisible(true);
|
||||||
|
},
|
||||||
onError: (error: string) => {
|
onError: (error: string) => {
|
||||||
|
// 现在只处理真正的错误
|
||||||
message.error(`生成失败: ${error}`);
|
message.error(`生成失败: ${error}`);
|
||||||
setSSEModalVisible(false);
|
setSSEModalVisible(false);
|
||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
@@ -259,7 +315,7 @@ export default function Outline() {
|
|||||||
const settings = await settingsResponse.json();
|
const settings = await settingsResponse.json();
|
||||||
const { api_key, api_base_url, api_provider } = settings;
|
const { api_key, api_base_url, api_provider } = settings;
|
||||||
|
|
||||||
let loadedModels: Array<{value: string, label: string}> = [];
|
let loadedModels: Array<{ value: string, label: string }> = [];
|
||||||
let defaultModel: string | undefined = undefined;
|
let defaultModel: string | undefined = undefined;
|
||||||
|
|
||||||
if (api_key && api_base_url) {
|
if (api_key && api_base_url) {
|
||||||
@@ -301,6 +357,7 @@ export default function Outline() {
|
|||||||
keep_existing: true,
|
keep_existing: true,
|
||||||
theme: currentProject.theme || '',
|
theme: currentProject.theme || '',
|
||||||
model: defaultModel, // 添加默认模型
|
model: defaultModel, // 添加默认模型
|
||||||
|
enable_auto_characters: false, // 默认禁用自动角色引入
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hasOutlines && (
|
{hasOutlines && (
|
||||||
@@ -408,6 +465,20 @@ export default function Outline() {
|
|||||||
<Form.Item label="其他要求" name="requirements">
|
<Form.Item label="其他要求" name="requirements">
|
||||||
<TextArea rows={2} placeholder="其他特殊要求(可选)" />
|
<TextArea rows={2} placeholder="其他特殊要求(可选)" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 自动角色引入开关 - 仅在续写模式显示 */}
|
||||||
|
{isContinue && (
|
||||||
|
<Form.Item
|
||||||
|
label="智能角色引入"
|
||||||
|
name="enable_auto_characters"
|
||||||
|
tooltip="AI会根据剧情发展自动判断是否需要引入新角色,并自动创建角色卡片和建立关系"
|
||||||
|
>
|
||||||
|
<Radio.Group buttonStyle="solid">
|
||||||
|
<Radio.Button value={true}>启用</Radio.Button>
|
||||||
|
<Radio.Button value={false}>禁用</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -1518,8 +1589,272 @@ export default function Outline() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 处理角色确认 - 用户同意创建角色
|
||||||
|
const handleConfirmCharacters = async (selectedCharacters: PredictedCharacter[]) => {
|
||||||
|
if (!pendingGenerateData) {
|
||||||
|
message.error('生成数据丢失,请重新操作');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setCharacterConfirmVisible(false);
|
||||||
|
setIsGenerating(true);
|
||||||
|
|
||||||
|
// 显示进度Modal
|
||||||
|
setSSEProgress(0);
|
||||||
|
setSSEMessage('正在创建确认的角色...');
|
||||||
|
setSSEModalVisible(true);
|
||||||
|
|
||||||
|
// 准备请求数据,添加确认的角色
|
||||||
|
const requestData = {
|
||||||
|
...pendingGenerateData,
|
||||||
|
confirmed_characters: selectedCharacters
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('携带确认角色重新请求:', requestData);
|
||||||
|
|
||||||
|
// 重新发起SSE请求
|
||||||
|
const apiUrl = `/api/outlines/generate-stream`;
|
||||||
|
const client = new SSEPostClient(apiUrl, requestData, {
|
||||||
|
onProgress: (msg: string, progress: number) => {
|
||||||
|
setSSEMessage(msg);
|
||||||
|
setSSEProgress(progress);
|
||||||
|
},
|
||||||
|
onResult: (data: any) => {
|
||||||
|
console.log('生成完成,结果:', data);
|
||||||
|
},
|
||||||
|
onError: (error: string) => {
|
||||||
|
message.error(`生成失败: ${error}`);
|
||||||
|
setSSEModalVisible(false);
|
||||||
|
setIsGenerating(false);
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
message.success('大纲生成完成!');
|
||||||
|
setSSEModalVisible(false);
|
||||||
|
setIsGenerating(false);
|
||||||
|
// 清理状态
|
||||||
|
setPendingGenerateData(null);
|
||||||
|
setCharacterConfirmData(null);
|
||||||
|
// 刷新大纲列表
|
||||||
|
refreshOutlines();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.connect();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('确认角色失败:', error);
|
||||||
|
message.error('操作失败');
|
||||||
|
setSSEModalVisible(false);
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理角色确认 - 用户拒绝创建角色
|
||||||
|
const handleRejectCharacters = async () => {
|
||||||
|
if (!pendingGenerateData) {
|
||||||
|
message.error('生成数据丢失,请重新操作');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setCharacterConfirmVisible(false);
|
||||||
|
setIsGenerating(true);
|
||||||
|
|
||||||
|
// 显示进度Modal
|
||||||
|
setSSEProgress(0);
|
||||||
|
setSSEMessage('跳过角色创建,开始续写大纲...');
|
||||||
|
setSSEModalVisible(true);
|
||||||
|
|
||||||
|
// 准备请求数据,禁用自动角色引入
|
||||||
|
const requestData = {
|
||||||
|
...pendingGenerateData,
|
||||||
|
enable_auto_characters: false // 禁用自动角色引入
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('跳过角色创建,重新请求:', requestData);
|
||||||
|
|
||||||
|
// 重新发起SSE请求
|
||||||
|
const apiUrl = `/api/outlines/generate-stream`;
|
||||||
|
const client = new SSEPostClient(apiUrl, requestData, {
|
||||||
|
onProgress: (msg: string, progress: number) => {
|
||||||
|
setSSEMessage(msg);
|
||||||
|
setSSEProgress(progress);
|
||||||
|
},
|
||||||
|
onResult: (data: any) => {
|
||||||
|
console.log('生成完成,结果:', data);
|
||||||
|
},
|
||||||
|
onError: (error: string) => {
|
||||||
|
message.error(`生成失败: ${error}`);
|
||||||
|
setSSEModalVisible(false);
|
||||||
|
setIsGenerating(false);
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
message.success('大纲生成完成!');
|
||||||
|
setSSEModalVisible(false);
|
||||||
|
setIsGenerating(false);
|
||||||
|
// 清理状态
|
||||||
|
setPendingGenerateData(null);
|
||||||
|
setCharacterConfirmData(null);
|
||||||
|
// 刷新大纲列表
|
||||||
|
refreshOutlines();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.connect();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('跳过角色创建失败:', error);
|
||||||
|
message.error('操作失败');
|
||||||
|
setSSEModalVisible(false);
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染角色确认对话框
|
||||||
|
const renderCharacterConfirmModal = () => {
|
||||||
|
if (!characterConfirmData) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<ExclamationCircleOutlined style={{ color: '#faad14' }} />
|
||||||
|
<span>确认引入新角色</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={characterConfirmVisible}
|
||||||
|
onOk={() => {
|
||||||
|
const selectedCharacters = characterConfirmData.predicted_characters.filter(
|
||||||
|
(_, idx) => selectedCharacterIndices.includes(idx)
|
||||||
|
);
|
||||||
|
handleConfirmCharacters(selectedCharacters);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认操作',
|
||||||
|
content: '是否跳过角色创建,直接续写大纲?',
|
||||||
|
okText: '跳过角色,继续续写',
|
||||||
|
cancelText: '返回选择',
|
||||||
|
onOk: handleRejectCharacters
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
width={800}
|
||||||
|
centered
|
||||||
|
okText={`确认创建选中的 ${selectedCharacterIndices.length} 个角色`}
|
||||||
|
cancelText="跳过角色创建"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 16, padding: 12, background: '#fffbe6', borderRadius: 4, border: '1px solid #ffe58f' }}>
|
||||||
|
<div style={{ fontWeight: 500, marginBottom: 8, color: '#d48806' }}>
|
||||||
|
AI 分析结果
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#666', marginBottom: 8 }}>
|
||||||
|
{characterConfirmData.reason}
|
||||||
|
</div>
|
||||||
|
<Tag color="blue">{characterConfirmData.chapter_range}</Tag>
|
||||||
|
<Tag color="green">{characterConfirmData.predicted_characters.length} 个预测角色</Tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => setSelectedCharacterIndices(
|
||||||
|
characterConfirmData.predicted_characters.map((_, idx) => idx)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => setSelectedCharacterIndices([])}
|
||||||
|
>
|
||||||
|
全不选
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<List
|
||||||
|
dataSource={characterConfirmData.predicted_characters}
|
||||||
|
renderItem={(character, index) => (
|
||||||
|
<List.Item
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
background: selectedCharacterIndices.includes(index) ? '#f0f5ff' : 'transparent',
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 4,
|
||||||
|
marginBottom: 8,
|
||||||
|
border: selectedCharacterIndices.includes(index) ? '1px solid #1890ff' : '1px solid #f0f0f0',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedCharacterIndices.includes(index)) {
|
||||||
|
setSelectedCharacterIndices(selectedCharacterIndices.filter(i => i !== index));
|
||||||
|
} else {
|
||||||
|
setSelectedCharacterIndices([...selectedCharacterIndices, index]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||||
|
<Space>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedCharacterIndices.includes(index)}
|
||||||
|
onChange={() => { }}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontWeight: 500, fontSize: 16 }}>
|
||||||
|
{character.name || character.role_description}
|
||||||
|
</span>
|
||||||
|
<Tag color="blue">{character.suggested_role_type}</Tag>
|
||||||
|
<Tag color="orange">{character.importance}</Tag>
|
||||||
|
</Space>
|
||||||
|
<Tag>第{character.appearance_chapter}章登场</Tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 8, color: '#666' }}>
|
||||||
|
<strong>剧情作用:</strong>{character.plot_function}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{character.key_abilities && character.key_abilities.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<strong>关键能力:</strong>
|
||||||
|
<Space wrap style={{ marginLeft: 8 }}>
|
||||||
|
{character.key_abilities.map((ability, idx) => (
|
||||||
|
<Tag key={idx} color="purple">{ability}</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{character.relationship_suggestions && character.relationship_suggestions.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<strong>建议关系:</strong>
|
||||||
|
<Space wrap style={{ marginLeft: 8 }}>
|
||||||
|
{character.relationship_suggestions.map((rel, idx) => (
|
||||||
|
<Tag key={idx} color="cyan">
|
||||||
|
{rel.target_character_name} - {rel.relationship_type}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* 角色确认对话框 */}
|
||||||
|
{renderCharacterConfirmModal()}
|
||||||
|
|
||||||
{/* 批量展开预览 Modal */}
|
{/* 批量展开预览 Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
title={
|
title={
|
||||||
@@ -1549,177 +1884,177 @@ export default function Outline() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
{/* 固定头部 */}
|
{/* 固定头部 */}
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
top: 0,
|
top: 0,
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
padding: isMobile ? '12px 0' : '16px 0',
|
padding: isMobile ? '12px 0' : '16px 0',
|
||||||
marginBottom: isMobile ? 12 : 16,
|
marginBottom: isMobile ? 12 : 16,
|
||||||
borderBottom: '1px solid #f0f0f0',
|
borderBottom: '1px solid #f0f0f0',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: isMobile ? 'column' : 'row',
|
flexDirection: isMobile ? 'column' : 'row',
|
||||||
gap: isMobile ? 12 : 0,
|
gap: isMobile ? 12 : 0,
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: isMobile ? 'stretch' : 'center'
|
alignItems: isMobile ? 'stretch' : 'center'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}>故事大纲</h2>
|
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}>故事大纲</h2>
|
||||||
{currentProject?.outline_mode && (
|
{currentProject?.outline_mode && (
|
||||||
<Tag color={currentProject.outline_mode === 'one-to-one' ? 'blue' : 'green'} style={{ width: 'fit-content' }}>
|
<Tag color={currentProject.outline_mode === 'one-to-one' ? 'blue' : 'green'} style={{ width: 'fit-content' }}>
|
||||||
{currentProject.outline_mode === 'one-to-one' ? '传统模式 (1→1)' : '细化模式 (1→N)'}
|
{currentProject.outline_mode === 'one-to-one' ? '传统模式 (1→1)' : '细化模式 (1→N)'}
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
<Space size="small" wrap={isMobile}>
|
||||||
|
<Button
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={showManualCreateOutlineModal}
|
||||||
|
block={isMobile}
|
||||||
|
>
|
||||||
|
手动创建
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<ThunderboltOutlined />}
|
||||||
|
onClick={showGenerateModal}
|
||||||
|
loading={isGenerating}
|
||||||
|
block={isMobile}
|
||||||
|
>
|
||||||
|
{isMobile ? 'AI生成/续写' : 'AI生成/续写大纲'}
|
||||||
|
</Button>
|
||||||
|
{outlines.length > 0 && currentProject?.outline_mode === 'one-to-many' && (
|
||||||
|
<Tooltip title="将所有大纲展开为多章,实现从大纲到章节的一对多关系">
|
||||||
|
<Button
|
||||||
|
icon={<AppstoreAddOutlined />}
|
||||||
|
onClick={handleBatchExpandOutlines}
|
||||||
|
loading={isExpanding}
|
||||||
|
disabled={isGenerating}
|
||||||
|
>
|
||||||
|
{isMobile ? '批量展开' : '批量展开为多章'}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
<Space size="small" wrap={isMobile}>
|
|
||||||
<Button
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={showManualCreateOutlineModal}
|
|
||||||
block={isMobile}
|
|
||||||
>
|
|
||||||
手动创建
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<ThunderboltOutlined />}
|
|
||||||
onClick={showGenerateModal}
|
|
||||||
loading={isGenerating}
|
|
||||||
block={isMobile}
|
|
||||||
>
|
|
||||||
{isMobile ? 'AI生成/续写' : 'AI生成/续写大纲'}
|
|
||||||
</Button>
|
|
||||||
{outlines.length > 0 && currentProject?.outline_mode === 'one-to-many' && (
|
|
||||||
<Tooltip title="将所有大纲展开为多章,实现从大纲到章节的一对多关系">
|
|
||||||
<Button
|
|
||||||
icon={<AppstoreAddOutlined />}
|
|
||||||
onClick={handleBatchExpandOutlines}
|
|
||||||
loading={isExpanding}
|
|
||||||
disabled={isGenerating}
|
|
||||||
>
|
|
||||||
{isMobile ? '批量展开' : '批量展开为多章'}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 可滚动内容区域 */}
|
{/* 可滚动内容区域 */}
|
||||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||||
{outlines.length === 0 ? (
|
{outlines.length === 0 ? (
|
||||||
<Empty description="还没有大纲,开始创建吧!" />
|
<Empty description="还没有大纲,开始创建吧!" />
|
||||||
) : (
|
) : (
|
||||||
<Card style={cardStyles.base}>
|
<Card style={cardStyles.base}>
|
||||||
<List
|
<List
|
||||||
dataSource={sortedOutlines}
|
dataSource={sortedOutlines}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<List.Item
|
<List.Item
|
||||||
style={{
|
style={{
|
||||||
padding: '16px 0',
|
padding: '16px 0',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
transition: 'background 0.3s ease',
|
transition: 'background 0.3s ease',
|
||||||
flexDirection: isMobile ? 'column' : 'row',
|
flexDirection: isMobile ? 'column' : 'row',
|
||||||
alignItems: isMobile ? 'flex-start' : 'center'
|
alignItems: isMobile ? 'flex-start' : 'center'
|
||||||
}}
|
}}
|
||||||
actions={isMobile ? undefined : [
|
actions={isMobile ? undefined : [
|
||||||
...(currentProject?.outline_mode === 'one-to-many' ? [
|
...(currentProject?.outline_mode === 'one-to-many' ? [
|
||||||
<Tooltip title="展开为多章">
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
icon={<BranchesOutlined />}
|
|
||||||
onClick={() => handleExpandOutline(item.id, item.title)}
|
|
||||||
loading={isExpanding}
|
|
||||||
>
|
|
||||||
展开
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
] : []), // 一对一模式:不显示任何展开/创建按钮
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
icon={<EditOutlined />}
|
|
||||||
onClick={() => handleOpenEditModal(item.id)}
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</Button>,
|
|
||||||
<Popconfirm
|
|
||||||
title="确定删除这条大纲吗?"
|
|
||||||
onConfirm={() => handleDeleteOutline(item.id)}
|
|
||||||
okText="确定"
|
|
||||||
cancelText="取消"
|
|
||||||
>
|
|
||||||
<Button type="text" danger icon={<DeleteOutlined />}>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<div style={{ width: '100%' }}>
|
|
||||||
<List.Item.Meta
|
|
||||||
title={
|
|
||||||
<Space size="small" style={{ fontSize: isMobile ? 14 : 16, flexWrap: 'wrap' }}>
|
|
||||||
<span style={{ color: '#1890ff', fontWeight: 'bold' }}>
|
|
||||||
{currentProject?.outline_mode === 'one-to-one'
|
|
||||||
? `第${item.order_index || '?'}章`
|
|
||||||
: `第${item.order_index || '?'}卷`
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
<span>{item.title}</span>
|
|
||||||
{/* ✅ 新增:展开状态标识 - 仅在一对多模式显示 */}
|
|
||||||
{currentProject?.outline_mode === 'one-to-many' && (
|
|
||||||
outlineExpandStatus[item.id] ? (
|
|
||||||
<Tag color="success" icon={<CheckCircleOutlined />}>已展开</Tag>
|
|
||||||
) : (
|
|
||||||
<Tag color="default">未展开</Tag>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
description={
|
|
||||||
<div style={{ fontSize: isMobile ? 12 : 14 }}>
|
|
||||||
{item.content}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 移动端:按钮显示在内容下方 */}
|
|
||||||
{isMobile && (
|
|
||||||
<Space style={{ marginTop: 12, width: '100%', justifyContent: 'flex-end' }} wrap>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
icon={<EditOutlined />}
|
|
||||||
onClick={() => handleOpenEditModal(item.id)}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
{/* 一对多模式:显示展开按钮 */}
|
|
||||||
{currentProject?.outline_mode === 'one-to-many' && (
|
|
||||||
<Tooltip title="展开为多章">
|
<Tooltip title="展开为多章">
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<BranchesOutlined />}
|
icon={<BranchesOutlined />}
|
||||||
onClick={() => handleExpandOutline(item.id, item.title)}
|
onClick={() => handleExpandOutline(item.id, item.title)}
|
||||||
loading={isExpanding}
|
loading={isExpanding}
|
||||||
size="small"
|
>
|
||||||
/>
|
展开
|
||||||
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
] : []), // 一对一模式:不显示任何展开/创建按钮
|
||||||
{/* 一对一模式:不显示任何展开/创建按钮 */}
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => handleOpenEditModal(item.id)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>,
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="确定删除这条大纲吗?"
|
title="确定删除这条大纲吗?"
|
||||||
onConfirm={() => handleDeleteOutline(item.id)}
|
onConfirm={() => handleDeleteOutline(item.id)}
|
||||||
okText="确定"
|
okText="确定"
|
||||||
cancelText="取消"
|
cancelText="取消"
|
||||||
>
|
>
|
||||||
<Button type="text" danger icon={<DeleteOutlined />} size="small" />
|
<Button type="text" danger icon={<DeleteOutlined />}>
|
||||||
</Popconfirm>
|
删除
|
||||||
</Space>
|
</Button>
|
||||||
)}
|
</Popconfirm>,
|
||||||
</div>
|
]}
|
||||||
</List.Item>
|
>
|
||||||
)}
|
<div style={{ width: '100%' }}>
|
||||||
/>
|
<List.Item.Meta
|
||||||
</Card>
|
title={
|
||||||
)}
|
<Space size="small" style={{ fontSize: isMobile ? 14 : 16, flexWrap: 'wrap' }}>
|
||||||
</div>
|
<span style={{ color: '#1890ff', fontWeight: 'bold' }}>
|
||||||
|
{currentProject?.outline_mode === 'one-to-one'
|
||||||
|
? `第${item.order_index || '?'}章`
|
||||||
|
: `第${item.order_index || '?'}卷`
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span>{item.title}</span>
|
||||||
|
{/* ✅ 新增:展开状态标识 - 仅在一对多模式显示 */}
|
||||||
|
{currentProject?.outline_mode === 'one-to-many' && (
|
||||||
|
outlineExpandStatus[item.id] ? (
|
||||||
|
<Tag color="success" icon={<CheckCircleOutlined />}>已展开</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag color="default">未展开</Tag>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<div style={{ fontSize: isMobile ? 12 : 14 }}>
|
||||||
|
{item.content}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 移动端:按钮显示在内容下方 */}
|
||||||
|
{isMobile && (
|
||||||
|
<Space style={{ marginTop: 12, width: '100%', justifyContent: 'flex-end' }} wrap>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => handleOpenEditModal(item.id)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
{/* 一对多模式:显示展开按钮 */}
|
||||||
|
{currentProject?.outline_mode === 'one-to-many' && (
|
||||||
|
<Tooltip title="展开为多章">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<BranchesOutlined />}
|
||||||
|
onClick={() => handleExpandOutline(item.id, item.title)}
|
||||||
|
loading={isExpanding}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{/* 一对一模式:不显示任何展开/创建按钮 */}
|
||||||
|
<Popconfirm
|
||||||
|
title="确定删除这条大纲吗?"
|
||||||
|
onConfirm={() => handleDeleteOutline(item.id)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button type="text" danger icon={<DeleteOutlined />} size="small" />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
BankOutlined,
|
BankOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
FundOutlined,
|
FundOutlined,
|
||||||
|
HeartOutlined,
|
||||||
} 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';
|
||||||
@@ -138,6 +139,11 @@ export default function ProjectDetail() {
|
|||||||
// icon: <ToolOutlined />,
|
// icon: <ToolOutlined />,
|
||||||
// label: <Link to={`/project/${projectId}/polish`}>AI去味</Link>,
|
// label: <Link to={`/project/${projectId}/polish`}>AI去味</Link>,
|
||||||
// },
|
// },
|
||||||
|
{
|
||||||
|
key: 'sponsor',
|
||||||
|
icon: <HeartOutlined />,
|
||||||
|
label: <Link to={`/project/${projectId}/sponsor`}>赞助支持</Link>,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 根据当前路径动态确定选中的菜单项
|
// 根据当前路径动态确定选中的菜单项
|
||||||
@@ -151,6 +157,7 @@ export default function ProjectDetail() {
|
|||||||
if (path.includes('/chapter-analysis')) return 'chapter-analysis';
|
if (path.includes('/chapter-analysis')) return 'chapter-analysis';
|
||||||
if (path.includes('/chapters')) return 'chapters';
|
if (path.includes('/chapters')) return 'chapters';
|
||||||
if (path.includes('/writing-styles')) return 'writing-styles';
|
if (path.includes('/writing-styles')) return 'writing-styles';
|
||||||
|
if (path.includes('/sponsor')) return 'sponsor';
|
||||||
// if (path.includes('/polish')) return 'polish';
|
// if (path.includes('/polish')) return 'polish';
|
||||||
return 'world-setting'; // 默认选中世界设定
|
return 'world-setting'; // 默认选中世界设定
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
@@ -268,90 +275,90 @@ export default function ProjectDetail() {
|
|||||||
{!mobile && (
|
{!mobile && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', zIndex: 1 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', zIndex: 1 }}>
|
||||||
<Row gutter={12} style={{ width: '450px', justifyContent: 'flex-end' }}>
|
<Row gutter={12} style={{ width: '450px', justifyContent: 'flex-end' }}>
|
||||||
<Col>
|
<Col>
|
||||||
<Card
|
<Card
|
||||||
size="small"
|
size="small"
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(255,255,255,0.95)',
|
background: 'rgba(255,255,255,0.95)',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
minWidth: '80px',
|
minWidth: '80px',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
padding: '4px 8px'
|
padding: '4px 8px'
|
||||||
}}
|
}}
|
||||||
styles={{ body: { padding: '8px' } }}
|
styles={{ body: { padding: '8px' } }}
|
||||||
>
|
>
|
||||||
<Statistic
|
<Statistic
|
||||||
title={<span style={{ fontSize: '11px', color: '#666' }}>大纲</span>}
|
title={<span style={{ fontSize: '11px', color: '#666' }}>大纲</span>}
|
||||||
value={outlines.length}
|
value={outlines.length}
|
||||||
suffix="条"
|
suffix="条"
|
||||||
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#667eea' }}
|
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#667eea' }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col>
|
<Col>
|
||||||
<Card
|
<Card
|
||||||
size="small"
|
size="small"
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(255,255,255,0.95)',
|
background: 'rgba(255,255,255,0.95)',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
minWidth: '80px',
|
minWidth: '80px',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
padding: '4px 8px'
|
padding: '4px 8px'
|
||||||
}}
|
}}
|
||||||
styles={{ body: { padding: '8px' } }}
|
styles={{ body: { padding: '8px' } }}
|
||||||
>
|
>
|
||||||
<Statistic
|
<Statistic
|
||||||
title={<span style={{ fontSize: '11px', color: '#666' }}>角色</span>}
|
title={<span style={{ fontSize: '11px', color: '#666' }}>角色</span>}
|
||||||
value={characters.length}
|
value={characters.length}
|
||||||
suffix="个"
|
suffix="个"
|
||||||
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#52c41a' }}
|
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#52c41a' }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col>
|
<Col>
|
||||||
<Card
|
<Card
|
||||||
size="small"
|
size="small"
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(255,255,255,0.95)',
|
background: 'rgba(255,255,255,0.95)',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
minWidth: '80px',
|
minWidth: '80px',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
padding: '4px 8px'
|
padding: '4px 8px'
|
||||||
}}
|
}}
|
||||||
styles={{ body: { padding: '8px' } }}
|
styles={{ body: { padding: '8px' } }}
|
||||||
>
|
>
|
||||||
<Statistic
|
<Statistic
|
||||||
title={<span style={{ fontSize: '11px', color: '#666' }}>章节</span>}
|
title={<span style={{ fontSize: '11px', color: '#666' }}>章节</span>}
|
||||||
value={chapters.length}
|
value={chapters.length}
|
||||||
suffix="章"
|
suffix="章"
|
||||||
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#1890ff' }}
|
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#1890ff' }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col>
|
<Col>
|
||||||
<Card
|
<Card
|
||||||
size="small"
|
size="small"
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(255,255,255,0.95)',
|
background: 'rgba(255,255,255,0.95)',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
minWidth: '80px',
|
minWidth: '80px',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
padding: '4px 8px'
|
padding: '4px 8px'
|
||||||
}}
|
}}
|
||||||
styles={{ body: { padding: '8px' } }}
|
styles={{ body: { padding: '8px' } }}
|
||||||
>
|
>
|
||||||
<Statistic
|
<Statistic
|
||||||
title={<span style={{ fontSize: '11px', color: '#666' }}>已写</span>}
|
title={<span style={{ fontSize: '11px', color: '#666' }}>已写</span>}
|
||||||
value={currentProject.current_words}
|
value={currentProject.current_words}
|
||||||
suffix="字"
|
suffix="字"
|
||||||
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#fa8c16' }}
|
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#fa8c16' }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -371,23 +378,23 @@ export default function ProjectDetail() {
|
|||||||
</Drawer>
|
</Drawer>
|
||||||
) : (
|
) : (
|
||||||
<Sider
|
<Sider
|
||||||
collapsible
|
collapsible
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onCollapse={setCollapsed}
|
onCollapse={setCollapsed}
|
||||||
trigger={null}
|
trigger={null}
|
||||||
width={220}
|
width={220}
|
||||||
collapsedWidth={60}
|
collapsedWidth={60}
|
||||||
style={{
|
style={{
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
left: 0,
|
left: 0,
|
||||||
top: 70,
|
top: 70,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
boxShadow: '2px 0 12px rgba(0,0,0,0.08)',
|
boxShadow: '2px 0 12px rgba(0,0,0,0.08)',
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
height: 'calc(100vh - 70px)'
|
height: 'calc(100vh - 70px)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{
|
<div style={{
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, Row, Col, Typography, Image, Divider, Modal, Button } from 'antd';
|
||||||
|
import {
|
||||||
|
HeartOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
RocketOutlined,
|
||||||
|
MessageOutlined,
|
||||||
|
StarOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
const { Title, Paragraph, Text } = Typography;
|
||||||
|
|
||||||
|
interface SponsorOption {
|
||||||
|
amount: number | string;
|
||||||
|
label: string;
|
||||||
|
image: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sponsorOptions: SponsorOption[] = [
|
||||||
|
{ amount: 5, label: '入门支持', image: '/5.png', description: '¥5' },
|
||||||
|
{ amount: 10, label: '进阶支持', image: '/10.png', description: '¥10' },
|
||||||
|
{ amount: 20, label: '标准支持', image: '/20.png', description: '¥20' },
|
||||||
|
{ amount: 50, label: '高级支持', image: '/50.png', description: '¥50' },
|
||||||
|
{ amount: 'custom', label: '任意金额', image: '/xx.png', description: '自定义' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const benefits = [
|
||||||
|
{
|
||||||
|
icon: <FileTextOutlined style={{ fontSize: '32px', color: '#1890ff' }} />,
|
||||||
|
title: '优先需求响应',
|
||||||
|
description: '您的功能需求和问题反馈将获得优先处理'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <RocketOutlined style={{ fontSize: '32px', color: '#52c41a' }} />,
|
||||||
|
title: 'Windows一键启动',
|
||||||
|
description: '获取免安装EXE程序,双击即可使用'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <MessageOutlined style={{ fontSize: '32px', color: '#fa8c16' }} />,
|
||||||
|
title: '专属技术支持',
|
||||||
|
description: '加入赞助者群,获得远程协助和配置指导'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Sponsor() {
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [selectedOption, setSelectedOption] = useState<SponsorOption | null>(null);
|
||||||
|
|
||||||
|
const handleCardClick = (option: SponsorOption) => {
|
||||||
|
setSelectedOption(option);
|
||||||
|
setModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: '100%',
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '16px'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
maxWidth: '1200px',
|
||||||
|
margin: '0 auto'
|
||||||
|
}}>
|
||||||
|
{/* 头部标题区域 */}
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
|
||||||
|
<Title level={1} style={{ marginBottom: '8px', fontSize: '32px', fontWeight: 'bold' }}>
|
||||||
|
赞助 MuMuAINovel
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: '13px', letterSpacing: '2px' }}>
|
||||||
|
SUPPORT AI NOVEL CREATION
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginTop: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
color: '#fff'
|
||||||
|
}}>
|
||||||
|
<Title level={4} style={{ color: '#fff', marginBottom: '8px' }}>
|
||||||
|
📚 MuMuAINovel - 基于 AI 的智能小说创作助手
|
||||||
|
</Title>
|
||||||
|
<Paragraph style={{ color: '#fff', fontSize: '14px', margin: 0 }}>
|
||||||
|
支持多AI模型、智能向导、角色管理、章节编辑等强大功能
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 赞助专属权益 */}
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<Title level={3} style={{ textAlign: 'center', marginBottom: '20px' }}>
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: '8px' }} />
|
||||||
|
赞助专属权益
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{benefits.map((benefit, index) => (
|
||||||
|
<Col xs={24} md={8} key={index}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
textAlign: 'center',
|
||||||
|
borderRadius: '10px',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.08)'
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
body: { padding: '20px 16px' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: '12px' }}>
|
||||||
|
{benefit.icon}
|
||||||
|
</div>
|
||||||
|
<Title level={5} style={{ marginBottom: '8px' }}>{benefit.title}</Title>
|
||||||
|
<Paragraph style={{ color: '#666', marginBottom: 0, fontSize: '13px' }}>
|
||||||
|
{benefit.description}
|
||||||
|
</Paragraph>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 选择金额 */}
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<Title level={3} style={{ textAlign: 'center', marginBottom: '20px' }}>
|
||||||
|
<HeartOutlined style={{ color: '#f5222d', marginRight: '8px' }} />
|
||||||
|
选择金额
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]} justify="center">
|
||||||
|
{sponsorOptions.map((option, index) => (
|
||||||
|
<Col xs={12} sm={8} md={6} lg={4} key={index}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
onClick={() => handleCardClick(option)}
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
borderRadius: '10px',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s',
|
||||||
|
border: '2px solid #f0f0f0'
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
body: { padding: '20px 12px' }
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(-8px)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 8px 24px rgba(0,0,0,0.15)';
|
||||||
|
e.currentTarget.style.borderColor = '#1890ff';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)';
|
||||||
|
e.currentTarget.style.borderColor = '#f0f0f0';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Title level={3} style={{
|
||||||
|
color: '#1890ff',
|
||||||
|
marginBottom: '4px',
|
||||||
|
fontSize: '28px',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}>
|
||||||
|
{option.description}
|
||||||
|
</Title>
|
||||||
|
<Text style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '24px 0' }} />
|
||||||
|
|
||||||
|
{/* 感谢文案 */}
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '24px 20px',
|
||||||
|
background: '#f9f9f9',
|
||||||
|
borderRadius: '10px'
|
||||||
|
}}>
|
||||||
|
<Title level={4} style={{ marginBottom: '12px' }}>
|
||||||
|
💖 感谢您对 MuMuAINovel 项目的支持
|
||||||
|
</Title>
|
||||||
|
<Paragraph style={{ fontSize: '14px', color: '#666', marginBottom: '12px' }}>
|
||||||
|
您的赞助将帮助我们持续改进产品,提供更好的AI小说创作体验
|
||||||
|
</Paragraph>
|
||||||
|
<div style={{ fontSize: '24px' }}>
|
||||||
|
<StarOutlined style={{ color: '#faad14', margin: '0 4px' }} />
|
||||||
|
<StarOutlined style={{ color: '#faad14', margin: '0 4px' }} />
|
||||||
|
<StarOutlined style={{ color: '#faad14', margin: '0 4px' }} />
|
||||||
|
<StarOutlined style={{ color: '#faad14', margin: '0 4px' }} />
|
||||||
|
<StarOutlined style={{ color: '#faad14', margin: '0 4px' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 二维码弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<Title level={3} style={{ marginBottom: '8px' }}>
|
||||||
|
{selectedOption?.description} {selectedOption?.label}
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary">请使用微信扫码支付</Text>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
open={modalVisible}
|
||||||
|
onCancel={() => setModalVisible(false)}
|
||||||
|
footer={[
|
||||||
|
<Button key="close" type="primary" onClick={() => setModalVisible(false)}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
]}
|
||||||
|
width={400}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||||
|
<Image
|
||||||
|
src={selectedOption?.image}
|
||||||
|
alt={`${selectedOption?.description}赞助码`}
|
||||||
|
style={{
|
||||||
|
maxWidth: '280px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #f0f0f0'
|
||||||
|
}}
|
||||||
|
preview={false}
|
||||||
|
/>
|
||||||
|
<Paragraph style={{ marginTop: '20px', color: '#666' }}>
|
||||||
|
扫描二维码完成支付
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph style={{ color: '#999', fontSize: '12px' }}>
|
||||||
|
支付后可添加微信/QQ联系我们获取权益
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -300,6 +300,35 @@ export const outlineApi = {
|
|||||||
generateOutline: (data: GenerateOutlineRequest) =>
|
generateOutline: (data: GenerateOutlineRequest) =>
|
||||||
api.post<unknown, { total: number; items: Outline[] }>('/outlines/generate', data).then(res => res.items),
|
api.post<unknown, { total: number; items: Outline[] }>('/outlines/generate', data).then(res => res.items),
|
||||||
|
|
||||||
|
// 预测续写所需角色
|
||||||
|
predictCharacters: (data: {
|
||||||
|
project_id: string;
|
||||||
|
start_chapter: number;
|
||||||
|
chapter_count: number;
|
||||||
|
plot_stage: string;
|
||||||
|
story_direction?: string;
|
||||||
|
enable_mcp: boolean;
|
||||||
|
}) =>
|
||||||
|
api.post<unknown, {
|
||||||
|
needs_new_characters: boolean;
|
||||||
|
reason: string;
|
||||||
|
character_count: number;
|
||||||
|
predicted_characters: Array<{
|
||||||
|
name: string | null;
|
||||||
|
role_description: string;
|
||||||
|
suggested_role_type: string;
|
||||||
|
importance: string;
|
||||||
|
appearance_chapter: number;
|
||||||
|
key_abilities: string[];
|
||||||
|
plot_function: string;
|
||||||
|
relationship_suggestions: Array<{
|
||||||
|
target_character_name: string;
|
||||||
|
relationship_type: string;
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}>('/outlines/predict-characters', data),
|
||||||
|
|
||||||
// 获取大纲关联的章节
|
// 获取大纲关联的章节
|
||||||
getOutlineChapters: (outlineId: string) =>
|
getOutlineChapters: (outlineId: string) =>
|
||||||
api.get<unknown, {
|
api.get<unknown, {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface SSEClientOptions {
|
|||||||
onError?: (error: string, code?: number) => void;
|
onError?: (error: string, code?: number) => void;
|
||||||
onComplete?: () => void;
|
onComplete?: () => void;
|
||||||
onConnectionError?: (error: Event) => void;
|
onConnectionError?: (error: Event) => void;
|
||||||
|
onCharacterConfirmation?: (data: any) => void; // 新增:角色确认回调
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SSEClient {
|
export class SSEClient {
|
||||||
@@ -160,6 +161,7 @@ export class SSEPostClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
|
let currentEvent = ''; // 跟踪当前事件类型
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
@@ -179,10 +181,31 @@ export class SSEPostClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 检查是否有事件类型
|
||||||
|
const eventMatch = line.match(/^event: (.+)$/m);
|
||||||
|
if (eventMatch) {
|
||||||
|
currentEvent = eventMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析数据
|
||||||
const dataMatch = line.match(/^data: (.+)$/m);
|
const dataMatch = line.match(/^data: (.+)$/m);
|
||||||
if (dataMatch) {
|
if (dataMatch) {
|
||||||
const message: SSEMessage = JSON.parse(dataMatch[1]);
|
const data = JSON.parse(dataMatch[1]);
|
||||||
await this.handleMessage(message, resolve, reject);
|
|
||||||
|
// 根据事件类型处理
|
||||||
|
if (currentEvent === 'character_confirmation_required') {
|
||||||
|
// 处理角色确认事件
|
||||||
|
if (this.options.onCharacterConfirmation) {
|
||||||
|
this.options.onCharacterConfirmation(data);
|
||||||
|
}
|
||||||
|
currentEvent = ''; // 重置事件类型
|
||||||
|
return; // 暂停流程,等待用户确认
|
||||||
|
} else {
|
||||||
|
// 标准消息处理
|
||||||
|
const message: SSEMessage = data;
|
||||||
|
await this.handleMessage(message, resolve, reject);
|
||||||
|
currentEvent = ''; // 重置事件类型
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('解析SSE消息失败:', error, line);
|
console.error('解析SSE消息失败:', error, line);
|
||||||
|
|||||||
Reference in New Issue
Block a user