update:1.小说项目创建支持双模式生成,大纲-章节(一对一&一对多) 2.新增章节管理-编辑章节规划功能 3.修复灵感模式可重复点击选项问题,刷新对话内容丢失问题
This commit is contained in:
+198
-9
@@ -28,7 +28,8 @@ from app.schemas.chapter import (
|
||||
ChapterGenerateRequest,
|
||||
BatchGenerateRequest,
|
||||
BatchGenerateResponse,
|
||||
BatchGenerateStatusResponse
|
||||
BatchGenerateStatusResponse,
|
||||
ExpansionPlanUpdate
|
||||
)
|
||||
from app.schemas.regeneration import (
|
||||
ChapterRegenerateRequest,
|
||||
@@ -1008,6 +1009,10 @@ async def generate_chapter_content_stream(
|
||||
yield f"data: {json.dumps({'type': 'error', 'error': '项目不存在'}, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
|
||||
# 获取项目的大纲模式
|
||||
outline_mode = project.outline_mode if project else 'one-to-many'
|
||||
logger.info(f"📋 项目大纲模式: {outline_mode}")
|
||||
|
||||
# 获取对应的大纲
|
||||
outline_result = await db_session.execute(
|
||||
select(Outline)
|
||||
@@ -1188,6 +1193,48 @@ async def generate_chapter_content_stream(
|
||||
logger.warning(f"⚠️ MCP工具调用失败,降级为基础模式: {str(e)}")
|
||||
yield f"data: {json.dumps({'type': 'progress', 'message': '⚠️ MCP工具暂时不可用,使用基础模式', 'progress': 32}, ensure_ascii=False)}\n\n"
|
||||
|
||||
# 📋 根据大纲模式构建差异化的章节大纲上下文
|
||||
chapter_outline_content = ""
|
||||
if outline_mode == 'one-to-one':
|
||||
# 一对一模式:使用大纲的 content
|
||||
chapter_outline_content = outline.content if outline else current_chapter.summary or '暂无大纲'
|
||||
logger.info(f"✏️ 一对一模式:使用大纲内容作为章节指导")
|
||||
else:
|
||||
# 一对多模式:优先使用 expansion_plan 的详细规划
|
||||
if current_chapter.expansion_plan:
|
||||
try:
|
||||
plan = json.loads(current_chapter.expansion_plan)
|
||||
chapter_outline_content = f"""【本章详细规划】
|
||||
剧情摘要:{plan.get('plot_summary', '无')}
|
||||
|
||||
关键事件:
|
||||
{chr(10).join(f'- {event}' for event in plan.get('key_events', []))}
|
||||
|
||||
角色焦点:{', '.join(plan.get('character_focus', []))}
|
||||
|
||||
情感基调:{plan.get('emotional_tone', '未设定')}
|
||||
|
||||
叙事目标:{plan.get('narrative_goal', '未设定')}
|
||||
|
||||
冲突类型:{plan.get('conflict_type', '未设定')}"""
|
||||
|
||||
# 可选:附加章节 summary
|
||||
if current_chapter.summary and current_chapter.summary.strip():
|
||||
chapter_outline_content += f"\n\n【章节补充说明】\n{current_chapter.summary}"
|
||||
|
||||
# 可选:附加大纲的背景信息
|
||||
if outline:
|
||||
chapter_outline_content += f"\n\n【大纲节点背景】\n{outline.content}"
|
||||
|
||||
logger.info(f"✏️ 一对多模式:使用expansion_plan详细规划({len(chapter_outline_content)}字符)")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"⚠️ expansion_plan解析失败: {e},回退到大纲内容")
|
||||
chapter_outline_content = outline.content if outline else current_chapter.summary or '暂无大纲'
|
||||
else:
|
||||
# 没有expansion_plan,使用大纲内容
|
||||
chapter_outline_content = outline.content if outline else current_chapter.summary or '暂无大纲'
|
||||
logger.warning(f"⚠️ 一对多模式但无expansion_plan,使用大纲内容")
|
||||
|
||||
# 根据是否有前置内容选择不同的提示词,并应用写作风格、记忆增强和MCP参考资料
|
||||
if previous_content:
|
||||
prompt = prompt_service.get_chapter_generation_with_context_prompt(
|
||||
@@ -1204,11 +1251,12 @@ async def generate_chapter_content_stream(
|
||||
previous_content=previous_content,
|
||||
chapter_number=current_chapter.chapter_number,
|
||||
chapter_title=current_chapter.title,
|
||||
chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲',
|
||||
chapter_outline=chapter_outline_content,
|
||||
style_content=style_content,
|
||||
target_word_count=target_word_count,
|
||||
memory_context=memory_context,
|
||||
mcp_references=mcp_reference_materials
|
||||
mcp_references=mcp_reference_materials,
|
||||
outline_mode=outline_mode
|
||||
)
|
||||
else:
|
||||
prompt = prompt_service.get_chapter_generation_prompt(
|
||||
@@ -1224,11 +1272,12 @@ async def generate_chapter_content_stream(
|
||||
outlines_context=outlines_context,
|
||||
chapter_number=current_chapter.chapter_number,
|
||||
chapter_title=current_chapter.title,
|
||||
chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲',
|
||||
chapter_outline=chapter_outline_content,
|
||||
style_content=style_content,
|
||||
target_word_count=target_word_count,
|
||||
memory_context=memory_context,
|
||||
mcp_references=mcp_reference_materials
|
||||
mcp_references=mcp_reference_materials,
|
||||
outline_mode=outline_mode
|
||||
)
|
||||
|
||||
if mcp_reference_materials:
|
||||
@@ -1238,11 +1287,39 @@ async def generate_chapter_content_stream(
|
||||
|
||||
# 流式生成内容
|
||||
full_content = ""
|
||||
chunk_count = 0
|
||||
last_progress = 0
|
||||
|
||||
async for chunk in user_ai_service.generate_text_stream(prompt=prompt):
|
||||
full_content += chunk
|
||||
chunk_count += 1
|
||||
|
||||
# 发送内容块
|
||||
yield f"data: {json.dumps({'type': 'content', 'content': chunk}, ensure_ascii=False)}\n\n"
|
||||
|
||||
# 每50个chunk发送一次进度更新(估算)
|
||||
if chunk_count % 50 == 0:
|
||||
current_word_count = len(full_content)
|
||||
# 根据目标字数估算进度(35%起步,最高95%,为后续保存留5%)
|
||||
estimated_progress = min(95, 35 + int((current_word_count / target_word_count) * 60))
|
||||
|
||||
# 只在进度变化时发送
|
||||
if estimated_progress > last_progress:
|
||||
progress_data = {
|
||||
'type': 'progress',
|
||||
'progress': estimated_progress,
|
||||
'message': f'正在创作中... 已生成 {current_word_count} 字',
|
||||
'word_count': current_word_count,
|
||||
'status': 'processing'
|
||||
}
|
||||
yield f"data: {json.dumps(progress_data, ensure_ascii=False)}\n\n"
|
||||
last_progress = estimated_progress
|
||||
|
||||
await asyncio.sleep(0) # 让出控制权
|
||||
|
||||
# 发送保存进度
|
||||
yield f"data: {json.dumps({'type': 'progress', 'progress': 98, 'message': '正在保存章节...', 'status': 'processing'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
# 更新章节内容到数据库
|
||||
old_word_count = current_chapter.word_count or 0
|
||||
current_chapter.content = full_content
|
||||
@@ -1297,6 +1374,9 @@ async def generate_chapter_content_stream(
|
||||
ai_service=user_ai_service
|
||||
)
|
||||
|
||||
# 发送最终进度100%
|
||||
yield f"data: {json.dumps({'type': 'progress', 'progress': 100, 'message': '创作完成!', 'word_count': new_word_count, 'status': 'success'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
# 发送完成事件(包含分析任务ID)
|
||||
completion_data = {
|
||||
'type': 'done',
|
||||
@@ -2216,6 +2296,10 @@ async def generate_single_chapter_for_batch(
|
||||
if not project:
|
||||
raise Exception("项目不存在")
|
||||
|
||||
# 获取项目的大纲模式
|
||||
outline_mode = project.outline_mode if project else 'one-to-many'
|
||||
logger.info(f"📋 批量生成 - 项目大纲模式: {outline_mode}")
|
||||
|
||||
# 获取对应的大纲
|
||||
outline_result = await db_session.execute(
|
||||
select(Outline)
|
||||
@@ -2285,6 +2369,48 @@ async def generate_single_chapter_for_batch(
|
||||
character_names=[c.name for c in characters] if characters else None
|
||||
)
|
||||
|
||||
# 📋 根据大纲模式构建差异化的章节大纲上下文
|
||||
chapter_outline_content = ""
|
||||
if outline_mode == 'one-to-one':
|
||||
# 一对一模式:使用大纲的 content
|
||||
chapter_outline_content = outline.content if outline else chapter.summary or '暂无大纲'
|
||||
logger.info(f"✏️ 批量生成 - 一对一模式:使用大纲内容")
|
||||
else:
|
||||
# 一对多模式:优先使用 expansion_plan 的详细规划
|
||||
if chapter.expansion_plan:
|
||||
try:
|
||||
plan = json.loads(chapter.expansion_plan)
|
||||
chapter_outline_content = f"""【本章详细规划】
|
||||
剧情摘要:{plan.get('plot_summary', '无')}
|
||||
|
||||
关键事件:
|
||||
{chr(10).join(f'- {event}' for event in plan.get('key_events', []))}
|
||||
|
||||
角色焦点:{', '.join(plan.get('character_focus', []))}
|
||||
|
||||
情感基调:{plan.get('emotional_tone', '未设定')}
|
||||
|
||||
叙事目标:{plan.get('narrative_goal', '未设定')}
|
||||
|
||||
冲突类型:{plan.get('conflict_type', '未设定')}"""
|
||||
|
||||
# 可选:附加章节 summary
|
||||
if chapter.summary and chapter.summary.strip():
|
||||
chapter_outline_content += f"\n\n【章节补充说明】\n{chapter.summary}"
|
||||
|
||||
# 可选:附加大纲的背景信息
|
||||
if outline:
|
||||
chapter_outline_content += f"\n\n【大纲节点背景】\n{outline.content}"
|
||||
|
||||
logger.info(f"✏️ 批量生成 - 一对多模式:使用expansion_plan详细规划")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"⚠️ expansion_plan解析失败: {e},回退到大纲内容")
|
||||
chapter_outline_content = outline.content if outline else chapter.summary or '暂无大纲'
|
||||
else:
|
||||
# 没有expansion_plan,使用大纲内容
|
||||
chapter_outline_content = outline.content if outline else chapter.summary or '暂无大纲'
|
||||
logger.warning(f"⚠️ 批量生成 - 一对多模式但无expansion_plan,使用大纲内容")
|
||||
|
||||
# 生成提示词
|
||||
if previous_content:
|
||||
prompt = prompt_service.get_chapter_generation_with_context_prompt(
|
||||
@@ -2301,10 +2427,11 @@ async def generate_single_chapter_for_batch(
|
||||
previous_content=previous_content,
|
||||
chapter_number=chapter.chapter_number,
|
||||
chapter_title=chapter.title,
|
||||
chapter_outline=outline.content if outline else chapter.summary or '暂无大纲',
|
||||
chapter_outline=chapter_outline_content,
|
||||
style_content=style_content,
|
||||
target_word_count=target_word_count,
|
||||
memory_context=memory_context
|
||||
memory_context=memory_context,
|
||||
outline_mode=outline_mode
|
||||
)
|
||||
else:
|
||||
prompt = prompt_service.get_chapter_generation_prompt(
|
||||
@@ -2320,10 +2447,11 @@ async def generate_single_chapter_for_batch(
|
||||
outlines_context=outlines_context,
|
||||
chapter_number=chapter.chapter_number,
|
||||
chapter_title=chapter.title,
|
||||
chapter_outline=outline.content if outline else chapter.summary or '暂无大纲',
|
||||
chapter_outline=chapter_outline_content,
|
||||
style_content=style_content,
|
||||
target_word_count=target_word_count,
|
||||
memory_context=memory_context
|
||||
memory_context=memory_context,
|
||||
outline_mode=outline_mode
|
||||
)
|
||||
|
||||
# 非流式生成内容
|
||||
@@ -2643,3 +2771,64 @@ async def get_regeneration_tasks(
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{chapter_id}/expansion-plan", response_model=dict, summary="更新章节规划信息")
|
||||
async def update_chapter_expansion_plan(
|
||||
chapter_id: str,
|
||||
expansion_plan: ExpansionPlanUpdate,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
更新章节的展开规划信息
|
||||
|
||||
Args:
|
||||
chapter_id: 章节ID
|
||||
expansion_plan: 规划信息更新数据
|
||||
|
||||
Returns:
|
||||
更新后的章节规划信息
|
||||
"""
|
||||
# 获取章节
|
||||
result = await db.execute(
|
||||
select(Chapter).where(Chapter.id == chapter_id)
|
||||
)
|
||||
chapter = result.scalar_one_or_none()
|
||||
|
||||
if not chapter:
|
||||
raise HTTPException(status_code=404, detail="章节不存在")
|
||||
|
||||
# 验证用户权限
|
||||
user_id = getattr(request.state, 'user_id', None)
|
||||
await verify_project_access(chapter.project_id, user_id, db)
|
||||
|
||||
# 准备更新数据(排除None值)
|
||||
plan_data = expansion_plan.model_dump(exclude_unset=True, exclude_none=True)
|
||||
|
||||
# 如果已有规划,合并更新;否则创建新规划
|
||||
if chapter.expansion_plan:
|
||||
try:
|
||||
existing_plan = json.loads(chapter.expansion_plan)
|
||||
# 合并更新
|
||||
existing_plan.update(plan_data)
|
||||
chapter.expansion_plan = json.dumps(existing_plan, ensure_ascii=False)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"章节 {chapter_id} 的expansion_plan格式错误,将覆盖")
|
||||
chapter.expansion_plan = json.dumps(plan_data, ensure_ascii=False)
|
||||
else:
|
||||
chapter.expansion_plan = json.dumps(plan_data, ensure_ascii=False)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(chapter)
|
||||
|
||||
logger.info(f"章节规划更新成功: {chapter_id}")
|
||||
|
||||
# 返回更新后的规划数据
|
||||
updated_plan = json.loads(chapter.expansion_plan) if chapter.expansion_plan else None
|
||||
|
||||
return {
|
||||
"id": chapter.id,
|
||||
"expansion_plan": updated_plan,
|
||||
"message": "规划信息更新成功"
|
||||
}
|
||||
|
||||
|
||||
+174
-11
@@ -236,18 +236,29 @@ async def delete_outline(
|
||||
|
||||
# 验证用户权限
|
||||
user_id = getattr(request.state, 'user_id', None)
|
||||
await verify_project_access(outline.project_id, user_id, db)
|
||||
project = await verify_project_access(outline.project_id, user_id, db)
|
||||
|
||||
project_id = outline.project_id
|
||||
deleted_order = outline.order_index
|
||||
|
||||
# 删除该大纲对应的所有章节(通过outline_id关联)
|
||||
delete_result = await db.execute(
|
||||
delete(Chapter).where(Chapter.outline_id == outline_id)
|
||||
)
|
||||
deleted_chapters_count = delete_result.rowcount
|
||||
|
||||
logger.info(f"删除大纲 {outline_id},同时删除了 {deleted_chapters_count} 个关联章节")
|
||||
# 根据项目模式删除对应的章节
|
||||
if project.outline_mode == 'one-to-one':
|
||||
# one-to-one模式:通过chapter_number删除对应章节
|
||||
delete_result = await db.execute(
|
||||
delete(Chapter).where(
|
||||
Chapter.project_id == project_id,
|
||||
Chapter.chapter_number == outline.order_index
|
||||
)
|
||||
)
|
||||
deleted_chapters_count = delete_result.rowcount
|
||||
logger.info(f"一对一模式:删除大纲 {outline_id}(序号{outline.order_index}),同时删除了第{outline.order_index}章({deleted_chapters_count}个章节)")
|
||||
else:
|
||||
# one-to-many模式:通过outline_id删除关联章节
|
||||
delete_result = await db.execute(
|
||||
delete(Chapter).where(Chapter.outline_id == outline_id)
|
||||
)
|
||||
deleted_chapters_count = delete_result.rowcount
|
||||
logger.info(f"一对多模式:删除大纲 {outline_id},同时删除了 {deleted_chapters_count} 个关联章节")
|
||||
|
||||
# 删除大纲
|
||||
await db.delete(outline)
|
||||
@@ -264,6 +275,21 @@ async def delete_outline(
|
||||
for o in subsequent_outlines:
|
||||
o.order_index -= 1
|
||||
|
||||
# 如果是one-to-one模式,还需要重新排序后续章节的chapter_number
|
||||
if project.outline_mode == 'one-to-one':
|
||||
chapters_result = await db.execute(
|
||||
select(Chapter).where(
|
||||
Chapter.project_id == project_id,
|
||||
Chapter.chapter_number > deleted_order
|
||||
).order_by(Chapter.chapter_number)
|
||||
)
|
||||
subsequent_chapters = chapters_result.scalars().all()
|
||||
|
||||
for ch in subsequent_chapters:
|
||||
ch.chapter_number -= 1
|
||||
|
||||
logger.info(f"一对一模式:重新排序了 {len(subsequent_chapters)} 个后续章节")
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
@@ -852,7 +878,17 @@ async def _save_outlines(
|
||||
db: AsyncSession,
|
||||
start_index: int = 1
|
||||
) -> List[Outline]:
|
||||
"""保存大纲到数据库(不自动创建章节)"""
|
||||
"""
|
||||
保存大纲到数据库
|
||||
|
||||
如果项目为one-to-one模式,同时自动创建对应的章节
|
||||
"""
|
||||
# 获取项目信息以确定outline_mode
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == project_id)
|
||||
)
|
||||
project = project_result.scalar_one_or_none()
|
||||
|
||||
outlines = []
|
||||
|
||||
for idx, chapter_data in enumerate(outline_data):
|
||||
@@ -879,6 +915,28 @@ async def _save_outlines(
|
||||
db.add(outline)
|
||||
outlines.append(outline)
|
||||
|
||||
# 如果是one-to-one模式,自动创建章节
|
||||
if project and project.outline_mode == 'one-to-one':
|
||||
await db.flush() # 确保大纲有ID
|
||||
|
||||
for outline in outlines:
|
||||
await db.refresh(outline)
|
||||
|
||||
# 为每个大纲创建对应的章节
|
||||
chapter = Chapter(
|
||||
project_id=project_id,
|
||||
title=outline.title,
|
||||
summary=outline.content,
|
||||
chapter_number=outline.order_index,
|
||||
sub_index=1,
|
||||
outline_id=None, # one-to-one模式不关联outline_id
|
||||
status='pending',
|
||||
content=""
|
||||
)
|
||||
db.add(chapter)
|
||||
|
||||
logger.info(f"一对一模式:为{len(outlines)}个大纲自动创建了对应的章节")
|
||||
|
||||
return outlines
|
||||
|
||||
|
||||
@@ -1646,6 +1704,104 @@ async def expand_outline_generator(
|
||||
yield await SSEResponse.send_error(f"展开失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/{outline_id}/create-single-chapter", summary="一对一创建章节(传统模式)")
|
||||
async def create_single_chapter_from_outline(
|
||||
outline_id: str,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
传统模式:一个大纲对应创建一个章节
|
||||
|
||||
适用场景:
|
||||
- 项目的outline_mode为'one-to-one'
|
||||
- 直接将大纲内容作为章节摘要
|
||||
- 不调用AI,不展开
|
||||
|
||||
流程:
|
||||
1. 验证项目模式为one-to-one
|
||||
2. 检查该大纲是否已创建章节
|
||||
3. 创建章节记录(outline_id=NULL,chapter_number=outline.order_index)
|
||||
|
||||
返回:创建的章节信息
|
||||
"""
|
||||
# 验证用户权限
|
||||
user_id = getattr(request.state, 'user_id', None)
|
||||
|
||||
# 获取大纲
|
||||
result = await db.execute(
|
||||
select(Outline).where(Outline.id == outline_id)
|
||||
)
|
||||
outline = result.scalar_one_or_none()
|
||||
|
||||
if not outline:
|
||||
raise HTTPException(status_code=404, detail="大纲不存在")
|
||||
|
||||
# 验证项目权限并获取项目信息
|
||||
project = await verify_project_access(outline.project_id, user_id, db)
|
||||
|
||||
# 验证项目模式
|
||||
if project.outline_mode != 'one-to-one':
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"当前项目为{project.outline_mode}模式,不支持一对一创建。请使用展开功能。"
|
||||
)
|
||||
|
||||
# 检查该大纲对应的章节是否已存在
|
||||
existing_chapter_result = await db.execute(
|
||||
select(Chapter).where(
|
||||
Chapter.project_id == outline.project_id,
|
||||
Chapter.chapter_number == outline.order_index,
|
||||
Chapter.sub_index == 1
|
||||
)
|
||||
)
|
||||
existing_chapter = existing_chapter_result.scalar_one_or_none()
|
||||
|
||||
if existing_chapter:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"第{outline.order_index}章已存在,不能重复创建"
|
||||
)
|
||||
|
||||
try:
|
||||
# 创建章节(outline_id=NULL表示一对一模式)
|
||||
new_chapter = Chapter(
|
||||
project_id=outline.project_id,
|
||||
title=outline.title,
|
||||
summary=outline.content, # 使用大纲内容作为摘要
|
||||
chapter_number=outline.order_index,
|
||||
sub_index=1, # 一对一模式固定为1
|
||||
outline_id=None, # 传统模式不关联outline_id
|
||||
status='pending'
|
||||
)
|
||||
|
||||
db.add(new_chapter)
|
||||
await db.commit()
|
||||
await db.refresh(new_chapter)
|
||||
|
||||
logger.info(f"一对一模式:为大纲 {outline.title} 创建章节 {new_chapter.chapter_number}")
|
||||
|
||||
return {
|
||||
"message": "章节创建成功",
|
||||
"chapter": {
|
||||
"id": new_chapter.id,
|
||||
"project_id": new_chapter.project_id,
|
||||
"title": new_chapter.title,
|
||||
"summary": new_chapter.summary,
|
||||
"chapter_number": new_chapter.chapter_number,
|
||||
"sub_index": new_chapter.sub_index,
|
||||
"outline_id": new_chapter.outline_id,
|
||||
"status": new_chapter.status,
|
||||
"created_at": new_chapter.created_at.isoformat() if new_chapter.created_at else None
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"一对一创建章节失败: {str(e)}", exc_info=True)
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"创建章节失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/{outline_id}/expand", response_model=OutlineExpansionResponse, summary="展开单个大纲为多章")
|
||||
async def expand_outline_to_chapters(
|
||||
outline_id: str,
|
||||
@@ -1681,8 +1837,15 @@ async def expand_outline_to_chapters(
|
||||
if not outline:
|
||||
raise HTTPException(status_code=404, detail="大纲不存在")
|
||||
|
||||
# 验证项目权限
|
||||
await verify_project_access(outline.project_id, user_id, db)
|
||||
# 验证项目权限并获取项目信息
|
||||
project = await verify_project_access(outline.project_id, user_id, db)
|
||||
|
||||
# 验证项目模式
|
||||
if project.outline_mode != 'one-to-many':
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"当前项目为{project.outline_mode}模式,不支持展开功能。请使用一对一创建。"
|
||||
)
|
||||
|
||||
try:
|
||||
# 创建展开服务实例
|
||||
|
||||
@@ -354,9 +354,8 @@ async def export_project_chapters(
|
||||
txt_content.append("\n" + "=" * 80 + "\n\n")
|
||||
|
||||
for chapter in chapters:
|
||||
# 处理子章节序号显示
|
||||
chapter_display = f"{chapter.chapter_number}-{chapter.sub_index}" if chapter.sub_index and chapter.sub_index > 1 else str(chapter.chapter_number)
|
||||
txt_content.append(f"第 {chapter_display} 章 {chapter.title}")
|
||||
# 只显示主章节号,不显示子索引
|
||||
txt_content.append(f"第 {chapter.chapter_number} 章 {chapter.title}")
|
||||
txt_content.append("-" * 80)
|
||||
txt_content.append("") # 空行
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ async def world_building_generator(
|
||||
target_words = data.get("target_words")
|
||||
chapter_count = data.get("chapter_count")
|
||||
character_count = data.get("character_count")
|
||||
outline_mode = data.get("outline_mode", "one-to-many") # 大纲模式,默认一对多
|
||||
provider = data.get("provider")
|
||||
model = data.get("model")
|
||||
enable_mcp = data.get("enable_mcp", True) # 默认启用MCP
|
||||
@@ -215,6 +216,7 @@ async def world_building_generator(
|
||||
target_words=target_words,
|
||||
chapter_count=chapter_count,
|
||||
character_count=character_count,
|
||||
outline_mode=outline_mode, # 设置大纲模式
|
||||
wizard_status="incomplete",
|
||||
wizard_step=1,
|
||||
status="planning"
|
||||
@@ -1017,39 +1019,82 @@ async def outline_generator(
|
||||
|
||||
logger.info(f"✅ 成功创建{len(created_outlines)}个大纲节点")
|
||||
|
||||
# 向导流程中不展开大纲,避免等待时间过长
|
||||
# 用户可以在大纲页面手动展开需要的大纲节点
|
||||
yield await SSEResponse.send_progress("跳过大纲展开,加快创建速度...", 85)
|
||||
# 根据项目的大纲模式决定是否自动创建章节
|
||||
created_chapters = []
|
||||
if project.outline_mode == 'one-to-one':
|
||||
# 一对一模式:自动为每个大纲创建对应的章节
|
||||
yield await SSEResponse.send_progress("一对一模式:自动创建章节...", 50)
|
||||
|
||||
for outline in created_outlines:
|
||||
chapter = Chapter(
|
||||
project_id=project_id,
|
||||
title=outline.title,
|
||||
content="", # 空内容,等待用户生成
|
||||
outline_id=None, # 一对一模式下不关联outline_id
|
||||
chapter_number=outline.order_index, # 使用chapter_number而不是order_index
|
||||
status="pending"
|
||||
)
|
||||
db.add(chapter)
|
||||
created_chapters.append(chapter)
|
||||
|
||||
await db.flush()
|
||||
for chapter in created_chapters:
|
||||
await db.refresh(chapter)
|
||||
|
||||
logger.info(f"✅ 一对一模式:自动创建了{len(created_chapters)}个章节")
|
||||
yield await SSEResponse.send_progress(f"已自动创建{len(created_chapters)}个章节", 85)
|
||||
else:
|
||||
# 一对多模式:跳过自动创建,用户可手动展开
|
||||
yield await SSEResponse.send_progress("细化模式:跳过自动创建章节", 85)
|
||||
logger.info(f"📝 细化模式:跳过章节创建,用户可在大纲页面手动展开")
|
||||
|
||||
# 更新项目信息
|
||||
project.chapter_count = 0 # 向导阶段不创建章节
|
||||
project.chapter_count = len(created_chapters) # 记录实际创建的章节数
|
||||
project.narrative_perspective = narrative_perspective
|
||||
project.target_words = target_words
|
||||
project.status = "writing"
|
||||
project.wizard_status = "completed"
|
||||
project.wizard_step = 3
|
||||
project.wizard_step = 3
|
||||
|
||||
await db.commit()
|
||||
db_committed = True
|
||||
|
||||
logger.info(f"📊 向导大纲生成完成:")
|
||||
logger.info(f" - 创建大纲节点:{len(created_outlines)} 个")
|
||||
logger.info(f" - 提示:可在大纲页面手动展开为章节")
|
||||
logger.info(f" - 创建章节:{len(created_chapters)} 个")
|
||||
logger.info(f" - 大纲模式:{project.outline_mode}")
|
||||
|
||||
# 构建结果消息
|
||||
if project.outline_mode == 'one-to-one':
|
||||
result_message = f"成功生成{len(created_outlines)}个大纲节点并自动创建{len(created_chapters)}个章节(传统模式)"
|
||||
result_note = "已自动创建章节,可直接生成内容"
|
||||
else:
|
||||
result_message = f"成功生成{len(created_outlines)}个大纲节点(细化模式,可在大纲页面手动展开)"
|
||||
result_note = "可在大纲页面展开为多个章节"
|
||||
|
||||
# 发送结果
|
||||
yield await SSEResponse.send_result({
|
||||
"message": f"成功生成{len(created_outlines)}个大纲节点(未展开章节,可在大纲页面手动展开)",
|
||||
"message": result_message,
|
||||
"outline_count": len(created_outlines),
|
||||
"chapter_count": 0,
|
||||
"chapter_count": len(created_chapters),
|
||||
"outline_mode": project.outline_mode,
|
||||
"outlines": [
|
||||
{
|
||||
"id": outline.id,
|
||||
"order_index": outline.order_index,
|
||||
"title": outline.title,
|
||||
"content": outline.content[:100] + "..." if len(outline.content) > 100 else outline.content,
|
||||
"note": "可在大纲页面展开为章节"
|
||||
"note": result_note
|
||||
} for outline in created_outlines
|
||||
]
|
||||
],
|
||||
"chapters": [
|
||||
{
|
||||
"id": chapter.id,
|
||||
"chapter_number": chapter.chapter_number,
|
||||
"title": chapter.title,
|
||||
"status": chapter.status
|
||||
} for chapter in created_chapters
|
||||
] if created_chapters else []
|
||||
})
|
||||
|
||||
yield await SSEResponse.send_progress("完成!", 100, "success")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""项目数据模型"""
|
||||
from sqlalchemy import Column, String, Text, DateTime, Integer
|
||||
from sqlalchemy import Column, String, Text, DateTime, Integer, CheckConstraint
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
import uuid
|
||||
@@ -20,6 +20,7 @@ class Project(Base):
|
||||
status = Column(String(20), default="planning", comment="创作状态")
|
||||
wizard_status = Column(String(20), default="incomplete", comment="向导完成状态: incomplete/completed")
|
||||
wizard_step = Column(Integer, default=0, comment="向导当前步骤: 0-4")
|
||||
outline_mode = Column(String(20), nullable=False, default="one-to-many", comment="大纲章节模式: one-to-one(传统模式) 或 one-to-many(细化模式)")
|
||||
|
||||
# 世界构建字段
|
||||
world_time_period = Column(Text, comment="时间背景")
|
||||
@@ -35,5 +36,12 @@ class Project(Base):
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"outline_mode IN ('one-to-one', 'one-to-many')",
|
||||
name='check_outline_mode'
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Project(id={self.id}, title={self.title})>"
|
||||
@@ -1,6 +1,6 @@
|
||||
"""章节相关的Pydantic模型"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@@ -118,4 +118,48 @@ class BatchGenerateStatusResponse(BaseModel):
|
||||
created_at: Optional[str] = None
|
||||
started_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
class SceneData(BaseModel):
|
||||
"""场景数据模型"""
|
||||
location: str = Field(..., description="场景地点")
|
||||
characters: List[str] = Field(..., description="参与角色列表")
|
||||
purpose: str = Field(..., description="场景目的")
|
||||
|
||||
|
||||
class ExpansionPlanUpdate(BaseModel):
|
||||
"""章节规划更新模型"""
|
||||
key_events: Optional[List[str]] = Field(None, description="关键事件列表")
|
||||
character_focus: Optional[List[str]] = Field(None, description="涉及角色列表")
|
||||
emotional_tone: Optional[str] = Field(None, description="情感基调")
|
||||
narrative_goal: Optional[str] = Field(None, description="叙事目标")
|
||||
conflict_type: Optional[str] = Field(None, description="冲突类型")
|
||||
estimated_words: Optional[int] = Field(None, description="预估字数", ge=500, le=10000)
|
||||
scenes: Optional[List[SceneData]] = Field(None, description="场景列表")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"key_events": ["主角遇到挑战", "关键决策时刻"],
|
||||
"character_focus": ["张三", "李四"],
|
||||
"emotional_tone": "紧张激烈",
|
||||
"narrative_goal": "推进主线剧情",
|
||||
"conflict_type": "内心冲突",
|
||||
"estimated_words": 3000,
|
||||
"scenes": [
|
||||
{
|
||||
"location": "城市广场",
|
||||
"characters": ["张三", "李四"],
|
||||
"purpose": "初次相遇"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ExpansionPlanResponse(BaseModel):
|
||||
"""章节规划响应模型"""
|
||||
id: str = Field(..., description="章节ID")
|
||||
expansion_plan: Optional[Dict[str, Any]] = Field(None, description="规划数据")
|
||||
message: str = Field(..., description="响应消息")
|
||||
@@ -1,6 +1,6 @@
|
||||
"""项目相关的Pydantic模型"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from typing import Optional, Literal
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ class ProjectBase(BaseModel):
|
||||
theme: Optional[str] = Field(None, description="主题")
|
||||
genre: Optional[str] = Field(None, description="小说类型")
|
||||
target_words: Optional[int] = Field(None, description="目标字数")
|
||||
outline_mode: Literal["one-to-one", "one-to-many"] = Field(
|
||||
default="one-to-many",
|
||||
description="大纲章节模式: one-to-one(传统模式,1大纲→1章节) 或 one-to-many(细化模式,1大纲→N章节)"
|
||||
)
|
||||
|
||||
|
||||
class ProjectCreate(ProjectBase):
|
||||
@@ -51,6 +55,7 @@ class ProjectResponse(ProjectBase):
|
||||
chapter_count: Optional[int] = None
|
||||
narrative_perspective: Optional[str] = None
|
||||
character_count: Optional[int] = None
|
||||
outline_mode: str # 显式声明以确保响应中包含
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -73,6 +78,10 @@ class ProjectWizardRequest(BaseModel):
|
||||
narrative_perspective: str = Field(..., description="叙事视角")
|
||||
character_count: int = Field(5, ge=5, description="角色数量(至少5个)")
|
||||
target_words: Optional[int] = Field(None, description="目标字数")
|
||||
outline_mode: Literal["one-to-one", "one-to-many"] = Field(
|
||||
default="one-to-many",
|
||||
description="大纲章节模式"
|
||||
)
|
||||
|
||||
|
||||
class WorldBuildingResponse(BaseModel):
|
||||
|
||||
@@ -207,14 +207,16 @@ class PromptService:
|
||||
4. **无特殊符号**:文本中不使用引号、方括号等特殊符号包裹内容
|
||||
5. **丰富细节**:每个字段提供充实的原创内容,避免模板化表达
|
||||
|
||||
# 反面示例(避免这样的设定)
|
||||
❌ 不好的设定:故事设定在大崩解后的XX纪元、新世界秩序、文明重启...
|
||||
✅ 好的设定:故事设定在2024年的深圳,互联网创业浪潮下的年轻人...
|
||||
请根据输入的类型和主题,生成**规模适当、风格匹配**的世界观设定。
|
||||
|
||||
❌ 不好的设定:升华纪元、共鸣指数、灵光纯度...(现代都市题材不要用这些)
|
||||
✅ 好的设定:通过高考分数、学历背景、家庭条件来衡量个人价值...(符合现实)
|
||||
# JSON格式示例
|
||||
|
||||
请根据输入的类型和主题,生成**规模适当、风格匹配**的世界观设定。"""
|
||||
{{
|
||||
"time_period": "时间背景与社会状态的详细描述(300-500字)",
|
||||
"location": "空间环境与地理特征的详细描述(300-500字)",
|
||||
"atmosphere": "感官体验与情感基调的详细描述(300-500字)",
|
||||
"rules": "世界规则与社会结构的详细描述(300-500字)"
|
||||
}}"""
|
||||
|
||||
# 批量角色生成提示词
|
||||
CHARACTERS_BATCH_GENERATION = """你是一位专业的角色设定师。请根据以下世界观和要求,生成{count}个立体丰满的角色和组织:
|
||||
@@ -1019,7 +1021,8 @@ class PromptService:
|
||||
chapter_outline: str, style_content: str = "",
|
||||
target_word_count: int = 3000,
|
||||
memory_context: dict = None,
|
||||
mcp_references: str = "") -> str:
|
||||
mcp_references: str = "",
|
||||
outline_mode: str = "one-to-many") -> str:
|
||||
"""
|
||||
获取章节完整创作提示词
|
||||
|
||||
@@ -1028,6 +1031,7 @@ class PromptService:
|
||||
target_word_count: 目标字数,默认3000字
|
||||
memory_context: 记忆上下文(可选)
|
||||
mcp_references: MCP工具搜索的参考资料(可选)
|
||||
outline_mode: 大纲模式 (one-to-one/one-to-many)
|
||||
"""
|
||||
# 计算最大字数(目标字数+1000)
|
||||
max_word_count = target_word_count + 1000
|
||||
@@ -1050,6 +1054,13 @@ class PromptService:
|
||||
mcp_text += mcp_references
|
||||
mcp_text += "\n"
|
||||
|
||||
# 根据大纲模式添加创作指导
|
||||
mode_instruction = ""
|
||||
if outline_mode == 'one-to-one':
|
||||
mode_instruction = "\n\n【创作模式说明】\n本章采用一对一模式:一个大纲节点对应一个章节。请充分展开大纲中的情节,注重叙事的完整性和丰满度。\n"
|
||||
else:
|
||||
mode_instruction = "\n\n【创作模式说明】\n本章采用细纲模式:本章是大纲节点的细化展开之一。请严格遵循上述详细规划中的剧情点、角色焦点和情感基调,确保与整体规划保持一致。\n"
|
||||
|
||||
base_prompt = cls.format_prompt(
|
||||
cls.CHAPTER_GENERATION,
|
||||
title=title,
|
||||
@@ -1079,7 +1090,13 @@ class PromptService:
|
||||
if insert_text:
|
||||
base_prompt = base_prompt.replace(
|
||||
"本章信息:",
|
||||
insert_text + "\n\n本章信息:"
|
||||
insert_text + mode_instruction + "\n\n本章信息:"
|
||||
)
|
||||
else:
|
||||
# 没有记忆和MCP时也要插入模式说明
|
||||
base_prompt = base_prompt.replace(
|
||||
"本章信息:",
|
||||
mode_instruction + "\n\n本章信息:"
|
||||
)
|
||||
|
||||
# 如果有风格要求,应用到提示词中
|
||||
@@ -1098,7 +1115,8 @@ class PromptService:
|
||||
style_content: str = "",
|
||||
target_word_count: int = 3000,
|
||||
memory_context: dict = None,
|
||||
mcp_references: str = "") -> str:
|
||||
mcp_references: str = "",
|
||||
outline_mode: str = "one-to-many") -> str:
|
||||
"""
|
||||
获取章节完整创作提示词(带前置章节上下文和记忆增强)
|
||||
|
||||
@@ -1107,6 +1125,7 @@ class PromptService:
|
||||
target_word_count: 目标字数,默认3000字
|
||||
memory_context: 记忆上下文(可选)
|
||||
mcp_references: MCP工具搜索的参考资料(可选)
|
||||
outline_mode: 大纲模式 (one-to-one/one-to-many)
|
||||
"""
|
||||
# 计算最大字数(目标字数+1000)
|
||||
max_word_count = target_word_count + 1000
|
||||
@@ -1128,6 +1147,13 @@ class PromptService:
|
||||
memory_text += "以下是通过MCP工具搜索到的相关参考资料,可用于丰富情节和细节:\n\n"
|
||||
memory_text += mcp_references
|
||||
|
||||
# 根据大纲模式添加创作指导
|
||||
mode_instruction = ""
|
||||
if outline_mode == 'one-to-one':
|
||||
mode_instruction = "\n\n【创作模式说明】\n本章采用一对一模式:一个大纲节点对应一个章节。请在承接前文的基础上,充分展开大纲中的情节,保持叙事的完整性。\n"
|
||||
else:
|
||||
mode_instruction = "\n\n【创作模式说明】\n本章采用细纲模式:本章是大纲节点的细化展开之一。请严格遵循上述详细规划(expansion_plan)中的剧情点、角色焦点、情感基调和叙事目标,确保与整体规划保持一致,同时自然衔接前文内容。\n"
|
||||
|
||||
base_prompt = cls.format_prompt(
|
||||
cls.CHAPTER_GENERATION_WITH_CONTEXT,
|
||||
title=title,
|
||||
@@ -1149,6 +1175,12 @@ class PromptService:
|
||||
memory_context=memory_text
|
||||
)
|
||||
|
||||
# 插入模式说明
|
||||
base_prompt = base_prompt.replace(
|
||||
"本章信息:",
|
||||
mode_instruction + "\n本章信息:"
|
||||
)
|
||||
|
||||
# 如果有风格要求,应用到提示词中
|
||||
if style_content:
|
||||
return WritingStyleManager.apply_style_to_prompt(base_prompt, style_content)
|
||||
|
||||
Reference in New Issue
Block a user