diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0c38719 --- /dev/null +++ b/AGENTS.md @@ -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` diff --git a/backend/api/route.py b/backend/api/route.py index 9473174..632fadd 100644 --- a/backend/api/route.py +++ b/backend/api/route.py @@ -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) diff --git a/backend/api/routes/fictionRoute.py b/backend/api/routes/fictionRoute.py new file mode 100644 index 0000000..9855323 --- /dev/null +++ b/backend/api/routes/fictionRoute.py @@ -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)) diff --git a/backend/core/config.py b/backend/core/config.py index 2372ec2..308f770 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -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) diff --git a/backend/models/fiction_models.py b/backend/models/fiction_models.py new file mode 100644 index 0000000..5113610 --- /dev/null +++ b/backend/models/fiction_models.py @@ -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 diff --git a/backend/services/fiction_chapter_service.py b/backend/services/fiction_chapter_service.py new file mode 100644 index 0000000..8ce18f4 --- /dev/null +++ b/backend/services/fiction_chapter_service.py @@ -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, + ) diff --git a/backend/services/fiction_coarse_service.py b/backend/services/fiction_coarse_service.py new file mode 100644 index 0000000..3d09630 --- /dev/null +++ b/backend/services/fiction_coarse_service.py @@ -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 diff --git a/backend/services/fiction_event_plan_progress.py b/backend/services/fiction_event_plan_progress.py new file mode 100644 index 0000000..7dee4af --- /dev/null +++ b/backend/services/fiction_event_plan_progress.py @@ -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) diff --git a/backend/services/fiction_event_plan_service.py b/backend/services/fiction_event_plan_service.py new file mode 100644 index 0000000..15c20cc --- /dev/null +++ b/backend/services/fiction_event_plan_service.py @@ -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"], + } diff --git a/backend/services/fiction_metadata_service.py b/backend/services/fiction_metadata_service.py new file mode 100644 index 0000000..70f56a3 --- /dev/null +++ b/backend/services/fiction_metadata_service.py @@ -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() diff --git a/backend/services/fiction_open_book_service.py b/backend/services/fiction_open_book_service.py new file mode 100644 index 0000000..3534ff1 --- /dev/null +++ b/backend/services/fiction_open_book_service.py @@ -0,0 +1,159 @@ +""" +爽文开书优化(fiction.open_book)— LLM 调用逻辑。 +""" +from __future__ import annotations + +import json +import logging +import re +from typing import Any, Dict, List, Optional + +from langchain_core.messages import HumanMessage, SystemMessage + +from models.fiction_models import ( + EmotionFlow, + FictionGuideWorldbook, + OpenBookResult, +) +from services.fiction_prompt_utils import resolve_prompt +from services.fiction_service import fiction_service +from services.studio_step_respond import resolve_api_config +from utils.llm_client import LLMClient + +logger = logging.getLogger(__name__) + +_llm_client = LLMClient() +_JSON_FENCE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE) + + +def _extract_json(raw: str) -> Dict[str, Any]: + text = (raw or "").strip() + if not text: + raise ValueError("模型返回为空") + fence = _JSON_FENCE.search(text) + if fence: + text = fence.group(1).strip() + try: + return json.loads(text) + except json.JSONDecodeError: + start = text.find("{") + end = text.rfind("}") + if start >= 0 and end > start: + return json.loads(text[start : end + 1]) + raise ValueError("无法解析模型返回的 JSON") + + +def _validate_api_config(api_config: Dict[str, str]) -> None: + if not api_config.get("api_key"): + raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥") + if not api_config.get("api_url"): + raise ValueError("API 地址未配置,请先在 API 配置页面保存 mainLLM") + + +def _format_catalog_for_prompt(flows: List[EmotionFlow]) -> str: + lines: List[str] = [] + for flow in flows: + tags = "、".join(flow.tags or []) + lines.append(f"- id: {flow.id}\n intro: {flow.intro}\n tags: {tags}") + return "\n".join(lines) if lines else "(无可用情绪流)" + + +def _format_guide_global_for_prompt() -> str: + entries = fiction_service.get_guide_global_entries().entries + lines: List[str] = [] + for entry in entries: + lines.append(f"[{entry.layer}] {entry.title}\n{entry.content}") + return "\n\n".join(lines) if lines else "(无全局指南)" + + +def _build_open_book_messages(inspiration: str) -> List[Any]: + default_settings = fiction_service.get_default_settings() + system_prompt = resolve_prompt("openBook", default_settings.prompts.openBook) + + catalog = fiction_service.get_emotion_catalog() + catalog_text = _format_catalog_for_prompt(catalog.flows) + guide_global_text = _format_guide_global_for_prompt() + + user_content = f"""## 全局创作指南(L0–L3) +{guide_global_text} + +## 可选情绪流 catalog +{catalog_text} + +## 用户创作灵感 +{inspiration.strip()} + +请根据以上信息优化开书方案。""" + + return [ + SystemMessage(content=system_prompt), + HumanMessage(content=user_content), + ] + + +def _normalize_flow_ids(raw_ids: Any, valid_ids: set[str]) -> List[str]: + if not isinstance(raw_ids, list): + return [] + result: List[str] = [] + for item in raw_ids: + fid = str(item).strip() + if fid in valid_ids and fid not in result: + result.append(fid) + return result + + +def _parse_open_book_response( + data: Dict[str, Any], valid_flow_ids: set[str] +) -> OpenBookResult: + guide_raw = data.get("guide") or {} + guide = FictionGuideWorldbook( + persona=str(guide_raw.get("persona") or "").strip(), + highlight=str(guide_raw.get("highlight") or "").strip(), + experience=str(guide_raw.get("experience") or "").strip(), + forbiddenZones=str(guide_raw.get("forbiddenZones") or "").strip(), + ) + allowed = _normalize_flow_ids(data.get("allowedFlowIds"), valid_flow_ids) + if not allowed and valid_flow_ids: + allowed = [next(iter(valid_flow_ids))] + + title = str(data.get("title") or "未命名作品").strip() or "未命名作品" + optimized_intro = str(data.get("optimizedIntro") or "").strip() + + return OpenBookResult( + title=title, + optimizedIntro=optimized_intro, + guide=guide, + allowedFlowIds=allowed, + ) + + +async def run_open_book( + inspiration: str, + *, + profile_id: Optional[str] = None, + api_config: Optional[Dict[str, str]] = None, +) -> OpenBookResult: + inspiration = (inspiration or "").strip() + if not inspiration: + raise ValueError("创作灵感不能为空") + + resolved = resolve_api_config(profile_id, api_config) + _validate_api_config(resolved) + + catalog = fiction_service.get_emotion_catalog() + valid_flow_ids = {f.id for f in catalog.flows} + + messages = _build_open_book_messages(inspiration) + response = await _llm_client.chat_completion( + messages=messages, + api_url=resolved["api_url"], + api_key=resolved["api_key"], + model=resolved.get("model", "gpt-4o-mini"), + temperature=0.7, + max_tokens=8000, + request_timeout=120, + stream=False, + ) + content = response["choices"][0]["message"]["content"] + data = _extract_json(content) + return _parse_open_book_response(data, valid_flow_ids) diff --git a/backend/services/fiction_orchestrator_service.py b/backend/services/fiction_orchestrator_service.py new file mode 100644 index 0000000..bcc04c4 --- /dev/null +++ b/backend/services/fiction_orchestrator_service.py @@ -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) diff --git a/backend/services/fiction_planning_service.py b/backend/services/fiction_planning_service.py new file mode 100644 index 0000000..9236f06 --- /dev/null +++ b/backend/services/fiction_planning_service.py @@ -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 diff --git a/backend/services/fiction_prompt_utils.py b/backend/services/fiction_prompt_utils.py new file mode 100644 index 0000000..e81c778 --- /dev/null +++ b/backend/services/fiction_prompt_utils.py @@ -0,0 +1,125 @@ +""" +爽文提示词解析 — 用户自然语言 + 内部 JSON 输出格式(不暴露给前端)。 +""" +from __future__ import annotations + +from typing import Dict + +# 新建书籍时的默认用户向提示(自然语言,不含 JSON 结构) +USER_DEFAULT_PROMPTS: Dict[str, str] = { + "openBook": ( + "你是爽文开书优化助手。根据用户创作灵感,提炼书名、优化简介," + "并生成主角人设、核心爽点、读者体验策略与创作禁区。" + "从情绪流目录中挑选 1–4 个最匹配的条目。" + ), + "coarseOutline": ( + "你是爽文大纲助手。根据本书设定与进度,生成事件链级别的粗纲," + "每个事件包含标题与概要,节奏紧凑、爽点清晰。" + ), + "eventPlan": ( + "你是爽文事件规划助手。将粗纲中的事件展开为章节级计划," + "结合情绪流起承转合,为每章规划核心冲突与爽点。" + ), + "chapter": ( + "你是爽文章节写作助手。根据事件计划、guide 设定与上文撰写正文," + "节奏明快、对话推动冲突、章末留钩子。" + ), + "nudge": ( + "你是爽文创作教练。根据当前进度与读者体验目标," + "给出 1–3 条简短的下一步写作建议,不直接写正文。" + ), +} + +# 调用 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 diff --git a/backend/services/fiction_service.py b/backend/services/fiction_service.py new file mode 100644 index 0000000..0c178d7 --- /dev/null +++ b/backend/services/fiction_service.py @@ -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() diff --git a/backend/services/tools/fiction_tools.py b/backend/services/tools/fiction_tools.py new file mode 100644 index 0000000..099e7c0 --- /dev/null +++ b/backend/services/tools/fiction_tools.py @@ -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 diff --git a/data/agent/fiction/books/我是汉使-谁敢不敬/guide.worldbook.json b/data/agent/fiction/books/我是汉使-谁敢不敬/guide.worldbook.json new file mode 100644 index 0000000..dd8c2c5 --- /dev/null +++ b/data/agent/fiction/books/我是汉使-谁敢不敬/guide.worldbook.json @@ -0,0 +1,6 @@ +{ + "persona": "主角表面温文尔雅、恪守使者礼节,实则杀伐果断、胸怀大格局。受辱时隐忍记账,关键节点一击致命。拥有超越时代的信息差与历史推演金手指,善于借势与布局,从单枪匹马到建立西域都护府权威。", + "highlight": "1. 身份反差:所有人都当他是落魄流民,直到汉节与国书亮相,震惊全场。2. 文明降维打击:用冶铁、造纸、兵法碾压西域各方势力。3. 外交爽文:舌战群胡、以一人退一国之兵。4. 势力养成:收服三十六国,在异域复刻大汉盛世。", + "experience": "第三人称有限视角跟随主角,初期通过旁观者的鄙夷积蓄压抑,中后期在亮身份、展实力时拉远镜头,放大旁观者的跪服与匈奴使者的恐惧,形成反复打脸爽感。每场外交冲突都按铺垫→加压→以汉威逆转的结构推进。", + "forbiddenZones": "禁止主角长期忍气吞声无所作为、禁止汉使身份被长期误解不开封、禁止面对胡人欺辱时以德报怨;挫折控制在1章内,且必须立刻给出明确反击预期。" +} \ No newline at end of file diff --git a/data/agent/fiction/books/我是汉使-谁敢不敬/meta.json b/data/agent/fiction/books/我是汉使-谁敢不敬/meta.json new file mode 100644 index 0000000..4c11fe9 --- /dev/null +++ b/data/agent/fiction/books/我是汉使-谁敢不敬/meta.json @@ -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" +} \ No newline at end of file diff --git a/data/agent/fiction/books/我是汉使-谁敢不敬/metadata.json b/data/agent/fiction/books/我是汉使-谁敢不敬/metadata.json new file mode 100644 index 0000000..d295f4f --- /dev/null +++ b/data/agent/fiction/books/我是汉使-谁敢不敬/metadata.json @@ -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 + } +} \ No newline at end of file diff --git a/data/agent/fiction/books/我是汉使-谁敢不敬/run.json b/data/agent/fiction/books/我是汉使-谁敢不敬/run.json new file mode 100644 index 0000000..a5878a4 --- /dev/null +++ b/data/agent/fiction/books/我是汉使-谁敢不敬/run.json @@ -0,0 +1,11 @@ +{ + "status": "error", + "pipelineStage": "event_plan", + "stage": "error", + "message": "事件纲要生成失败", + "progress": { + "done": 0, + "total": 11 + }, + "updatedAt": "2026-06-01T11:56:27.977792" +} \ No newline at end of file diff --git a/data/agent/fiction/books/我是汉使-谁敢不敬/settings.json b/data/agent/fiction/books/我是汉使-谁敢不敬/settings.json new file mode 100644 index 0000000..945e99f --- /dev/null +++ b/data/agent/fiction/books/我是汉使-谁敢不敬/settings.json @@ -0,0 +1,13 @@ +{ + "prompts": { + "openBook": "你是爽文开书优化助手。根据用户提供的创作灵感,输出结构化的开书方案。\n\n要求:\n1. 提炼并优化用户灵感,使其更适合网文爽文节奏。\n2. 生成 guide 世界书草稿:persona(主角人设)、highlight(核心爽点)、experience(读者体验/视角策略)、forbiddenZones(创作禁区)。\n3. 从提供的情绪流 catalog 中挑选 1–4 个最匹配的 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": "你是爽文创作教练。根据当前进度与读者体验目标,给出 1–3 条简短的下一步写作建议(不直接写正文)。" + }, + "reader": { + "contextWindowChars": 2000, + "prefetchRemainingWords": 300 + } +} \ No newline at end of file diff --git a/data/agent/fiction/emotion_flows/catalog.json b/data/agent/fiction/emotion_flows/catalog.json new file mode 100644 index 0000000..427be73 --- /dev/null +++ b/data/agent/fiction/emotion_flows/catalog.json @@ -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": "当众清算,恩怨了结,读者情绪得到释放。" } + ] + } + ] +} diff --git a/data/agent/fiction/guide_global/entries.json b/data/agent/fiction/guide_global/entries.json new file mode 100644 index 0000000..58c2b7b --- /dev/null +++ b/data/agent/fiction/guide_global/entries.json @@ -0,0 +1,44 @@ +{ + "entries": [ + { + "layer": "L0", + "title": "爽文基本节奏", + "content": "每章需有明确的情绪推进:铺垫→加压→释放。避免长时间无爽点的水文;小爽点每 800–1500 字,大爽点每 3–5 章。" + }, + { + "layer": "L0", + "title": "读者预期管理", + "content": "开书前 3 章必须建立核心卖点(金手指/身份差/仇恨对象)。让读者知道「这本书承诺给我什么爽感」。" + }, + { + "layer": "L1", + "title": "主角行为准则", + "content": "主角可以低调但不能窝囊;遇辱必报、有仇必记,但报复需有层次(先小胜再大胜)。避免圣母式原谅削弱爽感。" + }, + { + "layer": "L1", + "title": "对手设计", + "content": "反派/对手需有足够嚣张资本与明确动机;被打脸前要让读者足够讨厌他们。避免脸谱化到无法代入。" + }, + { + "layer": "L2", + "title": "信息投放", + "content": "世界观与力量体系采用「冰山法则」:每次只揭示与当前冲突相关的信息。悬念优于说明书式设定堆砌。" + }, + { + "layer": "L2", + "title": "对话与描写比例", + "content": "冲突场景多用短句对话推进;升级/打脸瞬间可加入 1–2 句环境或旁观者反应放大爽感,但避免冗长旁白。" + }, + { + "layer": "L3", + "title": "用户体验(视角/人称)", + "content": "默认第三人称有限视角跟随主角;关键爽点可短暂拉远至旁观者视角以放大震惊效果。人称切换需有明确叙事目的,避免混乱。" + }, + { + "layer": "L3", + "title": "禁区与雷点", + "content": "避免 NTR、主角长期受虐无反击、重要角色无理由降智。若需挫折,控制在 1–2 章内并给出明确反击预期。" + } + ] +} diff --git a/data/agent/runs/chat/帝国骑士维尔/默认聊天/events.jsonl b/data/agent/runs/chat/帝国骑士维尔/默认聊天/events.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/data/agent/runs/chat/帝国骑士维尔/默认聊天/run.json b/data/agent/runs/chat/帝国骑士维尔/默认聊天/run.json new file mode 100644 index 0000000..b8a555c --- /dev/null +++ b/data/agent/runs/chat/帝国骑士维尔/默认聊天/run.json @@ -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'" +} \ No newline at end of file diff --git a/data/agent/studio_projects/test-r1-72cd5b4b/meta.json b/data/agent/studio_projects/test-r1-72cd5b4b/meta.json new file mode 100644 index 0000000..006c251 --- /dev/null +++ b/data/agent/studio_projects/test-r1-72cd5b4b/meta.json @@ -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" +} \ No newline at end of file diff --git a/data/agent/studio_projects/test-r1-72cd5b4b/pipeline.json b/data/agent/studio_projects/test-r1-72cd5b4b/pipeline.json new file mode 100644 index 0000000..3fd8d1d --- /dev/null +++ b/data/agent/studio_projects/test-r1-72cd5b4b/pipeline.json @@ -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 + } + ] + } + ] +} \ No newline at end of file diff --git a/data/agent/studio_projects/新项目/meta.json b/data/agent/studio_projects/新项目/meta.json new file mode 100644 index 0000000..39bc5eb --- /dev/null +++ b/data/agent/studio_projects/新项目/meta.json @@ -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" +} \ No newline at end of file diff --git a/data/agent/studio_projects/新项目/pipeline.json b/data/agent/studio_projects/新项目/pipeline.json new file mode 100644 index 0000000..32b0ec2 --- /dev/null +++ b/data/agent/studio_projects/新项目/pipeline.json @@ -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 + } + ] + } + ] +} \ No newline at end of file diff --git a/data/agent/studio_runs/default/37704eb4-ccff-45bf-a6e5-92ec8e2d7094/run.json b/data/agent/studio_runs/default/37704eb4-ccff-45bf-a6e5-92ec8e2d7094/run.json new file mode 100644 index 0000000..fdefce2 --- /dev/null +++ b/data/agent/studio_runs/default/37704eb4-ccff-45bf-a6e5-92ec8e2d7094/run.json @@ -0,0 +1,330 @@ +{ + "id": "37704eb4-ccff-45bf-a6e5-92ec8e2d7094", + "projectId": "default", + "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": "step1:首先思考整个故事是怎么样的\nstep2:然后思考如何展示\nstep3:选中核心爽点", + "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-31T13:10:17.314892", + "currentNodeId": "aesthetic", + "nodeStates": [ + { + "nodeId": "init", + "displayName": "创建并绑定", + "skillId": "studio.init_bind", + "status": "completed", + "loopUntilSatisfied": false, + "lastDraft": { + "displayParams": { + "characterName": "汉使人生", + "worldbookName": "汉使人生" + }, + "characterId": "f04ba2d6-1ffd-4c33-9cfe-cf6fd026c175", + "worldbookId": "8682f790-1b9d-4842-826b-c07764be6b9b", + "characterName": "汉使人生", + "worldbookName": "汉使人生" + }, + "lastToolResponse": null, + "stepMessages": [], + "turnHistory": [] + }, + { + "nodeId": "aesthetic", + "displayName": "整体美学", + "skillId": "studio.worldbook_entry", + "status": "active", + "loopUntilSatisfied": true, + "lastDraft": { + "entryContent": "## 整体美学:汉使西行\n\n### 色调\n- **中原印记**:朱红(汉属火德,官服旌旗的正色)、玄黑(冠冕车驾)、生漆赭红(漆器上的暗沉血色)、竹简黄(文书)、青碧(祭祀礼玉之色)\n- **异域风土**:沙黄(大漠戈壁)、雪白(天山远影)、深靛(西域织物的矿物染色)、篝火橙(夜宿)、灰绿(绿洲植被蒙尘后的色泽)\n- **过渡交融**:暮色苍茫中的赭褐与灰紫、夯土烽燧的土黄在夕阳下短暂泛红、风沙迷蒙将一切色彩磨去棱角\n\n### 材质与质感\n- **丝绢**:柔滑而脆弱,易染尘,出使时身着深衣内衬留存的中原触感。在风沙中日渐粗砺,象征中原文明的精致在旅途中的磨损与坚守\n- **青铜**:冷硬沉重,符节、印信、带钩的核心材质。汉代青铜器皿表面未锈,是黄铜般的暖金色,在风沙中磨出本真的铜光——符节上的铜锈反而是后来者才会有的东西\n- **竹简与墨迹**:粗粝温暖的书写载体,旅途中的简牍窄小,字迹仓促,墨中带沙。未用完的空白简在行囊中日益减少,每一片都是珍贵的沉默\n- **西域之器物**:牛皮地图(异域的腥膻气尚未散尽)、驼毛毯(粗硬扎手,但入夜后是唯一的阻寒物)、粗陶与早期金属器——实用、粗犷、带着陌生的制作痕迹\n- **风沙侵蚀**:一切材质最终都被风沙打磨,丝绢起毛、竹简边缘磨损、符节上的铭文因沙粒嵌入而变得更像刻痕。时间在每一件器物上留下颗粒感\n\n### 氛围\n沉郁苍茫为主调,偶有暖色点缀。大部分场景笼罩在旅途的疲惫与未知的紧张中——河西走廊的夯土烽燧一线孤悬,玉门关外天地忽然展开,大漠孤烟直、长河落日圆、异域城邦的喧哗如潮水涌来。间歇性的安宁时刻:篝火旁摊开简牍书写、远方隐约传来的胡笳声、夜空中与长安相同的星斗——这些微小的熟悉感成为使臣情绪的锚点,提醒他从何处来,正去往无人走过之处。\n\n### 光线\n- **白昼**:强烈日照,沙地反光刺目如刀,中原的朱红在此光下被漂白成近乎苍白\n- **黄昏**:最富层次的时刻。暮色将汉家的赤色与西域的沙黄融为一炉,天地过渡成短暂的金红,随后迅速沉入冷紫\n- **夜晚**:篝火为唯一暖光源,其余沉入深蓝与墨黑。河西走廊以西的星空异常清澈,银河如尘如带——这是唯一在异域也被确认的故乡之物\n\n### 核心视觉母题\n**旌旗与符节**——出玉门关前,朱红旌旗是汉使威仪的视觉锚点,在风沙中翻卷、褪色、破损。出关后,大旌旗收卷于车,取而代之的是汉节(竹柄、牦尾、铜龙首),这个更内敛但同样不可丢失的器物,成为后半程的视觉核心。\n\n### 叙事基调\n- 第一人称或限知视角,沉静内敛\n- 语言风格:沿袭汉代文史的简洁克制,白描为主,不渲染情绪;写景状物多用单句,如“沙平如席,日烈如炙”\n- 节奏:舒缓,“行行重行行”的沉浸式旅途感,路程的冗长与间歇的冲击交替\n- 情感表达:以细节和动作代心声——抚摸符节上的铭文字口、在沙地上无意识地用竹笔画出汉字、对异域远山的长时间凝视、取出内衬丝绢一角摩挲\n- 时代底色:使命感的凝重与个体的渺小并存。前方没有先驱,只有传闻与未知——这是凿空,不是因循\n\n### 美学关键词\n苍茫、沉郁、克制、文明的脆弱与尊严、异域的神秘、凿空的孤独", + "insertionKey": "整体美学", + "insertionComment": "Studio · 整体美学", + "nodeId": "aesthetic", + "displayName": "整体美学", + "writtenEntryUid": "9aa8d49d-3231-489f-ba08-4be988360d1c", + "writtenWorldbookName": "汉使人生" + }, + "lastToolResponse": { + "thinking": "用户选择了西汉武帝时期作为历史背景。这一步需要将此前较为通用的整体美学设定,具体化为西汉(特别是张骞凿空西域前后)的时代质感。思考路径如下:\n1. 真实性与时代辨识度:需要在色调、材质等部分加入明确的汉代元素——比如汉代特有的赤色(汉属火德)、丝绢而非后期更常见的丝绸、未生锈的青铜(汉代实用器)、未发明的纸但已有简牍。这些细节让美学不再是泛古风,而是可定位的西汉。\n2. 贴合度与可注入世界书:原本的结构(色调/材质/氛围/叙事基调等)已经覆盖四维,此次修订是增强,不应推翻原框架。原色调中的朱红与玄黑本身就是汉代正色,很好;材质中的竹简与青铜也是汉使核心物件,保留并细化。需要补充的:建筑上,汉代河西走廊的夯土烽燧、驿站;器物上,汉代特有的漆器(朱红与黑)可作为记忆中的中原载体;景观上,明确“异域”是从河西走廊出玉门关后,向西域三十六国延伸,视觉上可加入雪山、戈壁、绿洲集市等。\n3. 叙事基调的衔接:武帝时期开拓感强,但作为单人体验不应过于宏大叙事,仍保持沉静内敛的第一人称。但可以在叙事基调中增加一丝时代独有的使命感的凝重——这不是普通的出使,是大汉首次正式凿空西域。这种微妙的情绪可融入“抚符节的瞬间沉静”等细节。\n4. 核心视觉母题不变(旌旗与风沙),但可补充一句:旌旗在出玉门关后会换成更小的汉节(牦牛尾装饰的符节),从张扬的旌旗到内敛的节杖,这本身就是视觉叙事。\n5. evaluation 部分:提醒用户,此次修订已将美学锚定在西汉,后续人设步骤可以直接调用这些细节,比如角色服饰可具体为汉代的深衣与冠弁,书写工具为笔墨简牍,符节形制等。", + "evaluation": "本次修订已将美学设定明确锚定在西汉武帝时期。色调部分加入了汉属火德的朱红与青碧礼玉色,增强了时代辨识度;材质部分细化了汉代实用的未锈青铜、丝绢(而非晚期丝绸)与有限的竹简,符合西汉书写载体历史;核心视觉母题补充了出关后旌旗收卷、汉节成为核心的变化,这不仅是视觉锚点,也为后续人设提供动作支点(如何持节、如何护卫竹节)。\n\n优化建议:\n1. 建筑细节尚未充分展开——后续人设步骤若涉及具体场景(如驿站、异域王庭、绿洲集市),可从此美学延伸出夯土墙、帷幔、火盆等具体视觉元素。\n2. “异域”目前是西域三十六国的统称,若有具体目标(如大宛、乌孙、月氏),后续可在景观、织物、器皿上进一步差异化。\n3. 叙事基调中“凿空的孤独”已作为关键词加入,这可以与人设步骤中的角色核心矛盾(使命与个体感受的张力)形成呼应。建议在人设步骤中主动回扣此关键词。", + "questions": [], + "generatedAt": "2026-05-31T13:44:57.312039" + }, + "stepMessages": [ + { + "id": "msg-e0670d32e833", + "role": "user", + "content": "我要作为一个汉使的体验", + "createdAt": "2026-05-31T13:40:51.856438" + }, + { + "id": "msg-de50fb79a7bb", + "role": "assistant", + "content": "已覆盖色调、材质、氛围、叙事基调四大维度,表述简洁可注入世界书。真实性方面:色调做了中原/异域两组对比,材质有具体物件支撑,氛围场景化,非空泛形容词堆砌。补充了光线设定和核心视觉母题(旌旗与风沙),增强了电影感和贯穿性。\n\n提醒:后续人设步骤可直接从此美学中延伸——比如角色服饰的朱红官服如何在风沙中褪色、他如何用磨损的竹简记录见闻,这些细节已在美学中埋下锚点。\n\n需用户确认:时代与异域方向,以便后续步骤细化美学中的建筑、器物、自然景观等具体元素。", + "createdAt": "2026-05-31T13:40:51.856438" + }, + { + "id": "msg-da9f24b89eca", + "role": "user", + "content": "[选项] 西汉武帝时期(张骞凿空西域前后,开拓感强)", + "createdAt": "2026-05-31T13:44:57.312039" + }, + { + "id": "msg-17b9d8698271", + "role": "assistant", + "content": "本次修订已将美学设定明确锚定在西汉武帝时期。色调部分加入了汉属火德的朱红与青碧礼玉色,增强了时代辨识度;材质部分细化了汉代实用的未锈青铜、丝绢(而非晚期丝绸)与有限的竹简,符合西汉书写载体历史;核心视觉母题补充了出关后旌旗收卷、汉节成为核心的变化,这不仅是视觉锚点,也为后续人设提供动作支点(如何持节、如何护卫竹节)。\n\n优化建议:\n1. 建筑细节尚未充分展开——后续人设步骤若涉及具体场景(如驿站、异域王庭、绿洲集市),可从此美学延伸出夯土墙、帷幔、火盆等具体视觉元素。\n2. “异域”目前是西域三十六国的统称,若有具体目标(如大宛、乌孙、月氏),后续可在景观、织物、器皿上进一步差异化。\n3. 叙事基调中“凿空的孤独”已作为关键词加入,这可以与人设步骤中的角色核心矛盾(使命与个体感受的张力)形成呼应。建议在人设步骤中主动回扣此关键词。", + "createdAt": "2026-05-31T13:44:57.312039" + } + ], + "turnHistory": [] + }, + { + "nodeId": "persona", + "displayName": "具体人设", + "skillId": "studio.worldbook_entry", + "status": "completed", + "loopUntilSatisfied": true, + "lastDraft": { + "entryContent": "## 沈旺旺\n\n### 人物概览\n沈旺旺,字明远(自称),不知何故坠入汉武之世,被编入西行使团,任文书副贰。身姿清瘦,肩背微佝,行路时脚步不紧不慢,却极少停歇。嗓音低涩,似沙砾磨过铜面,每吐一字都像斟酌再三。他沉默地行走在凿空的路上,手中符节既是汉家的威仪,也是他唯一能握住的时空之锚。\n\n### 性格\n外静内热。平日寡言,常伫立凝望远方,如有所思,实则内心翻涌着对两个时代的对比与追问。对同行者抱有一种克制的悲悯——他知历史走向,却无法言说,只能目睹他们遵循命运前行。困境中,他的知识偶露锋芒(如简易沙地储水法、星象辨位),但不惜以“祖传杂学”搪塞,避免暴露。骨子里谦逊,却因知晓未来而自带疏离的骄傲。那份骄傲并不外露,只在独处时显现为唇边一缕极淡的、转瞬即逝的哑笑。\n\n### 动机\n初为求生与寻归。渐渐地,被凿空之旅的史诗感浸透,生出“见证并真实记录”的执念——他意识到自己是唯一能留下不同于官史视角的人。更深层处,他背负着道德重负:自己的存在是否已扰动历史?这个秘密如沙砾,磨在心口,驱使他谨慎到近乎自虐。行囊中每一片写满的简牍,都是一份无收者的信。\n\n### 口癖与语言\n- 说话至末尾,常有片刻停顿,仿佛在脑海的词典里搜找属于这个时代的词。偶尔不经意间吐出简短现代的词汇(如“逻辑”“靠谱”),被问及时立刻改口,以“外邦方言”或“梦呓”搪塞,眼神微移。\n- 惯用“也许”“假设”“或许”收束判断,透出对一切皆可改变的惶惑。面对正使提问,常在答后补一句“此为大胆揣测,未必准”,以此留一步退路。\n- 在极度疲惫或安宁时刻,会低声背诵记忆中的诗句——如“大漠孤烟直”——旁人以为他在吟汉赋,只有他知那是千年后的回声。背完即默,从不解释。\n\n### 行为模式\n- **晨昏拭节**:每日清晨拔营前、黄昏歇止时,取出一小块残破丝绢布(实为现代衬衫内衬所裁),蘸一滴皮囊中的清水,擦拭汉节铜首。动作极缓,指腹沿铭文字口反复描摹,如同摩挲故乡门环。黄昏暮色将铜光与霞光融为一炉,那一刻他的面容最温柔,也最遥远。\n- **篝火书简**:夜间篝火旁,他掏出竹简与削刀,双面写满蝇头小字,字迹仓促中带克制。写完一简,必就火光再读一遍,如校对史官。偶尔他会用炭枝在沙地上画出怪异符号(现代纪年与简图),凝视片刻,随即抚平,痕迹不留。火光在他脸上跳动,旁人见其神情,以为在卜卦,却不知他在重绘记忆的星图。\n- **望星为盟**:异域夜空下仰头长久望星,确认银河走向。那是唯一在异域也被确认的故乡之物。望至深处,喉结微动,似吞下一句从未说出口的话。白日里,他常对远山或驼队发呆,目光越过同伴,仿佛在看一条看不见的河——那条河从长安流出,流向他再不能回返的远方。\n- **内衬之触**:焦虑或思乡时,探手入怀,用拇指摩挲深衣内衬那块现代格子布残片。其经纬已粗糙、褪色近白,但仍存一缕不属于此地的柔软记忆。这个动作隐秘,只有最细心的同伴才可能察觉,而他从不解释。\n\n### 关系\n- **与正使(假拟为“张副使”)**:沈旺旺因其广博学识受其器重,常被咨以地理、异俗。他敬其忠勇,但始终保持一步之遥。正使问策时,他必详举一二三,末尾却加一句“此为揣测,未必准”,以此回避过深的信任。他不饮酒,故每逢军中共饮,便静坐一旁,执符节竹柄,指尖轻叩节拍,像在计数什么。\n- **与护卫、向导**:起初被视作怪人,后因实用知识(治沙盲眼、煮水去浊)赢得尊敬。他在篝火旁教以简单星象,有问必答,唯独不答“你究竟从何而来”。被逼问时,只沉默,抬头望天,片刻后答:“从东土来。”那“东土”二字说得极轻,仿佛随时会被风吹散。\n- **与异域之人**:他用有限的语音模仿和手势尝试沟通,对语言的隔阂异常敏感,眼中常含一种焦灼的温柔——他比任何人都渴望交流,也比任何人都知晓交流的徒劳。每次成功传递意思后,他会微微抿唇,像品尝一丝甜,随即敛容。\n\n### 特殊细节\n- **服饰与遗物**:外着汉制深衣(朱红镶边已褪色近赭),头束帻巾,但内衬保留一片不规则裁下的格子布,洗得发白,是他穿越前衬衫的末梢。这是唯一的现代遗物。无人时,他偶尔取出就篝火之光端详,仿佛在读一封遗失地址的青书。\n- **心理标记**:他私下将玉门关称作“单向门的门缝”,将符节称作“我在这头,历史在那头”。这些比喻从未说出口,只在内心沉淀,成为自己的时空坐标。当他抚摸符节竹柄时,指节会微微用力,像在确认自己尚未被时间抹去。\n- **嗓音与步态**:嗓音低沉略带沙哑,语速迟缓,句与句之间常有停顿,如沙漠中行走之人珍惜水囊。步态平稳,极少急促,即使在风沙中也保持一种被迫的从容——那是两千年记忆附身的重量,也是他唯一不需掩饰的体态。", + "insertionKey": "具体人设", + "insertionComment": "Studio · 具体人设", + "nodeId": "persona", + "displayName": "具体人设" + }, + "lastToolResponse": { + "thinking": "需要基于整体美学“汉使西行”来细化具体人设条目。整体美学强调苍茫沉郁、克制白描、以细节代心声、符节与丝绢等视觉母题。现有人设框架较全,但写法偏分析性,需融入汉代简洁克制的质感,使人设条目本身即与整体美学一致,成为可扮演、可沉浸的文本。重点优化:1)语言风格融入白描短句,避免现代口吻;2)行为模式与美学光线/时刻绑定(晨昏拭符节、篝火书简、星下凝视);3)增加嗓音、步态等感官细节,让角色有更具体的身体性;4)强化时空错位的内心纹理,用克制的意象表达(符节为锚、玉门为缝);5)关系处理上体现“一步之遥”的具体表现,如回答正使时以假设收尾。整体保持人物的可扮演性和与整体美学的高度贴合。", + "evaluation": "此条目的主要优化方向是使角色细节与整体美学“汉使西行”高度贴合,并确保所有行为模式具有可扮演的具体性。\n\n**真实性**:新增了嗓音质感、步态、眼神等感官细节,使角色更有身体记忆。行为模式被绑定到特定时刻与光线(晨昏、篝火、星夜),增强了画面感和节奏感。口癖和语言仍保留了现代痕迹,但加入了克制修饰(如以“梦呓”掩饰、以“未必准”收尾),使现代感内敛化,避免破坏汉世语境,同时保持了人物的跨时代特质。动机链条从求生到见证到道德负担,逻辑一致,可在具体场景中通过竹简书写、沙地符号等外化。\n\n**贴合度**:语言风格已向整体美学的“简洁克制”靠拢,多用短句白描,情感表达以动作为锚(拭节、书简、抚布、望星),符合“以细节和动作代心声”的要求。核心视觉母题——符节、丝绢、竹简、星象——均被有机融入行为模式中,反复出现,形成人物的符号系统。光线描写(暮色中的铜光、篝火上的简影、银河下的凝望)直接引自整体美学,人物与场景在感官层面合一。\n\n**可优化空间**:可考虑在具体场景中再增加一两个微小的日常细节(如清晨收营时他如何将竹简按长短排列、如何用细绳捆扎,动作井然;或沙尘暴中他用身体掩护行囊中的空白简,而非自己),以进一步强化“文明脆弱与尊严”这一美学关键词。另可补充他与其他使人属员(如译令、医士)的零星互动,以拓展关系的宽度——但这一条属可选扩充,非必要。当前版本已能满足高阶角色扮演需求。", + "questions": [], + "generatedAt": "2026-05-31T14:58:41.278648" + }, + "stepMessages": [ + { + "id": "msg-2ab2f3b1a5cf", + "role": "user", + "content": "主角叫沈旺旺,是一个现代穿越的人", + "createdAt": "2026-05-31T14:58:41.278648" + }, + { + "id": "msg-8353ee966314", + "role": "assistant", + "content": "此条目的主要优化方向是使角色细节与整体美学“汉使西行”高度贴合,并确保所有行为模式具有可扮演的具体性。\n\n**真实性**:新增了嗓音质感、步态、眼神等感官细节,使角色更有身体记忆。行为模式被绑定到特定时刻与光线(晨昏、篝火、星夜),增强了画面感和节奏感。口癖和语言仍保留了现代痕迹,但加入了克制修饰(如以“梦呓”掩饰、以“未必准”收尾),使现代感内敛化,避免破坏汉世语境,同时保持了人物的跨时代特质。动机链条从求生到见证到道德负担,逻辑一致,可在具体场景中通过竹简书写、沙地符号等外化。\n\n**贴合度**:语言风格已向整体美学的“简洁克制”靠拢,多用短句白描,情感表达以动作为锚(拭节、书简、抚布、望星),符合“以细节和动作代心声”的要求。核心视觉母题——符节、丝绢、竹简、星象——均被有机融入行为模式中,反复出现,形成人物的符号系统。光线描写(暮色中的铜光、篝火上的简影、银河下的凝望)直接引自整体美学,人物与场景在感官层面合一。\n\n**可优化空间**:可考虑在具体场景中再增加一两个微小的日常细节(如清晨收营时他如何将竹简按长短排列、如何用细绳捆扎,动作井然;或沙尘暴中他用身体掩护行囊中的空白简,而非自己),以进一步强化“文明脆弱与尊严”这一美学关键词。另可补充他与其他使人属员(如译令、医士)的零星互动,以拓展关系的宽度——但这一条属可选扩充,非必要。当前版本已能满足高阶角色扮演需求。", + "createdAt": "2026-05-31T14:58:41.278648" + } + ], + "turnHistory": [ + { + "lastDraft": null, + "lastToolResponse": null, + "stepMessages": [], + "timestamp": "2026-05-31T14:52:59.315258" + }, + { + "lastDraft": { + "entryContent": "# 沈旺旺\n\n## 人物概览\n沈旺旺,字明远(自称),现代穿越者,不知何故坠入汉武之世,被编入西行使团,任文书副贰。外貌似青年,实际心智夹带两千年风尘。他沉默地行走在凿空的路上,手中符节既是汉家的威仪,也是他唯一能握住的时空之锚。\n\n## 性格\n外静内热。平日寡言,常伫立凝望远方,如有所思,实则内心翻涌着对两个时代的对比与追问。对同行者抱有一种克制的悲悯——他知历史走向,却无法言说,只能目睹他们遵循命运前行。困境中,他的知识偶露锋芒(如简易沙地储水法、星象辨位),但不惜以「祖传杂学」搪塞,避免暴露。骨子里谦逊,却因知晓未来而自带疏离的骄傲。\n\n## 动机\n初为求生与寻归。渐渐地,被凿空之旅的史诗感浸透,生出「见证并真实记录」的执念——他意识到自己是唯一能留下不同于官史视角的人。更深层处,他背负着道德重负:自己的存在是否已扰动历史?这个秘密如沙砾,磨在心口,驱使他谨慎到近乎自虐。\n\n## 口癖\n- 不经意间轻声吐出简短的现代词汇(如「靠谱」「逻辑」),被问及时改口以「外邦方言」或「梦呓」掩饰。\n- 惯用「也许」「假设」等不确定句式,透露出对一切皆可改变的惶惑。\n- 在极度疲惫或安宁时刻,会低声背诵记忆中的诗句——如「大漠孤烟直」——旁人以为他在吟汉赋,只有他知那是千年后的回声。\n\n## 行为模式\n- **符节之仪**:每日晨昏用一小块残破丝绢布(实为其现代衬衫内衬所改)擦拭汉节铜首,手指沿着铭文字口反复描摹,如同摩挲故乡的门环。\n- **书写之痴**:行囊中竹简日益稀少,书写极俭,每每将一简双面写满蝇头小字。夜间篝火旁,他会用炭枝在沙地上画出一些怪异符号(现代纪年与简图),随后抚平,痕迹不留。\n- **观星与凝视**:异域夜空下仰头长久望星,确认银河走向,那是唯一被确认的「故乡之物」。白日里,常对远山或驼队发呆,目光越过同伴,仿佛在看一条看不见的河。\n- **内衬之触**:焦虑或思乡时,会探手入怀,用拇指摩挲深衣内衬那块现代布料残片,其经纬已粗糙,但仍存一缕不属于此地的柔软记忆。\n\n## 关系\n- 与正使(假拟为「张副使」):沈旺旺因其广博学识受其器重,常被咨以地理、异俗。他敬其忠勇,但始终保持一步之遥,怕交心泄露天机。\n- 与护卫、向导:起初被视作怪人,后因实用知识(治沙盲眼、煮水去浊)赢得尊敬。他在篝火旁教以简单星象,有问必答,唯独不答「你究竟从何而来」。\n- 与异域之人:他用有限的语音模仿和手势尝试沟通,对语言的隔阂异常敏感,眼中常含一种焦灼的温柔——他比任何人都渴望交流,也比任何人都知晓交流的徒劳。\n\n## 特殊细节\n- **服饰**:外着汉制深衣(朱红镶边已褪色),头束帻巾,但内衬保留一片不规则裁下的格子布,洗得发白,是他穿越前衬衫的末梢。这是唯一的现代遗物。\n- **心理标记**:他私下将玉门关称作「单向门的门缝」,将符节称作「我在这头,历史在那头」。这些比喻从未说出口,只在内心沉淀,成为他自己的时空坐标。\n- **动作习性**:说话时习惯抚摸符节竹柄,仿佛在寻找节拍的支撑;感到荒谬时会无声哑笑,随即收敛,面容复归沉静。", + "insertionKey": "具体人设", + "insertionComment": "Studio · 具体人设", + "nodeId": "persona", + "displayName": "具体人设" + }, + "lastToolResponse": { + "thinking": "用户要求基于「整体美学:汉使西行」设计具体人设,主角是现代穿越者沈旺旺。我需要确保人设贴合美学的沉郁苍茫、克制内敛,同时注入现代性与汉代使命感的冲突,使角色可扮演、动机真实。我从性格、动机、口癖、行为模式等方面构思,结合整体美学中的色调、材质(如符节、竹简、丝绢)、光线与氛围,让沈旺旺的行为细节(如擦拭符节、摩挲内衬布料、夜空观星)直接呼应美学关键词。他在现代可能具备历史知识,这赋予他「旁观者」视角,强化文明的脆弱与尊严这个主题。口癖上,我用现代词汇或诗句的偶尔流露,制造语言错位,但以汉代白话掩盖。关系上,他与同僚保持微妙距离,既有信任也有隔阂。条目本身以白描式第三人称撰写,符合叙事基调。评价标准方面,我会检查真实性(动机与行为一致性)、贴合度(与美学契合,无冲突),并提出可能的问题供用户澄清穿越细节。", + "evaluation": "对照评价标准,本条目真实性与贴合度均较高。真实性方面:性格沉静内热、动机从求生到见证的转变合乎逻辑;口癖如现代词汇的泄露与吞回、背诵后世诗句,细节具体可扮演;行为模式均能对应具体情境,如擦拭符节、沙地书写、摩挲布料,使内心活动外化,操作性强。贴合度方面:人设与「整体美学」深度融合——色调上他钟爱暮色苍茫(对应美学中过渡交融色调),材质上丝绢布料与符节竹简直接呼应,氛围上他以旁观者身份强化凿空的孤独,叙事基调上条目本身采用白描、简洁第三人称。唯一潜在冲突是‘现代穿越’可能引入跳脱感,但通过克制描写与美学锚定(如以星斗、符节为情感载体),使这种现代性转化为‘文明脆弱与尊严’的镜子,而非破坏。建议在后续扮演中,注意保持语言风格的沉静内敛,避免现代词汇过度使用,确保每次流露都有充分的情绪铺垫。", + "questions": [ + { + "question": "沈旺旺穿越的具体时间点如何设定?这会影响他的初始状态与行为细节。", + "options": [ + "刚穿越不久即被编入使团,对汉代生存仍显生涩", + "已在汉代生活数月/数年,基本适应古代日常,但内心仍保留现代习惯", + "穿越时间模糊,他自己也说不清,只依稀记得坠入黑暗前的一幕" + ] + }, + { + "question": "他穿越前的现代身份或知识背景是否需要明确?这会决定他‘有用知识’的来源与可信度。", + "options": [ + "历史/考古相关学生或爱好者,对汉代出使有一定了解,知识有体系", + "普通现代人,只有零散常识(如野外求生、简单天文),靠本能应对", + "保持悬念,不明确具体职业,只通过行为暗示他受过良好教育" + ] + } + ], + "generatedAt": "2026-05-31T14:52:59.315115" + }, + "stepMessages": [ + { + "id": "msg-a59620da9a76", + "role": "user", + "content": "主角叫沈旺旺,是一个现代穿越的人", + "createdAt": "2026-05-31T14:52:59.315115" + }, + { + "id": "msg-21d038e69735", + "role": "assistant", + "content": "对照评价标准,本条目真实性与贴合度均较高。真实性方面:性格沉静内热、动机从求生到见证的转变合乎逻辑;口癖如现代词汇的泄露与吞回、背诵后世诗句,细节具体可扮演;行为模式均能对应具体情境,如擦拭符节、沙地书写、摩挲布料,使内心活动外化,操作性强。贴合度方面:人设与「整体美学」深度融合——色调上他钟爱暮色苍茫(对应美学中过渡交融色调),材质上丝绢布料与符节竹简直接呼应,氛围上他以旁观者身份强化凿空的孤独,叙事基调上条目本身采用白描、简洁第三人称。唯一潜在冲突是‘现代穿越’可能引入跳脱感,但通过克制描写与美学锚定(如以星斗、符节为情感载体),使这种现代性转化为‘文明脆弱与尊严’的镜子,而非破坏。建议在后续扮演中,注意保持语言风格的沉静内敛,避免现代词汇过度使用,确保每次流露都有充分的情绪铺垫。", + "createdAt": "2026-05-31T14:52:59.315115" + } + ], + "timestamp": "2026-05-31T14:58:41.278790" + } + ] + } + ], + "workflowVariables": { + "workflow.goal": "设计一个单人角色:先绑定角色卡与世界书,再迭代整体美学与具体人设,写入世界书条目。", + "workflow.boundCharacter": "名称:汉使人生\nID:f04ba2d6-1ffd-4c33-9cfe-cf6fd026c175", + "workflow.boundWorldbook": "名称:汉使人生\nID:8682f790-1b9d-4842-826b-c07764be6b9b" + }, + "lastPromptBlocks": [ + { + "id": "ref:workflow.goal", + "label": "工作流目标文本", + "content": "设计一个单人角色:先绑定角色卡与世界书,再迭代整体美学与具体人设,写入世界书条目。", + "source": "workflow" + }, + { + "id": "currentProduct", + "label": "目前产物", + "content": "## 整体美学:汉使西行\n\n### 色调\n- **中原印记**:朱红(汉属火德,官服旌旗的正色)、玄黑(冠冕车驾)、生漆赭红(漆器上的暗沉血色)、竹简黄(文书)、青碧(祭祀礼玉之色)\n- **异域风土**:沙黄(大漠戈壁)、雪白(天山远影)、深靛(西域织物的矿物染色)、篝火橙(夜宿)、灰绿(绿洲植被蒙尘后的色泽)\n- **过渡交融**:暮色苍茫中的赭褐与灰紫、夯土烽燧的土黄在夕阳下短暂泛红、风沙迷蒙将一切色彩磨去棱角\n\n### 材质与质感\n- **丝绢**:柔滑而脆弱,易染尘,出使时身着深衣内衬留存的中原触感。在风沙中日渐粗砺,象征中原文明的精致在旅途中的磨损与坚守\n- **青铜**:冷硬沉重,符节、印信、带钩的核心材质。汉代青铜器皿表面未锈,是黄铜般的暖金色,在风沙中磨出本真的铜光——符节上的铜锈反而是后来者才会有的东西\n- **竹简与墨迹**:粗粝温暖的书写载体,旅途中的简牍窄小,字迹仓促,墨中带沙。未用完的空白简在行囊中日益减少,每一片都是珍贵的沉默\n- **西域之器物**:牛皮地图(异域的腥膻气尚未散尽)、驼毛毯(粗硬扎手,但入夜后是唯一的阻寒物)、粗陶与早期金属器——实用、粗犷、带着陌生的制作痕迹\n- **风沙侵蚀**:一切材质最终都被风沙打磨,丝绢起毛、竹简边缘磨损、符节上的铭文因沙粒嵌入而变得更像刻痕。时间在每一件器物上留下颗粒感\n\n### 氛围\n沉郁苍茫为主调,偶有暖色点缀。大部分场景笼罩在旅途的疲惫与未知的紧张中——河西走廊的夯土烽燧一线孤悬,玉门关外天地忽然展开,大漠孤烟直、长河落日圆、异域城邦的喧哗如潮水涌来。间歇性的安宁时刻:篝火旁摊开简牍书写、远方隐约传来的胡笳声、夜空中与长安相同的星斗——这些微小的熟悉感成为使臣情绪的锚点,提醒他从何处来,正去往无人走过之处。\n\n### 光线\n- **白昼**:强烈日照,沙地反光刺目如刀,中原的朱红在此光下被漂白成近乎苍白\n- **黄昏**:最富层次的时刻。暮色将汉家的赤色与西域的沙黄融为一炉,天地过渡成短暂的金红,随后迅速沉入冷紫\n- **夜晚**:篝火为唯一暖光源,其余沉入深蓝与墨黑。河西走廊以西的星空异常清澈,银河如尘如带——这是唯一在异域也被确认的故乡之物\n\n### 核心视觉母题\n**旌旗与符节**——出玉门关前,朱红旌旗是汉使威仪的视觉锚点,在风沙中翻卷、褪色、破损。出关后,大旌旗收卷于车,取而代之的是汉节(竹柄、牦尾、铜龙首),这个更内敛但同样不可丢失的器物,成为后半程的视觉核心。\n\n### 叙事基调\n- 第一人称或限知视角,沉静内敛\n- 语言风格:沿袭汉代文史的简洁克制,白描为主,不渲染情绪;写景状物多用单句,如“沙平如席,日烈如炙”\n- 节奏:舒缓,“行行重行行”的沉浸式旅途感,路程的冗长与间歇的冲击交替\n- 情感表达:以细节和动作代心声——抚摸符节上的铭文字口、在沙地上无意识地用竹笔画出汉字、对异域远山的长时间凝视、取出内衬丝绢一角摩挲\n- 时代底色:使命感的凝重与个体的渺小并存。前方没有先驱,只有传闻与未知——这是凿空,不是因循\n\n### 美学关键词\n苍茫、沉郁、克制、文明的脆弱与尊严、异域的神秘、凿空的孤独", + "source": "auto" + }, + { + "id": "thinkingFlow", + "label": "思考流程", + "content": "step1:首先思考整个故事是怎么样的\nstep2:然后思考如何展示\nstep3:选中核心爽点", + "source": "auto" + }, + { + "id": "coreGoal", + "label": "核心目的", + "content": "产出角色的整体美学设定:视觉风格、氛围、叙事基调,供后续人设步骤引用。", + "source": "auto" + }, + { + "id": "scoringCriteria", + "label": "评价标准与优化建议", + "content": "- 真实性:美学设定是否自洽、可感知,而非空泛形容词堆砌。\n- 贴合度:是否覆盖色调/材质/氛围/叙事基调;是否可与后续人设衔接;表述是否简洁可注入世界书。", + "source": "auto" + } + ], + "title": "", + "createdAt": "2026-05-31T13:10:17.314892", + "updatedAt": "2026-05-31T15:51:20.331874" +} \ No newline at end of file diff --git a/data/agent/studio_runs/test-r1-72cd5b4b/6f4d68e5-f2cf-470d-a9f0-740c1403495f/run.json b/data/agent/studio_runs/test-r1-72cd5b4b/6f4d68e5-f2cf-470d-a9f0-740c1403495f/run.json new file mode 100644 index 0000000..e368dd6 --- /dev/null +++ b/data/agent/studio_runs/test-r1-72cd5b4b/6f4d68e5-f2cf-470d-a9f0-740c1403495f/run.json @@ -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" +} \ No newline at end of file diff --git a/data/agent/studio_runs/test-r1-72cd5b4b/c70b04fb-f793-4a5c-a687-56466aa95a4e/run.json b/data/agent/studio_runs/test-r1-72cd5b4b/c70b04fb-f793-4a5c-a687-56466aa95a4e/run.json new file mode 100644 index 0000000..9d7cc7f --- /dev/null +++ b/data/agent/studio_runs/test-r1-72cd5b4b/c70b04fb-f793-4a5c-a687-56466aa95a4e/run.json @@ -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\nID:b0992d14-cb7c-4d81-b0af-7f7434710903", + "workflow.boundWorldbook": "名称:测试世界书8c09f9\nID:e26e5d15-5b87-4693-aa47-f3021fbdc3d7" + }, + "createdAt": "2026-05-31T14:58:54.643943", + "updatedAt": "2026-05-31T14:58:54.659103" +} \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8ff951b..d01295f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ( -
- {/* ✅ TopBar 不再需要 props,直接从 Store 读取状态 */} - +
+ {!hideTopBar && } {/* 主内容容器 */} {activePage === 'chat' ? ( @@ -243,6 +264,10 @@ function App() {
+ ) : activePage === 'novel' ? ( +
+ +
) : (
diff --git a/frontend/src/Store/Novel/NovelSlice.jsx b/frontend/src/Store/Novel/NovelSlice.jsx new file mode 100644 index 0000000..8458e90 --- /dev/null +++ b/frontend/src/Store/Novel/NovelSlice.jsx @@ -0,0 +1,1107 @@ +import { create } from 'zustand'; + +async function resolveNovelApiConfig() { + let profileId = null; + let apiConfig = {}; + try { + const { default: useApiConfigStore } = await import('../SideBarLeft/ApiConfigSlice'); + const apiState = useApiConfigStore.getState(); + profileId = apiState.currentProfile?.id || null; + const mainLLM = apiState.currentProfile?.apis?.mainLLM; + if (mainLLM) { + apiConfig = { + api_url: mainLLM.apiUrl || '', + model: mainLLM.model || '', + }; + } + } catch { + /* optional store */ + } + return { profileId, apiConfig }; +} + +async function parseErrorResponse(res) { + let detail = await res.text(); + try { + const parsed = JSON.parse(detail); + detail = parsed.detail || detail; + } catch { + /* keep raw */ + } + return detail; +} + +let openBookAbortController = null; +let pipelinePollTimer = null; +let eventPlanStreamAbort = null; + +const EMPTY_EVENT_PLAN_PROGRESS = { + items: [], + done: 0, + total: 0, + error: null, + active: false, +}; + +function mergeEventPlanItems(existing, incoming) { + const map = new Map((existing || []).map((i) => [i.eventId, i])); + for (const item of incoming || []) { + if (item?.eventId) map.set(item.eventId, item); + } + return Array.from(map.values()); +} + +async function readNdjsonStream(res, onEvent) { + if (!res.body) return; + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + onEvent(JSON.parse(trimmed)); + } catch { + /* skip malformed line */ + } + } + } + const tail = buffer.trim(); + if (tail) { + try { + onEvent(JSON.parse(tail)); + } catch { + /* skip */ + } + } +} + +const DEFAULT_PIPELINE = { + semiAuto: false, + autoCoarse: true, + autoEventPlan: true, + autoChapter: true, +}; + +function countPlannedChapters(bookMetadata) { + const chapterPlans = bookMetadata?.chapterPlans || {}; + return Object.values(chapterPlans).reduce( + (sum, plans) => sum + (Array.isArray(plans) ? plans.length : 0), + 0 + ); +} + +function isEventFullyPlanned(bookMetadata, eventId) { + const plans = bookMetadata?.chapterPlans?.[eventId] || []; + return Array.isArray(plans) && plans.length > 0; +} + +function isPipelineBusy(bookRun) { + return bookRun?.status === 'running'; +} + +function pipelineNeedsWork(bookMetadata) { + const volumes = bookMetadata?.volumes || []; + if (volumes.length === 0) return true; + const eventChains = bookMetadata?.eventChains || {}; + const chapterPlans = bookMetadata?.chapterPlans || {}; + for (const volume of volumes) { + const events = eventChains[volume.id] || []; + if (events.length === 0) return true; + if (events.some((evt) => !Array.isArray(chapterPlans[evt.id]) || chapterPlans[evt.id].length === 0)) { + return true; + } + } + return false; +} + +function hasWrittenChapters(chapterList) { + return Array.isArray(chapterList) && chapterList.length > 0; +} + +function resolveReadingSeq(bookMetadata, chapterList) { + const written = (chapterList || []).map((c) => c.seq).sort((a, b) => a - b); + if (written.length === 0) return null; + const saved = bookMetadata?.progress?.currentChapterSeq || 0; + if (saved > 0 && written.includes(saved)) return saved; + return written[0]; +} + +function eventPlansReady(bookMetadata) { + const volumes = bookMetadata?.volumes || []; + if (volumes.length === 0) return false; + const eventChains = bookMetadata?.eventChains || {}; + for (const volume of volumes) { + const events = eventChains[volume.id] || []; + if (events.length === 0) return false; + if (!events.every((evt) => isEventFullyPlanned(bookMetadata, evt.id))) { + return false; + } + } + return true; +} + +function needsChapter(bookMetadata, chapterList) { + return eventPlansReady(bookMetadata) && !hasWrittenChapters(chapterList); +} + +function computePendingStages(bookMetadata, pipelineSettings, bookRun = null, chapterList = []) { + const pipeline = { ...DEFAULT_PIPELINE, ...pipelineSettings }; + const pending = []; + const volumes = bookMetadata?.volumes || []; + const eventChains = bookMetadata?.eventChains || {}; + const chapterPlans = bookMetadata?.chapterPlans || {}; + const needsVolume = volumes.length === 0; + const needsEventChain = + volumes.length > 0 && volumes.some((volume) => (eventChains[volume.id] || []).length === 0); + const needsChapterPlan = + volumes.length > 0 && + !needsEventChain && + volumes.some((volume) => + (eventChains[volume.id] || []).some( + (evt) => !Array.isArray(chapterPlans[evt.id]) || chapterPlans[evt.id].length === 0 + ) + ); + const chapterPending = needsChapter(bookMetadata, chapterList); + + if (needsVolume && !pipeline.autoCoarse) pending.push('volume'); + if (needsEventChain && !pipeline.autoEventPlan) pending.push('event_chain'); + if (needsChapterPlan && !pipeline.autoEventPlan) pending.push('chapter_plan'); + if (chapterPending && !pipeline.autoChapter) pending.push('chapter'); + + if (bookRun?.status === 'error' && bookRun?.pipelineStage) { + const failedMap = { + coarse: 'volume', + volume: 'volume', + event_chain: 'event_chain', + event_plan: 'chapter_plan', + chapter_plan: 'chapter_plan', + chapter: 'chapter', + }; + const failed = failedMap[bookRun.pipelineStage] || bookRun.pipelineStage; + if (failed === 'volume' && needsVolume && !pending.includes('volume')) { + pending.unshift('volume'); + } else if (failed === 'event_chain' && needsEventChain && !pending.includes('event_chain')) { + pending.unshift('event_chain'); + } else if (failed === 'chapter_plan' && needsChapterPlan && !pending.includes('chapter_plan')) { + pending.unshift('chapter_plan'); + } else if (failed === 'chapter' && chapterPending && !pending.includes('chapter')) { + pending.unshift('chapter'); + } + } + return pending; +} + +function syncSemiAuto(pipeline) { + const next = { ...DEFAULT_PIPELINE, ...pipeline }; + next.semiAuto = !(next.autoCoarse && next.autoEventPlan && next.autoChapter); + return next; +} + +const useNovelStore = create((set, get) => ({ + books: [], + emotionFlows: [], + currentBookId: null, + bookMeta: null, + bookGuide: null, + bookSettings: null, + bookMetadata: null, + bookRun: null, + pendingStages: [], + chapterList: [], + currentChapter: null, + readingChapterSeq: null, + view: 'bookshelf', + loading: false, + saving: false, + optimizing: false, + error: null, + message: null, + eventPlanProgress: { ...EMPTY_EVENT_PLAN_PROGRESS }, + + openBookDraft: { + inspiration: '', + title: '', + optimizedIntro: '', + guide: { + volume: { content: '' }, + event: { content: '' }, + chapter: { content: '' }, + }, + allowedFlowIds: [], + }, + + leftDrawerOpen: false, + rightDrawerOpen: false, + readingChromeVisible: false, + nudgeDraft: '', + + setView: (view) => set({ view }), + setMessage: (message) => set({ message }), + clearMessage: () => set({ message: null, error: null }), + setLeftDrawerOpen: (open) => set({ leftDrawerOpen: open, rightDrawerOpen: open ? false : get().rightDrawerOpen }), + setRightDrawerOpen: (open) => set({ rightDrawerOpen: open, leftDrawerOpen: open ? false : get().leftDrawerOpen }), + setReadingChromeVisible: (visible) => set({ readingChromeVisible: visible }), + closeDrawers: () => set({ leftDrawerOpen: false, rightDrawerOpen: false }), + setNudgeDraft: (nudgeDraft) => set({ nudgeDraft }), + + setOpenBookDraft: (patch) => + set((s) => ({ openBookDraft: { ...s.openBookDraft, ...patch } })), + + resetOpenBookDraft: () => + set({ + openBookDraft: { + inspiration: '', + title: '', + optimizedIntro: '', + guide: { + volume: { content: '' }, + event: { content: '' }, + chapter: { content: '' }, + }, + allowedFlowIds: [], + }, + }), + + initNovel: async () => { + const { fetchBooks, fetchEmotionCatalog } = get(); + await Promise.all([fetchBooks(), fetchEmotionCatalog()]); + }, + + fetchBooks: async () => { + set({ loading: true, error: null }); + try { + const res = await fetch('/api/fiction/books'); + if (!res.ok) throw new Error(await parseErrorResponse(res)); + const books = await res.json(); + set({ books, loading: false }); + return books; + } catch (e) { + set({ loading: false, error: e.message || '加载书架失败' }); + return []; + } + }, + + fetchEmotionCatalog: async () => { + try { + const res = await fetch('/api/fiction/emotion-flows/catalog'); + if (!res.ok) throw new Error(await parseErrorResponse(res)); + const data = await res.json(); + set({ emotionFlows: data.flows || [] }); + return data.flows || []; + } catch (e) { + set({ error: e.message || '加载情绪流目录失败' }); + return []; + } + }, + + fetchBook: async (bookId) => { + if (!bookId) return null; + set({ loading: true, error: null }); + try { + const [metaRes, guideRes, settingsRes, metadataRes, runRes] = await Promise.all([ + fetch(`/api/fiction/books/${encodeURIComponent(bookId)}`), + fetch(`/api/fiction/books/${encodeURIComponent(bookId)}/guide`), + fetch(`/api/fiction/books/${encodeURIComponent(bookId)}/settings`), + fetch(`/api/fiction/books/${encodeURIComponent(bookId)}/metadata`), + fetch(`/api/fiction/books/${encodeURIComponent(bookId)}/run`), + ]); + if (!metaRes.ok) throw new Error(await parseErrorResponse(metaRes)); + if (!guideRes.ok) throw new Error(await parseErrorResponse(guideRes)); + if (!settingsRes.ok) throw new Error(await parseErrorResponse(settingsRes)); + if (!metadataRes.ok) throw new Error(await parseErrorResponse(metadataRes)); + if (!runRes.ok) throw new Error(await parseErrorResponse(runRes)); + const meta = await metaRes.json(); + const guide = await guideRes.json(); + const settings = await settingsRes.json(); + const bookMetadata = await metadataRes.json(); + const bookRun = await runRes.json(); + const chapterList = await get().fetchChaptersQuiet(bookId); + const pendingStages = computePendingStages( + bookMetadata, + settings.pipeline, + bookRun, + chapterList + ); + const readingChapterSeq = resolveReadingSeq(bookMetadata, chapterList); + set({ + currentBookId: bookId, + bookMeta: meta, + bookGuide: guide, + bookSettings: { + ...settings, + pipeline: syncSemiAuto(settings.pipeline), + }, + bookMetadata, + bookRun, + chapterList, + readingChapterSeq, + pendingStages, + loading: false, + }); + if (readingChapterSeq) { + await get().fetchChapter(bookId, readingChapterSeq); + } else { + set({ currentChapter: null }); + } + return { meta, guide, settings, bookMetadata, bookRun, chapterList }; + } catch (e) { + set({ loading: false, error: e.message || '加载书籍失败' }); + return null; + } + }, + + fetchChaptersQuiet: async (bookId) => { + if (!bookId) return []; + try { + const res = await fetch(`/api/fiction/books/${encodeURIComponent(bookId)}/chapters`); + if (!res.ok) return []; + return await res.json(); + } catch { + return []; + } + }, + + fetchChapters: async (bookId) => { + const id = bookId || get().currentBookId; + if (!id) return []; + const chapterList = await get().fetchChaptersQuiet(id); + const pendingStages = computePendingStages( + get().bookMetadata, + get().bookSettings?.pipeline, + get().bookRun, + chapterList + ); + set({ chapterList, pendingStages }); + return chapterList; + }, + + fetchChapter: async (bookId, seq) => { + const id = bookId || get().currentBookId; + if (!id || !seq) return null; + try { + const res = await fetch( + `/api/fiction/books/${encodeURIComponent(id)}/chapters/${seq}` + ); + if (!res.ok) throw new Error(await parseErrorResponse(res)); + const currentChapter = await res.json(); + set({ currentChapter, readingChapterSeq: seq }); + return currentChapter; + } catch (e) { + set({ error: e.message || '加载章节失败' }); + return null; + } + }, + + navigateToChapter: async (seq) => { + const { currentBookId, chapterList } = get(); + if (!currentBookId || !seq) return null; + const exists = (chapterList || []).some((c) => c.seq === seq); + if (!exists) return null; + const chapter = await get().fetchChapter(currentBookId, seq); + if (chapter) { + try { + const res = await fetch( + `/api/fiction/books/${encodeURIComponent(currentBookId)}/progress`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ currentChapterSeq: seq, charOffset: 0 }), + } + ); + if (res.ok) { + const bookMetadata = await res.json(); + set({ bookMetadata }); + } + } catch { + /* progress sync optional */ + } + } + get().closeDrawers(); + return chapter; + }, + + fetchRunStatus: async (bookId) => { + const id = bookId || get().currentBookId; + if (!id) return null; + try { + const res = await fetch(`/api/fiction/books/${encodeURIComponent(id)}/pipeline/status`); + if (!res.ok) throw new Error(await parseErrorResponse(res)); + const data = await res.json(); + const pendingStages = computePendingStages( + get().bookMetadata, + get().bookSettings?.pipeline, + data.run, + get().chapterList + ); + set({ bookRun: data.run, pendingStages }); + return data; + } catch (e) { + set({ error: e.message || '加载流水线状态失败' }); + return null; + } + }, + + fetchMetadataQuiet: async (bookId) => { + const id = bookId || get().currentBookId; + if (!id) return null; + try { + const res = await fetch(`/api/fiction/books/${encodeURIComponent(id)}/metadata`); + if (!res.ok) return null; + const bookMetadata = await res.json(); + const chapterList = get().chapterList; + const pendingStages = computePendingStages( + bookMetadata, + get().bookSettings?.pipeline, + get().bookRun, + chapterList + ); + set({ bookMetadata, pendingStages }); + return bookMetadata; + } catch { + return null; + } + }, + + stopEventPlanProgressStream: () => { + if (eventPlanStreamAbort) { + eventPlanStreamAbort.abort(); + eventPlanStreamAbort = null; + } + set((s) => ({ + eventPlanProgress: { ...s.eventPlanProgress, active: false }, + })); + }, + + _applyEventPlanProgressEvent: (event) => { + if (!event?.type) return; + set((s) => { + const prev = s.eventPlanProgress || EMPTY_EVENT_PLAN_PROGRESS; + if (event.type === 'snapshot') { + return { + eventPlanProgress: { + items: mergeEventPlanItems(prev.items, event.items), + done: event.done ?? prev.done, + total: event.total ?? prev.total, + error: null, + active: true, + }, + }; + } + if (event.type === 'started') { + return { + eventPlanProgress: { + ...prev, + done: event.done ?? prev.done, + total: event.total ?? prev.total, + error: null, + active: true, + }, + }; + } + if (event.type === 'event_planned') { + const items = mergeEventPlanItems(prev.items, [event]); + const coarseTotal = + s.bookMetadata?.coarseOutline?.events?.length || prev.total || items.length; + return { + eventPlanProgress: { + items, + done: Math.max(prev.done, items.length), + total: coarseTotal, + error: null, + active: true, + }, + }; + } + if (event.type === 'done') { + return { + eventPlanProgress: { + ...prev, + done: event.done ?? prev.done, + total: event.total ?? prev.total, + active: false, + }, + }; + } + if (event.type === 'error') { + return { + eventPlanProgress: { + ...prev, + done: event.done ?? prev.done, + total: event.total ?? prev.total, + error: event.message || '生成失败', + active: false, + }, + }; + } + return s; + }); + }, + + startEventPlanProgressStream: async (bookId) => { + const id = bookId || get().currentBookId; + if (!id || eventPlanStreamAbort) return; + eventPlanStreamAbort = new AbortController(); + set({ + eventPlanProgress: { ...EMPTY_EVENT_PLAN_PROGRESS, active: true }, + }); + try { + const res = await fetch( + `/api/fiction/books/${encodeURIComponent(id)}/event-plan/stream`, + { signal: eventPlanStreamAbort.signal } + ); + if (!res.ok) throw new Error(await parseErrorResponse(res)); + await readNdjsonStream(res, (evt) => get()._applyEventPlanProgressEvent(evt)); + await get().fetchMetadataQuiet(id); + await get().fetchRunStatus(id); + } catch (e) { + if (e.name !== 'AbortError') { + set((s) => ({ + eventPlanProgress: { + ...s.eventPlanProgress, + error: e.message || '进度流连接失败', + active: false, + }, + })); + } + } finally { + eventPlanStreamAbort = null; + } + }, + + stopPipelinePolling: () => { + if (pipelinePollTimer) { + clearInterval(pipelinePollTimer); + pipelinePollTimer = null; + } + }, + + startPipelinePolling: () => { + get().stopPipelinePolling(); + pipelinePollTimer = setInterval(async () => { + const { currentBookId, view } = get(); + if (view !== 'reading' || !currentBookId) { + get().stopPipelinePolling(); + return; + } + const status = await get().fetchRunStatus(currentBookId); + if (!status) return; + if (status.run?.status === 'running') { + if ( + status.run?.pipelineStage === 'event_plan' || + status.run?.pipelineStage === 'event_chain' || + status.run?.pipelineStage === 'chapter_plan' || + status.run?.stage === 'event_plan_generating' + ) { + get().startEventPlanProgressStream(currentBookId); + } + await get().fetchMetadataQuiet(currentBookId); + return; + } + get().stopEventPlanProgressStream(); + await get().fetchMetadataQuiet(currentBookId); + await get().fetchChapters(currentBookId); + const { chapterList, bookMetadata } = get(); + const seq = resolveReadingSeq(bookMetadata, chapterList); + if (seq) { + await get().fetchChapter(currentBookId, seq); + } + get().stopPipelinePolling(); + if (status.run?.status === 'error') { + /* 错误详情在目录抽屉中展示,主区仅显示通用提示 */ + } else if (status.pendingStages?.length) { + /* 半自动:等待用户在抽屉内手动继续 */ + } else if (pipelineNeedsWork(get().bookMetadata) || needsChapter(get().bookMetadata, get().chapterList)) { + await get().tickReadingPipeline(currentBookId); + } + }, 2000); + }, + + tickReadingPipeline: async (bookId) => { + const id = bookId || get().currentBookId; + if (!id) return null; + try { + const { profileId, apiConfig } = await resolveNovelApiConfig(); + const res = await fetch(`/api/fiction/books/${encodeURIComponent(id)}/pipeline/tick`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ profile_id: profileId, api_config: apiConfig }), + }); + if (!res.ok) throw new Error(await parseErrorResponse(res)); + const result = await res.json(); + const pendingStages = computePendingStages( + get().bookMetadata, + get().bookSettings?.pipeline, + result.run, + get().chapterList + ); + set({ bookRun: result.run, pendingStages }); + if (result.run?.status === 'running') { + if ( + result.run?.pipelineStage === 'event_plan' || + result.run?.pipelineStage === 'event_chain' || + result.run?.pipelineStage === 'chapter_plan' || + result.run?.stage === 'event_plan_generating' + ) { + get().startEventPlanProgressStream(id); + } + get().startPipelinePolling(); + } + return result; + } catch (e) { + set({ error: e.message || '启动创作流水线失败' }); + return null; + } + }, + + triggerCoarseOutline: async () => { + const { currentBookId } = get(); + if (!currentBookId) return null; + set({ saving: true, error: null }); + try { + const { profileId, apiConfig } = await resolveNovelApiConfig(); + const res = await fetch( + `/api/fiction/books/${encodeURIComponent(currentBookId)}/volumes/ensure`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ profile_id: profileId, api_config: apiConfig }), + } + ); + if (!res.ok) throw new Error(await parseErrorResponse(res)); + const bookMetadata = await res.json(); + const pendingStages = computePendingStages( + bookMetadata, + get().bookSettings?.pipeline, + get().bookRun, + get().chapterList + ); + set({ bookMetadata, pendingStages, saving: false }); + await get().fetchRunStatus(currentBookId); + await get().tickReadingPipeline(currentBookId); + get().startPipelinePolling(); + return bookMetadata; + } catch (e) { + set({ saving: false, error: e.message || '生成粗纲失败' }); + await get().fetchRunStatus(currentBookId); + return null; + } + }, + + triggerEventPlan: async () => { + const { currentBookId } = get(); + if (!currentBookId) return null; + set({ + saving: true, + error: null, + eventPlanProgress: { ...EMPTY_EVENT_PLAN_PROGRESS, active: true }, + }); + try { + const { profileId, apiConfig } = await resolveNovelApiConfig(); + const res = await fetch( + `/api/fiction/books/${encodeURIComponent(currentBookId)}/chapter-plans/ensure`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + profile_id: profileId, + api_config: apiConfig, + }), + } + ); + if (!res.ok) throw new Error(await parseErrorResponse(res)); + const contentType = res.headers.get('content-type') || ''; + if (contentType.includes('ndjson') || contentType.includes('x-ndjson')) { + await readNdjsonStream(res, (evt) => get()._applyEventPlanProgressEvent(evt)); + } else { + const bookMetadata = await res.json(); + set({ bookMetadata }); + } + await get().fetchMetadataQuiet(currentBookId); + const bookMetadata = get().bookMetadata; + const pendingStages = computePendingStages( + bookMetadata, + get().bookSettings?.pipeline, + get().bookRun, + get().chapterList + ); + set({ pendingStages, saving: false }); + await get().fetchRunStatus(currentBookId); + await get().tickReadingPipeline(currentBookId); + get().startPipelinePolling(); + return bookMetadata; + } catch (e) { + set({ saving: false, error: e.message || '生成事件规划失败' }); + set((s) => ({ + eventPlanProgress: { ...s.eventPlanProgress, active: false, error: e.message }, + })); + await get().fetchRunStatus(currentBookId); + await get().fetchMetadataQuiet(currentBookId); + return null; + } + }, + + triggerChapter: async () => { + const { currentBookId } = get(); + if (!currentBookId) return null; + set({ saving: true, error: null }); + try { + const { profileId, apiConfig } = await resolveNovelApiConfig(); + const res = await fetch( + `/api/fiction/books/${encodeURIComponent(currentBookId)}/chapters/ensure`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ profile_id: profileId, api_config: apiConfig }), + } + ); + if (!res.ok) throw new Error(await parseErrorResponse(res)); + const currentChapter = await res.json(); + await get().fetchChapters(currentBookId); + await get().fetchMetadataQuiet(currentBookId); + await get().fetchRunStatus(currentBookId); + set({ currentChapter, readingChapterSeq: currentChapter.seq, saving: false }); + return currentChapter; + } catch (e) { + set({ saving: false, error: e.message || '撰写章节失败' }); + await get().fetchRunStatus(currentBookId); + return null; + } + }, + + openBook: async (bookId) => { + const result = await get().fetchBook(bookId); + if (!result) return; + + set({ + view: 'reading', + leftDrawerOpen: false, + rightDrawerOpen: false, + readingChromeVisible: false, + }); + + const { bookMetadata, bookSettings, bookRun, chapterList } = get(); + const needsWork = pipelineNeedsWork(bookMetadata); + const needsFirstChapter = needsChapter(bookMetadata, chapterList); + const hasPending = computePendingStages( + bookMetadata, + bookSettings?.pipeline, + bookRun, + chapterList + ).length > 0; + + if (needsWork || needsFirstChapter || hasPending || bookRun?.status === 'error') { + await get().tickReadingPipeline(bookId); + const updated = get(); + if ( + isPipelineBusy(updated.bookRun) || + updated.bookRun?.status === 'error' || + pipelineNeedsWork(updated.bookMetadata) || + needsChapter(updated.bookMetadata, updated.chapterList) + ) { + get().startPipelinePolling(); + } + } + }, + + deleteBook: async (bookId) => { + set({ saving: true, error: null }); + try { + const res = await fetch(`/api/fiction/books/${encodeURIComponent(bookId)}`, { + method: 'DELETE', + }); + if (!res.ok) throw new Error(await parseErrorResponse(res)); + const books = get().books.filter((b) => b.id !== bookId); + const patch = { books, saving: false, message: '已删除' }; + if (get().currentBookId === bookId) { + get().stopPipelinePolling(); + get().stopEventPlanProgressStream(); + Object.assign(patch, { + eventPlanProgress: { ...EMPTY_EVENT_PLAN_PROGRESS }, + currentBookId: null, + bookMeta: null, + bookGuide: null, + bookSettings: null, + bookMetadata: null, + bookRun: null, + pendingStages: [], + chapterList: [], + currentChapter: null, + readingChapterSeq: null, + view: 'bookshelf', + }); + } + set(patch); + return true; + } catch (e) { + set({ saving: false, error: e.message || '删除失败' }); + return false; + } + }, + + createBook: async () => { + const { openBookDraft } = get(); + const title = (openBookDraft.title || '').trim(); + if (!title) { + set({ error: '请先填写书名' }); + return null; + } + set({ saving: true, error: null }); + try { + const res = await fetch('/api/fiction/books', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title, + inspiration: openBookDraft.inspiration, + guide: openBookDraft.guide, + allowedFlowIds: openBookDraft.allowedFlowIds, + }), + }); + if (!res.ok) throw new Error(await parseErrorResponse(res)); + const meta = await res.json(); + await get().fetchBooks(); + set({ + saving: false, + message: '开书成功', + currentBookId: meta.id, + bookMeta: meta, + view: 'reading', + readingChromeVisible: false, + }); + await get().fetchBook(meta.id); + get().resetOpenBookDraft(); + await get().tickReadingPipeline(meta.id); + get().startPipelinePolling(); + return meta; + } catch (e) { + set({ saving: false, error: e.message || '创建书籍失败' }); + return null; + } + }, + + optimizeOpenBook: async () => { + const inspiration = (get().openBookDraft.inspiration || '').trim(); + if (!inspiration) { + set({ error: '请先在左侧输入创作灵感' }); + return null; + } + if (openBookAbortController) { + openBookAbortController.abort(); + } + openBookAbortController = new AbortController(); + set({ optimizing: true, error: null }); + try { + const { profileId, apiConfig } = await resolveNovelApiConfig(); + const res = await fetch('/api/fiction/open-book', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + inspiration, + profile_id: profileId, + api_config: apiConfig, + }), + signal: openBookAbortController.signal, + }); + if (!res.ok) throw new Error(await parseErrorResponse(res)); + const result = await res.json(); + set((s) => ({ + optimizing: false, + openBookDraft: { + ...s.openBookDraft, + title: result.title || s.openBookDraft.title, + optimizedIntro: result.optimizedIntro || '', + guide: result.guide || s.openBookDraft.guide, + allowedFlowIds: result.allowedFlowIds || [], + }, + message: '优化完成,请确认右侧结果', + })); + return result; + } catch (e) { + if (e.name === 'AbortError') { + set({ optimizing: false }); + return null; + } + set({ optimizing: false, error: e.message || '开书优化失败' }); + return null; + } finally { + openBookAbortController = null; + } + }, + + cancelOptimize: () => { + if (openBookAbortController) { + openBookAbortController.abort(); + openBookAbortController = null; + } + set({ optimizing: false }); + }, + + toggleFlowId: (flowId) => { + set((s) => { + const ids = new Set(s.openBookDraft.allowedFlowIds || []); + if (ids.has(flowId)) ids.delete(flowId); + else ids.add(flowId); + return { openBookDraft: { ...s.openBookDraft, allowedFlowIds: [...ids] } }; + }); + }, + + toggleFlowsByTag: (tag) => { + const flows = get().emotionFlows.filter((f) => (f.tags || []).includes(tag)); + const flowIds = flows.map((f) => f.id); + const current = new Set(get().openBookDraft.allowedFlowIds || []); + const allSelected = flowIds.length > 0 && flowIds.every((id) => current.has(id)); + if (allSelected) { + flowIds.forEach((id) => current.delete(id)); + } else { + flowIds.forEach((id) => current.add(id)); + } + set((s) => ({ + openBookDraft: { ...s.openBookDraft, allowedFlowIds: [...current] }, + })); + }, + + updateBookSettingsLocal: (patch) => + set((s) => ({ + bookSettings: { ...s.bookSettings, ...patch }, + })), + + updatePromptsLocal: (key, value) => + set((s) => ({ + bookSettings: { + ...s.bookSettings, + prompts: { ...s.bookSettings?.prompts, [key]: value }, + }, + })), + + updateReaderLocal: (key, value) => + set((s) => ({ + bookSettings: { + ...s.bookSettings, + reader: { ...s.bookSettings?.reader, [key]: value }, + }, + })), + + updatePipelineLocal: (key, value) => + set((s) => { + const pipeline = syncSemiAuto({ + ...DEFAULT_PIPELINE, + ...s.bookSettings?.pipeline, + [key]: value, + }); + const pendingStages = computePendingStages(s.bookMetadata, pipeline, s.bookRun, s.chapterList); + return { + bookSettings: { ...s.bookSettings, pipeline }, + pendingStages, + }; + }), + + saveBookSettings: async () => { + const { currentBookId, bookSettings } = get(); + if (!currentBookId || !bookSettings) return false; + set({ saving: true, error: null }); + try { + const pipeline = syncSemiAuto(bookSettings.pipeline); + const res = await fetch( + `/api/fiction/books/${encodeURIComponent(currentBookId)}/settings`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompts: bookSettings.prompts, + reader: bookSettings.reader, + pipeline, + }), + } + ); + if (!res.ok) throw new Error(await parseErrorResponse(res)); + const settings = await res.json(); + const mergedPipeline = syncSemiAuto(settings.pipeline); + const pendingStages = computePendingStages( + get().bookMetadata, + mergedPipeline, + get().bookRun, + get().chapterList + ); + set({ + bookSettings: { ...settings, pipeline: mergedPipeline }, + pendingStages, + saving: false, + message: '设置已保存', + }); + await get().tickReadingPipeline(currentBookId); + return true; + } catch (e) { + set({ saving: false, error: e.message || '保存设置失败' }); + return false; + } + }, + + goToBookshelf: () => { + get().stopPipelinePolling(); + get().stopEventPlanProgressStream(); + set({ + eventPlanProgress: { ...EMPTY_EVENT_PLAN_PROGRESS }, + view: 'bookshelf', + leftDrawerOpen: false, + rightDrawerOpen: false, + readingChromeVisible: false, + nudgeDraft: '', + currentChapter: null, + readingChapterSeq: null, + chapterList: [], + }); + }, + + submitNudge: () => { + const draft = (get().nudgeDraft || '').trim(); + if (!draft) { + set({ error: '请输入写作建议或反馈' }); + return; + } + set({ + nudgeDraft: '', + message: '已记录反馈(fiction.nudge 功能即将上线)', + }); + }, + + retryFailedPipeline: async () => { + const { bookRun, pendingStages, currentBookId } = get(); + if (!currentBookId || bookRun?.status !== 'error') return null; + const stage = bookRun.pipelineStage; + if (stage === 'coarse' || stage === 'volume' || pendingStages.includes('volume')) { + return get().triggerCoarseOutline(); + } + if ( + stage === 'event_plan' || + stage === 'event_chain' || + stage === 'chapter_plan' || + pendingStages.includes('event_chain') || + pendingStages.includes('chapter_plan') + ) { + return get().triggerEventPlan(); + } + if (stage === 'chapter' || pendingStages.includes('chapter')) { + return get().triggerChapter(); + } + return get().tickReadingPipeline(currentBookId); + }, + + startOpenBook: () => { + get().resetOpenBookDraft(); + set({ view: 'open_book', error: null, message: null }); + }, + + countPlannedChapters, + isPipelineBusy, + pipelineNeedsWork, + computePendingStages, +})); + +export default useNovelStore; diff --git a/frontend/src/components/Novel/NovelPage.css b/frontend/src/components/Novel/NovelPage.css new file mode 100644 index 0000000..cb4028c --- /dev/null +++ b/frontend/src/components/Novel/NovelPage.css @@ -0,0 +1,1041 @@ +.novel-page { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--color-bg-primary); +} + +.novel-page-title { + margin: 0; + font-size: 1.35rem; + font-weight: 600; + color: var(--color-text-primary); +} + +.novel-page-subtitle { + margin: var(--spacing-xs) 0 0; + font-size: 0.875rem; + color: var(--color-text-secondary); +} + +.novel-muted { + color: var(--color-text-secondary); + font-size: 0.875rem; +} + +.novel-banner { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-sm) var(--spacing-lg); + font-size: 0.875rem; + flex-shrink: 0; +} + +.novel-banner-error { + background: color-mix(in srgb, #e53935 12%, var(--color-bg-secondary)); + color: var(--color-text-primary); + border-bottom: 1px solid color-mix(in srgb, #e53935 30%, transparent); +} + +.novel-banner-success { + background: color-mix(in srgb, var(--color-accent) 10%, var(--color-bg-secondary)); + border-bottom: 1px solid var(--color-border-light); +} + +.novel-banner-close { + border: none; + background: transparent; + font-size: 1.25rem; + line-height: 1; + cursor: pointer; + color: var(--color-text-secondary); + padding: 0 var(--spacing-xs); +} + +.novel-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing-xs); + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background: var(--color-bg-secondary); + color: var(--color-text-primary); + font-size: 0.875rem; + cursor: pointer; + transition: background var(--transition-fast), border-color var(--transition-fast); +} + +.novel-btn:hover:not(:disabled) { + background: var(--color-bg-tertiary); +} + +.novel-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.novel-btn-primary { + background: var(--color-accent); + border-color: var(--color-accent); + color: #fff; +} + +.novel-btn-primary:hover:not(:disabled) { + filter: brightness(1.05); +} + +.novel-btn-ghost { + background: transparent; + border-color: transparent; +} + +.novel-btn-ghost.active, +.novel-btn-ghost:hover:not(:disabled) { + background: var(--color-bg-tertiary); + border-color: var(--color-border-light); +} + +.novel-btn-sm { + padding: var(--spacing-xs) var(--spacing-sm); + font-size: 0.8rem; + margin-top: var(--spacing-xs); +} + +.novel-input, +.novel-textarea { + width: 100%; + box-sizing: border-box; + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background: var(--color-bg-tertiary); + color: var(--color-text-primary); + font-size: 0.875rem; + font-family: inherit; + line-height: 1.5; +} + +.novel-input:focus, +.novel-textarea:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 2px var(--color-accent-light); +} + +.novel-textarea { + min-height: 120px; + resize: vertical; +} + +.novel-textarea-tall { + min-height: 320px; + flex: 1; +} + +.novel-textarea-inspiration { + min-height: 72px; + max-height: none; + resize: none; + flex: 0 0 auto; + overflow-y: hidden; +} + +.novel-textarea-prompt { + min-height: 100px; + font-family: inherit; + font-size: 0.875rem; + line-height: 1.6; +} + +.novel-input-narrow { + width: 120px; +} + +.novel-field { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + margin-bottom: var(--spacing-md); +} + +.novel-field > span { + font-size: 0.8rem; + font-weight: 500; + color: var(--color-text-secondary); +} + +.novel-field-inline { + flex-direction: row; + align-items: center; + justify-content: space-between; +} + +.novel-section-title { + margin: 0 0 var(--spacing-sm); + font-size: 0.95rem; + font-weight: 600; +} + +/* Bookshelf */ +.novel-bookshelf { + flex: 1; + overflow: auto; + padding: var(--spacing-lg) var(--spacing-xl); + max-width: 900px; + margin: 0 auto; + width: 100%; + box-sizing: border-box; +} + +.novel-bookshelf-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--spacing-md); + margin-bottom: var(--spacing-xl); +} + +.novel-empty { + text-align: center; + padding: var(--spacing-3xl) var(--spacing-lg); + border: 1px dashed var(--color-border-light); + border-radius: var(--radius-lg); + background: var(--color-bg-secondary); +} + +.novel-empty p { + margin: 0 0 var(--spacing-md); + color: var(--color-text-secondary); +} + +.novel-book-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.novel-book-card { + display: flex; + align-items: center; + gap: var(--spacing-sm); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); + background: var(--color-bg-secondary); + overflow: hidden; + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.novel-book-card:hover { + border-color: var(--color-accent); + box-shadow: var(--shadow-sm); +} + +.novel-book-card-main { + display: flex; + align-items: center; + gap: var(--spacing-md); + flex: 1; + min-width: 0; + padding: var(--spacing-md) var(--spacing-lg); + border: none; + background: transparent; + cursor: pointer; + text-align: left; + color: inherit; + font: inherit; +} + +.novel-book-card-icon { + font-size: 1.75rem; + flex-shrink: 0; +} + +.novel-book-card-body { + flex: 1; + min-width: 0; +} + +.novel-book-card-body h2 { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.novel-book-card-body p { + margin: var(--spacing-xs) 0 0; +} + +.novel-book-delete { + flex-shrink: 0; + margin-right: var(--spacing-md); + padding: var(--spacing-xs) var(--spacing-sm); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-secondary); + font-size: 0.75rem; + cursor: pointer; +} + +.novel-book-delete:hover { + color: #e53935; + border-color: color-mix(in srgb, #e53935 40%, transparent); +} + +/* Open book dual column */ +.novel-open-book { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.novel-open-book-header { + flex-shrink: 0; + padding: var(--spacing-md) var(--spacing-xl) 0; +} + +.novel-open-book-columns { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-md); + padding: var(--spacing-md) var(--spacing-xl) var(--spacing-lg); + overflow: hidden; +} + +.novel-open-col { + display: flex; + flex-direction: column; + min-height: 0; + overflow: auto; + padding: var(--spacing-md); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-lg); + background: var(--color-bg-secondary); +} + +.novel-open-col-left { + overflow: hidden; +} + +.novel-open-actions { + display: flex; + gap: var(--spacing-sm); + margin-top: var(--spacing-md); + flex-shrink: 0; +} + +.novel-open-actions-sticky { + margin-top: auto; + padding-top: var(--spacing-md); + border-top: 1px solid var(--color-border-light); +} + +.novel-guide-fields { + margin-top: var(--spacing-md); +} + +/* Emotion flows */ +.novel-flow-picker { + margin-top: var(--spacing-md); +} + +.novel-tag-batch { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-xs); + margin-bottom: var(--spacing-sm); +} + +.novel-tag-btn { + padding: 2px 10px; + border-radius: var(--radius-full); + border: 1px solid var(--color-border); + background: var(--color-bg-tertiary); + font-size: 0.75rem; + cursor: pointer; + color: var(--color-text-primary); +} + +.novel-tag-btn:hover { + border-color: var(--color-accent); + color: var(--color-accent); +} + +.novel-flow-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.novel-flow-item { + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); + padding: var(--spacing-sm); + background: var(--color-bg-primary); +} + +.novel-flow-label { + display: flex; + gap: var(--spacing-sm); + cursor: pointer; + font-size: 0.85rem; +} + +.novel-flow-label input { + margin-top: 4px; + flex-shrink: 0; +} + +.novel-flow-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: var(--spacing-xs) 0; +} + +.novel-flow-tag { + font-size: 0.7rem; + padding: 1px 6px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--color-accent) 12%, transparent); + color: var(--color-accent); +} + +.novel-flow-steps { + margin: var(--spacing-xs) 0 0; + padding-left: 1.2rem; + font-size: 0.8rem; + color: var(--color-text-secondary); +} + +.novel-flow-steps em { + font-style: normal; + font-weight: 600; + color: var(--color-text-primary); + margin-right: 4px; +} + +/* Reading — full viewport column */ +.novel-reading { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + width: 100%; + position: relative; +} + +.novel-reading-chrome { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 4; + transform: translateY(-100%); + opacity: 0; + pointer-events: none; + transition: transform var(--transition-normal), opacity var(--transition-normal); +} + +.novel-reading-chrome.visible { + transform: translateY(0); + opacity: 1; + pointer-events: auto; +} + +.novel-reading-header { + flex-shrink: 0; + display: flex; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-sm) var(--spacing-lg); + border-bottom: 1px solid var(--color-border-light); + background: color-mix(in srgb, var(--color-bg-secondary) 92%, transparent); + backdrop-filter: blur(8px); +} + +.novel-reading-title { + flex: 1; + margin: 0; + font-size: 1rem; + font-weight: 600; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.novel-reading-header-actions { + display: flex; + gap: var(--spacing-xs); +} + +.novel-reading-body { + flex: 1; + min-height: 0; + display: flex; + position: relative; + overflow: hidden; +} + +.novel-drawer-backdrop { + position: absolute; + inset: 0; + z-index: 5; + border: none; + background: color-mix(in srgb, #000 35%, transparent); + cursor: pointer; +} + +.novel-drawer { + position: absolute; + top: 0; + bottom: 0; + width: min(320px, 88vw); + overflow: hidden; + flex-shrink: 0; + transition: transform var(--transition-normal); + border-color: var(--color-border-light); + background: var(--color-bg-secondary); + padding: 0; + z-index: 6; + display: flex; + flex-direction: column; + box-shadow: var(--shadow-lg); +} + +.novel-drawer-left { + left: 0; + transform: translateX(-100%); +} + +.novel-drawer-right { + right: 0; + transform: translateX(100%); +} + +.novel-drawer.open { + transform: translateX(0); + padding: var(--spacing-md); + overflow: auto; +} + +.novel-drawer-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-md); + flex-shrink: 0; +} + +.novel-drawer-header .novel-section-title { + margin: 0; +} + +.novel-drawer-close { + border: none; + background: transparent; + font-size: 1.35rem; + line-height: 1; + cursor: pointer; + color: var(--color-text-secondary); + padding: 0 var(--spacing-xs); +} + +.novel-drawer-left.open { + border-right: 1px solid var(--color-border-light); +} + +.novel-drawer-right.open { + border-left: 1px solid var(--color-border-light); +} + +.novel-drawer-manual-actions { + margin-top: auto; + padding-top: var(--spacing-md); + border-top: 1px solid var(--color-border-light); + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.novel-btn-block { + width: 100%; +} + +.novel-reading-main { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + background: var(--color-bg-primary); + overflow: hidden; + position: relative; + cursor: default; +} + +.novel-reading-status { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--spacing-md); + padding: var(--spacing-xl); + text-align: center; + color: var(--color-text-secondary); +} + +.novel-reading-status p { + margin: 0; + font-size: 1rem; +} + +.novel-reading-status-hint { + font-size: 0.85rem !important; +} + +.novel-event-plan-progress { + width: 100%; + max-width: 28rem; + margin-top: var(--spacing-md); + text-align: left; +} + +.novel-event-plan-progress.compact { + max-width: none; + margin-top: 0; +} + +.novel-event-plan-progress-title { + margin: 0 0 var(--spacing-xs); + font-size: 0.95rem; + color: var(--color-text-primary); + text-align: center; +} + +.novel-event-plan-progress-counter { + margin: 0 0 var(--spacing-sm); + text-align: center; +} + +.novel-event-plan-progress-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.novel-event-plan-progress-item { + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-md); + background: var(--color-bg-secondary); + border: 1px solid var(--color-border-light); +} + +.novel-event-plan-progress-item-title { + font-weight: 600; + font-size: 0.9rem; + color: var(--color-text-primary); +} + +.novel-event-plan-progress-item-meta { + font-size: 0.8rem; + color: var(--color-text-secondary); +} + +.novel-reading-edge { + position: absolute; + top: 0; + bottom: 0; + width: 48px; + border: none; + background: transparent; + cursor: pointer; + z-index: 2; + opacity: 0; +} + +.novel-reading-edge-left { + left: 0; +} + +.novel-reading-edge-right { + right: 0; +} + +.novel-reading-edge:hover { + opacity: 1; + background: linear-gradient( + to right, + color-mix(in srgb, var(--color-accent) 8%, transparent), + transparent + ); +} + +.novel-reading-edge-right:hover { + background: linear-gradient( + to left, + color-mix(in srgb, var(--color-accent) 8%, transparent), + transparent + ); +} + +.novel-reading-content { + flex: 1; + min-height: 0; + overflow: auto; + padding: var(--spacing-xl) clamp(var(--spacing-md), 4vw, var(--spacing-3xl)); + font-size: 1.05rem; + line-height: 1.85; + letter-spacing: 0.02em; + max-width: 720px; + width: 100%; + margin: 0 auto; + box-sizing: border-box; + color: var(--color-text-primary); +} + +.novel-chapter-title { + margin: 0 0 var(--spacing-lg); + font-size: 1.25rem; + font-weight: 600; + text-align: center; + color: var(--color-text-primary); +} + +.novel-chapter-body p { + margin: 0 0 1em; + text-indent: 2em; +} + +.novel-chapter-body p:last-child { + margin-bottom: 0; +} + +.novel-toc-chapter-list { + list-style: none; + margin: 0 0 var(--spacing-lg); + padding: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.novel-toc-chapter-btn { + width: 100%; + text-align: left; + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); + background: var(--color-bg-primary); + color: var(--color-text-primary); + font: inherit; + cursor: pointer; + transition: background var(--transition-fast); +} + +.novel-toc-chapter-btn:hover { + background: var(--color-bg-tertiary); +} + +.novel-toc-chapter-btn.active { + border-color: var(--color-accent); + background: color-mix(in srgb, var(--color-accent) 10%, var(--color-bg-primary)); +} + +.novel-pipeline-banner { + flex-shrink: 0; + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-lg); + font-size: 0.875rem; + color: var(--color-text-secondary); + background: color-mix(in srgb, var(--color-accent) 8%, var(--color-bg-secondary)); + border-bottom: 1px solid var(--color-border-light); +} + +.novel-pipeline-banner-error { + color: var(--color-text-primary); + background: color-mix(in srgb, #e53935 10%, var(--color-bg-secondary)); + border-bottom-color: color-mix(in srgb, #e53935 25%, transparent); +} + +.novel-pipeline-spinner { + width: 14px; + height: 14px; + border: 2px solid var(--color-border); + border-top-color: var(--color-accent); + border-radius: 50%; + animation: novel-spin 0.8s linear infinite; + flex-shrink: 0; +} + +@keyframes novel-spin { + to { + transform: rotate(360deg); + } +} + +.novel-pipeline-panel { + margin-bottom: var(--spacing-xl); + padding: var(--spacing-md); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-lg); + background: var(--color-bg-secondary); +} + +.novel-pipeline-toggles { + margin-bottom: var(--spacing-lg); + padding-bottom: var(--spacing-md); + border-bottom: 1px solid var(--color-border-light); +} + +.novel-pipeline-toggle-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.novel-pipeline-toggle-item { + display: flex; + flex-direction: column; + gap: 2px; +} + +.novel-pipeline-toggle { + display: inline-flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-xs) var(--spacing-sm); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); + background: var(--color-bg-primary); + cursor: pointer; + font: inherit; + color: var(--color-text-primary); + text-align: left; + width: fit-content; +} + +.novel-pipeline-toggle.on { + border-color: var(--color-accent); + background: color-mix(in srgb, var(--color-accent) 10%, var(--color-bg-primary)); +} + +.novel-pipeline-toggle-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--color-border); + flex-shrink: 0; +} + +.novel-pipeline-toggle.on .novel-pipeline-toggle-dot { + background: var(--color-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 25%, transparent); +} + +.novel-pipeline-toggle-label { + font-weight: 600; + font-size: 0.9rem; +} + +.novel-pipeline-toggle-hint { + margin: 0 0 0 calc(10px + var(--spacing-sm)); + font-size: 0.75rem; +} + +.novel-pipeline-status { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm) var(--spacing-lg); + margin: var(--spacing-sm) 0 var(--spacing-md); + font-size: 0.85rem; +} + +.novel-pipeline-actions { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm); +} + +.novel-toc-list { + list-style: none; + margin: var(--spacing-md) 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.novel-toc-item { + padding: var(--spacing-sm); + border-radius: var(--radius-md); + background: var(--color-bg-primary); + border: 1px solid var(--color-border-light); +} + +.novel-toc-item strong { + display: block; + font-size: 0.875rem; +} + +.novel-toc-item p { + margin: var(--spacing-xs) 0 0; + font-size: 0.8rem; +} + +.novel-toc-meta { + display: inline-block; + margin-top: var(--spacing-xs); + font-size: 0.75rem; + color: var(--color-accent); +} + +.novel-reading-placeholder { + text-align: center; + margin: var(--spacing-3xl) 0; +} + +.novel-reading-snippet { + margin-top: var(--spacing-lg); + padding: var(--spacing-md); + border-radius: var(--radius-md); + background: var(--color-bg-tertiary); + font-size: 0.875rem; + line-height: 1.6; +} + +.novel-toc-placeholder { + list-style: none; + margin: var(--spacing-md) 0 0; + padding: 0; +} + +.novel-settings-panel { + font-size: 0.875rem; +} + +.novel-settings-hint { + margin: 0 0 var(--spacing-md); +} + +.novel-settings-hint strong { + font-weight: 600; + color: var(--color-text-primary); +} + +.novel-reading-bottom { + position: absolute; + left: 0; + right: 0; + bottom: 0; + z-index: 4; + transform: translateY(100%); + opacity: 0; + pointer-events: none; + transition: transform var(--transition-normal), opacity var(--transition-normal); + border-top: 1px solid var(--color-border-light); + background: color-mix(in srgb, var(--color-bg-secondary) 92%, transparent); + backdrop-filter: blur(8px); + padding: var(--spacing-sm) var(--spacing-md); +} + +.novel-reading-bottom.visible { + transform: translateY(0); + opacity: 1; + pointer-events: auto; +} + +.novel-nudge-form { + display: flex; + align-items: center; + gap: var(--spacing-sm); + max-width: 720px; + margin: 0 auto; +} + +.novel-nudge-input { + flex: 1; + min-width: 0; + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background: var(--color-bg-tertiary); + color: var(--color-text-primary); + font-size: 0.875rem; + font-family: inherit; +} + +.novel-nudge-input:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 2px var(--color-accent-light); +} + +.novel-nudge-submit { + flex-shrink: 0; +} + +.novel-drawer-error { + margin-bottom: var(--spacing-md); + padding: var(--spacing-md); + border-radius: var(--radius-md); + background: color-mix(in srgb, #e53935 8%, var(--color-bg-primary)); + border: 1px solid color-mix(in srgb, #e53935 25%, transparent); +} + +.novel-drawer-error-title { + margin: 0 0 var(--spacing-xs); + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text-primary); +} + +@media (max-width: 900px) { + .novel-open-book-columns { + grid-template-columns: 1fr; + overflow: auto; + } + + .novel-open-col { + max-height: none; + } + + .novel-open-col-left { + min-height: 220px; + } + + .novel-reading-content { + padding: var(--spacing-md); + } +} diff --git a/frontend/src/components/Novel/NovelPage.jsx b/frontend/src/components/Novel/NovelPage.jsx new file mode 100644 index 0000000..b0ca281 --- /dev/null +++ b/frontend/src/components/Novel/NovelPage.jsx @@ -0,0 +1,1043 @@ +import React, { useEffect, useMemo, useRef, useCallback } from 'react'; + +import useNovelStore from '../../Store/Novel/NovelSlice'; + +import './NovelPage.css'; + +const GUIDE_FIELDS = [ + { key: 'persona', label: '主角人设' }, + { key: 'highlight', label: '核心爽点' }, + { key: 'experience', label: '用户体验' }, + { key: 'forbiddenZones', label: '创作禁区' }, +]; + +const PROMPT_FIELDS = [ + { + key: 'openBook', + label: '开书优化', + placeholder: '例如:侧重历史穿越题材,主角智谋型,节奏要快、打脸要狠…', + }, + { + key: 'coarseOutline', + label: '粗纲', + placeholder: '例如:按事件链推进,每 3–5 个事件一个小高潮,避免拖沓…', + }, + { + key: 'eventPlan', + label: '事件规划', + placeholder: '例如:结合情绪流起承转合,每章一个核心冲突,章末留钩子…', + }, + { + key: 'chapter', + label: '章节写作', + placeholder: '例如:对话推动情节,描写简洁,每章 2000 字左右,爽点要落地…', + }, + { + key: 'nudge', + label: '写作建议', + placeholder: '例如:从读者视角给出简短建议,不直接写正文,语气像编辑…', + }, +]; + +const PIPELINE_LAYERS = [ + { key: 'autoCoarse', label: '粗纲', hint: '开启后进入阅读时自动生成粗纲' }, + { key: 'autoEventPlan', label: '事件纲要', hint: '粗纲完成后自动生成事件规划' }, + { key: 'autoChapter', label: '写章', hint: '事件纲要完成后自动撰写首章正文' }, +]; + +function AutoGrowTextarea({ + className = '', + value, + onChange, + placeholder, + minRows = 3, + maxRows = 14, +}) { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + el.style.height = 'auto'; + const style = window.getComputedStyle(el); + const lineHeight = parseFloat(style.lineHeight) || 22; + const padding = + parseFloat(style.paddingTop) + parseFloat(style.paddingBottom); + const maxHeight = lineHeight * maxRows + padding; + const nextHeight = Math.min(el.scrollHeight, maxHeight); + el.style.height = `${nextHeight}px`; + el.style.overflowY = el.scrollHeight > maxHeight ? 'auto' : 'hidden'; + }, [value, maxRows]); + + return ( +