diff --git a/backend/app/api/book_import.py b/backend/app/api/book_import.py new file mode 100644 index 0000000..f7a6daf --- /dev/null +++ b/backend/app/api/book_import.py @@ -0,0 +1,262 @@ +"""拆书导入 API""" +from __future__ import annotations + +import asyncio +from typing import AsyncGenerator + +from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.logger import get_logger +from app.schemas.book_import import ( + BookImportApplyRequest, + BookImportApplyResponse, + BookImportPreviewResponse, + BookImportRetryRequest, + BookImportTaskCreateResponse, + BookImportTaskStatusResponse, +) +from app.services.book_import_service import book_import_service +from app.utils.sse_response import SSEResponse, create_sse_response + +router = APIRouter(prefix="/book-import", tags=["拆书导入"]) +logger = get_logger(__name__) + +MAX_TXT_SIZE = 50 * 1024 * 1024 # 50MB + + +@router.post("/tasks", response_model=BookImportTaskCreateResponse, summary="创建拆书任务(上传TXT)") +async def create_book_import_task( + request: Request, + file: UploadFile = File(..., description="TXT 文件"), + project_id: str | None = Form(default=None, description="兼容参数:当前版本固定新建项目,不支持传入"), + create_new_project: bool = Form(default=True, description="兼容参数:当前版本仅支持 true"), + import_mode: str = Form(default="append", description="导入模式:append/overwrite"), +): + user_id = getattr(request.state, "user_id", None) + if not user_id: + raise HTTPException(status_code=401, detail="未登录") + + if not file.filename or not file.filename.lower().endswith(".txt"): + raise HTTPException(status_code=400, detail="仅支持 .txt 文件") + + if import_mode not in {"append", "overwrite"}: + raise HTTPException(status_code=400, detail="import_mode 仅支持 append 或 overwrite") + + if project_id: + raise HTTPException(status_code=400, detail="当前仅支持新建项目导入,不支持指定 project_id") + if not create_new_project: + raise HTTPException(status_code=400, detail="当前仅支持新建项目导入") + + content = await file.read() + if len(content) > MAX_TXT_SIZE: + raise HTTPException(status_code=413, detail="文件大小超过 50MB 限制") + + task = await book_import_service.create_task( + user_id=user_id, + filename=file.filename, + file_content=content, + project_id=None, + create_new_project=True, + import_mode=import_mode, + ) + return task + + +@router.get("/tasks/{task_id}", response_model=BookImportTaskStatusResponse, summary="查询拆书任务状态") +async def get_book_import_task_status(task_id: str, request: Request): + user_id = getattr(request.state, "user_id", None) + if not user_id: + raise HTTPException(status_code=401, detail="未登录") + + return await book_import_service.get_task_status(task_id=task_id, user_id=user_id) + + +@router.get("/tasks/{task_id}/preview", response_model=BookImportPreviewResponse, summary="获取拆书预览") +async def get_book_import_preview(task_id: str, request: Request): + user_id = getattr(request.state, "user_id", None) + if not user_id: + raise HTTPException(status_code=401, detail="未登录") + + return await book_import_service.get_preview(task_id=task_id, user_id=user_id) + + +@router.post("/tasks/{task_id}/apply", response_model=BookImportApplyResponse, summary="确认并导入") +async def apply_book_import( + task_id: str, + payload: BookImportApplyRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): + user_id = getattr(request.state, "user_id", None) + if not user_id: + raise HTTPException(status_code=401, detail="未登录") + + return await book_import_service.apply_import( + task_id=task_id, + user_id=user_id, + payload=payload, + db=db, + ) + + +@router.delete("/tasks/{task_id}", summary="取消拆书任务") +async def cancel_book_import_task(task_id: str, request: Request): + user_id = getattr(request.state, "user_id", None) + if not user_id: + raise HTTPException(status_code=401, detail="未登录") + + return await book_import_service.cancel_task(task_id=task_id, user_id=user_id) + + +@router.post("/tasks/{task_id}/apply-stream", summary="确认并导入(SSE流式进度)") +async def apply_book_import_stream( + task_id: str, + payload: BookImportApplyRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): + """ + SSE 流式接口:执行基础导入后,分步生成世界观/职业/角色并实时推送进度。 + 使用 asyncio.Queue 在服务与 SSE 生成器之间传递进度消息。 + """ + user_id = getattr(request.state, "user_id", None) + if not user_id: + raise HTTPException(status_code=401, detail="未登录") + + # 使用 asyncio.Queue 实现实时进度推送 + progress_queue: asyncio.Queue[str | None] = asyncio.Queue() + + async def _progress_callback(message: str, progress: int, status: str = "processing") -> None: + """进度回调:放入队列供 SSE 生成器消费""" + sse_msg = SSEResponse.format_sse({ + "type": "progress", + "message": message, + "progress": progress, + "status": status, + }) + await progress_queue.put(sse_msg) + + async def _run_import() -> None: + """在后台任务中执行导入并通过队列推送进度""" + try: + result = await book_import_service.apply_import_stream( + task_id=task_id, + user_id=user_id, + payload=payload, + db=db, + progress_callback=_progress_callback, + ) + + # 发送结果 + await progress_queue.put(await SSEResponse.send_result({ + "success": result.success, + "project_id": result.project_id, + "statistics": result.statistics, + })) + await progress_queue.put(await SSEResponse.send_progress("导入完成!", 100, "success")) + await progress_queue.put(await SSEResponse.send_done()) + except HTTPException as exc: + await progress_queue.put(await SSEResponse.send_error(exc.detail, exc.status_code)) + except Exception as exc: + logger.error(f"拆书SSE导入失败: {exc}", exc_info=True) + await progress_queue.put(await SSEResponse.send_error(str(exc), 500)) + finally: + # 发送终止信号 + await progress_queue.put(None) + + async def _streaming_generator() -> AsyncGenerator[str, None]: + yield await SSEResponse.send_progress("开始导入拆书数据...", 0, "processing") + + # 启动后台导入任务 + import_task = asyncio.create_task(_run_import()) + + try: + while True: + msg = await progress_queue.get() + if msg is None: + break + yield msg + except GeneratorExit: + import_task.cancel() + except Exception as exc: + logger.error(f"SSE生成器异常: {exc}", exc_info=True) + yield await SSEResponse.send_error(str(exc), 500) + + return create_sse_response(_streaming_generator()) + + +@router.post("/tasks/{task_id}/retry-stream", summary="重试失败的生成步骤(SSE流式进度)") +async def retry_failed_steps_stream( + task_id: str, + payload: BookImportRetryRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): + """ + SSE 流式接口:仅重试之前导入过程中失败的AI生成步骤(世界观/职业/角色)。 + """ + user_id = getattr(request.state, "user_id", None) + if not user_id: + raise HTTPException(status_code=401, detail="未登录") + + progress_queue: asyncio.Queue[str | None] = asyncio.Queue() + + async def _progress_callback(message: str, progress: int, status: str = "processing") -> None: + sse_msg = SSEResponse.format_sse({ + "type": "progress", + "message": message, + "progress": progress, + "status": status, + }) + await progress_queue.put(sse_msg) + + async def _run_retry() -> None: + try: + result = await book_import_service.retry_failed_steps_stream( + task_id=task_id, + user_id=user_id, + steps_to_retry=payload.steps, + db=db, + progress_callback=_progress_callback, + ) + + await progress_queue.put(await SSEResponse.send_result(result)) + + if result.get("still_failed"): + await progress_queue.put(await SSEResponse.send_progress( + f"重试完成,仍有 {len(result['still_failed'])} 个步骤失败", + 100, + "warning", + )) + else: + await progress_queue.put(await SSEResponse.send_progress("所有步骤重试成功!", 100, "success")) + + await progress_queue.put(await SSEResponse.send_done()) + except HTTPException as exc: + await progress_queue.put(await SSEResponse.send_error(exc.detail, exc.status_code)) + except Exception as exc: + logger.error(f"拆书SSE重试失败: {exc}", exc_info=True) + await progress_queue.put(await SSEResponse.send_error(str(exc), 500)) + finally: + await progress_queue.put(None) + + async def _streaming_generator() -> AsyncGenerator[str, None]: + yield await SSEResponse.send_progress("开始重试失败的生成步骤...", 0, "processing") + + retry_task = asyncio.create_task(_run_retry()) + + try: + while True: + msg = await progress_queue.get() + if msg is None: + break + yield msg + except GeneratorExit: + retry_task.cancel() + except Exception as exc: + logger.error(f"SSE重试生成器异常: {exc}", exc_info=True) + yield await SSEResponse.send_error(str(exc), 500) + + return create_sse_response(_streaming_generator()) \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index 0ab1490..4cf158f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -130,7 +130,7 @@ from app.api import ( wizard_stream, relationships, organizations, auth, users, settings, writing_styles, memories, mcp_plugins, admin, inspiration, prompt_templates, - changelog, careers, foreshadows, prompt_workshop + changelog, careers, foreshadows, prompt_workshop, book_import ) app.include_router(auth.router, prefix="/api") @@ -154,6 +154,7 @@ app.include_router(mcp_plugins.router, prefix="/api") # MCP插件管理API app.include_router(prompt_templates.router, prefix="/api") # 提示词模板管理API app.include_router(changelog.router, prefix="/api") # 更新日志API app.include_router(prompt_workshop.router, prefix="/api") # 提示词工坊API +app.include_router(book_import.router, prefix="/api") # 拆书导入API static_dir = Path(__file__).parent.parent / "static" if static_dir.exists(): diff --git a/backend/app/schemas/book_import.py b/backend/app/schemas/book_import.py new file mode 100644 index 0000000..38c3588 --- /dev/null +++ b/backend/app/schemas/book_import.py @@ -0,0 +1,92 @@ +"""拆书导入相关的 Pydantic Schema""" +from datetime import datetime +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field + + +TaskStatus = Literal["pending", "running", "completed", "failed", "cancelled"] +ImportMode = Literal["append", "overwrite"] +ExtractLevel = Literal["basic", "standard", "deep"] +WarningLevel = Literal["info", "warning", "error"] + + +class BookImportWarning(BaseModel): + """导入告警信息""" + code: str = Field(..., description="告警编码") + message: str = Field(..., description="告警内容") + level: WarningLevel = Field(default="warning", description="告警等级") + + +class ProjectSuggestion(BaseModel): + """项目建议信息(可在预览页修改)""" + title: str = Field(..., min_length=1, max_length=200, description="项目标题") + description: Optional[str] = Field(None, description="项目简介") + theme: Optional[str] = Field(None, description="主题") + genre: Optional[str] = Field(None, description="类型") + narrative_perspective: str = Field(default="第三人称", description="叙事视角") + target_words: int = Field(default=100000, ge=1000, description="目标字数(默认10万字)") + + +class BookImportChapter(BaseModel): + """预览章节""" + title: str = Field(..., min_length=1, max_length=200, description="章节标题") + content: str = Field(default="", description="章节正文") + summary: Optional[str] = Field(None, description="章节摘要") + chapter_number: int = Field(..., ge=1, description="章节序号") + outline_title: Optional[str] = Field(None, description="关联大纲标题(可选)") + + +class BookImportOutline(BaseModel): + """预览大纲""" + title: str = Field(..., min_length=1, max_length=200, description="大纲标题") + content: Optional[str] = Field(None, description="大纲内容") + order_index: int = Field(..., ge=1, description="排序序号") + structure: Optional[dict[str, Any]] = Field(None, description="结构化大纲(与系统大纲生成结构一致)") + + +class BookImportTaskCreateResponse(BaseModel): + """创建任务响应""" + task_id: str + status: TaskStatus + + +class BookImportTaskStatusResponse(BaseModel): + """任务状态响应""" + task_id: str + status: TaskStatus + progress: int = Field(..., ge=0, le=100) + message: Optional[str] = None + error: Optional[str] = None + created_at: datetime + updated_at: datetime + + +class BookImportPreviewResponse(BaseModel): + """预览数据响应""" + task_id: str + project_suggestion: ProjectSuggestion + chapters: list[BookImportChapter] + outlines: list[BookImportOutline] + warnings: list[BookImportWarning] + + +class BookImportApplyRequest(BaseModel): + """确认导入请求(支持前端修订后的数据)""" + project_suggestion: ProjectSuggestion + chapters: list[BookImportChapter] + outlines: list[BookImportOutline] = Field(default_factory=list) + import_mode: ImportMode = Field(default="append", description="导入模式") + + +class BookImportApplyResponse(BaseModel): + """确认导入响应""" + success: bool + project_id: str + statistics: dict[str, int] + warnings: list[BookImportWarning] = Field(default_factory=list) + + +class BookImportRetryRequest(BaseModel): + """重试失败步骤请求""" + steps: list[str] = Field(..., min_length=1, description="需要重试的步骤名列表,如 world_building / career_system / characters") \ No newline at end of file diff --git a/backend/app/services/book_import_service.py b/backend/app/services/book_import_service.py new file mode 100644 index 0000000..260a972 --- /dev/null +++ b/backend/app/services/book_import_service.py @@ -0,0 +1,2216 @@ +"""拆书导入服务:任务管理、预览构建与落库执行""" +from __future__ import annotations + +import asyncio +import json +import re +import uuid +from collections import Counter +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional + +from fastapi import HTTPException +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.api.common import verify_project_access +from app.config import settings as app_settings +from app.database import get_engine +from app.logger import get_logger +from app.models.chapter import Chapter +from app.models.character import Character +from app.models.career import Career, CharacterCareer +from app.models.foreshadow import Foreshadow +from app.models.mcp_plugin import MCPPlugin +from app.models.outline import Outline +from app.models.project import Project +from app.models.project_default_style import ProjectDefaultStyle +from app.models.relationship import CharacterRelationship, Organization, OrganizationMember, RelationshipType +from app.models.settings import Settings +from app.models.writing_style import WritingStyle +from app.schemas.book_import import ( + BookImportApplyRequest, + BookImportApplyResponse, + BookImportChapter, + BookImportOutline, + BookImportPreviewResponse, + BookImportTaskCreateResponse, + BookImportTaskStatusResponse, + BookImportWarning, + ProjectSuggestion, +) +from app.services.ai_service import AIService, create_user_ai_service_with_mcp +from app.services.prompt_service import PromptService +from app.services.txt_parser_service import txt_parser_service + +logger = get_logger(__name__) + + +@dataclass +class _StepFailure: + """记录某个生成步骤的失败信息""" + step_name: str # 步骤标识: world_building / career_system / characters + step_label: str # 步骤中文名 + error_message: str # 错误详情 + retry_count: int = 0 # 已重试次数 + + +@dataclass +class _BookImportTask: + task_id: str + user_id: str + filename: str + project_id: Optional[str] + create_new_project: bool + import_mode: str + status: str = "pending" + progress: int = 0 + message: Optional[str] = "任务已创建" + error: Optional[str] = None + created_at: datetime = field(default_factory=datetime.utcnow) + updated_at: datetime = field(default_factory=datetime.utcnow) + preview: Optional[BookImportPreviewResponse] = None + cancelled: bool = False + # 导入后生成的 project_id,用于重试时定位项目 + imported_project_id: Optional[str] = None + # 步骤级失败记录 + failed_steps: list[_StepFailure] = field(default_factory=list) + + +class BookImportService: + """拆书导入服务(首版:内存任务 + 规则解析)""" + + def __init__(self) -> None: + self._tasks: dict[str, _BookImportTask] = {} + self._tasks_lock = asyncio.Lock() + + async def create_task( + self, + *, + user_id: str, + filename: str, + file_content: bytes, + project_id: Optional[str], + create_new_project: bool, + import_mode: str, + ) -> BookImportTaskCreateResponse: + task_id = str(uuid.uuid4()) + task = _BookImportTask( + task_id=task_id, + user_id=user_id, + filename=filename, + project_id=project_id, + create_new_project=create_new_project, + import_mode=import_mode, + ) + async with self._tasks_lock: + self._tasks[task_id] = task + + asyncio.create_task(self._run_pipeline(task_id=task_id, file_content=file_content)) + return BookImportTaskCreateResponse(task_id=task_id, status="pending") + + async def get_task_status(self, *, task_id: str, user_id: str) -> BookImportTaskStatusResponse: + task = await self._get_task(task_id=task_id, user_id=user_id) + return self._to_status(task) + + async def get_preview(self, *, task_id: str, user_id: str) -> BookImportPreviewResponse: + task = await self._get_task(task_id=task_id, user_id=user_id) + if task.status != "completed": + raise HTTPException(status_code=400, detail="任务尚未完成,无法获取预览") + if not task.preview: + raise HTTPException(status_code=500, detail="预览数据不存在") + return task.preview + + async def cancel_task(self, *, task_id: str, user_id: str) -> dict: + task = await self._get_task(task_id=task_id, user_id=user_id) + if task.status in {"completed", "failed", "cancelled"}: + return {"success": True, "message": f"任务已是终态:{task.status}"} + + task.cancelled = True + self._set_task_state(task, status="cancelled", progress=task.progress, message="任务已取消") + return {"success": True, "message": "取消成功"} + + async def apply_import( + self, + *, + task_id: str, + user_id: str, + payload: BookImportApplyRequest, + db: AsyncSession, + ) -> BookImportApplyResponse: + task = await self._get_task(task_id=task_id, user_id=user_id) + if task.status != "completed": + raise HTTPException(status_code=400, detail="任务未完成,无法导入") + + statistics = { + "chapters": 0, + "outlines": 0, + } + + warnings = list(task.preview.warnings) if task.preview else [] + chapters_to_import, outlines_to_import, was_trimmed = self._trim_last_ten_for_apply( + chapters=payload.chapters, + outlines=payload.outlines, + ) + if was_trimmed: + warnings.append( + BookImportWarning( + code="apply_trimmed_to_last_ten", + message=f"导入阶段已强制仅保留最后 {len(chapters_to_import)} 章", + level="info", + ) + ) + + try: + project = await self._prepare_project( + db=db, + user_id=user_id, + task=task, + suggestion=payload.project_suggestion, + chapters=chapters_to_import, + import_mode=payload.import_mode, + ) + + outline_id_map = await self._import_outlines( + db=db, + project_id=project.id, + outlines=outlines_to_import, + import_mode=payload.import_mode, + ) + statistics["outlines"] = len(outlines_to_import) + + chapter_count, words_delta = await self._import_chapters( + db=db, + project_id=project.id, + chapters=chapters_to_import, + outline_id_map=outline_id_map, + import_mode=payload.import_mode, + ) + statistics["chapters"] = chapter_count + + if payload.import_mode == "overwrite": + project.current_words = words_delta + else: + project.current_words = (project.current_words or 0) + words_delta + + # 基于基础信息执行"向导前3步"(先生成世界观 -> 生成职业 -> 生成角色/组织),不生成大纲 + generated_world, generated_careers, generated_entities = await self._run_post_import_wizard_generation( + db=db, + user_id=user_id, + project=project, + character_count=max(project.character_count or 0, 8), + ) + statistics["generated_world_building"] = generated_world + statistics["generated_careers"] = generated_careers + statistics["generated_entities"] = generated_entities + + await db.commit() + + return BookImportApplyResponse( + success=True, + project_id=project.id, + statistics=statistics, + warnings=warnings, + ) + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + logger.error(f"拆书导入落库失败: {exc}", exc_info=True) + raise HTTPException(status_code=500, detail=f"导入失败: {exc}") + + # ---- 类型别名:进度回调 ---- + ProgressCallback = Optional[Any] # Callable[[str, int, str], Awaitable[None]] + + async def apply_import_stream( + self, + *, + task_id: str, + user_id: str, + payload: BookImportApplyRequest, + db: AsyncSession, + progress_callback: Any = None, + ) -> BookImportApplyResponse: + """ + 与 apply_import 相同的落库逻辑,但通过 progress_callback 推送细粒度进度。 + progress_callback(message: str, progress: int, status: str) + """ + task = await self._get_task(task_id=task_id, user_id=user_id) + if task.status != "completed": + raise HTTPException(status_code=400, detail="任务未完成,无法导入") + + statistics: Dict[str, int] = { + "chapters": 0, + "outlines": 0, + } + + warnings = list(task.preview.warnings) if task.preview else [] + chapters_to_import, outlines_to_import, was_trimmed = self._trim_last_ten_for_apply( + chapters=payload.chapters, + outlines=payload.outlines, + ) + if was_trimmed: + warnings.append( + BookImportWarning( + code="apply_trimmed_to_last_ten", + message=f"导入阶段已强制仅保留最后 {len(chapters_to_import)} 章", + level="info", + ) + ) + + async def _notify(message: str, progress: int, status: str = "processing") -> None: + if progress_callback: + await progress_callback(message, progress, status) + + try: + # -- 步骤1: 创建项目 (0-5%) + await _notify("正在创建项目...", 2) + project = await self._prepare_project( + db=db, + user_id=user_id, + task=task, + suggestion=payload.project_suggestion, + chapters=chapters_to_import, + import_mode=payload.import_mode, + ) + await _notify("项目创建完成", 5) + + # -- 步骤2: 导入大纲 (5-10%) + await _notify("正在导入大纲...", 6) + outline_id_map = await self._import_outlines( + db=db, + project_id=project.id, + outlines=outlines_to_import, + import_mode=payload.import_mode, + ) + statistics["outlines"] = len(outlines_to_import) + await _notify(f"已导入 {len(outlines_to_import)} 个大纲", 10) + + # -- 步骤3: 导入章节 (10-20%) + await _notify(f"正在导入 {len(chapters_to_import)} 个章节...", 12) + chapter_count, words_delta = await self._import_chapters( + db=db, + project_id=project.id, + chapters=chapters_to_import, + outline_id_map=outline_id_map, + import_mode=payload.import_mode, + ) + statistics["chapters"] = chapter_count + + if payload.import_mode == "overwrite": + project.current_words = words_delta + else: + project.current_words = (project.current_words or 0) + words_delta + await _notify(f"已导入 {chapter_count} 个章节({words_delta}字)", 20) + + # -- 步骤4: 生成世界观 (20-40%) + failed_steps: list[_StepFailure] = [] + + await _notify("🌍 正在生成世界观...", 22) + try: + generated_world = await self._generate_world_building_from_project( + db=db, + user_id=user_id, + project=project, + progress_callback=progress_callback, + progress_range=(22, 40), + raise_on_error=True, + ) + statistics["generated_world_building"] = generated_world + await _notify("🌍 世界观生成完成", 40) + except Exception as exc: + logger.warning(f"拆书导入:世界观生成失败(将继续后续步骤): {exc}") + failed_steps.append(_StepFailure( + step_name="world_building", + step_label="世界观生成", + error_message=str(exc), + )) + await _notify(f"⚠️ 世界观生成失败:{str(exc)[:80]},将继续后续步骤", 40, "warning") + + # -- 步骤5: 生成职业体系 (40-65%) + await _notify("💼 正在生成职业体系...", 42) + try: + generated_careers = await self._generate_career_system_from_project( + db=db, + user_id=user_id, + project=project, + progress_callback=progress_callback, + progress_range=(42, 65), + ) + statistics["generated_careers"] = generated_careers + await _notify(f"💼 职业体系生成完成({generated_careers}个)", 65) + except Exception as exc: + logger.warning(f"拆书导入:职业体系生成失败(将继续后续步骤): {exc}") + failed_steps.append(_StepFailure( + step_name="career_system", + step_label="职业体系生成", + error_message=str(exc), + )) + await _notify(f"⚠️ 职业体系生成失败:{str(exc)[:80]},将继续后续步骤", 65, "warning") + + # -- 步骤6: 生成角色/组织 (65-92%) + character_count_target = max(project.character_count or 0, 5) + await _notify("👥 正在生成角色与组织...", 67) + try: + generated_entities = await self._generate_characters_and_organizations_from_project( + db=db, + user_id=user_id, + project=project, + count=character_count_target, + progress_callback=progress_callback, + progress_range=(67, 92), + ) + statistics["generated_entities"] = generated_entities + await _notify(f"👥 角色/组织生成完成({generated_entities}个)", 92) + except Exception as exc: + logger.warning(f"拆书导入:角色/组织生成失败: {exc}") + failed_steps.append(_StepFailure( + step_name="characters", + step_label="角色与组织生成", + error_message=str(exc), + )) + await _notify(f"⚠️ 角色/组织生成失败:{str(exc)[:80]}", 92, "warning") + + # 标记向导完成并将项目置为创作中 + project.wizard_step = 3 + project.wizard_status = "completed" + project.status = "writing" + + # -- 步骤7: 提交数据库 (92-98%) + await _notify("正在保存到数据库...", 95) + await db.commit() + await _notify("数据保存完成", 98) + + # 记录失败步骤和项目ID到任务中,供重试使用 + task.imported_project_id = project.id + task.failed_steps = failed_steps + + # 如果有步骤失败,通过 SSE 推送失败步骤详情 + if failed_steps: + failed_info = [ + {"step_name": f.step_name, "step_label": f.step_label, "error": f.error_message} + for f in failed_steps + ] + await _notify( + f"⚠️ 导入完成,但有 {len(failed_steps)} 个生成步骤失败,可点击重试", + 98, + "warning", + ) + # 通过特殊的 progress 消息推送失败步骤列表 + if progress_callback: + await progress_callback( + json.dumps({"failed_steps": failed_info}, ensure_ascii=False), + 98, + "step_failures", + ) + + return BookImportApplyResponse( + success=True, + project_id=project.id, + statistics=statistics, + warnings=warnings, + ) + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + logger.error(f"拆书导入落库失败: {exc}", exc_info=True) + raise HTTPException(status_code=500, detail=f"导入失败: {exc}") + + async def retry_failed_steps_stream( + self, + *, + task_id: str, + user_id: str, + steps_to_retry: list[str], + db: AsyncSession, + progress_callback: Any = None, + ) -> dict: + """ + 仅重试之前导入时失败的AI生成步骤。 + steps_to_retry: 需要重试的步骤名列表, 如 ["world_building", "career_system", "characters"] + """ + task = await self._get_task(task_id=task_id, user_id=user_id) + project_id = task.imported_project_id + if not project_id: + raise HTTPException(status_code=400, detail="该任务尚未完成导入,无法重试") + + # 验证 steps_to_retry 都是合法的失败步骤 + failed_step_names = {f.step_name for f in task.failed_steps} + invalid_steps = [s for s in steps_to_retry if s not in failed_step_names] + if invalid_steps: + raise HTTPException( + status_code=400, + detail=f"以下步骤不在失败列表中,无法重试: {', '.join(invalid_steps)}", + ) + + async def _notify(message: str, progress: int, status: str = "processing") -> None: + if progress_callback: + await progress_callback(message, progress, status) + + try: + from app.api.common import verify_project_access + project = await verify_project_access(project_id, user_id, db) + + retry_results: dict[str, Any] = {} + still_failed: list[_StepFailure] = [] + total_steps = len(steps_to_retry) + + for step_idx, step_name in enumerate(steps_to_retry): + step_start_pct = int(5 + (step_idx / total_steps) * 85) + step_end_pct = int(5 + ((step_idx + 1) / total_steps) * 85) + + # 查找原来的失败记录 + original_failure = next((f for f in task.failed_steps if f.step_name == step_name), None) + retry_count = (original_failure.retry_count if original_failure else 0) + 1 + + if step_name == "world_building": + await _notify("🔄 正在重试世界观生成...", step_start_pct) + try: + result = await self._generate_world_building_from_project( + db=db, + user_id=user_id, + project=project, + progress_callback=progress_callback, + progress_range=(step_start_pct, step_end_pct), + raise_on_error=True, + ) + retry_results["generated_world_building"] = result + await _notify("✅ 世界观重试成功", step_end_pct) + except Exception as exc: + logger.warning(f"世界观重试失败 (第{retry_count}次): {exc}") + still_failed.append(_StepFailure( + step_name="world_building", + step_label="世界观生成", + error_message=str(exc), + retry_count=retry_count, + )) + await _notify(f"⚠️ 世界观重试失败:{str(exc)[:80]}", step_end_pct, "warning") + + elif step_name == "career_system": + await _notify("🔄 正在重试职业体系生成...", step_start_pct) + try: + result = await self._generate_career_system_from_project( + db=db, + user_id=user_id, + project=project, + progress_callback=progress_callback, + progress_range=(step_start_pct, step_end_pct), + ) + retry_results["generated_careers"] = result + await _notify(f"✅ 职业体系重试成功({result}个)", step_end_pct) + except Exception as exc: + logger.warning(f"职业体系重试失败 (第{retry_count}次): {exc}") + still_failed.append(_StepFailure( + step_name="career_system", + step_label="职业体系生成", + error_message=str(exc), + retry_count=retry_count, + )) + await _notify(f"⚠️ 职业体系重试失败:{str(exc)[:80]}", step_end_pct, "warning") + + elif step_name == "characters": + character_count_target = max(project.character_count or 0, 5) + await _notify("🔄 正在重试角色与组织生成...", step_start_pct) + try: + result = await self._generate_characters_and_organizations_from_project( + db=db, + user_id=user_id, + project=project, + count=character_count_target, + progress_callback=progress_callback, + progress_range=(step_start_pct, step_end_pct), + ) + retry_results["generated_entities"] = result + await _notify(f"✅ 角色/组织重试成功({result}个)", step_end_pct) + except Exception as exc: + logger.warning(f"角色/组织重试失败 (第{retry_count}次): {exc}") + still_failed.append(_StepFailure( + step_name="characters", + step_label="角色与组织生成", + error_message=str(exc), + retry_count=retry_count, + )) + await _notify(f"⚠️ 角色/组织重试失败:{str(exc)[:80]}", step_end_pct, "warning") + + # 提交数据库 + await _notify("正在保存到数据库...", 93) + await db.commit() + await _notify("数据保存完成", 96) + + # 更新任务的失败步骤记录 + task.failed_steps = still_failed + + if still_failed: + failed_info = [ + {"step_name": f.step_name, "step_label": f.step_label, "error": f.error_message, "retry_count": f.retry_count} + for f in still_failed + ] + if progress_callback: + await progress_callback( + json.dumps({"failed_steps": failed_info}, ensure_ascii=False), + 98, + "step_failures", + ) + + return { + "success": True, + "project_id": project_id, + "retry_results": retry_results, + "still_failed": [ + {"step_name": f.step_name, "step_label": f.step_label, "error": f.error_message, "retry_count": f.retry_count} + for f in still_failed + ], + } + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + logger.error(f"拆书重试失败: {exc}", exc_info=True) + raise HTTPException(status_code=500, detail=f"重试失败: {exc}") + + async def _run_pipeline(self, *, task_id: str, file_content: bytes) -> None: + task = self._tasks.get(task_id) + if not task: + return + + try: + # 进度分配:编码识别 5%,文本清洗 10%,章节切分 15%,截取末10章 18%,AI反向生成 20%-95%,完成 100% + self._set_task_state(task, status="running", progress=5, message="正在识别编码并读取文本...") + self._check_cancelled(task) + + text, encoding = txt_parser_service.decode_bytes(file_content) + cleaned = txt_parser_service.clean_text(text) + + self._set_task_state(task, status="running", progress=10, message=f"文本清洗完成(编码:{encoding})") + self._check_cancelled(task) + + chapters_data = txt_parser_service.split_chapters(cleaned) + if not chapters_data: + raise ValueError("未能识别到有效章节,请检查TXT内容") + + self._set_task_state( + task, status="running", progress=15, + message=f"已识别 {len(chapters_data)} 个章节,正在构建预览结构...", + ) + self._check_cancelled(task) + + self._set_task_state(task, status="running", progress=18, message="仅保留末10章并重建预览结构...") + preview = await self._build_preview( + task=task, + filename=task.filename, + task_id=task.task_id, + chapters_data=chapters_data, + ) + + self._check_cancelled(task) + task.preview = preview + self._set_task_state(task, status="completed", progress=100, message="解析完成,可预览并确认导入") + except asyncio.CancelledError: + self._set_task_state(task, status="cancelled", progress=task.progress, message="任务已取消") + except Exception as exc: + logger.error(f"拆书任务失败 task_id={task_id}: {exc}", exc_info=True) + self._set_task_state( + task, + status="failed", + progress=task.progress, + message="解析失败", + error=str(exc), + ) + + async def _prepare_project( + self, + *, + db: AsyncSession, + user_id: str, + task: _BookImportTask, + suggestion: ProjectSuggestion, + chapters: list[BookImportChapter], + import_mode: str, + ) -> Project: + world_time_period, world_location, world_atmosphere, world_rules = self._derive_world_settings( + suggestion=suggestion, + chapters=chapters, + ) + + if task.create_new_project: + project = Project( + user_id=user_id, + title=suggestion.title, + description=suggestion.description, + theme=suggestion.theme, + genre=suggestion.genre, + status="planning", + wizard_status="incomplete", + wizard_step=1, + outline_mode="one-to-one", + current_words=0, + target_words=max(1000, int(suggestion.target_words or 100000)), + narrative_perspective=(suggestion.narrative_perspective or "第三人称")[:50], + world_time_period=world_time_period, + world_location=world_location, + world_atmosphere=world_atmosphere, + world_rules=world_rules, + ) + db.add(project) + await db.flush() + await self._ensure_project_default_style(db=db, project_id=project.id) + return project + + if not task.project_id: + raise HTTPException(status_code=400, detail="缺少目标项目ID") + + project = await verify_project_access(task.project_id, user_id, db) + + # 覆盖模式清空相关数据 + if import_mode == "overwrite": + await self._clear_project_data(db=db, project_id=project.id) + project.title = suggestion.title or project.title + project.description = suggestion.description + project.theme = suggestion.theme + project.genre = suggestion.genre + project.target_words = max(1000, int(suggestion.target_words or 100000)) + project.narrative_perspective = (suggestion.narrative_perspective or "第三人称")[:50] + project.world_time_period = world_time_period + project.world_location = world_location + project.world_atmosphere = world_atmosphere + project.world_rules = world_rules + + await self._ensure_project_default_style(db=db, project_id=project.id) + return project + + async def _clear_project_data(self, *, db: AsyncSession, project_id: str) -> None: + await db.execute(delete(Foreshadow).where(Foreshadow.project_id == project_id)) + await db.execute(delete(Chapter).where(Chapter.project_id == project_id)) + await db.execute(delete(Outline).where(Outline.project_id == project_id)) + + # 覆盖导入时统一清理角色相关链路,避免后续自动生成出现脏数据 + char_ids_result = await db.execute(select(Character.id).where(Character.project_id == project_id)) + char_ids = [row[0] for row in char_ids_result.fetchall()] + + await db.execute(delete(CharacterRelationship).where(CharacterRelationship.project_id == project_id)) + await db.execute(delete(OrganizationMember).where(OrganizationMember.character_id.in_(char_ids))) + await db.execute(delete(Organization).where(Organization.project_id == project_id)) + await db.execute(delete(CharacterCareer).where(CharacterCareer.character_id.in_(char_ids))) + await db.execute(delete(Career).where(Career.project_id == project_id)) + await db.execute(delete(Character).where(Character.project_id == project_id)) + + async def _ensure_project_default_style(self, *, db: AsyncSession, project_id: str) -> None: + """确保项目存在默认写作风格(缺失时自动设置为首个全局预设风格)。""" + existing_result = await db.execute( + select(ProjectDefaultStyle.style_id).where(ProjectDefaultStyle.project_id == project_id) + ) + if existing_result.scalar_one_or_none() is not None: + return + + preset_result = await db.execute( + select(WritingStyle.id, WritingStyle.name) + .where(WritingStyle.user_id.is_(None)) + .order_by(func.coalesce(WritingStyle.order_index, 999999), WritingStyle.id) + .limit(1) + ) + preset_row = preset_result.first() + if not preset_row: + logger.warning(f"项目 {project_id} 未找到可用全局预设风格,跳过默认风格设置") + return + + style_id, style_name = preset_row + db.add(ProjectDefaultStyle(project_id=project_id, style_id=style_id)) + logger.info(f"项目 {project_id} 自动设置默认写作风格: {style_name}(id={style_id})") + + async def _import_outlines( + self, + *, + db: AsyncSession, + project_id: str, + outlines: list[BookImportOutline], + import_mode: str, + ) -> dict[str, str]: + if not outlines: + return {} + + existing_max_order = 0 + if import_mode == "append": + res = await db.execute(select(func.max(Outline.order_index)).where(Outline.project_id == project_id)) + existing_max_order = res.scalar_one() or 0 + + title_to_id: dict[str, str] = {} + for idx, item in enumerate(outlines, start=1): + outline_content = item.content + if not outline_content and item.structure and isinstance(item.structure, dict): + outline_content = str(item.structure.get("summary") or item.structure.get("content") or "").strip() + + outline = Outline( + project_id=project_id, + title=item.title, + content=outline_content, + structure=json.dumps(item.structure, ensure_ascii=False) if item.structure else None, + order_index=(existing_max_order + idx), + ) + db.add(outline) + await db.flush() + title_to_id[item.title] = outline.id + + return title_to_id + + async def _import_chapters( + self, + *, + db: AsyncSession, + project_id: str, + chapters: list[BookImportChapter], + outline_id_map: dict[str, str], + import_mode: str, + ) -> tuple[int, int]: + if not chapters: + return 0, 0 + + chapter_number_offset = 0 + if import_mode == "append": + res = await db.execute(select(func.max(Chapter.chapter_number)).where(Chapter.project_id == project_id)) + chapter_number_offset = res.scalar_one() or 0 + + count = 0 + total_words = 0 + for item in sorted(chapters, key=lambda x: x.chapter_number): + chapter_number = chapter_number_offset + item.chapter_number + word_count = len(item.content or "") + + chapter = Chapter( + project_id=project_id, + title=item.title, + content=item.content, + summary=item.summary, + chapter_number=chapter_number, + word_count=word_count, + status="draft", + outline_id=outline_id_map.get(item.outline_title or ""), + sub_index=1, + ) + db.add(chapter) + count += 1 + total_words += word_count + + return count, total_words + + def _trim_last_ten_for_apply( + self, + *, + chapters: list[BookImportChapter], + outlines: list[BookImportOutline], + ) -> tuple[list[BookImportChapter], list[BookImportOutline], bool]: + if not chapters: + return [], [], False + + sorted_chapters = sorted(chapters, key=lambda x: x.chapter_number) + selected = sorted_chapters[-10:] + was_trimmed = len(sorted_chapters) > len(selected) or len(outlines) > 10 + + normalized_chapters: list[BookImportChapter] = [] + for idx, item in enumerate(selected, start=1): + normalized_chapters.append( + BookImportChapter( + title=item.title, + content=item.content, + summary=item.summary, + chapter_number=idx, + outline_title=item.outline_title or item.title, + ) + ) + + normalized_outlines: list[BookImportOutline] = [] + sorted_outlines = sorted(outlines, key=lambda x: x.order_index) if outlines else [] + if sorted_outlines: + selected_outlines = sorted_outlines[-len(normalized_chapters):] + for idx, item in enumerate(selected_outlines, start=1): + normalized_outlines.append( + BookImportOutline( + title=item.title, + content=item.content, + order_index=idx, + structure=item.structure, + ) + ) + + while len(normalized_outlines) < len(normalized_chapters): + chapter = normalized_chapters[len(normalized_outlines)] + normalized_outlines.append( + BookImportOutline( + title=chapter.outline_title or chapter.title, + content=chapter.summary, + order_index=len(normalized_outlines) + 1, + structure=self._build_fallback_outline_structure(chapter), + ) + ) + + for idx in range(min(len(normalized_chapters), len(normalized_outlines))): + normalized_chapters[idx].outline_title = normalized_outlines[idx].title + + return normalized_chapters, normalized_outlines, was_trimmed + + def _derive_world_settings( + self, + *, + suggestion: ProjectSuggestion, + chapters: list[BookImportChapter], + ) -> tuple[str, str, str, str]: + """根据拆书内容推断基础世界设定,确保新建项目有可用初始值。""" + sample_parts: list[str] = [ + suggestion.title or "", + suggestion.theme or "", + suggestion.genre or "", + suggestion.description or "", + ] + for chapter in chapters[:3]: + if chapter.content: + sample_parts.append(chapter.content[:1200]) + + sample_text = "\n".join(sample_parts) + genre = suggestion.genre or "" + theme = suggestion.theme or "" + + time_period = self._detect_time_period(sample_text, genre) + location = self._detect_location(sample_text, genre) + atmosphere = self._detect_atmosphere(sample_text, genre, theme) + rules = self._detect_world_rules(sample_text, genre) + + return time_period, location, atmosphere, rules + + def _detect_time_period(self, text: str, genre: str) -> str: + if any(k in text for k in ("民国", "军阀", "北洋", "租界")): + return "近代民国时期" + if any(k in text for k in ("星际", "宇宙", "机甲", "赛博", "未来", "人工智能")): + return "未来科技时代" + if any(k in text for k in ("古代", "王朝", "皇帝", "后宫", "朝堂", "将军", "宗门", "修仙", "江湖", "武林")): + return "古代架空时代" + if any(k in text for k in ("校园", "大学", "高中", "公司", "都市", "地铁")): + return "现代都市" + + if any(k in genre for k in ("科幻", "星际")): + return "未来科技时代" + if any(k in genre for k in ("仙侠", "玄幻", "武侠", "历史", "古言")): + return "古代架空时代" + return "现代都市(可在世界设定页调整)" + + def _detect_location(self, text: str, genre: str) -> str: + if any(k in text for k in ("星际", "宇宙", "舰队", "空间站", "机甲")): + return "多星系宇宙与舰队文明" + if any(k in text for k in ("宗门", "仙门", "秘境", "灵脉", "江湖", "武林")): + return "宗门林立的江湖/仙侠世界" + if any(k in text for k in ("王朝", "都城", "皇宫", "边关", "朝堂")): + return "王朝都城与边疆并存的古代世界" + if any(k in text for k in ("校园", "大学", "高中")): + return "校园与城市生活场景" + if any(k in text for k in ("都市", "城市", "街区", "公司", "医院")): + return "现代城市社会" + + if "悬疑" in genre: + return "现代城市与封闭场景并行" + return "以人物活动区域为核心的现实场景" + + def _detect_atmosphere(self, text: str, genre: str, theme: str) -> str: + if any(k in text for k in ("悬疑", "谜", "诡", "凶案", "惊悚", "追查")): + return "紧张悬疑、危机渐进" + if any(k in text for k in ("热血", "战斗", "对决", "复仇", "战争")): + return "高压对抗、节奏强烈" + if any(k in text for k in ("治愈", "日常", "温馨", "轻松", "搞笑")): + return "日常细腻、轻松温暖" + if any(k in text for k in ("权谋", "宫斗", "朝堂", "家族斗争")): + return "权谋博弈、暗流涌动" + + if "言情" in genre: + return "情感拉扯、细腻克制" + if theme: + return f"{theme}导向、人物驱动" + return "人物驱动、冲突递进" + + def _detect_world_rules(self, text: str, genre: str) -> str: + if any(k in text for k in ("修仙", "玄幻", "灵气", "境界", "宗门", "飞升")) or any(k in genre for k in ("仙侠", "玄幻")): + return "存在修炼体系与等级秩序,资源与传承决定势力格局。" + if any(k in text for k in ("星际", "机甲", "赛博", "人工智能", "基因")) or any(k in genre for k in ("科幻", "星际")): + return "科技规则主导社会运行,组织制度与技术能力决定角色行动边界。" + if any(k in text for k in ("江湖", "门派", "武林", "侠客")) or "武侠" in genre: + return "江湖门派秩序与恩怨规则并行,强者与名望影响话语权。" + if any(k in text for k in ("王朝", "皇权", "朝堂", "礼法")) or any(k in genre for k in ("历史", "古言")): + return "以礼法与权力秩序为基础,家国与阶层关系深刻影响人物命运。" + return "以现实逻辑为基础,结合剧情推进逐步补充特殊设定。" + + def _strip_chapter_prefix(self, title: str) -> str: + """移除章节标题前缀“第X章/节/回/卷”,保留真实标题。""" + normalized = (title or "").strip() + if not normalized: + return normalized + + stripped = re.sub( + r"^第\s*[0-9零一二三四五六七八九十百千万两〇]+\s*[章节回卷]\s*[-—::、..))】\]]*\s*", + "", + normalized, + ).strip() + + return stripped or normalized + + async def _build_preview( + self, + *, + task: _BookImportTask, + filename: str, + task_id: str, + chapters_data: list[dict], + ) -> BookImportPreviewResponse: + suggestion = ProjectSuggestion( + title=Path(filename).stem[:200] or "拆书导入项目", + description="由拆书功能自动生成,可在导入前修改", + theme=None, + genre=None, + narrative_perspective="第三人称", + target_words=100000, + ) + + chapters: list[BookImportChapter] = [] + warnings: list[BookImportWarning] = [] + + # 仅保留最后10章用于最终导入,重建章节序号为 1..N + selected_chapters_raw = chapters_data[-10:] if len(chapters_data) > 10 else chapters_data + selected_total = len(selected_chapters_raw) + + title_counter: Counter[str] = Counter() + for idx, chapter in enumerate(selected_chapters_raw, start=1): + raw_title = (chapter.get("title") or f"第{idx}章").strip()[:200] + title = self._strip_chapter_prefix(raw_title)[:200] + content = (chapter.get("content") or "").strip() + summary = self._build_summary(content) + + chapters.append( + BookImportChapter( + title=title, + content=content, + summary=summary, + chapter_number=idx, + outline_title=title, + ) + ) + + title_counter[title] += 1 + if len(content) < 300: + warnings.append( + BookImportWarning( + code="chapter_too_short", + message=f"章节「{title}」内容较短,建议检查切分结果", + level="warning", + ) + ) + if len(content) > 12000: + warnings.append( + BookImportWarning( + code="chapter_too_long", + message=f"章节「{title}」内容较长,建议确认是否应继续拆分", + level="info", + ) + ) + + # 章节构建进度:18% -> 20%(在这个区间内按比例推进) + chapter_progress = 18 + int(2 * idx / max(1, selected_total)) + if idx % max(1, selected_total // 5) == 0 or idx == selected_total: + self._set_task_state( + task, + status="running", + progress=chapter_progress, + message=f"已处理末章 {idx}/{selected_total} 个章节结构...", + ) + + for title, count in title_counter.items(): + if count > 1: + warnings.append( + BookImportWarning( + code="duplicate_chapter_title", + message=f"检测到重复章节标题「{title}」共 {count} 次", + level="warning", + ) + ) + + if len(chapters_data) > selected_total: + warnings.append( + BookImportWarning( + code="trimmed_to_last_ten_chapters", + message=f"已按规则仅保留最后 {selected_total} 章用于导入(原始识别 {len(chapters_data)} 章)", + level="info", + ) + ) + + # AI 反向生成项目信息:进度 20% -> 95% + self._set_task_state( + task, + status="running", + progress=20, + message="正在调用AI反向生成项目信息(标题/简介/主题/类型)...", + ) + suggestion = await self._generate_reverse_project_suggestion( + user_id=task.user_id, + suggestion=suggestion, + chapters=chapters, + task=task, + ) + + outlines = await self._generate_reverse_outlines( + user_id=task.user_id, + suggestion=suggestion, + chapters=chapters, + task=task, + ) + + return BookImportPreviewResponse( + task_id=task_id, + project_suggestion=suggestion, + chapters=chapters, + outlines=outlines, + warnings=warnings, + ) + + async def _generate_reverse_project_suggestion( + self, + *, + user_id: str, + suggestion: ProjectSuggestion, + chapters: list[BookImportChapter], + task: Optional[_BookImportTask] = None, + ) -> ProjectSuggestion: + """ + 基于前3章内容反向生成项目信息: + 小说简介、主题、类型、叙事角度、目标字数(默认10W)。 + 进度区间:20% -> 95% + """ + fallback = self._build_fallback_project_suggestion( + title=suggestion.title, + chapters=chapters, + ) + + sampled_chapters = chapters[:3] + sampled_text = "\n\n".join( + f"【第{idx + 1}章 {chapter.title}】\n{(chapter.content or '')[:2000]}" + for idx, chapter in enumerate(sampled_chapters) + ).strip() + + if not sampled_text: + if task: + self._set_task_state(task, status="running", progress=95, message="文本样本不足,使用规则推断项目信息") + return fallback + + try: + if task: + self._set_task_state(task, status="running", progress=25, message="正在初始化AI服务...") + + engine = await get_engine(user_id) + session_factory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + async with session_factory() as db: + ai_service = await self._build_user_ai_service(db=db, user_id=user_id) + + if task: + self._set_task_state(task, status="running", progress=30, message="正在准备AI提示词...") + + template = await PromptService.get_template("BOOK_IMPORT_REVERSE_PROJECT_SUGGESTION", user_id, db) + prompt = PromptService.format_prompt( + template, + title=suggestion.title or "拆书导入项目", + sampled_text=sampled_text, + ) + + if task: + self._set_task_state(task, status="running", progress=35, message="AI正在分析文本内容...") + + # 启动一个模拟进度推进的协程,在AI调用期间持续更新进度 + ai_done = asyncio.Event() + + async def _progress_ticker() -> None: + """在AI生成期间,每2秒推进一次进度(35% -> 85%)""" + if not task: + return + current = 35 + messages = [ + "AI正在分析文本内容...", + "AI正在识别故事主题与类型...", + "AI正在推断叙事角度...", + "AI正在生成项目简介...", + "AI正在整理生成结果...", + ] + msg_idx = 0 + while not ai_done.is_set() and current < 85: + await asyncio.sleep(2) + if ai_done.is_set(): + break + current = min(current + 5, 85) + msg = messages[min(msg_idx, len(messages) - 1)] + msg_idx += 1 + self._set_task_state(task, status="running", progress=current, message=msg) + + ticker_task = asyncio.create_task(_progress_ticker()) + + try: + project_data = await ai_service.call_with_json_retry( + prompt=prompt, + max_retries=3, + expected_type="object", + ) + finally: + ai_done.set() + await ticker_task + + if task: + self._set_task_state(task, status="running", progress=90, message="AI生成完成,正在整理项目信息...") + + result = ProjectSuggestion( + title=suggestion.title, + description=(project_data.get("description") or fallback.description or "").strip(), + theme=(project_data.get("theme") or fallback.theme or "").strip() or fallback.theme, + genre=(project_data.get("genre") or fallback.genre or "").strip() or fallback.genre, + narrative_perspective=self._extract_narrative_perspective( + project_data, + fallback.narrative_perspective, + ), + target_words=self._normalize_target_words( + project_data.get("target_words"), + fallback.target_words, + ), + ) + + if task: + self._set_task_state(task, status="running", progress=95, message="项目信息生成完毕,准备预览...") + + return result + except Exception as exc: + logger.warning(f"反向生成项目信息失败,回退规则推断: {exc}") + if task: + self._set_task_state(task, status="running", progress=95, message="AI生成失败,使用规则推断项目信息") + return fallback + + async def _generate_reverse_outlines( + self, + *, + user_id: str, + suggestion: ProjectSuggestion, + chapters: list[BookImportChapter], + task: Optional[_BookImportTask] = None, + ) -> list[BookImportOutline]: + """ + 基于导入章节反向生成对应大纲,严格对齐现有 OUTLINE_CREATE 结构。 + 采用单批次5章分批生成,避免一次性上下文过大。 + """ + if not chapters: + return [] + + fallback_outlines = [ + BookImportOutline( + title=chapter.title, + content=(chapter.summary or self._build_summary(chapter.content or "")), + order_index=chapter.chapter_number, + structure=self._build_fallback_outline_structure(chapter), + ) + for chapter in chapters + ] + + try: + if task: + self._set_task_state(task, status="running", progress=95, message="正在反向生成章节大纲(分批5章)...") + + engine = await get_engine(user_id) + session_factory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + async with session_factory() as db: + ai_service = await self._build_user_ai_service(db=db, user_id=user_id) + template = await PromptService.get_template("BOOK_IMPORT_REVERSE_OUTLINES", user_id, db) + + batch_size = 5 + total_batches = (len(chapters) + batch_size - 1) // batch_size + all_structures: list[dict[str, Any]] = [] + + for batch_idx, start in enumerate(range(0, len(chapters), batch_size), start=1): + batch = chapters[start: start + batch_size] + if not batch: + continue + + start_chapter = batch[0].chapter_number + end_chapter = batch[-1].chapter_number + chapters_text = self._build_reverse_outline_chapters_text(batch) + expected_count = len(batch) + + if task: + progress = 95 + int(3 * (batch_idx - 1) / max(1, total_batches)) + self._set_task_state( + task, + status="running", + progress=progress, + message=f"正在生成大纲批次 {batch_idx}/{total_batches}(第{start_chapter}-{end_chapter}章)...", + ) + + prompt = PromptService.format_prompt( + template, + title=suggestion.title or "拆书导入项目", + genre=suggestion.genre or "通用", + theme=suggestion.theme or "未设定", + narrative_perspective=suggestion.narrative_perspective or "第三人称", + start_chapter=start_chapter, + end_chapter=end_chapter, + expected_count=expected_count, + chapters_text=chapters_text, + ) + + ai_data = await ai_service.call_with_json_retry( + prompt=prompt, + max_retries=3, + expected_type="array", + ) + normalized_batch = self._normalize_reverse_outline_batch(ai_data, batch) + all_structures.extend(normalized_batch) + + if len(all_structures) != len(chapters): + logger.warning( + f"反向大纲数量与章节数量不一致,回退校正: outlines={len(all_structures)}, chapters={len(chapters)}" + ) + all_structures = [ + self._build_fallback_outline_structure(chapter) + for chapter in chapters + ] + + outlines = [ + BookImportOutline( + title=chapter.title, + content=str((structure.get("summary") or structure.get("content") or "")).strip(), + order_index=chapter.chapter_number, + structure=structure, + ) + for chapter, structure in zip(chapters, all_structures) + ] + + if task: + self._set_task_state(task, status="running", progress=99, message="大纲反向生成完成,正在整理预览...") + + return outlines + except Exception as exc: + logger.warning(f"反向生成章节大纲失败,回退规则大纲: {exc}") + if task: + self._set_task_state(task, status="running", progress=99, message="AI大纲生成失败,使用规则大纲") + return fallback_outlines + + def _build_reverse_outline_chapters_text(self, chapters: list[BookImportChapter]) -> str: + parts: list[str] = [] + for chapter in chapters: + summary = (chapter.summary or "").strip() + excerpt = (chapter.content or "").strip()[:2200] + parts.append( + f"【第{chapter.chapter_number}章 {chapter.title}】\n" + f"章节摘要:{summary or '无'}\n" + f"正文节选:\n{excerpt or '无'}" + ) + return "\n\n".join(parts) + + def _normalize_reverse_outline_batch( + self, + ai_data: Any, + chapters: list[BookImportChapter], + ) -> list[dict[str, Any]]: + ai_items = ai_data if isinstance(ai_data, list) else [] + normalized: list[dict[str, Any]] = [] + + for idx, chapter in enumerate(chapters): + fallback = self._build_fallback_outline_structure(chapter) + candidate = ai_items[idx] if idx < len(ai_items) and isinstance(ai_items[idx], dict) else {} + normalized.append( + self._normalize_single_reverse_outline( + candidate, + fallback=fallback, + chapter_number=chapter.chapter_number, + chapter_title=chapter.title, + ) + ) + + return normalized + + def _normalize_single_reverse_outline( + self, + raw: dict[str, Any], + *, + fallback: dict[str, Any], + chapter_number: int, + chapter_title: str, + ) -> dict[str, Any]: + summary = str(raw.get("summary") or raw.get("content") or fallback.get("summary") or "").strip() + if not summary: + summary = str(fallback.get("summary") or "") + + scenes_raw = raw.get("scenes") if isinstance(raw.get("scenes"), list) else [] + scenes = [str(item).strip() for item in scenes_raw if str(item).strip()][:6] + if not scenes: + scenes = list(fallback.get("scenes") or []) + + characters_raw = raw.get("characters") if isinstance(raw.get("characters"), list) else [] + characters: list[dict[str, str]] = [] + for item in characters_raw: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").strip() + if not name: + continue + role_type = "organization" if str(item.get("type") or "").strip() == "organization" else "character" + characters.append({"name": name[:80], "type": role_type}) + if not characters: + characters = list(fallback.get("characters") or []) + + key_points_raw = raw.get("key_points") if isinstance(raw.get("key_points"), list) else [] + key_points = [str(item).strip() for item in key_points_raw if str(item).strip()][:8] + if not key_points: + key_points = list(fallback.get("key_points") or []) + + emotion = str(raw.get("emotion") or fallback.get("emotion") or "剧情递进").strip() or "剧情递进" + goal = str(raw.get("goal") or fallback.get("goal") or "推进主线冲突").strip() or "推进主线冲突" + + return { + "chapter_number": chapter_number, + "title": chapter_title, + "summary": summary[:2000], + "scenes": scenes, + "characters": characters, + "key_points": key_points, + "emotion": emotion[:200], + "goal": goal[:300], + } + + def _build_fallback_outline_structure(self, chapter: BookImportChapter) -> dict[str, Any]: + summary = (chapter.summary or self._build_summary(chapter.content or "")).strip() + if not summary: + summary = "本章围绕主要人物与核心冲突推进剧情。" + + return { + "chapter_number": chapter.chapter_number, + "title": chapter.title, + "summary": summary[:1200], + "scenes": [ + "主角在当前处境中做出关键选择", + "冲突升级并形成新的悬念", + ], + "characters": [], + "key_points": [ + "推进主线冲突", + "呈现角色动机与关系变化", + ], + "emotion": "紧张递进", + "goal": "承接前章并推动后续剧情发展", + } + + def _build_fallback_project_suggestion( + self, + *, + title: str, + chapters: list[BookImportChapter], + ) -> ProjectSuggestion: + sampled_chapters = chapters[:3] + sampled_text = "\n\n".join((chapter.content or "")[:2000] for chapter in sampled_chapters).strip() + fallback_description_source = "\n".join( + [chapter.summary or (chapter.content or "")[:600] for chapter in sampled_chapters] + ).strip() + fallback_description = ( + self._build_summary(fallback_description_source) + or "由拆书功能基于前3章自动提炼:该故事围绕核心人物与主要冲突展开,可在导入前继续修改。" + ) + + return ProjectSuggestion( + title=title, + description=fallback_description[:500], + theme=self._detect_theme_from_text(sampled_text), + genre=self._detect_genre_from_text(sampled_text), + narrative_perspective=self._detect_narrative_perspective(sampled_text), + target_words=100000, + ) + + def _detect_theme_from_text(self, text: str) -> str: + if any(k in text for k in ("复仇", "报仇", "雪恨")): + return "复仇与救赎" + if any(k in text for k in ("成长", "蜕变", "逆袭")): + return "成长与逆袭" + if any(k in text for k in ("真相", "谜团", "秘密", "调查")): + return "真相与抉择" + if any(k in text for k in ("权谋", "争权", "朝堂", "家族")): + return "权力与人性" + if any(k in text for k in ("爱情", "喜欢", "恋爱", "婚约")): + return "爱情与选择" + return "命运与选择" + + def _detect_genre_from_text(self, text: str) -> str: + if any(k in text for k in ("修仙", "宗门", "灵气", "飞升", "仙门")): + return "仙侠" + if any(k in text for k in ("玄幻", "异界", "魔法", "斗气")): + return "玄幻" + if any(k in text for k in ("星际", "机甲", "赛博", "人工智能", "宇宙")): + return "科幻" + if any(k in text for k in ("悬疑", "凶案", "推理", "谜案", "诡")): + return "悬疑" + if any(k in text for k in ("总裁", "职场", "都市", "豪门")): + return "都市" + if any(k in text for k in ("恋爱", "言情", "心动", "告白")): + return "言情" + return "通用" + + def _detect_narrative_perspective(self, text: str) -> str: + snippet = (text or "")[:6000] + first_person_hits = len(re.findall(r"[我咱俺]\S{0,2}", snippet)) + third_person_hits = len(re.findall(r"[他她它]\S{0,2}", snippet)) + + if first_person_hits >= 20 and first_person_hits > third_person_hits * 1.2: + return "第一人称" + return "第三人称" + + def _extract_narrative_perspective(self, project_data: Dict[str, Any], fallback: str = "第三人称") -> str: + """从AI返回中兼容提取叙事视角字段,统一映射到项目参数可接受值。""" + if not isinstance(project_data, dict): + return self._normalize_narrative_perspective(None, fallback) + + candidates = [ + project_data.get("narrative_perspective"), + project_data.get("narrativePerspective"), + project_data.get("perspective"), + project_data.get("narrative_view"), + project_data.get("narrative_angle"), + project_data.get("叙事视角"), + project_data.get("叙事角度"), + project_data.get("视角"), + ] + + for value in candidates: + normalized = self._normalize_narrative_perspective(value, "") + if normalized: + return normalized + + return self._normalize_narrative_perspective(None, fallback) + + def _normalize_narrative_perspective(self, value: Any, fallback: str = "第三人称") -> str: + raw = str(value or "").strip() + if not raw: + return fallback + + if raw in {"第一人称", "第三人称", "全知视角"}: + return raw + + raw_lower = raw.lower().replace("-", "_").replace(" ", "_") + if raw_lower in {"first_person", "firstperson", "first_person_perspective", "1st_person", "first"}: + return "第一人称" + if raw_lower in {"third_person", "thirdperson", "third_person_perspective", "3rd_person", "third"}: + return "第三人称" + if raw_lower in {"omniscient", "god_view", "godview", "all_knowing"}: + return "全知视角" + + if "第一人称" in raw or raw in {"第一视角", "主角视角", "第一人称(我)", "我视角"}: + return "第一人称" + if "第三人称" in raw or raw in {"第三视角", "第三人称(他/她)", "旁观视角"}: + return "第三人称" + if "全知" in raw or "上帝视角" in raw: + return "全知视角" + + return fallback + + def _normalize_target_words(self, value: Any, fallback: int = 100000) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + parsed = fallback + + if parsed < 1000: + return fallback + if parsed > 3000000: + return 3000000 + return parsed + + async def _build_user_ai_service(self, *, db: AsyncSession, user_id: str) -> AIService: + """读取用户AI配置并创建支持MCP的AI服务实例。""" + settings_result = await db.execute(select(Settings).where(Settings.user_id == user_id)) + user_settings = settings_result.scalar_one_or_none() + + if not user_settings: + default_provider = app_settings.default_ai_provider + if default_provider == "anthropic": + default_key = app_settings.anthropic_api_key or "" + default_base_url = app_settings.anthropic_base_url or "" + elif default_provider == "gemini": + default_key = app_settings.gemini_api_key or "" + default_base_url = app_settings.gemini_base_url or "" + else: + default_key = app_settings.openai_api_key or "" + default_base_url = app_settings.openai_base_url or "" + + user_settings = Settings( + user_id=user_id, + api_provider=default_provider, + api_key=default_key, + api_base_url=default_base_url, + llm_model=app_settings.default_model, + temperature=app_settings.default_temperature, + max_tokens=app_settings.default_max_tokens, + ) + db.add(user_settings) + await db.flush() + + mcp_result = await db.execute(select(MCPPlugin).where(MCPPlugin.user_id == user_id)) + mcp_plugins = mcp_result.scalars().all() + enable_mcp = any(plugin.enabled for plugin in mcp_plugins) if mcp_plugins else False + + if not user_settings.api_key: + raise HTTPException(status_code=400, detail="未配置AI Key,无法执行拆书反向生成") + + return create_user_ai_service_with_mcp( + api_provider=user_settings.api_provider, + api_key=user_settings.api_key, + api_base_url=user_settings.api_base_url or "", + model_name=user_settings.llm_model, + temperature=user_settings.temperature, + max_tokens=user_settings.max_tokens, + user_id=user_id, + db_session=db, + system_prompt=user_settings.system_prompt, + enable_mcp=enable_mcp, + ) + + async def _run_post_import_wizard_generation( + self, + *, + db: AsyncSession, + user_id: str, + project: Project, + character_count: int, + ) -> tuple[int, int, int]: + """ + 走“向导前3步”的核心链路: + 1) 基于项目信息生成世界观 + 2) 职业体系 + 3) 角色/组织 + 不生成大纲。 + """ + generated_world = await self._generate_world_building_from_project( + db=db, + user_id=user_id, + project=project, + ) + + generated_careers = await self._generate_career_system_from_project( + db=db, + user_id=user_id, + project=project, + ) + + generated_entities = await self._generate_characters_and_organizations_from_project( + db=db, + user_id=user_id, + project=project, + count=character_count, + ) + + # 拆书导入场景不需要继续到大纲,直接标记流程完成,避免项目列表再次跳向导生成大纲 + project.wizard_step = 3 + project.wizard_status = "completed" + project.status = "writing" + + return generated_world, generated_careers, generated_entities + + async def _generate_world_building_from_project( + self, + *, + db: AsyncSession, + user_id: str, + project: Project, + progress_callback: Any = None, + progress_range: tuple[int, int] = (0, 100), + raise_on_error: bool = False, + ) -> int: + """根据反向生成的项目基础信息,优先生成并写入世界观。""" + + async def _notify(msg: str, sub: float) -> None: + if progress_callback: + p = progress_range[0] + int((progress_range[1] - progress_range[0]) * sub) + await progress_callback(msg, p) + + try: + await _notify("🌍 正在初始化AI服务...", 0.1) + ai_service = await self._build_user_ai_service(db=db, user_id=user_id) + + await _notify("🌍 正在准备世界观提示词...", 0.2) + template = await PromptService.get_template("WORLD_BUILDING", user_id, db) + prompt = PromptService.format_prompt( + template, + title=project.title or "拆书导入项目", + genre=project.genre or "通用", + theme=project.theme or "未设定", + description=project.description or "暂无简介", + ) + + await _notify("🌍 AI正在生成世界观...", 0.3) + world_data = await ai_service.call_with_json_retry( + prompt=prompt, + max_retries=3, + expected_type="object", + ) + if not isinstance(world_data, dict): + return 0 + + await _notify("🌍 正在解析世界观数据...", 0.8) + time_period = str(world_data.get("time_period") or "").strip() + location = str(world_data.get("location") or "").strip() + atmosphere = str(world_data.get("atmosphere") or "").strip() + rules = str(world_data.get("rules") or "").strip() + + updated = 0 + if time_period: + project.world_time_period = time_period + updated = 1 + if location: + project.world_location = location + updated = 1 + if atmosphere: + project.world_atmosphere = atmosphere + updated = 1 + if rules: + project.world_rules = rules + updated = 1 + + await _notify("🌍 世界观写入完成", 1.0) + return updated + except Exception as exc: + logger.warning(f"拆书导入阶段生成世界观失败,沿用现有世界观: {exc}") + if raise_on_error: + raise + return 0 + + async def _generate_career_system_from_project( + self, + *, + db: AsyncSession, + user_id: str, + project: Project, + progress_callback: Any = None, + progress_range: tuple[int, int] = (0, 100), + ) -> int: + """根据项目世界观生成职业体系(3主2副)。""" + + async def _notify(msg: str, sub: float) -> None: + if progress_callback: + p = progress_range[0] + int((progress_range[1] - progress_range[0]) * sub) + await progress_callback(msg, p) + + await _notify("💼 正在初始化AI服务...", 0.1) + ai_service = await self._build_user_ai_service(db=db, user_id=user_id) + + await _notify("💼 正在准备职业体系提示词...", 0.2) + template = await PromptService.get_template("CAREER_SYSTEM_GENERATION", user_id, db) + prompt = PromptService.format_prompt( + template, + title=project.title, + genre=project.genre or "未设定", + theme=project.theme or "未设定", + description=project.description or "暂无简介", + time_period=project.world_time_period or "未设定", + location=project.world_location or "未设定", + atmosphere=project.world_atmosphere or "未设定", + rules=project.world_rules or "未设定", + ) + + await _notify("💼 AI正在生成职业体系...", 0.3) + career_data = await ai_service.call_with_json_retry( + prompt=prompt, + max_retries=3, + expected_type="object", + ) + + await _notify("💼 正在解析职业数据...", 0.7) + main_careers = career_data.get("main_careers", []) + sub_careers = career_data.get("sub_careers", []) + if not isinstance(main_careers, list): + main_careers = [] + if not isinstance(sub_careers, list): + sub_careers = [] + + # 清理历史职业,避免重复(拆书导入走新建项目,但这里保持幂等) + career_ids_result = await db.execute(select(Career.id).where(Career.project_id == project.id)) + career_ids = [row[0] for row in career_ids_result.fetchall()] + if career_ids: + await db.execute(delete(CharacterCareer).where(CharacterCareer.career_id.in_(career_ids))) + await db.execute(delete(Career).where(Career.project_id == project.id)) + + created = 0 + + def _to_career_model(item: dict[str, Any], career_type: str, idx: int) -> Career: + stages = item.get("stages", []) + if not isinstance(stages, list): + stages = [] + max_stage = item.get("max_stage", len(stages) if stages else (10 if career_type == "main" else 6)) + if not isinstance(max_stage, int) or max_stage <= 0: + max_stage = len(stages) if stages else (10 if career_type == "main" else 6) + + attr_bonuses = item.get("attribute_bonuses") + attr_bonuses_json = json.dumps(attr_bonuses, ensure_ascii=False) if attr_bonuses else None + + return Career( + project_id=project.id, + name=(item.get("name") or f"未命名{'主' if career_type == 'main' else '副'}职业{idx + 1}")[:100], + type=career_type, + description=item.get("description"), + category=item.get("category"), + stages=json.dumps(stages, ensure_ascii=False), + max_stage=max_stage, + requirements=item.get("requirements"), + special_abilities=item.get("special_abilities"), + worldview_rules=item.get("worldview_rules"), + attribute_bonuses=attr_bonuses_json, + source="ai", + ) + + for idx, item in enumerate(main_careers): + if not isinstance(item, dict): + continue + db.add(_to_career_model(item, "main", idx)) + created += 1 + + for idx, item in enumerate(sub_careers): + if not isinstance(item, dict): + continue + db.add(_to_career_model(item, "sub", idx)) + created += 1 + + await db.flush() + return created + + async def _generate_characters_and_organizations_from_project( + self, + *, + db: AsyncSession, + user_id: str, + project: Project, + count: int, + progress_callback: Any = None, + progress_range: tuple[int, int] = (0, 100), + ) -> int: + """根据世界观+职业体系生成角色/组织,并补全职业和组织成员关系。""" + + async def _notify(msg: str, sub: float) -> None: + if progress_callback: + p = progress_range[0] + int((progress_range[1] - progress_range[0]) * sub) + await progress_callback(msg, p) + + def _to_int(value: Any, default: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + await _notify("👥 正在初始化AI服务...", 0.05) + ai_service = await self._build_user_ai_service(db=db, user_id=user_id) + + # 控制数量区间,避免过多生成 + target_count = max(5, min(count, 20)) + + # 职业上下文:用于提示词约束与后续名称映射 + careers_result = await db.execute(select(Career).where(Career.project_id == project.id)) + careers = careers_result.scalars().all() + main_careers = [c for c in careers if c.type == "main"] + sub_careers = [c for c in careers if c.type == "sub"] + main_career_map = {c.name: c for c in main_careers} + sub_career_map = {c.name: c for c in sub_careers} + + await _notify("👥 正在准备角色生成提示词...", 0.15) + template = await PromptService.get_template("CHARACTERS_BATCH_GENERATION", user_id, db) + requirements = ( + "请生成能够支撑前期剧情推进的关键角色与组织," + "角色和组织都要与世界观、职业体系一致。" + "如果包含组织,数量不超过2个。" + "请尽量为非组织角色补充 organization_memberships。" + ) + + if main_careers or sub_careers: + careers_context = "\n\n【职业分配要求】\n" + careers_context += "请为每个非组织角色返回 career_assignment 字段:" + careers_context += '{"main_career":"主职业名称","main_stage":2,"sub_careers":[{"career":"副职业名称","stage":1}]}' + careers_context += "\n职业名称必须从以下列表中选择:\n" + if main_careers: + careers_context += "- 可用主职业:" + "、".join([c.name for c in main_careers]) + "\n" + if sub_careers: + careers_context += "- 可用副职业:" + "、".join([c.name for c in sub_careers]) + "\n" + requirements += careers_context + + prompt = PromptService.format_prompt( + template, + count=target_count, + time_period=project.world_time_period or "未设定", + location=project.world_location or "未设定", + atmosphere=project.world_atmosphere or "未设定", + rules=project.world_rules or "未设定", + theme=project.theme or "未设定", + genre=project.genre or "未设定", + requirements=requirements, + ) + + await _notify("👥 AI正在生成角色与组织...", 0.25) + generated_data = await ai_service.call_with_json_retry( + prompt=prompt, + max_retries=3, + expected_type="array", + ) + await _notify("👥 正在解析角色数据...", 0.7) + if isinstance(generated_data, dict): + generated_entities = [generated_data] + elif isinstance(generated_data, list): + generated_entities = generated_data + else: + generated_entities = [] + + # 预加载角色/组织,便于去重和兼容 append 场景的名称引用 + existing_chars_result = await db.execute(select(Character).where(Character.project_id == project.id)) + existing_chars = existing_chars_result.scalars().all() + existing_names = {c.name for c in existing_chars} + character_name_to_obj: dict[str, Character] = {c.name: c for c in existing_chars} + + existing_orgs_result = await db.execute( + select(Organization, Character.name) + .join(Character, Organization.character_id == Character.id) + .where(Organization.project_id == project.id) + ) + organization_name_to_obj: dict[str, Organization] = { + row[1]: row[0] for row in existing_orgs_result.all() if row[1] + } + + existing_member_result = await db.execute( + select(OrganizationMember.organization_id, OrganizationMember.character_id) + .join(Organization, OrganizationMember.organization_id == Organization.id) + .where(Organization.project_id == project.id) + ) + member_pairs = {(row[0], row[1]) for row in existing_member_result.all()} + + existing_rel_result = await db.execute( + select(CharacterRelationship.character_from_id, CharacterRelationship.character_to_id) + .where(CharacterRelationship.project_id == project.id) + ) + relationship_pairs = {(row[0], row[1]) for row in existing_rel_result.all()} + + rel_type_result = await db.execute(select(RelationshipType)) + relationship_type_map: dict[str, int] = { + rel_type.name: rel_type.id + for rel_type in rel_type_result.scalars().all() + if rel_type.name + } + + created = 0 + created_items: list[tuple[Character, dict[str, Any]]] = [] + + # 第一阶段:创建 Character / Organization 实体 + for item in generated_entities: + if not isinstance(item, dict): + continue + + raw_name = (item.get("name") or "").strip() + if not raw_name or raw_name in existing_names: + continue + + is_organization = bool(item.get("is_organization", False)) + character = Character( + project_id=project.id, + name=raw_name[:100], + age=(str(item.get("age")) if item.get("age") is not None else None) if not is_organization else None, + gender=item.get("gender") if not is_organization else None, + is_organization=is_organization, + role_type=(item.get("role_type") or "supporting")[:50], + personality=item.get("personality"), + background=item.get("background"), + appearance=item.get("appearance"), + organization_type=item.get("organization_type") if is_organization else None, + organization_purpose=item.get("organization_purpose") if is_organization else None, + organization_members=( + json.dumps(item.get("organization_members"), ensure_ascii=False) + if item.get("organization_members") is not None else None + ), + traits=json.dumps(item.get("traits", []), ensure_ascii=False) if item.get("traits") else None, + ) + db.add(character) + await db.flush() + + if is_organization: + organization = Organization( + character_id=character.id, + project_id=project.id, + power_level=max(0, min(_to_int(item.get("power_level", 50), 50), 100)), + member_count=0, + location=item.get("location"), + motto=item.get("motto"), + color=item.get("color"), + ) + db.add(organization) + await db.flush() + organization_name_to_obj[character.name] = organization + + created_items.append((character, item)) + character_name_to_obj[character.name] = character + existing_names.add(raw_name) + created += 1 + + # 第二阶段:创建职业关联(CharacterCareer + 冗余字段) + if created_items and (main_career_map or sub_career_map): + career_pairs: set[tuple[str, str]] = set() + + for character, item in created_items: + if character.is_organization: + continue + + # 兼容两种字段:career_assignment(批量) / career_info(单角色) + assignment = item.get("career_assignment") + if not isinstance(assignment, dict): + career_info = item.get("career_info") + if isinstance(career_info, dict): + assignment = { + "main_career": career_info.get("main_career_name"), + "main_stage": career_info.get("main_career_stage", 1), + "sub_careers": [ + { + "career": sub.get("career_name"), + "stage": sub.get("stage", 1), + } + for sub in (career_info.get("sub_careers") or []) + if isinstance(sub, dict) + ], + } + + if not isinstance(assignment, dict): + continue + + # 主职业 + main_name = (assignment.get("main_career") or "").strip() + if main_name and main_name in main_career_map: + main_career = main_career_map[main_name] + main_stage = max(1, min(_to_int(assignment.get("main_stage", 1), 1), max(main_career.max_stage or 1, 1))) + main_key = (character.id, main_career.id) + if main_key not in career_pairs: + db.add( + CharacterCareer( + character_id=character.id, + career_id=main_career.id, + career_type="main", + current_stage=main_stage, + stage_progress=0, + ) + ) + career_pairs.add(main_key) + + character.main_career_id = main_career.id + character.main_career_stage = main_stage + + # 副职业 + sub_list = assignment.get("sub_careers") or [] + if not isinstance(sub_list, list): + sub_list = [] + + sub_career_json: list[dict[str, Any]] = [] + for sub in sub_list[:2]: + if not isinstance(sub, dict): + continue + sub_name = (sub.get("career") or "").strip() + if not sub_name or sub_name not in sub_career_map: + continue + + sub_career = sub_career_map[sub_name] + sub_stage = max(1, min(_to_int(sub.get("stage", 1), 1), max(sub_career.max_stage or 1, 1))) + sub_key = (character.id, sub_career.id) + if sub_key in career_pairs: + continue + + db.add( + CharacterCareer( + character_id=character.id, + career_id=sub_career.id, + career_type="sub", + current_stage=sub_stage, + stage_progress=0, + ) + ) + career_pairs.add(sub_key) + sub_career_json.append({"career_id": sub_career.id, "stage": sub_stage}) + + if sub_career_json: + character.sub_careers = json.dumps(sub_career_json, ensure_ascii=False) + + # 第三阶段:创建角色关系(relationships_array / relationships) + for character, item in created_items: + if character.is_organization: + continue + + relationships_data = item.get("relationships_array") + if not isinstance(relationships_data, list): + legacy_relationships = item.get("relationships") + relationships_data = legacy_relationships if isinstance(legacy_relationships, list) else [] + + for rel in relationships_data: + if not isinstance(rel, dict): + continue + + target_name = (rel.get("target_character_name") or "").strip() + if not target_name: + continue + + target_char = character_name_to_obj.get(target_name) + if not target_char or target_char.is_organization: + continue + if target_char.id == character.id: + continue + + pair = (character.id, target_char.id) + if pair in relationship_pairs: + continue + + relationship_name = (rel.get("relationship_type") or "未知关系").strip()[:100] + intimacy_level = max(-100, min(_to_int(rel.get("intimacy_level", 50), 50), 100)) + status = (rel.get("status") or "active")[:20] + description = rel.get("description") + if description is not None: + description = str(description) + + db.add( + CharacterRelationship( + project_id=project.id, + character_from_id=character.id, + character_to_id=target_char.id, + relationship_type_id=relationship_type_map.get(relationship_name), + relationship_name=relationship_name, + intimacy_level=intimacy_level, + status=status, + description=description, + source="ai", + ) + ) + relationship_pairs.add(pair) + + # 第四阶段:创建组织成员关系(优先使用角色上的 organization_memberships) + for character, item in created_items: + if character.is_organization: + continue + + org_memberships = item.get("organization_memberships") + if not isinstance(org_memberships, list): + continue + + for membership in org_memberships: + if not isinstance(membership, dict): + continue + + org_name = (membership.get("organization_name") or "").strip() + if not org_name: + continue + + org = organization_name_to_obj.get(org_name) + if not org: + continue + + pair = (org.id, character.id) + if pair in member_pairs: + continue + + db.add( + OrganizationMember( + organization_id=org.id, + character_id=character.id, + position=(membership.get("position") or "成员")[:100], + rank=max(0, min(_to_int(membership.get("rank", 0), 0), 10)), + loyalty=max(0, min(_to_int(membership.get("loyalty", 50), 50), 100)), + joined_at=membership.get("joined_at"), + status=(membership.get("status") or "active")[:20], + source="ai", + ) + ) + member_pairs.add(pair) + org.member_count = (org.member_count or 0) + 1 + + # 第五阶段:回填组织对象里的 organization_members(按名称补充成员) + for character, item in created_items: + if not character.is_organization: + continue + + org = organization_name_to_obj.get(character.name) + if not org: + continue + + member_names_raw = item.get("organization_members") + member_names: list[str] = [] + if isinstance(member_names_raw, list): + member_names = [str(name).strip() for name in member_names_raw if str(name).strip()] + elif isinstance(member_names_raw, str) and member_names_raw.strip(): + member_names = [member_names_raw.strip()] + + for member_name in member_names: + member_char = character_name_to_obj.get(member_name) + if not member_char or member_char.is_organization: + continue + + pair = (org.id, member_char.id) + if pair in member_pairs: + continue + + db.add( + OrganizationMember( + organization_id=org.id, + character_id=member_char.id, + position="成员", + rank=0, + loyalty=50, + status="active", + source="ai", + ) + ) + member_pairs.add(pair) + org.member_count = (org.member_count or 0) + 1 + + await db.flush() + return created + + def _build_summary(self, content: str, max_len: int = 120) -> Optional[str]: + if not content: + return None + normalized = re.sub(r"\s+", " ", content).strip() + if len(normalized) <= max_len: + return normalized + return normalized[:max_len] + "…" + + async def _get_task(self, *, task_id: str, user_id: str) -> _BookImportTask: + async with self._tasks_lock: + task = self._tasks.get(task_id) + + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + if task.user_id != user_id: + raise HTTPException(status_code=403, detail="无权访问该任务") + return task + + def _to_status(self, task: _BookImportTask) -> BookImportTaskStatusResponse: + return BookImportTaskStatusResponse( + task_id=task.task_id, + status=task.status, # type: ignore[arg-type] + progress=task.progress, + message=task.message, + error=task.error, + created_at=task.created_at, + updated_at=task.updated_at, + ) + + def _set_task_state( + self, + task: _BookImportTask, + *, + status: str, + progress: int, + message: Optional[str], + error: Optional[str] = None, + ) -> None: + task.status = status + task.progress = max(0, min(100, progress)) + task.message = message + task.error = error + task.updated_at = datetime.utcnow() + + def _check_cancelled(self, task: _BookImportTask) -> None: + if task.cancelled or task.status == "cancelled": + raise asyncio.CancelledError("任务已取消") + + +book_import_service = BookImportService() \ No newline at end of file diff --git a/backend/app/services/prompt_service.py b/backend/app/services/prompt_service.py index af89ff6..ecd2c36 100644 --- a/backend/app/services/prompt_service.py +++ b/backend/app/services/prompt_service.py @@ -2416,6 +2416,136 @@ class PromptService: ❌ 添加任何元信息或说明 ❌ 改变叙事人称或视角 ❌ 偏离用户的修改要求 +""" + + # 拆书导入-反向项目提炼提示词 + BOOK_IMPORT_REVERSE_PROJECT_SUGGESTION = """ +你是资深网文策划编辑,擅长从小说正文中反向提炼项目立项信息。 + + + +【任务】 +基于提供的前3章内容,提炼该小说的核心立项信息,用于创建新项目。 + +【目标】 +在不偏离原文的前提下,输出可直接用于项目初始化的结构化信息。 + + + +【输入信息】 +书名:{title} +前3章内容: +{sampled_text} + + + +【输出格式】 +仅输出一个纯JSON对象(不要markdown、不要代码块、不要解释): + +{{ + "description": "小说简介", + "theme": "核心主题", + "genre": "小说类型", + "narrative_perspective": "第一人称/第三人称/全知视角", + "target_words": 100000 +}} + +【字段要求】 +1) description:120-260字,聚焦主角、核心冲突、主线目标与故事张力。 +2) theme:120-260字,提炼作品想表达的核心命题。 +3) genre:2-12字,如都市、玄幻、悬疑、科幻、言情等。 +4) narrative_perspective:只能是“第一人称”或“第三人称”或“全知视角”。 +5) target_words:整数。按网文体量合理预估;无法判断时返回100000。 + + + +【必须遵守】 +✅ 严格基于已给正文内容,不凭空添加关键设定 +✅ 保持信息自洽,避免互相矛盾 +✅ 输出必须是可解析JSON对象 +✅ 小说的genre可以由多个类型组成 + +【禁止事项】 +❌ 输出JSON以外的任何文字 +❌ 使用markdown标记或代码块包裹 +❌ narrative_perspective输出枚举值之外的内容 +❌ target_words输出非整数 +""" + + # 拆书导入-反向生成章节大纲(严格对齐 OUTLINE_CREATE 结构) + BOOK_IMPORT_REVERSE_OUTLINES = """ +你是资深网文总编与剧情策划,擅长基于已完成章节反向提炼标准化章节大纲。 + + + +【任务】 +基于给定的章节正文(每批最多5章),为每章反向生成对应大纲结构。 + +【核心目标】 +输出结构必须与系统现有大纲生成结构严格一致(与 OUTLINE_CREATE 字段一致),用于直接入库。 + + + +【项目信息】 +书名:{title} +类型:{genre} +主题:{theme} +叙事视角:{narrative_perspective} + + + +【批次范围】 +第{start_chapter}章 - 第{end_chapter}章(共{expected_count}章) + +【章节内容】 +{chapters_text} + + + +【输出格式】 +仅输出纯JSON数组(不要markdown、不要代码块、不要解释)。 +数组长度必须严格等于 {expected_count}。 + +每个对象字段必须严格为: +[ + {{ + "chapter_number": 1, + "title": "章节标题", + "summary": "章节概要(200-600字):主要情节、角色互动、关键事件、冲突与转折", + "scenes": ["场景1描述", "场景2描述"], + "characters": [ + {{"name": "角色名1", "type": "character"}}, + {{"name": "组织/势力名1", "type": "organization"}} + ], + "key_points": ["情节要点1", "情节要点2"], + "emotion": "本章情感基调", + "goal": "本章叙事目标" + }} +] + +【字段约束】 +- chapter_number:必须与输入章节号一致 +- title:必须与输入章节标题一致 +- summary:根据本章正文反向提炼,不得臆造未出现关键事件 +- scenes:2-6条 +- characters:可为空;type 仅允许 character 或 organization +- key_points:2-6条 +- emotion:一句话 +- goal:一句话 + + + +【必须遵守】 +✅ 严格一章对应一个对象,数量与顺序完全一致 +✅ 字段名、字段层级、字段类型严格一致 +✅ 仅基于输入正文提炼,不擅自扩展设定 +✅ 输出必须可被JSON直接解析 + +【禁止事项】 +❌ 输出JSON之外任何文本 +❌ 缺失字段或新增字段 +❌ chapter_number/title 与输入不一致 +❌ 使用 markdown 或代码块 """ @staticmethod @@ -2689,6 +2819,21 @@ class PromptService: "description": "用于生成小说世界观设定,包括时间背景、地理位置、氛围基调和世界规则", "parameters": ["title", "theme", "genre", "description"] }, + "BOOK_IMPORT_REVERSE_PROJECT_SUGGESTION": { + "name": "拆书导入-反向项目提炼", + "category": "拆书导入", + "description": "基于前3章内容反向提炼简介、主题、类型、叙事视角与目标字数", + "parameters": ["title", "sampled_text"] + }, + "BOOK_IMPORT_REVERSE_OUTLINES": { + "name": "拆书导入-反向章节大纲", + "category": "拆书导入", + "description": "基于章节正文反向生成与OUTLINE_CREATE一致结构的大纲(单批次5章)", + "parameters": [ + "title", "genre", "theme", "narrative_perspective", + "start_chapter", "end_chapter", "expected_count", "chapters_text" + ] + }, "CHARACTERS_BATCH_GENERATION": { "name": "批量角色生成", "category": "角色生成", diff --git a/backend/app/services/txt_parser_service.py b/backend/app/services/txt_parser_service.py new file mode 100644 index 0000000..8431f31 --- /dev/null +++ b/backend/app/services/txt_parser_service.py @@ -0,0 +1,171 @@ +"""TXT 解析服务:编码识别、文本清洗与章节切分""" +from __future__ import annotations + +import re +from typing import Optional + +from app.logger import get_logger + +logger = get_logger(__name__) + + +class TxtParserService: + """TXT 解析服务(规则优先)""" + + STRONG_CHAPTER_PATTERNS = [ + re.compile(r"^第[一二三四五六七八九十百千万零〇两\d]+[章节回卷集部篇].*$"), + re.compile(r"^chapter\s*\d+.*$", re.IGNORECASE), + re.compile(r"^chap\.\s*\d+.*$", re.IGNORECASE), + ] + + def decode_bytes(self, content: bytes) -> tuple[str, str]: + """ + 尝试解码 TXT 字节流 + + Returns: + (text, encoding) + """ + encodings = ["utf-8", "utf-8-sig", "gb18030", "gbk", "big5"] + for enc in encodings: + try: + return content.decode(enc), enc + except UnicodeDecodeError: + continue + + # 最后兜底:不抛错,尽量读出内容 + logger.warning("TXT 编码自动识别失败,使用 utf-8(ignore) 兜底") + return content.decode("utf-8", errors="ignore"), "utf-8(ignore)" + + def clean_text(self, text: str) -> str: + """基础清洗:换行归一、去除异常空白、压缩多余空行""" + normalized = text.replace("\r\n", "\n").replace("\r", "\n").replace("\ufeff", "") + normalized = normalized.replace("\u3000", " ") + normalized = re.sub(r"[ \t]+\n", "\n", normalized) + normalized = re.sub(r"\n{4,}", "\n\n\n", normalized) + return normalized.strip() + + def split_chapters(self, text: str) -> list[dict]: + """ + 章节切分(规则优先,失败兜底) + + Returns: + [{title, content, chapter_number}] + """ + if not text.strip(): + return [] + + lines = text.split("\n") + heading_indexes: list[int] = [] + + for idx, line in enumerate(lines): + stripped = line.strip() + if not stripped: + continue + if self._is_strong_heading(stripped) or self._is_weak_heading(lines, idx): + heading_indexes.append(idx) + + # 去重并排序 + heading_indexes = sorted(set(heading_indexes)) + + # 如果一个标题都识别不到,走固定窗口兜底 + if not heading_indexes: + return self._fallback_split(text) + + # 如果第一个标题前有较长正文,作为前言章节保留 + chapters: list[dict] = [] + chapter_no = 1 + + first_heading = heading_indexes[0] + if first_heading > 0: + preface = "\n".join(lines[:first_heading]).strip() + if len(preface) >= 200: + chapters.append( + { + "title": "前言", + "content": preface, + "chapter_number": chapter_no, + } + ) + chapter_no += 1 + + for i, start_idx in enumerate(heading_indexes): + end_idx = heading_indexes[i + 1] if i + 1 < len(heading_indexes) else len(lines) + title = lines[start_idx].strip()[:200] or f"第{chapter_no}章" + body = "\n".join(lines[start_idx + 1 : end_idx]).strip() + # 防止空标题/空正文完全丢失 + if not body and i + 1 < len(heading_indexes): + next_line = lines[start_idx + 1].strip() if start_idx + 1 < len(lines) else "" + body = next_line + + chapters.append( + { + "title": title, + "content": body, + "chapter_number": chapter_no, + } + ) + chapter_no += 1 + + # 过滤掉明显噪音章节 + filtered = [c for c in chapters if c["title"] or c["content"]] + if filtered: + return filtered + + return self._fallback_split(text) + + def _is_strong_heading(self, line: str) -> bool: + return any(pattern.match(line) for pattern in self.STRONG_CHAPTER_PATTERNS) + + def _is_weak_heading(self, lines: list[str], idx: int) -> bool: + """ + 弱模式:短行 + 前后空行 + 避免普通句子误判 + """ + line = lines[idx].strip() + if not line: + return False + if len(line) > 25: + return False + if re.search(r"[,。!?;:,.!?;:]", line): + return False + + prev_blank = idx == 0 or not lines[idx - 1].strip() + next_blank = idx == len(lines) - 1 or not lines[idx + 1].strip() + return prev_blank and next_blank + + def _fallback_split(self, text: str, min_window: int = 3000, max_window: int = 5000) -> list[dict]: + """ + 固定窗口 + 标点边界切分 + """ + chapters: list[dict] = [] + n = len(text) + start = 0 + chapter_no = 1 + boundary_punctuations = "。!?!?\n" + + while start < n: + ideal_end = min(start + max_window, n) + if ideal_end >= n: + end = n + else: + search_from = min(start + min_window, n) + segment = text[search_from:ideal_end] + offset = max(segment.rfind(p) for p in boundary_punctuations) + end = search_from + offset + 1 if offset >= 0 else ideal_end + + chunk = text[start:end].strip() + if chunk: + chapters.append( + { + "title": f"第{chapter_no}章", + "content": chunk, + "chapter_number": chapter_no, + } + ) + chapter_no += 1 + + start = end + + return chapters + + +txt_parser_service = TxtParserService() \ No newline at end of file diff --git a/frontend/src/pages/BookImport.tsx b/frontend/src/pages/BookImport.tsx new file mode 100644 index 0000000..18a3a3b --- /dev/null +++ b/frontend/src/pages/BookImport.tsx @@ -0,0 +1,918 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Alert, + Button, + Card, + Col, + Collapse, + Empty, + Input, + InputNumber, + List, + message, + Progress, + Row, + Select, + Space, + Spin, + Steps, + Tag, + Typography, + Upload, +} from 'antd'; +import type { UploadFile } from 'antd/es/upload/interface'; +import { InboxOutlined, PlayCircleOutlined, ReloadOutlined, StopOutlined, WarningOutlined, RedoOutlined } from '@ant-design/icons'; +import { bookImportApi } from '../services/api'; +import type { + BookImportApplyPayload, + BookImportPreview, + BookImportStepFailure, + BookImportTask, +} from '../types'; + +const { Text } = Typography; +const { Dragger } = Upload; +const { TextArea } = Input; + +const BOOK_IMPORT_CACHE_KEY = 'book_import_page_cache_v1'; + +type BookImportPageCache = { + taskId: string | null; + taskStatus: BookImportTask | null; + preview: BookImportPreview | null; + applyProgress: number; + applyMessage: string; + applyError: string | null; + isApplyComplete: boolean; + cachedAt: number; +}; + +function loadBookImportCache(): BookImportPageCache | null { + try { + const raw = sessionStorage.getItem(BOOK_IMPORT_CACHE_KEY); + if (!raw) return null; + return JSON.parse(raw) as BookImportPageCache; + } catch (error) { + console.warn('读取拆书页面缓存失败:', error); + return null; + } +} + +function saveBookImportCache(cache: BookImportPageCache) { + try { + sessionStorage.setItem(BOOK_IMPORT_CACHE_KEY, JSON.stringify(cache)); + } catch (error) { + const isQuotaExceeded = + error instanceof DOMException && + (error.name === 'QuotaExceededError' || error.name === 'NS_ERROR_DOM_QUOTA_REACHED'); + + if (isQuotaExceeded) { + // 发生容量溢出时降级为轻量缓存(不保存预览正文),避免持续报错 + try { + const lightweightCache: BookImportPageCache = { + ...cache, + preview: null, + }; + sessionStorage.setItem(BOOK_IMPORT_CACHE_KEY, JSON.stringify(lightweightCache)); + return; + } catch (fallbackError) { + console.warn('写入轻量拆书页面缓存失败:', fallbackError); + try { + sessionStorage.removeItem(BOOK_IMPORT_CACHE_KEY); + } catch { + // ignore + } + } + } + + console.warn('写入拆书页面缓存失败:', error); + } +} + +function clearBookImportCache() { + try { + sessionStorage.removeItem(BOOK_IMPORT_CACHE_KEY); + } catch (error) { + console.warn('清理拆书页面缓存失败:', error); + } +} + +function isNotFoundError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const maybeError = error as { response?: { status?: number } }; + return maybeError.response?.status === 404; +} + +export default function BookImport() { + const navigate = useNavigate(); + const [file, setFile] = useState(null); + + const [taskId, setTaskId] = useState(null); + const [taskStatus, setTaskStatus] = useState(null); + const [preview, setPreview] = useState(null); + + const [creatingTask, setCreatingTask] = useState(false); + const [loadingPreview, setLoadingPreview] = useState(false); + const [applying, setApplying] = useState(false); + const [applyProgress, setApplyProgress] = useState(0); + const [applyMessage, setApplyMessage] = useState(''); + const [applyError, setApplyError] = useState(null); + const [isApplyComplete, setIsApplyComplete] = useState(false); + const [cacheReady, setCacheReady] = useState(false); + + // 步骤级失败和重试相关状态 + const [failedSteps, setFailedSteps] = useState([]); + const [retrying, setRetrying] = useState(false); + const [retryProgress, setRetryProgress] = useState(0); + const [retryMessage, setRetryMessage] = useState(''); + const importedProjectId = useRef(null); + + const isTaskTerminal = useMemo(() => { + return !!taskStatus && ['completed', 'failed', 'cancelled'].includes(taskStatus.status); + }, [taskStatus]); + + const currentStep = useMemo(() => { + if (!taskId) return 0; + if (taskStatus && ['pending', 'running'].includes(taskStatus.status)) return 1; + if (applying || isApplyComplete) return 3; // 新增生成导入步骤 + if (preview) return 2; + return 1; + }, [taskId, taskStatus, preview, applying, isApplyComplete]); + + useEffect(() => { + const cache = loadBookImportCache(); + if (cache) { + const cacheAgeMs = typeof cache.cachedAt === 'number' + ? Date.now() - cache.cachedAt + : Number.POSITIVE_INFINITY; + + // 超过6小时的缓存直接视为失效,避免后端重启后继续使用旧taskId + if (cacheAgeMs > 6 * 60 * 60 * 1000) { + clearBookImportCache(); + } else { + setTaskId(cache.taskId); + setTaskStatus(cache.taskStatus); + setPreview(cache.preview); + setApplyProgress(cache.applyProgress); + setApplyError(cache.applyError); + setIsApplyComplete(cache.isApplyComplete); + setApplyMessage( + cache.applyMessage || (cache.applyProgress > 0 && !cache.isApplyComplete + ? '已恢复页面缓存,请重新点击“确认导入”继续。' + : '') + ); + message.info('已恢复拆书导入页面缓存'); + } + } + setCacheReady(true); + }, []); + + useEffect(() => { + if (!cacheReady) return; + + // 导入完成后必须清理缓存,避免后续回到页面时恢复到旧任务状态 + if (isApplyComplete) { + clearBookImportCache(); + return; + } + + const hasCacheData = Boolean( + taskId || + taskStatus || + preview || + applyError || + applyProgress > 0 || + applyMessage + ); + + if (!hasCacheData) { + clearBookImportCache(); + return; + } + + saveBookImportCache({ + taskId, + taskStatus, + // preview 含完整章节正文,体积大,容易触发 sessionStorage 配额限制 + // 页面恢复时可根据 taskId + taskStatus 重新拉取 preview + preview: null, + applyProgress, + applyMessage, + applyError, + isApplyComplete, + cachedAt: Date.now(), + }); + }, [ + cacheReady, + taskId, + taskStatus, + preview, + applyProgress, + applyMessage, + applyError, + isApplyComplete, + ]); + + useEffect(() => { + if (!taskId) return; + if (isTaskTerminal) return; + + const timer = setInterval(async () => { + try { + const status = await bookImportApi.getTaskStatus(taskId); + setTaskStatus(status); + } catch (error) { + console.error('轮询任务状态失败:', error); + if (isNotFoundError(error)) { + clearBookImportCache(); + setTaskId(null); + setTaskStatus(null); + setPreview(null); + setApplyProgress(0); + setApplyMessage(''); + setApplyError(null); + setIsApplyComplete(false); + message.warning('拆书任务已失效(可能因服务重启),请重新上传TXT并开始解析'); + } + } + }, 1500); + + return () => clearInterval(timer); + }, [taskId, isTaskTerminal]); + + useEffect(() => { + const fetchPreview = async () => { + if (!taskId || !taskStatus) return; + if (taskStatus.status !== 'completed' || preview) return; + + try { + setLoadingPreview(true); + const data = await bookImportApi.getPreview(taskId); + setPreview(data); + } catch (error) { + console.error('获取预览失败:', error); + if (isNotFoundError(error)) { + clearBookImportCache(); + setTaskId(null); + setTaskStatus(null); + setPreview(null); + setApplyProgress(0); + setApplyMessage(''); + setApplyError(null); + setIsApplyComplete(false); + message.warning('拆书任务预览不存在(可能因服务重启),已清空缓存,请重新上传TXT'); + } else { + message.error('获取预览失败'); + } + } finally { + setLoadingPreview(false); + } + }; + + fetchPreview(); + }, [taskId, taskStatus, preview]); + + const startTask = async () => { + if (!file) { + message.warning('请先选择 TXT 文件'); + return; + } + + try { + setCreatingTask(true); + setPreview(null); + setTaskStatus(null); + + const response = await bookImportApi.createTask({ + file, + }); + + setTaskId(response.task_id); + message.success('拆书任务已创建'); + } catch (error) { + console.error('创建任务失败:', error); + message.error('创建拆书任务失败'); + } finally { + setCreatingTask(false); + } + }; + + const refreshStatus = async () => { + if (!taskId) return; + try { + const status = await bookImportApi.getTaskStatus(taskId); + setTaskStatus(status); + } catch (error) { + console.error('刷新状态失败:', error); + if (isNotFoundError(error)) { + clearBookImportCache(); + setTaskId(null); + setTaskStatus(null); + setPreview(null); + setApplyProgress(0); + setApplyMessage(''); + setApplyError(null); + setIsApplyComplete(false); + message.warning('任务不存在,已清空本地缓存,请重新创建拆书任务'); + } + } + }; + + const cancelTask = async () => { + if (!taskId) return; + try { + await bookImportApi.cancelTask(taskId); + message.success('任务已取消'); + await refreshStatus(); + } catch (error) { + console.error('取消任务失败:', error); + message.error('取消任务失败'); + } + }; + + const applyImport = async () => { + if (!taskId || !preview) return; + + const payload: BookImportApplyPayload = { + project_suggestion: preview.project_suggestion, + chapters: preview.chapters, + outlines: preview.outlines, + import_mode: 'append', + }; + + try { + setApplying(true); + setApplyProgress(0); + setApplyMessage('准备导入...'); + setApplyError(null); + setIsApplyComplete(false); + setFailedSteps([]); + + await bookImportApi.applyImportStream( + taskId, + payload, + { + onProgress: (msg, prog, status) => { + // 检查是否是步骤失败的特殊消息 + if (status === 'step_failures') { + try { + const parsed = JSON.parse(msg); + if (parsed.failed_steps && Array.isArray(parsed.failed_steps)) { + setFailedSteps(parsed.failed_steps as BookImportStepFailure[]); + } + } catch { + // 不是JSON,忽略 + } + return; + } + setApplyProgress(prog); + setApplyMessage(msg); + }, + onResult: (result) => { + importedProjectId.current = result.project_id; + const generatedCareers = result.statistics?.generated_careers ?? 0; + const generatedEntities = result.statistics?.generated_entities ?? 0; + + // 检查最终是否有失败步骤 + setIsApplyComplete(true); + + // 如果没有失败步骤才自动跳转 + // 注意:这里需要延迟一帧来等待 failedSteps 的更新 + setTimeout(() => { + setFailedSteps(prev => { + if (prev.length === 0) { + message.success(`导入成功:已生成职业${generatedCareers}个,角色/组织${generatedEntities}个`); + clearBookImportCache(); + setTimeout(() => { + navigate(`/project/${result.project_id}/chapters`); + }, 1000); + } else { + message.warning(`导入完成,但有 ${prev.length} 个生成步骤失败,可点击重试`); + } + return prev; + }); + }, 100); + }, + onError: (error) => { + console.error('导入过程发生错误:', error); + setApplyError(`导入失败: ${error}`); + message.error(`导入失败: ${error}`); + setApplying(false); + }, + onComplete: () => { + setApplyProgress(100); + setApplyMessage('导入完成!'); + } + } + ); + } catch (error) { + console.error('确认导入失败:', error); + setApplyError('确认导入失败,无法连接到服务器'); + message.error('确认导入失败'); + setApplying(false); + } + }; + + const retryFailedSteps = useCallback(async () => { + if (!taskId || failedSteps.length === 0) return; + + const stepsToRetry = failedSteps.map(f => f.step_name); + + try { + setRetrying(true); + setRetryProgress(0); + setRetryMessage('正在重试失败的生成步骤...'); + + await bookImportApi.retryFailedStepsStream( + taskId, + stepsToRetry, + { + onProgress: (msg, prog, status) => { + if (status === 'step_failures') { + try { + const parsed = JSON.parse(msg); + if (parsed.failed_steps && Array.isArray(parsed.failed_steps)) { + setFailedSteps(parsed.failed_steps as BookImportStepFailure[]); + } + } catch { + // 不是JSON,忽略 + } + return; + } + setRetryProgress(prog); + setRetryMessage(msg); + }, + onResult: (result) => { + if (result.still_failed && result.still_failed.length > 0) { + setFailedSteps(result.still_failed); + message.warning(`重试完成,仍有 ${result.still_failed.length} 个步骤失败`); + } else { + setFailedSteps([]); + message.success('所有步骤重试成功!'); + clearBookImportCache(); + const projectId = result.project_id || importedProjectId.current; + if (projectId) { + setTimeout(() => { + navigate(`/project/${projectId}/chapters`); + }, 1000); + } + } + }, + onError: (error) => { + console.error('重试失败:', error); + message.error(`重试失败: ${error}`); + }, + onComplete: () => { + setRetrying(false); + setRetryProgress(100); + setRetryMessage('重试完成'); + } + } + ); + } catch (error) { + console.error('重试请求失败:', error); + message.error('重试请求失败,无法连接到服务器'); + setRetrying(false); + } + }, [taskId, failedSteps, navigate]); + + const skipFailedSteps = useCallback(() => { + setFailedSteps([]); + clearBookImportCache(); + const projectId = importedProjectId.current; + if (projectId) { + message.info('已跳过失败步骤,正在跳转到项目...'); + navigate(`/project/${projectId}/chapters`); + } + }, [navigate]); + + const updateChapter = (index: number, patch: Partial) => { + setPreview(prev => { + if (!prev) return prev; + const next = [...prev.chapters]; + next[index] = { ...next[index], ...patch }; + return { ...prev, chapters: next }; + }); + }; + + return ( +
+ + + + + {currentStep === 0 && ( + + + { + setFile(f); + return false; + }} + onRemove={() => { + setFile(null); + }} + fileList={ + file + ? [ + { + uid: 'selected-txt', + name: file.name, + status: 'done', + } as UploadFile, + ] + : [] + } + style={{ padding: '8px 0' }} + > +

+ +

+

点击或拖拽 TXT 文件到此区域

+

首版仅支持 .txt,建议不超过 50MB

+
+ + + + {taskId && ( + 任务ID: {taskId} + )} + +
+
+ )} + + {currentStep === 1 && ( + + {!taskId ? ( + + ) : ( +
+ +
+ + {taskStatus?.status === 'pending' && '等待调度...'} + {taskStatus?.status === 'running' && '正在解析TXT文件...'} + {taskStatus?.status === 'completed' && '解析完成!正在生成预览...'} + {taskStatus?.status === 'failed' && '解析失败'} + {taskStatus?.status === 'cancelled' && '已取消'} + + {taskStatus?.message && ( +
+ {taskStatus.message} +
+ )} +
+ + {taskStatus?.error && ( + + )} + + + + {taskStatus && ['pending', 'running'].includes(taskStatus.status) && ( + + )} + +
+ )} +
+ )} + + {currentStep === 2 && ( + <> + + 确认导入 + + } + style={{ marginBottom: 16 }} + > + + {!preview ? ( + + ) : ( +
+ + {preview.warnings.length > 0 && ( + + {preview.warnings.map((w, idx) => ( +
  • [{w.level}] {w.message}
  • + ))} + + } + /> + )} + + + + + 标题 + + setPreview(prev => prev ? ({ + ...prev, + project_suggestion: { ...prev.project_suggestion, title: e.target.value }, + }) : prev) + } + /> + + + 类型 + + setPreview(prev => prev ? ({ + ...prev, + project_suggestion: { ...prev.project_suggestion, genre: e.target.value }, + }) : prev) + } + /> + + + 主题 +