feat: add NovelPage component with bookshelf, open book, and reading views

This commit is contained in:
2026-06-02 08:19:38 +08:00
parent 54e17c9795
commit 81b0d0ea1f
39 changed files with 8057 additions and 64 deletions

View File

@@ -0,0 +1,439 @@
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))