feat: add NovelPage component with bookshelf, open book, and reading views
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
"""
|
||||
爽文事件规划(fiction.event_plan)— LLM 调用逻辑。
|
||||
按事件顺序生成,每完成一个事件即持久化并广播进度。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
from typing import Any, AsyncIterator, Dict, List, Optional
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
|
||||
from models.fiction_models import (
|
||||
ChapterPlanItem,
|
||||
CoarseOutlineEvent,
|
||||
EventPlanEntry,
|
||||
FictionBookMetadata,
|
||||
FlowStepsPlan,
|
||||
)
|
||||
from services.fiction_event_plan_progress import emit as emit_progress
|
||||
from services.fiction_event_plan_progress import subscribe as subscribe_progress
|
||||
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")
|
||||
|
||||
|
||||
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)
|
||||
parts = [
|
||||
f"主角人设:{guide.persona}",
|
||||
f"核心爽点:{guide.highlight}",
|
||||
f"用户体验:{guide.experience}",
|
||||
f"创作禁区:{guide.forbiddenZones}",
|
||||
]
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
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 _format_flow_steps(flow) -> str:
|
||||
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 _build_event_plan_messages(
|
||||
book_id: str,
|
||||
event_id: str,
|
||||
event_title: str,
|
||||
event_summary: str,
|
||||
emotion_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("eventPlan", user_prompt)
|
||||
|
||||
guide_l2 = _format_guide_global_layers(["L2"])
|
||||
book_guide = _format_book_guide(book_id)
|
||||
flow = _get_flow_by_id(emotion_flow_id)
|
||||
flow_text = _format_flow_steps(flow) if flow else f"情绪流 id: {emotion_flow_id}"
|
||||
|
||||
user_content = f"""## 全局创作指南(L2,仅用于事件规划)
|
||||
{guide_l2}
|
||||
|
||||
## 本书 Guide 世界书
|
||||
{book_guide}
|
||||
|
||||
## 当前粗纲事件
|
||||
- id: {event_id}
|
||||
- title: {event_title}
|
||||
- summary: {event_summary}
|
||||
|
||||
## 为本事件随机选定的情绪流
|
||||
{flow_text}
|
||||
|
||||
请输出 flowStepsPlan(起承转合)与 chapterPlan(章节级 brief)。
|
||||
|
||||
输出 JSON 示例:
|
||||
{{
|
||||
"flowStepsPlan": {{
|
||||
"起": "本阶段规划…",
|
||||
"承": "…",
|
||||
"转": "…",
|
||||
"合": "…"
|
||||
}},
|
||||
"chapterPlan": [
|
||||
{{ "seq": 1, "phaseKey": "起", "phaseSlice": "全", "brief": "本章要点", "status": "planned" }}
|
||||
]
|
||||
}}"""
|
||||
|
||||
return [
|
||||
SystemMessage(content=system_prompt),
|
||||
HumanMessage(content=user_content),
|
||||
]
|
||||
|
||||
|
||||
def _normalize_flow_steps_plan(raw: Any) -> FlowStepsPlan:
|
||||
data = raw if isinstance(raw, dict) else {}
|
||||
return FlowStepsPlan(
|
||||
起=str(data.get("起") or data.get("qi") or ""),
|
||||
承=str(data.get("承") or data.get("cheng") or ""),
|
||||
转=str(data.get("转") or data.get("zhuan") or ""),
|
||||
合=str(data.get("合") or data.get("he") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_chapter_plan(raw: Any) -> List[ChapterPlanItem]:
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
items: List[ChapterPlanItem] = []
|
||||
for idx, item in enumerate(raw):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
seq = item.get("seq")
|
||||
if not isinstance(seq, int):
|
||||
seq = idx + 1
|
||||
status = str(item.get("status") or "planned")
|
||||
items.append(
|
||||
ChapterPlanItem(
|
||||
seq=seq,
|
||||
phaseKey=str(item.get("phaseKey") or item.get("phase_key") or "起"),
|
||||
phaseSlice=str(item.get("phaseSlice") or item.get("phase_slice") or ""),
|
||||
brief=str(item.get("brief") or ""),
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
items.sort(key=lambda c: c.seq)
|
||||
return items
|
||||
|
||||
|
||||
def _parse_event_plan_response(data: Dict[str, Any], emotion_flow_id: str) -> EventPlanEntry:
|
||||
flow_steps = _normalize_flow_steps_plan(data.get("flowStepsPlan"))
|
||||
chapter_plan = _normalize_chapter_plan(data.get("chapterPlan"))
|
||||
if not chapter_plan:
|
||||
raise ValueError("chapterPlan 为空")
|
||||
return EventPlanEntry(
|
||||
emotionFlowId=emotion_flow_id,
|
||||
flowStepsPlan=flow_steps,
|
||||
chapterPlan=chapter_plan,
|
||||
)
|
||||
|
||||
|
||||
def _event_fully_planned(entry: EventPlanEntry) -> bool:
|
||||
return bool(entry.chapterPlan)
|
||||
|
||||
|
||||
def _resolve_targets(
|
||||
metadata: FictionBookMetadata,
|
||||
*,
|
||||
event_id: Optional[str] = None,
|
||||
) -> List[CoarseOutlineEvent]:
|
||||
coarse_events = metadata.coarseOutline.events
|
||||
if not coarse_events:
|
||||
raise ValueError("请先生成粗纲")
|
||||
|
||||
events_map = dict(metadata.events or {})
|
||||
if event_id:
|
||||
targets = [e for e in coarse_events if e.id == event_id]
|
||||
if not targets:
|
||||
raise ValueError(f"粗纲中不存在事件: {event_id}")
|
||||
return targets
|
||||
|
||||
return [
|
||||
e
|
||||
for e in coarse_events
|
||||
if e.id not in events_map or not _event_fully_planned(events_map[e.id])
|
||||
]
|
||||
|
||||
|
||||
def event_planned_payload(evt: CoarseOutlineEvent, entry: EventPlanEntry) -> Dict[str, Any]:
|
||||
phases: List[str] = []
|
||||
fsp = entry.flowStepsPlan
|
||||
for key in ("起", "承", "转", "合"):
|
||||
if getattr(fsp, key, ""):
|
||||
phases.append(key)
|
||||
return {
|
||||
"type": "event_planned",
|
||||
"eventId": evt.id,
|
||||
"title": evt.title,
|
||||
"chapterCount": len(entry.chapterPlan),
|
||||
"phases": phases,
|
||||
}
|
||||
|
||||
|
||||
def build_progress_snapshot(book_id: str) -> Dict[str, Any]:
|
||||
"""已规划事件的目录快照(不含 brief 剧透)。"""
|
||||
metadata = fiction_metadata_service.get_metadata(book_id)
|
||||
coarse_events = metadata.coarseOutline.events
|
||||
events_map = metadata.events or {}
|
||||
items: List[Dict[str, Any]] = []
|
||||
for evt in coarse_events:
|
||||
entry = events_map.get(evt.id)
|
||||
if entry and _event_fully_planned(entry):
|
||||
items.append(event_planned_payload(evt, entry))
|
||||
run = fiction_metadata_service.get_run(book_id)
|
||||
progress = run.progress or {}
|
||||
return {
|
||||
"type": "snapshot",
|
||||
"items": items,
|
||||
"done": progress.get("done", len(items)),
|
||||
"total": progress.get("total", len(coarse_events)),
|
||||
}
|
||||
|
||||
|
||||
async def _generate_one_event(
|
||||
book_id: str,
|
||||
evt: CoarseOutlineEvent,
|
||||
*,
|
||||
resolved: Dict[str, str],
|
||||
allowed: List[str],
|
||||
) -> EventPlanEntry:
|
||||
emotion_flow_id = random.choice(allowed)
|
||||
messages = _build_event_plan_messages(
|
||||
book_id,
|
||||
evt.id,
|
||||
evt.title,
|
||||
evt.summary,
|
||||
emotion_flow_id,
|
||||
)
|
||||
response = await _llm_client.chat_completion(
|
||||
messages=messages,
|
||||
api_url=resolved["api_url"],
|
||||
api_key=resolved["api_key"],
|
||||
model=resolved.get("model", "gpt-4o-mini"),
|
||||
temperature=0.7,
|
||||
max_tokens=8000,
|
||||
request_timeout=120,
|
||||
stream=False,
|
||||
)
|
||||
content = response["choices"][0]["message"]["content"]
|
||||
data = _extract_json(content)
|
||||
return _parse_event_plan_response(data, emotion_flow_id)
|
||||
|
||||
|
||||
async def iter_event_plan(
|
||||
book_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
event_id: Optional[str] = None,
|
||||
) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""按事件逐个生成,每完成一个即保存并 yield 进度事件。"""
|
||||
resolved = resolve_api_config(profile_id, api_config)
|
||||
_validate_api_config(resolved)
|
||||
|
||||
metadata = fiction_metadata_service.get_metadata(book_id)
|
||||
meta = fiction_service.get_book_meta(book_id)
|
||||
allowed = list(meta.allowedFlowIds or [])
|
||||
if not allowed:
|
||||
raise ValueError("本书未配置 allowedFlowIds")
|
||||
|
||||
targets = _resolve_targets(metadata, event_id=event_id)
|
||||
coarse_events = metadata.coarseOutline.events
|
||||
coarse_total = len(coarse_events)
|
||||
|
||||
if not targets:
|
||||
logger.info("Event plans already exist for book %s, skipping generation", book_id)
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="event_plan_done"
|
||||
)
|
||||
fiction_metadata_service.clear_pipeline_progress(book_id)
|
||||
done_evt: Dict[str, Any] = {"type": "done", "done": coarse_total, "total": coarse_total}
|
||||
emit_progress(book_id, done_evt)
|
||||
yield done_evt
|
||||
return
|
||||
|
||||
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="event_plan"
|
||||
)
|
||||
|
||||
events_map = dict(metadata.events or {})
|
||||
|
||||
def _planned_count() -> int:
|
||||
return sum(
|
||||
1
|
||||
for e in coarse_events
|
||||
if e.id in events_map and _event_fully_planned(events_map[e.id])
|
||||
)
|
||||
|
||||
initial_done = _planned_count()
|
||||
fiction_metadata_service.set_pipeline_progress(
|
||||
book_id, done=initial_done, total=coarse_total
|
||||
)
|
||||
started: Dict[str, Any] = {
|
||||
"type": "started",
|
||||
"done": initial_done,
|
||||
"total": coarse_total,
|
||||
"pending": len(targets),
|
||||
}
|
||||
emit_progress(book_id, started)
|
||||
yield started
|
||||
|
||||
try:
|
||||
for evt in targets:
|
||||
entry = await _generate_one_event(
|
||||
book_id, evt, resolved=resolved, allowed=allowed
|
||||
)
|
||||
events_map[evt.id] = entry
|
||||
metadata.events = events_map
|
||||
fiction_metadata_service.save_metadata(book_id, metadata)
|
||||
|
||||
done_count = _planned_count()
|
||||
fiction_metadata_service.set_pipeline_progress(
|
||||
book_id, done=done_count, total=coarse_total
|
||||
)
|
||||
payload = event_planned_payload(evt, entry)
|
||||
emit_progress(book_id, payload)
|
||||
yield payload
|
||||
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="event_plan_done"
|
||||
)
|
||||
fiction_metadata_service.clear_pipeline_progress(book_id)
|
||||
done_evt = {"type": "done", "done": _planned_count(), "total": coarse_total}
|
||||
emit_progress(book_id, done_evt)
|
||||
yield done_evt
|
||||
except Exception as exc:
|
||||
completed = [
|
||||
eid for eid, ent in events_map.items() if _event_fully_planned(ent)
|
||||
]
|
||||
err_evt: Dict[str, Any] = {
|
||||
"type": "error",
|
||||
"message": str(exc),
|
||||
"completedEvents": completed,
|
||||
"done": _planned_count(),
|
||||
"total": coarse_total,
|
||||
}
|
||||
emit_progress(book_id, err_evt)
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="error", pipeline_stage="event_plan"
|
||||
)
|
||||
yield err_evt
|
||||
raise
|
||||
|
||||
|
||||
async def run_event_plan(
|
||||
book_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
event_id: Optional[str] = None,
|
||||
) -> FictionBookMetadata:
|
||||
async for _event in iter_event_plan(
|
||||
book_id,
|
||||
profile_id=profile_id,
|
||||
api_config=api_config,
|
||||
event_id=event_id,
|
||||
):
|
||||
pass
|
||||
return fiction_metadata_service.get_metadata(book_id)
|
||||
|
||||
|
||||
async def stream_event_plan_subscribe(book_id: str) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""订阅进行中的事件纲要进度(先快照,再实时)。"""
|
||||
snapshot = build_progress_snapshot(book_id)
|
||||
yield snapshot
|
||||
|
||||
run = fiction_metadata_service.get_run(book_id)
|
||||
if run.status == "running" and run.pipelineStage == "event_plan":
|
||||
async for event in subscribe_progress(book_id):
|
||||
if event.get("type") == "snapshot":
|
||||
continue
|
||||
yield event
|
||||
elif snapshot["items"] or snapshot.get("done", 0) > 0:
|
||||
yield {
|
||||
"type": "done",
|
||||
"done": snapshot["done"],
|
||||
"total": snapshot["total"],
|
||||
}
|
||||
Reference in New Issue
Block a user