| """章节管理API""" |
| from fastapi import APIRouter, Depends, HTTPException, Request, Query |
| from fastapi.responses import StreamingResponse |
| from sqlalchemy.ext.asyncio import AsyncSession |
| from sqlalchemy import select, func |
| import json |
| import asyncio |
| from typing import Optional |
|
|
| from app.database import get_db |
| from app.models.chapter import Chapter |
| from app.models.project import Project |
| from app.models.outline import Outline |
| from app.models.character import Character |
| from app.models.generation_history import GenerationHistory |
| from app.models.writing_style import WritingStyle |
| from app.schemas.chapter import ( |
| ChapterCreate, |
| ChapterUpdate, |
| ChapterResponse, |
| ChapterListResponse, |
| ChapterGenerateRequest |
| ) |
| 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 |
|
|
| router = APIRouter(prefix="/chapters", tags=["章节管理"]) |
| logger = get_logger(__name__) |
|
|
|
|
| @router.post("", response_model=ChapterResponse, summary="创建章节") |
| async def create_chapter( |
| chapter: ChapterCreate, |
| db: AsyncSession = Depends(get_db) |
| ): |
| """创建新的章节""" |
| |
| result = await db.execute( |
| select(Project).where(Project.id == chapter.project_id) |
| ) |
| project = result.scalar_one_or_none() |
| if not project: |
| raise HTTPException(status_code=404, detail="项目不存在") |
| |
| |
| word_count = len(chapter.content) |
| |
| db_chapter = Chapter( |
| **chapter.model_dump(), |
| word_count=word_count |
| ) |
| db.add(db_chapter) |
| |
| |
| project.current_words = project.current_words + word_count |
| |
| await db.commit() |
| await db.refresh(db_chapter) |
| return db_chapter |
|
|
|
|
| @router.get("/project/{project_id}", response_model=ChapterListResponse, summary="获取项目的所有章节") |
| async def get_project_chapters( |
| project_id: str, |
| db: AsyncSession = Depends(get_db) |
| ): |
| """获取指定项目的所有章节(路径参数版本)""" |
| |
| count_result = await db.execute( |
| select(func.count(Chapter.id)).where(Chapter.project_id == project_id) |
| ) |
| total = count_result.scalar_one() |
| |
| |
| result = await db.execute( |
| select(Chapter) |
| .where(Chapter.project_id == project_id) |
| .order_by(Chapter.chapter_number) |
| ) |
| chapters = result.scalars().all() |
| |
| return ChapterListResponse(total=total, items=chapters) |
|
|
|
|
| @router.get("/{chapter_id}", response_model=ChapterResponse, summary="获取章节详情") |
| async def get_chapter( |
| chapter_id: str, |
| db: AsyncSession = Depends(get_db) |
| ): |
| """根据ID获取章节详情""" |
| result = await db.execute( |
| select(Chapter).where(Chapter.id == chapter_id) |
| ) |
| chapter = result.scalar_one_or_none() |
| |
| if not chapter: |
| raise HTTPException(status_code=404, detail="章节不存在") |
| |
| return chapter |
|
|
|
|
| @router.put("/{chapter_id}", response_model=ChapterResponse, summary="更新章节") |
| async def update_chapter( |
| chapter_id: str, |
| chapter_update: ChapterUpdate, |
| db: AsyncSession = Depends(get_db) |
| ): |
| """更新章节信息""" |
| result = await db.execute( |
| select(Chapter).where(Chapter.id == chapter_id) |
| ) |
| chapter = result.scalar_one_or_none() |
| |
| if not chapter: |
| raise HTTPException(status_code=404, detail="章节不存在") |
| |
| |
| old_word_count = chapter.word_count or 0 |
| |
| |
| update_data = chapter_update.model_dump(exclude_unset=True) |
| for field, value in update_data.items(): |
| setattr(chapter, field, value) |
| |
| |
| if "content" in update_data and chapter.content: |
| new_word_count = len(chapter.content) |
| chapter.word_count = new_word_count |
| |
| |
| result = await db.execute( |
| select(Project).where(Project.id == chapter.project_id) |
| ) |
| project = result.scalar_one_or_none() |
| if project: |
| project.current_words = project.current_words - old_word_count + new_word_count |
| |
| await db.commit() |
| await db.refresh(chapter) |
| return chapter |
|
|
|
|
| @router.delete("/{chapter_id}", summary="删除章节") |
| async def delete_chapter( |
| chapter_id: str, |
| db: AsyncSession = Depends(get_db) |
| ): |
| """删除章节""" |
| result = await db.execute( |
| select(Chapter).where(Chapter.id == chapter_id) |
| ) |
| chapter = result.scalar_one_or_none() |
| |
| if not chapter: |
| raise HTTPException(status_code=404, detail="章节不存在") |
| |
| |
| result = await db.execute( |
| select(Project).where(Project.id == chapter.project_id) |
| ) |
| project = result.scalar_one_or_none() |
| if project: |
| project.current_words = max(0, project.current_words - chapter.word_count) |
| |
| await db.delete(chapter) |
| await db.commit() |
| |
| return {"message": "章节删除成功"} |
|
|
|
|
| async def check_prerequisites(db: AsyncSession, chapter: Chapter) -> tuple[bool, str, list[Chapter]]: |
| """ |
| 检查章节前置条件 |
| |
| Args: |
| db: 数据库会话 |
| chapter: 当前章节 |
| |
| Returns: |
| (可否生成, 错误信息, 前置章节列表) |
| """ |
| |
| if chapter.chapter_number == 1: |
| return True, "", [] |
| |
| |
| result = await db.execute( |
| select(Chapter) |
| .where(Chapter.project_id == chapter.project_id) |
| .where(Chapter.chapter_number < chapter.chapter_number) |
| .order_by(Chapter.chapter_number) |
| ) |
| previous_chapters = result.scalars().all() |
| |
| |
| incomplete_chapters = [ |
| ch for ch in previous_chapters |
| if not ch.content or ch.content.strip() == "" |
| ] |
| |
| if incomplete_chapters: |
| missing_numbers = [str(ch.chapter_number) for ch in incomplete_chapters] |
| error_msg = f"需要先完成前置章节:第 {', '.join(missing_numbers)} 章" |
| return False, error_msg, previous_chapters |
| |
| return True, "", previous_chapters |
|
|
|
|
| @router.get("/{chapter_id}/can-generate", summary="检查章节是否可以生成") |
| async def check_can_generate( |
| chapter_id: str, |
| db: AsyncSession = Depends(get_db) |
| ): |
| """ |
| 检查章节是否满足生成条件 |
| 返回可生成状态和前置章节信息 |
| """ |
| |
| result = await db.execute( |
| select(Chapter).where(Chapter.id == chapter_id) |
| ) |
| chapter = result.scalar_one_or_none() |
| if not chapter: |
| raise HTTPException(status_code=404, detail="章节不存在") |
| |
| |
| can_generate, error_msg, previous_chapters = await check_prerequisites(db, chapter) |
| |
| |
| previous_info = [ |
| { |
| "id": ch.id, |
| "chapter_number": ch.chapter_number, |
| "title": ch.title, |
| "has_content": bool(ch.content and ch.content.strip()), |
| "word_count": ch.word_count or 0 |
| } |
| for ch in previous_chapters |
| ] |
| |
| return { |
| "can_generate": can_generate, |
| "reason": error_msg if not can_generate else "", |
| "previous_chapters": previous_info, |
| "chapter_number": chapter.chapter_number |
| } |
|
|
|
|
| @router.post("/{chapter_id}/generate-stream", summary="AI创作章节内容(流式)") |
| async def generate_chapter_content_stream( |
| chapter_id: str, |
| request: Request, |
| generate_request: ChapterGenerateRequest = ChapterGenerateRequest(), |
| user_ai_service: AIService = Depends(get_user_ai_service) |
| ): |
| """ |
| 根据大纲、前置章节内容和项目信息AI创作章节完整内容(流式返回) |
| 要求:必须按顺序生成,确保前置章节都已完成 |
| |
| 请求体参数: |
| - style_id: 可选,指定使用的写作风格ID。不提供则不使用任何风格 |
| |
| 注意:此函数不使用依赖注入的db,而是在生成器内部创建独立的数据库会话 |
| 以避免流式响应期间的连接泄漏问题 |
| """ |
| style_id = generate_request.style_id |
| |
| async for temp_db in get_db(request): |
| try: |
| result = await temp_db.execute( |
| select(Chapter).where(Chapter.id == chapter_id) |
| ) |
| chapter = result.scalar_one_or_none() |
| if not chapter: |
| raise HTTPException(status_code=404, detail="章节不存在") |
| |
| |
| can_generate, error_msg, previous_chapters = await check_prerequisites(temp_db, chapter) |
| if not can_generate: |
| raise HTTPException(status_code=400, detail=error_msg) |
| |
| |
| previous_chapters_data = [ |
| { |
| 'id': ch.id, |
| 'chapter_number': ch.chapter_number, |
| 'title': ch.title, |
| 'content': ch.content |
| } |
| for ch in previous_chapters |
| ] |
| finally: |
| await temp_db.close() |
| break |
| |
| async def event_generator(): |
| |
| db_session = None |
| db_committed = False |
| try: |
| |
| async for db_session in get_db(request): |
| |
| chapter_result = await db_session.execute( |
| select(Chapter).where(Chapter.id == chapter_id) |
| ) |
| current_chapter = chapter_result.scalar_one_or_none() |
| if not current_chapter: |
| yield f"data: {json.dumps({'type': 'error', 'error': '章节不存在'}, ensure_ascii=False)}\n\n" |
| return |
| |
| |
| project_result = await db_session.execute( |
| select(Project).where(Project.id == current_chapter.project_id) |
| ) |
| project = project_result.scalar_one_or_none() |
| if not project: |
| yield f"data: {json.dumps({'type': 'error', 'error': '项目不存在'}, ensure_ascii=False)}\n\n" |
| return |
| |
| |
| outline_result = await db_session.execute( |
| select(Outline) |
| .where(Outline.project_id == current_chapter.project_id) |
| .where(Outline.order_index == current_chapter.chapter_number) |
| .execution_options(populate_existing=True) |
| ) |
| outline = outline_result.scalar_one_or_none() |
| |
| |
| all_outlines_result = await db_session.execute( |
| select(Outline) |
| .where(Outline.project_id == current_chapter.project_id) |
| .order_by(Outline.order_index) |
| .execution_options(populate_existing=True) |
| ) |
| 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 == current_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 == current_chapter.project_id: |
| style_content = style.prompt_content or "" |
| style_type = "全局预设" if style.project_id is None else "项目自定义" |
| logger.info(f"使用指定风格: {style.name} ({style_type})") |
| else: |
| logger.warning(f"风格 {style_id} 不属于当前项目,无法使用") |
| else: |
| logger.warning(f"未找到风格 {style_id}") |
| else: |
| logger.info("未指定写作风格,使用原始提示词") |
| |
| |
| previous_content = "" |
| if previous_chapters_data: |
| recent_chapters = previous_chapters_data[-3:] if len(previous_chapters_data) > 3 else previous_chapters_data |
| early_chapters = previous_chapters_data[:-3] if len(previous_chapters_data) > 3 else [] |
| |
| if early_chapters: |
| early_summary = "【前期剧情概要】\n" + "\n".join([ |
| f"第{ch['chapter_number']}章《{ch['title']}》:{ch['content'][:200] if ch['content'] else ''}..." |
| for ch in early_chapters |
| ]) |
| previous_content += early_summary + "\n\n" |
| |
| if recent_chapters: |
| recent_content = "【最近章节完整内容】\n" + "\n\n".join([ |
| f"=== 第{ch['chapter_number']}章:{ch['title']} ===\n{ch['content']}" |
| for ch in recent_chapters |
| ]) |
| previous_content += recent_content |
| |
| logger.info(f"构建前置上下文:{len(early_chapters)}章摘要 + {len(recent_chapters)}章完整内容") |
| |
| |
| yield f"data: {json.dumps({'type': 'start', 'message': '开始AI创作...'}, ensure_ascii=False)}\n\n" |
| |
| |
| 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=current_chapter.chapter_number, |
| chapter_title=current_chapter.title, |
| chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲', |
| style_content=style_content |
| ) |
| 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=current_chapter.chapter_number, |
| chapter_title=current_chapter.title, |
| chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲', |
| style_content=style_content |
| ) |
| |
| logger.info(f"开始AI流式创作章节 {chapter_id}") |
| |
| |
| full_content = "" |
| async for chunk in user_ai_service.generate_text_stream(prompt=prompt): |
| full_content += chunk |
| yield f"data: {json.dumps({'type': 'content', 'content': chunk}, ensure_ascii=False)}\n\n" |
| await asyncio.sleep(0) |
| |
| |
| old_word_count = current_chapter.word_count or 0 |
| current_chapter.content = full_content |
| new_word_count = len(full_content) |
| current_chapter.word_count = new_word_count |
| current_chapter.status = "completed" |
| |
| |
| project.current_words = project.current_words - old_word_count + new_word_count |
| |
| |
| history = GenerationHistory( |
| project_id=current_chapter.project_id, |
| chapter_id=current_chapter.id, |
| prompt=f"创作章节: 第{current_chapter.chapter_number}章 {current_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() |
| db_committed = True |
| await db_session.refresh(current_chapter) |
| |
| logger.info(f"成功创作章节 {chapter_id},共 {new_word_count} 字") |
| |
| |
| yield f"data: {json.dumps({'type': 'done', 'message': '创作完成', 'word_count': new_word_count}, ensure_ascii=False)}\n\n" |
| |
| break |
| |
| except GeneratorExit: |
| |
| logger.warning("章节生成器被提前关闭(SSE断开)") |
| if db_session and not db_committed: |
| try: |
| if db_session.in_transaction(): |
| await db_session.rollback() |
| logger.info("章节生成事务已回滚(GeneratorExit)") |
| except Exception as e: |
| logger.error(f"GeneratorExit回滚失败: {str(e)}") |
| except Exception as e: |
| logger.error(f"流式创作章节失败: {str(e)}") |
| if db_session and not db_committed: |
| try: |
| if db_session.in_transaction(): |
| await db_session.rollback() |
| logger.info("章节生成事务已回滚(异常)") |
| except Exception as rollback_error: |
| logger.error(f"回滚失败: {str(rollback_error)}") |
| yield f"data: {json.dumps({'type': 'error', 'error': str(e)}, ensure_ascii=False)}\n\n" |
| finally: |
| |
| if db_session: |
| try: |
| |
| if not db_committed and db_session.in_transaction(): |
| await db_session.rollback() |
| logger.warning("在finally中发现未提交事务,已回滚") |
| |
| await db_session.close() |
| logger.info("数据库会话已关闭") |
| except Exception as close_error: |
| logger.error(f"关闭数据库会话失败: {str(close_error)}") |
| |
| try: |
| await db_session.close() |
| except: |
| pass |
| |
| return StreamingResponse( |
| event_generator(), |
| media_type="text/event-stream", |
| headers={ |
| "Cache-Control": "no-cache", |
| "Connection": "keep-alive", |
| "X-Accel-Buffering": "no" |
| } |
| ) |
|
|