feat: add NovelPage component with bookshelf, open book, and reading views
This commit is contained in:
302
backend/services/fiction_service.py
Normal file
302
backend/services/fiction_service.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
爽文书籍 CRUD 与全局资源读取。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.config import settings
|
||||
from models.fiction_models import (
|
||||
CreateFictionBookRequest,
|
||||
EmotionFlowCatalog,
|
||||
FictionBookMeta,
|
||||
FictionBookSettings,
|
||||
FictionBookSummary,
|
||||
FictionChapter,
|
||||
FictionChapterSummary,
|
||||
FictionGuideWorldbook,
|
||||
FictionPipelineSettings,
|
||||
FictionPrompts,
|
||||
FictionReaderSettings,
|
||||
GuideGlobalEntries,
|
||||
UpdateFictionBookSettingsRequest,
|
||||
)
|
||||
from services.fiction_prompt_utils import USER_DEFAULT_PROMPTS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_PROMPTS = FictionPrompts(
|
||||
openBook=USER_DEFAULT_PROMPTS["openBook"],
|
||||
coarseOutline=USER_DEFAULT_PROMPTS["coarseOutline"],
|
||||
eventPlan=USER_DEFAULT_PROMPTS["eventPlan"],
|
||||
chapter=USER_DEFAULT_PROMPTS["chapter"],
|
||||
nudge=USER_DEFAULT_PROMPTS["nudge"],
|
||||
)
|
||||
|
||||
DEFAULT_READER = FictionReaderSettings()
|
||||
DEFAULT_PIPELINE = FictionPipelineSettings()
|
||||
|
||||
DEFAULT_METADATA: Dict[str, Any] = {
|
||||
"version": 2,
|
||||
"volumes": [],
|
||||
"eventChains": {},
|
||||
"chapterPlans": {},
|
||||
"progress": {
|
||||
"currentChapterSeq": 0,
|
||||
"charOffset": 0,
|
||||
"ttsPaused": False,
|
||||
"genPaused": False,
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_RUN: Dict[str, Any] = {
|
||||
"status": "idle",
|
||||
"pipelineStage": None,
|
||||
"stage": "idle",
|
||||
"message": None,
|
||||
"updatedAt": "",
|
||||
}
|
||||
|
||||
|
||||
def _read_json(path: Path) -> Any:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _write_json(path: Path, data: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
text = (text or "").strip()
|
||||
text = re.sub(r"[^\w\u4e00-\u9fff\-]+", "-", text, flags=re.UNICODE)
|
||||
text = re.sub(r"-+", "-", text).strip("-")
|
||||
return text[:48] or "book"
|
||||
|
||||
|
||||
class FictionService:
|
||||
@property
|
||||
def books_root(self) -> Path:
|
||||
return settings.FICTION_BOOKS_PATH
|
||||
|
||||
def _book_dir(self, book_id: str) -> Path:
|
||||
return self.books_root / book_id
|
||||
|
||||
def _meta_path(self, book_id: str) -> Path:
|
||||
return self._book_dir(book_id) / "meta.json"
|
||||
|
||||
def _settings_path(self, book_id: str) -> Path:
|
||||
return self._book_dir(book_id) / "settings.json"
|
||||
|
||||
def _guide_path(self, book_id: str) -> Path:
|
||||
return self._book_dir(book_id) / "guide.worldbook.json"
|
||||
|
||||
def _metadata_path(self, book_id: str) -> Path:
|
||||
return self._book_dir(book_id) / "metadata.json"
|
||||
|
||||
def _run_path(self, book_id: str) -> Path:
|
||||
return self._book_dir(book_id) / "run.json"
|
||||
|
||||
def _chapters_dir(self, book_id: str) -> Path:
|
||||
return self._book_dir(book_id) / "chapters"
|
||||
|
||||
def _chapter_path(self, book_id: str, seq: int) -> Path:
|
||||
return self._chapters_dir(book_id) / f"{seq:04d}.json"
|
||||
|
||||
def chapter_exists(self, book_id: str, seq: int) -> bool:
|
||||
return self._chapter_path(book_id, seq).exists()
|
||||
|
||||
def get_chapter(self, book_id: str, seq: int) -> FictionChapter:
|
||||
path = self._chapter_path(book_id, seq)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Chapter not found: {book_id}/{seq}")
|
||||
return FictionChapter(**_read_json(path))
|
||||
|
||||
def save_chapter(self, book_id: str, chapter: FictionChapter) -> FictionChapter:
|
||||
self._ensure_book(book_id)
|
||||
_write_json(self._chapter_path(book_id, chapter.seq), chapter.model_dump())
|
||||
self._touch_book_meta(book_id)
|
||||
return chapter
|
||||
|
||||
def list_written_chapter_seqs(self, book_id: str) -> List[int]:
|
||||
chapters_dir = self._chapters_dir(book_id)
|
||||
if not chapters_dir.exists():
|
||||
return []
|
||||
seqs: List[int] = []
|
||||
for path in chapters_dir.glob("*.json"):
|
||||
try:
|
||||
seqs.append(int(path.stem))
|
||||
except ValueError:
|
||||
continue
|
||||
return sorted(seqs)
|
||||
|
||||
def list_chapter_summaries(self, book_id: str) -> List[FictionChapterSummary]:
|
||||
summaries: List[FictionChapterSummary] = []
|
||||
for seq in self.list_written_chapter_seqs(book_id):
|
||||
ch = self.get_chapter(book_id, seq)
|
||||
summaries.append(
|
||||
FictionChapterSummary(
|
||||
seq=ch.seq,
|
||||
title=ch.title,
|
||||
charCount=ch.charCount,
|
||||
eventId=ch.eventId,
|
||||
phaseKey=ch.phaseKey,
|
||||
)
|
||||
)
|
||||
return summaries
|
||||
|
||||
def _ensure_book(self, book_id: str) -> None:
|
||||
if not self._meta_path(book_id).exists():
|
||||
raise FileNotFoundError(f"Book not found: {book_id}")
|
||||
|
||||
def _touch_book_meta(self, book_id: str) -> None:
|
||||
meta_path = self._meta_path(book_id)
|
||||
meta = _read_json(meta_path)
|
||||
meta["updatedAt"] = datetime.now().isoformat()
|
||||
_write_json(meta_path, meta)
|
||||
|
||||
def get_default_settings(self) -> FictionBookSettings:
|
||||
return FictionBookSettings(
|
||||
prompts=DEFAULT_PROMPTS,
|
||||
reader=DEFAULT_READER,
|
||||
pipeline=DEFAULT_PIPELINE,
|
||||
)
|
||||
|
||||
def get_emotion_catalog(self) -> EmotionFlowCatalog:
|
||||
path = settings.FICTION_EMOTION_CATALOG_FILE
|
||||
if not path.exists():
|
||||
return EmotionFlowCatalog(flows=[])
|
||||
return EmotionFlowCatalog(**_read_json(path))
|
||||
|
||||
def get_guide_global_entries(self) -> GuideGlobalEntries:
|
||||
path = settings.FICTION_GUIDE_GLOBAL_ENTRIES_FILE
|
||||
if not path.exists():
|
||||
return GuideGlobalEntries(entries=[])
|
||||
return GuideGlobalEntries(**_read_json(path))
|
||||
|
||||
def list_books(self) -> List[FictionBookSummary]:
|
||||
root = self.books_root
|
||||
if not root.exists():
|
||||
return []
|
||||
summaries: List[FictionBookSummary] = []
|
||||
for child in sorted(root.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
meta_path = child / "meta.json"
|
||||
if not meta_path.exists():
|
||||
continue
|
||||
meta = _read_json(meta_path)
|
||||
summaries.append(
|
||||
FictionBookSummary(
|
||||
id=meta.get("id", child.name),
|
||||
title=meta.get("title", child.name),
|
||||
allowedFlowIds=meta.get("allowedFlowIds", []),
|
||||
updatedAt=meta.get("updatedAt", ""),
|
||||
)
|
||||
)
|
||||
summaries.sort(key=lambda x: x.updatedAt or "", reverse=True)
|
||||
return summaries
|
||||
|
||||
def get_book_meta(self, book_id: str) -> FictionBookMeta:
|
||||
meta_path = self._meta_path(book_id)
|
||||
if not meta_path.exists():
|
||||
raise FileNotFoundError(f"Book not found: {book_id}")
|
||||
return FictionBookMeta(**_read_json(meta_path))
|
||||
|
||||
def get_book_settings(self, book_id: str) -> FictionBookSettings:
|
||||
settings_path = self._settings_path(book_id)
|
||||
if not settings_path.exists():
|
||||
raise FileNotFoundError(f"Book not found: {book_id}")
|
||||
return FictionBookSettings(**_read_json(settings_path))
|
||||
|
||||
def update_book_settings(
|
||||
self, book_id: str, req: UpdateFictionBookSettingsRequest
|
||||
) -> FictionBookSettings:
|
||||
meta_path = self._meta_path(book_id)
|
||||
settings_path = self._settings_path(book_id)
|
||||
if not meta_path.exists():
|
||||
raise FileNotFoundError(f"Book not found: {book_id}")
|
||||
current = FictionBookSettings(**_read_json(settings_path))
|
||||
data = current.model_dump()
|
||||
if req.prompts is not None:
|
||||
data["prompts"] = req.prompts.model_dump()
|
||||
if req.reader is not None:
|
||||
data["reader"] = req.reader.model_dump()
|
||||
if req.pipeline is not None:
|
||||
data["pipeline"] = req.pipeline.model_dump()
|
||||
_write_json(settings_path, data)
|
||||
meta = _read_json(meta_path)
|
||||
meta["updatedAt"] = datetime.now().isoformat()
|
||||
_write_json(meta_path, meta)
|
||||
return FictionBookSettings(**data)
|
||||
|
||||
def get_book_guide(self, book_id: str) -> FictionGuideWorldbook:
|
||||
guide_path = self._guide_path(book_id)
|
||||
if not guide_path.exists():
|
||||
raise FileNotFoundError(f"Book not found: {book_id}")
|
||||
return FictionGuideWorldbook(**_read_json(guide_path))
|
||||
|
||||
def _unique_book_id(self, base_id: str) -> str:
|
||||
candidate = base_id
|
||||
n = 1
|
||||
while self._book_dir(candidate).exists():
|
||||
candidate = f"{base_id}-{n}"
|
||||
n += 1
|
||||
return candidate
|
||||
|
||||
def create_book(self, req: CreateFictionBookRequest) -> FictionBookMeta:
|
||||
title = (req.title or "").strip()
|
||||
if not title:
|
||||
raise ValueError("书名不能为空")
|
||||
|
||||
base_id = _slugify(title)
|
||||
if not base_id or base_id == "book":
|
||||
base_id = str(uuid.uuid4())[:8]
|
||||
book_id = self._unique_book_id(base_id)
|
||||
|
||||
dest = self._book_dir(book_id)
|
||||
dest.mkdir(parents=True, exist_ok=False)
|
||||
self._chapters_dir(book_id).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
meta = {
|
||||
"id": book_id,
|
||||
"title": title,
|
||||
"allowedFlowIds": list(req.allowedFlowIds or []),
|
||||
"createdAt": now,
|
||||
"updatedAt": now,
|
||||
}
|
||||
_write_json(self._meta_path(book_id), meta)
|
||||
|
||||
default_settings = self.get_default_settings()
|
||||
_write_json(self._settings_path(book_id), default_settings.model_dump())
|
||||
|
||||
guide = req.guide.model_dump() if req.guide else FictionGuideWorldbook().model_dump()
|
||||
_write_json(self._guide_path(book_id), guide)
|
||||
|
||||
metadata = dict(DEFAULT_METADATA)
|
||||
_write_json(self._metadata_path(book_id), metadata)
|
||||
|
||||
run_data = dict(DEFAULT_RUN)
|
||||
run_data["updatedAt"] = now
|
||||
_write_json(self._run_path(book_id), run_data)
|
||||
|
||||
return FictionBookMeta(**meta)
|
||||
|
||||
def delete_book(self, book_id: str) -> None:
|
||||
book_dir = self._book_dir(book_id)
|
||||
if not book_dir.exists():
|
||||
raise FileNotFoundError(f"Book not found: {book_id}")
|
||||
shutil.rmtree(book_dir)
|
||||
|
||||
|
||||
fiction_service = FictionService()
|
||||
Reference in New Issue
Block a user