From 12ded06b3653a05efc601b65e6037a383386a2f8 Mon Sep 17 00:00:00 2001 From: xiamuceer Date: Thu, 30 Oct 2025 22:01:10 +0800 Subject: [PATCH] =?UTF-8?q?update:=E6=96=B0=E5=A2=9E=E7=94=A8=E6=88=B7API?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=8F=90=E7=A4=BA=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=A4=A7=E7=BA=B2=E5=85=A8=E6=96=B0/=E7=BB=AD=E5=86=99?= =?UTF-8?q?=E7=9A=84=E5=88=86=E6=89=B9=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/outlines.py | 601 ++++++++++++++++++++++--- backend/app/database.py | 2 +- backend/app/services/prompt_service.py | 4 +- docs/outline_batch_generation.md | 149 ++++++ frontend/src/pages/Outline.tsx | 101 ++++- frontend/src/pages/ProjectList.tsx | 60 ++- frontend/src/pages/Settings.tsx | 4 +- 7 files changed, 830 insertions(+), 91 deletions(-) create mode 100644 docs/outline_batch_generation.md diff --git a/backend/app/api/outlines.py b/backend/app/api/outlines.py index d246977..aca7d50 100644 --- a/backend/app/api/outlines.py +++ b/backend/app/api/outlines.py @@ -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 \ No newline at end of file + 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}" + ) \ No newline at end of file diff --git a/backend/app/database.py b/backend/app/database.py index 8223d51..03bd341 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -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']},统计可能不准确!") diff --git a/backend/app/services/prompt_service.py b/backend/app/services/prompt_service.py index a96b68d..3d44af3 100644 --- a/backend/app/services/prompt_service.py +++ b/backend/app/services/prompt_service.py @@ -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, diff --git a/docs/outline_batch_generation.md b/docs/outline_batch_generation.md new file mode 100644 index 0000000..b05a92d --- /dev/null +++ b/docs/outline_batch_generation.md @@ -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` \ No newline at end of file diff --git a/frontend/src/pages/Outline.tsx b/frontend/src/pages/Outline.tsx index e08589d..d3b41f6 100644 --- a/frontend/src/pages/Outline.tsx +++ b/frontend/src/pages/Outline.tsx @@ -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 ( -
+ <> + {/* SSE进度Modal */} + +
+ +
+ {sseMessage} +
+
+
+ +
{/* 固定头部 */}
)}
-
+
+ ); } \ No newline at end of file diff --git a/frontend/src/pages/ProjectList.tsx b/frontend/src/pages/ProjectList.tsx index b79262b..1b43cbc 100644 --- a/frontend/src/pages/ProjectList.tsx +++ b/frontend/src/pages/ProjectList.tsx @@ -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() { + {showApiTip && projects.length === 0 && ( + + + + 首次使用提示 + + + } + description={ + + + 在开始创作之前,请先配置您的AI接口。系统支持OpenAI和Anthropic两种接口。 + + + + + + + } + type="info" + showIcon={false} + closable + closeIcon={} + 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 && ( diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index db85280..ad8b972 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -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) => {