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,
)

View File

@@ -0,0 +1,178 @@
"""
爽文粗纲生成fiction.coarse— LLM 调用逻辑。
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any, Dict, List, Optional
from langchain_core.messages import HumanMessage, SystemMessage
from models.fiction_models import CoarseOutline, CoarseOutlineEvent, FictionBookMetadata
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 _build_coarse_messages(book_id: str) -> 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("coarseOutline", user_prompt)
guide_l1 = _format_guide_global_layers(["L1"])
book_guide = _format_book_guide(book_id)
meta = fiction_service.get_book_meta(book_id)
user_content = f"""## 全局创作指南L1仅用于粗纲
{guide_l1}
## 本书 Guide 世界书
{book_guide}
## 书名
{meta.title}
## 已选情绪流 ID
{", ".join(meta.allowedFlowIds or []) or "(未配置)"}
请生成本书的粗纲事件链。"""
return [
SystemMessage(content=system_prompt),
HumanMessage(content=user_content),
]
def _normalize_coarse_events(raw_events: Any) -> List[CoarseOutlineEvent]:
if not isinstance(raw_events, list):
return []
events: List[CoarseOutlineEvent] = []
for idx, item in enumerate(raw_events):
if not isinstance(item, dict):
continue
evt_id = str(item.get("id") or f"evt-{idx + 1}").strip()
title = str(item.get("title") or f"事件 {idx + 1}").strip()
summary = str(item.get("summary") or "").strip()
order = item.get("order")
if not isinstance(order, int):
order = idx + 1
events.append(
CoarseOutlineEvent(id=evt_id, title=title, summary=summary, order=order)
)
events.sort(key=lambda e: e.order)
return events
def _parse_coarse_response(data: Dict[str, Any]) -> CoarseOutline:
events = _normalize_coarse_events(data.get("events"))
if not events:
raise ValueError("粗纲事件列表为空")
version = data.get("version")
if not isinstance(version, int):
version = 1
return CoarseOutline(events=events, version=version)
async def run_coarse_outline(
book_id: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> FictionBookMetadata:
existing = fiction_metadata_service.get_metadata(book_id)
if existing.coarseOutline.events:
logger.info("Coarse outline already exists for book %s, skipping generation", book_id)
return existing
resolved = resolve_api_config(profile_id, api_config)
_validate_api_config(resolved)
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="coarse"
)
try:
messages = _build_coarse_messages(book_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)
coarse = _parse_coarse_response(data)
current = fiction_metadata_service.get_metadata(book_id)
current.coarseOutline = coarse
saved = fiction_metadata_service.save_metadata(book_id, current)
fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="coarse_done"
)
return saved
except Exception:
fiction_metadata_service.set_pipeline_stage(
book_id, status="error", pipeline_stage="coarse"
)
raise

View File

@@ -0,0 +1,34 @@
"""
事件纲要生成进度广播 — 供 NDJSON 订阅端与后台流水线共享。
"""
from __future__ import annotations
import asyncio
from typing import Any, AsyncIterator, Dict, List
_subscribers: Dict[str, List[asyncio.Queue]] = {}
def emit(book_id: str, event: Dict[str, Any]) -> None:
for q in list(_subscribers.get(book_id, [])):
try:
q.put_nowait(event)
except asyncio.QueueFull:
pass
async def subscribe(book_id: str) -> AsyncIterator[Dict[str, Any]]:
q: asyncio.Queue = asyncio.Queue(maxsize=128)
_subscribers.setdefault(book_id, []).append(q)
try:
while True:
item = await q.get()
yield item
if item.get("type") in ("done", "error"):
break
finally:
subs = _subscribers.get(book_id, [])
if q in subs:
subs.remove(q)
if not subs:
_subscribers.pop(book_id, None)

View File

@@ -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"],
}

View File

@@ -0,0 +1,168 @@
"""
爽文 metadata.json / run.json 读写服务。
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
from models.fiction_models import FictionBookMetadata, FictionRunState
from services.fiction_service import fiction_service
logger = logging.getLogger(__name__)
_STAGE_MESSAGES: Dict[tuple, tuple] = {
("running", "coarse"): ("coarse_generating", "正在生成粗纲…"),
("running", "event_plan"): ("event_plan_generating", "正在生成事件纲要…"),
("running", "chapter"): ("chapter_generating", "正在撰写正文…"),
("error", "coarse"): ("error", "粗纲生成失败"),
("error", "event_plan"): ("error", "事件纲要生成失败"),
("error", "chapter"): ("error", "章节写作失败"),
}
def _resolve_stage_message(
status: str,
pipeline_stage: Optional[str],
override_message: Optional[str] = None,
) -> tuple:
if override_message is not None:
key = (status, pipeline_stage or "")
stage = _STAGE_MESSAGES.get(key, (status, override_message))[0]
if status == "error":
stage = "error"
elif status == "running" and pipeline_stage == "coarse":
stage = "coarse_generating"
elif status == "running" and pipeline_stage == "event_plan":
stage = "event_plan_generating"
elif status == "running" and pipeline_stage == "chapter":
stage = "chapter_generating"
elif status == "idle":
stage = "idle"
return stage, override_message
matched = _STAGE_MESSAGES.get((status, pipeline_stage or ""))
if matched:
return matched
if status == "idle":
return "idle", None
if status == "error":
return "error", "生成失败"
return status, None
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)
class FictionMetadataService:
def _metadata_path(self, book_id: str) -> Path:
return fiction_service._metadata_path(book_id)
def _run_path(self, book_id: str) -> Path:
return fiction_service._run_path(book_id)
def _ensure_book(self, book_id: str) -> None:
if not fiction_service._meta_path(book_id).exists():
raise FileNotFoundError(f"Book not found: {book_id}")
def get_metadata(self, book_id: str) -> FictionBookMetadata:
self._ensure_book(book_id)
path = self._metadata_path(book_id)
if not path.exists():
raise FileNotFoundError(f"metadata.json not found for book: {book_id}")
return FictionBookMetadata(**_read_json(path))
def save_metadata(self, book_id: str, metadata: FictionBookMetadata) -> FictionBookMetadata:
self._ensure_book(book_id)
_write_json(self._metadata_path(book_id), metadata.model_dump())
self._touch_book_meta(book_id)
return metadata
def update_metadata(self, book_id: str, patch: Dict[str, Any]) -> FictionBookMetadata:
current = self.get_metadata(book_id)
data = current.model_dump()
for key, value in patch.items():
data[key] = value
updated = FictionBookMetadata(**data)
return self.save_metadata(book_id, updated)
def get_run(self, book_id: str) -> FictionRunState:
self._ensure_book(book_id)
path = self._run_path(book_id)
if not path.exists():
return FictionRunState()
return FictionRunState(**_read_json(path))
def save_run(self, book_id: str, run: FictionRunState) -> FictionRunState:
self._ensure_book(book_id)
_write_json(self._run_path(book_id), run.model_dump())
return run
def set_pipeline_stage(
self,
book_id: str,
*,
status: str,
pipeline_stage: Optional[str] = None,
message: Optional[str] = None,
) -> FictionRunState:
run = self.get_run(book_id)
run.status = status
run.pipelineStage = pipeline_stage
run.stage, run.message = _resolve_stage_message(status, pipeline_stage, message)
run.updatedAt = datetime.now().isoformat()
return self.save_run(book_id, run)
def clear_pipeline_error(self, book_id: str) -> FictionRunState:
"""清除 error 状态,保留 pipelineStage 供重试参考。"""
run = self.get_run(book_id)
if run.status != "error":
return run
run.status = "idle"
run.stage = "idle"
run.message = None
run.updatedAt = datetime.now().isoformat()
return self.save_run(book_id, run)
def set_pipeline_progress(
self, book_id: str, *, done: int, total: int
) -> FictionRunState:
run = self.get_run(book_id)
run.progress = {"done": done, "total": total}
run.updatedAt = datetime.now().isoformat()
return self.save_run(book_id, run)
def clear_pipeline_progress(self, book_id: str) -> FictionRunState:
run = self.get_run(book_id)
run.progress = None
run.updatedAt = datetime.now().isoformat()
return self.save_run(book_id, run)
def update_progress(
self,
book_id: str,
*,
current_chapter_seq: Optional[int] = None,
char_offset: Optional[int] = None,
):
metadata = self.get_metadata(book_id)
progress = metadata.progress
if current_chapter_seq is not None:
progress.currentChapterSeq = current_chapter_seq
if char_offset is not None:
progress.charOffset = char_offset
metadata.progress = progress
return self.save_metadata(book_id, metadata)
fiction_metadata_service = FictionMetadataService()

