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

76
AGENTS.md Normal file
View File

@@ -0,0 +1,76 @@
# AI Agent Guidance for this Repository
## Repository overview
- Backend: `backend/` using FastAPI, Python 3.11+, `uvicorn --reload` for development.
- Frontend: `frontend/` using React 18 + Vite + TypeScript, with Zustand for state.
- Runtime data is stored under `data/` as JSON/files; do not treat it as source code.
- Docker support exists via `docker-compose.yml` and `docs/DOCKER_DEV.md`.
## What an AI coding agent should do first
1. Read `README.md` and `docs/DOCKER_DEV.md` before proposing environment or run commands.
2. Identify whether a change belongs in `backend/` or `frontend/`.
3. Prefer small, incremental edits.
4. When in doubt, ask the user before making large refactors or architectural changes.
## Build / run commands
### Backend local
- `python -m venv venv`
- `venv\Scripts\activate` (Windows)
- `pip install -r backend/requirements.txt`
- `cd backend && python main.py`
### Frontend local
- `cd frontend`
- `npm install`
- `npm run dev`
### Docker development
- `.\scripts\docker-up.ps1`
- `.\scripts\docker-restart.ps1 -Service backend`
- `.\scripts\docker-rebuild.ps1 -Service backend`
- `.\scripts\docker-logs.ps1`
- `docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d`
## Testing and quality checks
- Backend tests live in `backend/tests/`.
- Use `python -m pytest backend/tests` for automated backend test runs.
- Frontend has lint/type-check scripts in `frontend/package.json`:
- `npm run lint`
- `npm run type-check`
- Prefer adding or updating tests for bug fixes, new features, and non-trivial behavior changes.
- Keep changes tidy and consistent with the repository's existing style.
## Code conventions and review preferences
- The user prefers code that is:
- 基本审查过的
- 规范整洁的
- 严谨测试覆盖的
- Do not perform sweeping refactors without explicit user approval.
- If a change affects core logic, clearly explain the reason and the hypothesis for the fix.
- For any change, state whether it is:
- bug fix
- cleanup/refactor
- feature addition
## Important paths and domains
- `backend/main.py` — FastAPI app entrypoint
- `backend/api/` — HTTP route definitions
- `backend/services/` — core business logic and domain services
- `backend/models/` — data models and converters
- `backend/utils/` — shared helper modules
- `frontend/src/` — React source code
- `frontend/package.json` — frontend scripts and dependencies
- `docs/DOCKER_DEV.md` — Docker development guidance
## Practical guidance for AI agents
- Avoid editing generated or runtime content in `data/` unless explicitly asked.
- Prefer changes that are easy to reason about and test.
- Use existing test tools rather than inventing new workflows.
- Link to repository docs instead of duplicating long explanations.
- If a requested change is uncertain, ask for clarification rather than guessing.
## References
- `README.md`
- `docs/DOCKER_DEV.md`
- `frontend/package.json`
- `backend/requirements.txt`

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute, charactersRoute, chatWsRoute, tokenUsageRoute, imageGalleryRoute, regexRoute, chatSummaryRoute, studioRoute
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute, charactersRoute, chatWsRoute, tokenUsageRoute, imageGalleryRoute, regexRoute, chatSummaryRoute, studioRoute, fictionRoute
from utils.file_utils import get_all_roles_and_chats
from core.config import settings
from pathlib import Path
@@ -19,6 +19,7 @@ router.include_router(imageGalleryRoute.router)
router.include_router(regexRoute.router)
router.include_router(chatSummaryRoute.router)
router.include_router(studioRoute.router)
router.include_router(fictionRoute.router)
# ✅ 注册 WebSocket 路由(必须在 HTTP 路由之后,避免路径冲突)
router.include_router(chatWsRoute.router)

View File

