feat: add NovelPage component with bookshelf, open book, and reading views
This commit is contained in:
76
AGENTS.md
Normal file
76
AGENTS.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# AI Agent Guidance for this Repository
|
||||
|
||||
## Repository overview
|
||||
- Backend: `backend/` using FastAPI, Python 3.11+, `uvicorn --reload` for development.
|
||||
- Frontend: `frontend/` using React 18 + Vite + TypeScript, with Zustand for state.
|
||||
- Runtime data is stored under `data/` as JSON/files; do not treat it as source code.
|
||||
- Docker support exists via `docker-compose.yml` and `docs/DOCKER_DEV.md`.
|
||||
|
||||
## What an AI coding agent should do first
|
||||
1. Read `README.md` and `docs/DOCKER_DEV.md` before proposing environment or run commands.
|
||||
2. Identify whether a change belongs in `backend/` or `frontend/`.
|
||||
3. Prefer small, incremental edits.
|
||||
4. When in doubt, ask the user before making large refactors or architectural changes.
|
||||
|
||||
## Build / run commands
|
||||
### Backend local
|
||||
- `python -m venv venv`
|
||||
- `venv\Scripts\activate` (Windows)
|
||||
- `pip install -r backend/requirements.txt`
|
||||
- `cd backend && python main.py`
|
||||
|
||||
### Frontend local
|
||||
- `cd frontend`
|
||||
- `npm install`
|
||||
- `npm run dev`
|
||||
|
||||
### Docker development
|
||||
- `.\scripts\docker-up.ps1`
|
||||
- `.\scripts\docker-restart.ps1 -Service backend`
|
||||
- `.\scripts\docker-rebuild.ps1 -Service backend`
|
||||
- `.\scripts\docker-logs.ps1`
|
||||
- `docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d`
|
||||
|
||||
## Testing and quality checks
|
||||
- Backend tests live in `backend/tests/`.
|
||||
- Use `python -m pytest backend/tests` for automated backend test runs.
|
||||
- Frontend has lint/type-check scripts in `frontend/package.json`:
|
||||
- `npm run lint`
|
||||
- `npm run type-check`
|
||||
- Prefer adding or updating tests for bug fixes, new features, and non-trivial behavior changes.
|
||||
- Keep changes tidy and consistent with the repository's existing style.
|
||||
|
||||
## Code conventions and review preferences
|
||||
- The user prefers code that is:
|
||||
- 基本审查过的
|
||||
- 规范整洁的
|
||||
- 严谨测试覆盖的
|
||||
- Do not perform sweeping refactors without explicit user approval.
|
||||
- If a change affects core logic, clearly explain the reason and the hypothesis for the fix.
|
||||
- For any change, state whether it is:
|
||||
- bug fix
|
||||
- cleanup/refactor
|
||||
- feature addition
|
||||
|
||||
## Important paths and domains
|
||||
- `backend/main.py` — FastAPI app entrypoint
|
||||
- `backend/api/` — HTTP route definitions
|
||||
- `backend/services/` — core business logic and domain services
|
||||
- `backend/models/` — data models and converters
|
||||
- `backend/utils/` — shared helper modules
|
||||
- `frontend/src/` — React source code
|
||||
- `frontend/package.json` — frontend scripts and dependencies
|
||||
- `docs/DOCKER_DEV.md` — Docker development guidance
|
||||
|
||||
## Practical guidance for AI agents
|
||||
- Avoid editing generated or runtime content in `data/` unless explicitly asked.
|
||||
- Prefer changes that are easy to reason about and test.
|
||||
- Use existing test tools rather than inventing new workflows.
|
||||
- Link to repository docs instead of duplicating long explanations.
|
||||
- If a requested change is uncertain, ask for clarification rather than guessing.
|
||||
|
||||
## References
|
||||
- `README.md`
|
||||
- `docs/DOCKER_DEV.md`
|
||||
- `frontend/package.json`
|
||||
- `backend/requirements.txt`
|
||||
@@ -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)
|
||||
|
||||
439
backend/api/routes/fictionRoute.py
Normal file
439
backend/api/routes/fictionRoute.py
Normal file
@@ -0,0 +1,439 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
import services.tools.fiction_tools # noqa: F401 — register fiction tools
|
||||
from models.fiction_models import (
|
||||
CreateFictionBookRequest,
|
||||
EmotionFlowCatalog,
|
||||
FictionBookMeta,
|
||||
FictionBookMetadata,
|
||||
FictionBookSettings,
|
||||
FictionBookSummary,
|
||||
FictionChapter,
|
||||
FictionChapterSummary,
|
||||
FictionGenerationRequest,
|
||||
FictionGuideWorldbook,
|
||||
FictionPipelineTickResult,
|
||||
FictionRunState,
|
||||
FictionStartReadingResult,
|
||||
GuideGlobalEntries,
|
||||
OpenBookRequest,
|
||||
OpenBookResult,
|
||||
UpdateFictionBookSettingsRequest,
|
||||
UpdateFictionProgressRequest,
|
||||
)
|
||||
from services.fiction_chapter_service import ensure_chapter, run_chapter
|
||||
from services.fiction_coarse_service import run_coarse_outline
|
||||
from services.fiction_event_plan_service import (
|
||||
iter_event_plan,
|
||||
run_event_plan,
|
||||
stream_event_plan_subscribe,
|
||||
)
|
||||
from services.fiction_metadata_service import fiction_metadata_service
|
||||
from services.fiction_open_book_service import run_open_book
|
||||
from services.fiction_planning_service import (
|
||||
ensure_chapter_plan,
|
||||
ensure_event_chain,
|
||||
ensure_volume,
|
||||
)
|
||||
from services.fiction_orchestrator_service import (
|
||||
get_pending_stages,
|
||||
get_pipeline_run,
|
||||
start_reading_pipeline,
|
||||
tick_reading_pipeline,
|
||||
)
|
||||
from services.fiction_service import fiction_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/fiction", tags=["fiction"])
|
||||
|
||||
|
||||
@router.get("/books", response_model=List[FictionBookSummary])
|
||||
async def list_fiction_books():
|
||||
try:
|
||||
return fiction_service.list_books()
|
||||
except Exception as e:
|
||||
logger.error("Failed to list fiction books: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/books", response_model=FictionBookMeta)
|
||||
async def create_fiction_book(req: CreateFictionBookRequest):
|
||||
try:
|
||||
return fiction_service.create_book(req)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileExistsError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to create fiction book: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/books/{book_id}", response_model=FictionBookMeta)
|
||||
async def get_fiction_book_meta(book_id: str):
|
||||
try:
|
||||
return fiction_service.get_book_meta(book_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to get fiction book %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/books/{book_id}")
|
||||
async def delete_fiction_book(book_id: str):
|
||||
try:
|
||||
fiction_service.delete_book(book_id)
|
||||
return {"ok": True}
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete fiction book %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/books/{book_id}/settings", response_model=FictionBookSettings)
|
||||
async def get_fiction_book_settings(book_id: str):
|
||||
try:
|
||||
return fiction_service.get_book_settings(book_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to get settings for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/books/{book_id}/settings", response_model=FictionBookSettings)
|
||||
async def update_fiction_book_settings(
|
||||
book_id: str, req: UpdateFictionBookSettingsRequest
|
||||
):
|
||||
try:
|
||||
return fiction_service.update_book_settings(book_id, req)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to update settings for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/books/{book_id}/guide", response_model=FictionGuideWorldbook)
|
||||
async def get_fiction_book_guide(book_id: str):
|
||||
try:
|
||||
return fiction_service.get_book_guide(book_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to get guide for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/emotion-flows/catalog", response_model=EmotionFlowCatalog)
|
||||
async def get_emotion_flow_catalog():
|
||||
try:
|
||||
return fiction_service.get_emotion_catalog()
|
||||
except Exception as e:
|
||||
logger.error("Failed to get emotion catalog: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/guide-global/entries", response_model=GuideGlobalEntries)
|
||||
async def get_guide_global_entries():
|
||||
try:
|
||||
return fiction_service.get_guide_global_entries()
|
||||
except Exception as e:
|
||||
logger.error("Failed to get guide global entries: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/open-book", response_model=OpenBookResult)
|
||||
async def open_fiction_book(req: OpenBookRequest):
|
||||
try:
|
||||
return await run_open_book(
|
||||
req.inspiration,
|
||||
profile_id=req.profile_id,
|
||||
api_config=req.api_config,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to open fiction book: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/books/{book_id}/metadata", response_model=FictionBookMetadata)
|
||||
async def get_fiction_book_metadata(book_id: str):
|
||||
try:
|
||||
return fiction_metadata_service.get_metadata(book_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to get metadata for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/books/{book_id}/run", response_model=FictionRunState)
|
||||
async def get_fiction_book_run(book_id: str):
|
||||
try:
|
||||
return fiction_metadata_service.get_run(book_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to get run for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/books/{book_id}/start", response_model=FictionStartReadingResult)
|
||||
async def start_fiction_reading(book_id: str, req: FictionGenerationRequest):
|
||||
"""进入阅读时自动触发粗纲 / 事件纲要流水线(后台异步)。"""
|
||||
try:
|
||||
return await start_reading_pipeline(
|
||||
book_id,
|
||||
profile_id=req.profile_id,
|
||||
api_config=req.api_config,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to start reading pipeline for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/books/{book_id}/pipeline/tick", response_model=FictionPipelineTickResult)
|
||||
async def tick_fiction_pipeline(book_id: str, req: FictionGenerationRequest):
|
||||
"""检查并推进流水线(尊重半自动设置)。"""
|
||||
try:
|
||||
return await tick_reading_pipeline(
|
||||
book_id,
|
||||
profile_id=req.profile_id,
|
||||
api_config=req.api_config,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to tick pipeline for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/books/{book_id}/pipeline/status")
|
||||
async def get_fiction_pipeline_status(book_id: str):
|
||||
"""流水线 run 状态 + 待手动阶段列表。"""
|
||||
try:
|
||||
run = get_pipeline_run(book_id)
|
||||
pending = get_pending_stages(book_id)
|
||||
return {"run": run, "pendingStages": pending}
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to get pipeline status for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/books/{book_id}/volumes/ensure", response_model=FictionBookMetadata)
|
||||
async def ensure_fiction_volume(book_id: str, req: FictionGenerationRequest):
|
||||
try:
|
||||
return await ensure_volume(
|
||||
book_id,
|
||||
profile_id=req.profile_id,
|
||||
api_config=req.api_config,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to ensure volume for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/books/{book_id}/events/ensure", response_model=FictionBookMetadata)
|
||||
async def ensure_fiction_event_chain(book_id: str, req: FictionGenerationRequest):
|
||||
try:
|
||||
return await ensure_event_chain(
|
||||
book_id,
|
||||
profile_id=req.profile_id,
|
||||
api_config=req.api_config,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to ensure event chain for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/books/{book_id}/chapter-plans/ensure", response_model=FictionBookMetadata)
|
||||
async def ensure_fiction_chapter_plan(book_id: str, req: FictionGenerationRequest):
|
||||
try:
|
||||
return await ensure_chapter_plan(
|
||||
book_id,
|
||||
event_id=req.event_id,
|
||||
profile_id=req.profile_id,
|
||||
api_config=req.api_config,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to ensure chapter plan for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/books/{book_id}/chapters/ensure", response_model=FictionChapter)
|
||||
async def ensure_fiction_chapter(book_id: str, req: FictionGenerationRequest):
|
||||
try:
|
||||
return await ensure_chapter(
|
||||
book_id,
|
||||
profile_id=req.profile_id,
|
||||
api_config=req.api_config,
|
||||
seq=req.seq,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to ensure chapter for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/books/{book_id}/coarse-outline", response_model=FictionBookMetadata)
|
||||
async def generate_coarse_outline(book_id: str, req: FictionGenerationRequest):
|
||||
try:
|
||||
return await ensure_volume(
|
||||
book_id,
|
||||
profile_id=req.profile_id,
|
||||
api_config=req.api_config,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to ensure volume for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/books/{book_id}/event-plan", response_model=FictionBookMetadata)
|
||||
async def generate_event_plan(book_id: str, req: FictionGenerationRequest):
|
||||
if req.stream:
|
||||
|
||||
async def ndjson_stream():
|
||||
try:
|
||||
async for event in iter_event_plan(
|
||||
book_id,
|
||||
profile_id=req.profile_id,
|
||||
api_config=req.api_config,
|
||||
event_id=req.event_id,
|
||||
):
|
||||
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||||
except FileNotFoundError as e:
|
||||
yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n"
|
||||
except ValueError as e:
|
||||
yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n"
|
||||
except Exception as e:
|
||||
logger.error("Failed to stream event plan for %s: %s", book_id, e)
|
||||
yield json.dumps(
|
||||
{"type": "error", "message": str(e)}, ensure_ascii=False
|
||||
) + "\n"
|
||||
|
||||
return StreamingResponse(ndjson_stream(), media_type="application/x-ndjson")
|
||||
|
||||
try:
|
||||
return await ensure_chapter_plan(
|
||||
book_id,
|
||||
event_id=req.event_id,
|
||||
profile_id=req.profile_id,
|
||||
api_config=req.api_config,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to generate event plan for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/books/{book_id}/event-plan/stream")
|
||||
async def subscribe_event_plan_stream(book_id: str):
|
||||
"""订阅事件纲要生成进度(NDJSON),适用于后台流水线已启动时。"""
|
||||
try:
|
||||
fiction_service.get_book_meta(book_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
async def ndjson_stream():
|
||||
try:
|
||||
async for event in stream_event_plan_subscribe(book_id):
|
||||
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||||
except FileNotFoundError as e:
|
||||
yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n"
|
||||
except Exception as e:
|
||||
logger.error("Failed to subscribe event plan stream for %s: %s", book_id, e)
|
||||
yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n"
|
||||
|
||||
return StreamingResponse(ndjson_stream(), media_type="application/x-ndjson")
|
||||
|
||||
|
||||
@router.get("/books/{book_id}/chapters", response_model=List[FictionChapterSummary])
|
||||
async def list_fiction_chapters(book_id: str):
|
||||
try:
|
||||
return fiction_service.list_chapter_summaries(book_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to list chapters for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/books/{book_id}/chapters/{seq}", response_model=FictionChapter)
|
||||
async def get_fiction_chapter(book_id: str, seq: int):
|
||||
try:
|
||||
return fiction_service.get_chapter(book_id, seq)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to get chapter %s for %s: %s", seq, book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/books/{book_id}/chapter", response_model=FictionChapter)
|
||||
async def generate_fiction_chapter(book_id: str, req: FictionGenerationRequest):
|
||||
"""撰写下一章或指定 seq 的章节(本地已存在则直接返回)。"""
|
||||
try:
|
||||
return await run_chapter(
|
||||
book_id,
|
||||
profile_id=req.profile_id,
|
||||
api_config=req.api_config,
|
||||
seq=req.seq,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to generate chapter for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/books/{book_id}/progress", response_model=FictionBookMetadata)
|
||||
async def update_fiction_progress(book_id: str, req: UpdateFictionProgressRequest):
|
||||
try:
|
||||
return fiction_metadata_service.update_progress(
|
||||
book_id,
|
||||
current_chapter_seq=req.currentChapterSeq,
|
||||
char_offset=req.charOffset,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to update progress for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -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)
|
||||
|
||||
285
backend/models/fiction_models.py
Normal file
285
backend/models/fiction_models.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
爽文(Fiction / Novel)数据模型。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class EmotionFlowStep(BaseModel):
|
||||
key: str
|
||||
text: str
|
||||
|
||||
|
||||
class EmotionFlow(BaseModel):
|
||||
id: str
|
||||
intro: str
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
steps: List[EmotionFlowStep] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EmotionFlowCatalog(BaseModel):
|
||||
flows: List[EmotionFlow] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GuideGlobalEntry(BaseModel):
|
||||
layer: str
|
||||
title: str
|
||||
content: str
|
||||
|
||||
|
||||
class GuideGlobalEntries(BaseModel):
|
||||
entries: List[GuideGlobalEntry] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FictionGuideWorldbook(BaseModel):
|
||||
"""Book-local guide 世界书:本书具体人设 / 爽点 / 用户体验 / 禁区。"""
|
||||
|
||||
persona: str = ""
|
||||
highlight: str = ""
|
||||
experience: str = ""
|
||||
forbiddenZones: str = ""
|
||||
|
||||
|
||||
class FictionBookMeta(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
allowedFlowIds: List[str] = Field(default_factory=list)
|
||||
createdAt: str = ""
|
||||
updatedAt: str = ""
|
||||
|
||||
|
||||
class FictionBookSummary(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
allowedFlowIds: List[str] = Field(default_factory=list)
|
||||
updatedAt: str = ""
|
||||
|
||||
|
||||
class FictionPrompts(BaseModel):
|
||||
openBook: str = ""
|
||||
coarseOutline: str = ""
|
||||
eventPlan: str = ""
|
||||
chapter: str = ""
|
||||
nudge: str = ""
|
||||
|
||||
|
||||
class FictionReaderSettings(BaseModel):
|
||||
contextWindowChars: int = 2000
|
||||
prefetchRemainingWords: int = 300
|
||||
|
||||
|
||||
class FictionPipelineSettings(BaseModel):
|
||||
"""半自动流水线:各层 ON=自动,OFF=需手动触发。"""
|
||||
|
||||
semiAuto: bool = False
|
||||
autoCoarse: bool = True
|
||||
autoEventPlan: bool = True
|
||||
autoChapter: bool = True
|
||||
|
||||
|
||||
class FictionBookSettings(BaseModel):
|
||||
prompts: FictionPrompts = Field(default_factory=FictionPrompts)
|
||||
reader: FictionReaderSettings = Field(default_factory=FictionReaderSettings)
|
||||
pipeline: FictionPipelineSettings = Field(default_factory=FictionPipelineSettings)
|
||||
|
||||
|
||||
class CreateFictionBookRequest(BaseModel):
|
||||
title: str
|
||||
inspiration: str = ""
|
||||
guide: FictionGuideWorldbook
|
||||
allowedFlowIds: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class OpenBookRequest(BaseModel):
|
||||
inspiration: str
|
||||
profile_id: Optional[str] = None
|
||||
api_config: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
class OpenBookResult(BaseModel):
|
||||
title: str
|
||||
optimizedIntro: str
|
||||
guide: FictionGuideWorldbook
|
||||
allowedFlowIds: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UpdateFictionBookSettingsRequest(BaseModel):
|
||||
prompts: Optional[FictionPrompts] = None
|
||||
reader: Optional[FictionReaderSettings] = None
|
||||
pipeline: Optional[FictionPipelineSettings] = None
|
||||
|
||||
|
||||
class FictionPipelineTickResult(BaseModel):
|
||||
run: FictionRunState
|
||||
started: bool = False
|
||||
pendingStages: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class VolumeOutline(BaseModel):
|
||||
"""卷纲:当前 10~30 章的阶段规划,不是整本书全局大纲。"""
|
||||
|
||||
id: str
|
||||
order: int = 1
|
||||
title: str = ""
|
||||
goal: str = ""
|
||||
coreConflict: str = ""
|
||||
powerProgression: str = ""
|
||||
emotionalPromise: str = ""
|
||||
endingHook: str = ""
|
||||
targetChapterCount: int = 20
|
||||
primaryEmotionFlowId: str = ""
|
||||
status: str = "active"
|
||||
|
||||
|
||||
class CoarseOutlineEvent(BaseModel):
|
||||
"""兼容旧 coarseOutline.events,同时作为新版事件串条目的轻量视图。"""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
summary: str = ""
|
||||
order: int = 1
|
||||
volumeId: str = ""
|
||||
|
||||
|
||||
class CoarseOutline(BaseModel):
|
||||
events: List[CoarseOutlineEvent] = Field(default_factory=list)
|
||||
version: int = 1
|
||||
|
||||
|
||||
class EventChainItem(BaseModel):
|
||||
"""事件串:卷纲 + 情感链在剧情层的落地。"""
|
||||
|
||||
id: str
|
||||
volumeId: str = ""
|
||||
order: int = 1
|
||||
title: str = ""
|
||||
summary: str = ""
|
||||
purpose: str = ""
|
||||
conflict: str = ""
|
||||
turningPoint: str = ""
|
||||
expectedPayoff: str = ""
|
||||
targetChapterCount: int = 3
|
||||
emotionFlowId: str = ""
|
||||
emotionStepKey: str = ""
|
||||
emotionStepText: str = ""
|
||||
status: str = "planned"
|
||||
|
||||
|
||||
class FlowStepsPlan(BaseModel):
|
||||
起: str = ""
|
||||
承: str = ""
|
||||
转: str = ""
|
||||
合: str = ""
|
||||
|
||||
|
||||
class ChapterPlanItem(BaseModel):
|
||||
seq: int
|
||||
|
||||
# 旧字段:保留以兼容现有 metadata.events[eventId].chapterPlan。
|
||||
phaseKey: str = ""
|
||||
phaseSlice: str = ""
|
||||
brief: str = ""
|
||||
|
||||
# 新字段:章纲是写章前最具体的规划层。
|
||||
eventId: str = ""
|
||||
title: str = ""
|
||||
goal: str = ""
|
||||
opening: str = ""
|
||||
mainConflict: str = ""
|
||||
emotionalTurn: str = ""
|
||||
emotionStepKey: str = ""
|
||||
emotionGoal: str = ""
|
||||
payoff: str = ""
|
||||
endingHook: str = ""
|
||||
forbidden: str = ""
|
||||
targetWords: int = 2000
|
||||
|
||||
status: str = "planned"
|
||||
|
||||
|
||||
class EventPlanEntry(BaseModel):
|
||||
emotionFlowId: str = ""
|
||||
flowStepsPlan: FlowStepsPlan = Field(default_factory=FlowStepsPlan)
|
||||
chapterPlan: List[ChapterPlanItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FictionProgress(BaseModel):
|
||||
currentChapterSeq: int = 0
|
||||
charOffset: int = 0
|
||||
ttsPaused: bool = False
|
||||
genPaused: bool = False
|
||||
|
||||
|
||||
class FictionChapter(BaseModel):
|
||||
seq: int
|
||||
title: str = ""
|
||||
body: str = ""
|
||||
charCount: int = 0
|
||||
status: str = "written"
|
||||
eventId: str = ""
|
||||
phaseKey: str = ""
|
||||
createdAt: str = ""
|
||||
|
||||
|
||||
class FictionChapterSummary(BaseModel):
|
||||
seq: int
|
||||
title: str = ""
|
||||
charCount: int = 0
|
||||
eventId: str = ""
|
||||
phaseKey: str = ""
|
||||
|
||||
|
||||
class FictionBookMetadata(BaseModel):
|
||||
# v2 新规划结构:不再保留旧 coarseOutline/events 作为持久化主结构。
|
||||
version: int = 2
|
||||
volumes: List[VolumeOutline] = Field(default_factory=list)
|
||||
eventChains: Dict[str, List[EventChainItem]] = Field(default_factory=dict)
|
||||
chapterPlans: Dict[str, List[ChapterPlanItem]] = Field(default_factory=dict)
|
||||
progress: FictionProgress = Field(default_factory=FictionProgress)
|
||||
|
||||
|
||||
class FictionRunState(BaseModel):
|
||||
status: str = "idle"
|
||||
pipelineStage: Optional[str] = None
|
||||
stage: str = "idle"
|
||||
message: Optional[str] = None
|
||||
progress: Optional[Dict[str, int]] = None
|
||||
updatedAt: str = ""
|
||||
|
||||
|
||||
class FictionStartReadingResult(BaseModel):
|
||||
run: FictionRunState
|
||||
started: bool = False
|
||||
|
||||
|
||||
class FictionGenerationRequest(BaseModel):
|
||||
profile_id: Optional[str] = None
|
||||
api_config: Optional[Dict[str, str]] = None
|
||||
event_id: Optional[str] = None
|
||||
seq: Optional[int] = None
|
||||
stream: bool = False
|
||||
|
||||
|
||||
class UpdateFictionProgressRequest(BaseModel):
|
||||
currentChapterSeq: Optional[int] = None
|
||||
charOffset: Optional[int] = None
|
||||
ttsPaused: Optional[bool] = None
|
||||
genPaused: Optional[bool] = None
|
||||
|
||||
|
||||
class FictionPrefetchRequest(BaseModel):
|
||||
profile_id: Optional[str] = None
|
||||
api_config: Optional[Dict[str, str]] = None
|
||||
currentChapterSeq: int
|
||||
charOffset: int = 0
|
||||
remainingWords: Optional[int] = None
|
||||
|
||||
|
||||
class FictionPrefetchResult(BaseModel):
|
||||
run: FictionRunState
|
||||
started: bool = False
|
||||
targetSeq: Optional[int] = None
|
||||
skippedReason: Optional[str] = None
|
||||
317
backend/services/fiction_chapter_service.py
Normal file
317
backend/services/fiction_chapter_service.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
爽文章节写作(fiction.chapter)— 新版 metadata v2 章纲驱动。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Iterator, List, Optional, Tuple
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
|
||||
from models.fiction_models import (
|
||||
ChapterPlanItem,
|
||||
EventChainItem,
|
||||
FictionBookMetadata,
|
||||
FictionChapter,
|
||||
VolumeOutline,
|
||||
)
|
||||
from services.fiction_metadata_service import fiction_metadata_service
|
||||
from services.fiction_planning_service import ensure_chapter_plan
|
||||
from services.fiction_prompt_utils import resolve_prompt
|
||||
from services.fiction_service import fiction_service
|
||||
from services.studio_step_respond import resolve_api_config
|
||||
from utils.llm_client import LLMClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_llm_client = LLMClient()
|
||||
_JSON_FENCE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
|
||||
|
||||
PlannedChapter = Tuple[int, VolumeOutline, EventChainItem, ChapterPlanItem]
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> Dict[str, Any]:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
raise ValueError("模型返回为空")
|
||||
fence = _JSON_FENCE.search(text)
|
||||
if fence:
|
||||
text = fence.group(1).strip()
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
return json.loads(text[start : end + 1])
|
||||
raise ValueError("无法解析模型返回的 JSON")
|
||||
|
||||
|
||||
def _validate_api_config(api_config: Dict[str, str]) -> None:
|
||||
if not api_config.get("api_key"):
|
||||
raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥")
|
||||
if not api_config.get("api_url"):
|
||||
raise ValueError("API 地址未配置,请先在 API 配置页面保存 mainLLM")
|
||||
if not api_config.get("model"):
|
||||
raise ValueError("模型未配置,请先在 API 配置页面保存 mainLLM 模型")
|
||||
|
||||
|
||||
def _format_guide_global_layers(layers: List[str]) -> str:
|
||||
entries = fiction_service.get_guide_global_entries().entries
|
||||
filtered = [e for e in entries if e.layer in layers]
|
||||
lines: List[str] = []
|
||||
for entry in filtered:
|
||||
lines.append(f"[{entry.layer}] {entry.title}\n{entry.content}")
|
||||
return "\n\n".join(lines) if lines else "(无全局指南)"
|
||||
|
||||
|
||||
def _format_book_guide(book_id: str) -> str:
|
||||
guide = fiction_service.get_book_guide(book_id)
|
||||
return guide.chapter.content or "(无章节层世界书)"
|
||||
|
||||
|
||||
def iter_planned_chapters(metadata: FictionBookMetadata) -> Iterator[PlannedChapter]:
|
||||
"""按卷纲 → 事件链 → 章纲顺序展开章节计划。"""
|
||||
volumes = sorted(metadata.volumes or [], key=lambda v: v.order)
|
||||
for volume in volumes:
|
||||
events = sorted(metadata.eventChains.get(volume.id, []), key=lambda e: e.order)
|
||||
for event in events:
|
||||
plans = sorted(metadata.chapterPlans.get(event.id, []), key=lambda c: c.seq)
|
||||
for item in plans:
|
||||
yield item.seq, volume, event, item
|
||||
|
||||
|
||||
def find_next_unwritten_chapter(
|
||||
book_id: str, metadata: Optional[FictionBookMetadata] = None
|
||||
) -> Optional[PlannedChapter]:
|
||||
metadata = metadata or fiction_metadata_service.get_metadata(book_id)
|
||||
for seq, volume, event, item in iter_planned_chapters(metadata):
|
||||
if fiction_service.chapter_exists(book_id, seq):
|
||||
continue
|
||||
if item.status == "written":
|
||||
continue
|
||||
return seq, volume, event, item
|
||||
return None
|
||||
|
||||
|
||||
def has_written_chapters(book_id: str) -> bool:
|
||||
return len(fiction_service.list_written_chapter_seqs(book_id)) > 0
|
||||
|
||||
|
||||
def _build_context_tail(book_id: str, before_seq: int, context_chars: int) -> str:
|
||||
if before_seq <= 1 or context_chars <= 0:
|
||||
return ""
|
||||
parts: List[str] = []
|
||||
for seq in range(1, before_seq):
|
||||
if not fiction_service.chapter_exists(book_id, seq):
|
||||
continue
|
||||
ch = fiction_service.get_chapter(book_id, seq)
|
||||
if ch.body:
|
||||
parts.append(ch.body)
|
||||
combined = "\n\n".join(parts)
|
||||
if len(combined) <= context_chars:
|
||||
return combined
|
||||
return combined[-context_chars:]
|
||||
|
||||
|
||||
def _build_chapter_messages(
|
||||
book_id: str,
|
||||
global_seq: int,
|
||||
volume: VolumeOutline,
|
||||
event: EventChainItem,
|
||||
plan_item: ChapterPlanItem,
|
||||
) -> List[Any]:
|
||||
settings = fiction_service.get_book_settings(book_id)
|
||||
user_prompt = settings.prompts.chapter or fiction_service.get_default_settings().prompts.chapter
|
||||
system_prompt = resolve_prompt("chapter", user_prompt)
|
||||
|
||||
context_chars = settings.reader.contextWindowChars if settings.reader else 2000
|
||||
context_tail = _build_context_tail(book_id, global_seq, context_chars)
|
||||
guide_l3 = _format_guide_global_layers(["L3"])
|
||||
book_guide = _format_book_guide(book_id)
|
||||
|
||||
context_block = context_tail if context_tail else "(本章为开篇,无上文)"
|
||||
|
||||
user_content = f"""## 全局创作指南(L3)
|
||||
{guide_l3}
|
||||
|
||||
## 本书世界书(章节层)
|
||||
{book_guide}
|
||||
|
||||
## 当前卷纲
|
||||
- id: {volume.id}
|
||||
- title: {volume.title}
|
||||
- goal: {volume.goal}
|
||||
- coreConflict: {volume.coreConflict}
|
||||
- emotionalPromise: {volume.emotionalPromise}
|
||||
|
||||
## 当前事件
|
||||
- id: {event.id}
|
||||
- title: {event.title}
|
||||
- summary: {event.summary}
|
||||
- purpose: {event.purpose}
|
||||
- conflict: {event.conflict}
|
||||
- turningPoint: {event.turningPoint}
|
||||
- expectedPayoff: {event.expectedPayoff}
|
||||
|
||||
## 本章章纲(第 {global_seq} 章)
|
||||
- title: {plan_item.title}
|
||||
- goal: {plan_item.goal}
|
||||
- opening: {plan_item.opening}
|
||||
- mainConflict: {plan_item.mainConflict}
|
||||
- emotionalTurn: {plan_item.emotionalTurn}
|
||||
- emotionStepKey: {plan_item.emotionStepKey}
|
||||
- emotionGoal: {plan_item.emotionGoal}
|
||||
- payoff: {plan_item.payoff}
|
||||
- endingHook: {plan_item.endingHook}
|
||||
- forbidden: {plan_item.forbidden}
|
||||
- targetWords: {plan_item.targetWords}
|
||||
|
||||
## 已读上文末尾(最多 {context_chars} 字,供衔接)
|
||||
{context_block}
|
||||
|
||||
## 绝对要求
|
||||
- 只生成第 {global_seq} 章正文。
|
||||
- 正文字数目标约 {plan_item.targetWords or 2000} 汉字。
|
||||
- 必须遵循本章章纲、当前事件、当前卷纲与章节层世界书。
|
||||
- 不生成下一章章纲,不生成解释说明。"""
|
||||
|
||||
return [
|
||||
SystemMessage(content=system_prompt),
|
||||
HumanMessage(content=user_content),
|
||||
]
|
||||
|
||||
|
||||
def _parse_chapter_response(
|
||||
data: Dict[str, Any],
|
||||
*,
|
||||
global_seq: int,
|
||||
event: EventChainItem,
|
||||
plan_item: ChapterPlanItem,
|
||||
) -> FictionChapter:
|
||||
body = str(data.get("body") or "").strip()
|
||||
if not body:
|
||||
raise ValueError("章节正文为空")
|
||||
title = str(data.get("title") or plan_item.title or f"第{global_seq}章").strip()
|
||||
return FictionChapter(
|
||||
seq=global_seq,
|
||||
title=title,
|
||||
body=body,
|
||||
charCount=len(body),
|
||||
status="written",
|
||||
eventId=event.id,
|
||||
phaseKey=plan_item.emotionStepKey or plan_item.phaseKey,
|
||||
createdAt=datetime.now().isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def _mark_chapter_written(
|
||||
metadata: FictionBookMetadata, event_id: str, plan_seq: int
|
||||
) -> FictionBookMetadata:
|
||||
plans = metadata.chapterPlans.get(event_id, [])
|
||||
updated_plan: List[ChapterPlanItem] = []
|
||||
for item in plans:
|
||||
if item.seq == plan_seq:
|
||||
updated_plan.append(item.model_copy(update={"status": "written"}))
|
||||
else:
|
||||
updated_plan.append(item)
|
||||
metadata.chapterPlans[event_id] = updated_plan
|
||||
return metadata
|
||||
|
||||
|
||||
async def run_chapter(
|
||||
book_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
seq: Optional[int] = None,
|
||||
) -> FictionChapter:
|
||||
resolved = resolve_api_config(profile_id, api_config)
|
||||
_validate_api_config(resolved)
|
||||
|
||||
metadata = await ensure_chapter_plan(
|
||||
book_id,
|
||||
profile_id=profile_id,
|
||||
api_config=api_config,
|
||||
)
|
||||
|
||||
target: Optional[PlannedChapter] = None
|
||||
if seq is not None:
|
||||
for global_seq, volume, event, item in iter_planned_chapters(metadata):
|
||||
if global_seq == seq:
|
||||
target = (global_seq, volume, event, item)
|
||||
break
|
||||
if not target:
|
||||
raise ValueError(f"章节规划不存在: seq={seq}")
|
||||
global_seq, volume, event, item = target
|
||||
if fiction_service.chapter_exists(book_id, global_seq):
|
||||
return fiction_service.get_chapter(book_id, global_seq)
|
||||
else:
|
||||
found = find_next_unwritten_chapter(book_id, metadata)
|
||||
if not found:
|
||||
raise ValueError("没有待撰写的章节")
|
||||
global_seq, volume, event, item = found
|
||||
if fiction_service.chapter_exists(book_id, global_seq):
|
||||
return fiction_service.get_chapter(book_id, global_seq)
|
||||
|
||||
run = fiction_metadata_service.get_run(book_id)
|
||||
if run.status == "error":
|
||||
fiction_metadata_service.clear_pipeline_error(book_id)
|
||||
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="running", pipeline_stage="chapter"
|
||||
)
|
||||
try:
|
||||
messages = _build_chapter_messages(book_id, global_seq, volume, event, item)
|
||||
response = await _llm_client.chat_completion(
|
||||
messages=messages,
|
||||
api_url=resolved["api_url"],
|
||||
api_key=resolved["api_key"],
|
||||
model=resolved["model"],
|
||||
temperature=0.8,
|
||||
max_tokens=8000,
|
||||
request_timeout=180,
|
||||
stream=False,
|
||||
)
|
||||
content = response["choices"][0]["message"]["content"]
|
||||
data = _extract_json(content)
|
||||
chapter = _parse_chapter_response(
|
||||
data, global_seq=global_seq, event=event, plan_item=item
|
||||
)
|
||||
fiction_service.save_chapter(book_id, chapter)
|
||||
|
||||
metadata = fiction_metadata_service.get_metadata(book_id)
|
||||
metadata = _mark_chapter_written(metadata, event.id, item.seq)
|
||||
progress = metadata.progress
|
||||
if progress.currentChapterSeq <= 0:
|
||||
progress.currentChapterSeq = global_seq
|
||||
metadata.progress = progress
|
||||
fiction_metadata_service.save_metadata(book_id, metadata)
|
||||
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="ready"
|
||||
)
|
||||
return chapter
|
||||
except Exception:
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="error", pipeline_stage="chapter"
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def ensure_chapter(
|
||||
book_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
seq: Optional[int] = None,
|
||||
) -> FictionChapter:
|
||||
return await run_chapter(
|
||||
book_id,
|
||||
profile_id=profile_id,
|
||||
api_config=api_config,
|
||||
seq=seq,
|
||||
)
|
||||
178
backend/services/fiction_coarse_service.py
Normal file
178
backend/services/fiction_coarse_service.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
爽文粗纲生成(fiction.coarse)— LLM 调用逻辑。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
|
||||
from models.fiction_models import CoarseOutline, CoarseOutlineEvent, FictionBookMetadata
|
||||
from services.fiction_metadata_service import fiction_metadata_service
|
||||
from services.fiction_prompt_utils import resolve_prompt
|
||||
from services.fiction_service import fiction_service
|
||||
from services.studio_step_respond import resolve_api_config
|
||||
from utils.llm_client import LLMClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_llm_client = LLMClient()
|
||||
_JSON_FENCE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> Dict[str, Any]:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
raise ValueError("模型返回为空")
|
||||
fence = _JSON_FENCE.search(text)
|
||||
if fence:
|
||||
text = fence.group(1).strip()
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
return json.loads(text[start : end + 1])
|
||||
raise ValueError("无法解析模型返回的 JSON")
|
||||
|
||||
|
||||
def _validate_api_config(api_config: Dict[str, str]) -> None:
|
||||
if not api_config.get("api_key"):
|
||||
raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥")
|
||||
if not api_config.get("api_url"):
|
||||
raise ValueError("API 地址未配置,请先在 API 配置页面保存 mainLLM")
|
||||
|
||||
|
||||
def _format_guide_global_layers(layers: List[str]) -> str:
|
||||
entries = fiction_service.get_guide_global_entries().entries
|
||||
filtered = [e for e in entries if e.layer in layers]
|
||||
lines: List[str] = []
|
||||
for entry in filtered:
|
||||
lines.append(f"[{entry.layer}] {entry.title}\n{entry.content}")
|
||||
return "\n\n".join(lines) if lines else "(无全局指南)"
|
||||
|
||||
|
||||
def _format_book_guide(book_id: str) -> str:
|
||||
guide = fiction_service.get_book_guide(book_id)
|
||||
parts = [
|
||||
f"主角人设:{guide.persona}",
|
||||
f"核心爽点:{guide.highlight}",
|
||||
f"用户体验:{guide.experience}",
|
||||
f"创作禁区:{guide.forbiddenZones}",
|
||||
]
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _build_coarse_messages(book_id: str) -> List[Any]:
|
||||
settings = fiction_service.get_book_settings(book_id)
|
||||
user_prompt = settings.prompts.coarseOutline or fiction_service.get_default_settings().prompts.coarseOutline
|
||||
system_prompt = resolve_prompt("coarseOutline", user_prompt)
|
||||
|
||||
guide_l1 = _format_guide_global_layers(["L1"])
|
||||
book_guide = _format_book_guide(book_id)
|
||||
meta = fiction_service.get_book_meta(book_id)
|
||||
|
||||
user_content = f"""## 全局创作指南(L1,仅用于粗纲)
|
||||
{guide_l1}
|
||||
|
||||
## 本书 Guide 世界书
|
||||
{book_guide}
|
||||
|
||||
## 书名
|
||||
{meta.title}
|
||||
|
||||
## 已选情绪流 ID
|
||||
{", ".join(meta.allowedFlowIds or []) or "(未配置)"}
|
||||
|
||||
请生成本书的粗纲事件链。"""
|
||||
|
||||
return [
|
||||
SystemMessage(content=system_prompt),
|
||||
HumanMessage(content=user_content),
|
||||
]
|
||||
|
||||
|
||||
def _normalize_coarse_events(raw_events: Any) -> List[CoarseOutlineEvent]:
|
||||
if not isinstance(raw_events, list):
|
||||
return []
|
||||
events: List[CoarseOutlineEvent] = []
|
||||
for idx, item in enumerate(raw_events):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
evt_id = str(item.get("id") or f"evt-{idx + 1}").strip()
|
||||
title = str(item.get("title") or f"事件 {idx + 1}").strip()
|
||||
summary = str(item.get("summary") or "").strip()
|
||||
order = item.get("order")
|
||||
if not isinstance(order, int):
|
||||
order = idx + 1
|
||||
events.append(
|
||||
CoarseOutlineEvent(id=evt_id, title=title, summary=summary, order=order)
|
||||
)
|
||||
events.sort(key=lambda e: e.order)
|
||||
return events
|
||||
|
||||
|
||||
def _parse_coarse_response(data: Dict[str, Any]) -> CoarseOutline:
|
||||
events = _normalize_coarse_events(data.get("events"))
|
||||
if not events:
|
||||
raise ValueError("粗纲事件列表为空")
|
||||
version = data.get("version")
|
||||
if not isinstance(version, int):
|
||||
version = 1
|
||||
return CoarseOutline(events=events, version=version)
|
||||
|
||||
|
||||
async def run_coarse_outline(
|
||||
book_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
) -> FictionBookMetadata:
|
||||
existing = fiction_metadata_service.get_metadata(book_id)
|
||||
if existing.coarseOutline.events:
|
||||
logger.info("Coarse outline already exists for book %s, skipping generation", book_id)
|
||||
return existing
|
||||
|
||||
resolved = resolve_api_config(profile_id, api_config)
|
||||
_validate_api_config(resolved)
|
||||
|
||||
run = fiction_metadata_service.get_run(book_id)
|
||||
if run.status == "error":
|
||||
fiction_metadata_service.clear_pipeline_error(book_id)
|
||||
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="running", pipeline_stage="coarse"
|
||||
)
|
||||
try:
|
||||
messages = _build_coarse_messages(book_id)
|
||||
response = await _llm_client.chat_completion(
|
||||
messages=messages,
|
||||
api_url=resolved["api_url"],
|
||||
api_key=resolved["api_key"],
|
||||
model=resolved.get("model", "gpt-4o-mini"),
|
||||
temperature=0.7,
|
||||
max_tokens=8000,
|
||||
request_timeout=120,
|
||||
stream=False,
|
||||
)
|
||||
content = response["choices"][0]["message"]["content"]
|
||||
data = _extract_json(content)
|
||||
coarse = _parse_coarse_response(data)
|
||||
|
||||
current = fiction_metadata_service.get_metadata(book_id)
|
||||
current.coarseOutline = coarse
|
||||
saved = fiction_metadata_service.save_metadata(book_id, current)
|
||||
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="coarse_done"
|
||||
)
|
||||
return saved
|
||||
except Exception:
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="error", pipeline_stage="coarse"
|
||||
)
|
||||
raise
|
||||
34
backend/services/fiction_event_plan_progress.py
Normal file
34
backend/services/fiction_event_plan_progress.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
事件纲要生成进度广播 — 供 NDJSON 订阅端与后台流水线共享。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, AsyncIterator, Dict, List
|
||||
|
||||
_subscribers: Dict[str, List[asyncio.Queue]] = {}
|
||||
|
||||
|
||||
def emit(book_id: str, event: Dict[str, Any]) -> None:
|
||||
for q in list(_subscribers.get(book_id, [])):
|
||||
try:
|
||||
q.put_nowait(event)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
|
||||
async def subscribe(book_id: str) -> AsyncIterator[Dict[str, Any]]:
|
||||
q: asyncio.Queue = asyncio.Queue(maxsize=128)
|
||||
_subscribers.setdefault(book_id, []).append(q)
|
||||
try:
|
||||
while True:
|
||||
item = await q.get()
|
||||
yield item
|
||||
if item.get("type") in ("done", "error"):
|
||||
break
|
||||
finally:
|
||||
subs = _subscribers.get(book_id, [])
|
||||
if q in subs:
|
||||
subs.remove(q)
|
||||
if not subs:
|
||||
_subscribers.pop(book_id, None)
|
||||
422
backend/services/fiction_event_plan_service.py
Normal file
422
backend/services/fiction_event_plan_service.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""
|
||||
爽文事件规划(fiction.event_plan)— LLM 调用逻辑。
|
||||
按事件顺序生成,每完成一个事件即持久化并广播进度。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
from typing import Any, AsyncIterator, Dict, List, Optional
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
|
||||
from models.fiction_models import (
|
||||
ChapterPlanItem,
|
||||
CoarseOutlineEvent,
|
||||
EventPlanEntry,
|
||||
FictionBookMetadata,
|
||||
FlowStepsPlan,
|
||||
)
|
||||
from services.fiction_event_plan_progress import emit as emit_progress
|
||||
from services.fiction_event_plan_progress import subscribe as subscribe_progress
|
||||
from services.fiction_metadata_service import fiction_metadata_service
|
||||
from services.fiction_prompt_utils import resolve_prompt
|
||||
from services.fiction_service import fiction_service
|
||||
from services.studio_step_respond import resolve_api_config
|
||||
from utils.llm_client import LLMClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_llm_client = LLMClient()
|
||||
_JSON_FENCE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> Dict[str, Any]:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
raise ValueError("模型返回为空")
|
||||
fence = _JSON_FENCE.search(text)
|
||||
if fence:
|
||||
text = fence.group(1).strip()
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
return json.loads(text[start : end + 1])
|
||||
raise ValueError("无法解析模型返回的 JSON")
|
||||
|
||||
|
||||
def _validate_api_config(api_config: Dict[str, str]) -> None:
|
||||
if not api_config.get("api_key"):
|
||||
raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥")
|
||||
if not api_config.get("api_url"):
|
||||
raise ValueError("API 地址未配置,请先在 API 配置页面保存 mainLLM")
|
||||
|
||||
|
||||
def _format_guide_global_layers(layers: List[str]) -> str:
|
||||
entries = fiction_service.get_guide_global_entries().entries
|
||||
filtered = [e for e in entries if e.layer in layers]
|
||||
lines: List[str] = []
|
||||
for entry in filtered:
|
||||
lines.append(f"[{entry.layer}] {entry.title}\n{entry.content}")
|
||||
return "\n\n".join(lines) if lines else "(无全局指南)"
|
||||
|
||||
|
||||
def _format_book_guide(book_id: str) -> str:
|
||||
guide = fiction_service.get_book_guide(book_id)
|
||||
parts = [
|
||||
f"主角人设:{guide.persona}",
|
||||
f"核心爽点:{guide.highlight}",
|
||||
f"用户体验:{guide.experience}",
|
||||
f"创作禁区:{guide.forbiddenZones}",
|
||||
]
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _get_flow_by_id(flow_id: str):
|
||||
catalog = fiction_service.get_emotion_catalog()
|
||||
for flow in catalog.flows:
|
||||
if flow.id == flow_id:
|
||||
return flow
|
||||
return None
|
||||
|
||||
|
||||
def _format_flow_steps(flow) -> str:
|
||||
lines = [f"情绪流:{flow.intro} (id: {flow.id})"]
|
||||
for step in flow.steps or []:
|
||||
lines.append(f" [{step.key}] {step.text}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_event_plan_messages(
|
||||
book_id: str,
|
||||
event_id: str,
|
||||
event_title: str,
|
||||
event_summary: str,
|
||||
emotion_flow_id: str,
|
||||
) -> List[Any]:
|
||||
settings = fiction_service.get_book_settings(book_id)
|
||||
user_prompt = settings.prompts.eventPlan or fiction_service.get_default_settings().prompts.eventPlan
|
||||
system_prompt = resolve_prompt("eventPlan", user_prompt)
|
||||
|
||||
guide_l2 = _format_guide_global_layers(["L2"])
|
||||
book_guide = _format_book_guide(book_id)
|
||||
flow = _get_flow_by_id(emotion_flow_id)
|
||||
flow_text = _format_flow_steps(flow) if flow else f"情绪流 id: {emotion_flow_id}"
|
||||
|
||||
user_content = f"""## 全局创作指南(L2,仅用于事件规划)
|
||||
{guide_l2}
|
||||
|
||||
## 本书 Guide 世界书
|
||||
{book_guide}
|
||||
|
||||
## 当前粗纲事件
|
||||
- id: {event_id}
|
||||
- title: {event_title}
|
||||
- summary: {event_summary}
|
||||
|
||||
## 为本事件随机选定的情绪流
|
||||
{flow_text}
|
||||
|
||||
请输出 flowStepsPlan(起承转合)与 chapterPlan(章节级 brief)。
|
||||
|
||||
输出 JSON 示例:
|
||||
{{
|
||||
"flowStepsPlan": {{
|
||||
"起": "本阶段规划…",
|
||||
"承": "…",
|
||||
"转": "…",
|
||||
"合": "…"
|
||||
}},
|
||||
"chapterPlan": [
|
||||
{{ "seq": 1, "phaseKey": "起", "phaseSlice": "全", "brief": "本章要点", "status": "planned" }}
|
||||
]
|
||||
}}"""
|
||||
|
||||
return [
|
||||
SystemMessage(content=system_prompt),
|
||||
HumanMessage(content=user_content),
|
||||
]
|
||||
|
||||
|
||||
def _normalize_flow_steps_plan(raw: Any) -> FlowStepsPlan:
|
||||
data = raw if isinstance(raw, dict) else {}
|
||||
return FlowStepsPlan(
|
||||
起=str(data.get("起") or data.get("qi") or ""),
|
||||
承=str(data.get("承") or data.get("cheng") or ""),
|
||||
转=str(data.get("转") or data.get("zhuan") or ""),
|
||||
合=str(data.get("合") or data.get("he") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_chapter_plan(raw: Any) -> List[ChapterPlanItem]:
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
items: List[ChapterPlanItem] = []
|
||||
for idx, item in enumerate(raw):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
seq = item.get("seq")
|
||||
if not isinstance(seq, int):
|
||||
seq = idx + 1
|
||||
status = str(item.get("status") or "planned")
|
||||
items.append(
|
||||
ChapterPlanItem(
|
||||
seq=seq,
|
||||
phaseKey=str(item.get("phaseKey") or item.get("phase_key") or "起"),
|
||||
phaseSlice=str(item.get("phaseSlice") or item.get("phase_slice") or ""),
|
||||
brief=str(item.get("brief") or ""),
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
items.sort(key=lambda c: c.seq)
|
||||
return items
|
||||
|
||||
|
||||
def _parse_event_plan_response(data: Dict[str, Any], emotion_flow_id: str) -> EventPlanEntry:
|
||||
flow_steps = _normalize_flow_steps_plan(data.get("flowStepsPlan"))
|
||||
chapter_plan = _normalize_chapter_plan(data.get("chapterPlan"))
|
||||
if not chapter_plan:
|
||||
raise ValueError("chapterPlan 为空")
|
||||
return EventPlanEntry(
|
||||
emotionFlowId=emotion_flow_id,
|
||||
flowStepsPlan=flow_steps,
|
||||
chapterPlan=chapter_plan,
|
||||
)
|
||||
|
||||
|
||||
def _event_fully_planned(entry: EventPlanEntry) -> bool:
|
||||
return bool(entry.chapterPlan)
|
||||
|
||||
|
||||
def _resolve_targets(
|
||||
metadata: FictionBookMetadata,
|
||||
*,
|
||||
event_id: Optional[str] = None,
|
||||
) -> List[CoarseOutlineEvent]:
|
||||
coarse_events = metadata.coarseOutline.events
|
||||
if not coarse_events:
|
||||
raise ValueError("请先生成粗纲")
|
||||
|
||||
events_map = dict(metadata.events or {})
|
||||
if event_id:
|
||||
targets = [e for e in coarse_events if e.id == event_id]
|
||||
if not targets:
|
||||
raise ValueError(f"粗纲中不存在事件: {event_id}")
|
||||
return targets
|
||||
|
||||
return [
|
||||
e
|
||||
for e in coarse_events
|
||||
if e.id not in events_map or not _event_fully_planned(events_map[e.id])
|
||||
]
|
||||
|
||||
|
||||
def event_planned_payload(evt: CoarseOutlineEvent, entry: EventPlanEntry) -> Dict[str, Any]:
|
||||
phases: List[str] = []
|
||||
fsp = entry.flowStepsPlan
|
||||
for key in ("起", "承", "转", "合"):
|
||||
if getattr(fsp, key, ""):
|
||||
phases.append(key)
|
||||
return {
|
||||
"type": "event_planned",
|
||||
"eventId": evt.id,
|
||||
"title": evt.title,
|
||||
"chapterCount": len(entry.chapterPlan),
|
||||
"phases": phases,
|
||||
}
|
||||
|
||||
|
||||
def build_progress_snapshot(book_id: str) -> Dict[str, Any]:
|
||||
"""已规划事件的目录快照(不含 brief 剧透)。"""
|
||||
metadata = fiction_metadata_service.get_metadata(book_id)
|
||||
coarse_events = metadata.coarseOutline.events
|
||||
events_map = metadata.events or {}
|
||||
items: List[Dict[str, Any]] = []
|
||||
for evt in coarse_events:
|
||||
entry = events_map.get(evt.id)
|
||||
if entry and _event_fully_planned(entry):
|
||||
items.append(event_planned_payload(evt, entry))
|
||||
run = fiction_metadata_service.get_run(book_id)
|
||||
progress = run.progress or {}
|
||||
return {
|
||||
"type": "snapshot",
|
||||
"items": items,
|
||||
"done": progress.get("done", len(items)),
|
||||
"total": progress.get("total", len(coarse_events)),
|
||||
}
|
||||
|
||||
|
||||
async def _generate_one_event(
|
||||
book_id: str,
|
||||
evt: CoarseOutlineEvent,
|
||||
*,
|
||||
resolved: Dict[str, str],
|
||||
allowed: List[str],
|
||||
) -> EventPlanEntry:
|
||||
emotion_flow_id = random.choice(allowed)
|
||||
messages = _build_event_plan_messages(
|
||||
book_id,
|
||||
evt.id,
|
||||
evt.title,
|
||||
evt.summary,
|
||||
emotion_flow_id,
|
||||
)
|
||||
response = await _llm_client.chat_completion(
|
||||
messages=messages,
|
||||
api_url=resolved["api_url"],
|
||||
api_key=resolved["api_key"],
|
||||
model=resolved.get("model", "gpt-4o-mini"),
|
||||
temperature=0.7,
|
||||
max_tokens=8000,
|
||||
request_timeout=120,
|
||||
stream=False,
|
||||
)
|
||||
content = response["choices"][0]["message"]["content"]
|
||||
data = _extract_json(content)
|
||||
return _parse_event_plan_response(data, emotion_flow_id)
|
||||
|
||||
|
||||
async def iter_event_plan(
|
||||
book_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
event_id: Optional[str] = None,
|
||||
) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""按事件逐个生成,每完成一个即保存并 yield 进度事件。"""
|
||||
resolved = resolve_api_config(profile_id, api_config)
|
||||
_validate_api_config(resolved)
|
||||
|
||||
metadata = fiction_metadata_service.get_metadata(book_id)
|
||||
meta = fiction_service.get_book_meta(book_id)
|
||||
allowed = list(meta.allowedFlowIds or [])
|
||||
if not allowed:
|
||||
raise ValueError("本书未配置 allowedFlowIds")
|
||||
|
||||
targets = _resolve_targets(metadata, event_id=event_id)
|
||||
coarse_events = metadata.coarseOutline.events
|
||||
coarse_total = len(coarse_events)
|
||||
|
||||
if not targets:
|
||||
logger.info("Event plans already exist for book %s, skipping generation", book_id)
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="event_plan_done"
|
||||
)
|
||||
fiction_metadata_service.clear_pipeline_progress(book_id)
|
||||
done_evt: Dict[str, Any] = {"type": "done", "done": coarse_total, "total": coarse_total}
|
||||
emit_progress(book_id, done_evt)
|
||||
yield done_evt
|
||||
return
|
||||
|
||||
run = fiction_metadata_service.get_run(book_id)
|
||||
if run.status == "error":
|
||||
fiction_metadata_service.clear_pipeline_error(book_id)
|
||||
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="running", pipeline_stage="event_plan"
|
||||
)
|
||||
|
||||
events_map = dict(metadata.events or {})
|
||||
|
||||
def _planned_count() -> int:
|
||||
return sum(
|
||||
1
|
||||
for e in coarse_events
|
||||
if e.id in events_map and _event_fully_planned(events_map[e.id])
|
||||
)
|
||||
|
||||
initial_done = _planned_count()
|
||||
fiction_metadata_service.set_pipeline_progress(
|
||||
book_id, done=initial_done, total=coarse_total
|
||||
)
|
||||
started: Dict[str, Any] = {
|
||||
"type": "started",
|
||||
"done": initial_done,
|
||||
"total": coarse_total,
|
||||
"pending": len(targets),
|
||||
}
|
||||
emit_progress(book_id, started)
|
||||
yield started
|
||||
|
||||
try:
|
||||
for evt in targets:
|
||||
entry = await _generate_one_event(
|
||||
book_id, evt, resolved=resolved, allowed=allowed
|
||||
)
|
||||
events_map[evt.id] = entry
|
||||
metadata.events = events_map
|
||||
fiction_metadata_service.save_metadata(book_id, metadata)
|
||||
|
||||
done_count = _planned_count()
|
||||
fiction_metadata_service.set_pipeline_progress(
|
||||
book_id, done=done_count, total=coarse_total
|
||||
)
|
||||
payload = event_planned_payload(evt, entry)
|
||||
emit_progress(book_id, payload)
|
||||
yield payload
|
||||
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="event_plan_done"
|
||||
)
|
||||
fiction_metadata_service.clear_pipeline_progress(book_id)
|
||||
done_evt = {"type": "done", "done": _planned_count(), "total": coarse_total}
|
||||
emit_progress(book_id, done_evt)
|
||||
yield done_evt
|
||||
except Exception as exc:
|
||||
completed = [
|
||||
eid for eid, ent in events_map.items() if _event_fully_planned(ent)
|
||||
]
|
||||
err_evt: Dict[str, Any] = {
|
||||
"type": "error",
|
||||
"message": str(exc),
|
||||
"completedEvents": completed,
|
||||
"done": _planned_count(),
|
||||
"total": coarse_total,
|
||||
}
|
||||
emit_progress(book_id, err_evt)
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="error", pipeline_stage="event_plan"
|
||||
)
|
||||
yield err_evt
|
||||
raise
|
||||
|
||||
|
||||
async def run_event_plan(
|
||||
book_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
event_id: Optional[str] = None,
|
||||
) -> FictionBookMetadata:
|
||||
async for _event in iter_event_plan(
|
||||
book_id,
|
||||
profile_id=profile_id,
|
||||
api_config=api_config,
|
||||
event_id=event_id,
|
||||
):
|
||||
pass
|
||||
return fiction_metadata_service.get_metadata(book_id)
|
||||
|
||||
|
||||
async def stream_event_plan_subscribe(book_id: str) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""订阅进行中的事件纲要进度(先快照,再实时)。"""
|
||||
snapshot = build_progress_snapshot(book_id)
|
||||
yield snapshot
|
||||
|
||||
run = fiction_metadata_service.get_run(book_id)
|
||||
if run.status == "running" and run.pipelineStage == "event_plan":
|
||||
async for event in subscribe_progress(book_id):
|
||||
if event.get("type") == "snapshot":
|
||||
continue
|
||||
yield event
|
||||
elif snapshot["items"] or snapshot.get("done", 0) > 0:
|
||||
yield {
|
||||
"type": "done",
|
||||
"done": snapshot["done"],
|
||||
"total": snapshot["total"],
|
||||
}
|
||||
168
backend/services/fiction_metadata_service.py
Normal file
168
backend/services/fiction_metadata_service.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
爽文 metadata.json / run.json 读写服务。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from models.fiction_models import FictionBookMetadata, FictionRunState
|
||||
from services.fiction_service import fiction_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_STAGE_MESSAGES: Dict[tuple, tuple] = {
|
||||
("running", "coarse"): ("coarse_generating", "正在生成粗纲…"),
|
||||
("running", "event_plan"): ("event_plan_generating", "正在生成事件纲要…"),
|
||||
("running", "chapter"): ("chapter_generating", "正在撰写正文…"),
|
||||
("error", "coarse"): ("error", "粗纲生成失败"),
|
||||
("error", "event_plan"): ("error", "事件纲要生成失败"),
|
||||
("error", "chapter"): ("error", "章节写作失败"),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_stage_message(
|
||||
status: str,
|
||||
pipeline_stage: Optional[str],
|
||||
override_message: Optional[str] = None,
|
||||
) -> tuple:
|
||||
if override_message is not None:
|
||||
key = (status, pipeline_stage or "")
|
||||
stage = _STAGE_MESSAGES.get(key, (status, override_message))[0]
|
||||
if status == "error":
|
||||
stage = "error"
|
||||
elif status == "running" and pipeline_stage == "coarse":
|
||||
stage = "coarse_generating"
|
||||
elif status == "running" and pipeline_stage == "event_plan":
|
||||
stage = "event_plan_generating"
|
||||
elif status == "running" and pipeline_stage == "chapter":
|
||||
stage = "chapter_generating"
|
||||
elif status == "idle":
|
||||
stage = "idle"
|
||||
return stage, override_message
|
||||
matched = _STAGE_MESSAGES.get((status, pipeline_stage or ""))
|
||||
if matched:
|
||||
return matched
|
||||
if status == "idle":
|
||||
return "idle", None
|
||||
if status == "error":
|
||||
return "error", "生成失败"
|
||||
return status, None
|
||||
|
||||
|
||||
def _read_json(path: Path) -> Any:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _write_json(path: Path, data: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
class FictionMetadataService:
|
||||
def _metadata_path(self, book_id: str) -> Path:
|
||||
return fiction_service._metadata_path(book_id)
|
||||
|
||||
def _run_path(self, book_id: str) -> Path:
|
||||
return fiction_service._run_path(book_id)
|
||||
|
||||
def _ensure_book(self, book_id: str) -> None:
|
||||
if not fiction_service._meta_path(book_id).exists():
|
||||
raise FileNotFoundError(f"Book not found: {book_id}")
|
||||
|
||||
def get_metadata(self, book_id: str) -> FictionBookMetadata:
|
||||
self._ensure_book(book_id)
|
||||
path = self._metadata_path(book_id)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"metadata.json not found for book: {book_id}")
|
||||
return FictionBookMetadata(**_read_json(path))
|
||||
|
||||
def save_metadata(self, book_id: str, metadata: FictionBookMetadata) -> FictionBookMetadata:
|
||||
self._ensure_book(book_id)
|
||||
_write_json(self._metadata_path(book_id), metadata.model_dump())
|
||||
self._touch_book_meta(book_id)
|
||||
return metadata
|
||||
|
||||
def update_metadata(self, book_id: str, patch: Dict[str, Any]) -> FictionBookMetadata:
|
||||
current = self.get_metadata(book_id)
|
||||
data = current.model_dump()
|
||||
for key, value in patch.items():
|
||||
data[key] = value
|
||||
updated = FictionBookMetadata(**data)
|
||||
return self.save_metadata(book_id, updated)
|
||||
|
||||
def get_run(self, book_id: str) -> FictionRunState:
|
||||
self._ensure_book(book_id)
|
||||
path = self._run_path(book_id)
|
||||
if not path.exists():
|
||||
return FictionRunState()
|
||||
return FictionRunState(**_read_json(path))
|
||||
|
||||
def save_run(self, book_id: str, run: FictionRunState) -> FictionRunState:
|
||||
self._ensure_book(book_id)
|
||||
_write_json(self._run_path(book_id), run.model_dump())
|
||||
return run
|
||||
|
||||
def set_pipeline_stage(
|
||||
self,
|
||||
book_id: str,
|
||||
*,
|
||||
status: str,
|
||||
pipeline_stage: Optional[str] = None,
|
||||
message: Optional[str] = None,
|
||||
) -> FictionRunState:
|
||||
run = self.get_run(book_id)
|
||||
run.status = status
|
||||
run.pipelineStage = pipeline_stage
|
||||
run.stage, run.message = _resolve_stage_message(status, pipeline_stage, message)
|
||||
run.updatedAt = datetime.now().isoformat()
|
||||
return self.save_run(book_id, run)
|
||||
|
||||
def clear_pipeline_error(self, book_id: str) -> FictionRunState:
|
||||
"""清除 error 状态,保留 pipelineStage 供重试参考。"""
|
||||
run = self.get_run(book_id)
|
||||
if run.status != "error":
|
||||
return run
|
||||
run.status = "idle"
|
||||
run.stage = "idle"
|
||||
run.message = None
|
||||
run.updatedAt = datetime.now().isoformat()
|
||||
return self.save_run(book_id, run)
|
||||
|
||||
def set_pipeline_progress(
|
||||
self, book_id: str, *, done: int, total: int
|
||||
) -> FictionRunState:
|
||||
run = self.get_run(book_id)
|
||||
run.progress = {"done": done, "total": total}
|
||||
run.updatedAt = datetime.now().isoformat()
|
||||
return self.save_run(book_id, run)
|
||||
|
||||
def clear_pipeline_progress(self, book_id: str) -> FictionRunState:
|
||||
run = self.get_run(book_id)
|
||||
run.progress = None
|
||||
run.updatedAt = datetime.now().isoformat()
|
||||
return self.save_run(book_id, run)
|
||||
|
||||
def update_progress(
|
||||
self,
|
||||
book_id: str,
|
||||
*,
|
||||
current_chapter_seq: Optional[int] = None,
|
||||
char_offset: Optional[int] = None,
|
||||
):
|
||||
metadata = self.get_metadata(book_id)
|
||||
progress = metadata.progress
|
||||
if current_chapter_seq is not None:
|
||||
progress.currentChapterSeq = current_chapter_seq
|
||||
if char_offset is not None:
|
||||
progress.charOffset = char_offset
|
||||
metadata.progress = progress
|
||||
return self.save_metadata(book_id, metadata)
|
||||
|
||||
|
||||
fiction_metadata_service = FictionMetadataService()
|
||||
159
backend/services/fiction_open_book_service.py
Normal file
159
backend/services/fiction_open_book_service.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
爽文开书优化(fiction.open_book)— LLM 调用逻辑。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
|
||||
from models.fiction_models import (
|
||||
EmotionFlow,
|
||||
FictionGuideWorldbook,
|
||||
OpenBookResult,
|
||||
)
|
||||
from services.fiction_prompt_utils import resolve_prompt
|
||||
from services.fiction_service import fiction_service
|
||||
from services.studio_step_respond import resolve_api_config
|
||||
from utils.llm_client import LLMClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_llm_client = LLMClient()
|
||||
_JSON_FENCE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> Dict[str, Any]:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
raise ValueError("模型返回为空")
|
||||
fence = _JSON_FENCE.search(text)
|
||||
if fence:
|
||||
text = fence.group(1).strip()
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
return json.loads(text[start : end + 1])
|
||||
raise ValueError("无法解析模型返回的 JSON")
|
||||
|
||||
|
||||
def _validate_api_config(api_config: Dict[str, str]) -> None:
|
||||
if not api_config.get("api_key"):
|
||||
raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥")
|
||||
if not api_config.get("api_url"):
|
||||
raise ValueError("API 地址未配置,请先在 API 配置页面保存 mainLLM")
|
||||
|
||||
|
||||
def _format_catalog_for_prompt(flows: List[EmotionFlow]) -> str:
|
||||
lines: List[str] = []
|
||||
for flow in flows:
|
||||
tags = "、".join(flow.tags or [])
|
||||
lines.append(f"- id: {flow.id}\n intro: {flow.intro}\n tags: {tags}")
|
||||
return "\n".join(lines) if lines else "(无可用情绪流)"
|
||||
|
||||
|
||||
def _format_guide_global_for_prompt() -> str:
|
||||
entries = fiction_service.get_guide_global_entries().entries
|
||||
lines: List[str] = []
|
||||
for entry in entries:
|
||||
lines.append(f"[{entry.layer}] {entry.title}\n{entry.content}")
|
||||
return "\n\n".join(lines) if lines else "(无全局指南)"
|
||||
|
||||
|
||||
def _build_open_book_messages(inspiration: str) -> List[Any]:
|
||||
default_settings = fiction_service.get_default_settings()
|
||||
system_prompt = resolve_prompt("openBook", default_settings.prompts.openBook)
|
||||
|
||||
catalog = fiction_service.get_emotion_catalog()
|
||||
catalog_text = _format_catalog_for_prompt(catalog.flows)
|
||||
guide_global_text = _format_guide_global_for_prompt()
|
||||
|
||||
user_content = f"""## 全局创作指南(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)
|
||||
290
backend/services/fiction_orchestrator_service.py
Normal file
290
backend/services/fiction_orchestrator_service.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
爽文阅读流水线编排 — 新版 ensure 滚动补齐:卷纲 → 事件链 → 章纲 → 章节。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from models.fiction_models import (
|
||||
FictionPipelineSettings,
|
||||
FictionPipelineTickResult,
|
||||
FictionRunState,
|
||||
FictionStartReadingResult,
|
||||
)
|
||||
from services.fiction_chapter_service import (
|
||||
find_next_unwritten_chapter,
|
||||
has_written_chapters,
|
||||
run_chapter,
|
||||
)
|
||||
from services.fiction_metadata_service import fiction_metadata_service
|
||||
from services.fiction_planning_service import (
|
||||
ensure_chapter_plan,
|
||||
ensure_event_chain,
|
||||
ensure_volume,
|
||||
)
|
||||
from services.fiction_service import fiction_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_active_tasks: Dict[str, asyncio.Task] = {}
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _get_pipeline_settings(book_id: str) -> FictionPipelineSettings:
|
||||
settings = fiction_service.get_book_settings(book_id)
|
||||
return settings.pipeline or FictionPipelineSettings()
|
||||
|
||||
|
||||
def _needs_volume(book_id: str) -> bool:
|
||||
metadata = fiction_metadata_service.get_metadata(book_id)
|
||||
return not metadata.volumes
|
||||
|
||||
|
||||
def _needs_event_chain(book_id: str) -> bool:
|
||||
metadata = fiction_metadata_service.get_metadata(book_id)
|
||||
if not metadata.volumes:
|
||||
return False
|
||||
for volume in metadata.volumes:
|
||||
if not metadata.eventChains.get(volume.id):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _needs_chapter_plan(book_id: str) -> bool:
|
||||
metadata = fiction_metadata_service.get_metadata(book_id)
|
||||
if not metadata.volumes:
|
||||
return False
|
||||
for volume in metadata.volumes:
|
||||
events = metadata.eventChains.get(volume.id, [])
|
||||
if not events:
|
||||
return False
|
||||
for event in events:
|
||||
if not metadata.chapterPlans.get(event.id):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _needs_chapter(book_id: str) -> bool:
|
||||
if _needs_volume(book_id) or _needs_event_chain(book_id) or _needs_chapter_plan(book_id):
|
||||
return False
|
||||
if has_written_chapters(book_id):
|
||||
return False
|
||||
metadata = fiction_metadata_service.get_metadata(book_id)
|
||||
return find_next_unwritten_chapter(book_id, metadata) is not None
|
||||
|
||||
|
||||
def _pipeline_complete(book_id: str) -> bool:
|
||||
return (
|
||||
not _needs_volume(book_id)
|
||||
and not _needs_event_chain(book_id)
|
||||
and not _needs_chapter_plan(book_id)
|
||||
and not _needs_chapter(book_id)
|
||||
)
|
||||
|
||||
|
||||
def get_pending_stages(book_id: str) -> List[str]:
|
||||
"""返回需手动触发的阶段 id 列表(auto 关闭且仍有工作,或上次失败需重试)。"""
|
||||
pipeline = _get_pipeline_settings(book_id)
|
||||
pending: List[str] = []
|
||||
if _needs_volume(book_id) and not pipeline.autoCoarse:
|
||||
pending.append("volume")
|
||||
if _needs_event_chain(book_id) and not pipeline.autoEventPlan:
|
||||
pending.append("event_chain")
|
||||
if _needs_chapter_plan(book_id) and not pipeline.autoEventPlan:
|
||||
pending.append("chapter_plan")
|
||||
if _needs_chapter(book_id) and not pipeline.autoChapter:
|
||||
pending.append("chapter")
|
||||
|
||||
run = fiction_metadata_service.get_run(book_id)
|
||||
if run.status == "error" and run.pipelineStage:
|
||||
retry_map = {
|
||||
"volume": "volume",
|
||||
"coarse": "volume",
|
||||
"event_chain": "event_chain",
|
||||
"event_plan": "chapter_plan",
|
||||
"chapter_plan": "chapter_plan",
|
||||
"chapter": "chapter",
|
||||
}
|
||||
failed = retry_map.get(run.pipelineStage)
|
||||
if failed and failed not in pending:
|
||||
if failed == "volume" and _needs_volume(book_id):
|
||||
pending.insert(0, failed)
|
||||
elif failed == "event_chain" and _needs_event_chain(book_id):
|
||||
pending.insert(0, failed)
|
||||
elif failed == "chapter_plan" and _needs_chapter_plan(book_id):
|
||||
pending.insert(0, failed)
|
||||
elif failed == "chapter" and _needs_chapter(book_id):
|
||||
pending.insert(0, failed)
|
||||
return pending
|
||||
|
||||
|
||||
def _next_auto_stage(book_id: str) -> Optional[str]:
|
||||
pipeline = _get_pipeline_settings(book_id)
|
||||
if _needs_volume(book_id):
|
||||
return "volume" if pipeline.autoCoarse else None
|
||||
if _needs_event_chain(book_id):
|
||||
return "event_chain" if pipeline.autoEventPlan else None
|
||||
if _needs_chapter_plan(book_id):
|
||||
return "chapter_plan" if pipeline.autoEventPlan else None
|
||||
if _needs_chapter(book_id):
|
||||
return "chapter" if pipeline.autoChapter else None
|
||||
return None
|
||||
|
||||
|
||||
async def _run_pipeline(
|
||||
book_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
) -> None:
|
||||
pipeline = _get_pipeline_settings(book_id)
|
||||
try:
|
||||
if _needs_volume(book_id):
|
||||
if not pipeline.autoCoarse:
|
||||
return
|
||||
await ensure_volume(
|
||||
book_id, profile_id=profile_id, api_config=api_config
|
||||
)
|
||||
|
||||
if _needs_event_chain(book_id):
|
||||
if not pipeline.autoEventPlan:
|
||||
return
|
||||
await ensure_event_chain(
|
||||
book_id, profile_id=profile_id, api_config=api_config
|
||||
)
|
||||
|
||||
if _needs_chapter_plan(book_id):
|
||||
if not pipeline.autoEventPlan:
|
||||
return
|
||||
await ensure_chapter_plan(
|
||||
book_id, profile_id=profile_id, api_config=api_config
|
||||
)
|
||||
|
||||
if _needs_chapter(book_id):
|
||||
if not pipeline.autoChapter:
|
||||
return
|
||||
await run_chapter(
|
||||
book_id, profile_id=profile_id, api_config=api_config
|
||||
)
|
||||
|
||||
if _pipeline_complete(book_id):
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="ready"
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Fiction pipeline failed for book %s", book_id)
|
||||
finally:
|
||||
async with _lock:
|
||||
_active_tasks.pop(book_id, None)
|
||||
|
||||
|
||||
async def _task_is_active(book_id: str) -> bool:
|
||||
async with _lock:
|
||||
task = _active_tasks.get(book_id)
|
||||
return task is not None and not task.done()
|
||||
|
||||
|
||||
async def _start_pipeline_task(
|
||||
book_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
) -> FictionPipelineTickResult:
|
||||
run = fiction_metadata_service.get_run(book_id)
|
||||
pending = get_pending_stages(book_id)
|
||||
|
||||
if run.status == "running":
|
||||
if await _task_is_active(book_id):
|
||||
return FictionPipelineTickResult(run=run, started=False, pendingStages=pending)
|
||||
logger.warning("Stale running pipeline for book %s, resetting to idle", book_id)
|
||||
run = fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage=run.pipelineStage
|
||||
)
|
||||
|
||||
if run.status == "error":
|
||||
if _pipeline_complete(book_id):
|
||||
run = fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="ready"
|
||||
)
|
||||
return FictionPipelineTickResult(
|
||||
run=run, started=False, pendingStages=pending
|
||||
)
|
||||
run = fiction_metadata_service.clear_pipeline_error(book_id)
|
||||
|
||||
if _pipeline_complete(book_id):
|
||||
if run.pipelineStage != "ready":
|
||||
run = fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="ready"
|
||||
)
|
||||
return FictionPipelineTickResult(run=run, started=False, pendingStages=pending)
|
||||
|
||||
next_stage = _next_auto_stage(book_id)
|
||||
if not next_stage:
|
||||
if run.pipelineStage not in (
|
||||
None,
|
||||
"ready",
|
||||
"volume_done",
|
||||
"event_chain_done",
|
||||
"chapter_plan_done",
|
||||
"chapter_done",
|
||||
):
|
||||
run = fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="ready"
|
||||
)
|
||||
return FictionPipelineTickResult(
|
||||
run=run, started=False, pendingStages=pending
|
||||
)
|
||||
|
||||
async with _lock:
|
||||
existing = _active_tasks.get(book_id)
|
||||
if existing and not existing.done():
|
||||
run = fiction_metadata_service.get_run(book_id)
|
||||
return FictionPipelineTickResult(
|
||||
run=run, started=False, pendingStages=pending
|
||||
)
|
||||
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="running", pipeline_stage=next_stage
|
||||
)
|
||||
|
||||
task = asyncio.create_task(
|
||||
_run_pipeline(
|
||||
book_id, profile_id=profile_id, api_config=api_config
|
||||
)
|
||||
)
|
||||
_active_tasks[book_id] = task
|
||||
|
||||
run = fiction_metadata_service.get_run(book_id)
|
||||
pending = get_pending_stages(book_id)
|
||||
return FictionPipelineTickResult(run=run, started=True, pendingStages=pending)
|
||||
|
||||
|
||||
async def tick_reading_pipeline(
|
||||
book_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
) -> FictionPipelineTickResult:
|
||||
"""检查 metadata + settings,按需启动下一自动阶段。"""
|
||||
return await _start_pipeline_task(
|
||||
book_id, profile_id=profile_id, api_config=api_config
|
||||
)
|
||||
|
||||
|
||||
async def start_reading_pipeline(
|
||||
book_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
) -> FictionStartReadingResult:
|
||||
"""进入阅读时的流水线入口(兼容旧接口)。"""
|
||||
result = await tick_reading_pipeline(
|
||||
book_id, profile_id=profile_id, api_config=api_config
|
||||
)
|
||||
return FictionStartReadingResult(run=result.run, started=result.started)
|
||||
|
||||
|
||||
def get_pipeline_run(book_id: str) -> FictionRunState:
|
||||
return fiction_metadata_service.get_run(book_id)
|
||||
540
backend/services/fiction_planning_service.py
Normal file
540
backend/services/fiction_planning_service.py
Normal file
@@ -0,0 +1,540 @@
|
||||
"""
|
||||
爽文新版规划服务:卷纲 → 情感链事件串 → 章纲。
|
||||
|
||||
原则:
|
||||
- 硬编码提示词只保留规定性约束:输出结构、字数/章节数、必须遵循的上游内容。
|
||||
- “如何写爽点/如何留钩子”等创作方法交给 book-local 世界书与全局指南。
|
||||
- book-local 世界书按 volume/event/chapter 三层分别插入,不混用。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
|
||||
from models.fiction_models import (
|
||||
ChapterPlanItem,
|
||||
EventChainItem,
|
||||
FictionBookMetadata,
|
||||
VolumeOutline,
|
||||
)
|
||||
from services.fiction_metadata_service import fiction_metadata_service
|
||||
from services.fiction_prompt_utils import resolve_prompt
|
||||
from services.fiction_service import fiction_service
|
||||
from services.studio_step_respond import resolve_api_config
|
||||
from utils.llm_client import LLMClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_llm_client = LLMClient()
|
||||
_JSON_FENCE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> Dict[str, Any]:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
raise ValueError("模型返回为空")
|
||||
fence = _JSON_FENCE.search(text)
|
||||
if fence:
|
||||
text = fence.group(1).strip()
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
return json.loads(text[start : end + 1])
|
||||
raise ValueError("无法解析模型返回的 JSON")
|
||||
|
||||
|
||||
def _validate_api_config(api_config: Dict[str, str]) -> None:
|
||||
if not api_config.get("api_key"):
|
||||
raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥")
|
||||
if not api_config.get("api_url"):
|
||||
raise ValueError("API 地址未配置,请先在 API 配置页面保存 mainLLM")
|
||||
if not api_config.get("model"):
|
||||
raise ValueError("模型未配置,请先在 API 配置页面保存 mainLLM 模型")
|
||||
|
||||
|
||||
def _format_guide_global_layers(layers: List[str]) -> str:
|
||||
entries = fiction_service.get_guide_global_entries().entries
|
||||
filtered = [e for e in entries if e.layer in layers]
|
||||
lines: List[str] = []
|
||||
for entry in filtered:
|
||||
lines.append(f"[{entry.layer}] {entry.title}\n{entry.content}")
|
||||
return "\n\n".join(lines) if lines else "(无全局指南)"
|
||||
|
||||
|
||||
def _format_book_guide(book_id: str) -> str:
|
||||
guide = fiction_service.get_book_guide(book_id)
|
||||
lines = [
|
||||
f"主角人设:{guide.persona}",
|
||||
f"核心爽点:{guide.highlight}",
|
||||
f"用户体验:{guide.experience}",
|
||||
f"创作禁区:{guide.forbiddenZones}",
|
||||
]
|
||||
text = "\n".join(line for line in lines if line.split(":", 1)[1].strip()).strip()
|
||||
return text or "(无本书 guide)"
|
||||
|
||||
|
||||
def _flow_catalog_text() -> str:
|
||||
catalog = fiction_service.get_emotion_catalog()
|
||||
lines: List[str] = []
|
||||
for flow in catalog.flows:
|
||||
steps = " → ".join([f"{s.key}:{s.text}" for s in flow.steps])
|
||||
lines.append(f"- {flow.id}: {flow.intro} | steps={steps}")
|
||||
return "\n".join(lines) if lines else "(无情感链目录)"
|
||||
|
||||
|
||||
def _get_flow_by_id(flow_id: str):
|
||||
catalog = fiction_service.get_emotion_catalog()
|
||||
for flow in catalog.flows:
|
||||
if flow.id == flow_id:
|
||||
return flow
|
||||
return None
|
||||
|
||||
|
||||
def _choose_flow_id(book_id: str) -> str:
|
||||
meta = fiction_service.get_book_meta(book_id)
|
||||
allowed = list(meta.allowedFlowIds or [])
|
||||
catalog = fiction_service.get_emotion_catalog()
|
||||
catalog_ids = [flow.id for flow in catalog.flows]
|
||||
candidates = [fid for fid in allowed if fid in catalog_ids] or allowed or catalog_ids
|
||||
if not candidates:
|
||||
return ""
|
||||
return random.choice(candidates)
|
||||
|
||||
|
||||
def _format_flow(flow_id: str) -> str:
|
||||
flow = _get_flow_by_id(flow_id)
|
||||
if not flow:
|
||||
return f"情感链 id: {flow_id or '(未指定)'}"
|
||||
lines = [f"情感链:{flow.intro} (id: {flow.id})"]
|
||||
for step in flow.steps or []:
|
||||
lines.append(f"- {step.key}: {step.text}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _next_volume_id(metadata: FictionBookMetadata) -> str:
|
||||
return f"vol_{len(metadata.volumes) + 1:03d}"
|
||||
|
||||
|
||||
def _next_event_id(metadata: FictionBookMetadata, index: int) -> str:
|
||||
all_events = [event for chain in metadata.eventChains.values() for event in chain]
|
||||
return f"evt_{len(all_events) + index + 1:04d}"
|
||||
|
||||
|
||||
def _build_volume_messages(book_id: str, metadata: FictionBookMetadata) -> List[Any]:
|
||||
settings = fiction_service.get_book_settings(book_id)
|
||||
user_prompt = (
|
||||
settings.prompts.coarseOutline
|
||||
or fiction_service.get_default_settings().prompts.coarseOutline
|
||||
)
|
||||
system_prompt = resolve_prompt("volumeOutline", user_prompt)
|
||||
|
||||
meta = fiction_service.get_book_meta(book_id)
|
||||
guide_l1 = _format_guide_global_layers(["L1"])
|
||||
book_guide = _format_book_guide(book_id)
|
||||
existing = "\n".join([f"- {v.id} {v.title}: {v.goal}" for v in metadata.volumes]) or "(暂无)"
|
||||
|
||||
user_content = f"""## 全局创作指南(L1)
|
||||
{guide_l1}
|
||||
|
||||
## 本书 guide(具体人设/爽点/体验/禁区)
|
||||
{book_guide}
|
||||
|
||||
## 书名
|
||||
{meta.title}
|
||||
|
||||
## 已有卷纲
|
||||
{existing}
|
||||
|
||||
## 可用情感链目录
|
||||
{_flow_catalog_text()}
|
||||
|
||||
## 本次任务
|
||||
生成下一卷卷纲。
|
||||
|
||||
## 绝对要求
|
||||
- 只生成 1 卷。
|
||||
- 本卷目标章节数 targetChapterCount 必须在 10 到 30 之间。
|
||||
- primaryEmotionFlowId 必须来自可用情感链目录;如目录为空则留空。
|
||||
- 不生成事件链、章纲或正文。
|
||||
"""
|
||||
|
||||
return [SystemMessage(content=system_prompt), HumanMessage(content=user_content)]
|
||||
|
||||
|
||||
def _normalize_volume(raw: Dict[str, Any], volume_id: str, order: int) -> VolumeOutline:
|
||||
target = raw.get("targetChapterCount")
|
||||
if not isinstance(target, int):
|
||||
target = 20
|
||||
return VolumeOutline(
|
||||
id=str(raw.get("id") or volume_id),
|
||||
order=order,
|
||||
title=str(raw.get("title") or f"第{order}卷"),
|
||||
goal=str(raw.get("goal") or ""),
|
||||
coreConflict=str(raw.get("coreConflict") or raw.get("core_conflict") or ""),
|
||||
powerProgression=str(raw.get("powerProgression") or raw.get("power_progression") or ""),
|
||||
emotionalPromise=str(raw.get("emotionalPromise") or raw.get("emotional_promise") or ""),
|
||||
endingHook=str(raw.get("endingHook") or raw.get("ending_hook") or ""),
|
||||
targetChapterCount=max(10, min(30, target)),
|
||||
primaryEmotionFlowId=str(raw.get("primaryEmotionFlowId") or raw.get("primary_emotion_flow_id") or ""),
|
||||
status=str(raw.get("status") or "active"),
|
||||
)
|
||||
|
||||
|
||||
def _build_event_chain_messages(book_id: str, volume: VolumeOutline, flow_id: str) -> List[Any]:
|
||||
settings = fiction_service.get_book_settings(book_id)
|
||||
user_prompt = (
|
||||
settings.prompts.eventPlan
|
||||
or fiction_service.get_default_settings().prompts.eventPlan
|
||||
)
|
||||
system_prompt = resolve_prompt("eventChain", user_prompt)
|
||||
|
||||
guide_l2 = _format_guide_global_layers(["L2"])
|
||||
book_guide = _format_book_guide(book_id)
|
||||
flow_text = _format_flow(flow_id)
|
||||
|
||||
user_content = f"""## 全局创作指南(L2)
|
||||
{guide_l2}
|
||||
|
||||
## 本书 guide(具体人设/爽点/体验/禁区)
|
||||
{book_guide}
|
||||
|
||||
## 当前卷纲
|
||||
- id: {volume.id}
|
||||
- title: {volume.title}
|
||||
- goal: {volume.goal}
|
||||
- coreConflict: {volume.coreConflict}
|
||||
- powerProgression: {volume.powerProgression}
|
||||
- emotionalPromise: {volume.emotionalPromise}
|
||||
- endingHook: {volume.endingHook}
|
||||
- targetChapterCount: {volume.targetChapterCount}
|
||||
|
||||
## 必须遵循的情感链
|
||||
{flow_text}
|
||||
|
||||
## 本次任务
|
||||
生成当前卷的事件链。
|
||||
|
||||
## 绝对要求
|
||||
- 事件链总章节数应接近卷纲 targetChapterCount。
|
||||
- 每个事件 targetChapterCount 必须在 2 到 5 之间。
|
||||
- 每个事件必须填写 emotionFlowId、emotionStepKey、emotionStepText。
|
||||
- 事件顺序必须遵循情感链 steps 的顺序,不得倒置。
|
||||
- 不生成章纲或正文。
|
||||
"""
|
||||
|
||||
return [SystemMessage(content=system_prompt), HumanMessage(content=user_content)]
|
||||
|
||||
|
||||
def _normalize_event_chain(
|
||||
raw: Any,
|
||||
metadata: FictionBookMetadata,
|
||||
volume: VolumeOutline,
|
||||
flow_id: str,
|
||||
) -> List[EventChainItem]:
|
||||
raw_events = raw if isinstance(raw, list) else []
|
||||
events: List[EventChainItem] = []
|
||||
flow = _get_flow_by_id(flow_id)
|
||||
steps = flow.steps if flow else []
|
||||
for idx, item in enumerate(raw_events):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
target = item.get("targetChapterCount")
|
||||
if not isinstance(target, int):
|
||||
target = 3
|
||||
step = steps[min(idx, len(steps) - 1)] if steps else None
|
||||
events.append(
|
||||
EventChainItem(
|
||||
id=str(item.get("id") or _next_event_id(metadata, idx)),
|
||||
volumeId=volume.id,
|
||||
order=int(item.get("order")) if isinstance(item.get("order"), int) else idx + 1,
|
||||
title=str(item.get("title") or f"事件 {idx + 1}"),
|
||||
summary=str(item.get("summary") or ""),
|
||||
purpose=str(item.get("purpose") or ""),
|
||||
conflict=str(item.get("conflict") or ""),
|
||||
turningPoint=str(item.get("turningPoint") or item.get("turning_point") or ""),
|
||||
expectedPayoff=str(item.get("expectedPayoff") or item.get("expected_payoff") or ""),
|
||||
targetChapterCount=max(2, min(5, target)),
|
||||
emotionFlowId=str(item.get("emotionFlowId") or item.get("emotion_flow_id") or flow_id),
|
||||
emotionStepKey=str(item.get("emotionStepKey") or item.get("emotion_step_key") or (step.key if step else "")),
|
||||
emotionStepText=str(item.get("emotionStepText") or item.get("emotion_step_text") or (step.text if step else "")),
|
||||
status=str(item.get("status") or "planned"),
|
||||
)
|
||||
)
|
||||
events.sort(key=lambda e: e.order)
|
||||
if not events:
|
||||
raise ValueError("事件链为空")
|
||||
return events
|
||||
|
||||
|
||||
def _build_chapter_plan_messages(
|
||||
book_id: str,
|
||||
volume: VolumeOutline,
|
||||
event: EventChainItem,
|
||||
) -> List[Any]:
|
||||
settings = fiction_service.get_book_settings(book_id)
|
||||
user_prompt = (
|
||||
settings.prompts.eventPlan
|
||||
or fiction_service.get_default_settings().prompts.eventPlan
|
||||
)
|
||||
system_prompt = resolve_prompt("chapterPlan", user_prompt)
|
||||
|
||||
guide_l3 = _format_guide_global_layers(["L3"])
|
||||
book_guide = _format_book_guide(book_id)
|
||||
|
||||
user_content = f"""## 全局创作指南(L3)
|
||||
{guide_l3}
|
||||
|
||||
## 本书 guide(具体人设/爽点/体验/禁区)
|
||||
{book_guide}
|
||||
|
||||
## 当前卷纲
|
||||
- id: {volume.id}
|
||||
- title: {volume.title}
|
||||
- goal: {volume.goal}
|
||||
- coreConflict: {volume.coreConflict}
|
||||
- emotionalPromise: {volume.emotionalPromise}
|
||||
|
||||
## 当前事件
|
||||
- id: {event.id}
|
||||
- title: {event.title}
|
||||
- summary: {event.summary}
|
||||
- purpose: {event.purpose}
|
||||
- conflict: {event.conflict}
|
||||
- turningPoint: {event.turningPoint}
|
||||
- expectedPayoff: {event.expectedPayoff}
|
||||
- targetChapterCount: {event.targetChapterCount}
|
||||
|
||||
## 当前事件绑定的情感链步骤
|
||||
- emotionFlowId: {event.emotionFlowId}
|
||||
- emotionStepKey: {event.emotionStepKey}
|
||||
- emotionStepText: {event.emotionStepText}
|
||||
|
||||
## 本次任务
|
||||
为当前事件生成章纲。
|
||||
|
||||
## 绝对要求
|
||||
- 必须生成 {event.targetChapterCount} 章章纲。
|
||||
- 每章 targetWords 必须为 2000。
|
||||
- 每章必须继承当前事件 id。
|
||||
- 每章必须填写 emotionStepKey 与 emotionGoal。
|
||||
- 不生成正文。
|
||||
"""
|
||||
|
||||
return [SystemMessage(content=system_prompt), HumanMessage(content=user_content)]
|
||||
|
||||
|
||||
def _next_chapter_seq(metadata: FictionBookMetadata) -> int:
|
||||
max_seq = 0
|
||||
for plans in metadata.chapterPlans.values():
|
||||
for item in plans:
|
||||
max_seq = max(max_seq, item.seq)
|
||||
return max_seq + 1
|
||||
|
||||
|
||||
def _normalize_chapter_plans(
|
||||
raw: Any,
|
||||
metadata: FictionBookMetadata,
|
||||
event: EventChainItem,
|
||||
) -> List[ChapterPlanItem]:
|
||||
raw_items = raw if isinstance(raw, list) else []
|
||||
start_seq = _next_chapter_seq(metadata)
|
||||
items: List[ChapterPlanItem] = []
|
||||
for idx, item in enumerate(raw_items):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
seq = start_seq + idx
|
||||
title = str(item.get("title") or f"第{seq}章")
|
||||
goal = str(item.get("goal") or item.get("brief") or "")
|
||||
items.append(
|
||||
ChapterPlanItem(
|
||||
seq=seq,
|
||||
phaseKey=str(item.get("phaseKey") or event.emotionStepKey),
|
||||
phaseSlice=str(item.get("phaseSlice") or ""),
|
||||
brief=str(item.get("brief") or goal),
|
||||
eventId=event.id,
|
||||
title=title,
|
||||
goal=goal,
|
||||
opening=str(item.get("opening") or ""),
|
||||
mainConflict=str(item.get("mainConflict") or item.get("main_conflict") or event.conflict),
|
||||
emotionalTurn=str(item.get("emotionalTurn") or item.get("emotional_turn") or ""),
|
||||
emotionStepKey=str(item.get("emotionStepKey") or item.get("emotion_step_key") or event.emotionStepKey),
|
||||
emotionGoal=str(item.get("emotionGoal") or item.get("emotion_goal") or event.emotionStepText),
|
||||
payoff=str(item.get("payoff") or event.expectedPayoff),
|
||||
endingHook=str(item.get("endingHook") or item.get("ending_hook") or ""),
|
||||
forbidden=str(item.get("forbidden") or ""),
|
||||
targetWords=2000,
|
||||
status=str(item.get("status") or "planned"),
|
||||
)
|
||||
)
|
||||
items.sort(key=lambda c: c.seq)
|
||||
if not items:
|
||||
raise ValueError("章纲为空")
|
||||
return items
|
||||
|
||||
|
||||
async def ensure_volume(
|
||||
book_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
) -> FictionBookMetadata:
|
||||
metadata = fiction_metadata_service.get_metadata(book_id)
|
||||
if metadata.volumes:
|
||||
return metadata
|
||||
|
||||
resolved = resolve_api_config(profile_id, api_config)
|
||||
_validate_api_config(resolved)
|
||||
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="running", pipeline_stage="volume"
|
||||
)
|
||||
try:
|
||||
messages = _build_volume_messages(book_id, metadata)
|
||||
response = await _llm_client.chat_completion(
|
||||
messages=messages,
|
||||
api_url=resolved["api_url"],
|
||||
api_key=resolved["api_key"],
|
||||
model=resolved["model"],
|
||||
temperature=0.7,
|
||||
max_tokens=8000,
|
||||
request_timeout=120,
|
||||
stream=False,
|
||||
)
|
||||
data = _extract_json(response["choices"][0]["message"]["content"])
|
||||
volume = _normalize_volume(
|
||||
data.get("volume") if isinstance(data.get("volume"), dict) else data,
|
||||
_next_volume_id(metadata),
|
||||
len(metadata.volumes) + 1,
|
||||
)
|
||||
if not volume.primaryEmotionFlowId:
|
||||
volume.primaryEmotionFlowId = _choose_flow_id(book_id)
|
||||
metadata.volumes.append(volume)
|
||||
saved = fiction_metadata_service.save_metadata(book_id, metadata)
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="volume_done"
|
||||
)
|
||||
return saved
|
||||
except Exception:
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="error", pipeline_stage="volume"
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def ensure_event_chain(
|
||||
book_id: str,
|
||||
*,
|
||||
volume_id: Optional[str] = None,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
) -> FictionBookMetadata:
|
||||
metadata = await ensure_volume(book_id, profile_id=profile_id, api_config=api_config)
|
||||
volume = next((v for v in metadata.volumes if v.id == volume_id), metadata.volumes[-1])
|
||||
if metadata.eventChains.get(volume.id):
|
||||
return metadata
|
||||
|
||||
resolved = resolve_api_config(profile_id, api_config)
|
||||
_validate_api_config(resolved)
|
||||
|
||||
flow_id = volume.primaryEmotionFlowId or _choose_flow_id(book_id)
|
||||
volume.primaryEmotionFlowId = flow_id
|
||||
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="running", pipeline_stage="event_chain"
|
||||
)
|
||||
try:
|
||||
messages = _build_event_chain_messages(book_id, volume, flow_id)
|
||||
response = await _llm_client.chat_completion(
|
||||
messages=messages,
|
||||
api_url=resolved["api_url"],
|
||||
api_key=resolved["api_key"],
|
||||
model=resolved["model"],
|
||||
temperature=0.7,
|
||||
max_tokens=8000,
|
||||
request_timeout=120,
|
||||
stream=False,
|
||||
)
|
||||
data = _extract_json(response["choices"][0]["message"]["content"])
|
||||
raw_events = data.get("events") or data.get("eventChain") or data.get("event_chain")
|
||||
events = _normalize_event_chain(raw_events, metadata, volume, flow_id)
|
||||
metadata.eventChains[volume.id] = events
|
||||
saved = fiction_metadata_service.save_metadata(book_id, metadata)
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="event_chain_done"
|
||||
)
|
||||
return saved
|
||||
except Exception:
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="error", pipeline_stage="event_chain"
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def ensure_chapter_plan(
|
||||
book_id: str,
|
||||
*,
|
||||
event_id: Optional[str] = None,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
) -> FictionBookMetadata:
|
||||
metadata = await ensure_event_chain(book_id, profile_id=profile_id, api_config=api_config)
|
||||
|
||||
target_event: Optional[EventChainItem] = None
|
||||
target_volume: Optional[VolumeOutline] = None
|
||||
for volume in metadata.volumes:
|
||||
for event in metadata.eventChains.get(volume.id, []):
|
||||
if event_id and event.id != event_id:
|
||||
continue
|
||||
if metadata.chapterPlans.get(event.id):
|
||||
if event_id:
|
||||
return metadata
|
||||
continue
|
||||
target_event = event
|
||||
target_volume = volume
|
||||
break
|
||||
if target_event:
|
||||
break
|
||||
|
||||
if not target_event or not target_volume:
|
||||
return metadata
|
||||
|
||||
resolved = resolve_api_config(profile_id, api_config)
|
||||
_validate_api_config(resolved)
|
||||
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="running", pipeline_stage="chapter_plan"
|
||||
)
|
||||
try:
|
||||
messages = _build_chapter_plan_messages(book_id, target_volume, target_event)
|
||||
response = await _llm_client.chat_completion(
|
||||
messages=messages,
|
||||
api_url=resolved["api_url"],
|
||||
api_key=resolved["api_key"],
|
||||
model=resolved["model"],
|
||||
temperature=0.7,
|
||||
max_tokens=8000,
|
||||
request_timeout=120,
|
||||
stream=False,
|
||||
)
|
||||
data = _extract_json(response["choices"][0]["message"]["content"])
|
||||
raw_items = data.get("chapterPlan") or data.get("chapters") or data.get("chapter_plan")
|
||||
plans = _normalize_chapter_plans(raw_items, metadata, target_event)
|
||||
metadata.chapterPlans[target_event.id] = plans
|
||||
saved = fiction_metadata_service.save_metadata(book_id, metadata)
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="chapter_plan_done"
|
||||
)
|
||||
return saved
|
||||
except Exception:
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="error", pipeline_stage="chapter_plan"
|
||||
)
|
||||
raise
|
||||
125
backend/services/fiction_prompt_utils.py
Normal file
125
backend/services/fiction_prompt_utils.py
Normal file
@@ -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
|
||||
302
backend/services/fiction_service.py
Normal file
302
backend/services/fiction_service.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
爽文书籍 CRUD 与全局资源读取。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.config import settings
|
||||
from models.fiction_models import (
|
||||
CreateFictionBookRequest,
|
||||
EmotionFlowCatalog,
|
||||
FictionBookMeta,
|
||||
FictionBookSettings,
|
||||
FictionBookSummary,
|
||||
FictionChapter,
|
||||
FictionChapterSummary,
|
||||
FictionGuideWorldbook,
|
||||
FictionPipelineSettings,
|
||||
FictionPrompts,
|
||||
FictionReaderSettings,
|
||||
GuideGlobalEntries,
|
||||
UpdateFictionBookSettingsRequest,
|
||||
)
|
||||
from services.fiction_prompt_utils import USER_DEFAULT_PROMPTS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_PROMPTS = FictionPrompts(
|
||||
openBook=USER_DEFAULT_PROMPTS["openBook"],
|
||||
coarseOutline=USER_DEFAULT_PROMPTS["coarseOutline"],
|
||||
eventPlan=USER_DEFAULT_PROMPTS["eventPlan"],
|
||||
chapter=USER_DEFAULT_PROMPTS["chapter"],
|
||||
nudge=USER_DEFAULT_PROMPTS["nudge"],
|
||||
)
|
||||
|
||||
DEFAULT_READER = FictionReaderSettings()
|
||||
DEFAULT_PIPELINE = FictionPipelineSettings()
|
||||
|
||||
DEFAULT_METADATA: Dict[str, Any] = {
|
||||
"version": 2,
|
||||
"volumes": [],
|
||||
"eventChains": {},
|
||||
"chapterPlans": {},
|
||||
"progress": {
|
||||
"currentChapterSeq": 0,
|
||||
"charOffset": 0,
|
||||
"ttsPaused": False,
|
||||
"genPaused": False,
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_RUN: Dict[str, Any] = {
|
||||
"status": "idle",
|
||||
"pipelineStage": None,
|
||||
"stage": "idle",
|
||||
"message": None,
|
||||
"updatedAt": "",
|
||||
}
|
||||
|
||||
|
||||
def _read_json(path: Path) -> Any:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _write_json(path: Path, data: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
text = (text or "").strip()
|
||||
text = re.sub(r"[^\w\u4e00-\u9fff\-]+", "-", text, flags=re.UNICODE)
|
||||
text = re.sub(r"-+", "-", text).strip("-")
|
||||
return text[:48] or "book"
|
||||
|
||||
|
||||
class FictionService:
|
||||
@property
|
||||
def books_root(self) -> Path:
|
||||
return settings.FICTION_BOOKS_PATH
|
||||
|
||||
def _book_dir(self, book_id: str) -> Path:
|
||||
return self.books_root / book_id
|
||||
|
||||
def _meta_path(self, book_id: str) -> Path:
|
||||
return self._book_dir(book_id) / "meta.json"
|
||||
|
||||
def _settings_path(self, book_id: str) -> Path:
|
||||
return self._book_dir(book_id) / "settings.json"
|
||||
|
||||
def _guide_path(self, book_id: str) -> Path:
|
||||
return self._book_dir(book_id) / "guide.worldbook.json"
|
||||
|
||||
def _metadata_path(self, book_id: str) -> Path:
|
||||
return self._book_dir(book_id) / "metadata.json"
|
||||
|
||||
def _run_path(self, book_id: str) -> Path:
|
||||
return self._book_dir(book_id) / "run.json"
|
||||
|
||||
def _chapters_dir(self, book_id: str) -> Path:
|
||||
return self._book_dir(book_id) / "chapters"
|
||||
|
||||
def _chapter_path(self, book_id: str, seq: int) -> Path:
|
||||
return self._chapters_dir(book_id) / f"{seq:04d}.json"
|
||||
|
||||
def chapter_exists(self, book_id: str, seq: int) -> bool:
|
||||
return self._chapter_path(book_id, seq).exists()
|
||||
|
||||
def get_chapter(self, book_id: str, seq: int) -> FictionChapter:
|
||||
path = self._chapter_path(book_id, seq)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Chapter not found: {book_id}/{seq}")
|
||||
return FictionChapter(**_read_json(path))
|
||||
|
||||
def save_chapter(self, book_id: str, chapter: FictionChapter) -> FictionChapter:
|
||||
self._ensure_book(book_id)
|
||||
_write_json(self._chapter_path(book_id, chapter.seq), chapter.model_dump())
|
||||
self._touch_book_meta(book_id)
|
||||
return chapter
|
||||
|
||||
def list_written_chapter_seqs(self, book_id: str) -> List[int]:
|
||||
chapters_dir = self._chapters_dir(book_id)
|
||||
if not chapters_dir.exists():
|
||||
return []
|
||||
seqs: List[int] = []
|
||||
for path in chapters_dir.glob("*.json"):
|
||||
try:
|
||||
seqs.append(int(path.stem))
|
||||
except ValueError:
|
||||
continue
|
||||
return sorted(seqs)
|
||||
|
||||
def list_chapter_summaries(self, book_id: str) -> List[FictionChapterSummary]:
|
||||
summaries: List[FictionChapterSummary] = []
|
||||
for seq in self.list_written_chapter_seqs(book_id):
|
||||
ch = self.get_chapter(book_id, seq)
|
||||
summaries.append(
|
||||
FictionChapterSummary(
|
||||
seq=ch.seq,
|
||||
title=ch.title,
|
||||
charCount=ch.charCount,
|
||||
eventId=ch.eventId,
|
||||
phaseKey=ch.phaseKey,
|
||||
)
|
||||
)
|
||||
return summaries
|
||||
|
||||
def _ensure_book(self, book_id: str) -> None:
|
||||
if not self._meta_path(book_id).exists():
|
||||
raise FileNotFoundError(f"Book not found: {book_id}")
|
||||
|
||||
def _touch_book_meta(self, book_id: str) -> None:
|
||||
meta_path = self._meta_path(book_id)
|
||||
meta = _read_json(meta_path)
|
||||
meta["updatedAt"] = datetime.now().isoformat()
|
||||
_write_json(meta_path, meta)
|
||||
|
||||
def get_default_settings(self) -> FictionBookSettings:
|
||||
return FictionBookSettings(
|
||||
prompts=DEFAULT_PROMPTS,
|
||||
reader=DEFAULT_READER,
|
||||
pipeline=DEFAULT_PIPELINE,
|
||||
)
|
||||
|
||||
def get_emotion_catalog(self) -> EmotionFlowCatalog:
|
||||
path = settings.FICTION_EMOTION_CATALOG_FILE
|
||||
if not path.exists():
|
||||
return EmotionFlowCatalog(flows=[])
|
||||
return EmotionFlowCatalog(**_read_json(path))
|
||||
|
||||
def get_guide_global_entries(self) -> GuideGlobalEntries:
|
||||
path = settings.FICTION_GUIDE_GLOBAL_ENTRIES_FILE
|
||||
if not path.exists():
|
||||
return GuideGlobalEntries(entries=[])
|
||||
return GuideGlobalEntries(**_read_json(path))
|
||||
|
||||
def list_books(self) -> List[FictionBookSummary]:
|
||||
root = self.books_root
|
||||
if not root.exists():
|
||||
return []
|
||||
summaries: List[FictionBookSummary] = []
|
||||
for child in sorted(root.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
meta_path = child / "meta.json"
|
||||
if not meta_path.exists():
|
||||
continue
|
||||
meta = _read_json(meta_path)
|
||||
summaries.append(
|
||||
FictionBookSummary(
|
||||
id=meta.get("id", child.name),
|
||||
title=meta.get("title", child.name),
|
||||
allowedFlowIds=meta.get("allowedFlowIds", []),
|
||||
updatedAt=meta.get("updatedAt", ""),
|
||||
)
|
||||
)
|
||||
summaries.sort(key=lambda x: x.updatedAt or "", reverse=True)
|
||||
return summaries
|
||||
|
||||
def get_book_meta(self, book_id: str) -> FictionBookMeta:
|
||||
meta_path = self._meta_path(book_id)
|
||||
if not meta_path.exists():
|
||||
raise FileNotFoundError(f"Book not found: {book_id}")
|
||||
return FictionBookMeta(**_read_json(meta_path))
|
||||
|
||||
def get_book_settings(self, book_id: str) -> FictionBookSettings:
|
||||
settings_path = self._settings_path(book_id)
|
||||
if not settings_path.exists():
|
||||
raise FileNotFoundError(f"Book not found: {book_id}")
|
||||
return FictionBookSettings(**_read_json(settings_path))
|
||||
|
||||
def update_book_settings(
|
||||
self, book_id: str, req: UpdateFictionBookSettingsRequest
|
||||
) -> FictionBookSettings:
|
||||
meta_path = self._meta_path(book_id)
|
||||
settings_path = self._settings_path(book_id)
|
||||
if not meta_path.exists():
|
||||
raise FileNotFoundError(f"Book not found: {book_id}")
|
||||
current = FictionBookSettings(**_read_json(settings_path))
|
||||
data = current.model_dump()
|
||||
if req.prompts is not None:
|
||||
data["prompts"] = req.prompts.model_dump()
|
||||
if req.reader is not None:
|
||||
data["reader"] = req.reader.model_dump()
|
||||
if req.pipeline is not None:
|
||||
data["pipeline"] = req.pipeline.model_dump()
|
||||
_write_json(settings_path, data)
|
||||
meta = _read_json(meta_path)
|
||||
meta["updatedAt"] = datetime.now().isoformat()
|
||||
_write_json(meta_path, meta)
|
||||
return FictionBookSettings(**data)
|
||||
|
||||
def get_book_guide(self, book_id: str) -> FictionGuideWorldbook:
|
||||
guide_path = self._guide_path(book_id)
|
||||
if not guide_path.exists():
|
||||
raise FileNotFoundError(f"Book not found: {book_id}")
|
||||
return FictionGuideWorldbook(**_read_json(guide_path))
|
||||
|
||||
def _unique_book_id(self, base_id: str) -> str:
|
||||
candidate = base_id
|
||||
n = 1
|
||||
while self._book_dir(candidate).exists():
|
||||
candidate = f"{base_id}-{n}"
|
||||
n += 1
|
||||
return candidate
|
||||
|
||||
def create_book(self, req: CreateFictionBookRequest) -> FictionBookMeta:
|
||||
title = (req.title or "").strip()
|
||||
if not title:
|
||||
raise ValueError("书名不能为空")
|
||||
|
||||
base_id = _slugify(title)
|
||||
if not base_id or base_id == "book":
|
||||
base_id = str(uuid.uuid4())[:8]
|
||||
book_id = self._unique_book_id(base_id)
|
||||
|
||||
dest = self._book_dir(book_id)
|
||||
dest.mkdir(parents=True, exist_ok=False)
|
||||
self._chapters_dir(book_id).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
meta = {
|
||||
"id": book_id,
|
||||
"title": title,
|
||||
"allowedFlowIds": list(req.allowedFlowIds or []),
|
||||
"createdAt": now,
|
||||
"updatedAt": now,
|
||||
}
|
||||
_write_json(self._meta_path(book_id), meta)
|
||||
|
||||
default_settings = self.get_default_settings()
|
||||
_write_json(self._settings_path(book_id), default_settings.model_dump())
|
||||
|
||||
guide = req.guide.model_dump() if req.guide else FictionGuideWorldbook().model_dump()
|
||||
_write_json(self._guide_path(book_id), guide)
|
||||
|
||||
metadata = dict(DEFAULT_METADATA)
|
||||
_write_json(self._metadata_path(book_id), metadata)
|
||||
|
||||
run_data = dict(DEFAULT_RUN)
|
||||
run_data["updatedAt"] = now
|
||||
_write_json(self._run_path(book_id), run_data)
|
||||
|
||||
return FictionBookMeta(**meta)
|
||||
|
||||
def delete_book(self, book_id: str) -> None:
|
||||
book_dir = self._book_dir(book_id)
|
||||
if not book_dir.exists():
|
||||
raise FileNotFoundError(f"Book not found: {book_id}")
|
||||
shutil.rmtree(book_dir)
|
||||
|
||||
|
||||
fiction_service = FictionService()
|
||||
185
backend/services/tools/fiction_tools.py
Normal file
185
backend/services/tools/fiction_tools.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
爽文工作流 Tool 注册。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
try:
|
||||
from backend.models.agent import TurnContext
|
||||
from backend.services.fiction_chapter_service import run_chapter
|
||||
from backend.services.fiction_coarse_service import run_coarse_outline
|
||||
from backend.services.fiction_event_plan_service import run_event_plan
|
||||
from backend.services.fiction_open_book_service import run_open_book
|
||||
from backend.services.tool_registry import ToolRegistry
|
||||
except ImportError:
|
||||
from models.agent import TurnContext
|
||||
from services.fiction_chapter_service import run_chapter
|
||||
from services.fiction_coarse_service import run_coarse_outline
|
||||
from services.fiction_event_plan_service import run_event_plan
|
||||
from services.fiction_open_book_service import run_open_book
|
||||
from services.tool_registry import ToolRegistry
|
||||
|
||||
|
||||
async def fiction_open_book(ctx: TurnContext) -> None:
|
||||
"""fiction.open_book — 根据用户灵感优化开书方案(不创建书籍目录)。"""
|
||||
request = ctx.request_data or {}
|
||||
inspiration = str(request.get("inspiration") or request.get("intro") or "").strip()
|
||||
profile_id = request.get("profile_id") or request.get("profileId")
|
||||
api_config = request.get("api_config") or request.get("apiConfig")
|
||||
|
||||
result = await run_open_book(
|
||||
inspiration,
|
||||
profile_id=profile_id,
|
||||
api_config=api_config,
|
||||
)
|
||||
payload = result.model_dump()
|
||||
ctx.request_data["fictionOpenBookResult"] = payload
|
||||
ctx.generated_content = json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
async def fiction_coarse(ctx: TurnContext) -> None:
|
||||
"""fiction.coarse — 生成本书粗纲事件链,写入 metadata.json。"""
|
||||
request = ctx.request_data or {}
|
||||
book_id = str(request.get("book_id") or request.get("bookId") or "").strip()
|
||||
if not book_id:
|
||||
raise ValueError("book_id 不能为空")
|
||||
|
||||
profile_id = request.get("profile_id") or request.get("profileId")
|
||||
api_config = request.get("api_config") or request.get("apiConfig")
|
||||
|
||||
result = await run_coarse_outline(
|
||||
book_id,
|
||||
profile_id=profile_id,
|
||||
api_config=api_config,
|
||||
)
|
||||
payload = result.model_dump()
|
||||
ctx.request_data["fictionCoarseResult"] = payload
|
||||
ctx.generated_content = json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
async def fiction_event_plan(ctx: TurnContext) -> None:
|
||||
"""fiction.event_plan — 为粗纲事件生成 flowStepsPlan + chapterPlan。"""
|
||||
request = ctx.request_data or {}
|
||||
book_id = str(request.get("book_id") or request.get("bookId") or "").strip()
|
||||
if not book_id:
|
||||
raise ValueError("book_id 不能为空")
|
||||
|
||||
profile_id = request.get("profile_id") or request.get("profileId")
|
||||
api_config = request.get("api_config") or request.get("apiConfig")
|
||||
event_id = request.get("event_id") or request.get("eventId")
|
||||
|
||||
result = await run_event_plan(
|
||||
book_id,
|
||||
profile_id=profile_id,
|
||||
api_config=api_config,
|
||||
event_id=event_id,
|
||||
)
|
||||
payload = result.model_dump()
|
||||
ctx.request_data["fictionEventPlanResult"] = payload
|
||||
ctx.generated_content = json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
async def fiction_chapter(ctx: TurnContext) -> None:
|
||||
"""fiction.chapter — 根据 chapterPlan brief 撰写正文,写入 chapters/{seq}.json。"""
|
||||
request = ctx.request_data or {}
|
||||
book_id = str(request.get("book_id") or request.get("bookId") or "").strip()
|
||||
if not book_id:
|
||||
raise ValueError("book_id 不能为空")
|
||||
|
||||
profile_id = request.get("profile_id") or request.get("profileId")
|
||||
api_config = request.get("api_config") or request.get("apiConfig")
|
||||
seq = request.get("seq") or request.get("chapterSeq")
|
||||
|
||||
result = await run_chapter(
|
||||
book_id,
|
||||
profile_id=profile_id,
|
||||
api_config=api_config,
|
||||
seq=int(seq) if seq is not None else None,
|
||||
)
|
||||
payload = result.model_dump()
|
||||
ctx.request_data["fictionChapterResult"] = payload
|
||||
ctx.generated_content = json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
def register_fiction_tools(registry: ToolRegistry) -> None:
|
||||
registry.register(
|
||||
"fiction.open_book",
|
||||
fiction_open_book,
|
||||
description="根据用户创作灵感优化爽文开书方案,返回 guide 草稿与推荐情绪流",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"inspiration": {"type": "string", "description": "用户创作灵感/简介"},
|
||||
"profile_id": {"type": "string", "description": "API 配置 profile ID"},
|
||||
"api_config": {
|
||||
"type": "object",
|
||||
"description": "可选 inline API 配置",
|
||||
},
|
||||
},
|
||||
"required": ["inspiration"],
|
||||
},
|
||||
)
|
||||
registry.register(
|
||||
"fiction.coarse",
|
||||
fiction_coarse,
|
||||
description="根据本书 guide 与 L1 全局指南生成粗纲,写入 metadata.coarseOutline",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"book_id": {"type": "string", "description": "书籍 ID"},
|
||||
"profile_id": {"type": "string", "description": "API 配置 profile ID"},
|
||||
"api_config": {"type": "object", "description": "可选 inline API 配置"},
|
||||
},
|
||||
"required": ["book_id"],
|
||||
},
|
||||
)
|
||||
registry.register(
|
||||
"fiction.event_plan",
|
||||
fiction_event_plan,
|
||||
description="为粗纲事件随机选情绪流并生成 flowStepsPlan + chapterPlan",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"book_id": {"type": "string", "description": "书籍 ID"},
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"description": "可选,仅规划指定粗纲事件;缺省则规划全部",
|
||||
},
|
||||
"profile_id": {"type": "string", "description": "API 配置 profile ID"},
|
||||
"api_config": {"type": "object", "description": "可选 inline API 配置"},
|
||||
},
|
||||
"required": ["book_id"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
registry.register(
|
||||
"fiction.chapter",
|
||||
fiction_chapter,
|
||||
description="根据 chapterPlan brief 撰写章节正文,写入 chapters 目录",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"book_id": {"type": "string", "description": "书籍 ID"},
|
||||
"seq": {
|
||||
"type": "integer",
|
||||
"description": "可选,指定章节序号;缺省则写下一未写章",
|
||||
},
|
||||
"profile_id": {"type": "string", "description": "API 配置 profile ID"},
|
||||
"api_config": {"type": "object", "description": "可选 inline API 配置"},
|
||||
},
|
||||
"required": ["book_id"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# 模块加载时注册到默认 registry
|
||||
try:
|
||||
from services.tool_registry import default_tool_registry
|
||||
|
||||
register_fiction_tools(default_tool_registry)
|
||||
except Exception:
|
||||
pass
|
||||
6
data/agent/fiction/books/我是汉使-谁敢不敬/guide.worldbook.json
Normal file
6
data/agent/fiction/books/我是汉使-谁敢不敬/guide.worldbook.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"persona": "主角表面温文尔雅、恪守使者礼节,实则杀伐果断、胸怀大格局。受辱时隐忍记账,关键节点一击致命。拥有超越时代的信息差与历史推演金手指,善于借势与布局,从单枪匹马到建立西域都护府权威。",
|
||||
"highlight": "1. 身份反差:所有人都当他是落魄流民,直到汉节与国书亮相,震惊全场。2. 文明降维打击:用冶铁、造纸、兵法碾压西域各方势力。3. 外交爽文:舌战群胡、以一人退一国之兵。4. 势力养成:收服三十六国,在异域复刻大汉盛世。",
|
||||
"experience": "第三人称有限视角跟随主角,初期通过旁观者的鄙夷积蓄压抑,中后期在亮身份、展实力时拉远镜头,放大旁观者的跪服与匈奴使者的恐惧,形成反复打脸爽感。每场外交冲突都按铺垫→加压→以汉威逆转的结构推进。",
|
||||
"forbiddenZones": "禁止主角长期忍气吞声无所作为、禁止汉使身份被长期误解不开封、禁止面对胡人欺辱时以德报怨;挫折控制在1章内,且必须立刻给出明确反击预期。"
|
||||
}
|
||||
11
data/agent/fiction/books/我是汉使-谁敢不敬/meta.json
Normal file
11
data/agent/fiction/books/我是汉使-谁敢不敬/meta.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "我是汉使-谁敢不敬",
|
||||
"title": "我是汉使,谁敢不敬",
|
||||
"allowedFlowIds": [
|
||||
"power-reveal",
|
||||
"face-slap-rise",
|
||||
"alliance-dominate"
|
||||
],
|
||||
"createdAt": "2026-06-01T10:36:45.755451",
|
||||
"updatedAt": "2026-06-01T10:56:58.330409"
|
||||
}
|
||||
120
data/agent/fiction/books/我是汉使-谁敢不敬/metadata.json
Normal file
120
data/agent/fiction/books/我是汉使-谁敢不敬/metadata.json
Normal file
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"coarseOutline": {
|
||||
"events": [
|
||||
{
|
||||
"id": "evt-1",
|
||||
"title": "流落楼兰,身份的蛰伏",
|
||||
"summary": "主角衣衫褴褛抵达楼兰城外,被守城胡兵当成逃难流民肆意驱赶羞辱。主角冷眼观察局势,暗中记录下所有侮辱,等待时机。入城后被安置在最低贱的商贾聚集区。",
|
||||
"order": 1
|
||||
},
|
||||
{
|
||||
"id": "evt-2",
|
||||
"title": "匈奴使团嚣张登场,压抑升级",
|
||||
"summary": "匈奴特使率领百骑使团入城,楼兰王卑躬屈膝迎接。匈奴使者在市集当众嘲笑汉人弱如羔羊,主角被推搡却不动声色,只展露些许冶铁知识换取铁匠铺收留,埋下反击伏笔。",
|
||||
"order": 2
|
||||
},
|
||||
{
|
||||
"id": "evt-3",
|
||||
"title": "纸刀初试,小胜立威",
|
||||
"summary": "匈奴人再次当众侮辱主角,逼其钻胯。主角以“教你们写汉字”为赌约,用刚造出的粗糙纸张和断笔,当众写下檄文,暗讽匈奴无文。贵族们对纸惊为神物,主角赢得楼兰大商人的庇护,匈奴使吃瘪离去。",
|
||||
"order": 3
|
||||
},
|
||||
{
|
||||
"id": "evt-4",
|
||||
"title": "王宫夜宴亮汉节,身份反转",
|
||||
"summary": "楼兰王宴请匈奴使,主角被大商贾带入宴席。匈奴使阁楼内强迫楼兰王交出“汉人奸细”处死,主角缓步出列,手捧封尘汉节,展开黄绫国书,朗声道:“大汉使臣张明,持节出使西域三十六国,谁敢不敬?”全场死寂,匈奴使脸色煞白。",
|
||||
"order": 4
|
||||
},
|
||||
{
|
||||
"id": "evt-5",
|
||||
"title": "斩杀匈奴使,楼兰臣服",
|
||||
"summary": "匈奴使强辩汉节是伪造,命令护卫拿下主角。主角喝破楼兰王昔日杀汉使将招致灭国之祸,并当场宣读大汉讨罪檄文。在楼兰王犹豫之际,主角以迅雷之势拔出随从暗藏环首刀,亲手斩杀匈奴正使,威震大殿。楼兰王跪伏,愿为汉属。",
|
||||
"order": 5
|
||||
},
|
||||
{
|
||||
"id": "evt-6",
|
||||
"title": "以寡敌众,冶铁骑兵显威",
|
||||
"summary": "匈奴副使逃出带领城外百骑反扑,主角早有准备,调遣楼兰城卫,并用改良的冶铁马蹄铁和简易马镫装备了二十人骑兵小队,正面冲垮毫无防备的匈奴骑兵。一战斩首七十,俘虏三十,彻底打垮楼兰境内匈奴势力。",
|
||||
"order": 6
|
||||
},
|
||||
{
|
||||
"id": "evt-7",
|
||||
"title": "楼兰盟约,都护雏形",
|
||||
"summary": "主角与楼兰王歃血为盟,设立汉使常驻衙门,推行简易汉律保护商路。同时放出流言:大汉西域都护府即将设立,归附者可得冶铁、造纸之术。周边小国开始遣使试探。",
|
||||
"order": 7
|
||||
},
|
||||
{
|
||||
"id": "evt-8",
|
||||
"title": "车师设局,舌战降服",
|
||||
"summary": "车师国受匈奴蛊惑扣押汉商队,主角仅带十骑赴会。在王庭之上,车师王列甲士恐吓,主角从容陈说匈奴败亡之势,并以楼兰之变震慑,允诺开放贸易和冶铁秘法。车师贵族分裂,最终斩杀亲匈奴大臣,迎汉使入驻。",
|
||||
"order": 8
|
||||
},
|
||||
{
|
||||
"id": "evt-9",
|
||||
"title": "三十六国初盟,匈奴单于震怒",
|
||||
"summary": "半年间,主角借商业和文明技术输出,接连与十余国结盟。一次盟会上,主角正式打出“大汉西域都护府”旗号,诸国共尊汉使为都护。消息传到漠北,匈奴单于怒极,下令三万铁骑西征,誓要血洗西域汉势力。",
|
||||
"order": 9
|
||||
},
|
||||
{
|
||||
"id": "evt-10",
|
||||
"title": "大漠烽烟,以少胜多",
|
||||
"summary": "主角利用信息差,提前获知匈奴行军路线,以兵法“围点打援”诱敌深入。集结诸国联军八千,在干涸河床设伏,用改良的硬弩和连环马阵正面击溃匈奴前锋,再以火攻断其辎重。匈奴单于亲率的中军溃败,折损过半,仓皇东逃。",
|
||||
"order": 10
|
||||
},
|
||||
{
|
||||
"id": "evt-11",
|
||||
"title": "丝路重开,万邦来朝",
|
||||
"summary": "主角将缴获的匈奴单于金箭与捷报一同传往长安。汉武帝大喜,正式册封主角为西域都护,授权统管西域。丝绸之路全线贯通,大汉商队、工匠、文人涌入西域,诸国争相效仿汉制。主角立于都护府高台,俯瞰一片繁华,当年那些羞辱过他的人早已化为尘埃。",
|
||||
"order": 11
|
||||
}
|
||||
],
|
||||
"version": 1
|
||||
},
|
||||
"events": {
|
||||
"evt-1": {
|
||||
"emotionFlowId": "alliance-dominate",
|
||||
"flowStepsPlan": {
|
||||
"起": "主角孤身抵达楼兰城外,衣着破烂,人脉与资源为零。被守城胡兵当作流民百般羞辱驱赶,主角隐忍观察,暗中记录所有折辱者面孔与背景,最终被丢到最低贱的商贾聚集区。此时主角处于绝对弱势,积蓄着对胡人傲慢的认识与反击的渴望。",
|
||||
"承": "在商贾区,主角利用超越时代的知识小露锋芒,比如用古法提纯井盐、鉴别劣质铁器。这些举动引起了几类人的注意:落魄的本地护卫、被排挤的西域小商贩,以及楼兰城中一名失势贵族管家。主角刻意展示出自己‘虽落魄却有秘术’的价值,为后续结盟埋下伏笔。",
|
||||
"转": "楼兰城中某个小权贵欲吞占商贾区,设局陷害聚集区商户。主角在暗中洞悉阴谋,指使刚结交的护卫提前揭露,并在众人面前以智谋反制,让权贵当众出丑。此事令那失势贵族管家背后的小主子意识到主角不凡,主动邀约,主角以‘各取所需’的姿态赢得其暂时效忠,完成第一次关键人收服。",
|
||||
"合": "主角以这失势贵族为跳板,在商贾区建立初步的情报网络,收服了第一批追随者——护卫担任贴身力量,小商贩负责打探消息,失势贵族提供身份掩护。虽然依旧隐藏汉使身份,但一个以他为核心的微型势力雏形已在楼兰底层成型,为后续朝堂亮相、收服三十六国埋下暗线。"
|
||||
},
|
||||
"chapterPlan": [
|
||||
{
|
||||
"seq": 1,
|
||||
"phaseKey": "起",
|
||||
"phaseSlice": "全",
|
||||
"brief": "主角衣衫褴褛到楼兰城下,被胡兵当难民拖拽驱赶、吐口水羞辱,他一言不发冷眼观察。城门口,一名胡商因货物被刁难,主角用简单西域话帮其解围,展露冷静思维。最终被丢入最混乱的商贾区,在破棚里用手刻下第一个仇人名字。爽点:冷静隐忍与记账的伏笔,通过旁人对‘这个乞丐居然会西域话’的诧异铺垫反差。",
|
||||
"status": "planned"
|
||||
},
|
||||
{
|
||||
"seq": 2,
|
||||
"phaseKey": "承",
|
||||
"phaseSlice": "全",
|
||||
"brief": "主角以替人写书信、鉴别货物真伪在商贾区站稳脚跟,用提纯粗盐的方法让一名小贩获利三倍,引发小轰动。落魄护卫昆图目睹后主动试探,主角故意露一手卸骨擒拿术震慑对方。同时引起失势贵族之管家注意。爽点:文明降维打击——人人当他是流民,他却随手点石成金,周围人从轻蔑转为巴结。",
|
||||
"status": "planned"
|
||||
},
|
||||
{
|
||||
"seq": 3,
|
||||
"phaseKey": "转",
|
||||
"phaseSlice": "全",
|
||||
"brief": "小权贵古尔贡欲用‘盗窃军马’罪名强占商贾区,抓走两名商贩。主角让昆图护住证人,自己通过管家带话给失势贵族,以三句话点破古尔贡布局的漏洞。在古尔贡带兵来封市时,主角当众拆穿伪造证据,并反手指控其勾结马贼,围观胡人从嘲弄变为惊惧。古尔贡被当街打脸,失势贵族公开表态庇护商圈。爽点:以一介贱民之身,翻手间让权贵灰头土脸,旁观者倒戈,首次展示翻云覆雨的智谋。",
|
||||
"status": "planned"
|
||||
},
|
||||
{
|
||||
"seq": 4,
|
||||
"phaseKey": "合",
|
||||
"phaseSlice": "全",
|
||||
"brief": "事后失势贵族幼主密邀主角,试探其来历。主角只展露汉节一角却不宣明身份,提出互利之约:贵族借主角之智复起,主角借贵族之便铺开暗棋。昆图宣誓效忠,小贩们主动成为眼线。主角在商贾区租下一处院落,挂上破损卦幡,以此为据点开始收集三十六国情报。爽点:收服班底、势力雏形确立,身份依旧成谜但威势已成,为下一阶段亮明汉使身份蓄满张力。",
|
||||
"status": "planned"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"progress": {
|
||||
"currentChapterSeq": 0,
|
||||
"charOffset": 0,
|
||||
"ttsPaused": false,
|
||||
"genPaused": false
|
||||
}
|
||||
}
|
||||
11
data/agent/fiction/books/我是汉使-谁敢不敬/run.json
Normal file
11
data/agent/fiction/books/我是汉使-谁敢不敬/run.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"status": "error",
|
||||
"pipelineStage": "event_plan",
|
||||
"stage": "error",
|
||||
"message": "事件纲要生成失败",
|
||||
"progress": {
|
||||
"done": 0,
|
||||
"total": 11
|
||||
},
|
||||
"updatedAt": "2026-06-01T11:56:27.977792"
|
||||
}
|
||||
13
data/agent/fiction/books/我是汉使-谁敢不敬/settings.json
Normal file
13
data/agent/fiction/books/我是汉使-谁敢不敬/settings.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
59
data/agent/fiction/emotion_flows/catalog.json
Normal file
59
data/agent/fiction/emotion_flows/catalog.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"flows": [
|
||||
{
|
||||
"id": "face-slap-rise",
|
||||
"intro": "经典打脸逆袭:先抑后扬,让读者在主角翻盘点获得强烈爽感。",
|
||||
"tags": ["打脸", "逆袭", "装逼"],
|
||||
"steps": [
|
||||
{ "key": "起", "text": "主角被轻视、被嘲讽,处境处于低谷,埋下伏笔。" },
|
||||
{ "key": "承", "text": "矛盾升级,对手步步紧逼,读者情绪被压抑到极点。" },
|
||||
{ "key": "转", "text": "主角展露真实实力或底牌,局势开始逆转。" },
|
||||
{ "key": "合", "text": "当众打脸,对手颜面尽失,主角收获声望与资源。" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "treasure-upgrade",
|
||||
"intro": "奇遇升级流:获得宝物/传承后实力跃迁,节奏明快。",
|
||||
"tags": ["奇遇", "升级", "宝物"],
|
||||
"steps": [
|
||||
{ "key": "起", "text": "主角陷入险境或瓶颈,看似无路可走。" },
|
||||
{ "key": "承", "text": "意外触发隐藏机缘,获得线索或残缺传承。" },
|
||||
{ "key": "转", "text": "完成试炼或解开封印,实力/境界突破。" },
|
||||
{ "key": "合", "text": "以新力量解决眼前危机,并留下更大悬念。" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "power-reveal",
|
||||
"intro": "扮猪吃虎:隐藏身份或实力,在关键时刻一鸣惊人。",
|
||||
"tags": ["扮猪吃虎", "身份", "震惊"],
|
||||
"steps": [
|
||||
{ "key": "起", "text": "主角以弱者/凡人形象出现,被各方忽视。" },
|
||||
{ "key": "承", "text": "敌人或路人持续挑衅,形成对比张力。" },
|
||||
{ "key": "转", "text": "危机时刻主角不再隐藏,展露真实层次。" },
|
||||
{ "key": "合", "text": "全场震惊,先前嘲讽者态度一百八十度转变。" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "alliance-dominate",
|
||||
"intro": "势力扩张:收服强者、建立势力,格局由小变大。",
|
||||
"tags": ["势力", "收服", "格局"],
|
||||
"steps": [
|
||||
{ "key": "起", "text": "主角孤身或小团队,资源与人脉有限。" },
|
||||
{ "key": "承", "text": "展现价值或魅力,引起潜在盟友/强者的注意。" },
|
||||
{ "key": "转", "text": "通过实力或智谋赢得关键人物认可。" },
|
||||
{ "key": "合", "text": "势力雏形确立,为下一阶段大事件铺垫。" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "revenge-climax",
|
||||
"intro": "复仇清算:旧怨新仇一并了结,情绪释放型高潮。",
|
||||
"tags": ["复仇", "清算", "高潮"],
|
||||
"steps": [
|
||||
{ "key": "起", "text": "回忆旧怨,明确复仇对象与动机。" },
|
||||
{ "key": "承", "text": "对手仍嚣张或以为主角不足为惧。" },
|
||||
{ "key": "转", "text": "主角布局收网,切断对手退路。" },
|
||||
{ "key": "合", "text": "当众清算,恩怨了结,读者情绪得到释放。" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
44
data/agent/fiction/guide_global/entries.json
Normal file
44
data/agent/fiction/guide_global/entries.json
Normal file
@@ -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 章内并给出明确反击预期。"
|
||||
}
|
||||
]
|
||||
}
|
||||
0
data/agent/runs/chat/帝国骑士维尔/默认聊天/events.jsonl
Normal file
0
data/agent/runs/chat/帝国骑士维尔/默认聊天/events.jsonl
Normal file
15
data/agent/runs/chat/帝国骑士维尔/默认聊天/run.json
Normal file
15
data/agent/runs/chat/帝国骑士维尔/默认聊天/run.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "c529576bd6de40969648cf80a3780db9",
|
||||
"template_id": "builtin.chat",
|
||||
"binding": {
|
||||
"role_name": "帝国骑士维尔",
|
||||
"chat_name": "默认聊天",
|
||||
"template_id": "builtin.chat"
|
||||
},
|
||||
"status": "failed",
|
||||
"started_at": "2026-05-30T18:20:55.872092",
|
||||
"finished_at": "2026-05-30T18:21:31.857209",
|
||||
"current_state": "regex_apply_ai_output",
|
||||
"result_content": "",
|
||||
"error": "'<' not supported between instances of 'int' and 'NoneType'"
|
||||
}
|
||||
10
data/agent/studio_projects/test-r1-72cd5b4b/meta.json
Normal file
10
data/agent/studio_projects/test-r1-72cd5b4b/meta.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "test-r1-72cd5b4b",
|
||||
"name": "test-r1-72cd5b4b",
|
||||
"description": "从创建绑定到世界书条目的默认三步流水线,可在工作流编辑页复制并修改。",
|
||||
"templateId": "builtin.studio.example",
|
||||
"characterId": "b0992d14-cb7c-4d81-b0af-7f7434710903",
|
||||
"worldbookId": "e26e5d15-5b87-4693-aa47-f3021fbdc3d7",
|
||||
"createdAt": "2026-05-31T14:58:54.635290",
|
||||
"updatedAt": "2026-05-31T14:58:54.656107"
|
||||
}
|
||||
118
data/agent/studio_projects/test-r1-72cd5b4b/pipeline.json
Normal file
118
data/agent/studio_projects/test-r1-72cd5b4b/pipeline.json
Normal file
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"workflowGoal": "设计一个单人角色:先绑定角色卡与世界书,再迭代整体美学与具体人设,写入世界书条目。",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "init",
|
||||
"skillId": "studio.init_bind",
|
||||
"displayName": "创建并绑定",
|
||||
"enabled": true,
|
||||
"config": {},
|
||||
"displayParams": [
|
||||
{
|
||||
"key": "characterName",
|
||||
"label": "角色卡名称",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"placeholder": ""
|
||||
},
|
||||
{
|
||||
"key": "worldbookName",
|
||||
"label": "世界书名称",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"placeholder": ""
|
||||
}
|
||||
],
|
||||
"inputs": []
|
||||
},
|
||||
{
|
||||
"id": "aesthetic",
|
||||
"skillId": "studio.worldbook_entry",
|
||||
"displayName": "整体美学",
|
||||
"niche": "aesthetic_tone",
|
||||
"enabled": true,
|
||||
"loopUntilSatisfied": true,
|
||||
"config": {
|
||||
"stepGoal": "产出角色的整体美学设定:视觉风格、氛围、叙事基调,供后续人设步骤引用。",
|
||||
"thinkingPrompt": "",
|
||||
"insertion": {
|
||||
"position": 1,
|
||||
"activationType": "permanent",
|
||||
"key": "整体美学",
|
||||
"comment": "Studio · 整体美学"
|
||||
},
|
||||
"scoring": {
|
||||
"enabled": true,
|
||||
"dimensions": [
|
||||
{
|
||||
"id": "authenticity",
|
||||
"name": "真实性",
|
||||
"criteria": "美学设定是否自洽、可感知,而非空泛形容词堆砌。"
|
||||
},
|
||||
{
|
||||
"id": "fit",
|
||||
"name": "贴合度",
|
||||
"criteria": "是否覆盖色调/材质/氛围/叙事基调;是否可与后续人设衔接;表述是否简洁可注入世界书。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"displayParams": [],
|
||||
"inputs": [
|
||||
{
|
||||
"ref": "workflow.goal",
|
||||
"label": "工作流目标文本"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "persona",
|
||||
"skillId": "studio.worldbook_entry",
|
||||
"displayName": "具体人设",
|
||||
"niche": "persona_detail",
|
||||
"enabled": true,
|
||||
"loopUntilSatisfied": true,
|
||||
"config": {
|
||||
"stepGoal": "在整体美学基础上,写出可扮演的人设:性格、动机、口癖、关系与行为模式。",
|
||||
"thinkingPrompt": "",
|
||||
"insertion": {
|
||||
"position": 1,
|
||||
"activationType": "keyword",
|
||||
"key": "具体人设",
|
||||
"comment": "Studio · 具体人设"
|
||||
},
|
||||
"scoring": {
|
||||
"enabled": true,
|
||||
"dimensions": [
|
||||
{
|
||||
"id": "authenticity",
|
||||
"name": "真实性",
|
||||
"criteria": "人设细节是否具体、可扮演,动机与行为是否一致。"
|
||||
},
|
||||
{
|
||||
"id": "fit",
|
||||
"name": "贴合度",
|
||||
"criteria": "是否与人设目标一致;是否与整体美学一致;是否避免与已有条目冲突。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"displayParams": [],
|
||||
"inputs": [
|
||||
{
|
||||
"ref": "workflow.goal",
|
||||
"label": "工作流目标文本"
|
||||
},
|
||||
{
|
||||
"ref": "aesthetic.output",
|
||||
"label": "整体美学 · 上轮产物"
|
||||
},
|
||||
{
|
||||
"ref": "persona.output",
|
||||
"label": "具体人设 · 上轮产物",
|
||||
"optional": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
10
data/agent/studio_projects/新项目/meta.json
Normal file
10
data/agent/studio_projects/新项目/meta.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "新项目",
|
||||
"name": "新项目",
|
||||
"description": "从创建绑定到世界书条目的默认三步流水线,可在工作流编辑页复制并修改。",
|
||||
"templateId": "builtin.studio.example",
|
||||
"characterId": null,
|
||||
"worldbookId": null,
|
||||
"createdAt": "2026-05-30T19:57:24.114210",
|
||||
"updatedAt": "2026-05-30T19:57:24.114210"
|
||||
}
|
||||
83
data/agent/studio_projects/新项目/pipeline.json
Normal file
83
data/agent/studio_projects/新项目/pipeline.json
Normal file
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"workflowGoal": "设计一个单人角色:先绑定角色卡与世界书,再迭代整体美学与具体人设,写入世界书条目。",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "init",
|
||||
"skillId": "studio.init_bind",
|
||||
"displayName": "创建并绑定",
|
||||
"enabled": true,
|
||||
"config": {},
|
||||
"displayParams": [
|
||||
{
|
||||
"key": "characterName",
|
||||
"label": "角色卡名称",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"placeholder": ""
|
||||
},
|
||||
{
|
||||
"key": "worldbookName",
|
||||
"label": "世界书名称",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"placeholder": ""
|
||||
}
|
||||
],
|
||||
"inputs": []
|
||||
},
|
||||
{
|
||||
"id": "aesthetic",
|
||||
"skillId": "studio.worldbook_entry",
|
||||
"displayName": "整体美学",
|
||||
"niche": "aesthetic_tone",
|
||||
"enabled": true,
|
||||
"loopUntilSatisfied": true,
|
||||
"config": {
|
||||
"stepGoal": "产出角色的整体美学设定:视觉风格、氛围、叙事基调,供后续人设步骤引用。",
|
||||
"insertion": {
|
||||
"position": 4,
|
||||
"activationType": "normal",
|
||||
"key": "整体美学",
|
||||
"comment": "Studio · 整体美学"
|
||||
},
|
||||
"scoring": {
|
||||
"enabled": true,
|
||||
"rubric": "是否覆盖色调/材质/氛围/叙事基调;是否可与后续人设衔接;表述是否简洁可注入世界书。"
|
||||
}
|
||||
},
|
||||
"displayParams": [],
|
||||
"inputs": []
|
||||
},
|
||||
{
|
||||
"id": "persona",
|
||||
"skillId": "studio.worldbook_entry",
|
||||
"displayName": "具体人设",
|
||||
"niche": "persona_detail",
|
||||
"enabled": true,
|
||||
"loopUntilSatisfied": true,
|
||||
"config": {
|
||||
"stepGoal": "在整体美学基础上,写出可扮演的人设:性格、动机、口癖、关系与行为模式。",
|
||||
"insertion": {
|
||||
"position": 4,
|
||||
"activationType": "normal",
|
||||
"key": "具体人设",
|
||||
"comment": "Studio · 具体人设"
|
||||
},
|
||||
"scoring": {
|
||||
"enabled": true,
|
||||
"rubric": "是否与人设目标一致;是否与整体美学一致;是否具备可扮演细节;是否避免与已有条目冲突。"
|
||||
}
|
||||
},
|
||||
"displayParams": [],
|
||||
"inputs": [
|
||||
{
|
||||
"ref": "aesthetic.output"
|
||||
},
|
||||
{
|
||||
"ref": "persona.output",
|
||||
"optional": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
// frontend-react/src/App.jsx
|
||||
import React, { useCallback, useEffect, useRef } from 'react'; // ✅ 移除 useState
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import TopBar from './components/TopBar';
|
||||
import { ChatBox } from './components/Mid';
|
||||
import SideBarLeft from './components/SideBarLeft';
|
||||
import SideBarRight from './components/SideBarRight';
|
||||
import PlaceholderPage from './components/PlaceholderPage';
|
||||
import NovelPage from './components/Novel/NovelPage';
|
||||
import StudioEditPage from './components/Studio/StudioEditPage';
|
||||
import StudioRunPage from './components/Studio/StudioRunPage';
|
||||
import useAppLayoutStore from './Store/AppLayoutSlice'; // ✅ 新增
|
||||
import useStudioStore from './Store/Studio/StudioSlice';
|
||||
import useNovelStore from './Store/Novel/NovelSlice';
|
||||
import useApiConfigStore from './Store/SideBarLeft/ApiConfigSlice'; // ✅ 引入 API 配置 Store
|
||||
import usePresetStore from './Store/SideBarLeft/PresetSlice'; // ✅ 引入预设 Store
|
||||
import useCharacterStore from './Store/SideBarLeft/CharacterSlice'; // ✅ 引入角色卡 Store
|
||||
@@ -194,22 +196,41 @@ function App() {
|
||||
|
||||
const initStudio = useStudioStore((s) => s.initStudio);
|
||||
const initStudioRun = useStudioStore((s) => s.initStudioRun);
|
||||
const initNovel = useNovelStore((s) => s.initNovel);
|
||||
const novelView = useNovelStore((s) => s.view);
|
||||
const readingChromeVisible = useNovelStore((s) => s.readingChromeVisible);
|
||||
const isStudioEditPage = activePage === 'studio_edit';
|
||||
const isStudioRunPage = activePage === 'studio_run';
|
||||
const isNovelPage = activePage === 'novel';
|
||||
const isStudioPage = isStudioEditPage || isStudioRunPage;
|
||||
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(() =>
|
||||
typeof window !== 'undefined' && window.matchMedia('(max-width: 768px)').matches
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 768px)');
|
||||
const onChange = (e) => setIsMobileViewport(e.matches);
|
||||
mq.addEventListener('change', onChange);
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
const hideTopBar =
|
||||
isNovelPage && novelView === 'reading' && isMobileViewport && !readingChromeVisible;
|
||||
|
||||
useEffect(() => {
|
||||
if (isStudioEditPage) {
|
||||
initStudio();
|
||||
} else if (isStudioRunPage) {
|
||||
initStudioRun();
|
||||
} else if (isNovelPage) {
|
||||
initNovel();
|
||||
}
|
||||
}, [isStudioEditPage, isStudioRunPage, initStudio, initStudioRun]);
|
||||
}, [isStudioEditPage, isStudioRunPage, isNovelPage, initStudio, initStudioRun, initNovel]);
|
||||
|
||||
return (
|
||||
<div className={`app ${layoutMode}-mode`}>
|
||||
{/* ✅ TopBar 不再需要 props,直接从 Store 读取状态 */}
|
||||
<TopBar />
|
||||
<div className={`app ${layoutMode}-mode${hideTopBar ? ' novel-reading-immersive' : ''}`}>
|
||||
{!hideTopBar && <TopBar />}
|
||||
|
||||
{/* 主内容容器 */}
|
||||
{activePage === 'chat' ? (
|
||||
@@ -243,6 +264,10 @@ function App() {
|
||||
<div className="main-container studio-container">
|
||||
<StudioRunPage />
|
||||
</div>
|
||||
) : activePage === 'novel' ? (
|
||||
<div className="main-container novel-container">
|
||||
<NovelPage />
|
||||
</div>
|
||||
) : (
|
||||
<div className="main-container placeholder-container">
|
||||
<PlaceholderPage page={activePage} />
|
||||
|
||||
1107
frontend/src/Store/Novel/NovelSlice.jsx
Normal file
1107
frontend/src/Store/Novel/NovelSlice.jsx
Normal file
File diff suppressed because it is too large
Load Diff
1041
frontend/src/components/Novel/NovelPage.css
Normal file
1041
frontend/src/components/Novel/NovelPage.css
Normal file
File diff suppressed because it is too large
Load Diff
1043
frontend/src/components/Novel/NovelPage.jsx
Normal file
1043
frontend/src/components/Novel/NovelPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -17,8 +17,21 @@
|
||||
padding: 0 var(--spacing-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--spacing-md);
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-scrollbar) transparent;
|
||||
}
|
||||
|
||||
.top-bar-content::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.top-bar-content::-webkit-scrollbar-thumb {
|
||||
background: var(--color-scrollbar);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
/* Status Section - Left side */
|
||||
@@ -26,10 +39,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
flex: 1;
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Actions Section - Right side */
|
||||
@@ -38,6 +48,7 @@
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Status Badge Styles - Minimalist */
|
||||
@@ -87,21 +98,25 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Action Button Styles - Badge-like (Similar to status-badge) */
|
||||
/* Action Button Styles — compact square, matches theme toggle */
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
cursor: pointer;
|
||||
font-size: 1.4rem; /* 更大的图标 */
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
min-height: 36px; /* 与 status-badge 一致 */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
@@ -665,3 +680,16 @@
|
||||
border-color: var(--color-text-muted);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Mobile: hide TopBar horizontal scrollbar (keep swipe scroll) */
|
||||
@media (max-width: 768px) {
|
||||
.top-bar-content {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.top-bar-content::-webkit-scrollbar {
|
||||
display: none;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import useApiConfigStore from '../../Store/SideBarLeft/ApiConfigSlice';
|
||||
import PageModeToggle from './items/PageModeToggle';
|
||||
import StudioRunControls from './items/StudioRunControls';
|
||||
import ThemeToggle from './items/ThemeToggle';
|
||||
import RegexPanel from '../SideBarLeft/tabs/Regex/RegexPanel'; // ✅ 新增:导入正则管理组件
|
||||
import RegexPanel from '../SideBarLeft/tabs/Regex/RegexPanel';
|
||||
import ApiConfig from '../SideBarLeft/tabs/ApiConfig/ApiConfig';
|
||||
import './TopBar.css';
|
||||
|
||||
const Toolbar = () => {
|
||||
@@ -24,6 +25,7 @@ const Toolbar = () => {
|
||||
} = useAppLayoutStore();
|
||||
|
||||
const isStudioRunPage = activePage === 'studio_run';
|
||||
const isNovelPage = activePage === 'novel';
|
||||
|
||||
// ✅ 从 UserStore 获取用户角色和方法
|
||||
const { currentUserRole, userRoles, updateRoleName, updateRoleDescription, selectUserRole, addUserRole } = useUserStore();
|
||||
@@ -32,10 +34,17 @@ const Toolbar = () => {
|
||||
const { globalWorldBooks } = useWorldBookStore();
|
||||
|
||||
// 从 ApiConfigStore 获取核心和辅助 API 配置
|
||||
const { activeMap, fetchProfile } = useApiConfigStore();
|
||||
const { currentProfile } = useApiConfigStore();
|
||||
const [coreModel, setCoreModel] = useState('未设置');
|
||||
const [assistModel, setAssistModel] = useState('未设置');
|
||||
|
||||
useEffect(() => {
|
||||
const main = currentProfile?.apis?.mainLLM;
|
||||
const secondary = currentProfile?.apis?.secondaryLLM;
|
||||
setCoreModel(main?.model || main?.name || '未设置');
|
||||
setAssistModel(secondary?.model || secondary?.name || '未设置');
|
||||
}, [currentProfile]);
|
||||
|
||||
// 系统设置状态
|
||||
const [settings, setSettings] = useState({
|
||||
thinkingTagPrefix: '<thinking>',
|
||||
@@ -155,6 +164,8 @@ const Toolbar = () => {
|
||||
<div className="status-section">
|
||||
{isStudioRunPage ? <StudioRunControls /> : null}
|
||||
|
||||
{!isNovelPage && (
|
||||
<>
|
||||
{/* 当前玩家角色 */}
|
||||
<div
|
||||
className="status-badge"
|
||||
@@ -164,19 +175,31 @@ const Toolbar = () => {
|
||||
<span className="status-icon">😊</span>
|
||||
<span className="status-label">{truncateText(currentUserRole.name || '未设置', 15)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 核心配置 */}
|
||||
<div className="status-badge" title={`核心配置: ${coreModel}`}>
|
||||
<div
|
||||
className="status-badge"
|
||||
title={`核心配置: ${coreModel}`}
|
||||
onClick={isNovelPage ? () => handlePanelToggle('apiConfig') : undefined}
|
||||
>
|
||||
<span className="status-icon">🔌</span>
|
||||
<span className="status-label">{truncateText(coreModel, 12)}</span>
|
||||
</div>
|
||||
|
||||
{/* 辅助配置 */}
|
||||
<div className="status-badge" title={`辅助配置: ${assistModel}`}>
|
||||
<div
|
||||
className="status-badge"
|
||||
title={`辅助配置: ${assistModel}`}
|
||||
onClick={isNovelPage ? () => handlePanelToggle('apiConfig') : undefined}
|
||||
>
|
||||
<span className="status-icon">🔗</span>
|
||||
<span className="status-label">{truncateText(assistModel, 12)}</span>
|
||||
</div>
|
||||
|
||||
{!isNovelPage && (
|
||||
<>
|
||||
{/* 当前预设 */}
|
||||
<div className="status-badge" title="当前预设名">
|
||||
<span className="status-icon">⚙️</span>
|
||||
@@ -192,10 +215,14 @@ const Toolbar = () => {
|
||||
: '无'}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:操作按钮区域 */}
|
||||
<div className="actions-section">
|
||||
{!isNovelPage && (
|
||||
<>
|
||||
{/* 设置按钮 */}
|
||||
<button
|
||||
className="action-btn"
|
||||
@@ -213,6 +240,8 @@ const Toolbar = () => {
|
||||
>
|
||||
⊞
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 页面模式 */}
|
||||
<PageModeToggle />
|
||||
@@ -223,6 +252,23 @@ const Toolbar = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API 配置面板(爽文模式) */}
|
||||
{activePanel === 'apiConfig' && (
|
||||
<div className="panel-overlay" ref={panelRef}>
|
||||
<div className="panel-content settings-panel">
|
||||
<div className="panel-header">
|
||||
<h3>API 配置</h3>
|
||||
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-body" style={{ padding: 0 }}>
|
||||
<ApiConfig />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ✅ 用户角色设置面板 */}
|
||||
{activePanel === 'currentRole' && (
|
||||
<div className="panel-overlay" ref={panelRef}>
|
||||
|
||||
@@ -28,15 +28,22 @@
|
||||
}
|
||||
|
||||
.main-container.placeholder-container,
|
||||
.main-container.studio-container {
|
||||
.main-container.studio-container,
|
||||
.main-container.novel-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-container.studio-container {
|
||||
.main-container.studio-container,
|
||||
.main-container.novel-container {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app.novel-reading-immersive .main-container.novel-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Smooth transitions for theme changes - only apply to specific properties */
|
||||
.app {
|
||||
transition: background-color var(--transition-normal),
|
||||
|
||||
Reference in New Issue
Block a user