import json import logging from typing import List from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse import services.tools.fiction_tools # noqa: F401 — register fiction tools from models.fiction_models import ( CreateFictionBookRequest, EmotionFlowCatalog, FictionBookMeta, FictionBookMetadata, FictionBookSettings, FictionBookSummary, FictionChapter, FictionChapterSummary, FictionGenerationRequest, FictionGuideWorldbook, FictionPipelineTickResult, FictionRunState, FictionStartReadingResult, GuideGlobalEntries, OpenBookRequest, OpenBookResult, UpdateFictionBookSettingsRequest, UpdateFictionProgressRequest, ) from services.fiction_chapter_service import ensure_chapter, run_chapter from services.fiction_coarse_service import run_coarse_outline from services.fiction_event_plan_service import ( iter_event_plan, run_event_plan, stream_event_plan_subscribe, ) from services.fiction_metadata_service import fiction_metadata_service from services.fiction_open_book_service import run_open_book from services.fiction_planning_service import ( ensure_chapter_plan, ensure_event_chain, ensure_volume, ) from services.fiction_orchestrator_service import ( get_pending_stages, get_pipeline_run, start_reading_pipeline, tick_reading_pipeline, ) from services.fiction_service import fiction_service logger = logging.getLogger(__name__) router = APIRouter(prefix="/fiction", tags=["fiction"]) @router.get("/books", response_model=List[FictionBookSummary]) async def list_fiction_books(): try: return fiction_service.list_books() except Exception as e: logger.error("Failed to list fiction books: %s", e) raise HTTPException(status_code=500, detail=str(e)) @router.post("/books", response_model=FictionBookMeta) async def create_fiction_book(req: CreateFictionBookRequest): try: return fiction_service.create_book(req) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except FileExistsError as e: raise HTTPException(status_code=409, detail=str(e)) except Exception as e: logger.error("Failed to create fiction book: %s", e) raise HTTPException(status_code=500, detail=str(e)) @router.get("/books/{book_id}", response_model=FictionBookMeta) async def get_fiction_book_meta(book_id: str): try: return fiction_service.get_book_meta(book_id) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error("Failed to get fiction book %s: %s", book_id, e) raise HTTPException(status_code=500, detail=str(e)) @router.delete("/books/{book_id}") async def delete_fiction_book(book_id: str): try: fiction_service.delete_book(book_id) return {"ok": True} except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error("Failed to delete fiction book %s: %s", book_id, e) raise HTTPException(status_code=500, detail=str(e)) @router.get("/books/{book_id}/settings", response_model=FictionBookSettings) async def get_fiction_book_settings(book_id: str): try: return fiction_service.get_book_settings(book_id) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error("Failed to get settings for %s: %s", book_id, e) raise HTTPException(status_code=500, detail=str(e)) @router.put("/books/{book_id}/settings", response_model=FictionBookSettings) async def update_fiction_book_settings( book_id: str, req: UpdateFictionBookSettingsRequest ): try: return fiction_service.update_book_settings(book_id, req) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error("Failed to update settings for %s: %s", book_id, e) raise HTTPException(status_code=500, detail=str(e)) @router.get("/books/{book_id}/guide", response_model=FictionGuideWorldbook) async def get_fiction_book_guide(book_id: str): try: return fiction_service.get_book_guide(book_id) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error("Failed to get guide for %s: %s", book_id, e) raise HTTPException(status_code=500, detail=str(e)) @router.get("/emotion-flows/catalog", response_model=EmotionFlowCatalog) async def get_emotion_flow_catalog(): try: return fiction_service.get_emotion_catalog() except Exception as e: logger.error("Failed to get emotion catalog: %s", e) raise HTTPException(status_code=500, detail=str(e)) @router.get("/guide-global/entries", response_model=GuideGlobalEntries) async def get_guide_global_entries(): try: return fiction_service.get_guide_global_entries() except Exception as e: logger.error("Failed to get guide global entries: %s", e) raise HTTPException(status_code=500, detail=str(e)) @router.post("/open-book", response_model=OpenBookResult) async def open_fiction_book(req: OpenBookRequest): try: return await run_open_book( req.inspiration, profile_id=req.profile_id, api_config=req.api_config, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error("Failed to open fiction book: %s", e) raise HTTPException(status_code=500, detail=str(e)) @router.get("/books/{book_id}/metadata", response_model=FictionBookMetadata) async def get_fiction_book_metadata(book_id: str): try: return fiction_metadata_service.get_metadata(book_id) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error("Failed to get metadata for %s: %s", book_id, e) raise HTTPException(status_code=500, detail=str(e)) @router.get("/books/{book_id}/run", response_model=FictionRunState) async def get_fiction_book_run(book_id: str): try: return fiction_metadata_service.get_run(book_id) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error("Failed to get run for %s: %s", book_id, e) raise HTTPException(status_code=500, detail=str(e)) @router.post("/books/{book_id}/start", response_model=FictionStartReadingResult) async def start_fiction_reading(book_id: str, req: FictionGenerationRequest): """进入阅读时自动触发粗纲 / 事件纲要流水线(后台异步)。""" try: return await start_reading_pipeline( book_id, profile_id=req.profile_id, api_config=req.api_config, ) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error("Failed to start reading pipeline for %s: %s", book_id, e) raise HTTPException(status_code=500, detail=str(e)) @router.post("/books/{book_id}/pipeline/tick", response_model=FictionPipelineTickResult) async def tick_fiction_pipeline(book_id: str, req: FictionGenerationRequest): """检查并推进流水线(尊重半自动设置)。""" try: return await tick_reading_pipeline( book_id, profile_id=req.profile_id, api_config=req.api_config, ) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error("Failed to tick pipeline for %s: %s", book_id, e) raise HTTPException(status_code=500, detail=str(e)) @router.get("/books/{book_id}/pipeline/status") async def get_fiction_pipeline_status(book_id: str): """流水线 run 状态 + 待手动阶段列表。""" try: run = get_pipeline_run(book_id) pending = get_pending_stages(book_id) return {"run": run, "pendingStages": pending} except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error("Failed to get pipeline status for %s: %s", book_id, e) raise HTTPException(status_code=500, detail=str(e)) @router.post("/books/{book_id}/volumes/ensure", response_model=FictionBookMetadata) async def ensure_fiction_volume(book_id: str, req: FictionGenerationRequest): try: return await ensure_volume( book_id, profile_id=req.profile_id, api_config=req.api_config, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error("Failed to ensure volume for %s: %s", book_id, e) raise HTTPException(status_code=500, detail=str(e)) @router.post("/books/{book_id}/events/ensure", response_model=FictionBookMetadata) async def ensure_fiction_event_chain(book_id: str, req: FictionGenerationRequest): try: return await ensure_event_chain( book_id, profile_id=req.profile_id, api_config=req.api_config, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error("Failed to ensure event chain for %s: %s", book_id, e) raise HTTPException(status_code=500, detail=str(e)) @router.post("/books/{book_id}/chapter-plans/ensure", response_model=FictionBookMetadata) async def ensure_fiction_chapter_plan(book_id: str, req: FictionGenerationRequest): try: return await ensure_chapter_plan( book_id, event_id=req.event_id, profile_id=req.profile_id, api_config=req.api_config, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error("Failed to ensure chapter plan for %s: %s", book_id, e) raise HTTPException(status_code=500, detail=str(e)) @router.post("/books/{book_id}/chapters/ensure", response_model=FictionChapter) async def ensure_fiction_chapter(book_id: str, req: FictionGenerationRequest): try: return await ensure_chapter( book_id, profile_id=req.profile_id, api_config=req.api_config, seq=req.seq, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error("Failed to ensure chapter for %s: %s", book_id, e) raise HTTPException(status_code=500, detail=str(e)) @router.post("/books/{book_id}/coarse-outline", response_model=FictionBookMetadata) async def generate_coarse_outline(book_id: str, req: FictionGenerationRequest): try: return await ensure_volume( book_id, profile_id=req.profile_id, api_config=req.api_config, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error("Failed to ensure volume for %s: %s", book_id, e) raise HTTPException(status_code=500, detail=str(e)) @router.post("/books/{book_id}/event-plan", response_model=FictionBookMetadata) async def generate_event_plan(book_id: str, req: FictionGenerationRequest): if req.stream: async def ndjson_stream(): try: async for event in iter_event_plan( book_id, profile_id=req.profile_id, api_config=req.api_config, event_id=req.event_id, ): yield json.dumps(event, ensure_ascii=False) + "\n" except FileNotFoundError as e: yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n" except ValueError as e: yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n" except Exception as e: logger.error("Failed to stream event plan for %s: %s", book_id, e) yield json.dumps( {"type": "error", "message": str(e)}, ensure_ascii=False ) + "\n" return StreamingResponse(ndjson_stream(), media_type="application/x-ndjson") try: return await ensure_chapter_plan( book_id, event_id=req.event_id, profile_id=req.profile_id, api_config=req.api_config, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error("Failed to generate event plan for %s: %s", book_id, e) raise HTTPException(status_code=500, detail=str(e)) @router.get("/books/{book_id}/event-plan/stream") async def subscribe_event_plan_stream(book_id: str): """订阅事件纲要生成进度(NDJSON),适用于后台流水线已启动时。""" try: fiction_service.get_book_meta(book_id) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) async def ndjson_stream(): try: async for event in stream_event_plan_subscribe(book_id): yield json.dumps(event, ensure_ascii=False) + "\n" except FileNotFoundError as e: yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n" except Exception as e: logger.error("Failed to subscribe event plan stream for %s: %s", book_id, e) yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n" return StreamingResponse(ndjson_stream(), media_type="application/x-ndjson") @router.get("/books/{book_id}/chapters", response_model=List[FictionChapterSummary]) async def list_fiction_chapters(book_id: str): try: return fiction_service.list_chapter_summaries(book_id) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error("Failed to list chapters for %s: %s", book_id, e) raise HTTPException(status_code=500, detail=str(e)) @router.get("/books/{book_id}/chapters/{seq}", response_model=FictionChapter) async def get_fiction_chapter(book_id: str, seq: int): try: return fiction_service.get_chapter(book_id, seq) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error("Failed to get chapter %s for %s: %s", seq, book_id, e) raise HTTPException(status_code=500, detail=str(e)) @router.post("/books/{book_id}/chapter", response_model=FictionChapter) async def generate_fiction_chapter(book_id: str, req: FictionGenerationRequest): """撰写下一章或指定 seq 的章节(本地已存在则直接返回)。""" try: return await run_chapter( book_id, profile_id=req.profile_id, api_config=req.api_config, seq=req.seq, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error("Failed to generate chapter for %s: %s", book_id, e) raise HTTPException(status_code=500, detail=str(e)) @router.patch("/books/{book_id}/progress", response_model=FictionBookMetadata) async def update_fiction_progress(book_id: str, req: UpdateFictionProgressRequest): try: return fiction_metadata_service.update_progress( book_id, current_chapter_seq=req.currentChapterSeq, char_offset=req.charOffset, ) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error("Failed to update progress for %s: %s", book_id, e) raise HTTPException(status_code=500, detail=str(e))