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,540 @@
"""
爽文新版规划服务:卷纲 → 情感链事件串 → 章纲。
原则:
- 硬编码提示词只保留规定性约束:输出结构、字数/章节数、必须遵循的上游内容。
- “如何写爽点/如何留钩子”等创作方法交给 book-local 世界书与全局指南。
- book-local 世界书按 volume/event/chapter 三层分别插入,不混用。
"""
from __future__ import annotations
import json
import logging
import random
import re
from typing import Any, Dict, List, Optional
from langchain_core.messages import HumanMessage, SystemMessage
from models.fiction_models import (
ChapterPlanItem,
EventChainItem,
FictionBookMetadata,
VolumeOutline,
)
from services.fiction_metadata_service import fiction_metadata_service
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)
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)
lines = [
f"主角人设:{guide.persona}",
f"核心爽点:{guide.highlight}",
f"用户体验:{guide.experience}",
f"创作禁区:{guide.forbiddenZones}",
]
text = "\n".join(line for line in lines if line.split("", 1)[1].strip()).strip()
return text or "(无本书 guide"
def _flow_catalog_text() -> str:
catalog = fiction_service.get_emotion_catalog()
lines: List[str] = []
for flow in catalog.flows:
steps = "".join([f"{s.key}:{s.text}" for s in flow.steps])
lines.append(f"- {flow.id}: {flow.intro} | steps={steps}")
return "\n".join(lines) if lines else "(无情感链目录)"
def _get_flow_by_id(flow_id: str):
catalog = fiction_service.get_emotion_catalog()
for flow in catalog.flows:
if flow.id == flow_id:
return flow
return None
def _choose_flow_id(book_id: str) -> str:
meta = fiction_service.get_book_meta(book_id)
allowed = list(meta.allowedFlowIds or [])
catalog = fiction_service.get_emotion_catalog()
catalog_ids = [flow.id for flow in catalog.flows]
candidates = [fid for fid in allowed if fid in catalog_ids] or allowed or catalog_ids
if not candidates:
return ""
return random.choice(candidates)
def _format_flow(flow_id: str) -> str:
flow = _get_flow_by_id(flow_id)
if not flow:
return f"情感链 id: {flow_id or '(未指定)'}"
lines = [f"情感链:{flow.intro} (id: {flow.id})"]
for step in flow.steps or []:
lines.append(f"- {step.key}: {step.text}")
return "\n".join(lines)
def _next_volume_id(metadata: FictionBookMetadata) -> str:
return f"vol_{len(metadata.volumes) + 1:03d}"
def _next_event_id(metadata: FictionBookMetadata, index: int) -> str:
all_events = [event for chain in metadata.eventChains.values() for event in chain]
return f"evt_{len(all_events) + index + 1:04d}"
def _build_volume_messages(book_id: str, metadata: FictionBookMetadata) -> List[Any]:
settings = fiction_service.get_book_settings(book_id)
user_prompt = (
settings.prompts.coarseOutline
or fiction_service.get_default_settings().prompts.coarseOutline
)
system_prompt = resolve_prompt("volumeOutline", user_prompt)
meta = fiction_service.get_book_meta(book_id)
guide_l1 = _format_guide_global_layers(["L1"])
book_guide = _format_book_guide(book_id)
existing = "\n".join([f"- {v.id} {v.title}: {v.goal}" for v in metadata.volumes]) or "(暂无)"
user_content = f"""## 全局创作指南L1
{guide_l1}
## 本书 guide具体人设/爽点/体验/禁区)
{book_guide}
## 书名
{meta.title}
## 已有卷纲
{existing}
## 可用情感链目录
{_flow_catalog_text()}
## 本次任务
生成下一卷卷纲。
## 绝对要求
- 只生成 1 卷。
- 本卷目标章节数 targetChapterCount 必须在 10 到 30 之间。
- primaryEmotionFlowId 必须来自可用情感链目录;如目录为空则留空。
- 不生成事件链、章纲或正文。
"""
return [SystemMessage(content=system_prompt), HumanMessage(content=user_content)]
def _normalize_volume(raw: Dict[str, Any], volume_id: str, order: int) -> VolumeOutline:
target = raw.get("targetChapterCount")
if not isinstance(target, int):
target = 20
return VolumeOutline(
id=str(raw.get("id") or volume_id),
order=order,
title=str(raw.get("title") or f"{order}"),
goal=str(raw.get("goal") or ""),
coreConflict=str(raw.get("coreConflict") or raw.get("core_conflict") or ""),
powerProgression=str(raw.get("powerProgression") or raw.get("power_progression") or ""),
emotionalPromise=str(raw.get("emotionalPromise") or raw.get("emotional_promise") or ""),
endingHook=str(raw.get("endingHook") or raw.get("ending_hook") or ""),
targetChapterCount=max(10, min(30, target)),
primaryEmotionFlowId=str(raw.get("primaryEmotionFlowId") or raw.get("primary_emotion_flow_id") or ""),
status=str(raw.get("status") or "active"),
)
def _build_event_chain_messages(book_id: str, volume: VolumeOutline, flow_id: str) -> List[Any]:
settings = fiction_service.get_book_settings(book_id)
user_prompt = (
settings.prompts.eventPlan
or fiction_service.get_default_settings().prompts.eventPlan
)
system_prompt = resolve_prompt("eventChain", user_prompt)
guide_l2 = _format_guide_global_layers(["L2"])
book_guide = _format_book_guide(book_id)
flow_text = _format_flow(flow_id)
user_content = f"""## 全局创作指南L2
{guide_l2}
## 本书 guide具体人设/爽点/体验/禁区)
{book_guide}
## 当前卷纲
- id: {volume.id}
- title: {volume.title}
- goal: {volume.goal}
- coreConflict: {volume.coreConflict}
- powerProgression: {volume.powerProgression}
- emotionalPromise: {volume.emotionalPromise}
- endingHook: {volume.endingHook}
- targetChapterCount: {volume.targetChapterCount}
## 必须遵循的情感链
{flow_text}
## 本次任务
生成当前卷的事件链。
## 绝对要求
- 事件链总章节数应接近卷纲 targetChapterCount。
- 每个事件 targetChapterCount 必须在 2 到 5 之间。
- 每个事件必须填写 emotionFlowId、emotionStepKey、emotionStepText。
- 事件顺序必须遵循情感链 steps 的顺序,不得倒置。
- 不生成章纲或正文。
"""
return [SystemMessage(content=system_prompt), HumanMessage(content=user_content)]
def _normalize_event_chain(
raw: Any,
metadata: FictionBookMetadata,
volume: VolumeOutline,
flow_id: str,
) -> List[EventChainItem]:
raw_events = raw if isinstance(raw, list) else []
events: List[EventChainItem] = []
flow = _get_flow_by_id(flow_id)
steps = flow.steps if flow else []
for idx, item in enumerate(raw_events):
if not isinstance(item, dict):
continue
target = item.get("targetChapterCount")
if not isinstance(target, int):
target = 3
step = steps[min(idx, len(steps) - 1)] if steps else None
events.append(
EventChainItem(
id=str(item.get("id") or _next_event_id(metadata, idx)),
volumeId=volume.id,
order=int(item.get("order")) if isinstance(item.get("order"), int) else idx + 1,
title=str(item.get("title") or f"事件 {idx + 1}"),
summary=str(item.get("summary") or ""),
purpose=str(item.get("purpose") or ""),
conflict=str(item.get("conflict") or ""),
turningPoint=str(item.get("turningPoint") or item.get("turning_point") or ""),
expectedPayoff=str(item.get("expectedPayoff") or item.get("expected_payoff") or ""),
targetChapterCount=max(2, min(5, target)),
emotionFlowId=str(item.get("emotionFlowId") or item.get("emotion_flow_id") or flow_id),
emotionStepKey=str(item.get("emotionStepKey") or item.get("emotion_step_key") or (step.key if step else "")),
emotionStepText=str(item.get("emotionStepText") or item.get("emotion_step_text") or (step.text if step else "")),
status=str(item.get("status") or "planned"),
)
)
events.sort(key=lambda e: e.order)
if not events:
raise ValueError("事件链为空")
return events
def _build_chapter_plan_messages(
book_id: str,
volume: VolumeOutline,
event: EventChainItem,
) -> List[Any]:
settings = fiction_service.get_book_settings(book_id)
user_prompt = (
settings.prompts.eventPlan
or fiction_service.get_default_settings().prompts.eventPlan
)
system_prompt = resolve_prompt("chapterPlan", user_prompt)
guide_l3 = _format_guide_global_layers(["L3"])
book_guide = _format_book_guide(book_id)
user_content = f"""## 全局创作指南L3
{guide_l3}
## 本书 guide具体人设/爽点/体验/禁区)
{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}
- targetChapterCount: {event.targetChapterCount}
## 当前事件绑定的情感链步骤
- emotionFlowId: {event.emotionFlowId}
- emotionStepKey: {event.emotionStepKey}
- emotionStepText: {event.emotionStepText}
## 本次任务
为当前事件生成章纲。
## 绝对要求
- 必须生成 {event.targetChapterCount} 章章纲。
- 每章 targetWords 必须为 2000。
- 每章必须继承当前事件 id。
- 每章必须填写 emotionStepKey 与 emotionGoal。
- 不生成正文。
"""
return [SystemMessage(content=system_prompt), HumanMessage(content=user_content)]
def _next_chapter_seq(metadata: FictionBookMetadata) -> int:
max_seq = 0
for plans in metadata.chapterPlans.values():
for item in plans:
max_seq = max(max_seq, item.seq)
return max_seq + 1
def _normalize_chapter_plans(
raw: Any,
metadata: FictionBookMetadata,
event: EventChainItem,
) -> List[ChapterPlanItem]:
raw_items = raw if isinstance(raw, list) else []
start_seq = _next_chapter_seq(metadata)
items: List[ChapterPlanItem] = []
for idx, item in enumerate(raw_items):
if not isinstance(item, dict):
continue
seq = start_seq + idx
title = str(item.get("title") or f"{seq}")
goal = str(item.get("goal") or item.get("brief") or "")
items.append(
ChapterPlanItem(
seq=seq,
phaseKey=str(item.get("phaseKey") or event.emotionStepKey),
phaseSlice=str(item.get("phaseSlice") or ""),
brief=str(item.get("brief") or goal),
eventId=event.id,
title=title,
goal=goal,
opening=str(item.get("opening") or ""),
mainConflict=str(item.get("mainConflict") or item.get("main_conflict") or event.conflict),
emotionalTurn=str(item.get("emotionalTurn") or item.get("emotional_turn") or ""),
emotionStepKey=str(item.get("emotionStepKey") or item.get("emotion_step_key") or event.emotionStepKey),
emotionGoal=str(item.get("emotionGoal") or item.get("emotion_goal") or event.emotionStepText),
payoff=str(item.get("payoff") or event.expectedPayoff),
endingHook=str(item.get("endingHook") or item.get("ending_hook") or ""),
forbidden=str(item.get("forbidden") or ""),
targetWords=2000,
status=str(item.get("status") or "planned"),
)
)
items.sort(key=lambda c: c.seq)
if not items:
raise ValueError("章纲为空")
return items
async def ensure_volume(
book_id: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> FictionBookMetadata:
metadata = fiction_metadata_service.get_metadata(book_id)
if metadata.volumes:
return metadata
resolved = resolve_api_config(profile_id, api_config)
_validate_api_config(resolved)
fiction_metadata_service.set_pipeline_stage(
book_id, status="running", pipeline_stage="volume"
)
try:
messages = _build_volume_messages(book_id, metadata)
response = await _llm_client.chat_completion(
messages=messages,
api_url=resolved["api_url"],
api_key=resolved["api_key"],
model=resolved["model"],
temperature=0.7,
max_tokens=8000,
request_timeout=120,
stream=False,
)
data = _extract_json(response["choices"][0]["message"]["content"])
volume = _normalize_volume(
data.get("volume") if isinstance(data.get("volume"), dict) else data,
_next_volume_id(metadata),
len(metadata.volumes) + 1,
)
if not volume.primaryEmotionFlowId:
volume.primaryEmotionFlowId = _choose_flow_id(book_id)
metadata.volumes.append(volume)
saved = fiction_metadata_service.save_metadata(book_id, metadata)
fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="volume_done"
)
return saved
except Exception:
fiction_metadata_service.set_pipeline_stage(
book_id, status="error", pipeline_stage="volume"
)
raise
async def ensure_event_chain(
book_id: str,
*,
volume_id: Optional[str] = None,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> FictionBookMetadata:
metadata = await ensure_volume(book_id, profile_id=profile_id, api_config=api_config)
volume = next((v for v in metadata.volumes if v.id == volume_id), metadata.volumes[-1])
if metadata.eventChains.get(volume.id):
return metadata
resolved = resolve_api_config(profile_id, api_config)
_validate_api_config(resolved)
flow_id = volume.primaryEmotionFlowId or _choose_flow_id(book_id)
volume.primaryEmotionFlowId = flow_id
fiction_metadata_service.set_pipeline_stage(
book_id, status="running", pipeline_stage="event_chain"
)
try:
messages = _build_event_chain_messages(book_id, volume, flow_id)
response = await _llm_client.chat_completion(
messages=messages,
api_url=resolved["api_url"],
api_key=resolved["api_key"],
model=resolved["model"],
temperature=0.7,
max_tokens=8000,
request_timeout=120,
stream=False,
)
data = _extract_json(response["choices"][0]["message"]["content"])
raw_events = data.get("events") or data.get("eventChain") or data.get("event_chain")
events = _normalize_event_chain(raw_events, metadata, volume, flow_id)
metadata.eventChains[volume.id] = events
saved = fiction_metadata_service.save_metadata(book_id, metadata)
fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="event_chain_done"
)
return saved
except Exception:
fiction_metadata_service.set_pipeline_stage(
book_id, status="error", pipeline_stage="event_chain"
)
raise
async def ensure_chapter_plan(
book_id: str,
*,
event_id: Optional[str] = None,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> FictionBookMetadata:
metadata = await ensure_event_chain(book_id, profile_id=profile_id, api_config=api_config)
target_event: Optional[EventChainItem] = None
target_volume: Optional[VolumeOutline] = None
for volume in metadata.volumes:
for event in metadata.eventChains.get(volume.id, []):
if event_id and event.id != event_id:
continue
if metadata.chapterPlans.get(event.id):
if event_id:
return metadata
continue
target_event = event
target_volume = volume
break
if target_event:
break
if not target_event or not target_volume:
return metadata
resolved = resolve_api_config(profile_id, api_config)
_validate_api_config(resolved)
fiction_metadata_service.set_pipeline_stage(
book_id, status="running", pipeline_stage="chapter_plan"
)
try:
messages = _build_chapter_plan_messages(book_id, target_volume, target_event)
response = await _llm_client.chat_completion(
messages=messages,
api_url=resolved["api_url"],
api_key=resolved["api_key"],
model=resolved["model"],
temperature=0.7,
max_tokens=8000,
request_timeout=120,
stream=False,
)
data = _extract_json(response["choices"][0]["message"]["content"])
raw_items = data.get("chapterPlan") or data.get("chapters") or data.get("chapter_plan")
plans = _normalize_chapter_plans(raw_items, metadata, target_event)
metadata.chapterPlans[target_event.id] = plans
saved = fiction_metadata_service.save_metadata(book_id, metadata)
fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="chapter_plan_done"
)
return saved
except Exception:
fiction_metadata_service.set_pipeline_stage(
book_id, status="error", pipeline_stage="chapter_plan"
)
raise