update:新增用户API配置提示 优化大纲全新/续写的分批生成
This commit is contained in:
+534
-67
@@ -2,7 +2,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, delete
|
||||
from typing import List
|
||||
from typing import List, AsyncGenerator, Dict, Any
|
||||
import json
|
||||
|
||||
from app.database import get_db
|
||||
@@ -23,6 +23,7 @@ from app.services.ai_service import AIService
|
||||
from app.services.prompt_service import prompt_service
|
||||
from app.logger import get_logger
|
||||
from app.api.settings import get_user_ai_service
|
||||
from app.utils.sse_response import SSEResponse, create_sse_response
|
||||
|
||||
router = APIRouter(prefix="/outlines", tags=["大纲管理"])
|
||||
logger = get_logger(__name__)
|
||||
@@ -479,27 +480,21 @@ async def _continue_outline(
|
||||
db: AsyncSession,
|
||||
user_ai_service: AIService
|
||||
) -> OutlineListResponse:
|
||||
"""续写大纲"""
|
||||
"""续写大纲 - 分批生成,每批5章"""
|
||||
logger.info(f"续写大纲 - 项目: {project.id}, 已有: {len(existing_outlines)} 章")
|
||||
|
||||
# 分析已有大纲
|
||||
current_chapter_count = len(existing_outlines)
|
||||
last_chapter_number = existing_outlines[-1].order_index
|
||||
|
||||
# 获取最近2章的剧情
|
||||
recent_outlines = existing_outlines[-2:] if len(existing_outlines) >= 2 else existing_outlines
|
||||
recent_plot = "\n".join([
|
||||
f"第{o.order_index}章《{o.title}》: {o.content}"
|
||||
for o in recent_outlines
|
||||
])
|
||||
# logger.debug(f"最近三章内容:{recent_plot}")
|
||||
# 全部章节概览
|
||||
all_chapters_brief = "\n".join([
|
||||
f"第{o.order_index}章: {o.title}"
|
||||
for o in existing_outlines
|
||||
])
|
||||
# 计算需要生成的总章数和批次
|
||||
total_chapters_to_generate = request.chapter_count
|
||||
batch_size = 5 # 每批生成5章
|
||||
total_batches = (total_chapters_to_generate + batch_size - 1) // batch_size
|
||||
|
||||
# 获取角色信息
|
||||
logger.info(f"分批生成计划: 总共{total_chapters_to_generate}章,分{total_batches}批,每批{batch_size}章")
|
||||
|
||||
# 获取角色信息(所有批次共用)
|
||||
characters_result = await db.execute(
|
||||
select(Character).where(Character.project_id == project.id)
|
||||
)
|
||||
@@ -518,65 +513,104 @@ async def _continue_outline(
|
||||
}
|
||||
stage_instruction = stage_instructions.get(request.plot_stage, "")
|
||||
|
||||
# 使用标准续写提示词模板
|
||||
prompt = prompt_service.get_outline_continue_prompt(
|
||||
title=project.title,
|
||||
theme=request.theme or project.theme or "未设定",
|
||||
genre=request.genre or project.genre or "通用",
|
||||
narrative_perspective=request.narrative_perspective,
|
||||
chapter_count=request.chapter_count,
|
||||
time_period=project.world_time_period or "未设定",
|
||||
location=project.world_location or "未设定",
|
||||
atmosphere=project.world_atmosphere or "未设定",
|
||||
rules=project.world_rules or "未设定",
|
||||
characters_info=characters_info or "暂无角色信息",
|
||||
current_chapter_count=current_chapter_count,
|
||||
all_chapters_brief=all_chapters_brief,
|
||||
recent_plot=recent_plot,
|
||||
plot_stage_instruction=stage_instruction,
|
||||
start_chapter=last_chapter_number + 1,
|
||||
story_direction=request.story_direction or "自然延续",
|
||||
requirements=request.requirements or ""
|
||||
)
|
||||
# 批量生成
|
||||
all_new_outlines = []
|
||||
current_start_chapter = last_chapter_number + 1
|
||||
|
||||
# 调用AI
|
||||
ai_response = await user_ai_service.generate_text(
|
||||
prompt=prompt,
|
||||
provider=request.provider,
|
||||
model=request.model
|
||||
)
|
||||
|
||||
# 解析响应
|
||||
outline_data = _parse_ai_response(ai_response)
|
||||
|
||||
# 保存续写的大纲
|
||||
new_outlines = await _save_outlines(
|
||||
project.id, outline_data, db, start_index=last_chapter_number + 1
|
||||
)
|
||||
|
||||
# 记录历史
|
||||
history = GenerationHistory(
|
||||
project_id=project.id,
|
||||
prompt=prompt,
|
||||
generated_content=ai_response,
|
||||
model=request.model or "default"
|
||||
)
|
||||
db.add(history)
|
||||
|
||||
await db.commit()
|
||||
|
||||
for outline in new_outlines:
|
||||
await db.refresh(outline)
|
||||
for batch_num in range(total_batches):
|
||||
# 计算当前批次的章节数
|
||||
remaining_chapters = total_chapters_to_generate - len(all_new_outlines)
|
||||
current_batch_size = min(batch_size, remaining_chapters)
|
||||
|
||||
logger.info(f"开始生成第{batch_num + 1}/{total_batches}批,章节范围: {current_start_chapter}-{current_start_chapter + current_batch_size - 1}")
|
||||
|
||||
# 获取最新的大纲列表(包括之前批次生成的)
|
||||
latest_result = await db.execute(
|
||||
select(Outline)
|
||||
.where(Outline.project_id == project.id)
|
||||
.order_by(Outline.order_index)
|
||||
)
|
||||
latest_outlines = latest_result.scalars().all()
|
||||
|
||||
# 获取最近2章的剧情
|
||||
recent_outlines = latest_outlines[-2:] if len(latest_outlines) >= 2 else latest_outlines
|
||||
recent_plot = "\n".join([
|
||||
f"第{o.order_index}章《{o.title}》: {o.content}"
|
||||
for o in recent_outlines
|
||||
])
|
||||
|
||||
# 全部章节概览
|
||||
all_chapters_brief = "\n".join([
|
||||
f"第{o.order_index}章: {o.title}"
|
||||
for o in latest_outlines
|
||||
])
|
||||
|
||||
# 使用标准续写提示词模板
|
||||
prompt = prompt_service.get_outline_continue_prompt(
|
||||
title=project.title,
|
||||
theme=request.theme or project.theme or "未设定",
|
||||
genre=request.genre or project.genre or "通用",
|
||||
narrative_perspective=request.narrative_perspective,
|
||||
chapter_count=current_batch_size, # 当前批次的章节数
|
||||
time_period=project.world_time_period or "未设定",
|
||||
location=project.world_location or "未设定",
|
||||
atmosphere=project.world_atmosphere or "未设定",
|
||||
rules=project.world_rules or "未设定",
|
||||
characters_info=characters_info or "暂无角色信息",
|
||||
current_chapter_count=len(latest_outlines),
|
||||
all_chapters_brief=all_chapters_brief,
|
||||
recent_plot=recent_plot,
|
||||
plot_stage_instruction=stage_instruction,
|
||||
start_chapter=current_start_chapter,
|
||||
story_direction=request.story_direction or "自然延续",
|
||||
requirements=request.requirements or ""
|
||||
)
|
||||
|
||||
# 调用AI生成当前批次
|
||||
logger.info(f"正在调用AI生成第{batch_num + 1}批...")
|
||||
ai_response = await user_ai_service.generate_text(
|
||||
prompt=prompt,
|
||||
provider=request.provider,
|
||||
model=request.model
|
||||
)
|
||||
|
||||
# 解析响应
|
||||
outline_data = _parse_ai_response(ai_response)
|
||||
|
||||
# 保存当前批次的大纲
|
||||
batch_outlines = await _save_outlines(
|
||||
project.id, outline_data, db, start_index=current_start_chapter
|
||||
)
|
||||
|
||||
# 记录历史
|
||||
history = GenerationHistory(
|
||||
project_id=project.id,
|
||||
prompt=f"[批次{batch_num + 1}/{total_batches}] {str(prompt)[:500]}",
|
||||
generated_content=ai_response,
|
||||
model=request.model or "default"
|
||||
)
|
||||
db.add(history)
|
||||
|
||||
# 提交当前批次
|
||||
await db.commit()
|
||||
|
||||
for outline in batch_outlines:
|
||||
await db.refresh(outline)
|
||||
|
||||
all_new_outlines.extend(batch_outlines)
|
||||
current_start_chapter += current_batch_size
|
||||
|
||||
logger.info(f"第{batch_num + 1}批生成完成,本批生成{len(batch_outlines)}章")
|
||||
|
||||
# 返回所有大纲(包括旧的和新的)
|
||||
all_result = await db.execute(
|
||||
final_result = await db.execute(
|
||||
select(Outline)
|
||||
.where(Outline.project_id == project.id)
|
||||
.order_by(Outline.order_index)
|
||||
)
|
||||
all_outlines = all_result.scalars().all()
|
||||
all_outlines = final_result.scalars().all()
|
||||
|
||||
logger.info(f"续写完成 - 新增 {len(new_outlines)} 章,总计 {len(all_outlines)} 章")
|
||||
logger.info(f"续写完成 - 共{total_batches}批,新增 {len(all_new_outlines)} 章,总计 {len(all_outlines)} 章")
|
||||
return OutlineListResponse(total=len(all_outlines), items=all_outlines)
|
||||
|
||||
|
||||
@@ -658,4 +692,437 @@ async def _save_outlines(
|
||||
)
|
||||
db.add(chapter)
|
||||
|
||||
return outlines
|
||||
return outlines
|
||||
|
||||
|
||||
async def new_outline_generator(
|
||||
data: Dict[str, Any],
|
||||
db: AsyncSession,
|
||||
user_ai_service: AIService
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""全新生成大纲SSE生成器"""
|
||||
db_committed = False
|
||||
try:
|
||||
yield await SSEResponse.send_progress("开始生成大纲...", 5)
|
||||
|
||||
project_id = data.get("project_id")
|
||||
# 确保chapter_count是整数(前端可能传字符串)
|
||||
chapter_count = int(data.get("chapter_count", 10))
|
||||
|
||||
# 验证项目
|
||||
yield await SSEResponse.send_progress("加载项目信息...", 10)
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
yield await SSEResponse.send_error("项目不存在", 404)
|
||||
return
|
||||
|
||||
yield await SSEResponse.send_progress(f"准备生成{chapter_count}章大纲...", 15)
|
||||
|
||||
# 获取角色信息
|
||||
characters_result = await db.execute(
|
||||
select(Character).where(Character.project_id == project_id)
|
||||
)
|
||||
characters = characters_result.scalars().all()
|
||||
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
|
||||
])
|
||||
|
||||
# 使用完整提示词
|
||||
yield await SSEResponse.send_progress("准备AI提示词...", 20)
|
||||
prompt = prompt_service.get_complete_outline_prompt(
|
||||
title=project.title,
|
||||
theme=data.get("theme") or project.theme or "未设定",
|
||||
genre=data.get("genre") or project.genre or "通用",
|
||||
chapter_count=chapter_count,
|
||||
narrative_perspective=data.get("narrative_perspective") or "第三人称",
|
||||
target_words=data.get("target_words") or project.target_words or 100000,
|
||||
time_period=project.world_time_period or "未设定",
|
||||
location=project.world_location or "未设定",
|
||||
atmosphere=project.world_atmosphere or "未设定",
|
||||
rules=project.world_rules or "未设定",
|
||||
characters_info=characters_info or "暂无角色信息",
|
||||
requirements=data.get("requirements") or ""
|
||||
)
|
||||
|
||||
# 调用AI
|
||||
yield await SSEResponse.send_progress("🤖 正在调用AI生成...", 30)
|
||||
ai_response = await user_ai_service.generate_text(
|
||||
prompt=prompt,
|
||||
provider=data.get("provider"),
|
||||
model=data.get("model")
|
||||
)
|
||||
|
||||
yield await SSEResponse.send_progress("✅ AI生成完成,正在解析...", 70)
|
||||
|
||||
# 解析响应
|
||||
outline_data = _parse_ai_response(ai_response)
|
||||
|
||||
# 删除旧大纲和章节
|
||||
yield await SSEResponse.send_progress("清理旧数据...", 75)
|
||||
logger.info(f"删除项目 {project_id} 的旧大纲和章节")
|
||||
await db.execute(
|
||||
delete(Outline).where(Outline.project_id == project_id)
|
||||
)
|
||||
await db.execute(
|
||||
delete(Chapter).where(Chapter.project_id == project_id)
|
||||
)
|
||||
|
||||
# 保存新大纲
|
||||
yield await SSEResponse.send_progress("💾 保存大纲到数据库...", 80)
|
||||
outlines = await _save_outlines(
|
||||
project_id, outline_data, db, start_index=1
|
||||
)
|
||||
|
||||
# 记录历史
|
||||
history = GenerationHistory(
|
||||
project_id=project_id,
|
||||
prompt=prompt,
|
||||
generated_content=ai_response,
|
||||
model=data.get("model") or "default"
|
||||
)
|
||||
db.add(history)
|
||||
|
||||
await db.commit()
|
||||
db_committed = True
|
||||
|
||||
for outline in outlines:
|
||||
await db.refresh(outline)
|
||||
|
||||
yield await SSEResponse.send_progress("整理结果数据...", 95)
|
||||
|
||||
logger.info(f"全新生成完成 - {len(outlines)} 章")
|
||||
|
||||
# 发送最终结果
|
||||
yield await SSEResponse.send_result({
|
||||
"message": f"成功生成{len(outlines)}章大纲",
|
||||
"total_chapters": len(outlines),
|
||||
"outlines": [
|
||||
{
|
||||
"id": outline.id,
|
||||
"project_id": outline.project_id,
|
||||
"title": outline.title,
|
||||
"content": outline.content,
|
||||
"order_index": outline.order_index,
|
||||
"structure": outline.structure,
|
||||
"created_at": outline.created_at.isoformat() if outline.created_at else None,
|
||||
"updated_at": outline.updated_at.isoformat() if outline.updated_at else None
|
||||
} for outline in outlines
|
||||
]
|
||||
})
|
||||
|
||||
yield await SSEResponse.send_progress("🎉 生成完成!", 100, "success")
|
||||
yield await SSEResponse.send_done()
|
||||
|
||||
except GeneratorExit:
|
||||
logger.warning("大纲生成器被提前关闭")
|
||||
if not db_committed and db.in_transaction():
|
||||
await db.rollback()
|
||||
logger.info("大纲生成事务已回滚(GeneratorExit)")
|
||||
except Exception as e:
|
||||
logger.error(f"大纲生成失败: {str(e)}")
|
||||
if not db_committed and db.in_transaction():
|
||||
await db.rollback()
|
||||
logger.info("大纲生成事务已回滚(异常)")
|
||||
yield await SSEResponse.send_error(f"生成失败: {str(e)}")
|
||||
|
||||
|
||||
async def continue_outline_generator(
|
||||
data: Dict[str, Any],
|
||||
db: AsyncSession,
|
||||
user_ai_service: AIService
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""大纲续写SSE生成器 - 分批生成,推送进度"""
|
||||
db_committed = False
|
||||
try:
|
||||
yield await SSEResponse.send_progress("开始续写大纲...", 5)
|
||||
|
||||
project_id = data.get("project_id")
|
||||
# 确保chapter_count是整数(前端可能传字符串)
|
||||
total_chapters_to_generate = int(data.get("chapter_count", 5))
|
||||
|
||||
# 验证项目
|
||||
yield await SSEResponse.send_progress("加载项目信息...", 10)
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
yield await SSEResponse.send_error("项目不存在", 404)
|
||||
return
|
||||
|
||||
# 获取现有大纲
|
||||
yield await SSEResponse.send_progress("分析已有大纲...", 15)
|
||||
existing_result = await db.execute(
|
||||
select(Outline)
|
||||
.where(Outline.project_id == project_id)
|
||||
.order_by(Outline.order_index)
|
||||
)
|
||||
existing_outlines = existing_result.scalars().all()
|
||||
|
||||
if not existing_outlines:
|
||||
yield await SSEResponse.send_error("续写模式需要已有大纲,当前项目没有大纲", 400)
|
||||
return
|
||||
|
||||
current_chapter_count = len(existing_outlines)
|
||||
last_chapter_number = existing_outlines[-1].order_index
|
||||
|
||||
yield await SSEResponse.send_progress(
|
||||
f"当前已有{str(current_chapter_count)}章,将续写{str(total_chapters_to_generate)}章",
|
||||
20
|
||||
)
|
||||
|
||||
# 获取角色信息
|
||||
characters_result = await db.execute(
|
||||
select(Character).where(Character.project_id == project_id)
|
||||
)
|
||||
characters = characters_result.scalars().all()
|
||||
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
|
||||
])
|
||||
|
||||
# 分批配置
|
||||
batch_size = 5
|
||||
total_batches = (total_chapters_to_generate + batch_size - 1) // batch_size
|
||||
yield await SSEResponse.send_progress(
|
||||
f"分批生成计划: 总共{str(total_chapters_to_generate)}章,分{str(total_batches)}批,每批{str(batch_size)}章",
|
||||
25
|
||||
)
|
||||
|
||||
# 情节阶段指导
|
||||
stage_instructions = {
|
||||
"development": "继续展开情节,深化角色关系,推进主线冲突",
|
||||
"climax": "进入故事高潮,矛盾激化,关键冲突爆发",
|
||||
"ending": "解决主要冲突,收束伏笔,给出结局"
|
||||
}
|
||||
stage_instruction = stage_instructions.get(data.get("plot_stage", "development"), "")
|
||||
|
||||
# 批量生成
|
||||
all_new_outlines = []
|
||||
current_start_chapter = last_chapter_number + 1
|
||||
|
||||
for batch_num in range(total_batches):
|
||||
# 计算当前批次的章节数
|
||||
remaining_chapters = int(total_chapters_to_generate) - len(all_new_outlines)
|
||||
current_batch_size = min(batch_size, remaining_chapters)
|
||||
|
||||
batch_progress = 25 + (batch_num * 60 // total_batches)
|
||||
|
||||
yield await SSEResponse.send_progress(
|
||||
f"📝 第{str(batch_num + 1)}/{str(total_batches)}批: 生成第{str(current_start_chapter)}-{str(current_start_chapter + current_batch_size - 1)}章",
|
||||
batch_progress
|
||||
)
|
||||
|
||||
# 获取最新的大纲列表(包括之前批次生成的)
|
||||
latest_result = await db.execute(
|
||||
select(Outline)
|
||||
.where(Outline.project_id == project_id)
|
||||
.order_by(Outline.order_index)
|
||||
)
|
||||
latest_outlines = latest_result.scalars().all()
|
||||
|
||||
# 获取最近2章的剧情
|
||||
recent_outlines = latest_outlines[-2:] if len(latest_outlines) >= 2 else latest_outlines
|
||||
recent_plot = "\n".join([
|
||||
f"第{o.order_index}章《{o.title}》: {o.content}"
|
||||
for o in recent_outlines
|
||||
])
|
||||
|
||||
# 全部章节概览
|
||||
all_chapters_brief = "\n".join([
|
||||
f"第{o.order_index}章: {o.title}"
|
||||
for o in latest_outlines
|
||||
])
|
||||
|
||||
yield await SSEResponse.send_progress(
|
||||
f"🤖 调用AI生成第{str(batch_num + 1)}批...",
|
||||
batch_progress + 5
|
||||
)
|
||||
|
||||
# 使用标准续写提示词模板
|
||||
prompt = prompt_service.get_outline_continue_prompt(
|
||||
title=project.title,
|
||||
theme=data.get("theme") or project.theme or "未设定",
|
||||
genre=data.get("genre") or project.genre or "通用",
|
||||
narrative_perspective=data.get("narrative_perspective") or project.narrative_perspective or "第三人称",
|
||||
chapter_count=current_batch_size,
|
||||
time_period=project.world_time_period or "未设定",
|
||||
location=project.world_location or "未设定",
|
||||
atmosphere=project.world_atmosphere or "未设定",
|
||||
rules=project.world_rules or "未设定",
|
||||
characters_info=characters_info or "暂无角色信息",
|
||||
current_chapter_count=len(latest_outlines),
|
||||
all_chapters_brief=all_chapters_brief,
|
||||
recent_plot=recent_plot,
|
||||
plot_stage_instruction=stage_instruction,
|
||||
start_chapter=current_start_chapter,
|
||||
story_direction=data.get("story_direction", "自然延续"),
|
||||
requirements=data.get("requirements", "")
|
||||
)
|
||||
|
||||
# 调用AI生成当前批次
|
||||
ai_response = await user_ai_service.generate_text(
|
||||
prompt=prompt,
|
||||
provider=data.get("provider"),
|
||||
model=data.get("model")
|
||||
)
|
||||
|
||||
yield await SSEResponse.send_progress(
|
||||
f"✅ 第{str(batch_num + 1)}批AI生成完成,正在解析...",
|
||||
batch_progress + 10
|
||||
)
|
||||
|
||||
# 解析响应
|
||||
outline_data = _parse_ai_response(ai_response)
|
||||
|
||||
# 保存当前批次的大纲
|
||||
batch_outlines = await _save_outlines(
|
||||
project_id, outline_data, db, start_index=current_start_chapter
|
||||
)
|
||||
|
||||
# 记录历史
|
||||
history = GenerationHistory(
|
||||
project_id=project_id,
|
||||
prompt=f"[续写批次{batch_num + 1}/{total_batches}] {str(prompt)[:500]}",
|
||||
generated_content=ai_response,
|
||||
model=data.get("model") or "default"
|
||||
)
|
||||
db.add(history)
|
||||
|
||||
# 提交当前批次
|
||||
await db.commit()
|
||||
|
||||
for outline in batch_outlines:
|
||||
await db.refresh(outline)
|
||||
|
||||
all_new_outlines.extend(batch_outlines)
|
||||
current_start_chapter += current_batch_size
|
||||
|
||||
yield await SSEResponse.send_progress(
|
||||
f"💾 第{str(batch_num + 1)}批保存成功!本批生成{str(len(batch_outlines))}章,累计新增{str(len(all_new_outlines))}章",
|
||||
batch_progress + 15
|
||||
)
|
||||
|
||||
logger.info(f"第{str(batch_num + 1)}批生成完成,本批生成{str(len(batch_outlines))}章")
|
||||
|
||||
db_committed = True
|
||||
|
||||
# 返回所有大纲(包括旧的和新的)
|
||||
final_result = await db.execute(
|
||||
select(Outline)
|
||||
.where(Outline.project_id == project_id)
|
||||
.order_by(Outline.order_index)
|
||||
)
|
||||
all_outlines = final_result.scalars().all()
|
||||
|
||||
yield await SSEResponse.send_progress("整理结果数据...", 95)
|
||||
|
||||
# 发送最终结果
|
||||
yield await SSEResponse.send_result({
|
||||
"message": f"续写完成!共{str(total_batches)}批,新增{str(len(all_new_outlines))}章,总计{str(len(all_outlines))}章",
|
||||
"total_batches": total_batches,
|
||||
"new_chapters": len(all_new_outlines),
|
||||
"total_chapters": len(all_outlines),
|
||||
"outlines": [
|
||||
{
|
||||
"id": outline.id,
|
||||
"project_id": outline.project_id,
|
||||
"title": outline.title,
|
||||
"content": outline.content,
|
||||
"order_index": outline.order_index,
|
||||
"structure": outline.structure,
|
||||
"created_at": outline.created_at.isoformat() if outline.created_at else None,
|
||||
"updated_at": outline.updated_at.isoformat() if outline.updated_at else None
|
||||
} for outline in all_outlines
|
||||
]
|
||||
})
|
||||
|
||||
yield await SSEResponse.send_progress("🎉 续写完成!", 100, "success")
|
||||
yield await SSEResponse.send_done()
|
||||
|
||||
except GeneratorExit:
|
||||
logger.warning("大纲续写生成器被提前关闭")
|
||||
if not db_committed and db.in_transaction():
|
||||
await db.rollback()
|
||||
logger.info("大纲续写事务已回滚(GeneratorExit)")
|
||||
except Exception as e:
|
||||
logger.error(f"大纲续写失败: {str(e)}")
|
||||
if not db_committed and db.in_transaction():
|
||||
await db.rollback()
|
||||
logger.info("大纲续写事务已回滚(异常)")
|
||||
yield await SSEResponse.send_error(f"续写失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/generate-stream", summary="AI生成/续写大纲(SSE流式)")
|
||||
async def generate_outline_stream(
|
||||
data: Dict[str, Any],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user_ai_service: AIService = Depends(get_user_ai_service)
|
||||
):
|
||||
"""
|
||||
使用SSE流式生成或续写小说大纲,实时推送批次进度
|
||||
|
||||
支持模式:
|
||||
- auto: 自动判断(无大纲→新建,有大纲→续写)
|
||||
- new: 全新生成
|
||||
- continue: 续写模式
|
||||
|
||||
请求体示例:
|
||||
{
|
||||
"project_id": "项目ID",
|
||||
"chapter_count": 5, // 章节数
|
||||
"mode": "auto", // auto/new/continue
|
||||
"theme": "故事主题", // new模式必需
|
||||
"story_direction": "故事发展方向", // continue模式可选
|
||||
"plot_stage": "development", // continue模式:development/climax/ending
|
||||
"narrative_perspective": "第三人称",
|
||||
"requirements": "其他要求",
|
||||
"provider": "openai", // 可选
|
||||
"model": "gpt-4" // 可选
|
||||
}
|
||||
"""
|
||||
# 验证项目是否存在
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == data.get("project_id"))
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 判断模式
|
||||
mode = data.get("mode", "auto")
|
||||
|
||||
# 获取现有大纲
|
||||
existing_result = await db.execute(
|
||||
select(Outline)
|
||||
.where(Outline.project_id == data.get("project_id"))
|
||||
.order_by(Outline.order_index)
|
||||
)
|
||||
existing_outlines = existing_result.scalars().all()
|
||||
|
||||
# 自动判断模式
|
||||
if mode == "auto":
|
||||
mode = "continue" if existing_outlines else "new"
|
||||
logger.info(f"自动判断模式:{'续写' if existing_outlines else '新建'}")
|
||||
|
||||
# 根据模式选择生成器
|
||||
if mode == "new":
|
||||
return create_sse_response(new_outline_generator(data, db, user_ai_service))
|
||||
elif mode == "continue":
|
||||
if not existing_outlines:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="续写模式需要已有大纲,当前项目没有大纲"
|
||||
)
|
||||
return create_sse_response(continue_outline_generator(data, db, user_ai_service))
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"不支持的模式: {mode}"
|
||||
)
|
||||
@@ -148,7 +148,7 @@ async def get_db(request: Request):
|
||||
|
||||
logger.debug(f"📊 会话关闭 [User:{user_id}][ID:{session_id}] - 活跃:{_session_stats['active']}, 总创建:{_session_stats['created']}, 总关闭:{_session_stats['closed']}, 错误:{_session_stats['errors']}")
|
||||
|
||||
if _session_stats["active"] > 10:
|
||||
if _session_stats["active"] > 100:
|
||||
logger.warning(f"🚨 活跃会话数过多: {_session_stats['active']},可能存在连接泄漏!")
|
||||
elif _session_stats["active"] < 0:
|
||||
logger.error(f"🚨 活跃会话数异常: {_session_stats['active']},统计可能不准确!")
|
||||
|
||||
@@ -150,7 +150,7 @@ class PromptService:
|
||||
3. 不要引用任何本批次中不存在的角色或组织名称
|
||||
4. 文本描述中不要使用中文引号(""),改用【】或《》"""
|
||||
|
||||
# 完整大纲生成提示词
|
||||
# 向导大纲生成提示词
|
||||
COMPLETE_OUTLINE_GENERATION = """你是一位经验丰富的小说作家和编剧。请根据以下信息生成完整的{chapter_count}章小说大纲:
|
||||
|
||||
基本信息:
|
||||
@@ -639,7 +639,7 @@ class PromptService:
|
||||
target_words: int, time_period: str, location: str,
|
||||
atmosphere: str, rules: str, characters_info: str,
|
||||
requirements: str = "") -> str:
|
||||
"""获取完整大纲生成提示词"""
|
||||
"""获取向导大纲生成提示词"""
|
||||
return cls.format_prompt(
|
||||
cls.COMPLETE_OUTLINE_GENERATION,
|
||||
title=title,
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# 大纲分批续写功能说明
|
||||
|
||||
## 概述
|
||||
|
||||
优化后的大纲续写功能实现了**分批生成**机制,每批次生成5章大纲。这种方式相比一次性生成所有章节具有以下优势:
|
||||
|
||||
## 优势
|
||||
|
||||
1. **降低API压力**:分批次调用AI接口,避免单次请求过大导致超时
|
||||
2. **提高成功率**:小批量生成更稳定,减少因token限制导致的失败
|
||||
3. **更好的连贯性**:每批次基于最新生成的内容继续,确保剧情连贯
|
||||
4. **渐进式反馈**:用户可以看到分批次的进度,体验更好
|
||||
5. **容错性强**:单个批次失败不影响已生成的内容
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 核心逻辑
|
||||
|
||||
```python
|
||||
# 分批配置
|
||||
batch_size = 5 # 每批生成5章
|
||||
total_batches = (total_chapters_to_generate + batch_size - 1) // batch_size
|
||||
|
||||
# 批次循环
|
||||
for batch_num in range(total_batches):
|
||||
# 计算当前批次章节数
|
||||
current_batch_size = min(batch_size, remaining_chapters)
|
||||
|
||||
# 获取最新大纲列表(包括之前批次生成的)
|
||||
latest_outlines = await db.execute(...)
|
||||
|
||||
# 基于最新上下文生成
|
||||
prompt = prompt_service.get_outline_continue_prompt(
|
||||
chapter_count=current_batch_size, # 当前批次数量
|
||||
...
|
||||
)
|
||||
|
||||
# 保存并提交当前批次
|
||||
await db.commit()
|
||||
```
|
||||
|
||||
### 关键特性
|
||||
|
||||
1. **动态上下文更新**:每个批次都会获取最新的大纲列表,包括之前批次生成的内容
|
||||
2. **智能章节数计算**:最后一批会自动调整为剩余章节数(不一定是5章)
|
||||
3. **历史记录**:每个批次都会记录到 `GenerationHistory` 表,便于追溯
|
||||
4. **事务安全**:每批次独立提交,确保已生成内容不会丢失
|
||||
|
||||
## 使用示例
|
||||
|
||||
### API 调用
|
||||
|
||||
```bash
|
||||
POST /api/outlines/generate
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"project_id": "project-uuid",
|
||||
"mode": "continue",
|
||||
"chapter_count": 15, # 将分3批生成(5+5+5)
|
||||
"theme": "科幻冒险",
|
||||
"narrative_perspective": "第三人称",
|
||||
"plot_stage": "development",
|
||||
"story_direction": "主角开始探索新世界"
|
||||
}
|
||||
```
|
||||
|
||||
### 生成过程
|
||||
|
||||
```
|
||||
续写15章的执行流程:
|
||||
|
||||
批次1: 生成第11-15章 (5章)
|
||||
├─ 获取已有1-10章
|
||||
├─ 基于最近2章剧情
|
||||
└─ 提交并保存
|
||||
|
||||
批次2: 生成第16-20章 (5章)
|
||||
├─ 获取已有1-15章(包括批次1生成的)
|
||||
├─ 基于最近2章剧情(第14-15章)
|
||||
└─ 提交并保存
|
||||
|
||||
批次3: 生成第21-25章 (5章)
|
||||
├─ 获取已有1-20章(包括批次1-2生成的)
|
||||
├─ 基于最近2章剧情(第19-20章)
|
||||
└─ 提交并保存
|
||||
|
||||
结果: 总计新增15章,分3批完成
|
||||
```
|
||||
|
||||
## 日志示例
|
||||
|
||||
```
|
||||
[INFO] 续写大纲 - 项目: abc-123, 已有: 10 章
|
||||
[INFO] 分批生成计划: 总共15章,分3批,每批5章
|
||||
[INFO] 开始生成第1/3批,章节范围: 11-15
|
||||
[INFO] 正在调用AI生成第1批...
|
||||
[INFO] 第1批生成完成,本批生成5章
|
||||
[INFO] 开始生成第2/3批,章节范围: 16-20
|
||||
[INFO] 正在调用AI生成第2批...
|
||||
[INFO] 第2批生成完成,本批生成5章
|
||||
[INFO] 开始生成第3/3批,章节范围: 21-25
|
||||
[INFO] 正在调用AI生成第3批...
|
||||
[INFO] 第3批生成完成,本批生成5章
|
||||
[INFO] 续写完成 - 共3批,新增 15 章,总计 25 章
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 批次大小
|
||||
|
||||
当前固定为 **5章/批次**,在 `_continue_outline` 函数中定义:
|
||||
|
||||
```python
|
||||
batch_size = 5 # 每批生成5章
|
||||
```
|
||||
|
||||
如需调整,修改此值即可。建议值:
|
||||
- 3-5章:最佳平衡点,稳定性高
|
||||
- 6-8章:适合长篇小说,需要更强的AI模型
|
||||
- 1-2章:超稳定模式,但会增加API调用次数
|
||||
|
||||
### 提示词优化
|
||||
|
||||
提示词已自动适配分批生成:
|
||||
- `chapter_count`: 动态调整为当前批次的章节数
|
||||
- `start_chapter`: 当前批次的起始章节号
|
||||
- `current_chapter_count`: 实时更新已有章节总数
|
||||
- `recent_plot`: 基于最新的2章剧情
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **API费用**:分批生成会增加API调用次数,但单次token消耗更少
|
||||
2. **生成时间**:总时间会略长于一次性生成(因为有多次网络请求)
|
||||
3. **连贯性**:通过获取最新上下文确保连贯性,实际效果可能优于一次性生成
|
||||
4. **中断恢复**:如果某批次失败,已生成的批次内容会保留
|
||||
|
||||
## 未来优化方向
|
||||
|
||||
1. **可配置批次大小**:允许用户通过API参数自定义批次大小
|
||||
2. **并行生成**:对于独立的批次可以考虑并行生成(需要仔细设计)
|
||||
3. **进度推送**:通过WebSocket或SSE实时推送生成进度
|
||||
4. **智能批次调整**:根据已有章节数和剩余章节数智能调整批次大小
|
||||
|
||||
## 相关文件
|
||||
|
||||
- **实现文件**: `backend/app/api/outlines.py` - `_continue_outline()` 函数
|
||||
- **提示词模板**: `backend/app/services/prompt_service.py` - `OUTLINE_CONTINUE_GENERATION`
|
||||
- **Schema定义**: `backend/app/schemas/outline.py` - `OutlineGenerateRequest`
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag } from 'antd';
|
||||
import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag, Progress } from 'antd';
|
||||
import { EditOutlined, DeleteOutlined, ThunderboltOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { useOutlineSync } from '../store/hooks';
|
||||
import { cardStyles } from '../components/CardStyles';
|
||||
import { SSEPostClient } from '../utils/sseClient';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
@@ -13,6 +14,11 @@ export default function Outline() {
|
||||
const [editForm] = Form.useForm();
|
||||
const [generateForm] = Form.useForm();
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
|
||||
|
||||
// SSE进度状态
|
||||
const [sseProgress, setSSEProgress] = useState(0);
|
||||
const [sseMessage, setSSEMessage] = useState('');
|
||||
const [sseModalVisible, setSSEModalVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
@@ -23,13 +29,12 @@ export default function Outline() {
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// 使用同步 hooks(移除createOutline)
|
||||
// 使用同步 hooks
|
||||
const {
|
||||
refreshOutlines,
|
||||
updateOutline,
|
||||
deleteOutline,
|
||||
reorderOutlines,
|
||||
generateOutlines
|
||||
reorderOutlines
|
||||
} = useOutlineSync();
|
||||
|
||||
// 初始加载大纲列表
|
||||
@@ -159,9 +164,17 @@ export default function Outline() {
|
||||
const handleGenerate = async (values: GenerateFormValues) => {
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
// 如果是全新生成模式,keep_existing应该为false
|
||||
const isNewMode = values.mode === 'new';
|
||||
const result = await generateOutlines({
|
||||
|
||||
// 关闭生成表单Modal
|
||||
Modal.destroyAll();
|
||||
|
||||
// 显示进度Modal
|
||||
setSSEProgress(0);
|
||||
setSSEMessage('正在连接AI服务...');
|
||||
setSSEModalVisible(true);
|
||||
|
||||
// 准备请求数据
|
||||
const requestData = {
|
||||
project_id: currentProject.id,
|
||||
genre: currentProject.genre || '通用',
|
||||
theme: values.theme || currentProject.theme || '',
|
||||
@@ -169,20 +182,44 @@ export default function Outline() {
|
||||
narrative_perspective: values.narrative_perspective || currentProject.narrative_perspective || '第三人称',
|
||||
target_words: currentProject.target_words || 100000,
|
||||
requirements: values.requirements,
|
||||
// 续写参数
|
||||
mode: values.mode || 'auto',
|
||||
story_direction: values.story_direction,
|
||||
plot_stage: values.plot_stage || 'development',
|
||||
keep_existing: !isNewMode, // 全新生成模式下不保留旧大纲
|
||||
provider: values.provider,
|
||||
model: values.model
|
||||
};
|
||||
|
||||
// 使用SSE客户端
|
||||
const apiUrl = `/api/outlines/generate-stream`;
|
||||
const client = new SSEPostClient(apiUrl, requestData, {
|
||||
onProgress: (msg: string, progress: number) => {
|
||||
setSSEMessage(msg);
|
||||
setSSEProgress(progress);
|
||||
},
|
||||
onResult: (data: any) => {
|
||||
console.log('生成完成,结果:', data);
|
||||
},
|
||||
onError: (error: string) => {
|
||||
message.error(`生成失败: ${error}`);
|
||||
setSSEModalVisible(false);
|
||||
setIsGenerating(false);
|
||||
},
|
||||
onComplete: () => {
|
||||
message.success('大纲生成完成!');
|
||||
setSSEModalVisible(false);
|
||||
setIsGenerating(false);
|
||||
// 刷新大纲列表
|
||||
refreshOutlines();
|
||||
}
|
||||
});
|
||||
message.success(`成功生成 ${result.length} 条大纲`);
|
||||
Modal.destroyAll();
|
||||
// 刷新大纲列表,确保显示最新数据
|
||||
await refreshOutlines();
|
||||
|
||||
// 开始连接
|
||||
client.connect();
|
||||
|
||||
} catch (error) {
|
||||
console.error('AI生成失败:', error);
|
||||
message.error('AI生成失败');
|
||||
} finally {
|
||||
setSSEModalVisible(false);
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
@@ -335,7 +372,38 @@ export default function Outline() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<>
|
||||
{/* SSE进度Modal */}
|
||||
<Modal
|
||||
title="生成大纲中"
|
||||
open={sseModalVisible}
|
||||
footer={null}
|
||||
closable={false}
|
||||
centered
|
||||
width={500}
|
||||
>
|
||||
<div style={{ padding: '20px 0' }}>
|
||||
<Progress
|
||||
percent={sseProgress}
|
||||
status={sseProgress === 100 ? 'success' : 'active'}
|
||||
strokeColor={{
|
||||
'0%': '#108ee9',
|
||||
'100%': '#87d068',
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
marginTop: 16,
|
||||
color: '#666',
|
||||
fontSize: 14,
|
||||
minHeight: 40,
|
||||
lineHeight: '20px'
|
||||
}}>
|
||||
{sseMessage}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* 固定头部 */}
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
@@ -475,6 +543,7 @@ export default function Outline() {
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, Button, Empty, Modal, message, Spin, Row, Col, Statistic, Space, Tag, Progress, Typography, Tooltip, Badge } from 'antd';
|
||||
import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, FireOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { Card, Button, Empty, Modal, message, Spin, Row, Col, Statistic, Space, Tag, Progress, Typography, Tooltip, Badge, Alert } from 'antd';
|
||||
import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, FireOutlined, SettingOutlined, InfoCircleOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { useProjectSync } from '../store/hooks';
|
||||
import type { ReactNode } from 'react';
|
||||
@@ -13,6 +13,7 @@ const { Title, Text, Paragraph } = Typography;
|
||||
export default function ProjectList() {
|
||||
const navigate = useNavigate();
|
||||
const { projects, loading } = useStore();
|
||||
const [showApiTip, setShowApiTip] = useState(true);
|
||||
|
||||
const { refreshProjects, deleteProject } = useProjectSync();
|
||||
|
||||
@@ -195,6 +196,59 @@ export default function ProjectList() {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{showApiTip && projects.length === 0 && (
|
||||
<Alert
|
||||
message={
|
||||
<Space align="center" style={{ width: '100%' }}>
|
||||
<InfoCircleOutlined style={{ fontSize: 16, color: '#1890ff' }} />
|
||||
<Text strong style={{ fontSize: window.innerWidth <= 768 ? 13 : 14 }}>
|
||||
首次使用提示
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Text style={{ fontSize: window.innerWidth <= 768 ? 12 : 13 }}>
|
||||
在开始创作之前,请先配置您的AI接口。系统支持OpenAI和Anthropic两种接口。
|
||||
</Text>
|
||||
<Space size={8}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => navigate('/settings')}
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
border: 'none'
|
||||
}}
|
||||
>
|
||||
立即配置
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setShowApiTip(false)}
|
||||
style={{ borderRadius: 6 }}
|
||||
>
|
||||
暂不提醒
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
}
|
||||
type="info"
|
||||
showIcon={false}
|
||||
closable
|
||||
closeIcon={<CloseOutlined style={{ fontSize: 12 }} />}
|
||||
onClose={() => setShowApiTip(false)}
|
||||
style={{
|
||||
marginTop: window.innerWidth <= 768 ? 16 : 24,
|
||||
borderRadius: 12,
|
||||
background: 'linear-gradient(135deg, #e6f7ff 0%, #f0f5ff 100%)',
|
||||
border: '1px solid #91d5ff'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{projects.length > 0 && (
|
||||
<Row gutter={[16, 16]} style={{ marginTop: window.innerWidth <= 768 ? 16 : 24 }}>
|
||||
<Col xs={24} sm={8}>
|
||||
|
||||
@@ -116,9 +116,9 @@ export default function SettingsPage() {
|
||||
|
||||
const apiProviders = [
|
||||
{ value: 'openai', label: 'OpenAI', defaultUrl: 'https://api.openai.com/v1' },
|
||||
{ value: 'azure', label: 'Azure OpenAI', defaultUrl: 'https://YOUR-RESOURCE.openai.azure.com' },
|
||||
// { value: 'azure', label: 'Azure OpenAI', defaultUrl: 'https://YOUR-RESOURCE.openai.azure.com' },
|
||||
{ value: 'anthropic', label: 'Anthropic', defaultUrl: 'https://api.anthropic.com' },
|
||||
{ value: 'custom', label: '自定义', defaultUrl: '' },
|
||||
// { value: 'custom', label: '自定义', defaultUrl: '' },
|
||||
];
|
||||
|
||||
const handleProviderChange = (value: string) => {
|
||||
|
||||
Reference in New Issue
Block a user