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,317 @@
"""
爽文章节写作fiction.chapter— 新版 metadata v2 章纲驱动。
"""
from __future__ import annotations
import json
import logging
import re
from datetime import datetime
from typing import Any, Dict, Iterator, List, Optional, Tuple
from langchain_core.messages import HumanMessage, SystemMessage
from models.fiction_models import (
ChapterPlanItem,
EventChainItem,
FictionBookMetadata,
FictionChapter,
VolumeOutline,
)
from services.fiction_metadata_service import fiction_metadata_service
from services.fiction_planning_service import ensure_chapter_plan
from services.fiction_prompt_utils import resolve_prompt
from services.fiction_service import fiction_service
from services.studio_step_respond import resolve_api_config
from utils.llm_client import LLMClient
logger = logging.getLogger(__name__)
_llm_client = LLMClient()
_JSON_FENCE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
PlannedChapter = Tuple[int, VolumeOutline, EventChainItem, ChapterPlanItem]
def _extract_json(raw: str) -> Dict[str, Any]:
text = (raw or "").strip()
if not text:
raise ValueError("模型返回为空")
fence = _JSON_FENCE.search(text)
if fence:
text = fence.group(1).strip()
try:
return json.loads(text)
except json.JSONDecodeError:
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
return json.loads(text[start : end + 1])
raise ValueError("无法解析模型返回的 JSON")
def _validate_api_config(api_config: Dict[str, str]) -> None:
if not api_config.get("api_key"):
raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥")
if not api_config.get("api_url"):
raise ValueError("API 地址未配置,请先在 API 配置页面保存 mainLLM")
if not api_config.get("model"):
raise ValueError("模型未配置,请先在 API 配置页面保存 mainLLM 模型")
def _format_guide_global_layers(layers: List[str]) -> str:
entries = fiction_service.get_guide_global_entries().entries
filtered = [e for e in entries if e.layer in layers]
lines: List[str] = []
for entry in filtered:
lines.append(f"[{entry.layer}] {entry.title}\n{entry.content}")
return "\n\n".join(lines) if lines else "(无全局指南)"
def _format_book_guide(book_id: str) -> str:
guide = fiction_service.get_book_guide(book_id)
return guide.chapter.content or "(无章节层世界书)"
def iter_planned_chapters(metadata: FictionBookMetadata) -> Iterator[PlannedChapter]:
"""按卷纲 → 事件链 → 章纲顺序展开章节计划。"""
volumes = sorted(metadata.volumes or [], key=lambda v: v.order)
for volume in volumes:
events = sorted(metadata.eventChains.get(volume.id, []), key=lambda e: e.order)
for event in events:
plans = sorted(metadata.chapterPlans.get(event.id, []), key=lambda c: c.seq)
for item in plans:
yield item.seq, volume, event, item
def find_next_unwritten_chapter(
book_id: str, metadata: Optional[FictionBookMetadata] = None
) -> Optional[PlannedChapter]:
metadata = metadata or fiction_metadata_service.get_metadata(book_id)
for seq, volume, event, item in iter_planned_chapters(metadata):
if fiction_service.chapter_exists(book_id, seq):
continue
if item.status == "written":
continue
return seq, volume, event, item
return None
def has_written_chapters(book_id: str) -> bool:
return len(fiction_service.list_written_chapter_seqs(book_id)) > 0
def _build_context_tail(book_id: str, before_seq: int, context_chars: int) -> str:
if before_seq <= 1 or context_chars <= 0:
return ""
parts: List[str] = []
for seq in range(1, before_seq):
if not fiction_service.chapter_exists(book_id, seq):
continue
ch = fiction_service.get_chapter(book_id, seq)
if ch.body:
parts.append(ch.body)
combined = "\n\n".join(parts)
if len(combined) <= context_chars:
return combined
return combined[-context_chars:]
def _build_chapter_messages(
book_id: str,
global_seq: int,
volume: VolumeOutline,
event: EventChainItem,
plan_item: ChapterPlanItem,
) -> List[Any]:
settings = fiction_service.get_book_settings(book_id)
user_prompt = settings.prompts.chapter or fiction_service.get_default_settings().prompts.chapter
system_prompt = resolve_prompt("chapter", user_prompt)
context_chars = settings.reader.contextWindowChars if settings.reader else 2000
context_tail = _build_context_tail(book_id, global_seq, context_chars)
guide_l3 = _format_guide_global_layers(["L3"])
book_guide = _format_book_guide(book_id)
context_block = context_tail if context_tail else "(本章为开篇,无上文)"
user_content = f"""## 全局创作指南L3
{guide_l3}
## 本书世界书(章节层)
{book_guide}
## 当前卷纲
- id: {volume.id}
- title: {volume.title}
- goal: {volume.goal}
- coreConflict: {volume.coreConflict}
- emotionalPromise: {volume.emotionalPromise}
## 当前事件
- id: {event.id}
- title: {event.title}
- summary: {event.summary}
- purpose: {event.purpose}
- conflict: {event.conflict}
- turningPoint: {event.turningPoint}
- expectedPayoff: {event.expectedPayoff}
## 本章章纲(第 {global_seq} 章)
- title: {plan_item.title}
- goal: {plan_item.goal}
- opening: {plan_item.opening}
- mainConflict: {plan_item.mainConflict}
- emotionalTurn: {plan_item.emotionalTurn}
- emotionStepKey: {plan_item.emotionStepKey}
- emotionGoal: {plan_item.emotionGoal}
- payoff: {plan_item.payoff}
- endingHook: {plan_item.endingHook}
- forbidden: {plan_item.forbidden}
- targetWords: {plan_item.targetWords}
## 已读上文末尾(最多 {context_chars} 字,供衔接)
{context_block}
## 绝对要求
- 只生成第 {global_seq} 章正文。
- 正文字数目标约 {plan_item.targetWords or 2000} 汉字。
- 必须遵循本章章纲、当前事件、当前卷纲与章节层世界书。
- 不生成下一章章纲,不生成解释说明。"""
return [
SystemMessage(content=system_prompt),
HumanMessage(content=user_content),
]
def _parse_chapter_response(
data: Dict[str, Any],
*,
global_seq: int,
event: EventChainItem,
plan_item: ChapterPlanItem,
) -> FictionChapter:
body = str(data.get("body") or "").strip()
if not body:
raise ValueError("章节正文为空")
title = str(data.get("title") or plan_item.title or f"{global_seq}").strip()
return FictionChapter(
seq=global_seq,
title=title,
body=body,
charCount=len(body),
status="written",
eventId=event.id,
phaseKey=plan_item.emotionStepKey or plan_item.phaseKey,
createdAt=datetime.now().isoformat(),
)
def _mark_chapter_written(
metadata: FictionBookMetadata, event_id: str, plan_seq: int
) -> FictionBookMetadata:
plans = metadata.chapterPlans.get(event_id, [])
updated_plan: List[ChapterPlanItem] = []
for item in plans:
if item.seq == plan_seq:
updated_plan.append(item.model_copy(update={"status": "written"}))
else:
updated_plan.append(item)
metadata.chapterPlans[event_id] = updated_plan
return metadata
async def run_chapter(
book_id: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
seq: Optional[int] = None,
) -> FictionChapter:
resolved = resolve_api_config(profile_id, api_config)
_validate_api_config(resolved)
metadata = await ensure_chapter_plan(
book_id,
profile_id=profile_id,
api_config=api_config,
)
target: Optional[PlannedChapter] = None
if seq is not None:
for global_seq, volume, event, item in iter_planned_chapters(metadata):
if global_seq == seq:
target = (global_seq, volume, event, item)
break
if not target:
raise ValueError(f"章节规划不存在: seq={seq}")
global_seq, volume, event, item = target
if fiction_service.chapter_exists(book_id, global_seq):
return fiction_service.get_chapter(book_id, global_seq)
else:
found = find_next_unwritten_chapter(book_id, metadata)
if not found:
raise ValueError("没有待撰写的章节")
global_seq, volume, event, item = found
if fiction_service.chapter_exists(book_id, global_seq):
return fiction_service.get_chapter(book_id, global_seq)
run = fiction_metadata_service.get_run(book_id)
if run.status == "error":
fiction_metadata_service.clear_pipeline_error(book_id)
fiction_metadata_service.set_pipeline_stage(
book_id, status="running", pipeline_stage="chapter"
)
try:
messages = _build_chapter_messages(book_id, global_seq, volume, event, item)
response = await _llm_client.chat_completion(
messages=messages,
api_url=resolved["api_url"],
api_key=resolved["api_key"],
model=resolved["model"],
temperature=0.8,
max_tokens=8000,
request_timeout=180,
stream=False,
)
content = response["choices"][0]["message"]["content"]
data = _extract_json(content)
chapter = _parse_chapter_response(
data, global_seq=global_seq, event=event, plan_item=item
)
fiction_service.save_chapter(book_id, chapter)
metadata = fiction_metadata_service.get_metadata(book_id)
metadata = _mark_chapter_written(metadata, event.id, item.seq)
progress = metadata.progress
if progress.currentChapterSeq <= 0:
progress.currentChapterSeq = global_seq
metadata.progress = progress
fiction_metadata_service.save_metadata(book_id, metadata)
fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="ready"
)
return chapter
except Exception:
fiction_metadata_service.set_pipeline_stage(
book_id, status="error", pipeline_stage="chapter"
)
raise
async def ensure_chapter(
book_id: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
seq: Optional[int] = None,
) -> FictionChapter:
return await run_chapter(
book_id,
profile_id=profile_id,
api_config=api_config,
seq=seq,
)