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
@@ -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"""## 全局创作指南(L0–L3)
{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)