View File

@@ -0,0 +1,159 @@
"""
爽文开书优化fiction.open_book— LLM 调用逻辑。
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any, Dict, List, Optional
from langchain_core.messages import HumanMessage, SystemMessage
from models.fiction_models import (
EmotionFlow,
FictionGuideWorldbook,
OpenBookResult,
)
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_catalog_for_prompt(flows: List[EmotionFlow]) -> str:
lines: List[str] = []
for flow in flows:
tags = "".join(flow.tags or [])
lines.append(f"- id: {flow.id}\n intro: {flow.intro}\n tags: {tags}")
return "\n".join(lines) if lines else "(无可用情绪流)"
def _format_guide_global_for_prompt() -> str:
entries = fiction_service.get_guide_global_entries().entries
lines: List[str] = []
for entry in entries:
lines.append(f"[{entry.layer}] {entry.title}\n{entry.content}")
return "\n\n".join(lines) if lines else "(无全局指南)"
def _build_open_book_messages(inspiration: str) -> List[Any]:
default_settings = fiction_service.get_default_settings()
system_prompt = resolve_prompt("openBook", default_settings.prompts.openBook)
catalog = fiction_service.get_emotion_catalog()
catalog_text = _format_catalog_for_prompt(catalog.flows)
guide_global_text = _format_guide_global_for_prompt()
user_content = f"""## 全局创作指南L0L3
{guide_global_text}
## 可选情绪流 catalog
{catalog_text}
## 用户创作灵感
{inspiration.strip()}
请根据以上信息优化开书方案。"""
return [
SystemMessage(content=system_prompt),
HumanMessage(content=user_content),
]
def _normalize_flow_ids(raw_ids: Any, valid_ids: set[str]) -> List[str]:
if not isinstance(raw_ids, list):
return []
result: List[str] = []
for item in raw_ids:
fid = str(item).strip()
if fid in valid_ids and fid not in result:
result.append(fid)
return result
def _parse_open_book_response(
data: Dict[str, Any], valid_flow_ids: set[str]
) -> OpenBookResult:
guide_raw = data.get("guide") or {}
guide = FictionGuideWorldbook(
persona=str(guide_raw.get("persona") or "").strip(),
highlight=str(guide_raw.get("highlight") or "").strip(),
experience=str(guide_raw.get("experience") or "").strip(),
forbiddenZones=str(guide_raw.get("forbiddenZones") or "").strip(),
)
allowed = _normalize_flow_ids(data.get("allowedFlowIds"), valid_flow_ids)
if not allowed and valid_flow_ids:
allowed = [next(iter(valid_flow_ids))]
title = str(data.get("title") or "未命名作品").strip() or "未命名作品"
optimized_intro = str(data.get("optimizedIntro") or "").strip()
return OpenBookResult(
title=title,
optimizedIntro=optimized_intro,
guide=guide,
allowedFlowIds=allowed,
)
async def run_open_book(
inspiration: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> OpenBookResult:
inspiration = (inspiration or "").strip()
if not inspiration:
raise ValueError("创作灵感不能为空")
resolved = resolve_api_config(profile_id, api_config)
_validate_api_config(resolved)
catalog = fiction_service.get_emotion_catalog()
valid_flow_ids = {f.id for f in catalog.flows}
messages = _build_open_book_messages(inspiration)
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_open_book_response(data, valid_flow_ids)

View File

@@ -0,0 +1,290 @@
"""
爽文阅读流水线编排 — 新版 ensure 滚动补齐:卷纲 → 事件链 → 章纲 → 章节。
"""
from __future__ import annotations
import asyncio
import logging
from typing import Dict, List, Optional
from models.fiction_models import (
FictionPipelineSettings,
FictionPipelineTickResult,
FictionRunState,
FictionStartReadingResult,
)
from services.fiction_chapter_service import (
find_next_unwritten_chapter,
has_written_chapters,
run_chapter,
)
from services.fiction_metadata_service import fiction_metadata_service
from services.fiction_planning_service import (
ensure_chapter_plan,
ensure_event_chain,
ensure_volume,
)
from services.fiction_service import fiction_service
logger = logging.getLogger(__name__)
_active_tasks: Dict[str, asyncio.Task] = {}
_lock = asyncio.Lock()
def _get_pipeline_settings(book_id: str) -> FictionPipelineSettings:
settings = fiction_service.get_book_settings(book_id)
return settings.pipeline or FictionPipelineSettings()
def _needs_volume(book_id: str) -> bool:
metadata = fiction_metadata_service.get_metadata(book_id)
return not metadata.volumes
def _needs_event_chain(book_id: str) -> bool:
metadata = fiction_metadata_service.get_metadata(book_id)
if not metadata.volumes:
return False
for volume in metadata.volumes:
if not metadata.eventChains.get(volume.id):
return True
return False
def _needs_chapter_plan(book_id: str) -> bool:
metadata = fiction_metadata_service.get_metadata(book_id)
if not metadata.volumes:
return False
for volume in metadata.volumes:
events = metadata.eventChains.get(volume.id, [])
if not events:
return False
for event in events:
if not metadata.chapterPlans.get(event.id):
return True
return False
def _needs_chapter(book_id: str) -> bool:
if _needs_volume(book_id) or _needs_event_chain(book_id) or _needs_chapter_plan(book_id):
return False
if has_written_chapters(book_id):
return False
metadata = fiction_metadata_service.get_metadata(book_id)
return find_next_unwritten_chapter(book_id, metadata) is not None
def _pipeline_complete(book_id: str) -> bool:
return (
not _needs_volume(book_id)
and not _needs_event_chain(book_id)
and not _needs_chapter_plan(book_id)
and not _needs_chapter(book_id)
)
def get_pending_stages(book_id: str) -> List[str]:
"""返回需手动触发的阶段 id 列表auto 关闭且仍有工作,或上次失败需重试)。"""
pipeline = _get_pipeline_settings(book_id)
pending: List[str] = []
if _needs_volume(book_id) and not pipeline.autoCoarse:
pending.append("volume")
if _needs_event_chain(book_id) and not pipeline.autoEventPlan:
pending.append("event_chain")
if _needs_chapter_plan(book_id) and not pipeline.autoEventPlan:
pending.append("chapter_plan")
if _needs_chapter(book_id) and not pipeline.autoChapter:
pending.append("chapter")
run = fiction_metadata_service.get_run(book_id)
if run.status == "error" and run.pipelineStage:
retry_map = {
"volume": "volume",
"coarse": "volume",
"event_chain": "event_chain",
"event_plan": "chapter_plan",
"chapter_plan": "chapter_plan",
"chapter": "chapter",
}
failed = retry_map.get(run.pipelineStage)
if failed and failed not in pending:
if failed == "volume" and _needs_volume(book_id):
pending.insert(0, failed)
elif failed == "event_chain" and _needs_event_chain(book_id):
pending.insert(0, failed)
elif failed == "chapter_plan" and _needs_chapter_plan(book_id):
pending.insert(0, failed)
elif failed == "chapter" and _needs_chapter(book_id):
pending.insert(0, failed)
return pending
def _next_auto_stage(book_id: str) -> Optional[str]:
pipeline = _get_pipeline_settings(book_id)
if _needs_volume(book_id):
return "volume" if pipeline.autoCoarse else None
if _needs_event_chain(book_id):
return "event_chain" if pipeline.autoEventPlan else None
if _needs_chapter_plan(book_id):
return "chapter_plan" if pipeline.autoEventPlan else None
if _needs_chapter(book_id):
return "chapter" if pipeline.autoChapter else None
return None
async def _run_pipeline(
book_id: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> None:
pipeline = _get_pipeline_settings(book_id)
try:
if _needs_volume(book_id):
if not pipeline.autoCoarse:
return
await ensure_volume(
book_id, profile_id=profile_id, api_config=api_config
)
if _needs_event_chain(book_id):
if not pipeline.autoEventPlan:
return
await ensure_event_chain(
book_id, profile_id=profile_id, api_config=api_config
)
if _needs_chapter_plan(book_id):
if not pipeline.autoEventPlan:
return
await ensure_chapter_plan(
book_id, profile_id=profile_id, api_config=api_config
)
if _needs_chapter(book_id):
if not pipeline.autoChapter:
return
await run_chapter(
book_id, profile_id=profile_id, api_config=api_config
)
if _pipeline_complete(book_id):
fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="ready"
)
except Exception:
logger.exception("Fiction pipeline failed for book %s", book_id)
finally:
async with _lock:
_active_tasks.pop(book_id, None)
async def _task_is_active(book_id: str) -> bool:
async with _lock:
task = _active_tasks.get(book_id)
return task is not None and not task.done()
async def _start_pipeline_task(
book_id: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> FictionPipelineTickResult:
run = fiction_metadata_service.get_run(book_id)
pending = get_pending_stages(book_id)
if run.status == "running":
if await _task_is_active(book_id):
return FictionPipelineTickResult(run=run, started=False, pendingStages=pending)
logger.warning("Stale running pipeline for book %s, resetting to idle", book_id)
run = fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage=run.pipelineStage
)
if run.status == "error":
if _pipeline_complete(book_id):
run = fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="ready"
)
return FictionPipelineTickResult(
run=run, started=False, pendingStages=pending
)
run = fiction_metadata_service.clear_pipeline_error(book_id)
if _pipeline_complete(book_id):
if run.pipelineStage != "ready":
run = fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="ready"
)
return FictionPipelineTickResult(run=run, started=False, pendingStages=pending)
next_stage = _next_auto_stage(book_id)
if not next_stage:
if run.pipelineStage not in (
None,
"ready",
"volume_done",
"event_chain_done",
"chapter_plan_done",
"chapter_done",
):
run = fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="ready"
)
return FictionPipelineTickResult(
run=run, started=False, pendingStages=pending
)
async with _lock:
existing = _active_tasks.get(book_id)
if existing and not existing.done():
run = fiction_metadata_service.get_run(book_id)
return FictionPipelineTickResult(
run=run, started=False, pendingStages=pending
)
fiction_metadata_service.set_pipeline_stage(
book_id, status="running", pipeline_stage=next_stage
)
task = asyncio.create_task(
_run_pipeline(
book_id, profile_id=profile_id, api_config=api_config
)
)
_active_tasks[book_id] = task
run = fiction_metadata_service.get_run(book_id)
pending = get_pending_stages(book_id)
return FictionPipelineTickResult(run=run, started=True, pendingStages=pending)
async def tick_reading_pipeline(
book_id: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> FictionPipelineTickResult:
"""检查 metadata + settings按需启动下一自动阶段。"""
return await _start_pipeline_task(
book_id, profile_id=profile_id, api_config=api_config
)
async def start_reading_pipeline(
book_id: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> FictionStartReadingResult:
"""进入阅读时的流水线入口(兼容旧接口)。"""
result = await tick_reading_pipeline(
book_id, profile_id=profile_id, api_config=api_config
)
return FictionStartReadingResult(run=result.run, started=result.started)
def get_pipeline_run(book_id: str) -> FictionRunState:
return fiction_metadata_service.get_run(book_id)

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

View File

@@ -0,0 +1,125 @@
"""
爽文提示词解析 — 用户自然语言 + 内部 JSON 输出格式(不暴露给前端)。
"""
from __future__ import annotations
from typing import Dict
# 新建书籍时的默认用户向提示(自然语言,不含 JSON 结构)
USER_DEFAULT_PROMPTS: Dict[str, str] = {
"openBook": (
"你是爽文开书优化助手。根据用户创作灵感,提炼书名、优化简介,"
"并生成主角人设、核心爽点、读者体验策略与创作禁区。"
"从情绪流目录中挑选 14 个最匹配的条目。"
),
"coarseOutline": (
"你是爽文大纲助手。根据本书设定与进度,生成事件链级别的粗纲,"
"每个事件包含标题与概要,节奏紧凑、爽点清晰。"
),
"eventPlan": (
"你是爽文事件规划助手。将粗纲中的事件展开为章节级计划,"
"结合情绪流起承转合,为每章规划核心冲突与爽点。"
),
"chapter": (
"你是爽文章节写作助手。根据事件计划、guide 设定与上文撰写正文,"
"节奏明快、对话推动冲突、章末留钩子。"
),
"nudge": (
"你是爽文创作教练。根据当前进度与读者体验目标,"
"给出 13 条简短的下一步写作建议,不直接写正文。"
),
}
# 调用 LLM 时在系统提示末尾追加的输出格式(用户 UI 不可见)
_INTERNAL_FORMAT: Dict[str, str] = {
"openBook": """
【输出格式】只输出 JSON不要 markdown 代码块外的文字:
{
"title": "书名",
"optimizedIntro": "优化后的开书灵感",
"guide": {
"persona": "主角人设:身份、性格、欲望、能力边界与成长方向",
"highlight": "核心爽点:本书最稳定兑现的爽点、打脸方式、升级/获得感",
"experience": "用户体验:视角/人称、听感、节奏、世界感与读者情绪承诺",
"forbiddenZones": "创作禁区:不能写、不能破坏、不能弱化的内容"
},
"allowedFlowIds": ["flow-id"]
}""",
"volumeOutline": """
【输出格式】只输出 JSON不要 markdown 代码块外的文字:
{
"id": "vol_001",
"order": 1,
"title": "卷名",
"goal": "本卷目标",
"coreConflict": "本卷核心冲突",
"powerProgression": "本卷成长/变化",
"emotionalPromise": "本卷情绪承诺",
"endingHook": "本卷结尾钩子",
"targetChapterCount": 20,
"primaryEmotionFlowId": "emotion-flow-id",
"status": "active"
}""",
"eventChain": """
【输出格式】只输出 JSON不要 markdown 代码块外的文字:
{
"events": [
{
"id": "evt_0001",
"volumeId": "vol_001",
"order": 1,
"title": "事件标题",
"summary": "事件概要",
"purpose": "事件作用",
"conflict": "事件冲突",
"turningPoint": "事件转折",
"expectedPayoff": "预期兑现",
"targetChapterCount": 3,
"emotionFlowId": "emotion-flow-id",
"emotionStepKey": "情感链步骤 key",
"emotionStepText": "情感链步骤 text",
"status": "planned"
}
]
}""",
"chapterPlan": """
【输出格式】只输出 JSON不要 markdown 代码块外的文字:
{
"chapterPlan": [
{
"title": "章标题",
"goal": "本章目标",
"opening": "开场内容",
"mainConflict": "本章主要冲突",
"emotionalTurn": "本章情绪变化",
"emotionStepKey": "情感链步骤 key",
"emotionGoal": "本章情绪目标",
"payoff": "本章兑现",
"endingHook": "章末信息",
"forbidden": "本章禁止事项",
"targetWords": 2000,
"status": "planned"
}
]
}""",
"chapter": """
【输出格式】只输出 JSON
{ "title": "章标题", "body": "正文(可分段)" }""",
"nudge": "",
}
def resolve_prompt(prompt_key: str, user_text: str | None) -> str:
"""合并用户自然语言指令与内部 JSON 输出格式,供 LLM 系统提示使用。"""
base = (user_text or "").strip()
if not base:
base = USER_DEFAULT_PROMPTS.get(prompt_key, "")
fmt = _INTERNAL_FORMAT.get(prompt_key, "")
if fmt and fmt.strip() not in base:
return base + fmt
return base

View 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()

View File

@@ -0,0 +1,185 @@
"""
爽文工作流 Tool 注册。
"""
from __future__ import annotations
import json
from typing import Any, Dict, Optional
try:
from backend.models.agent import TurnContext
from backend.services.fiction_chapter_service import run_chapter
from backend.services.fiction_coarse_service import run_coarse_outline
from backend.services.fiction_event_plan_service import run_event_plan
from backend.services.fiction_open_book_service import run_open_book
from backend.services.tool_registry import ToolRegistry
except ImportError:
from models.agent import TurnContext
from services.fiction_chapter_service import run_chapter
from services.fiction_coarse_service import run_coarse_outline
from services.fiction_event_plan_service import run_event_plan
from services.fiction_open_book_service import run_open_book
from services.tool_registry import ToolRegistry
async def fiction_open_book(ctx: TurnContext) -> None:
"""fiction.open_book — 根据用户灵感优化开书方案(不创建书籍目录)。"""
request = ctx.request_data or {}
inspiration = str(request.get("inspiration") or request.get("intro") or "").strip()
profile_id = request.get("profile_id") or request.get("profileId")
api_config = request.get("api_config") or request.get("apiConfig")
result = await run_open_book(
inspiration,
profile_id=profile_id,
api_config=api_config,
)
payload = result.model_dump()
ctx.request_data["fictionOpenBookResult"] = payload
ctx.generated_content = json.dumps(payload, ensure_ascii=False)
async def fiction_coarse(ctx: TurnContext) -> None:
"""fiction.coarse — 生成本书粗纲事件链,写入 metadata.json。"""
request = ctx.request_data or {}
book_id = str(request.get("book_id") or request.get("bookId") or "").strip()
if not book_id:
raise ValueError("book_id 不能为空")
profile_id = request.get("profile_id") or request.get("profileId")
api_config = request.get("api_config") or request.get("apiConfig")
result = await run_coarse_outline(
book_id,
profile_id=profile_id,
api_config=api_config,
)
payload = result.model_dump()
ctx.request_data["fictionCoarseResult"] = payload
ctx.generated_content = json.dumps(payload, ensure_ascii=False)
async def fiction_event_plan(ctx: TurnContext) -> None:
"""fiction.event_plan — 为粗纲事件生成 flowStepsPlan + chapterPlan。"""
request = ctx.request_data or {}
book_id = str(request.get("book_id") or request.get("bookId") or "").strip()
if not book_id:
raise ValueError("book_id 不能为空")
profile_id = request.get("profile_id") or request.get("profileId")
api_config = request.get("api_config") or request.get("apiConfig")
event_id = request.get("event_id") or request.get("eventId")
result = await run_event_plan(
book_id,
profile_id=profile_id,
api_config=api_config,
event_id=event_id,
)
payload = result.model_dump()
ctx.request_data["fictionEventPlanResult"] = payload
ctx.generated_content = json.dumps(payload, ensure_ascii=False)
async def fiction_chapter(ctx: TurnContext) -> None:
"""fiction.chapter — 根据 chapterPlan brief 撰写正文,写入 chapters/{seq}.json。"""
request = ctx.request_data or {}
book_id = str(request.get("book_id") or request.get("bookId") or "").strip()
if not book_id:
raise ValueError("book_id 不能为空")
profile_id = request.get("profile_id") or request.get("profileId")
api_config = request.get("api_config") or request.get("apiConfig")
seq = request.get("seq") or request.get("chapterSeq")
result = await run_chapter(
book_id,
profile_id=profile_id,
api_config=api_config,
seq=int(seq) if seq is not None else None,
)
payload = result.model_dump()
ctx.request_data["fictionChapterResult"] = payload
ctx.generated_content = json.dumps(payload, ensure_ascii=False)
def register_fiction_tools(registry: ToolRegistry) -> None:
registry.register(
"fiction.open_book",
fiction_open_book,
description="根据用户创作灵感优化爽文开书方案,返回 guide 草稿与推荐情绪流",
parameters={
"type": "object",
"properties": {
"inspiration": {"type": "string", "description": "用户创作灵感/简介"},
"profile_id": {"type": "string", "description": "API 配置 profile ID"},
"api_config": {
"type": "object",
"description": "可选 inline API 配置",
},
},
"required": ["inspiration"],
},
)
registry.register(
"fiction.coarse",
fiction_coarse,
description="根据本书 guide 与 L1 全局指南生成粗纲,写入 metadata.coarseOutline",
parameters={
"type": "object",
"properties": {
"book_id": {"type": "string", "description": "书籍 ID"},
"profile_id": {"type": "string", "description": "API 配置 profile ID"},
"api_config": {"type": "object", "description": "可选 inline API 配置"},
},
"required": ["book_id"],
},
)
registry.register(
"fiction.event_plan",
fiction_event_plan,
description="为粗纲事件随机选情绪流并生成 flowStepsPlan + chapterPlan",
parameters={
"type": "object",
"properties": {
"book_id": {"type": "string", "description": "书籍 ID"},
"event_id": {
"type": "string",
"description": "可选,仅规划指定粗纲事件;缺省则规划全部",
},
"profile_id": {"type": "string", "description": "API 配置 profile ID"},
"api_config": {"type": "object", "description": "可选 inline API 配置"},
},
"required": ["book_id"],
},
)
registry.register(
"fiction.chapter",
fiction_chapter,
description="根据 chapterPlan brief 撰写章节正文,写入 chapters 目录",
parameters={
"type": "object",
"properties": {
"book_id": {"type": "string", "description": "书籍 ID"},
"seq": {
"type": "integer",
"description": "可选,指定章节序号;缺省则写下一未写章",
},
"profile_id": {"type": "string", "description": "API 配置 profile ID"},
"api_config": {"type": "object", "description": "可选 inline API 配置"},
},
"required": ["book_id"],
},
)
# 模块加载时注册到默认 registry
try:
from services.tool_registry import default_tool_registry
register_fiction_tools(default_tool_registry)
except Exception:
pass