Files
SillyTavern_replica/backend/api/routes/fictionRoute.py

440 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))