diff --git a/backend/app/api/chapters.py b/backend/app/api/chapters.py index 1d3d0b3..f192a29 100644 --- a/backend/app/api/chapters.py +++ b/backend/app/api/chapters.py @@ -18,12 +18,16 @@ from app.models.generation_history import GenerationHistory from app.models.writing_style import WritingStyle from app.models.analysis_task import AnalysisTask from app.models.memory import PlotAnalysis, StoryMemory +from app.models.batch_generation_task import BatchGenerationTask from app.schemas.chapter import ( ChapterCreate, ChapterUpdate, ChapterResponse, ChapterListResponse, - ChapterGenerateRequest + ChapterGenerateRequest, + BatchGenerateRequest, + BatchGenerateResponse, + BatchGenerateStatusResponse ) from app.services.ai_service import AIService from app.services.prompt_service import prompt_service @@ -1514,3 +1518,610 @@ async def trigger_chapter_analysis( "status": "pending", "message": "分析任务已创建并开始执行" } + + + +def calculate_estimated_time( + chapter_count: int, + target_word_count: int, + enable_analysis: bool +) -> int: + """ + 计算预估耗时(分钟) + + 基准: + - 生成3000字约需2分钟 + - 分析约需1分钟 + """ + generation_time_per_chapter = (target_word_count / 3000) * 2 + analysis_time_per_chapter = 1 if enable_analysis else 0 + + total_time = chapter_count * (generation_time_per_chapter + analysis_time_per_chapter) + + return max(1, int(total_time)) + + +@router.post("/project/{project_id}/batch-generate", response_model=BatchGenerateResponse, summary="批量顺序生成章节内容") +async def batch_generate_chapters_in_order( + project_id: str, + batch_request: BatchGenerateRequest, + request: Request, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + user_ai_service: AIService = Depends(get_user_ai_service) +): + """ + 从指定章节开始,按顺序批量生成指定数量的章节 + + 特性: + 1. 严格按章节序号顺序生成(不可跳过) + 2. 自动检测起始章节是否可生成 + 3. 可选同步分析(影响耗时和质量) + 4. 失败后终止,不继续后续章节 + """ + user_id = getattr(request.state, "user_id", None) + if not user_id: + raise HTTPException(status_code=401, detail="未登录") + + # 验证项目存在 + project_result = await db.execute( + select(Project).where(Project.id == project_id) + ) + project = project_result.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="项目不存在") + + # 获取项目的所有章节,按序号排序 + result = await db.execute( + select(Chapter) + .where(Chapter.project_id == project_id) + .order_by(Chapter.chapter_number) + ) + all_chapters = result.scalars().all() + + if not all_chapters: + raise HTTPException(status_code=404, detail="项目没有章节") + + # 计算要生成的章节范围 + start_number = batch_request.start_chapter_number + end_number = start_number + batch_request.count - 1 + + # 筛选出要生成的章节 + chapters_to_generate = [ + ch for ch in all_chapters + if start_number <= ch.chapter_number <= end_number + ] + + if not chapters_to_generate: + raise HTTPException(status_code=404, detail="指定范围内没有章节") + + # 验证起始章节的前置条件 + first_chapter = chapters_to_generate[0] + can_generate, error_msg, _ = await check_prerequisites(db, first_chapter) + if not can_generate: + raise HTTPException(status_code=400, detail=f"起始章节无法生成:{error_msg}") + + # 创建批量生成任务 + batch_task = BatchGenerationTask( + project_id=project_id, + user_id=user_id, + start_chapter_number=start_number, + chapter_count=len(chapters_to_generate), + chapter_ids=[ch.id for ch in chapters_to_generate], + style_id=batch_request.style_id, + target_word_count=batch_request.target_word_count, + enable_analysis=batch_request.enable_analysis, + max_retries=batch_request.max_retries, + status='pending', + total_chapters=len(chapters_to_generate), + completed_chapters=0, + failed_chapters=[], + current_retry_count=0 + ) + db.add(batch_task) + await db.commit() + await db.refresh(batch_task) + + batch_id = batch_task.id + + # 计算预估耗时 + estimated_time = calculate_estimated_time( + chapter_count=len(chapters_to_generate), + target_word_count=batch_request.target_word_count, + enable_analysis=batch_request.enable_analysis + ) + + logger.info(f"📦 创建批量生成任务: {batch_id}, 章节: 第{start_number}-{end_number}章, 预估耗时: {estimated_time}分钟") + + # 启动后台批量生成任务 + background_tasks.add_task( + execute_batch_generation_in_order, + batch_id=batch_id, + user_id=user_id, + ai_service=user_ai_service + ) + + return BatchGenerateResponse( + batch_id=batch_id, + message=f"批量生成任务已创建,将生成 {len(chapters_to_generate)} 个章节", + chapters_to_generate=[ + { + "id": ch.id, + "chapter_number": ch.chapter_number, + "title": ch.title + } + for ch in chapters_to_generate + ], + estimated_time_minutes=estimated_time + ) + + +@router.get("/batch-generate/{batch_id}/status", response_model=BatchGenerateStatusResponse, summary="查询批量生成任务状态") +async def get_batch_generation_status( + batch_id: str, + db: AsyncSession = Depends(get_db) +): + """查询批量生成任务的状态和进度""" + result = await db.execute( + select(BatchGenerationTask).where(BatchGenerationTask.id == batch_id) + ) + task = result.scalar_one_or_none() + + if not task: + raise HTTPException(status_code=404, detail="批量生成任务不存在") + + return BatchGenerateStatusResponse( + batch_id=task.id, + status=task.status, + total=task.total_chapters, + completed=task.completed_chapters, + current_chapter_id=task.current_chapter_id, + current_chapter_number=task.current_chapter_number, + current_retry_count=task.current_retry_count, + max_retries=task.max_retries, + failed_chapters=task.failed_chapters or [], + created_at=task.created_at.isoformat() if task.created_at else None, + started_at=task.started_at.isoformat() if task.started_at else None, + completed_at=task.completed_at.isoformat() if task.completed_at else None, + error_message=task.error_message + ) + + +@router.get("/project/{project_id}/batch-generate/active", summary="获取项目当前运行中的批量生成任务") +async def get_active_batch_generation( + project_id: str, + db: AsyncSession = Depends(get_db) +): + """ + 获取项目当前运行中的批量生成任务 + 用于页面刷新后恢复任务状态 + """ + result = await db.execute( + select(BatchGenerationTask) + .where(BatchGenerationTask.project_id == project_id) + .where(BatchGenerationTask.status.in_(['pending', 'running'])) + .order_by(BatchGenerationTask.created_at.desc()) + .limit(1) + ) + task = result.scalar_one_or_none() + + if not task: + return { + "has_active_task": False, + "task": None + } + + return { + "has_active_task": True, + "task": { + "batch_id": task.id, + "status": task.status, + "total": task.total_chapters, + "completed": task.completed_chapters, + "current_chapter_id": task.current_chapter_id, + "current_chapter_number": task.current_chapter_number, + "created_at": task.created_at.isoformat() if task.created_at else None, + "started_at": task.started_at.isoformat() if task.started_at else None + } + } + + +@router.post("/batch-generate/{batch_id}/cancel", summary="取消批量生成任务") +async def cancel_batch_generation( + batch_id: str, + db: AsyncSession = Depends(get_db) +): + """取消正在进行的批量生成任务""" + result = await db.execute( + select(BatchGenerationTask).where(BatchGenerationTask.id == batch_id) + ) + task = result.scalar_one_or_none() + + if not task: + raise HTTPException(status_code=404, detail="批量生成任务不存在") + + if task.status in ['completed', 'failed', 'cancelled']: + raise HTTPException(status_code=400, detail=f"任务已处于 {task.status} 状态,无法取消") + + task.status = 'cancelled' + task.completed_at = datetime.now() + await db.commit() + + logger.info(f"🛑 批量生成任务已取消: {batch_id}") + + return { + "message": "批量生成任务已取消", + "batch_id": batch_id, + "completed_chapters": task.completed_chapters, + "total_chapters": task.total_chapters + } + + +async def execute_batch_generation_in_order( + batch_id: str, + user_id: str, + ai_service: AIService +): + """ + 按顺序执行批量生成任务(后台任务) + - 严格按章节序号顺序 + - 任一章节失败则终止后续生成 + - 可选同步分析 + """ + db_session = None + write_lock = await get_db_write_lock(user_id) + + try: + logger.info(f"📦 开始执行顺序批量生成任务: {batch_id}") + + # 创建独立数据库会话 + from app.database import get_engine + from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession + + engine = await get_engine(user_id) + AsyncSessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False + ) + db_session = AsyncSessionLocal() + + # 获取任务 + task_result = await db_session.execute( + select(BatchGenerationTask).where(BatchGenerationTask.id == batch_id) + ) + task = task_result.scalar_one_or_none() + + if not task: + logger.error(f"❌ 批量生成任务不存在: {batch_id}") + return + + # 更新任务状态为运行中 + async with write_lock: + task.status = 'running' + task.started_at = datetime.now() + await db_session.commit() + + # 按顺序生成每个章节 + for idx, chapter_id in enumerate(task.chapter_ids, 1): + # 检查任务是否被取消 + await db_session.refresh(task) + if task.status == 'cancelled': + logger.info(f"🛑 批量生成任务已被取消: {batch_id}") + return + + # 更新当前章节 + async with write_lock: + task.current_chapter_id = chapter_id + task.current_retry_count = 0 # 重置重试计数 + await db_session.commit() + + # 重试循环 + retry_count = 0 + chapter_success = False + chapter = None + last_error = None + + while retry_count <= task.max_retries and not chapter_success: + try: + # 获取章节信息 + chapter_result = await db_session.execute( + select(Chapter).where(Chapter.id == chapter_id) + ) + chapter = chapter_result.scalar_one_or_none() + + if not chapter: + raise Exception(f"章节 {chapter_id} 不存在") + + # 更新当前章节序号和重试次数 + async with write_lock: + task.current_chapter_number = chapter.chapter_number + task.current_retry_count = retry_count + await db_session.commit() + + if retry_count > 0: + logger.info(f"🔄 [{idx}/{task.total_chapters}] 重试生成章节 (第{retry_count}次): 第{chapter.chapter_number}章 《{chapter.title}》") + else: + logger.info(f"📝 [{idx}/{task.total_chapters}] 开始生成章节: 第{chapter.chapter_number}章 《{chapter.title}》") + + # 检查前置条件(每次都检查,确保顺序性) + can_generate, error_msg, _ = await check_prerequisites(db_session, chapter) + if not can_generate: + raise Exception(f"前置条件不满足: {error_msg}") + + # 生成章节内容(复用现有流式生成逻辑的核心部分) + await generate_single_chapter_for_batch( + db_session=db_session, + chapter=chapter, + user_id=user_id, + style_id=task.style_id, + target_word_count=task.target_word_count, + ai_service=ai_service, + write_lock=write_lock + ) + + logger.info(f"✅ 章节生成完成: 第{chapter.chapter_number}章") + + # 如果启用同步分析 + if task.enable_analysis: + logger.info(f"🔍 开始同步分析章节: 第{chapter.chapter_number}章") + + async with write_lock: + analysis_task = AnalysisTask( + chapter_id=chapter_id, + user_id=user_id, + project_id=task.project_id, + status='pending', + progress=0 + ) + db_session.add(analysis_task) + await db_session.commit() + await db_session.refresh(analysis_task) + + # 同步执行分析(等待完成) + await analyze_chapter_background( + chapter_id=chapter_id, + user_id=user_id, + project_id=task.project_id, + task_id=analysis_task.id, + ai_service=ai_service + ) + + logger.info(f"✅ 章节分析完成: 第{chapter.chapter_number}章") + + # 标记成功 + chapter_success = True + + # 更新完成数 + async with write_lock: + task.completed_chapters += 1 + task.current_retry_count = 0 # 重置重试计数 + await db_session.commit() + + logger.info(f"✅ 进度: {task.completed_chapters}/{task.total_chapters}") + + except Exception as e: + last_error = str(e) + logger.error(f"❌ 章节生成失败: 第{chapter.chapter_number if chapter else '?'}章, 错误: {last_error}") + + retry_count += 1 + + # 如果还有重试机会,等待一小段时间后重试 + if retry_count <= task.max_retries: + wait_time = min(2 ** retry_count, 10) # 指数退避,最多等待10秒 + logger.info(f"⏳ 等待 {wait_time} 秒后重试...") + await asyncio.sleep(wait_time) + else: + # 达到最大重试次数,记录失败信息 + logger.error(f"❌ 章节生成失败,已达最大重试次数({task.max_retries}): 第{chapter.chapter_number if chapter else '?'}章") + + failed_info = { + 'chapter_id': chapter_id, + 'chapter_number': chapter.chapter_number if chapter else -1, + 'title': chapter.title if chapter else '未知', + 'error': last_error, + 'retry_count': retry_count - 1 + } + + async with write_lock: + if task.failed_chapters is None: + task.failed_chapters = [] + task.failed_chapters.append(failed_info) + + # 标记任务失败并终止 + task.status = 'failed' + task.error_message = f"第{chapter.chapter_number}章生成失败(重试{retry_count-1}次): {last_error}"[:500] + task.completed_at = datetime.now() + task.current_retry_count = 0 + await db_session.commit() + + logger.error(f"🛑 批量生成终止于第{chapter.chapter_number}章") + return + + # 全部完成 + async with write_lock: + task.status = 'completed' + task.completed_at = datetime.now() + task.current_chapter_id = None + task.current_chapter_number = None + await db_session.commit() + + logger.info(f"✅ 批量生成任务全部完成: {batch_id}, 成功生成 {task.completed_chapters} 章") + + except Exception as e: + logger.error(f"❌ 批量生成任务异常: {str(e)}", exc_info=True) + if db_session and task: + try: + async with write_lock: + task.status = 'failed' + task.error_message = str(e)[:500] + task.completed_at = datetime.now() + await db_session.commit() + except Exception as commit_error: + logger.error(f"❌ 更新任务失败状态失败: {str(commit_error)}") + finally: + if db_session: + await db_session.close() + + +async def generate_single_chapter_for_batch( + db_session: AsyncSession, + chapter: Chapter, + user_id: str, + style_id: Optional[int], + target_word_count: int, + ai_service: AIService, + write_lock: Lock +): + """ + 为批量生成执行单个章节的生成(非流式) + 复用现有生成逻辑的核心部分 + """ + # 获取项目信息 + project_result = await db_session.execute( + select(Project).where(Project.id == chapter.project_id) + ) + project = project_result.scalar_one_or_none() + if not project: + raise Exception("项目不存在") + + # 获取对应的大纲 + outline_result = await db_session.execute( + select(Outline) + .where(Outline.project_id == chapter.project_id) + .where(Outline.order_index == chapter.chapter_number) + ) + outline = outline_result.scalar_one_or_none() + + # 获取所有大纲用于上下文 + all_outlines_result = await db_session.execute( + select(Outline) + .where(Outline.project_id == chapter.project_id) + .order_by(Outline.order_index) + ) + all_outlines = all_outlines_result.scalars().all() + outlines_context = "\n".join([ + f"第{o.order_index}章 {o.title}: {o.content[:100]}..." + for o in all_outlines + ]) + + # 获取角色信息 + characters_result = await db_session.execute( + select(Character).where(Character.project_id == chapter.project_id) + ) + characters = characters_result.scalars().all() + characters_info = "\n".join([ + f"- {c.name}({'组织' if c.is_organization else '角色'}, {c.role_type}): {c.personality[:100] if c.personality else ''}" + for c in characters + ]) + + # 获取写作风格 + style_content = "" + if style_id: + style_result = await db_session.execute( + select(WritingStyle).where(WritingStyle.id == style_id) + ) + style = style_result.scalar_one_or_none() + if style: + if style.project_id is None or style.project_id == chapter.project_id: + style_content = style.prompt_content or "" + + # 构建智能上下文 + smart_context = await build_smart_chapter_context( + db=db_session, + project_id=project.id, + current_chapter_number=chapter.chapter_number, + user_id=user_id + ) + + # 组装上下文 + previous_content = "" + if smart_context['story_skeleton']: + previous_content += smart_context['story_skeleton'] + "\n\n" + if smart_context['relevant_history']: + previous_content += smart_context['relevant_history'] + "\n\n" + if smart_context['recent_summary']: + previous_content += smart_context['recent_summary'] + "\n\n" + if smart_context['recent_full']: + previous_content += smart_context['recent_full'] + + # 构建记忆增强上下文 + memory_context = await memory_service.build_context_for_generation( + user_id=user_id, + project_id=project.id, + current_chapter=chapter.chapter_number, + chapter_outline=outline.content if outline else chapter.summary or "", + character_names=[c.name for c in characters] if characters else None + ) + + # 生成提示词 + if previous_content: + prompt = prompt_service.get_chapter_generation_with_context_prompt( + title=project.title, + theme=project.theme or '', + genre=project.genre or '', + narrative_perspective=project.narrative_perspective or '第三人称', + 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 '暂无角色信息', + outlines_context=outlines_context, + previous_content=previous_content, + chapter_number=chapter.chapter_number, + chapter_title=chapter.title, + chapter_outline=outline.content if outline else chapter.summary or '暂无大纲', + style_content=style_content, + target_word_count=target_word_count, + memory_context=memory_context + ) + else: + prompt = prompt_service.get_chapter_generation_prompt( + title=project.title, + theme=project.theme or '', + genre=project.genre or '', + narrative_perspective=project.narrative_perspective or '第三人称', + 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 '暂无角色信息', + outlines_context=outlines_context, + chapter_number=chapter.chapter_number, + chapter_title=chapter.title, + chapter_outline=outline.content if outline else chapter.summary or '暂无大纲', + style_content=style_content, + target_word_count=target_word_count, + memory_context=memory_context + ) + + # 非流式生成内容 + full_content = "" + async for chunk in ai_service.generate_text_stream(prompt=prompt): + full_content += chunk + + # 更新章节内容到数据库(使用锁保护) + async with write_lock: + old_word_count = chapter.word_count or 0 + chapter.content = full_content + new_word_count = len(full_content) + chapter.word_count = new_word_count + chapter.status = "completed" + + # 更新项目字数 + project.current_words = project.current_words - old_word_count + new_word_count + + # 记录生成历史 + history = GenerationHistory( + project_id=chapter.project_id, + chapter_id=chapter.id, + prompt=f"批量生成: 第{chapter.chapter_number}章 {chapter.title}", + generated_content=full_content[:500] if len(full_content) > 500 else full_content, + model="default" + ) + db_session.add(history) + + await db_session.commit() + await db_session.refresh(chapter) + + logger.info(f"✅ 单章节生成完成: 第{chapter.chapter_number}章,共 {new_word_count} 字") diff --git a/backend/app/database.py b/backend/app/database.py index 93f0b18..86aebac 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -21,7 +21,7 @@ from app.models import ( Project, Outline, Character, Chapter, GenerationHistory, Settings, WritingStyle, ProjectDefaultStyle, RelationshipType, CharacterRelationship, Organization, OrganizationMember, - StoryMemory, PlotAnalysis, AnalysisTask + StoryMemory, PlotAnalysis, AnalysisTask, BatchGenerationTask ) # 引擎缓存:每个用户一个引擎 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 61f7e8c..396099c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -15,6 +15,7 @@ from app.models.relationship import ( ) from app.models.memory import StoryMemory, PlotAnalysis from app.models.analysis_task import AnalysisTask +from app.models.batch_generation_task import BatchGenerationTask __all__ = [ "Project", @@ -32,4 +33,5 @@ __all__ = [ "StoryMemory", "PlotAnalysis", "AnalysisTask", + "BatchGenerationTask", ] \ No newline at end of file diff --git a/backend/app/models/batch_generation_task.py b/backend/app/models/batch_generation_task.py new file mode 100644 index 0000000..85ac92b --- /dev/null +++ b/backend/app/models/batch_generation_task.py @@ -0,0 +1,43 @@ +"""批量生成任务数据模型""" +from sqlalchemy import Column, String, Integer, DateTime, Boolean, JSON +from sqlalchemy.sql import func +from app.database import Base +import uuid + + +class BatchGenerationTask(Base): + """批量生成任务表""" + __tablename__ = "batch_generation_tasks" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + project_id = Column(String(36), nullable=False, comment="项目ID") + user_id = Column(String(100), nullable=False, comment="用户ID") + + # 任务配置 + start_chapter_number = Column(Integer, nullable=False, comment="起始章节序号") + chapter_count = Column(Integer, nullable=False, comment="生成章节数量") + chapter_ids = Column(JSON, nullable=False, comment="待生成的章节ID列表") + style_id = Column(Integer, comment="使用的写作风格ID") + target_word_count = Column(Integer, default=3000, comment="目标字数") + enable_analysis = Column(Boolean, default=False, comment="是否启用同步分析") + + # 任务状态 + status = Column(String(20), default="pending", comment="任务状态: pending/running/completed/failed/cancelled") + total_chapters = Column(Integer, default=0, comment="总章节数") + completed_chapters = Column(Integer, default=0, comment="已完成章节数") + failed_chapters = Column(JSON, default=list, comment="失败的章节信息列表") + current_chapter_id = Column(String(36), comment="当前正在生成的章节ID") + current_chapter_number = Column(Integer, comment="当前正在生成的章节序号") + current_retry_count = Column(Integer, default=0, comment="当前章节重试次数") + max_retries = Column(Integer, default=3, comment="最大重试次数") + + # 时间记录 + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + started_at = Column(DateTime, comment="开始时间") + completed_at = Column(DateTime, comment="完成时间") + + # 错误信息 + error_message = Column(String(500), comment="错误信息") + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/backend/app/schemas/chapter.py b/backend/app/schemas/chapter.py index b82c369..52e9d7f 100644 --- a/backend/app/schemas/chapter.py +++ b/backend/app/schemas/chapter.py @@ -65,4 +65,44 @@ class ChapterGenerateRequest(BaseModel): description="目标字数,默认3000字", ge=500, # 最小500字 le=10000 # 最大10000字 - ) \ No newline at end of file + ) + + +class BatchGenerateRequest(BaseModel): + """批量生成章节的请求模型""" + start_chapter_number: int = Field(..., description="起始章节序号") + count: int = Field(..., description="生成章节数量", ge=1, le=20) + style_id: Optional[int] = Field(None, description="写作风格ID") + target_word_count: Optional[int] = Field( + 3000, + description="目标字数,默认3000字", + ge=500, + le=10000 + ) + enable_analysis: bool = Field(False, description="是否启用同步分析") + max_retries: int = Field(3, description="每个章节的最大重试次数", ge=0, le=5) + + +class BatchGenerateResponse(BaseModel): + """批量生成响应模型""" + batch_id: str = Field(..., description="批次ID") + message: str = Field(..., description="响应消息") + chapters_to_generate: list[dict] = Field(..., description="待生成章节列表") + estimated_time_minutes: int = Field(..., description="预估耗时(分钟)") + + +class BatchGenerateStatusResponse(BaseModel): + """批量生成状态响应模型""" + batch_id: str + status: str + total: int + completed: int + current_chapter_id: Optional[str] = None + current_chapter_number: Optional[int] = None + current_retry_count: Optional[int] = None + max_retries: Optional[int] = None + failed_chapters: list[dict] = [] + created_at: Optional[str] = None + started_at: Optional[str] = None + completed_at: Optional[str] = None + error_message: Optional[str] = None \ No newline at end of file diff --git a/backend/app/services/prompt_service.py b/backend/app/services/prompt_service.py index 1b05a0f..d80c41d 100644 --- a/backend/app/services/prompt_service.py +++ b/backend/app/services/prompt_service.py @@ -478,7 +478,7 @@ class PromptService: 3. 符合角色性格设定 4. 体现世界观特色 5. 使用{narrative_perspective}视角 -6. 字数要求:不得低于{target_word_count}字 +6. **字数要求:目标{target_word_count}字,不得低于{target_word_count}字,建议控制在{target_word_count}至{max_word_count}字之间** 7. 语言自然流畅,避免AI痕迹 请直接输出章节正文内容,不要包含章节标题和其他说明文字。""" @@ -536,7 +536,7 @@ class PromptService: 4. **写作风格**: - 使用{narrative_perspective}视角 -- 字数要求:不得低于{target_word_count}字 +- **字数要求:目标{target_word_count}字,不得低于{target_word_count}字,建议控制在{target_word_count}至{max_word_count}字之间** - 语言自然流畅,避免AI痕迹 - 体现世界观特色 @@ -871,6 +871,9 @@ class PromptService: target_word_count: 目标字数,默认3000字 memory_context: 记忆上下文(可选) """ + # 计算最大字数(目标字数+1000) + max_word_count = target_word_count + 1000 + # 格式化记忆上下文 memory_text = "" if memory_context: @@ -896,7 +899,8 @@ class PromptService: chapter_number=chapter_number, chapter_title=chapter_title, chapter_outline=chapter_outline, - target_word_count=target_word_count + target_word_count=target_word_count, + max_word_count=max_word_count ) # 插入记忆上下文 @@ -930,6 +934,9 @@ class PromptService: target_word_count: 目标字数,默认3000字 memory_context: 记忆上下文(可选) """ + # 计算最大字数(目标字数+1000) + max_word_count = target_word_count + 1000 + # 格式化记忆上下文 memory_text = "" if memory_context: @@ -958,6 +965,7 @@ class PromptService: chapter_title=chapter_title, chapter_outline=chapter_outline, target_word_count=target_word_count, + max_word_count=max_word_count, memory_context=memory_text ) diff --git a/frontend/public/qq.jpg b/frontend/public/qq.jpg new file mode 100644 index 0000000..b94fcd7 Binary files /dev/null and b/frontend/public/qq.jpg differ diff --git a/frontend/src/components/AnnouncementModal.tsx b/frontend/src/components/AnnouncementModal.tsx new file mode 100644 index 0000000..d5b8583 --- /dev/null +++ b/frontend/src/components/AnnouncementModal.tsx @@ -0,0 +1,121 @@ +import { Modal, Button, Space } from 'antd'; +import { useEffect, useState } from 'react'; + +interface AnnouncementModalProps { + visible: boolean; + onClose: () => void; + onDoNotShowToday: () => void; +} + +export default function AnnouncementModal({ visible, onClose, onDoNotShowToday }: AnnouncementModalProps) { + const [imageError, setImageError] = useState(false); + + useEffect(() => { + if (visible) { + setImageError(false); + } + }, [visible]); + + const handleDoNotShowToday = () => { + onDoNotShowToday(); + onClose(); + }; + + return ( + + + + + } + width={600} + centered + styles={{ + body: { + padding: '24px', + }, + }} + > +
+
+