@@ -0,0 +1,439 @@
import json
import logging
from typing import List
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
import services.tools.fiction_tools # noqa: F401 — register fiction tools
from models.fiction_models import (
CreateFictionBookRequest,
EmotionFlowCatalog,
FictionBookMeta,
FictionBookMetadata,
FictionBookSettings,
FictionBookSummary,
FictionChapter,
FictionChapterSummary,
FictionGenerationRequest,
FictionGuideWorldbook,
FictionPipelineTickResult,
FictionRunState,
FictionStartReadingResult,
GuideGlobalEntries,
OpenBookRequest,
OpenBookResult,
UpdateFictionBookSettingsRequest,
UpdateFictionProgressRequest,
)
from services.fiction_chapter_service import ensure_chapter, run_chapter
from services.fiction_coarse_service import run_coarse_outline
from services.fiction_event_plan_service import (
iter_event_plan,
run_event_plan,
stream_event_plan_subscribe,
)
from services.fiction_metadata_service import fiction_metadata_service
from services.fiction_open_book_service import run_open_book
from services.fiction_planning_service import (
ensure_chapter_plan,
ensure_event_chain,
ensure_volume,
)
from services.fiction_orchestrator_service import (
get_pending_stages,
get_pipeline_run,
start_reading_pipeline,
tick_reading_pipeline,
)
from services.fiction_service import fiction_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/fiction", tags=["fiction"])
@router.get("/books", response_model=List[FictionBookSummary])
async def list_fiction_books():
try:
return fiction_service.list_books()
except Exception as e:
logger.error("Failed to list fiction books: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/books", response_model=FictionBookMeta)
async def create_fiction_book(req: CreateFictionBookRequest):
try:
return fiction_service.create_book(req)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileExistsError as e:
raise HTTPException(status_code=409, detail=str(e))
except Exception as e:
logger.error("Failed to create fiction book: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/books/{book_id}", response_model=FictionBookMeta)
async def get_fiction_book_meta(book_id: str):
try:
return fiction_service.get_book_meta(book_id)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to get fiction book %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/books/{book_id}")
async def delete_fiction_book(book_id: str):
try:
fiction_service.delete_book(book_id)
return {"ok": True}
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to delete fiction book %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/books/{book_id}/settings", response_model=FictionBookSettings)
async def get_fiction_book_settings(book_id: str):
try:
return fiction_service.get_book_settings(book_id)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to get settings for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.put("/books/{book_id}/settings", response_model=FictionBookSettings)
async def update_fiction_book_settings(
book_id: str, req: UpdateFictionBookSettingsRequest
):
try:
return fiction_service.update_book_settings(book_id, req)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to update settings for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/books/{book_id}/guide", response_model=FictionGuideWorldbook)
async def get_fiction_book_guide(book_id: str):
try:
return fiction_service.get_book_guide(book_id)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to get guide for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/emotion-flows/catalog", response_model=EmotionFlowCatalog)
async def get_emotion_flow_catalog():
try:
return fiction_service.get_emotion_catalog()
except Exception as e:
logger.error("Failed to get emotion catalog: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/guide-global/entries", response_model=GuideGlobalEntries)
async def get_guide_global_entries():
try:
return fiction_service.get_guide_global_entries()
except Exception as e:
logger.error("Failed to get guide global entries: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/open-book", response_model=OpenBookResult)
async def open_fiction_book(req: OpenBookRequest):
try:
return await run_open_book(
req.inspiration,
profile_id=req.profile_id,
api_config=req.api_config,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Failed to open fiction book: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/books/{book_id}/metadata", response_model=FictionBookMetadata)
async def get_fiction_book_metadata(book_id: str):
try:
return fiction_metadata_service.get_metadata(book_id)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to get metadata for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/books/{book_id}/run", response_model=FictionRunState)
async def get_fiction_book_run(book_id: str):
try:
return fiction_metadata_service.get_run(book_id)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to get run for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/books/{book_id}/start", response_model=FictionStartReadingResult)
async def start_fiction_reading(book_id: str, req: FictionGenerationRequest):
"""进入阅读时自动触发粗纲 / 事件纲要流水线(后台异步)。"""
try:
return await start_reading_pipeline(
book_id,
profile_id=req.profile_id,
api_config=req.api_config,
)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to start reading pipeline for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/books/{book_id}/pipeline/tick", response_model=FictionPipelineTickResult)
async def tick_fiction_pipeline(book_id: str, req: FictionGenerationRequest):
"""检查并推进流水线(尊重半自动设置)。"""
try:
return await tick_reading_pipeline(
book_id,
profile_id=req.profile_id,
api_config=req.api_config,
)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to tick pipeline for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/books/{book_id}/pipeline/status")
async def get_fiction_pipeline_status(book_id: str):
"""流水线 run 状态 + 待手动阶段列表。"""
try:
run = get_pipeline_run(book_id)
pending = get_pending_stages(book_id)
return {"run": run, "pendingStages": pending}
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to get pipeline status for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/books/{book_id}/volumes/ensure", response_model=FictionBookMetadata)
async def ensure_fiction_volume(book_id: str, req: FictionGenerationRequest):
try:
return await ensure_volume(
book_id,
profile_id=req.profile_id,
api_config=req.api_config,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to ensure volume for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/books/{book_id}/events/ensure", response_model=FictionBookMetadata)
async def ensure_fiction_event_chain(book_id: str, req: FictionGenerationRequest):
try:
return await ensure_event_chain(
book_id,
profile_id=req.profile_id,
api_config=req.api_config,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to ensure event chain for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/books/{book_id}/chapter-plans/ensure", response_model=FictionBookMetadata)
async def ensure_fiction_chapter_plan(book_id: str, req: FictionGenerationRequest):
try:
return await ensure_chapter_plan(
book_id,
event_id=req.event_id,
profile_id=req.profile_id,
api_config=req.api_config,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to ensure chapter plan for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/books/{book_id}/chapters/ensure", response_model=FictionChapter)
async def ensure_fiction_chapter(book_id: str, req: FictionGenerationRequest):
try:
return await ensure_chapter(
book_id,
profile_id=req.profile_id,
api_config=req.api_config,
seq=req.seq,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to ensure chapter for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/books/{book_id}/coarse-outline", response_model=FictionBookMetadata)
async def generate_coarse_outline(book_id: str, req: FictionGenerationRequest):
try:
return await ensure_volume(
book_id,
profile_id=req.profile_id,
api_config=req.api_config,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to ensure volume for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/books/{book_id}/event-plan", response_model=FictionBookMetadata)
async def generate_event_plan(book_id: str, req: FictionGenerationRequest):
if req.stream:
async def ndjson_stream():
try:
async for event in iter_event_plan(
book_id,
profile_id=req.profile_id,
api_config=req.api_config,
event_id=req.event_id,
):
yield json.dumps(event, ensure_ascii=False) + "\n"
except FileNotFoundError as e:
yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n"
except ValueError as e:
yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n"
except Exception as e:
logger.error("Failed to stream event plan for %s: %s", book_id, e)
yield json.dumps(
{"type": "error", "message": str(e)}, ensure_ascii=False
) + "\n"
return StreamingResponse(ndjson_stream(), media_type="application/x-ndjson")
try:
return await ensure_chapter_plan(
book_id,
event_id=req.event_id,
profile_id=req.profile_id,
api_config=req.api_config,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to generate event plan for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/books/{book_id}/event-plan/stream")
async def subscribe_event_plan_stream(book_id: str):
"""订阅事件纲要生成进度NDJSON适用于后台流水线已启动时。"""
try:
fiction_service.get_book_meta(book_id)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
async def ndjson_stream():
try:
async for event in stream_event_plan_subscribe(book_id):
yield json.dumps(event, ensure_ascii=False) + "\n"
except FileNotFoundError as e:
yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n"
except Exception as e:
logger.error("Failed to subscribe event plan stream for %s: %s", book_id, e)
yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n"
return StreamingResponse(ndjson_stream(), media_type="application/x-ndjson")
@router.get("/books/{book_id}/chapters", response_model=List[FictionChapterSummary])
async def list_fiction_chapters(book_id: str):
try:
return fiction_service.list_chapter_summaries(book_id)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to list chapters for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/books/{book_id}/chapters/{seq}", response_model=FictionChapter)
async def get_fiction_chapter(book_id: str, seq: int):
try:
return fiction_service.get_chapter(book_id, seq)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to get chapter %s for %s: %s", seq, book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/books/{book_id}/chapter", response_model=FictionChapter)
async def generate_fiction_chapter(book_id: str, req: FictionGenerationRequest):
"""撰写下一章或指定 seq 的章节(本地已存在则直接返回)。"""
try:
return await run_chapter(
book_id,
profile_id=req.profile_id,
api_config=req.api_config,
seq=req.seq,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to generate chapter for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/books/{book_id}/progress", response_model=FictionBookMetadata)
async def update_fiction_progress(book_id: str, req: UpdateFictionProgressRequest):
try:
return fiction_metadata_service.update_progress(
book_id,
current_chapter_seq=req.currentChapterSeq,
char_offset=req.charOffset,
)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to update progress for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -70,6 +70,14 @@ class Settings:
AGENT_NICHES_FILE = DATA_PATH / "agent" / "niches.json"
AGENT_WORKFLOW_VARIABLES_FILE = DATA_PATH / "agent" / "workflow_variables.json"
# 爽文Fiction / Novel数据目录
FICTION_PATH = DATA_PATH / "agent" / "fiction"
FICTION_EMOTION_FLOWS_PATH = FICTION_PATH / "emotion_flows"
FICTION_GUIDE_GLOBAL_PATH = FICTION_PATH / "guide_global"
FICTION_BOOKS_PATH = FICTION_PATH / "books"
FICTION_EMOTION_CATALOG_FILE = FICTION_EMOTION_FLOWS_PATH / "catalog.json"
FICTION_GUIDE_GLOBAL_ENTRIES_FILE = FICTION_GUIDE_GLOBAL_PATH / "entries.json"
def ensure_directories(self):
"""确保所有配置的目录存在,如果不存在则创建"""
directories = [
@@ -85,6 +93,10 @@ class Settings:
self.AGENT_RUNS_PATH,
self.AGENT_STUDIO_PROJECTS_PATH,
self.AGENT_STUDIO_RUNS_PATH,
self.FICTION_PATH,
self.FICTION_EMOTION_FLOWS_PATH,
self.FICTION_GUIDE_GLOBAL_PATH,
self.FICTION_BOOKS_PATH,
]
for directory in directories:
directory.mkdir(parents=True, exist_ok=True)

View File

@@ -0,0 +1,285 @@
"""
爽文Fiction / Novel数据模型。
"""
from __future__ import annotations
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
class EmotionFlowStep(BaseModel):
key: str
text: str
class EmotionFlow(BaseModel):
id: str
intro: str
tags: List[str] = Field(default_factory=list)
steps: List[EmotionFlowStep] = Field(default_factory=list)
class EmotionFlowCatalog(BaseModel):
flows: List[EmotionFlow] = Field(default_factory=list)
class GuideGlobalEntry(BaseModel):
layer: str
title: str
content: str
class GuideGlobalEntries(BaseModel):
entries: List[GuideGlobalEntry] = Field(default_factory=list)
class FictionGuideWorldbook(BaseModel):
"""Book-local guide 世界书:本书具体人设 / 爽点 / 用户体验 / 禁区。"""
persona: str = ""
highlight: str = ""
experience: str = ""
forbiddenZones: str = ""
class FictionBookMeta(BaseModel):
id: str
title: str
allowedFlowIds: List[str] = Field(default_factory=list)
createdAt: str = ""
updatedAt: str = ""
class FictionBookSummary(BaseModel):
id: str
title: str
allowedFlowIds: List[str] = Field(default_factory=list)
updatedAt: str = ""
class FictionPrompts(BaseModel):
openBook: str = ""
coarseOutline: str = ""
eventPlan: str = ""
chapter: str = ""
nudge: str = ""
class FictionReaderSettings(BaseModel):
contextWindowChars: int = 2000
prefetchRemainingWords: int = 300
class FictionPipelineSettings(BaseModel):
"""半自动流水线:各层 ON=自动OFF=需手动触发。"""
semiAuto: bool = False
autoCoarse: bool = True
autoEventPlan: bool = True
autoChapter: bool = True
class FictionBookSettings(BaseModel):
prompts: FictionPrompts = Field(default_factory=FictionPrompts)
reader: FictionReaderSettings = Field(default_factory=FictionReaderSettings)
pipeline: FictionPipelineSettings = Field(default_factory=FictionPipelineSettings)
class CreateFictionBookRequest(BaseModel):
title: str
inspiration: str = ""
guide: FictionGuideWorldbook
allowedFlowIds: List[str] = Field(default_factory=list)
class OpenBookRequest(BaseModel):
inspiration: str
profile_id: Optional[str] = None
api_config: Optional[Dict[str, str]] = None
class OpenBookResult(BaseModel):
title: str
optimizedIntro: str
guide: FictionGuideWorldbook
allowedFlowIds: List[str] = Field(default_factory=list)
class UpdateFictionBookSettingsRequest(BaseModel):
prompts: Optional[FictionPrompts] = None
reader: Optional[FictionReaderSettings] = None
pipeline: Optional[FictionPipelineSettings] = None
class FictionPipelineTickResult(BaseModel):
run: FictionRunState
started: bool = False
pendingStages: List[str] = Field(default_factory=list)
class VolumeOutline(BaseModel):
"""卷纲:当前 10~30 章的阶段规划,不是整本书全局大纲。"""
id: str
order: int = 1
title: str = ""
goal: str = ""
coreConflict: str = ""
powerProgression: str = ""
emotionalPromise: str = ""
endingHook: str = ""
targetChapterCount: int = 20
primaryEmotionFlowId: str = ""
status: str = "active"
class CoarseOutlineEvent(BaseModel):
"""兼容旧 coarseOutline.events同时作为新版事件串条目的轻量视图。"""
id: str
title: str
summary: str = ""
order: int = 1
volumeId: str = ""
class CoarseOutline(BaseModel):
events: List[CoarseOutlineEvent] = Field(default_factory=list)
version: int = 1
class EventChainItem(BaseModel):
"""事件串:卷纲 + 情感链在剧情层的落地。"""
id: str
volumeId: str = ""
order: int = 1
title: str = ""
summary: str = ""
purpose: str = ""
conflict: str = ""
turningPoint: str = ""
expectedPayoff: str = ""
targetChapterCount: int = 3
emotionFlowId: str = ""
emotionStepKey: str = ""
emotionStepText: str = ""
status: str = "planned"
class FlowStepsPlan(BaseModel):
: str = ""
: str = ""
: str = ""
: str = ""
class ChapterPlanItem(BaseModel):
seq: int
# 旧字段:保留以兼容现有 metadata.events[eventId].chapterPlan。
phaseKey: str = ""
phaseSlice: str = ""
brief: str = ""
# 新字段:章纲是写章前最具体的规划层。
eventId: str = ""
title: str = ""
goal: str = ""
opening: str = ""
mainConflict: str = ""
emotionalTurn: str = ""
emotionStepKey: str = ""
emotionGoal: str = ""
payoff: str = ""
endingHook: str = ""
forbidden: str = ""
targetWords: int = 2000
status: str = "planned"
class EventPlanEntry(BaseModel):
emotionFlowId: str = ""
flowStepsPlan: FlowStepsPlan = Field(default_factory=FlowStepsPlan)
chapterPlan: List[ChapterPlanItem] = Field(default_factory=list)
class FictionProgress(BaseModel):
currentChapterSeq: int = 0
charOffset: int = 0
ttsPaused: bool = False
genPaused: bool = False
class FictionChapter(BaseModel):
seq: int
title: str = ""
body: str = ""
charCount: int = 0
status: str = "written"
eventId: str = ""
phaseKey: str = ""
createdAt: str = ""
class FictionChapterSummary(BaseModel):
seq: int
title: str = ""
charCount: int = 0
eventId: str = ""
phaseKey: str = ""
class FictionBookMetadata(BaseModel):
# v2 新规划结构:不再保留旧 coarseOutline/events 作为持久化主结构。
version: int = 2
volumes: List[VolumeOutline] = Field(default_factory=list)
eventChains: Dict[str, List[EventChainItem]] = Field(default_factory=dict)
chapterPlans: Dict[str, List[ChapterPlanItem]] = Field(default_factory=dict)
progress: FictionProgress = Field(default_factory=FictionProgress)
class FictionRunState(BaseModel):
status: str = "idle"
pipelineStage: Optional[str] = None
stage: str = "idle"
message: Optional[str] = None
progress: Optional[Dict[str, int]] = None
updatedAt: str = ""
class FictionStartReadingResult(BaseModel):
run: FictionRunState
started: bool = False
class FictionGenerationRequest(BaseModel):
profile_id: Optional[str] = None
api_config: Optional[Dict[str, str]] = None
event_id: Optional[str] = None
seq: Optional[int] = None
stream: bool = False
class UpdateFictionProgressRequest(BaseModel):
currentChapterSeq: Optional[int] = None
charOffset: Optional[int] = None
ttsPaused: Optional[bool] = None
genPaused: Optional[bool] = None
class FictionPrefetchRequest(BaseModel):
profile_id: Optional[str] = None
api_config: Optional[Dict[str, str]] = None
currentChapterSeq: int
charOffset: int = 0
remainingWords: Optional[int] = None
class FictionPrefetchResult(BaseModel):
run: FictionRunState
started: bool = False
targetSeq: Optional[int] = None
skippedReason: Optional[str] = None

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

View File

@@ -0,0 +1,6 @@
{
"persona": "主角表面温文尔雅、恪守使者礼节,实则杀伐果断、胸怀大格局。受辱时隐忍记账,关键节点一击致命。拥有超越时代的信息差与历史推演金手指,善于借势与布局,从单枪匹马到建立西域都护府权威。",
"highlight": "1. 身份反差所有人都当他是落魄流民直到汉节与国书亮相震惊全场。2. 文明降维打击用冶铁、造纸、兵法碾压西域各方势力。3. 外交爽文舌战群胡、以一人退一国之兵。4. 势力养成:收服三十六国,在异域复刻大汉盛世。",
"experience": "第三人称有限视角跟随主角,初期通过旁观者的鄙夷积蓄压抑,中后期在亮身份、展实力时拉远镜头,放大旁观者的跪服与匈奴使者的恐惧,形成反复打脸爽感。每场外交冲突都按铺垫→加压→以汉威逆转的结构推进。",
"forbiddenZones": "禁止主角长期忍气吞声无所作为、禁止汉使身份被长期误解不开封、禁止面对胡人欺辱时以德报怨挫折控制在1章内且必须立刻给出明确反击预期。"
}

View File

@@ -0,0 +1,11 @@
{
"id": "我是汉使-谁敢不敬",
"title": "我是汉使,谁敢不敬",
"allowedFlowIds": [
"power-reveal",
"face-slap-rise",
"alliance-dominate"
],
"createdAt": "2026-06-01T10:36:45.755451",
"updatedAt": "2026-06-01T10:56:58.330409"
}

View File

@@ -0,0 +1,120 @@
{
"coarseOutline": {
"events": [
{
"id": "evt-1",
"title": "流落楼兰,身份的蛰伏",
"summary": "主角衣衫褴褛抵达楼兰城外,被守城胡兵当成逃难流民肆意驱赶羞辱。主角冷眼观察局势,暗中记录下所有侮辱,等待时机。入城后被安置在最低贱的商贾聚集区。",
"order": 1
},
{
"id": "evt-2",
"title": "匈奴使团嚣张登场,压抑升级",
"summary": "匈奴特使率领百骑使团入城,楼兰王卑躬屈膝迎接。匈奴使者在市集当众嘲笑汉人弱如羔羊,主角被推搡却不动声色,只展露些许冶铁知识换取铁匠铺收留,埋下反击伏笔。",
"order": 2
},
{
"id": "evt-3",
"title": "纸刀初试,小胜立威",
"summary": "匈奴人再次当众侮辱主角,逼其钻胯。主角以“教你们写汉字”为赌约,用刚造出的粗糙纸张和断笔,当众写下檄文,暗讽匈奴无文。贵族们对纸惊为神物,主角赢得楼兰大商人的庇护,匈奴使吃瘪离去。",
"order": 3
},
{
"id": "evt-4",
"title": "王宫夜宴亮汉节,身份反转",
"summary": "楼兰王宴请匈奴使,主角被大商贾带入宴席。匈奴使阁楼内强迫楼兰王交出“汉人奸细”处死,主角缓步出列,手捧封尘汉节,展开黄绫国书,朗声道:“大汉使臣张明,持节出使西域三十六国,谁敢不敬?”全场死寂,匈奴使脸色煞白。",
"order": 4
},
{
"id": "evt-5",
"title": "斩杀匈奴使,楼兰臣服",
"summary": "匈奴使强辩汉节是伪造,命令护卫拿下主角。主角喝破楼兰王昔日杀汉使将招致灭国之祸,并当场宣读大汉讨罪檄文。在楼兰王犹豫之际,主角以迅雷之势拔出随从暗藏环首刀,亲手斩杀匈奴正使,威震大殿。楼兰王跪伏,愿为汉属。",
"order": 5
},
{
"id": "evt-6",
"title": "以寡敌众,冶铁骑兵显威",
"summary": "匈奴副使逃出带领城外百骑反扑,主角早有准备,调遣楼兰城卫,并用改良的冶铁马蹄铁和简易马镫装备了二十人骑兵小队,正面冲垮毫无防备的匈奴骑兵。一战斩首七十,俘虏三十,彻底打垮楼兰境内匈奴势力。",
"order": 6
},
{
"id": "evt-7",
"title": "楼兰盟约,都护雏形",
"summary": "主角与楼兰王歃血为盟,设立汉使常驻衙门,推行简易汉律保护商路。同时放出流言:大汉西域都护府即将设立,归附者可得冶铁、造纸之术。周边小国开始遣使试探。",
"order": 7
},
{
"id": "evt-8",
"title": "车师设局,舌战降服",
"summary": "车师国受匈奴蛊惑扣押汉商队,主角仅带十骑赴会。在王庭之上,车师王列甲士恐吓,主角从容陈说匈奴败亡之势,并以楼兰之变震慑,允诺开放贸易和冶铁秘法。车师贵族分裂,最终斩杀亲匈奴大臣,迎汉使入驻。",
"order": 8
},
{
"id": "evt-9",
"title": "三十六国初盟,匈奴单于震怒",
"summary": "半年间,主角借商业和文明技术输出,接连与十余国结盟。一次盟会上,主角正式打出“大汉西域都护府”旗号,诸国共尊汉使为都护。消息传到漠北,匈奴单于怒极,下令三万铁骑西征,誓要血洗西域汉势力。",
"order": 9
},
{
"id": "evt-10",
"title": "大漠烽烟,以少胜多",
"summary": "主角利用信息差,提前获知匈奴行军路线,以兵法“围点打援”诱敌深入。集结诸国联军八千,在干涸河床设伏,用改良的硬弩和连环马阵正面击溃匈奴前锋,再以火攻断其辎重。匈奴单于亲率的中军溃败,折损过半,仓皇东逃。",
"order": 10
},
{
"id": "evt-11",
"title": "丝路重开,万邦来朝",
"summary": "主角将缴获的匈奴单于金箭与捷报一同传往长安。汉武帝大喜,正式册封主角为西域都护,授权统管西域。丝绸之路全线贯通,大汉商队、工匠、文人涌入西域,诸国争相效仿汉制。主角立于都护府高台,俯瞰一片繁华,当年那些羞辱过他的人早已化为尘埃。",
"order": 11
}
],
"version": 1
},
"events": {
"evt-1": {
"emotionFlowId": "alliance-dominate",
"flowStepsPlan": {
"起": "主角孤身抵达楼兰城外,衣着破烂,人脉与资源为零。被守城胡兵当作流民百般羞辱驱赶,主角隐忍观察,暗中记录所有折辱者面孔与背景,最终被丢到最低贱的商贾聚集区。此时主角处于绝对弱势,积蓄着对胡人傲慢的认识与反击的渴望。",
"承": "在商贾区,主角利用超越时代的知识小露锋芒,比如用古法提纯井盐、鉴别劣质铁器。这些举动引起了几类人的注意:落魄的本地护卫、被排挤的西域小商贩,以及楼兰城中一名失势贵族管家。主角刻意展示出自己‘虽落魄却有秘术’的价值,为后续结盟埋下伏笔。",
"转": "楼兰城中某个小权贵欲吞占商贾区,设局陷害聚集区商户。主角在暗中洞悉阴谋,指使刚结交的护卫提前揭露,并在众人面前以智谋反制,让权贵当众出丑。此事令那失势贵族管家背后的小主子意识到主角不凡,主动邀约,主角以‘各取所需’的姿态赢得其暂时效忠,完成第一次关键人收服。",
"合": "主角以这失势贵族为跳板,在商贾区建立初步的情报网络,收服了第一批追随者——护卫担任贴身力量,小商贩负责打探消息,失势贵族提供身份掩护。虽然依旧隐藏汉使身份,但一个以他为核心的微型势力雏形已在楼兰底层成型,为后续朝堂亮相、收服三十六国埋下暗线。"
},
"chapterPlan": [
{
"seq": 1,
"phaseKey": "起",
"phaseSlice": "全",
"brief": "主角衣衫褴褛到楼兰城下,被胡兵当难民拖拽驱赶、吐口水羞辱,他一言不发冷眼观察。城门口,一名胡商因货物被刁难,主角用简单西域话帮其解围,展露冷静思维。最终被丢入最混乱的商贾区,在破棚里用手刻下第一个仇人名字。爽点:冷静隐忍与记账的伏笔,通过旁人对‘这个乞丐居然会西域话’的诧异铺垫反差。",
"status": "planned"
},
{
"seq": 2,
"phaseKey": "承",
"phaseSlice": "全",
"brief": "主角以替人写书信、鉴别货物真伪在商贾区站稳脚跟,用提纯粗盐的方法让一名小贩获利三倍,引发小轰动。落魄护卫昆图目睹后主动试探,主角故意露一手卸骨擒拿术震慑对方。同时引起失势贵族之管家注意。爽点:文明降维打击——人人当他是流民,他却随手点石成金,周围人从轻蔑转为巴结。",
"status": "planned"
},
{
"seq": 3,
"phaseKey": "转",
"phaseSlice": "全",
"brief": "小权贵古尔贡欲用‘盗窃军马’罪名强占商贾区,抓走两名商贩。主角让昆图护住证人,自己通过管家带话给失势贵族,以三句话点破古尔贡布局的漏洞。在古尔贡带兵来封市时,主角当众拆穿伪造证据,并反手指控其勾结马贼,围观胡人从嘲弄变为惊惧。古尔贡被当街打脸,失势贵族公开表态庇护商圈。爽点:以一介贱民之身,翻手间让权贵灰头土脸,旁观者倒戈,首次展示翻云覆雨的智谋。",
"status": "planned"
},
{
"seq": 4,
"phaseKey": "合",
"phaseSlice": "全",
"brief": "事后失势贵族幼主密邀主角,试探其来历。主角只展露汉节一角却不宣明身份,提出互利之约:贵族借主角之智复起,主角借贵族之便铺开暗棋。昆图宣誓效忠,小贩们主动成为眼线。主角在商贾区租下一处院落,挂上破损卦幡,以此为据点开始收集三十六国情报。爽点:收服班底、势力雏形确立,身份依旧成谜但威势已成,为下一阶段亮明汉使身份蓄满张力。",
"status": "planned"
}
]
}
},
"progress": {
"currentChapterSeq": 0,
"charOffset": 0,
"ttsPaused": false,
"genPaused": false
}
}

View File

@@ -0,0 +1,11 @@
{
"status": "error",
"pipelineStage": "event_plan",
"stage": "error",
"message": "事件纲要生成失败",
"progress": {
"done": 0,
"total": 11
},
"updatedAt": "2026-06-01T11:56:27.977792"
}

View File

@@ -0,0 +1,13 @@
{
"prompts": {
"openBook": "你是爽文开书优化助手。根据用户提供的创作灵感,输出结构化的开书方案。\n\n要求\n1. 提炼并优化用户灵感,使其更适合网文爽文节奏。\n2. 生成 guide 世界书草稿persona主角人设、highlight核心爽点、experience读者体验/视角策略、forbiddenZones创作禁区。\n3. 从提供的情绪流 catalog 中挑选 14 个最匹配的 flow id 写入 allowedFlowIds。\n4. 建议一个简洁有力的书名。\n\n只输出 JSON不要 markdown 代码块外的文字:\n{\n \"title\": \"书名\",\n \"optimizedIntro\": \"优化后的开书灵感\",\n \"guide\": {\n \"persona\": \"...\",\n \"highlight\": \"...\",\n \"experience\": \"...\",\n \"forbiddenZones\": \"...\"\n },\n \"allowedFlowIds\": [\"flow-id\"]\n}",
"coarseOutline": "你是爽文大纲助手。根据当前书籍设定与进度,生成/修订粗纲(事件链级别,非细章)。\n\n输出 JSON\n{\n \"events\": [\n { \"id\": \"evt-1\", \"title\": \"事件标题\", \"summary\": \"事件概要\", \"emotionFlowId\": \"可选\" }\n ],\n \"version\": 1\n}",
"eventPlan": "你是爽文事件规划助手。将粗纲中的某个事件展开为可执行的章节级计划。\n\n输出 JSON包含章节序号建议、每章核心冲突与爽点类型。",
"chapter": "你是爽文章节写作助手。根据当前事件计划、guide 设定与上文,撰写本章正文。\n\n要求节奏明快、对话推动冲突、每章末尾留钩子。输出 JSON\n{ \"title\": \"章标题\", \"body\": \"正文(可分段)\" }",
"nudge": "你是爽文创作教练。根据当前进度与读者体验目标,给出 13 条简短的下一步写作建议(不直接写正文)。"
},
"reader": {
"contextWindowChars": 2000,
"prefetchRemainingWords": 300
}
}

View File

@@ -0,0 +1,59 @@
{
"flows": [
{
"id": "face-slap-rise",
"intro": "经典打脸逆袭:先抑后扬,让读者在主角翻盘点获得强烈爽感。",
"tags": ["打脸", "逆袭", "装逼"],
"steps": [
{ "key": "起", "text": "主角被轻视、被嘲讽,处境处于低谷,埋下伏笔。" },
{ "key": "承", "text": "矛盾升级,对手步步紧逼,读者情绪被压抑到极点。" },
{ "key": "转", "text": "主角展露真实实力或底牌,局势开始逆转。" },
{ "key": "合", "text": "当众打脸,对手颜面尽失,主角收获声望与资源。" }
]
},
{
"id": "treasure-upgrade",
"intro": "奇遇升级流:获得宝物/传承后实力跃迁,节奏明快。",
"tags": ["奇遇", "升级", "宝物"],
"steps": [
{ "key": "起", "text": "主角陷入险境或瓶颈,看似无路可走。" },
{ "key": "承", "text": "意外触发隐藏机缘,获得线索或残缺传承。" },
{ "key": "转", "text": "完成试炼或解开封印,实力/境界突破。" },
{ "key": "合", "text": "以新力量解决眼前危机,并留下更大悬念。" }
]
},
{
"id": "power-reveal",
"intro": "扮猪吃虎:隐藏身份或实力,在关键时刻一鸣惊人。",
"tags": ["扮猪吃虎", "身份", "震惊"],
"steps": [
{ "key": "起", "text": "主角以弱者/凡人形象出现,被各方忽视。" },
{ "key": "承", "text": "敌人或路人持续挑衅,形成对比张力。" },
{ "key": "转", "text": "危机时刻主角不再隐藏,展露真实层次。" },
{ "key": "合", "text": "全场震惊,先前嘲讽者态度一百八十度转变。" }
]
},
{
"id": "alliance-dominate",
"intro": "势力扩张:收服强者、建立势力,格局由小变大。",
"tags": ["势力", "收服", "格局"],
"steps": [
{ "key": "起", "text": "主角孤身或小团队,资源与人脉有限。" },
{ "key": "承", "text": "展现价值或魅力,引起潜在盟友/强者的注意。" },
{ "key": "转", "text": "通过实力或智谋赢得关键人物认可。" },
{ "key": "合", "text": "势力雏形确立,为下一阶段大事件铺垫。" }
]
},
{
"id": "revenge-climax",
"intro": "复仇清算:旧怨新仇一并了结,情绪释放型高潮。",
"tags": ["复仇", "清算", "高潮"],
"steps": [
{ "key": "起", "text": "回忆旧怨,明确复仇对象与动机。" },
{ "key": "承", "text": "对手仍嚣张或以为主角不足为惧。" },
{ "key": "转", "text": "主角布局收网,切断对手退路。" },
{ "key": "合", "text": "当众清算,恩怨了结,读者情绪得到释放。" }
]
}
]
}

View File

@@ -0,0 +1,44 @@
{
"entries": [
{
"layer": "L0",
"title": "爽文基本节奏",
"content": "每章需有明确的情绪推进:铺垫→加压→释放。避免长时间无爽点的水文;小爽点每 8001500 字,大爽点每 35 章。"
},
{
"layer": "L0",
"title": "读者预期管理",
"content": "开书前 3 章必须建立核心卖点(金手指/身份差/仇恨对象)。让读者知道「这本书承诺给我什么爽感」。"
},
{
"layer": "L1",
"title": "主角行为准则",
"content": "主角可以低调但不能窝囊;遇辱必报、有仇必记,但报复需有层次(先小胜再大胜)。避免圣母式原谅削弱爽感。"
},
{
"layer": "L1",
"title": "对手设计",
"content": "反派/对手需有足够嚣张资本与明确动机;被打脸前要让读者足够讨厌他们。避免脸谱化到无法代入。"
},
{
"layer": "L2",
"title": "信息投放",
"content": "世界观与力量体系采用「冰山法则」:每次只揭示与当前冲突相关的信息。悬念优于说明书式设定堆砌。"
},
{
"layer": "L2",
"title": "对话与描写比例",
"content": "冲突场景多用短句对话推进;升级/打脸瞬间可加入 12 句环境或旁观者反应放大爽感,但避免冗长旁白。"
},
{
"layer": "L3",
"title": "用户体验(视角/人称)",
"content": "默认第三人称有限视角跟随主角;关键爽点可短暂拉远至旁观者视角以放大震惊效果。人称切换需有明确叙事目的,避免混乱。"
},
{
"layer": "L3",
"title": "禁区与雷点",
"content": "避免 NTR、主角长期受虐无反击、重要角色无理由降智。若需挫折控制在 12 章内并给出明确反击预期。"
}
]
}

View File

@@ -0,0 +1,15 @@
{
"id": "c529576bd6de40969648cf80a3780db9",
"template_id": "builtin.chat",
"binding": {
"role_name": "帝国骑士维尔",
"chat_name": "默认聊天",
"template_id": "builtin.chat"
},
"status": "failed",
"started_at": "2026-05-30T18:20:55.872092",
"finished_at": "2026-05-30T18:21:31.857209",
"current_state": "regex_apply_ai_output",
"result_content": "",
"error": "'<' not supported between instances of 'int' and 'NoneType'"
}

View File

@@ -0,0 +1,10 @@
{
"id": "test-r1-72cd5b4b",
"name": "test-r1-72cd5b4b",
"description": "从创建绑定到世界书条目的默认三步流水线,可在工作流编辑页复制并修改。",
"templateId": "builtin.studio.example",
"characterId": "b0992d14-cb7c-4d81-b0af-7f7434710903",
"worldbookId": "e26e5d15-5b87-4693-aa47-f3021fbdc3d7",
"createdAt": "2026-05-31T14:58:54.635290",
"updatedAt": "2026-05-31T14:58:54.656107"
}

View File

@@ -0,0 +1,118 @@
{
"workflowGoal": "设计一个单人角色:先绑定角色卡与世界书,再迭代整体美学与具体人设,写入世界书条目。",
"nodes": [
{
"id": "init",
"skillId": "studio.init_bind",
"displayName": "创建并绑定",
"enabled": true,
"config": {},
"displayParams": [
{
"key": "characterName",
"label": "角色卡名称",
"type": "text",
"required": true,
"placeholder": ""
},
{
"key": "worldbookName",
"label": "世界书名称",
"type": "text",
"required": true,
"placeholder": ""
}
],
"inputs": []
},
{
"id": "aesthetic",
"skillId": "studio.worldbook_entry",
"displayName": "整体美学",
"niche": "aesthetic_tone",
"enabled": true,
"loopUntilSatisfied": true,
"config": {
"stepGoal": "产出角色的整体美学设定:视觉风格、氛围、叙事基调,供后续人设步骤引用。",
"thinkingPrompt": "",
"insertion": {
"position": 1,
"activationType": "permanent",
"key": "整体美学",
"comment": "Studio · 整体美学"
},
"scoring": {
"enabled": true,
"dimensions": [
{
"id": "authenticity",
"name": "真实性",
"criteria": "美学设定是否自洽、可感知,而非空泛形容词堆砌。"
},
{
"id": "fit",
"name": "贴合度",
"criteria": "是否覆盖色调/材质/氛围/叙事基调;是否可与后续人设衔接;表述是否简洁可注入世界书。"
}
]
}
},
"displayParams": [],
"inputs": [
{
"ref": "workflow.goal",
"label": "工作流目标文本"
}
]
},
{
"id": "persona",
"skillId": "studio.worldbook_entry",
"displayName": "具体人设",
"niche": "persona_detail",
"enabled": true,
"loopUntilSatisfied": true,
"config": {
"stepGoal": "在整体美学基础上,写出可扮演的人设:性格、动机、口癖、关系与行为模式。",
"thinkingPrompt": "",
"insertion": {
"position": 1,
"activationType": "keyword",
"key": "具体人设",
"comment": "Studio · 具体人设"
},
"scoring": {
"enabled": true,
"dimensions": [
{
"id": "authenticity",
"name": "真实性",
"criteria": "人设细节是否具体、可扮演,动机与行为是否一致。"
},
{
"id": "fit",
"name": "贴合度",
"criteria": "是否与人设目标一致;是否与整体美学一致;是否避免与已有条目冲突。"
}
]
}
},
"displayParams": [],
"inputs": [
{
"ref": "workflow.goal",
"label": "工作流目标文本"
},
{
"ref": "aesthetic.output",
"label": "整体美学 · 上轮产物"
},
{
"ref": "persona.output",
"label": "具体人设 · 上轮产物",
"optional": true
}
]
}
]
}

View File

@@ -0,0 +1,10 @@
{
"id": "新项目",
"name": "新项目",
"description": "从创建绑定到世界书条目的默认三步流水线,可在工作流编辑页复制并修改。",
"templateId": "builtin.studio.example",
"characterId": null,
"worldbookId": null,
"createdAt": "2026-05-30T19:57:24.114210",
"updatedAt": "2026-05-30T19:57:24.114210"
}

View File

@@ -0,0 +1,83 @@
{
"workflowGoal": "设计一个单人角色:先绑定角色卡与世界书,再迭代整体美学与具体人设,写入世界书条目。",
"nodes": [
{
"id": "init",
"skillId": "studio.init_bind",
"displayName": "创建并绑定",
"enabled": true,
"config": {},
"displayParams": [
{
"key": "characterName",
"label": "角色卡名称",
"type": "text",
"required": true,
"placeholder": ""
},
{
"key": "worldbookName",
"label": "世界书名称",
"type": "text",
"required": true,
"placeholder": ""
}
],
"inputs": []
},
{
"id": "aesthetic",
"skillId": "studio.worldbook_entry",
"displayName": "整体美学",
"niche": "aesthetic_tone",
"enabled": true,
"loopUntilSatisfied": true,
"config": {
"stepGoal": "产出角色的整体美学设定:视觉风格、氛围、叙事基调,供后续人设步骤引用。",
"insertion": {
"position": 4,
"activationType": "normal",
"key": "整体美学",
"comment": "Studio · 整体美学"
},
"scoring": {
"enabled": true,
"rubric": "是否覆盖色调/材质/氛围/叙事基调;是否可与后续人设衔接;表述是否简洁可注入世界书。"
}
},
"displayParams": [],
"inputs": []
},
{
"id": "persona",
"skillId": "studio.worldbook_entry",
"displayName": "具体人设",
"niche": "persona_detail",
"enabled": true,
"loopUntilSatisfied": true,
"config": {
"stepGoal": "在整体美学基础上,写出可扮演的人设:性格、动机、口癖、关系与行为模式。",
"insertion": {
"position": 4,
"activationType": "normal",
"key": "具体人设",
"comment": "Studio · 具体人设"
},
"scoring": {
"enabled": true,
"rubric": "是否与人设目标一致;是否与整体美学一致;是否具备可扮演细节;是否避免与已有条目冲突。"
}
},
"displayParams": [],
"inputs": [
{
"ref": "aesthetic.output"
},
{
"ref": "persona.output",
"optional": true
}
]
}
]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,161 @@
{
"id": "6f4d68e5-f2cf-470d-a9f0-740c1403495f",
"projectId": "test-r1-72cd5b4b",
"status": "running",
"pipelineSnapshot": {
"workflowGoal": "设计一个单人角色:先绑定角色卡与世界书,再迭代整体美学与具体人设,写入世界书条目。",
"nodes": [
{
"id": "init",
"skillId": "studio.init_bind",
"displayName": "创建并绑定",
"enabled": true,
"niche": null,
"loopUntilSatisfied": false,
"config": {},
"displayParams": [
{
"key": "characterName",
"label": "角色卡名称",
"type": "text",
"required": true,
"placeholder": ""
},
{
"key": "worldbookName",
"label": "世界书名称",
"type": "text",
"required": true,
"placeholder": ""
}
],
"inputs": []
},
{
"id": "aesthetic",
"skillId": "studio.worldbook_entry",
"displayName": "整体美学",
"enabled": true,
"niche": "aesthetic_tone",
"loopUntilSatisfied": true,
"config": {
"stepGoal": "产出角色的整体美学设定:视觉风格、氛围、叙事基调,供后续人设步骤引用。",
"thinkingPrompt": "",
"insertion": {
"position": 1,
"activationType": "permanent",
"key": "整体美学",
"comment": "Studio · 整体美学"
},
"scoring": {
"enabled": true,
"dimensions": [
{
"id": "authenticity",
"name": "真实性",
"criteria": "美学设定是否自洽、可感知,而非空泛形容词堆砌。"
},
{
"id": "fit",
"name": "贴合度",
"criteria": "是否覆盖色调/材质/氛围/叙事基调;是否可与后续人设衔接;表述是否简洁可注入世界书。"
}
]
}
},
"displayParams": [],
"inputs": [
{
"ref": "workflow.goal",
"label": "工作流目标文本",
"optional": false
}
]
},
{
"id": "persona",
"skillId": "studio.worldbook_entry",
"displayName": "具体人设",
"enabled": true,
"niche": "persona_detail",
"loopUntilSatisfied": true,
"config": {
"stepGoal": "在整体美学基础上,写出可扮演的人设:性格、动机、口癖、关系与行为模式。",
"thinkingPrompt": "",
"insertion": {
"position": 1,
"activationType": "keyword",
"key": "具体人设",
"comment": "Studio · 具体人设"
},
"scoring": {
"enabled": true,
"dimensions": [
{
"id": "authenticity",
"name": "真实性",
"criteria": "人设细节是否具体、可扮演,动机与行为是否一致。"
},
{
"id": "fit",
"name": "贴合度",
"criteria": "是否与人设目标一致;是否与整体美学一致;是否避免与已有条目冲突。"
}
]
}
},
"displayParams": [],
"inputs": [
{
"ref": "workflow.goal",
"label": "工作流目标文本",
"optional": false
},
{
"ref": "aesthetic.output",
"label": "整体美学 · 上轮产物",
"optional": false
},
{
"ref": "persona.output",
"label": "具体人设 · 上轮产物",
"optional": true
}
]
}
]
},
"pipelineVersionNote": "2026-05-31T11:24:30.222456",
"currentNodeId": "init",
"nodeStates": [
{
"nodeId": "init",
"displayName": "创建并绑定",
"skillId": "studio.init_bind",
"status": "active",
"loopUntilSatisfied": false,
"lastDraft": null
},
{
"nodeId": "aesthetic",
"displayName": "整体美学",
"skillId": "studio.worldbook_entry",
"status": "pending",
"loopUntilSatisfied": true,
"lastDraft": null
},
{
"nodeId": "persona",
"displayName": "具体人设",
"skillId": "studio.worldbook_entry",
"status": "pending",
"loopUntilSatisfied": true,
"lastDraft": null
}
],
"workflowVariables": {
"workflow.goal": "设计一个单人角色:先绑定角色卡与世界书,再迭代整体美学与具体人设,写入世界书条目。"
},
"createdAt": "2026-05-31T11:24:30.222456",
"updatedAt": "2026-05-31T11:24:30.222456"
}

View File

@@ -0,0 +1,172 @@
{
"id": "c70b04fb-f793-4a5c-a687-56466aa95a4e",
"projectId": "test-r1-72cd5b4b",
"status": "running",
"pipelineSnapshot": {
"workflowGoal": "设计一个单人角色:先绑定角色卡与世界书,再迭代整体美学与具体人设,写入世界书条目。",
"nodes": [
{
"id": "init",
"skillId": "studio.init_bind",
"displayName": "创建并绑定",
"enabled": true,
"niche": null,
"loopUntilSatisfied": false,
"config": {},
"displayParams": [
{
"key": "characterName",
"label": "角色卡名称",
"type": "text",
"required": true,
"placeholder": ""
},
{
"key": "worldbookName",
"label": "世界书名称",
"type": "text",
"required": true,
"placeholder": ""
}
],
"inputs": []
},
{
"id": "aesthetic",
"skillId": "studio.worldbook_entry",
"displayName": "整体美学",
"enabled": true,
"niche": "aesthetic_tone",
"loopUntilSatisfied": true,
"config": {
"stepGoal": "产出角色的整体美学设定:视觉风格、氛围、叙事基调,供后续人设步骤引用。",
"thinkingPrompt": "",
"insertion": {
"position": 1,
"activationType": "permanent",
"key": "整体美学",
"comment": "Studio · 整体美学"
},
"scoring": {
"enabled": true,
"dimensions": [
{
"id": "authenticity",
"name": "真实性",
"criteria": "美学设定是否自洽、可感知,而非空泛形容词堆砌。"
},
{
"id": "fit",
"name": "贴合度",
"criteria": "是否覆盖色调/材质/氛围/叙事基调;是否可与后续人设衔接;表述是否简洁可注入世界书。"
}
]
}
},
"displayParams": [],
"inputs": [
{
"ref": "workflow.goal",
"label": "工作流目标文本",
"optional": false
}
]
},
{
"id": "persona",
"skillId": "studio.worldbook_entry",
"displayName": "具体人设",
"enabled": true,
"niche": "persona_detail",
"loopUntilSatisfied": true,
"config": {
"stepGoal": "在整体美学基础上,写出可扮演的人设:性格、动机、口癖、关系与行为模式。",
"thinkingPrompt": "",
"insertion": {
"position": 1,
"activationType": "keyword",
"key": "具体人设",
"comment": "Studio · 具体人设"
},
"scoring": {
"enabled": true,
"dimensions": [
{
"id": "authenticity",
"name": "真实性",
"criteria": "人设细节是否具体、可扮演,动机与行为是否一致。"
},
{
"id": "fit",
"name": "贴合度",
"criteria": "是否与人设目标一致;是否与整体美学一致;是否避免与已有条目冲突。"
}
]
}
},
"displayParams": [],
"inputs": [
{
"ref": "workflow.goal",
"label": "工作流目标文本",
"optional": false
},
{
"ref": "aesthetic.output",
"label": "整体美学 · 上轮产物",
"optional": false
},
{
"ref": "persona.output",
"label": "具体人设 · 上轮产物",
"optional": true
}
]
}
]
},
"pipelineVersionNote": "2026-05-31T14:58:54.643943",
"currentNodeId": "aesthetic",
"nodeStates": [
{
"nodeId": "init",
"displayName": "创建并绑定",
"skillId": "studio.init_bind",
"status": "completed",
"loopUntilSatisfied": false,
"lastDraft": {
"displayParams": {
"characterName": "测试角色6367da",
"worldbookName": "测试世界书8c09f9"
},
"characterId": "b0992d14-cb7c-4d81-b0af-7f7434710903",
"worldbookId": "e26e5d15-5b87-4693-aa47-f3021fbdc3d7",
"characterName": "测试角色6367da",
"worldbookName": "测试世界书8c09f9"
}
},
{
"nodeId": "aesthetic",
"displayName": "整体美学",
"skillId": "studio.worldbook_entry",
"status": "active",
"loopUntilSatisfied": true,
"lastDraft": null
},
{
"nodeId": "persona",
"displayName": "具体人设",
"skillId": "studio.worldbook_entry",
"status": "pending",
"loopUntilSatisfied": true,
"lastDraft": null
}
],
"workflowVariables": {
"workflow.goal": "设计一个单人角色:先绑定角色卡与世界书,再迭代整体美学与具体人设,写入世界书条目。",
"workflow.boundCharacter": "名称测试角色6367da\nIDb0992d14-cb7c-4d81-b0af-7f7434710903",
"workflow.boundWorldbook": "名称测试世界书8c09f9\nIDe26e5d15-5b87-4693-aa47-f3021fbdc3d7"
},
"createdAt": "2026-05-31T14:58:54.643943",
"updatedAt": "2026-05-31T14:58:54.659103"
}

View File

@@ -1,14 +1,16 @@
// frontend-react/src/App.jsx
import React, { useCallback, useEffect, useRef } from 'react'; // ✅ 移除 useState
import React, { useCallback, useEffect, useRef, useState } from 'react';
import TopBar from './components/TopBar';
import { ChatBox } from './components/Mid';
import SideBarLeft from './components/SideBarLeft';
import SideBarRight from './components/SideBarRight';
import PlaceholderPage from './components/PlaceholderPage';
import NovelPage from './components/Novel/NovelPage';
import StudioEditPage from './components/Studio/StudioEditPage';
import StudioRunPage from './components/Studio/StudioRunPage';
import useAppLayoutStore from './Store/AppLayoutSlice'; // ✅ 新增
import useStudioStore from './Store/Studio/StudioSlice';
import useNovelStore from './Store/Novel/NovelSlice';
import useApiConfigStore from './Store/SideBarLeft/ApiConfigSlice'; // ✅ 引入 API 配置 Store
import usePresetStore from './Store/SideBarLeft/PresetSlice'; // ✅ 引入预设 Store
import useCharacterStore from './Store/SideBarLeft/CharacterSlice'; // ✅ 引入角色卡 Store
@@ -194,22 +196,41 @@ function App() {
const initStudio = useStudioStore((s) => s.initStudio);
const initStudioRun = useStudioStore((s) => s.initStudioRun);
const initNovel = useNovelStore((s) => s.initNovel);
const novelView = useNovelStore((s) => s.view);
const readingChromeVisible = useNovelStore((s) => s.readingChromeVisible);
const isStudioEditPage = activePage === 'studio_edit';
const isStudioRunPage = activePage === 'studio_run';
const isNovelPage = activePage === 'novel';
const isStudioPage = isStudioEditPage || isStudioRunPage;
const [isMobileViewport, setIsMobileViewport] = useState(() =>
typeof window !== 'undefined' && window.matchMedia('(max-width: 768px)').matches
);
useEffect(() => {
const mq = window.matchMedia('(max-width: 768px)');
const onChange = (e) => setIsMobileViewport(e.matches);
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);
const hideTopBar =
isNovelPage && novelView === 'reading' && isMobileViewport && !readingChromeVisible;
useEffect(() => {
if (isStudioEditPage) {
initStudio();
} else if (isStudioRunPage) {
initStudioRun();
} else if (isNovelPage) {
initNovel();
}
}, [isStudioEditPage, isStudioRunPage, initStudio, initStudioRun]);
}, [isStudioEditPage, isStudioRunPage, isNovelPage, initStudio, initStudioRun, initNovel]);
return (
<div className={`app ${layoutMode}-mode`}>
{/* ✅ TopBar 不再需要 props直接从 Store 读取状态 */}
<TopBar />
<div className={`app ${layoutMode}-mode${hideTopBar ? ' novel-reading-immersive' : ''}`}>
{!hideTopBar && <TopBar />}
{/* 主内容容器 */}
{activePage === 'chat' ? (
@@ -243,6 +264,10 @@ function App() {
<div className="main-container studio-container">
<StudioRunPage />
</div>
) : activePage === 'novel' ? (
<div className="main-container novel-container">
<NovelPage />
</div>
) : (
<div className="main-container placeholder-container">
<PlaceholderPage page={activePage} />

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -17,8 +17,21 @@
padding: 0 var(--spacing-lg);
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: nowrap;
gap: var(--spacing-md);
min-width: 0;
overflow-x: auto;
scrollbar-width: thin;
scrollbar-color: var(--color-scrollbar) transparent;
}
.top-bar-content::-webkit-scrollbar {
height: 6px;
}
.top-bar-content::-webkit-scrollbar-thumb {
background: var(--color-scrollbar);
border-radius: var(--radius-full);
}
/* Status Section - Left side */
@@ -26,10 +39,7 @@
display: flex;
align-items: center;
gap: var(--spacing-md);
flex: 1;
justify-content: flex-start;
min-width: 0;
overflow-x: auto;
flex-shrink: 0;
}
/* Actions Section - Right side */
@@ -38,6 +48,7 @@
align-items: center;
gap: var(--spacing-sm);
flex-shrink: 0;
margin-left: auto;
}
/* Status Badge Styles - Minimalist */
@@ -87,21 +98,25 @@
white-space: nowrap;
}
/* Action Button Styles - Badge-like (Similar to status-badge) */
/* Action Button Styles — compact square, matches theme toggle */
.action-btn {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-sm) var(--spacing-md);
width: 32px;
height: 32px;
min-width: 32px;
min-height: 32px;
padding: 0;
background-color: transparent;
color: var(--color-text-secondary);
border: none;
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
cursor: pointer;
font-size: 1.4rem; /* 更大的图标 */
font-size: 1rem;
line-height: 1;
min-height: 36px; /* 与 status-badge 一致 */
flex-shrink: 0;
}
.action-btn:hover {
@@ -665,3 +680,16 @@
border-color: var(--color-text-muted);
color: var(--color-text-primary);
}
/* Mobile: hide TopBar horizontal scrollbar (keep swipe scroll) */
@media (max-width: 768px) {
.top-bar-content {
scrollbar-width: none;
-ms-overflow-style: none;
}
.top-bar-content::-webkit-scrollbar {
display: none;
height: 0;
}
}

View File

@@ -7,7 +7,8 @@ import useApiConfigStore from '../../Store/SideBarLeft/ApiConfigSlice';
import PageModeToggle from './items/PageModeToggle';
import StudioRunControls from './items/StudioRunControls';
import ThemeToggle from './items/ThemeToggle';
import RegexPanel from '../SideBarLeft/tabs/Regex/RegexPanel'; // ✅ 新增:导入正则管理组件
import RegexPanel from '../SideBarLeft/tabs/Regex/RegexPanel';
import ApiConfig from '../SideBarLeft/tabs/ApiConfig/ApiConfig';
import './TopBar.css';
const Toolbar = () => {
@@ -24,6 +25,7 @@ const Toolbar = () => {
} = useAppLayoutStore();
const isStudioRunPage = activePage === 'studio_run';
const isNovelPage = activePage === 'novel';
// ✅ 从 UserStore 获取用户角色和方法
const { currentUserRole, userRoles, updateRoleName, updateRoleDescription, selectUserRole, addUserRole } = useUserStore();
@@ -32,10 +34,17 @@ const Toolbar = () => {
const { globalWorldBooks } = useWorldBookStore();
// 从 ApiConfigStore 获取核心和辅助 API 配置
const { activeMap, fetchProfile } = useApiConfigStore();
const { currentProfile } = useApiConfigStore();
const [coreModel, setCoreModel] = useState('未设置');
const [assistModel, setAssistModel] = useState('未设置');
useEffect(() => {
const main = currentProfile?.apis?.mainLLM;
const secondary = currentProfile?.apis?.secondaryLLM;
setCoreModel(main?.model || main?.name || '未设置');
setAssistModel(secondary?.model || secondary?.name || '未设置');
}, [currentProfile]);
// 系统设置状态
const [settings, setSettings] = useState({
thinkingTagPrefix: '<thinking>',
@@ -155,6 +164,8 @@ const Toolbar = () => {
<div className="status-section">
{isStudioRunPage ? <StudioRunControls /> : null}
{!isNovelPage && (
<>
{/* 当前玩家角色 */}
<div
className="status-badge"
@@ -164,19 +175,31 @@ const Toolbar = () => {
<span className="status-icon">😊</span>
<span className="status-label">{truncateText(currentUserRole.name || '未设置', 15)}</span>
</div>
</>
)}
{/* 核心配置 */}
<div className="status-badge" title={`核心配置: ${coreModel}`}>
<div
className="status-badge"
title={`核心配置: ${coreModel}`}
onClick={isNovelPage ? () => handlePanelToggle('apiConfig') : undefined}
>
<span className="status-icon">🔌</span>
<span className="status-label">{truncateText(coreModel, 12)}</span>
</div>
{/* 辅助配置 */}
<div className="status-badge" title={`辅助配置: ${assistModel}`}>
<div
className="status-badge"
title={`辅助配置: ${assistModel}`}
onClick={isNovelPage ? () => handlePanelToggle('apiConfig') : undefined}
>
<span className="status-icon">🔗</span>
<span className="status-label">{truncateText(assistModel, 12)}</span>
</div>
{!isNovelPage && (
<>
{/* 当前预设 */}
<div className="status-badge" title="当前预设名">
<span className="status-icon"></span>
@@ -192,10 +215,14 @@ const Toolbar = () => {
: '无'}
</span>
</div>
</>
)}
</div>
{/* 右侧:操作按钮区域 */}
<div className="actions-section">
{!isNovelPage && (
<>
{/* 设置按钮 */}
<button
className="action-btn"
@@ -213,6 +240,8 @@ const Toolbar = () => {
>
</button>
</>
)}
{/* 页面模式 */}
<PageModeToggle />
@@ -223,6 +252,23 @@ const Toolbar = () => {
</div>
</div>
{/* API 配置面板(爽文模式) */}
{activePanel === 'apiConfig' && (
<div className="panel-overlay" ref={panelRef}>
<div className="panel-content settings-panel">
<div className="panel-header">
<h3>API 配置</h3>
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
</button>
</div>
<div className="panel-body" style={{ padding: 0 }}>
<ApiConfig />
</div>
</div>
</div>
)}
{/* ✅ 用户角色设置面板 */}
{activePanel === 'currentRole' && (
<div className="panel-overlay" ref={panelRef}>

View File

@@ -28,15 +28,22 @@
}
.main-container.placeholder-container,
.main-container.studio-container {
.main-container.studio-container,
.main-container.novel-container {
flex-direction: column;
}
.main-container.studio-container {
.main-container.studio-container,
.main-container.novel-container {
display: flex;
min-height: 0;
}
.app.novel-reading-immersive .main-container.novel-container {
flex: 1;
min-height: 0;
}
/* Smooth transitions for theme changes - only apply to specific properties */
.app {
transition: background-color var(--transition-normal),