update:1.更新AI生成角色/组织实现自动建立关系 2.新增AI续写大纲智能引入角色功能

This commit is contained in:
xiamuceer
2025-12-11 12:43:28 +08:00
parent 9fcc06055c
commit 02bd2a2529
17 changed files with 2356 additions and 430 deletions
+147
View File
@@ -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
View File
@@ -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
@@ -914,6 +1148,7 @@ async def _continue_outline(
current_start_chapter += current_batch_size current_start_chapter += current_batch_size
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(
@@ -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
+34 -1
View File
@@ -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
+201
View File
@@ -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"]
} }
} }
+11
View File
@@ -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

+3 -1
View File
@@ -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';
@@ -37,7 +38,7 @@ function App() {
<Routes> <Routes>
<Route path="/login" element={<><Login /><AppFooter /></>} /> <Route path="/login" element={<><Login /><AppFooter /></>} />
<Route path="/auth/callback" element={<AuthCallback />} /> <Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/" element={<ProtectedRoute><><ProjectList /><AppFooter /></></ProtectedRoute>} /> <Route path="/" element={<ProtectedRoute><><ProjectList /><AppFooter /></></ProtectedRoute>} />
<Route path="/projects" element={<ProtectedRoute><><ProjectList /><AppFooter /></></ProtectedRoute>} /> <Route path="/projects" element={<ProtectedRoute><><ProjectList /><AppFooter /></></ProtectedRoute>} />
<Route path="/wizard" element={<ProtectedRoute><ProjectWizardNew /></ProtectedRoute>} /> <Route path="/wizard" element={<ProtectedRoute><ProjectWizardNew /></ProtectedRoute>} />
@@ -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>
File diff suppressed because it is too large Load Diff
+112 -105
View File
@@ -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';
@@ -66,7 +67,7 @@ export default function ProjectDetail() {
// 加载项目基本信息 // 加载项目基本信息
const project = await projectApi.getProject(id); const project = await projectApi.getProject(id);
setCurrentProject(project); setCurrentProject(project);
// 并行加载其他数据 // 并行加载其他数据
await Promise.all([ await Promise.all([
refreshOutlines(id), refreshOutlines(id),
@@ -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]);
@@ -227,7 +234,7 @@ export default function ProjectDetail() {
</Button> </Button>
)} )}
</div> </div>
<h2 style={{ <h2 style={{
margin: 0, margin: 0,
color: '#fff', color: '#fff',
@@ -247,7 +254,7 @@ export default function ProjectDetail() {
}}> }}>
{currentProject.title} {currentProject.title}
</h2> </h2>
{mobile && ( {mobile && (
<Button <Button
type="text" type="text"
@@ -264,94 +271,94 @@ export default function ProjectDetail() {
</Button> </Button>
)} )}
{!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%',
+244
View File
@@ -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>
);
}
+105 -76
View File
@@ -72,11 +72,11 @@ api.interceptors.response.use(
}, },
(error) => { (error) => {
let errorMessage = '请求失败'; let errorMessage = '请求失败';
if (error.response) { if (error.response) {
const status = error.response.status; const status = error.response.status;
const data = error.response.data; const data = error.response.data;
switch (status) { switch (status) {
case 400: case 400:
errorMessage = data?.detail || '请求参数错误'; errorMessage = data?.detail || '请求参数错误';
@@ -113,54 +113,54 @@ api.interceptors.response.use(
} else { } else {
errorMessage = error.message || '请求失败'; errorMessage = error.message || '请求失败';
} }
message.error(errorMessage); message.error(errorMessage);
console.error('API Error:', errorMessage, error); console.error('API Error:', errorMessage, error);
return Promise.reject(error); return Promise.reject(error);
} }
); );
export const authApi = { export const authApi = {
getAuthConfig: () => api.get<unknown, { local_auth_enabled: boolean; linuxdo_enabled: boolean }>('/auth/config'), getAuthConfig: () => api.get<unknown, { local_auth_enabled: boolean; linuxdo_enabled: boolean }>('/auth/config'),
localLogin: (username: string, password: string) => localLogin: (username: string, password: string) =>
api.post<unknown, { success: boolean; message: string; user: User }>('/auth/local/login', { username, password }), api.post<unknown, { success: boolean; message: string; user: User }>('/auth/local/login', { username, password }),
bindAccountLogin: (username: string, password: string) => bindAccountLogin: (username: string, password: string) =>
api.post<unknown, { success: boolean; message: string; user: User }>('/auth/bind/login', { username, password }), api.post<unknown, { success: boolean; message: string; user: User }>('/auth/bind/login', { username, password }),
getLinuxDOAuthUrl: () => api.get<unknown, AuthUrlResponse>('/auth/linuxdo/url'), getLinuxDOAuthUrl: () => api.get<unknown, AuthUrlResponse>('/auth/linuxdo/url'),
getCurrentUser: () => api.get<unknown, User>('/auth/user'), getCurrentUser: () => api.get<unknown, User>('/auth/user'),
getPasswordStatus: () => api.get<unknown, { getPasswordStatus: () => api.get<unknown, {
has_password: boolean; has_password: boolean;
has_custom_password: boolean; has_custom_password: boolean;
username: string | null; username: string | null;
default_password: string | null; default_password: string | null;
}>('/auth/password/status'), }>('/auth/password/status'),
setPassword: (password: string) => setPassword: (password: string) =>
api.post<unknown, { success: boolean; message: string }>('/auth/password/set', { password }), api.post<unknown, { success: boolean; message: string }>('/auth/password/set', { password }),
refreshSession: () => api.post<unknown, { message: string; expire_at: number; remaining_minutes: number }>('/auth/refresh'), refreshSession: () => api.post<unknown, { message: string; expire_at: number; remaining_minutes: number }>('/auth/refresh'),
logout: () => api.post('/auth/logout'), logout: () => api.post('/auth/logout'),
}; };
export const userApi = { export const userApi = {
getCurrentUser: () => api.get<unknown, User>('/users/current'), getCurrentUser: () => api.get<unknown, User>('/users/current'),
listUsers: () => api.get<unknown, User[]>('/users'), listUsers: () => api.get<unknown, User[]>('/users'),
setAdmin: (userId: string, isAdmin: boolean) => setAdmin: (userId: string, isAdmin: boolean) =>
api.post('/users/set-admin', { user_id: userId, is_admin: isAdmin }), api.post('/users/set-admin', { user_id: userId, is_admin: isAdmin }),
deleteUser: (userId: string) => api.delete(`/users/${userId}`), deleteUser: (userId: string) => api.delete(`/users/${userId}`),
getUser: (userId: string) => api.get<unknown, User>(`/users/${userId}`), getUser: (userId: string) => api.get<unknown, User>(`/users/${userId}`),
resetPassword: (userId: string, newPassword?: string) => resetPassword: (userId: string, newPassword?: string) =>
api.post<unknown, { api.post<unknown, {
message: string; message: string;
@@ -172,18 +172,18 @@ export const userApi = {
export const settingsApi = { export const settingsApi = {
getSettings: () => api.get<unknown, Settings>('/settings'), getSettings: () => api.get<unknown, Settings>('/settings'),
saveSettings: (data: SettingsUpdate) => saveSettings: (data: SettingsUpdate) =>
api.post<unknown, Settings>('/settings', data), api.post<unknown, Settings>('/settings', data),
updateSettings: (data: SettingsUpdate) => updateSettings: (data: SettingsUpdate) =>
api.put<unknown, Settings>('/settings', data), api.put<unknown, Settings>('/settings', data),
deleteSettings: () => api.delete<unknown, { message: string; user_id: string }>('/settings'), deleteSettings: () => api.delete<unknown, { message: string; user_id: string }>('/settings'),
getAvailableModels: (params: { api_key: string; api_base_url: string; provider: string }) => getAvailableModels: (params: { api_key: string; api_base_url: string; provider: string }) =>
api.get<unknown, { provider: string; models: Array<{ value: string; label: string; description: string }>; count?: number }>('/settings/models', { params }), api.get<unknown, { provider: string; models: Array<{ value: string; label: string; description: string }>; count?: number }>('/settings/models', { params }),
testApiConnection: (params: { api_key: string; api_base_url: string; provider: string; llm_model: string }) => testApiConnection: (params: { api_key: string; api_base_url: string; provider: string; llm_model: string }) =>
api.post<unknown, { api.post<unknown, {
success: boolean; success: boolean;
@@ -201,20 +201,20 @@ export const settingsApi = {
export const projectApi = { export const projectApi = {
getProjects: () => api.get<unknown, Project[]>('/projects'), getProjects: () => api.get<unknown, Project[]>('/projects'),
getProject: (id: string) => api.get<unknown, Project>(`/projects/${id}`), getProject: (id: string) => api.get<unknown, Project>(`/projects/${id}`),
createProject: (data: ProjectCreate) => api.post<unknown, Project>('/projects', data), createProject: (data: ProjectCreate) => api.post<unknown, Project>('/projects', data),
updateProject: (id: string, data: ProjectUpdate) => updateProject: (id: string, data: ProjectUpdate) =>
api.put<unknown, Project>(`/projects/${id}`, data), api.put<unknown, Project>(`/projects/${id}`, data),
deleteProject: (id: string) => api.delete(`/projects/${id}`), deleteProject: (id: string) => api.delete(`/projects/${id}`),
exportProject: (id: string) => { exportProject: (id: string) => {
window.open(`/api/projects/${id}/export`, '_blank'); window.open(`/api/projects/${id}/export`, '_blank');
}, },
// 导出项目数据为JSON // 导出项目数据为JSON
exportProjectData: async (id: string, options: { include_generation_history?: boolean; include_writing_styles?: boolean }) => { exportProjectData: async (id: string, options: { include_generation_history?: boolean; include_writing_styles?: boolean }) => {
const response = await axios.post( const response = await axios.post(
@@ -227,7 +227,7 @@ export const projectApi = {
}, },
} }
); );
// 从响应头获取文件名 // 从响应头获取文件名
const contentDisposition = response.headers['content-disposition']; const contentDisposition = response.headers['content-disposition'];
let filename = 'project_export.json'; let filename = 'project_export.json';
@@ -237,7 +237,7 @@ export const projectApi = {
filename = decodeURIComponent(matches[1]); filename = decodeURIComponent(matches[1]);
} }
} }
// 创建下载链接 // 创建下载链接
const url = window.URL.createObjectURL(new Blob([response.data])); const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a'); const link = document.createElement('a');
@@ -248,7 +248,7 @@ export const projectApi = {
link.remove(); link.remove();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
}, },
// 验证导入文件 // 验证导入文件
validateImportFile: (file: File) => { validateImportFile: (file: File) => {
const formData = new FormData(); const formData = new FormData();
@@ -264,7 +264,7 @@ export const projectApi = {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
}); });
}, },
// 导入项目 // 导入项目
importProject: (file: File) => { importProject: (file: File) => {
const formData = new FormData(); const formData = new FormData();
@@ -284,22 +284,51 @@ export const projectApi = {
export const outlineApi = { export const outlineApi = {
getOutlines: (projectId: string) => getOutlines: (projectId: string) =>
api.get<unknown, { total: number; items: Outline[] }>(`/outlines/project/${projectId}`).then(res => res.items), api.get<unknown, { total: number; items: Outline[] }>(`/outlines/project/${projectId}`).then(res => res.items),
getOutline: (id: string) => api.get<unknown, Outline>(`/outlines/${id}`), getOutline: (id: string) => api.get<unknown, Outline>(`/outlines/${id}`),
createOutline: (data: OutlineCreate) => api.post<unknown, Outline>('/outlines', data), createOutline: (data: OutlineCreate) => api.post<unknown, Outline>('/outlines', data),
updateOutline: (id: string, data: OutlineUpdate) => updateOutline: (id: string, data: OutlineUpdate) =>
api.put<unknown, Outline>(`/outlines/${id}`, data), api.put<unknown, Outline>(`/outlines/${id}`, data),
deleteOutline: (id: string) => api.delete(`/outlines/${id}`), deleteOutline: (id: string) => api.delete(`/outlines/${id}`),
reorderOutlines: (data: OutlineReorderRequest) => reorderOutlines: (data: OutlineReorderRequest) =>
api.post<unknown, { message: string; updated_outlines: number; updated_chapters: number }>('/outlines/reorder', data), api.post<unknown, { message: string; updated_outlines: number; updated_chapters: number }>('/outlines/reorder', data),
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, {
@@ -333,11 +362,11 @@ export const outlineApi = {
}> | null; }> | null;
}> | null; }> | null;
}>(`/outlines/${outlineId}/chapters`), }>(`/outlines/${outlineId}/chapters`),
// 单个大纲展开为多章 // 单个大纲展开为多章
expandOutline: (outlineId: string, data: OutlineExpansionRequest) => expandOutline: (outlineId: string, data: OutlineExpansionRequest) =>
api.post<unknown, OutlineExpansionResponse>(`/outlines/${outlineId}/expand`, data), api.post<unknown, OutlineExpansionResponse>(`/outlines/${outlineId}/expand`, data),
// 根据已有规划创建章节(避免重复AI调用) // 根据已有规划创建章节(避免重复AI调用)
createChaptersFromPlans: (outlineId: string, chapterPlans: any[]) => createChaptersFromPlans: (outlineId: string, chapterPlans: any[]) =>
api.post<unknown, { api.post<unknown, {
@@ -354,7 +383,7 @@ export const outlineApi = {
status: string; status: string;
}>; }>;
}>(`/outlines/${outlineId}/create-chapters-from-plans`, { chapter_plans: chapterPlans }), }>(`/outlines/${outlineId}/create-chapters-from-plans`, { chapter_plans: chapterPlans }),
// 批量展开大纲 // 批量展开大纲
batchExpandOutlines: (data: BatchOutlineExpansionRequest) => batchExpandOutlines: (data: BatchOutlineExpansionRequest) =>
api.post<unknown, BatchOutlineExpansionResponse>('/outlines/batch-expand', data), api.post<unknown, BatchOutlineExpansionResponse>('/outlines/batch-expand', data),
@@ -363,9 +392,9 @@ export const outlineApi = {
export const characterApi = { export const characterApi = {
getCharacters: (projectId: string) => getCharacters: (projectId: string) =>
api.get<unknown, Character[]>(`/characters/project/${projectId}`), api.get<unknown, Character[]>(`/characters/project/${projectId}`),
getCharacter: (id: string) => api.get<unknown, Character>(`/characters/${id}`), getCharacter: (id: string) => api.get<unknown, Character>(`/characters/${id}`),
createCharacter: (data: { createCharacter: (data: {
project_id: string; project_id: string;
name: string; name: string;
@@ -388,12 +417,12 @@ export const characterApi = {
color?: string; color?: string;
}) => }) =>
api.post<unknown, Character>('/characters', data), api.post<unknown, Character>('/characters', data),
updateCharacter: (id: string, data: CharacterUpdate) => updateCharacter: (id: string, data: CharacterUpdate) =>
api.put<unknown, Character>(`/characters/${id}`, data), api.put<unknown, Character>(`/characters/${id}`, data),
deleteCharacter: (id: string) => api.delete(`/characters/${id}`), deleteCharacter: (id: string) => api.delete(`/characters/${id}`),
generateCharacter: (data: GenerateCharacterRequest) => generateCharacter: (data: GenerateCharacterRequest) =>
api.post<unknown, Character>('/characters/generate', data), api.post<unknown, Character>('/characters/generate', data),
}; };
@@ -401,19 +430,19 @@ export const characterApi = {
export const chapterApi = { export const chapterApi = {
getChapters: (projectId: string) => getChapters: (projectId: string) =>
api.get<unknown, Chapter[]>(`/chapters/project/${projectId}`), api.get<unknown, Chapter[]>(`/chapters/project/${projectId}`),
getChapter: (id: string) => api.get<unknown, Chapter>(`/chapters/${id}`), getChapter: (id: string) => api.get<unknown, Chapter>(`/chapters/${id}`),
createChapter: (data: ChapterCreate) => api.post<unknown, Chapter>('/chapters', data), createChapter: (data: ChapterCreate) => api.post<unknown, Chapter>('/chapters', data),
updateChapter: (id: string, data: ChapterUpdate) => updateChapter: (id: string, data: ChapterUpdate) =>
api.put<unknown, Chapter>(`/chapters/${id}`, data), api.put<unknown, Chapter>(`/chapters/${id}`, data),
deleteChapter: (id: string) => api.delete(`/chapters/${id}`), deleteChapter: (id: string) => api.delete(`/chapters/${id}`),
checkCanGenerate: (chapterId: string) => checkCanGenerate: (chapterId: string) =>
api.get<unknown, import('../types').ChapterCanGenerateResponse>(`/chapters/${chapterId}/can-generate`), api.get<unknown, import('../types').ChapterCanGenerateResponse>(`/chapters/${chapterId}/can-generate`),
// 章节重新生成相关 // 章节重新生成相关
getRegenerationTasks: (chapterId: string, limit?: number) => getRegenerationTasks: (chapterId: string, limit?: number) =>
api.get<unknown, { api.get<unknown, {
@@ -436,31 +465,31 @@ export const writingStyleApi = {
// 获取预设风格列表 // 获取预设风格列表
getPresetStyles: () => getPresetStyles: () =>
api.get<unknown, PresetStyle[]>('/writing-styles/presets/list'), api.get<unknown, PresetStyle[]>('/writing-styles/presets/list'),
// 获取用户的所有风格(新接口) // 获取用户的所有风格(新接口)
getUserStyles: () => getUserStyles: () =>
api.get<unknown, WritingStyleListResponse>('/writing-styles/user'), api.get<unknown, WritingStyleListResponse>('/writing-styles/user'),
// 获取项目的所有风格(保留向后兼容) // 获取项目的所有风格(保留向后兼容)
getProjectStyles: (projectId: string) => getProjectStyles: (projectId: string) =>
api.get<unknown, WritingStyleListResponse>(`/writing-styles/project/${projectId}`), api.get<unknown, WritingStyleListResponse>(`/writing-styles/project/${projectId}`),
// 创建新风格(基于预设或自定义) // 创建新风格(基于预设或自定义)
createStyle: (data: WritingStyleCreate) => createStyle: (data: WritingStyleCreate) =>
api.post<unknown, WritingStyle>('/writing-styles', data), api.post<unknown, WritingStyle>('/writing-styles', data),
// 更新风格 // 更新风格
updateStyle: (styleId: number, data: WritingStyleUpdate) => updateStyle: (styleId: number, data: WritingStyleUpdate) =>
api.put<unknown, WritingStyle>(`/writing-styles/${styleId}`, data), api.put<unknown, WritingStyle>(`/writing-styles/${styleId}`, data),
// 删除风格 // 删除风格
deleteStyle: (styleId: number) => deleteStyle: (styleId: number) =>
api.delete<unknown, { message: string }>(`/writing-styles/${styleId}`), api.delete<unknown, { message: string }>(`/writing-styles/${styleId}`),
// 设置默认风格 // 设置默认风格
setDefaultStyle: (styleId: number, projectId: string) => setDefaultStyle: (styleId: number, projectId: string) =>
api.post<unknown, WritingStyle>(`/writing-styles/${styleId}/set-default`, { project_id: projectId }), api.post<unknown, WritingStyle>(`/writing-styles/${styleId}/set-default`, { project_id: projectId }),
// 为项目初始化默认风格(如果没有任何风格) // 为项目初始化默认风格(如果没有任何风格)
initializeDefaultStyles: (projectId: string) => initializeDefaultStyles: (projectId: string) =>
api.post<unknown, WritingStyleListResponse>(`/writing-styles/project/${projectId}/initialize`, {}), api.post<unknown, WritingStyleListResponse>(`/writing-styles/project/${projectId}/initialize`, {}),
@@ -469,7 +498,7 @@ export const writingStyleApi = {
export const polishApi = { export const polishApi = {
polishText: (data: PolishTextRequest) => polishText: (data: PolishTextRequest) =>
api.post<unknown, { polished_text: string }>('/polish', data), api.post<unknown, { polished_text: string }>('/polish', data),
polishBatch: (texts: string[]) => polishBatch: (texts: string[]) =>
api.post<unknown, { polished_texts: string[] }>('/polish/batch', { texts }), api.post<unknown, { polished_texts: string[] }>('/polish/batch', { texts }),
}; };
@@ -488,7 +517,7 @@ export const inspirationApi = {
options: string[]; options: string[];
error?: string; error?: string;
}>('/inspiration/generate-options', data), }>('/inspiration/generate-options', data),
// 智能补全缺失信息 // 智能补全缺失信息
quickGenerate: (data: { quickGenerate: (data: {
title?: string; title?: string;
@@ -607,39 +636,39 @@ export const mcpPluginApi = {
// 获取所有插件 // 获取所有插件
getPlugins: () => getPlugins: () =>
api.get<unknown, MCPPlugin[]>('/mcp/plugins'), api.get<unknown, MCPPlugin[]>('/mcp/plugins'),
// 获取单个插件 // 获取单个插件
getPlugin: (id: string) => getPlugin: (id: string) =>
api.get<unknown, MCPPlugin>(`/mcp/plugins/${id}`), api.get<unknown, MCPPlugin>(`/mcp/plugins/${id}`),
// 创建插件 // 创建插件
createPlugin: (data: MCPPluginCreate) => createPlugin: (data: MCPPluginCreate) =>
api.post<unknown, MCPPlugin>('/mcp/plugins', data), api.post<unknown, MCPPlugin>('/mcp/plugins', data),
// 简化创建插件(通过标准MCP配置JSON) // 简化创建插件(通过标准MCP配置JSON)
createPluginSimple: (data: MCPPluginSimpleCreate) => createPluginSimple: (data: MCPPluginSimpleCreate) =>
api.post<unknown, MCPPlugin>('/mcp/plugins/simple', data), api.post<unknown, MCPPlugin>('/mcp/plugins/simple', data),
// 更新插件 // 更新插件
updatePlugin: (id: string, data: MCPPluginUpdate) => updatePlugin: (id: string, data: MCPPluginUpdate) =>
api.put<unknown, MCPPlugin>(`/mcp/plugins/${id}`, data), api.put<unknown, MCPPlugin>(`/mcp/plugins/${id}`, data),
// 删除插件 // 删除插件
deletePlugin: (id: string) => deletePlugin: (id: string) =>
api.delete<unknown, { message: string }>(`/mcp/plugins/${id}`), api.delete<unknown, { message: string }>(`/mcp/plugins/${id}`),
// 启用/禁用插件 // 启用/禁用插件
togglePlugin: (id: string, enabled: boolean) => togglePlugin: (id: string, enabled: boolean) =>
api.post<unknown, MCPPlugin>(`/mcp/plugins/${id}/toggle`, null, { params: { enabled } }), api.post<unknown, MCPPlugin>(`/mcp/plugins/${id}/toggle`, null, { params: { enabled } }),
// 测试插件连接 // 测试插件连接
testPlugin: (id: string) => testPlugin: (id: string) =>
api.post<unknown, MCPTestResult>(`/mcp/plugins/${id}/test`), api.post<unknown, MCPTestResult>(`/mcp/plugins/${id}/test`),
// 获取插件工具列表 // 获取插件工具列表
getPluginTools: (id: string) => getPluginTools: (id: string) =>
api.get<unknown, { tools: MCPTool[] }>(`/mcp/plugins/${id}/tools`), api.get<unknown, { tools: MCPTool[] }>(`/mcp/plugins/${id}/tools`),
// 调用工具 // 调用工具
callTool: (data: MCPToolCallRequest) => callTool: (data: MCPToolCallRequest) =>
api.post<unknown, MCPToolCallResponse>('/mcp/call', data), api.post<unknown, MCPToolCallResponse>('/mcp/call', data),
@@ -650,7 +679,7 @@ export const adminApi = {
// 获取用户列表 // 获取用户列表
getUsers: () => getUsers: () =>
api.get<unknown, { total: number; users: User[] }>('/admin/users'), api.get<unknown, { total: number; users: User[] }>('/admin/users'),
// 添加用户 // 添加用户
createUser: (data: { createUser: (data: {
username: string; username: string;
@@ -666,7 +695,7 @@ export const adminApi = {
user: User; user: User;
default_password?: string; default_password?: string;
}>('/admin/users', data), }>('/admin/users', data),
// 编辑用户 // 编辑用户
updateUser: (userId: string, data: { updateUser: (userId: string, data: {
display_name?: string; display_name?: string;
@@ -678,7 +707,7 @@ export const adminApi = {
message: string; message: string;
user: User; user: User;
}>(`/admin/users/${userId}`, data), }>(`/admin/users/${userId}`, data),
// 切换用户状态(启用/禁用) // 切换用户状态(启用/禁用)
toggleUserStatus: (userId: string, isActive: boolean) => toggleUserStatus: (userId: string, isActive: boolean) =>
api.post<unknown, { api.post<unknown, {
@@ -686,7 +715,7 @@ export const adminApi = {
message: string; message: string;
is_active: boolean; is_active: boolean;
}>(`/admin/users/${userId}/toggle-status`, { is_active: isActive }), }>(`/admin/users/${userId}/toggle-status`, { is_active: isActive }),
// 重置密码 // 重置密码
resetPassword: (userId: string, newPassword?: string) => resetPassword: (userId: string, newPassword?: string) =>
api.post<unknown, { api.post<unknown, {
@@ -694,7 +723,7 @@ export const adminApi = {
message: string; message: string;
new_password: string; new_password: string;
}>(`/admin/users/${userId}/reset-password`, { new_password: newPassword }), }>(`/admin/users/${userId}/reset-password`, { new_password: newPassword }),
// 删除用户 // 删除用户
deleteUser: (userId: string) => deleteUser: (userId: string) =>
api.delete<unknown, { api.delete<unknown, {
+27 -4
View File
@@ -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 {
@@ -34,7 +35,7 @@ export class SSEClient {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
this.eventSource = new EventSource(this.url); this.eventSource = new EventSource(this.url);
this.eventSource.onmessage = (event) => { this.eventSource.onmessage = (event) => {
try { try {
const message: SSEMessage = JSON.parse(event.data); const message: SSEMessage = JSON.parse(event.data);
@@ -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();
@@ -169,7 +171,7 @@ export class SSEPostClient {
} }
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n'); const lines = buffer.split('\n\n');
buffer = lines.pop() || ''; buffer = lines.pop() || '';
@@ -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);