👋 欢迎加入我们的交流群!

+

在这里你可以:

+
    +
  • 💬 与其他创作者交流心得
  • +
  • 💡 获取最新功能更新和使用技巧
  • +
  • 🐛 反馈问题和建议
  • +
  • 📚 分享创作经验和灵感
  • +
+

+ 扫描下方二维码加入QQ交流群: +

+
+ + {!imageError ? ( +
+ QQ交流群二维码 setImageError(true)} + /> +
+ ) : ( +
+

二维码加载失败

+

+ 请确保 qq.jpg 文件位于 frontend/public/ 目录下 +

+
+ )} + +
+ 💡 提示:点击"今天内不再提示"可在今天内不再显示此公告 +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/AuthCallback.tsx b/frontend/src/pages/AuthCallback.tsx index 112eee7..fb3c716 100644 --- a/frontend/src/pages/AuthCallback.tsx +++ b/frontend/src/pages/AuthCallback.tsx @@ -2,11 +2,13 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Spin, Result, Button } from 'antd'; import { authApi } from '../services/api'; +import AnnouncementModal from '../components/AnnouncementModal'; export default function AuthCallback() { const navigate = useNavigate(); const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); const [errorMessage, setErrorMessage] = useState(''); + const [showAnnouncement, setShowAnnouncement] = useState(false); useEffect(() => { const handleCallback = async () => { @@ -21,10 +23,21 @@ export default function AuthCallback() { const redirect = sessionStorage.getItem('login_redirect') || '/'; sessionStorage.removeItem('login_redirect'); - // 延迟一下再跳转,让用户看到成功提示 - setTimeout(() => { - navigate(redirect); - }, 1000); + // 检查今天是否已经显示过公告 + const doNotShowUntil = localStorage.getItem('announcement_do_not_show_until'); + const now = new Date().getTime(); + + if (!doNotShowUntil || now > parseInt(doNotShowUntil)) { + // 延迟一下再显示公告,让用户看到成功提示 + setTimeout(() => { + setShowAnnouncement(true); + }, 1000); + } else { + // 延迟一下再跳转,让用户看到成功提示 + setTimeout(() => { + navigate(redirect); + }, 1000); + } } catch (error) { console.error('登录失败:', error); setStatus('error'); @@ -78,20 +91,41 @@ export default function AuthCallback() { ); } + const handleAnnouncementClose = () => { + setShowAnnouncement(false); + const redirect = sessionStorage.getItem('login_redirect') || '/'; + sessionStorage.removeItem('login_redirect'); + navigate(redirect); + }; + + const handleDoNotShowToday = () => { + // 设置到今天23:59:59不再显示 + const tomorrow = new Date(); + tomorrow.setHours(23, 59, 59, 999); + localStorage.setItem('announcement_do_not_show_until', tomorrow.getTime().toString()); + }; + return ( -
- + -
+
+ +
+ ); } \ No newline at end of file diff --git a/frontend/src/pages/Chapters.tsx b/frontend/src/pages/Chapters.tsx index 08b5707..e427630 100644 --- a/frontend/src/pages/Chapters.tsx +++ b/frontend/src/pages/Chapters.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react'; -import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip, InputNumber } from 'antd'; -import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'; +import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip, InputNumber, Progress, Alert, Radio } from 'antd'; +import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { useChapterSync } from '../store/hooks'; import { projectApi, writingStyleApi } from '../services/api'; @@ -29,6 +29,19 @@ export default function Chapters() { // 分析任务状态管理 const [analysisTasksMap, setAnalysisTasksMap] = useState>({}); const pollingIntervalsRef = useRef>({}); + + // 批量生成相关状态 + const [batchGenerateVisible, setBatchGenerateVisible] = useState(false); + const [batchGenerating, setBatchGenerating] = useState(false); + const [batchTaskId, setBatchTaskId] = useState(null); + const [batchProgress, setBatchProgress] = useState<{ + status: string; + total: number; + completed: number; + current_chapter_number: number | null; + estimated_time_minutes?: number; + } | null>(null); + const batchPollingIntervalRef = useRef(null); useEffect(() => { const handleResize = () => { @@ -50,6 +63,7 @@ export default function Chapters() { refreshChapters(); loadWritingStyles(); loadAnalysisTasks(); + checkAndRestoreBatchTask(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentProject?.id]); @@ -60,6 +74,9 @@ export default function Chapters() { Object.values(pollingIntervalsRef.current).forEach(interval => { clearInterval(interval); }); + if (batchPollingIntervalRef.current) { + clearInterval(batchPollingIntervalRef.current); + } }; }, []); @@ -157,6 +174,40 @@ export default function Chapters() { } }; + // 检查并恢复批量生成任务 + const checkAndRestoreBatchTask = async () => { + if (!currentProject?.id) return; + + try { + const response = await fetch(`/api/chapters/project/${currentProject.id}/batch-generate/active`); + if (!response.ok) return; + + const data = await response.json(); + + if (data.has_active_task && data.task) { + const task = data.task; + + // 恢复任务状态 + setBatchTaskId(task.batch_id); + setBatchProgress({ + status: task.status, + total: task.total, + completed: task.completed, + current_chapter_number: task.current_chapter_number, + }); + setBatchGenerating(true); + setBatchGenerateVisible(true); + + // 启动轮询 + startBatchPolling(task.batch_id); + + message.info('检测到未完成的批量生成任务,已自动恢复'); + } + } catch (error) { + console.error('检查批量生成任务失败:', error); + } + }; + if (!currentProject) return null; const canGenerateChapter = (chapter: Chapter): boolean => { @@ -436,6 +487,168 @@ export default function Chapters() { setAnalysisVisible(true); }; + // 批量生成函数 + const handleBatchGenerate = async (values: { + startChapterNumber: number; + count: number; + enableAnalysis: boolean; + styleId?: number; + targetWordCount?: number; + }) => { + if (!currentProject?.id) return; + + // 使用批量生成对话框中选择的风格和字数,如果没有选择则使用默认值 + const styleId = values.styleId || selectedStyleId; + const wordCount = values.targetWordCount || targetWordCount; + + if (!styleId) { + message.error('请选择写作风格'); + return; + } + + try { + setBatchGenerating(true); + + const response = await fetch(`/api/chapters/project/${currentProject.id}/batch-generate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + start_chapter_number: values.startChapterNumber, + count: values.count, + enable_analysis: values.enableAnalysis, + style_id: styleId, + target_word_count: wordCount, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || '创建批量生成任务失败'); + } + + const result = await response.json(); + setBatchTaskId(result.batch_id); + setBatchProgress({ + status: 'running', + total: result.chapters_to_generate.length, + completed: 0, + current_chapter_number: values.startChapterNumber, + estimated_time_minutes: result.estimated_time_minutes, + }); + + message.success(`批量生成任务已创建,预计需要 ${result.estimated_time_minutes} 分钟`); + + // 开始轮询任务状态 + startBatchPolling(result.batch_id); + + } catch (error: any) { + message.error('创建批量生成任务失败:' + (error.message || '未知错误')); + setBatchGenerating(false); + setBatchGenerateVisible(false); + } + }; + + // 轮询批量生成任务状态 + const startBatchPolling = (taskId: string) => { + if (batchPollingIntervalRef.current) { + clearInterval(batchPollingIntervalRef.current); + } + + const poll = async () => { + try { + const response = await fetch(`/api/chapters/batch-generate/${taskId}/status`); + if (!response.ok) return; + + const status = await response.json(); + setBatchProgress({ + status: status.status, + total: status.total, + completed: status.completed, + current_chapter_number: status.current_chapter_number, + }); + + // 任务完成或失败,停止轮询 + if (status.status === 'completed' || status.status === 'failed' || status.status === 'cancelled') { + if (batchPollingIntervalRef.current) { + clearInterval(batchPollingIntervalRef.current); + batchPollingIntervalRef.current = null; + } + + setBatchGenerating(false); + + if (status.status === 'completed') { + message.success(`批量生成完成!成功生成 ${status.completed} 章`); + // 刷新章节列表 + refreshChapters(); + loadAnalysisTasks(); + } else if (status.status === 'failed') { + message.error(`批量生成失败:${status.error_message || '未知错误'}`); + } else if (status.status === 'cancelled') { + message.warning('批量生成已取消'); + } + + // 延迟关闭对话框,让用户看到最终状态 + setTimeout(() => { + setBatchGenerateVisible(false); + setBatchTaskId(null); + setBatchProgress(null); + }, 2000); + } + } catch (error) { + console.error('轮询批量生成状态失败:', error); + } + }; + + // 立即执行一次 + poll(); + + // 每2秒轮询一次 + batchPollingIntervalRef.current = window.setInterval(poll, 2000); + }; + + // 取消批量生成 + const handleCancelBatchGenerate = async () => { + if (!batchTaskId) return; + + try { + const response = await fetch(`/api/chapters/batch-generate/${batchTaskId}/cancel`, { + method: 'POST', + }); + + if (!response.ok) { + throw new Error('取消失败'); + } + + message.success('批量生成已取消'); + } catch (error: any) { + message.error('取消失败:' + (error.message || '未知错误')); + } + }; + + // 打开批量生成对话框 + const handleOpenBatchGenerate = () => { + // 找到第一个未生成的章节 + const firstIncompleteChapter = sortedChapters.find( + ch => !ch.content || ch.content.trim() === '' + ); + + if (!firstIncompleteChapter) { + message.info('所有章节都已生成内容'); + return; + } + + // 检查该章节是否可以生成 + if (!canGenerateChapter(firstIncompleteChapter)) { + const reason = getGenerateDisabledReason(firstIncompleteChapter); + message.warning(reason); + return; + } + + setBatchGenerateVisible(true); + }; + // 渲染分析状态标签 const renderAnalysisStatus = (chapterId: string) => { const task = analysisTasksMap[chapterId]; @@ -496,6 +709,17 @@ export default function Chapters() { + + + + + + ) : ( +
+
+
+ 生成进度: + + + {batchProgress?.completed || 0} / {batchProgress?.total || 0} + + 章 + +
+ +
+ + {batchProgress?.current_chapter_number && ( + } + style={{ marginBottom: 16 }} + /> + )} + + {batchProgress?.estimated_time_minutes && batchProgress.completed === 0 && ( +
+ ⏱️ 预计耗时:约 {batchProgress.estimated_time_minutes} 分钟 +
+ )} + + +
  • 批量生成需要一定时间,可以切换到其他页面
  • +
  • 关闭页面后重新打开,会自动恢复任务进度
  • +
  • 可以随时点击"取消任务"按钮中止生成
  • + + } + type="warning" + showIcon + style={{ marginBottom: 16 }} + /> + +
    + +
    +
    + )} + ); } \ No newline at end of file diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 1cd7055..c7bdfb9 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -3,6 +3,7 @@ import { Button, Card, Space, Typography, message, Spin, Form, Input, Tabs } fro import { UserOutlined, LockOutlined } from '@ant-design/icons'; import { authApi } from '../services/api'; import { useNavigate, useSearchParams } from 'react-router-dom'; +import AnnouncementModal from '../components/AnnouncementModal'; const { Title, Paragraph } = Typography; @@ -14,6 +15,7 @@ export default function Login() { const [localAuthEnabled, setLocalAuthEnabled] = useState(false); const [linuxdoEnabled, setLinuxdoEnabled] = useState(false); const [form] = Form.useForm(); + const [showAnnouncement, setShowAnnouncement] = useState(false); // 检查是否已登录和获取认证配置 useEffect(() => { @@ -47,8 +49,17 @@ export default function Login() { if (response.success) { message.success('登录成功!'); - const redirect = searchParams.get('redirect') || '/'; - navigate(redirect); + + // 检查今天是否已经显示过公告 + const doNotShowUntil = localStorage.getItem('announcement_do_not_show_until'); + const now = new Date().getTime(); + + if (!doNotShowUntil || now > parseInt(doNotShowUntil)) { + setShowAnnouncement(true); + } else { + const redirect = searchParams.get('redirect') || '/'; + navigate(redirect); + } } } catch (error) { console.error('本地登录失败:', error); @@ -185,8 +196,27 @@ export default function Login() { ); + const handleAnnouncementClose = () => { + setShowAnnouncement(false); + const redirect = searchParams.get('redirect') || '/'; + navigate(redirect); + }; + + const handleDoNotShowToday = () => { + // 设置到今天23:59:59不再显示 + const tomorrow = new Date(); + tomorrow.setHours(23, 59, 59, 999); + localStorage.setItem('announcement_do_not_show_until', tomorrow.getTime().toString()); + }; + return ( -
    + +
    + ); } \ No newline at end of file