Compare commits
10 Commits
feature/ll
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 81b0d0ea1f | |||
| 54e17c9795 | |||
| 2d0f82ee87 | |||
| 71e673a2ed | |||
| 0f50c98cf3 | |||
| 5bfe7a733f | |||
| 63a32bfa7c | |||
| f3792915a3 | |||
| fa6907fb8d | |||
| bc130d98f4 |
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`
|
||||
33
README.md
33
README.md
@@ -122,20 +122,35 @@ npm run dev
|
||||
|
||||
前端将在 `http://localhost:5173` 启动,自动代理 API 请求到后端。
|
||||
|
||||
### Docker 部署
|
||||
### Docker 开发(Windows / Docker Desktop)
|
||||
|
||||
```bash
|
||||
# 一键启动
|
||||
docker-compose up -d
|
||||
日常改代码**不需要重启 Docker Desktop**——后端 uvicorn `--reload`、前端 Vite HMR 会自动生效。
|
||||
|
||||
```powershell
|
||||
# 启动(项目根目录)
|
||||
.\scripts\docker-up.ps1
|
||||
|
||||
# 仅重启容器(HMR/reload 异常时)
|
||||
.\scripts\docker-restart.ps1 -Service frontend # 或 backend / all
|
||||
|
||||
# 依赖或 Dockerfile 变更后重建
|
||||
.\scripts\docker-rebuild.ps1 -Service backend
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f
|
||||
|
||||
# 停止服务
|
||||
docker-compose down
|
||||
.\scripts\docker-logs.ps1
|
||||
```
|
||||
|
||||
访问 `http://localhost:80` 即可使用。
|
||||
| 服务 | 地址 |
|
||||
|------|------|
|
||||
| 后端 API | http://localhost:23337 |
|
||||
| 前端 | http://localhost:23338 |
|
||||
|
||||
详细说明(何时 rebuild、何时才需要重启 Docker Desktop、本地开发替代方案)见 **[docs/DOCKER_DEV.md](./docs/DOCKER_DEV.md)**。
|
||||
|
||||
```powershell
|
||||
# 停止服务
|
||||
docker compose down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute, charactersRoute, chatWsRoute, tokenUsageRoute, imageGalleryRoute, regexRoute, chatSummaryRoute
|
||||
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
|
||||
@@ -18,6 +18,8 @@ router.include_router(tokenUsageRoute.router)
|
||||
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)
|
||||
|
||||
@@ -221,119 +221,47 @@ async def _handle_stream_chat(
|
||||
workflow_service
|
||||
):
|
||||
"""
|
||||
处理流式聊天请求
|
||||
|
||||
Args:
|
||||
websocket: WebSocket 连接
|
||||
role_name: 角色名
|
||||
chat_name: 聊天名
|
||||
request_data: 请求数据
|
||||
workflow_service: 工作流服务实例
|
||||
处理流式聊天请求 – engine callbacks emit worldbook_active / tasks_created / chunk.
|
||||
"""
|
||||
try:
|
||||
print(f"[StreamChat] 🚀 开始流式处理")
|
||||
|
||||
# ✅ 第1步:加载角色卡
|
||||
current_role = request_data.get("currentRole")
|
||||
character_data = request_data.get("characterData")
|
||||
chunk_count = [0]
|
||||
|
||||
if character_data:
|
||||
try:
|
||||
from backend.models.internal import CharacterCard
|
||||
except ImportError:
|
||||
from models.internal import CharacterCard
|
||||
character = CharacterCard(**character_data)
|
||||
else:
|
||||
from backend.services.character_service import CharacterService
|
||||
character_service = CharacterService()
|
||||
character = character_service.get_character_by_name(current_role)
|
||||
async def on_worldbook_active(entries):
|
||||
if entries:
|
||||
print(f"[StreamChat] 📤 发送世界书激活信息: {len(entries)} 个条目")
|
||||
await websocket.send_json({
|
||||
"type": "worldbook_active",
|
||||
"entries": entries,
|
||||
})
|
||||
|
||||
if not character:
|
||||
print(f"[StreamChat] ❌ 错误: 无法加载角色 '{current_role}'")
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": f"角色 '{current_role}' 不存在"
|
||||
})
|
||||
return
|
||||
async def on_tasks_created(task_ids):
|
||||
if task_ids.get("imageWorkflow") or task_ids.get("dynamicTable"):
|
||||
print(f"[StreamChat] 📤 发送任务ID信息: {task_ids}")
|
||||
await websocket.send_json({
|
||||
"type": "tasks_created",
|
||||
"tasks": task_ids,
|
||||
})
|
||||
|
||||
print(f"[StreamChat] ✅ 已加载角色卡: {character.name}")
|
||||
async def on_chunk(chunk):
|
||||
chunk_count[0] += 1
|
||||
if chunk_count[0] % 10 == 0:
|
||||
print(f"[StreamChat] 📤 已发送 {chunk_count[0]} 个 chunks")
|
||||
await websocket.send_json({"type": "chunk", "content": chunk})
|
||||
|
||||
# ✅ 第2步:激活世界书条目(在LLM调用之前)
|
||||
print(f"[StreamChat] 📚 正在激活世界书条目...")
|
||||
active_entries = await workflow_service._collect_and_activate_worldbooks(
|
||||
request_data,
|
||||
character
|
||||
)
|
||||
|
||||
# ✅ 发送激活的世界书条目信息(在LLM调用前)
|
||||
if active_entries:
|
||||
print(f"[StreamChat] 📤 发送世界书激活信息: {len(active_entries)} 个条目")
|
||||
# 将 Pydantic 模型转换为字典
|
||||
entries_dict = [entry.model_dump() for entry in active_entries]
|
||||
await websocket.send_json({
|
||||
"type": "worldbook_active",
|
||||
"entries": entries_dict
|
||||
})
|
||||
|
||||
# ✅ TODO: RAG检索(暂时为空,待实现)
|
||||
rag_results = []
|
||||
if rag_results:
|
||||
print(f"[StreamChat] 🔍 发送 RAG 检索结果: {len(rag_results)} 条")
|
||||
await websocket.send_json({
|
||||
"type": "rag_results",
|
||||
"results": rag_results
|
||||
})
|
||||
|
||||
# ✅ 第2步:启动并行任务(在LLM调用前创建任务ID)
|
||||
options = request_data.get("options", {})
|
||||
task_ids = {
|
||||
"imageWorkflow": None,
|
||||
"dynamicTable": None
|
||||
}
|
||||
|
||||
if options.get("imageWorkflow", False):
|
||||
import uuid
|
||||
chat_id = f"{role_name}/{chat_name}"
|
||||
task_ids["imageWorkflow"] = f"img_{uuid.uuid4().hex[:8]}"
|
||||
from backend.services.task_queue_manager import task_queue_manager, TaskType
|
||||
await task_queue_manager.add_task(task_ids["imageWorkflow"], TaskType.IMAGE_WORKFLOW, chat_id)
|
||||
|
||||
if options.get("dynamicTable", False):
|
||||
import uuid
|
||||
chat_id = f"{role_name}/{chat_name}"
|
||||
task_ids["dynamicTable"] = f"tbl_{uuid.uuid4().hex[:8]}"
|
||||
from backend.services.task_queue_manager import task_queue_manager, TaskType
|
||||
await task_queue_manager.add_task(task_ids["dynamicTable"], TaskType.DYNAMIC_TABLE, chat_id)
|
||||
|
||||
# ✅ 发送任务ID信息(在LLM调用前)
|
||||
if task_ids.get("imageWorkflow") or task_ids.get("dynamicTable"):
|
||||
print(f"[StreamChat] 📤 发送任务ID信息: {task_ids}")
|
||||
await websocket.send_json({
|
||||
"type": "tasks_created",
|
||||
"tasks": task_ids
|
||||
})
|
||||
|
||||
# ✅ 第3步:调用LLM流式生成
|
||||
chunk_count = [0] # 使用列表以便在闭包中修改
|
||||
result = await workflow_service.process_chat_request_stream(
|
||||
request_data,
|
||||
on_chunk=lambda chunk: asyncio.create_task(
|
||||
_send_chunk_with_log(websocket, chunk, chunk_count)
|
||||
)
|
||||
on_chunk=on_chunk,
|
||||
on_worldbook_active=on_worldbook_active,
|
||||
on_tasks_created=on_tasks_created,
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
content = result["content"]
|
||||
|
||||
print(f"\n[StreamChat] ✨ 流式生成成功,总长度: {len(content)}")
|
||||
|
||||
# 发送完成信号
|
||||
print(f"[StreamChat] ✅ 发送完成信号")
|
||||
await websocket.send_json({
|
||||
"type": "complete"
|
||||
})
|
||||
|
||||
# 保存消息
|
||||
await websocket.send_json({"type": "complete"})
|
||||
print(f"[StreamChat] 💾 保存消息到文件...")
|
||||
await _save_messages(role_name, chat_name, request_data, content)
|
||||
print(f"[StreamChat] ✅ 消息保存完成\n")
|
||||
@@ -342,7 +270,7 @@ async def _handle_stream_chat(
|
||||
print(f"[StreamChat] ❌ 流式处理失败: {error_msg}")
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": error_msg
|
||||
"message": error_msg,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
@@ -351,26 +279,10 @@ async def _handle_stream_chat(
|
||||
traceback.print_exc()
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": f"流式处理失败: {str(e)}"
|
||||
"message": f"流式处理失败: {str(e)}",
|
||||
})
|
||||
|
||||
|
||||
async def _send_chunk_with_log(websocket: WebSocket, chunk: str, chunk_count: list):
|
||||
"""
|
||||
发送 chunk 并记录日志
|
||||
|
||||
Args:
|
||||
websocket: WebSocket 连接
|
||||
chunk: 文本片段
|
||||
chunk_count: 计数器(使用列表以便在闭包中修改)
|
||||
"""
|
||||
chunk_count[0] += 1
|
||||
if chunk_count[0] % 10 == 0: # 每10个chunk记录一次
|
||||
print(f"[StreamChat] 📤 已发送 {chunk_count[0]} 个 chunks")
|
||||
|
||||
await websocket.send_json({"type": "chunk", "content": chunk})
|
||||
|
||||
|
||||
async def _save_messages(
|
||||
role_name: str,
|
||||
chat_name: str,
|
||||
|
||||
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))
|
||||
390
backend/api/routes/studioRoute.py
Normal file
390
backend/api/routes/studioRoute.py
Normal file
@@ -0,0 +1,390 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from models.studio_models import (
|
||||
AdvanceRunRequest,
|
||||
CreateStudioProjectRequest,
|
||||
PipelineDefinition,
|
||||
RenameRunRequest,
|
||||
RunMessageRequest,
|
||||
RunRerollRequest,
|
||||
SaveRunRequest,
|
||||
StudioProject,
|
||||
StudioProjectSummary,
|
||||
StudioRun,
|
||||
StudioRunSummary,
|
||||
SwitchRunNodeRequest,
|
||||
UpdateStudioProjectRequest,
|
||||
WorkflowTemplateSummary,
|
||||
WorkflowVariablesResponse,
|
||||
)
|
||||
from services.studio_project_service import studio_project_service
|
||||
from services.studio_run_service import studio_run_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/studio", tags=["studio"])
|
||||
|
||||
|
||||
@router.get("/projects", response_model=List[StudioProjectSummary])
|
||||
async def list_studio_projects():
|
||||
try:
|
||||
return studio_project_service.list_projects()
|
||||
except Exception as e:
|
||||
logger.error("Failed to list studio projects: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}", response_model=StudioProject)
|
||||
async def get_studio_project(project_id: str):
|
||||
try:
|
||||
return studio_project_service.get_project(project_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to get studio project %s: %s", project_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/projects/{project_id}", response_model=StudioProject)
|
||||
async def update_studio_project(project_id: str, req: UpdateStudioProjectRequest):
|
||||
try:
|
||||
return studio_project_service.update_project_meta(
|
||||
project_id,
|
||||
name=req.name,
|
||||
description=req.description,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to update studio project %s: %s", project_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/projects/{project_id}/pipeline", response_model=StudioProject)
|
||||
async def save_studio_pipeline(project_id: str, pipeline: PipelineDefinition):
|
||||
try:
|
||||
return studio_project_service.save_pipeline(project_id, pipeline)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to save pipeline for %s: %s", project_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/templates", response_model=List[WorkflowTemplateSummary])
|
||||
async def list_workflow_templates():
|
||||
try:
|
||||
return studio_project_service.list_workflow_templates()
|
||||
except Exception as e:
|
||||
logger.error("Failed to list workflow templates: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/variables", response_model=WorkflowVariablesResponse)
|
||||
async def get_workflow_variables(projectId: str | None = None):
|
||||
try:
|
||||
return studio_project_service.get_workflow_variables(projectId)
|
||||
except Exception as e:
|
||||
logger.error("Failed to load workflow variables: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/skill-templates")
|
||||
async def get_skill_templates() -> Dict[str, Any]:
|
||||
try:
|
||||
return studio_project_service.get_skill_templates()
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to load skill templates: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/niches")
|
||||
async def get_niches() -> Dict[str, Any]:
|
||||
try:
|
||||
return studio_project_service.get_niches()
|
||||
except Exception as e:
|
||||
logger.error("Failed to load niches: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/projects/{project_id}")
|
||||
async def delete_studio_project(project_id: str):
|
||||
try:
|
||||
studio_project_service.delete_project(project_id)
|
||||
return {"ok": True, "id": project_id}
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete studio project %s: %s", project_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/projects", response_model=StudioProject)
|
||||
async def create_studio_project(req: CreateStudioProjectRequest):
|
||||
try:
|
||||
return studio_project_service.create_project(req)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to create studio project: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/runs", response_model=StudioRun)
|
||||
async def create_studio_run(project_id: str):
|
||||
try:
|
||||
return studio_run_service.create_run(project_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to create studio run for %s: %s", project_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/runs", response_model=List[StudioRunSummary])
|
||||
async def list_studio_runs(project_id: str):
|
||||
try:
|
||||
studio_project_service.get_project(project_id)
|
||||
return studio_run_service.list_runs(project_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to list studio runs for %s: %s", project_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/runs/{run_id}", response_model=StudioRun)
|
||||
async def get_studio_run(project_id: str, run_id: str):
|
||||
try:
|
||||
return studio_run_service.get_run(project_id, run_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to get studio run %s/%s: %s", project_id, run_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/runs/{run_id}/advance", response_model=StudioRun)
|
||||
async def advance_studio_run(
|
||||
project_id: str, run_id: str, req: AdvanceRunRequest
|
||||
):
|
||||
try:
|
||||
return studio_run_service.advance_run(
|
||||
project_id, run_id, display_params=req.displayParams, save_mode=req.saveMode
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except NotImplementedError as e:
|
||||
raise HTTPException(status_code=501, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to advance studio run %s/%s: %s", project_id, run_id, e
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/runs/{run_id}/save", response_model=StudioRun)
|
||||
async def save_studio_run(
|
||||
project_id: str, run_id: str, req: SaveRunRequest
|
||||
):
|
||||
try:
|
||||
return studio_run_service.save_run(project_id, run_id, req.mode)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to save studio run %s/%s: %s", project_id, run_id, e
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/runs/{run_id}/switch-node", response_model=StudioRun)
|
||||
async def switch_studio_run_node(
|
||||
project_id: str, run_id: str, req: SwitchRunNodeRequest
|
||||
):
|
||||
try:
|
||||
return studio_run_service.switch_run_node(project_id, run_id, req.nodeId)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to switch studio run node %s/%s: %s", project_id, run_id, e
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/runs/{run_id}/message")
|
||||
async def send_studio_run_message(
|
||||
project_id: str, run_id: str, req: RunMessageRequest
|
||||
):
|
||||
if req.stream:
|
||||
async def ndjson_stream():
|
||||
try:
|
||||
async for event in studio_run_service.send_run_message_stream(
|
||||
project_id,
|
||||
run_id,
|
||||
req.content,
|
||||
profile_id=req.profileId,
|
||||
api_config=req.apiConfig,
|
||||
):
|
||||
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||||
except FileNotFoundError as e:
|
||||
yield json.dumps(
|
||||
{"type": "error", "detail": str(e)}, ensure_ascii=False
|
||||
) + "\n"
|
||||
except ValueError as e:
|
||||
yield json.dumps(
|
||||
{"type": "error", "detail": str(e)}, ensure_ascii=False
|
||||
) + "\n"
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to stream studio run message %s/%s: %s",
|
||||
project_id,
|
||||
run_id,
|
||||
e,
|
||||
)
|
||||
yield json.dumps(
|
||||
{"type": "error", "detail": f"消息处理失败:{e}"},
|
||||
ensure_ascii=False,
|
||||
) + "\n"
|
||||
|
||||
return StreamingResponse(
|
||||
ndjson_stream(),
|
||||
media_type="application/x-ndjson",
|
||||
)
|
||||
|
||||
try:
|
||||
return await studio_run_service.send_run_message(
|
||||
project_id,
|
||||
run_id,
|
||||
req.content,
|
||||
stream=req.stream,
|
||||
profile_id=req.profileId,
|
||||
api_config=req.apiConfig,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to send studio run message %s/%s: %s", project_id, run_id, e
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"消息处理失败:{e}")
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/runs/{run_id}/undo", response_model=StudioRun)
|
||||
async def undo_studio_run(project_id: str, run_id: str):
|
||||
try:
|
||||
return studio_run_service.undo_run(project_id, run_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to undo studio run %s/%s: %s", project_id, run_id, e
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"回退失败:{e}")
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/runs/{run_id}/reroll")
|
||||
async def reroll_studio_run(
|
||||
project_id: str, run_id: str, req: RunRerollRequest
|
||||
):
|
||||
if req.stream:
|
||||
async def ndjson_stream():
|
||||
try:
|
||||
async for event in studio_run_service.reroll_run_stream(
|
||||
project_id,
|
||||
run_id,
|
||||
profile_id=req.profileId,
|
||||
api_config=req.apiConfig,
|
||||
):
|
||||
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||||
except FileNotFoundError as e:
|
||||
yield json.dumps(
|
||||
{"type": "error", "detail": str(e)}, ensure_ascii=False
|
||||
) + "\n"
|
||||
except ValueError as e:
|
||||
yield json.dumps(
|
||||
{"type": "error", "detail": str(e)}, ensure_ascii=False
|
||||
) + "\n"
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to stream studio run reroll %s/%s: %s",
|
||||
project_id,
|
||||
run_id,
|
||||
e,
|
||||
)
|
||||
yield json.dumps(
|
||||
{"type": "error", "detail": f"重 roll 失败:{e}"},
|
||||
ensure_ascii=False,
|
||||
) + "\n"
|
||||
|
||||
return StreamingResponse(
|
||||
ndjson_stream(),
|
||||
media_type="application/x-ndjson",
|
||||
)
|
||||
|
||||
try:
|
||||
return await studio_run_service.reroll_run(
|
||||
project_id,
|
||||
run_id,
|
||||
stream=req.stream,
|
||||
profile_id=req.profileId,
|
||||
api_config=req.apiConfig,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to reroll studio run %s/%s: %s", project_id, run_id, e
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"重 roll 失败:{e}")
|
||||
|
||||
|
||||
@router.delete("/projects/{project_id}/runs/{run_id}")
|
||||
async def delete_studio_run(project_id: str, run_id: str):
|
||||
try:
|
||||
studio_run_service.delete_run(project_id, run_id)
|
||||
return {"ok": True, "id": run_id}
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to delete studio run %s/%s: %s", project_id, run_id, e
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/projects/{project_id}/runs/{run_id}", response_model=StudioRun)
|
||||
async def rename_studio_run(
|
||||
project_id: str, run_id: str, req: RenameRunRequest
|
||||
):
|
||||
try:
|
||||
return studio_run_service.rename_run(project_id, run_id, req.title)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to rename studio run %s/%s: %s", project_id, run_id, e
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -61,6 +61,23 @@ class Settings:
|
||||
# 图片资源目录
|
||||
IMAGES_PATH = DATA_PATH / "images"
|
||||
|
||||
# Agent 工作流模板与运行记录
|
||||
AGENT_TEMPLATES_PATH = DATA_PATH / "agent" / "templates"
|
||||
AGENT_RUNS_PATH = DATA_PATH / "agent" / "runs"
|
||||
AGENT_STUDIO_PROJECTS_PATH = DATA_PATH / "agent" / "studio_projects"
|
||||
AGENT_STUDIO_RUNS_PATH = DATA_PATH / "agent" / "studio_runs"
|
||||
AGENT_SKILL_TEMPLATES_FILE = DATA_PATH / "agent" / "skill_templates.json"
|
||||
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 = [
|
||||
@@ -72,6 +89,14 @@ class Settings:
|
||||
self.COMFYUI_WORKFLOWS_PATH,
|
||||
self.CHARACTERS_PATH,
|
||||
self.IMAGES_PATH,
|
||||
self.AGENT_TEMPLATES_PATH,
|
||||
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)
|
||||
|
||||
128
backend/models/agent.py
Normal file
128
backend/models/agent.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Agent workflow engine data models.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class WorkflowTemplateKind(str, Enum):
|
||||
BUILTIN_CHAT = "builtin.chat"
|
||||
|
||||
|
||||
class RunStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class RunEventType(str, Enum):
|
||||
STATE_ENTER = "state_enter"
|
||||
TOOL_START = "tool_start"
|
||||
TOOL_END = "tool_end"
|
||||
WORLD_BOOK_ACTIVE = "worldbook_active"
|
||||
TASKS_CREATED = "tasks_created"
|
||||
CHUNK = "chunk"
|
||||
ERROR = "error"
|
||||
COMPLETE = "complete"
|
||||
|
||||
|
||||
class ToolSpec(BaseModel):
|
||||
name: str
|
||||
description: str = ""
|
||||
parameters: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SkillManifest(BaseModel):
|
||||
id: str
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
path: str = ""
|
||||
|
||||
|
||||
class WorkflowTemplate(BaseModel):
|
||||
id: str
|
||||
kind: WorkflowTemplateKind
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
version: str = "1.0.0"
|
||||
state_machine_path: str = "state_machine.json"
|
||||
skills: List[SkillManifest] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ChatRunBinding(BaseModel):
|
||||
role_name: str
|
||||
chat_name: str
|
||||
template_id: str = WorkflowTemplateKind.BUILTIN_CHAT.value
|
||||
|
||||
|
||||
class TurnCallbacks(BaseModel):
|
||||
"""Optional async callbacks for streaming / WS events."""
|
||||
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
on_worldbook_active: Optional[Callable[[List[Any]], Awaitable[None]]] = None
|
||||
on_tasks_created: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None
|
||||
on_chunk: Optional[Callable[[str], Awaitable[None]]] = None
|
||||
|
||||
|
||||
class TurnContext(BaseModel):
|
||||
"""Mutable per-turn execution context passed between tools."""
|
||||
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
request_data: Dict[str, Any] = Field(default_factory=dict)
|
||||
template_id: str = WorkflowTemplateKind.BUILTIN_CHAT.value
|
||||
run_id: str = ""
|
||||
stream: bool = False
|
||||
callbacks: Optional[TurnCallbacks] = None
|
||||
|
||||
current_role: str = ""
|
||||
current_chat: str = ""
|
||||
user_message: str = ""
|
||||
preset_name: Optional[str] = None
|
||||
character: Any = None
|
||||
active_entries: List[Any] = Field(default_factory=list)
|
||||
chat_history: List[Any] = Field(default_factory=list)
|
||||
prompt_messages: List[Any] = Field(default_factory=list)
|
||||
generated_content: str = ""
|
||||
token_usage: Dict[str, Any] = Field(default_factory=dict)
|
||||
duration: float = 0.0
|
||||
task_ids: Dict[str, Optional[str]] = Field(default_factory=dict)
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class WorkflowRun(BaseModel):
|
||||
id: str
|
||||
template_id: str
|
||||
binding: ChatRunBinding
|
||||
status: RunStatus = RunStatus.PENDING
|
||||
started_at: str = Field(default_factory=lambda: datetime.now().isoformat())
|
||||
finished_at: Optional[str] = None
|
||||
current_state: Optional[str] = None
|
||||
result_content: str = ""
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class RunEvent(BaseModel):
|
||||
run_id: str
|
||||
type: RunEventType
|
||||
timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())
|
||||
state: Optional[str] = None
|
||||
tool: Optional[str] = None
|
||||
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ChatTurnResult(BaseModel):
|
||||
success: bool
|
||||
content: str = ""
|
||||
error: Optional[str] = None
|
||||
active_entries: List[Any] = Field(default_factory=list)
|
||||
task_ids: Dict[str, Optional[str]] = Field(default_factory=dict)
|
||||
run_id: str = ""
|
||||
workflow_template_id: str = WorkflowTemplateKind.BUILTIN_CHAT.value
|
||||
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
|
||||
@@ -215,6 +215,10 @@ class ChatHeader(BaseModel):
|
||||
messageCount: int = Field(0, description="消息数量")
|
||||
ragLibraryId: Optional[str] = Field(None, description="关联的 RAG 历史消息库 ID")
|
||||
|
||||
# Agent workflow engine (optional, backward compatible)
|
||||
workflowTemplateId: Optional[str] = Field(None, description="工作流模板 ID")
|
||||
engineRunId: Optional[str] = Field(None, description="最近一次引擎运行 ID")
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
"""
|
||||
|
||||
258
backend/models/studio_models.py
Normal file
258
backend/models/studio_models.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
Studio workflow editor data models.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DisplayParam(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
type: str = "text"
|
||||
required: bool = True
|
||||
placeholder: str = ""
|
||||
|
||||
|
||||
class InputRef(BaseModel):
|
||||
ref: str
|
||||
label: Optional[str] = None
|
||||
optional: bool = False
|
||||
|
||||
|
||||
class ScoringDimension(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
criteria: str = ""
|
||||
|
||||
|
||||
class InsertionRagConfig(BaseModel):
|
||||
libraryId: str = ""
|
||||
threshold: float = 0.5
|
||||
maxEntries: int = 3
|
||||
|
||||
|
||||
class InsertionConfig(BaseModel):
|
||||
position: int = 1
|
||||
activationType: str = "permanent"
|
||||
key: str = ""
|
||||
keysecondary: str = ""
|
||||
comment: str = ""
|
||||
ragConfig: Optional[InsertionRagConfig] = None
|
||||
|
||||
|
||||
class ScoringConfig(BaseModel):
|
||||
enabled: bool = True
|
||||
dimensions: List[ScoringDimension] = Field(default_factory=list)
|
||||
rubric: Optional[str] = None
|
||||
|
||||
|
||||
class StudioNode(BaseModel):
|
||||
id: str
|
||||
skillId: str
|
||||
displayName: str
|
||||
enabled: bool = True
|
||||
niche: Optional[str] = None
|
||||
loopUntilSatisfied: bool = False
|
||||
config: Dict[str, Any] = Field(default_factory=dict)
|
||||
displayParams: List[DisplayParam] = Field(default_factory=list)
|
||||
inputs: List[InputRef] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PipelineDefinition(BaseModel):
|
||||
workflowGoal: str = ""
|
||||
nodes: List[StudioNode] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StudioProjectMeta(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str = ""
|
||||
templateId: Optional[str] = None
|
||||
characterId: Optional[str] = None
|
||||
worldbookId: Optional[str] = None
|
||||
createdAt: str = ""
|
||||
updatedAt: str = ""
|
||||
|
||||
|
||||
class StudioProject(BaseModel):
|
||||
meta: StudioProjectMeta
|
||||
pipeline: PipelineDefinition
|
||||
|
||||
|
||||
class ArtifactDef(BaseModel):
|
||||
type: str
|
||||
displayName: str = ""
|
||||
|
||||
|
||||
class SkillTemplateDef(BaseModel):
|
||||
skillId: str
|
||||
displayName: str
|
||||
description: str = ""
|
||||
displayParams: List[DisplayParam] = Field(default_factory=list)
|
||||
configWhitelist: List[str] = Field(default_factory=list)
|
||||
artifacts: List[ArtifactDef] = Field(default_factory=list)
|
||||
supportsLoopUntilSatisfied: bool = False
|
||||
supportsInputs: bool = False
|
||||
supportsInsertion: bool = False
|
||||
supportsScoring: bool = False
|
||||
|
||||
|
||||
class WorkflowTemplateSummary(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
class WorkflowVariableDef(BaseModel):
|
||||
ref: str
|
||||
label: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
class DynamicVariableSuffix(BaseModel):
|
||||
suffix: str
|
||||
labelPattern: str
|
||||
|
||||
|
||||
class WorkflowVariablesResponse(BaseModel):
|
||||
builtIn: List[WorkflowVariableDef] = Field(default_factory=list)
|
||||
dynamic: List[WorkflowVariableDef] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SkillTemplatesCatalog(BaseModel):
|
||||
templates: List[SkillTemplateDef] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StudioProjectSummary(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str = ""
|
||||
updatedAt: str = ""
|
||||
|
||||
|
||||
class CreateStudioProjectRequest(BaseModel):
|
||||
name: str = "新项目"
|
||||
template_id: str = "builtin.studio.example"
|
||||
project_id: Optional[str] = None
|
||||
|
||||
|
||||
class UpdateStudioProjectRequest(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=120)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
class StudioRunStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
PAUSED = "paused"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class ToolQuestionOption(BaseModel):
|
||||
question: str
|
||||
options: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StepMessage(BaseModel):
|
||||
"""Short step-scoped dialogue (not full chat history)."""
|
||||
id: str
|
||||
role: str # user | assistant
|
||||
content: str
|
||||
createdAt: Optional[str] = None
|
||||
|
||||
|
||||
class LastToolResponse(BaseModel):
|
||||
"""LLM tool-call payload surfaced to the run UI (R2+)."""
|
||||
thinking: Optional[str] = None
|
||||
evaluation: Optional[str] = None
|
||||
questions: List[ToolQuestionOption] = Field(default_factory=list)
|
||||
generatedAt: Optional[str] = None
|
||||
|
||||
|
||||
class PromptBlock(BaseModel):
|
||||
"""Single assembled context section for LLM prompt (R2 debug / execution)."""
|
||||
id: str
|
||||
label: str
|
||||
content: str
|
||||
source: str = "auto" # auto | manual | workflow
|
||||
|
||||
|
||||
class TurnSnapshot(BaseModel):
|
||||
"""State captured before each LLM turn (for undo)."""
|
||||
lastDraft: Optional[Dict[str, Any]] = None
|
||||
lastToolResponse: Optional[LastToolResponse] = None
|
||||
stepMessages: List[StepMessage] = Field(default_factory=list)
|
||||
timestamp: Optional[str] = None
|
||||
|
||||
|
||||
class StudioNodeRunState(BaseModel):
|
||||
nodeId: str
|
||||
displayName: str
|
||||
skillId: str
|
||||
status: str # pending | active | completed | skipped
|
||||
loopUntilSatisfied: bool = False
|
||||
lastDraft: Optional[Dict[str, Any]] = None
|
||||
lastToolResponse: Optional[LastToolResponse] = None
|
||||
stepMessages: List[StepMessage] = Field(default_factory=list)
|
||||
turnHistory: List[TurnSnapshot] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StudioRun(BaseModel):
|
||||
id: str
|
||||
projectId: str
|
||||
status: StudioRunStatus
|
||||
pipelineSnapshot: PipelineDefinition
|
||||
pipelineVersionNote: str
|
||||
currentNodeId: Optional[str] = None
|
||||
nodeStates: List[StudioNodeRunState] = Field(default_factory=list)
|
||||
workflowVariables: Dict[str, Any] = Field(default_factory=dict)
|
||||
lastPromptBlocks: List[PromptBlock] = Field(default_factory=list)
|
||||
title: str = ""
|
||||
createdAt: str = ""
|
||||
updatedAt: str = ""
|
||||
|
||||
|
||||
class AdvanceRunRequest(BaseModel):
|
||||
displayParams: Dict[str, str] = Field(default_factory=dict)
|
||||
saveMode: Literal["advance", "append", "overwrite"] = "advance"
|
||||
|
||||
|
||||
class SaveRunRequest(BaseModel):
|
||||
mode: Literal["incremental", "overwrite"]
|
||||
|
||||
|
||||
class SwitchRunNodeRequest(BaseModel):
|
||||
nodeId: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class RunMessageRequest(BaseModel):
|
||||
content: str = Field(..., min_length=1, max_length=32000)
|
||||
stream: bool = False
|
||||
profileId: Optional[str] = None
|
||||
apiConfig: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
class RunRerollRequest(BaseModel):
|
||||
stream: bool = False
|
||||
profileId: Optional[str] = None
|
||||
apiConfig: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
class RenameRunRequest(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=120)
|
||||
|
||||
|
||||
class StudioRunSummary(BaseModel):
|
||||
id: str
|
||||
projectId: str
|
||||
status: StudioRunStatus
|
||||
currentNodeId: Optional[str] = None
|
||||
title: str = ""
|
||||
createdAt: str = ""
|
||||
updatedAt: str = ""
|
||||
@@ -17,6 +17,7 @@ try:
|
||||
from backend.utils.llm_client import LLMClient
|
||||
from backend.services.task_queue_manager import task_queue_manager, TaskType
|
||||
from backend.core.config import settings
|
||||
from backend.services.workflow_engine import workflow_engine
|
||||
except ImportError:
|
||||
from services.character_service import CharacterService
|
||||
from services.worldbook_service import WorldBookService
|
||||
@@ -24,6 +25,7 @@ except ImportError:
|
||||
from utils.llm_client import LLMClient
|
||||
from services.task_queue_manager import task_queue_manager, TaskType
|
||||
from core.config import settings
|
||||
from services.workflow_engine import workflow_engine
|
||||
|
||||
|
||||
class ChatWorkflowService:
|
||||
@@ -128,201 +130,17 @@ class ChatWorkflowService:
|
||||
self,
|
||||
request_data: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
处理聊天请求的核心工作流
|
||||
|
||||
Args:
|
||||
request_data: 前端发送的完整数据
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": bool,
|
||||
"content": str, # 生成的回复内容
|
||||
"error": str | None
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# === 第1步:解析请求数据 ===
|
||||
current_role = request_data.get("currentRole")
|
||||
current_chat = request_data.get("currentChat")
|
||||
user_message = request_data.get("mes", "")
|
||||
|
||||
if not current_role or not user_message:
|
||||
return {
|
||||
"success": False,
|
||||
"content": "",
|
||||
"error": "缺少必要的参数:currentRole 或 mes"
|
||||
}
|
||||
|
||||
print(f"[ChatWorkflow] 开始处理请求: role={current_role}, chat={current_chat}")
|
||||
|
||||
# ✅ 获取预设名称(用于加载预设绑定的正则规则)
|
||||
preset_config = request_data.get("presetConfig", {})
|
||||
preset_name = preset_config.get("selectedPreset")
|
||||
|
||||
# ✅ 第1.5步:应用用户输入的正则规则
|
||||
from services.regex_service import regex_service
|
||||
from models.regex_rules import RegexPlacement
|
||||
|
||||
processed_user_message = regex_service.apply_rules_by_placement(
|
||||
text=user_message,
|
||||
placement=RegexPlacement.USER_INPUT.value,
|
||||
character_name=current_role,
|
||||
preset_name=preset_name,
|
||||
message_depth=0,
|
||||
is_for_llm=True, # ✅ 用户输入会发送给LLM
|
||||
is_markdown_rendered=False
|
||||
)
|
||||
|
||||
if processed_user_message != user_message:
|
||||
print(f"[Regex] ✅ 已应用用户输入正则规则")
|
||||
|
||||
user_message = processed_user_message
|
||||
|
||||
# === 第2步:加载角色卡 ===
|
||||
character_data = request_data.get("characterData")
|
||||
if not character_data:
|
||||
# 如果没有提供角色卡数据,从后端加载
|
||||
character = self.character_service.get_character_by_name(current_role)
|
||||
if not character:
|
||||
return {
|
||||
"success": False,
|
||||
"content": "",
|
||||
"error": f"角色 '{current_role}' 不存在"
|
||||
}
|
||||
else:
|
||||
# 使用前端提供的角色卡数据(可能包含用户修改)
|
||||
try:
|
||||
from backend.models.internal import CharacterCard
|
||||
except ImportError:
|
||||
from models.internal import CharacterCard
|
||||
character = CharacterCard(**character_data)
|
||||
|
||||
print(f"[ChatWorkflow] 已加载角色卡: {character.name}")
|
||||
|
||||
# === 第3步:收集并激活世界书条目 ===
|
||||
active_entries = await self._collect_and_activate_worldbooks(
|
||||
request_data,
|
||||
character
|
||||
)
|
||||
print(f"[ChatWorkflow] 激活了 {len(active_entries)} 个世界书条目")
|
||||
|
||||
# === 第4步:加载聊天历史 ===
|
||||
chat_history = await self._load_chat_history(current_role, current_chat)
|
||||
print(f"[ChatWorkflow] 加载了 {len(chat_history)} 条历史消息")
|
||||
|
||||
# === 第5步:组装提示词 ===
|
||||
prompt_messages = self._assemble_prompt(
|
||||
character,
|
||||
chat_history,
|
||||
user_message,
|
||||
active_entries,
|
||||
request_data
|
||||
)
|
||||
print(f"[ChatWorkflow] 组装了 {len(prompt_messages)} 条提示消息")
|
||||
|
||||
# === 第6步:调用LLM生成回复 ===
|
||||
api_config = request_data.get("apiConfig", {})
|
||||
preset_config = request_data.get("presetConfig", {})
|
||||
stream_output = request_data.get("stream", False)
|
||||
|
||||
result = await self._generate_response(
|
||||
prompt_messages,
|
||||
api_config,
|
||||
preset_config,
|
||||
stream_output
|
||||
)
|
||||
|
||||
generated_content = result["content"]
|
||||
token_usage = result.get("usage", {})
|
||||
duration = result.get("duration")
|
||||
|
||||
print(f"[ChatWorkflow] 生成完成,内容长度: {len(generated_content)}")
|
||||
print(f"[ChatWorkflow] Token 使用: {token_usage}")
|
||||
|
||||
# ✅ 第6.5步:应用 AI 输出的正则规则
|
||||
processed_ai_output = regex_service.apply_rules_by_placement(
|
||||
text=generated_content,
|
||||
placement=RegexPlacement.AI_OUTPUT.value,
|
||||
character_name=current_role,
|
||||
preset_name=preset_name,
|
||||
message_depth=0,
|
||||
is_for_llm=False, # ✅ AI输出是显示给用户的
|
||||
is_markdown_rendered=False
|
||||
)
|
||||
|
||||
if processed_ai_output != generated_content:
|
||||
print(f"[Regex] ✅ 已应用 AI 输出正则规则")
|
||||
generated_content = processed_ai_output
|
||||
|
||||
# === 第7步:记录 Token 使用 ===
|
||||
chat_id = f"{current_role}/{request_data.get('currentChat', '')}"
|
||||
floor = request_data.get("floor", 0)
|
||||
|
||||
try:
|
||||
try:
|
||||
from backend.services.token_usage_service import token_usage_service
|
||||
from backend.models.internal import TokenUsageStatus
|
||||
except ImportError:
|
||||
from services.token_usage_service import token_usage_service
|
||||
from models.internal import TokenUsageStatus
|
||||
|
||||
await token_usage_service.record_usage(
|
||||
chat_id=chat_id,
|
||||
role_name=current_role,
|
||||
chat_name=request_data.get('currentChat', ''),
|
||||
prompt_tokens=token_usage.get("prompt_tokens", 0),
|
||||
completion_tokens=token_usage.get("completion_tokens", 0),
|
||||
total_tokens=token_usage.get("total_tokens", 0),
|
||||
status=TokenUsageStatus.COMPLETED,
|
||||
floor=floor + 1, # AI 回复的楼层
|
||||
duration=duration,
|
||||
model=api_config.get("model"),
|
||||
api_provider="openai", # TODO: 从 API URL 检测提供商
|
||||
api_url=api_config.get("api_url") # ✅ 记录 API URL
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[ChatWorkflow] 记录 Token 使用失败: {e}")
|
||||
|
||||
# === 第8步:启动异步并行任务 ===
|
||||
|
||||
# 创建任务ID
|
||||
image_task_id = None
|
||||
table_task_id = None
|
||||
|
||||
options = request_data.get("options", {})
|
||||
if options.get("imageWorkflow", False):
|
||||
import uuid
|
||||
image_task_id = f"img_{uuid.uuid4().hex[:8]}"
|
||||
await task_queue_manager.add_task(image_task_id, TaskType.IMAGE_WORKFLOW, chat_id)
|
||||
|
||||
if options.get("dynamicTable", False):
|
||||
import uuid
|
||||
table_task_id = f"tbl_{uuid.uuid4().hex[:8]}"
|
||||
await task_queue_manager.add_task(table_task_id, TaskType.DYNAMIC_TABLE, chat_id)
|
||||
|
||||
await self._start_parallel_tasks(request_data, generated_content, image_task_id, table_task_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"content": generated_content,
|
||||
"error": None,
|
||||
"activeEntries": active_entries,
|
||||
"taskIds": {
|
||||
"imageWorkflow": image_task_id,
|
||||
"dynamicTable": table_task_id
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ChatWorkflow] 错误: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {
|
||||
"success": False,
|
||||
"content": "",
|
||||
"error": f"工作流执行失败: {str(e)}"
|
||||
}
|
||||
"""处理聊天请求 – delegates to WorkflowEngine.run_turn."""
|
||||
result = await workflow_engine.run_turn(request_data, stream=False)
|
||||
return {
|
||||
"success": result.success,
|
||||
"content": result.content,
|
||||
"error": result.error,
|
||||
"activeEntries": result.active_entries,
|
||||
"taskIds": result.task_ids,
|
||||
"engineRunId": result.run_id,
|
||||
"workflowTemplateId": result.workflow_template_id,
|
||||
}
|
||||
|
||||
def _normalize_worldbook_entry(self, entry_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -1211,280 +1029,24 @@ class ChatWorkflowService:
|
||||
async def process_chat_request_stream(
|
||||
self,
|
||||
request_data: Dict[str, Any],
|
||||
on_chunk
|
||||
on_chunk,
|
||||
on_worldbook_active=None,
|
||||
on_tasks_created=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
处理流式聊天请求
|
||||
|
||||
Args:
|
||||
request_data: 前端发送的完整数据
|
||||
on_chunk: 回调函数,每次收到 chunk 时调用
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": bool,
|
||||
"content": str, # 完整的生成内容
|
||||
"error": str | None,
|
||||
"activeEntries": List,
|
||||
"taskIds": Dict
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# === 第1步:解析请求数据 ===
|
||||
current_role = request_data.get("currentRole")
|
||||
current_chat = request_data.get("currentChat")
|
||||
user_message = request_data.get("mes", "")
|
||||
|
||||
if not current_role or not user_message:
|
||||
return {
|
||||
"success": False,
|
||||
"content": "",
|
||||
"error": "缺少必要的参数:currentRole 或 mes"
|
||||
}
|
||||
|
||||
print(f"\n{'#'*80}")
|
||||
print(f"[ChatWorkflow-Stream] 🚀 开始处理请求")
|
||||
print(f" - Role: {current_role}")
|
||||
print(f" - Chat: {current_chat}")
|
||||
print(f" - Message Length: {len(user_message)}")
|
||||
print(f"{'#'*80}\n")
|
||||
|
||||
# ✅ 获取预设名称(用于加载预设绑定的正则规则)
|
||||
preset_config = request_data.get("presetConfig", {})
|
||||
preset_name = preset_config.get("selectedPreset")
|
||||
|
||||
# ✅ 第1.5步:应用用户输入的正则规则
|
||||
from services.regex_service import regex_service
|
||||
from models.regex_rules import RegexPlacement
|
||||
|
||||
processed_user_message = regex_service.apply_rules_by_placement(
|
||||
text=user_message,
|
||||
placement=RegexPlacement.USER_INPUT.value,
|
||||
character_name=current_role,
|
||||
preset_name=preset_name,
|
||||
message_depth=0,
|
||||
is_for_llm=True, # ✅ 用户输入会发送给LLM
|
||||
is_markdown_rendered=False
|
||||
)
|
||||
|
||||
if processed_user_message != user_message:
|
||||
print(f"[Regex] ✅ 已应用用户输入正则规则")
|
||||
|
||||
user_message = processed_user_message
|
||||
|
||||
# === 第2步:加载角色卡 ===
|
||||
character_data = request_data.get("characterData")
|
||||
if not character_data:
|
||||
character = self.character_service.get_character_by_name(current_role)
|
||||
if not character:
|
||||
return {
|
||||
"success": False,
|
||||
"content": "",
|
||||
"error": f"角色 '{current_role}' 不存在"
|
||||
}
|
||||
else:
|
||||
try:
|
||||
from backend.models.internal import CharacterCard
|
||||
except ImportError:
|
||||
from models.internal import CharacterCard
|
||||
character = CharacterCard(**character_data)
|
||||
|
||||
print(f"[ChatWorkflow-Stream] ✅ 已加载角色卡: {character.name}")
|
||||
|
||||
# === 第3步:收集并激活世界书条目 ===
|
||||
active_entries = await self._collect_and_activate_worldbooks(
|
||||
request_data,
|
||||
character
|
||||
)
|
||||
print(f"\n[ChatWorkflow-Stream] 📚 世界书激活结果: {len(active_entries)} 个条目")
|
||||
for i, entry in enumerate(active_entries, 1):
|
||||
print(f" {i}. {getattr(entry, 'name', 'N/A')} (UID: {getattr(entry, 'uid', 'N/A')})")
|
||||
|
||||
# === 第4步:加载聊天历史 ===
|
||||
chat_history = await self._load_chat_history(current_role, current_chat)
|
||||
print(f"[ChatWorkflow-Stream] 💬 聊天历史加载结果: {len(chat_history)} 条消息")
|
||||
|
||||
# === 第5步:组装提示词 ===
|
||||
prompt_messages = self._assemble_prompt(
|
||||
character,
|
||||
chat_history,
|
||||
user_message,
|
||||
active_entries,
|
||||
request_data
|
||||
)
|
||||
print(f"[ChatWorkflow-Stream] 📝 提示词组装结果: {len(prompt_messages)} 条消息")
|
||||
for i, msg in enumerate(prompt_messages, 1):
|
||||
role = getattr(msg, 'role', 'unknown')
|
||||
content_preview = str(getattr(msg, 'content', ''))[:50]
|
||||
print(f" {i}. [{role}] {content_preview}...")
|
||||
|
||||
# === 第6步:流式调用LLM生成回复 ===
|
||||
api_config = request_data.get("apiConfig", {})
|
||||
preset_config = request_data.get("presetConfig", {})
|
||||
|
||||
print(f"\n[ChatWorkflow-Stream] 🤖 开始流式调用 LLM")
|
||||
print(f" - Model: {api_config.get('model', 'N/A')}")
|
||||
print(f" - API URL: {api_config.get('api_url', 'N/A')[:50]}...")
|
||||
print(f" - API Key: {'已设置' if api_config.get('api_key') else '⚠️ 未设置'}")
|
||||
print(f" - Temperature: {preset_config.get('parameters', {}).get('temperature', 1.0)}")
|
||||
print(f" - Max Tokens: {preset_config.get('parameters', {}).get('max_tokens', 30000)}")
|
||||
print(f" - Request Timeout: {preset_config.get('parameters', {}).get('request_timeout', 60)}s")
|
||||
print(f"{'~'*80}")
|
||||
|
||||
# ✅ 验证 API Key
|
||||
if not api_config.get('api_key'):
|
||||
print(f"[ChatWorkflow-Stream] ❌ 错误: API Key 为空!")
|
||||
print(f" - 请检查配置文件中是否保存了 API Key")
|
||||
print(f" - Profile ID: {request_data.get('currentProfile', {}).get('id', 'N/A')}")
|
||||
raise Exception("API Key 未配置,请先在 API 配置页面保存密钥")
|
||||
|
||||
generated_content = ""
|
||||
token_usage = {}
|
||||
duration = 0
|
||||
chunk_count = 0
|
||||
start_time = time.time()
|
||||
|
||||
print(f"[ChatWorkflow-Stream] 📡 正在调用 llm_client.stream_chat()...")
|
||||
|
||||
try:
|
||||
async for chunk_dict in self.llm_client.stream_chat(
|
||||
messages=prompt_messages,
|
||||
api_url=api_config.get("api_url", ""),
|
||||
api_key=api_config.get("api_key", ""),
|
||||
model=api_config.get("model", ""),
|
||||
temperature=preset_config.get("parameters", {}).get("temperature", 1.0),
|
||||
max_tokens=preset_config.get("parameters", {}).get("max_tokens", 30000),
|
||||
request_timeout=preset_config.get("parameters", {}).get("request_timeout", 60) # ✅ 传递超时时间
|
||||
):
|
||||
# ✅ 处理 LLM 返回的字典格式数据
|
||||
if isinstance(chunk_dict, dict):
|
||||
if chunk_dict.get("type") == "chunk":
|
||||
chunk_content = chunk_dict.get("content", "")
|
||||
elif chunk_dict.get("type") == "usage":
|
||||
# 跳过 usage 信息,不拼接到内容中
|
||||
continue
|
||||
else:
|
||||
# 兼容旧格式或未知类型,尝试直接获取 content
|
||||
chunk_content = chunk_dict.get("content", str(chunk_dict))
|
||||
else:
|
||||
# 如果直接返回字符串(兼容情况)
|
||||
chunk_content = str(chunk_dict)
|
||||
|
||||
# ✅ 拼接纯文本内容
|
||||
generated_content += chunk_content
|
||||
chunk_count += 1
|
||||
|
||||
# 第一个 chunk 到达时记录
|
||||
if chunk_count == 1:
|
||||
first_chunk_time = time.time()
|
||||
print(f"[ChatWorkflow-Stream] ✨ 收到第一个 chunk (耗时: {first_chunk_time - start_time:.2f}s)")
|
||||
|
||||
# 每20个chunk记录一次进度
|
||||
if chunk_count % 20 == 0:
|
||||
elapsed = time.time() - start_time
|
||||
print(f"[ChatWorkflow-Stream] 📊 已接收 {chunk_count} 个 chunks, 当前长度: {len(generated_content)}, 耗时: {elapsed:.2f}s")
|
||||
|
||||
# ✅ 调用回调函数发送纯文本 chunk
|
||||
await on_chunk(chunk_content)
|
||||
|
||||
except Exception as stream_error:
|
||||
elapsed = time.time() - start_time
|
||||
print(f"[ChatWorkflow-Stream] ❌ 流式调用失败 (耗时: {elapsed:.2f}s)")
|
||||
print(f" - 错误类型: {type(stream_error).__name__}")
|
||||
print(f" - 错误信息: {str(stream_error)}")
|
||||
raise
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
print(f"{'~'*80}")
|
||||
print(f"[ChatWorkflow-Stream] ✅ LLM 流式调用完成")
|
||||
print(f" - 总 Chunks: {chunk_count}")
|
||||
print(f" - 内容长度: {len(generated_content)}")
|
||||
print(f" - 总耗时: {elapsed:.2f}s")
|
||||
print(f" - 平均速度: {len(generated_content)/elapsed if elapsed > 0 else 0:.0f} chars/s")
|
||||
print(f"{'#'*80}\n")
|
||||
|
||||
# ✅ 第6.5步:应用 AI 输出的正则规则
|
||||
processed_ai_output = regex_service.apply_rules_by_placement(
|
||||
text=generated_content,
|
||||
placement=RegexPlacement.AI_OUTPUT.value,
|
||||
character_name=current_role,
|
||||
preset_name=preset_name,
|
||||
message_depth=0,
|
||||
is_for_llm=False, # ✅ AI输出是显示给用户的
|
||||
is_markdown_rendered=False
|
||||
)
|
||||
|
||||
if processed_ai_output != generated_content:
|
||||
print(f"[Regex] ✅ 已应用 AI 输出正则规则")
|
||||
generated_content = processed_ai_output
|
||||
|
||||
# === 第7步:记录 Token 使用(估算) ===
|
||||
chat_id = f"{current_role}/{request_data.get('currentChat', '')}"
|
||||
floor = request_data.get("floor", 0)
|
||||
|
||||
try:
|
||||
try:
|
||||
from backend.services.token_usage_service import token_usage_service
|
||||
from backend.models.internal import TokenUsageStatus
|
||||
except ImportError:
|
||||
from services.token_usage_service import token_usage_service
|
||||
from models.internal import TokenUsageStatus
|
||||
|
||||
# 估算 token 数量
|
||||
prompt_tokens = len(str(prompt_messages)) // 4
|
||||
completion_tokens = len(generated_content) // 4
|
||||
|
||||
await token_usage_service.record_usage(
|
||||
chat_id=chat_id,
|
||||
role_name=current_role,
|
||||
chat_name=request_data.get('currentChat', ''),
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=prompt_tokens + completion_tokens,
|
||||
status=TokenUsageStatus.COMPLETED,
|
||||
floor=floor + 1,
|
||||
duration=duration,
|
||||
model=api_config.get("model"),
|
||||
api_provider="openai",
|
||||
api_url=api_config.get("api_url") # ✅ 记录 API URL
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[ChatWorkflow-Stream] 记录 Token 使用失败: {e}")
|
||||
|
||||
# === 第8步:启动异步并行任务 ===
|
||||
image_task_id = None
|
||||
table_task_id = None
|
||||
|
||||
options = request_data.get("options", {})
|
||||
if options.get("imageWorkflow", False):
|
||||
import uuid
|
||||
image_task_id = f"img_{uuid.uuid4().hex[:8]}"
|
||||
await task_queue_manager.add_task(image_task_id, TaskType.IMAGE_WORKFLOW, chat_id)
|
||||
|
||||
if options.get("dynamicTable", False):
|
||||
import uuid
|
||||
table_task_id = f"tbl_{uuid.uuid4().hex[:8]}"
|
||||
await task_queue_manager.add_task(table_task_id, TaskType.DYNAMIC_TABLE, chat_id)
|
||||
|
||||
await self._start_parallel_tasks(request_data, generated_content, image_task_id, table_task_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"content": generated_content,
|
||||
"error": None,
|
||||
"activeEntries": active_entries,
|
||||
"taskIds": {
|
||||
"imageWorkflow": image_task_id,
|
||||
"dynamicTable": table_task_id
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ChatWorkflow-Stream] 错误: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {
|
||||
"success": False,
|
||||
"content": "",
|
||||
"error": f"工作流执行失败: {str(e)}"
|
||||
}
|
||||
"""处理流式聊天请求 – delegates to WorkflowEngine.run_turn with callbacks."""
|
||||
result = await workflow_engine.run_turn(
|
||||
request_data,
|
||||
stream=True,
|
||||
on_chunk=on_chunk,
|
||||
on_worldbook_active=on_worldbook_active,
|
||||
on_tasks_created=on_tasks_created,
|
||||
)
|
||||
return {
|
||||
"success": result.success,
|
||||
"content": result.content,
|
||||
"error": result.error,
|
||||
"activeEntries": result.active_entries,
|
||||
"taskIds": result.task_ids,
|
||||
"engineRunId": result.run_id,
|
||||
"workflowTemplateId": result.workflow_template_id,
|
||||
}
|
||||
|
||||
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()
|
||||
105
backend/services/state_machine_runner.py
Normal file
105
backend/services/state_machine_runner.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
JSON state machine runner for workflow templates.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
try:
|
||||
from backend.models.agent import RunEvent, RunEventType, TurnContext, WorkflowRun, RunStatus
|
||||
from backend.services.tool_registry import ToolRegistry
|
||||
except ImportError:
|
||||
from models.agent import RunEvent, RunEventType, TurnContext, WorkflowRun, RunStatus
|
||||
from services.tool_registry import ToolRegistry
|
||||
|
||||
|
||||
class StateMachineRunner:
|
||||
def __init__(
|
||||
self,
|
||||
definition: Dict[str, Any],
|
||||
registry: ToolRegistry,
|
||||
*,
|
||||
on_event: Optional[Callable[[RunEvent], None]] = None,
|
||||
) -> None:
|
||||
self.definition = definition
|
||||
self.registry = registry
|
||||
self.on_event = on_event
|
||||
self.states: Dict[str, Dict[str, Any]] = definition.get("states", {})
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: Path, registry: ToolRegistry, **kwargs) -> "StateMachineRunner":
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
definition = json.load(f)
|
||||
return cls(definition, registry, **kwargs)
|
||||
|
||||
def _emit(self, run: WorkflowRun, event_type: RunEventType, **payload: Any) -> RunEvent:
|
||||
event = RunEvent(
|
||||
run_id=run.id,
|
||||
type=event_type,
|
||||
state=run.current_state,
|
||||
tool=payload.pop("tool", None),
|
||||
payload=payload,
|
||||
)
|
||||
if self.on_event:
|
||||
self.on_event(event)
|
||||
return event
|
||||
|
||||
async def run(self, run: WorkflowRun, ctx: TurnContext) -> List[RunEvent]:
|
||||
events: List[RunEvent] = []
|
||||
original_on_event = self.on_event
|
||||
|
||||
def collect(event: RunEvent) -> None:
|
||||
events.append(event)
|
||||
if original_on_event:
|
||||
original_on_event(event)
|
||||
|
||||
self.on_event = collect
|
||||
|
||||
initial = self.definition.get("initial")
|
||||
if not initial:
|
||||
raise ValueError("State machine missing 'initial' state")
|
||||
|
||||
current = initial
|
||||
run.status = RunStatus.RUNNING
|
||||
|
||||
try:
|
||||
while current:
|
||||
state_def = self.states.get(current)
|
||||
if not state_def:
|
||||
raise ValueError(f"Unknown state: {current}")
|
||||
|
||||
run.current_state = current
|
||||
events.append(self._emit(run, RunEventType.STATE_ENTER, state=current))
|
||||
|
||||
tool_name = state_def.get("tool")
|
||||
if tool_name:
|
||||
events.append(self._emit(run, RunEventType.TOOL_START, tool=tool_name))
|
||||
await self.registry.execute(tool_name, ctx)
|
||||
events.append(
|
||||
self._emit(
|
||||
run,
|
||||
RunEventType.TOOL_END,
|
||||
tool=tool_name,
|
||||
success=True,
|
||||
)
|
||||
)
|
||||
|
||||
current = state_def.get("next")
|
||||
if current == "end" or current is None:
|
||||
break
|
||||
|
||||
run.status = RunStatus.COMPLETED
|
||||
run.result_content = ctx.generated_content
|
||||
events.append(self._emit(run, RunEventType.COMPLETE))
|
||||
except Exception as exc:
|
||||
run.status = RunStatus.FAILED
|
||||
run.error = str(exc)
|
||||
ctx.error = str(exc)
|
||||
events.append(self._emit(run, RunEventType.ERROR, message=str(exc)))
|
||||
raise
|
||||
finally:
|
||||
self.on_event = original_on_event
|
||||
|
||||
return events
|
||||
188
backend/services/studio_context_service.py
Normal file
188
backend/services/studio_context_service.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
Assemble Studio run prompt context from pipeline snapshot, workflow variables,
|
||||
and node outputs (R2). Does not include full chat history.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from models.studio_models import (
|
||||
PipelineDefinition,
|
||||
PromptBlock,
|
||||
StudioNode,
|
||||
StudioNodeRunState,
|
||||
StudioRun,
|
||||
)
|
||||
|
||||
_NODE_OUTPUT_REF = re.compile(r"^([^.]+)\.output$")
|
||||
|
||||
AUTO_BLOCK_SPECS = (
|
||||
("currentProduct", "目前产物", "auto"),
|
||||
("thinkingFlow", "思考流程", "auto"),
|
||||
("coreGoal", "核心目的", "auto"),
|
||||
("scoringCriteria", "评价标准与优化建议", "auto"),
|
||||
)
|
||||
|
||||
|
||||
def _find_node(pipeline: PipelineDefinition, node_id: str) -> Optional[StudioNode]:
|
||||
for node in pipeline.nodes:
|
||||
if node.id == node_id:
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
def _state_map(run: StudioRun) -> Dict[str, StudioNodeRunState]:
|
||||
return {s.nodeId: s for s in run.nodeStates}
|
||||
|
||||
|
||||
def _format_draft(draft: Optional[Dict[str, Any]]) -> str:
|
||||
if not draft:
|
||||
return "(暂无内容)"
|
||||
for key in ("entryContent", "content", "text", "body"):
|
||||
value = draft.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return json.dumps(draft, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _format_scoring(config: Dict[str, Any]) -> str:
|
||||
scoring = config.get("scoring") or {}
|
||||
if not scoring.get("enabled", True):
|
||||
return "(本步骤未启用评价)"
|
||||
dimensions = scoring.get("dimensions") or []
|
||||
if not dimensions:
|
||||
rubric = scoring.get("rubric")
|
||||
if rubric:
|
||||
return str(rubric).strip()
|
||||
return "(未配置评价维度)"
|
||||
lines: List[str] = []
|
||||
for dim in dimensions:
|
||||
name = dim.get("name") or dim.get("id") or "维度"
|
||||
criteria = (dim.get("criteria") or "").strip()
|
||||
lines.append(f"- {name}:{criteria}" if criteria else f"- {name}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _resolve_workflow_ref(ref: str, workflow_variables: Dict[str, Any]) -> str:
|
||||
value = workflow_variables.get(ref)
|
||||
if value is None:
|
||||
return "(尚未可用)"
|
||||
if isinstance(value, str):
|
||||
return value.strip() or "(空)"
|
||||
return json.dumps(value, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _resolve_node_output_ref(
|
||||
ref: str,
|
||||
pipeline: PipelineDefinition,
|
||||
state_by_id: Dict[str, StudioNodeRunState],
|
||||
) -> str:
|
||||
match = _NODE_OUTPUT_REF.match(ref)
|
||||
if not match:
|
||||
return f"(无法解析引用:{ref})"
|
||||
node_id = match.group(1)
|
||||
source_node = _find_node(pipeline, node_id)
|
||||
source_state = state_by_id.get(node_id)
|
||||
label = source_node.displayName if source_node else node_id
|
||||
if not source_state or source_state.status != "completed":
|
||||
return f"(前序步骤「{label}」尚未完成)"
|
||||
return _format_draft(source_state.lastDraft)
|
||||
|
||||
|
||||
def _auto_block_content(
|
||||
block_id: str,
|
||||
node: StudioNode,
|
||||
node_state: Optional[StudioNodeRunState],
|
||||
) -> str:
|
||||
config = node.config or {}
|
||||
if block_id == "currentProduct":
|
||||
return _format_draft(node_state.lastDraft if node_state else None)
|
||||
if block_id == "thinkingFlow":
|
||||
return (config.get("thinkingPrompt") or "").strip() or "(未配置思考流程)"
|
||||
if block_id == "coreGoal":
|
||||
return (config.get("stepGoal") or "").strip() or "(未配置步骤目标)"
|
||||
if block_id == "scoringCriteria":
|
||||
return _format_scoring(config)
|
||||
return ""
|
||||
|
||||
|
||||
def assemble_prompt_blocks(run: StudioRun, node_id: str) -> List[PromptBlock]:
|
||||
"""
|
||||
Build ordered prompt blocks for a worldbook step from inputs[].ref,
|
||||
workflow variables, node outputs, and auto-injected context items.
|
||||
"""
|
||||
pipeline = run.pipelineSnapshot
|
||||
node = _find_node(pipeline, node_id)
|
||||
if not node:
|
||||
return []
|
||||
|
||||
if node.skillId != "studio.worldbook_entry":
|
||||
return []
|
||||
|
||||
state_by_id = _state_map(run)
|
||||
node_state = state_by_id.get(node_id)
|
||||
workflow_variables = dict(run.workflowVariables or {})
|
||||
blocks: List[PromptBlock] = []
|
||||
seen_ids: set[str] = set()
|
||||
|
||||
def append_block(
|
||||
block_id: str,
|
||||
label: str,
|
||||
content: str,
|
||||
source: str,
|
||||
) -> None:
|
||||
if block_id in seen_ids:
|
||||
return
|
||||
seen_ids.add(block_id)
|
||||
blocks.append(
|
||||
PromptBlock(
|
||||
id=block_id,
|
||||
label=label,
|
||||
content=content,
|
||||
source=source,
|
||||
)
|
||||
)
|
||||
|
||||
for inp in node.inputs or []:
|
||||
ref = (inp.ref or "").strip()
|
||||
if not ref:
|
||||
continue
|
||||
label = (inp.label or ref).strip()
|
||||
block_id = f"ref:{ref}"
|
||||
|
||||
if ref.startswith("workflow."):
|
||||
content = _resolve_workflow_ref(ref, workflow_variables)
|
||||
if inp.optional and content in ("(尚未可用)", "(空)"):
|
||||
continue
|
||||
append_block(block_id, label, content, "workflow")
|
||||
continue
|
||||
|
||||
if _NODE_OUTPUT_REF.match(ref):
|
||||
content = _resolve_node_output_ref(ref, pipeline, state_by_id)
|
||||
if inp.optional and content.startswith("(前序步骤"):
|
||||
continue
|
||||
append_block(block_id, label, content, "manual")
|
||||
continue
|
||||
|
||||
append_block(block_id, label, f"(未知引用类型:{ref})", "manual")
|
||||
|
||||
for block_id, label, source in AUTO_BLOCK_SPECS:
|
||||
content = _auto_block_content(block_id, node, node_state)
|
||||
append_block(block_id, label, content, source)
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def store_context_on_run(run: StudioRun, node_id: Optional[str]) -> StudioRun:
|
||||
"""Attach assembled prompt blocks to run for debug / frontend display."""
|
||||
if not node_id:
|
||||
return run.model_copy(update={"lastPromptBlocks": []})
|
||||
|
||||
node = _find_node(run.pipelineSnapshot, node_id)
|
||||
if not node or node.skillId != "studio.worldbook_entry":
|
||||
return run.model_copy(update={"lastPromptBlocks": []})
|
||||
|
||||
blocks = assemble_prompt_blocks(run, node_id)
|
||||
return run.model_copy(update={"lastPromptBlocks": blocks})
|
||||
456
backend/services/studio_project_service.py
Normal file
456
backend/services/studio_project_service.py
Normal file
@@ -0,0 +1,456 @@
|
||||
"""
|
||||
Load/save Studio projects and skill templates from data/agent/.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.config import settings
|
||||
from models.studio_models import (
|
||||
CreateStudioProjectRequest,
|
||||
PipelineDefinition,
|
||||
StudioProject,
|
||||
StudioProjectMeta,
|
||||
StudioProjectSummary,
|
||||
WorkflowTemplateSummary,
|
||||
WorkflowVariablesResponse,
|
||||
WorkflowVariableDef,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TEMPLATE_ID = "builtin.studio.example"
|
||||
|
||||
POSITION_STRING_MAP = {
|
||||
"after_char": 0,
|
||||
"before_char": 1,
|
||||
"before_example": 2,
|
||||
"after_example": 3,
|
||||
"system": 4,
|
||||
"as_system": 5,
|
||||
"depth": 6,
|
||||
"macro": 7,
|
||||
}
|
||||
|
||||
ACTIVATION_LEGACY_MAP = {
|
||||
"normal": "permanent",
|
||||
"constant": "permanent",
|
||||
"selective": "keyword",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_position(value: Any) -> int:
|
||||
if value is None:
|
||||
return 1
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
if value.isdigit():
|
||||
return int(value)
|
||||
return POSITION_STRING_MAP.get(value, 1)
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
|
||||
def _normalize_activation(value: Any) -> str:
|
||||
if not value:
|
||||
return "permanent"
|
||||
text = str(value)
|
||||
return ACTIVATION_LEGACY_MAP.get(text, text)
|
||||
|
||||
|
||||
def _migrate_scoring(scoring: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not scoring:
|
||||
return {"enabled": True, "dimensions": []}
|
||||
if scoring.get("dimensions"):
|
||||
return scoring
|
||||
rubric = scoring.get("rubric")
|
||||
if rubric:
|
||||
scoring = {**scoring}
|
||||
scoring["dimensions"] = [
|
||||
{
|
||||
"id": "default",
|
||||
"name": "综合质量",
|
||||
"criteria": rubric,
|
||||
}
|
||||
]
|
||||
scoring.pop("rubric", None)
|
||||
return scoring
|
||||
|
||||
|
||||
def _normalize_node(node: Dict[str, Any]) -> Dict[str, Any]:
|
||||
node = dict(node)
|
||||
config = dict(node.get("config") or {})
|
||||
insertion = dict(config.get("insertion") or {})
|
||||
if insertion:
|
||||
insertion["position"] = _normalize_position(insertion.get("position"))
|
||||
insertion["activationType"] = _normalize_activation(
|
||||
insertion.get("activationType")
|
||||
)
|
||||
config["insertion"] = insertion
|
||||
if "scoring" in config:
|
||||
config["scoring"] = _migrate_scoring(dict(config.get("scoring") or {}))
|
||||
node["config"] = config
|
||||
return node
|
||||
|
||||
|
||||
def _parse_node_ref(ref: str, node_ids: set[str]) -> Optional[str]:
|
||||
if not ref or not ref.endswith(".output"):
|
||||
return None
|
||||
node_id = ref[: -len(".output")]
|
||||
return node_id if node_id in node_ids else None
|
||||
|
||||
|
||||
def _build_node_dependency_edges(pipeline: Dict[str, Any]) -> List[tuple[str, str]]:
|
||||
nodes = pipeline.get("nodes") or []
|
||||
node_ids = {n["id"] for n in nodes if n.get("id")}
|
||||
edges: List[tuple[str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for node in nodes:
|
||||
to_id = node.get("id")
|
||||
if not to_id:
|
||||
continue
|
||||
for inp in node.get("inputs") or []:
|
||||
src = _parse_node_ref(inp.get("ref", ""), node_ids)
|
||||
if not src or src == to_id:
|
||||
continue
|
||||
pair = (src, to_id)
|
||||
if pair in seen:
|
||||
continue
|
||||
seen.add(pair)
|
||||
edges.append(pair)
|
||||
return edges
|
||||
|
||||
|
||||
def _detect_reference_cycles(pipeline: Dict[str, Any]) -> List[List[str]]:
|
||||
nodes = pipeline.get("nodes") or []
|
||||
node_ids = [n["id"] for n in nodes if n.get("id")]
|
||||
adj: Dict[str, List[str]] = {nid: [] for nid in node_ids}
|
||||
for src, dst in _build_node_dependency_edges(pipeline):
|
||||
adj[src].append(dst)
|
||||
|
||||
cycles: List[List[str]] = []
|
||||
visited: set[str] = set()
|
||||
stack: set[str] = set()
|
||||
path: List[str] = []
|
||||
|
||||
def dfs(node_id: str) -> None:
|
||||
visited.add(node_id)
|
||||
stack.add(node_id)
|
||||
path.append(node_id)
|
||||
for nxt in adj.get(node_id, []):
|
||||
if nxt not in visited:
|
||||
dfs(nxt)
|
||||
elif nxt in stack:
|
||||
start = path.index(nxt)
|
||||
if start >= 0:
|
||||
cycles.append(path[start:] + [nxt])
|
||||
path.pop()
|
||||
stack.discard(node_id)
|
||||
|
||||
for nid in node_ids:
|
||||
if nid not in visited:
|
||||
dfs(nid)
|
||||
return cycles
|
||||
|
||||
|
||||
def _validate_pipeline_refs(pipeline: Dict[str, Any]) -> None:
|
||||
cycles = _detect_reference_cycles(pipeline)
|
||||
if not cycles:
|
||||
return
|
||||
nodes = {n["id"]: n.get("displayName", n["id"]) for n in pipeline.get("nodes") or []}
|
||||
first = cycles[0]
|
||||
chain = " → ".join(nodes.get(nid, nid) for nid in first)
|
||||
raise ValueError(f"流水线存在循环引用:{chain}")
|
||||
|
||||
|
||||
def _normalize_pipeline_dict(pipeline: Dict[str, Any]) -> Dict[str, Any]:
|
||||
pipeline = dict(pipeline)
|
||||
nodes = pipeline.get("nodes") or []
|
||||
pipeline["nodes"] = [_normalize_node(n) for n in nodes]
|
||||
return pipeline
|
||||
|
||||
|
||||
def _read_json(path: Path) -> Any:
|
||||
with path.open("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 path.open("w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
slug = re.sub(r"[^\w\u4e00-\u9fff-]+", "-", name.strip(), flags=re.UNICODE)
|
||||
slug = re.sub(r"-+", "-", slug).strip("-").lower()
|
||||
return slug or "project"
|
||||
|
||||
|
||||
class StudioProjectService:
|
||||
@property
|
||||
def projects_root(self) -> Path:
|
||||
return settings.AGENT_STUDIO_PROJECTS_PATH
|
||||
|
||||
@property
|
||||
def templates_root(self) -> Path:
|
||||
return settings.AGENT_TEMPLATES_PATH
|
||||
|
||||
def _project_dir(self, project_id: str) -> Path:
|
||||
return self.projects_root / project_id
|
||||
|
||||
def _meta_path(self, project_id: str) -> Path:
|
||||
return self._project_dir(project_id) / "meta.json"
|
||||
|
||||
def _pipeline_path(self, project_id: str) -> Path:
|
||||
return self._project_dir(project_id) / "pipeline.json"
|
||||
|
||||
def list_projects(self) -> List[StudioProjectSummary]:
|
||||
root = self.projects_root
|
||||
if not root.exists():
|
||||
return []
|
||||
summaries: List[StudioProjectSummary] = []
|
||||
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(
|
||||
StudioProjectSummary(
|
||||
id=meta.get("id", child.name),
|
||||
name=meta.get("name", child.name),
|
||||
description=meta.get("description", ""),
|
||||
updatedAt=meta.get("updatedAt", ""),
|
||||
)
|
||||
)
|
||||
return summaries
|
||||
|
||||
def get_project(self, project_id: str) -> StudioProject:
|
||||
meta_path = self._meta_path(project_id)
|
||||
pipeline_path = self._pipeline_path(project_id)
|
||||
if not meta_path.exists() or not pipeline_path.exists():
|
||||
raise FileNotFoundError(f"Studio project not found: {project_id}")
|
||||
meta = _read_json(meta_path)
|
||||
pipeline = _normalize_pipeline_dict(_read_json(pipeline_path))
|
||||
return StudioProject(
|
||||
meta=StudioProjectMeta(**meta),
|
||||
pipeline=PipelineDefinition(**pipeline),
|
||||
)
|
||||
|
||||
def update_project_bindings(
|
||||
self,
|
||||
project_id: str,
|
||||
character_id: str,
|
||||
worldbook_id: str,
|
||||
) -> StudioProject:
|
||||
meta_path = self._meta_path(project_id)
|
||||
if not meta_path.exists():
|
||||
raise FileNotFoundError(f"Studio project not found: {project_id}")
|
||||
meta = _read_json(meta_path)
|
||||
meta["characterId"] = character_id
|
||||
meta["worldbookId"] = worldbook_id
|
||||
meta["updatedAt"] = datetime.now().isoformat()
|
||||
_write_json(meta_path, meta)
|
||||
return self.get_project(project_id)
|
||||
|
||||
def update_project_meta(
|
||||
self,
|
||||
project_id: str,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> StudioProject:
|
||||
meta_path = self._meta_path(project_id)
|
||||
if not meta_path.exists():
|
||||
raise FileNotFoundError(f"Studio project not found: {project_id}")
|
||||
meta = _read_json(meta_path)
|
||||
if name is not None:
|
||||
meta["name"] = name.strip()
|
||||
if description is not None:
|
||||
meta["description"] = description
|
||||
meta["updatedAt"] = datetime.now().isoformat()
|
||||
_write_json(meta_path, meta)
|
||||
return self.get_project(project_id)
|
||||
|
||||
def save_pipeline(self, project_id: str, pipeline: PipelineDefinition) -> StudioProject:
|
||||
meta_path = self._meta_path(project_id)
|
||||
if not meta_path.exists():
|
||||
raise FileNotFoundError(f"Studio project not found: {project_id}")
|
||||
normalized = _normalize_pipeline_dict(pipeline.model_dump(exclude_none=True))
|
||||
_validate_pipeline_refs(normalized)
|
||||
meta = _read_json(meta_path)
|
||||
now = datetime.now().isoformat()
|
||||
meta["updatedAt"] = now
|
||||
_write_json(meta_path, meta)
|
||||
_write_json(self._pipeline_path(project_id), normalized)
|
||||
return self.get_project(project_id)
|
||||
|
||||
def list_workflow_templates(self) -> List[WorkflowTemplateSummary]:
|
||||
root = self.templates_root
|
||||
if not root.exists():
|
||||
return []
|
||||
summaries: List[WorkflowTemplateSummary] = []
|
||||
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(
|
||||
WorkflowTemplateSummary(
|
||||
id=meta.get("id", child.name),
|
||||
name=meta.get("name", child.name),
|
||||
description=meta.get("description", ""),
|
||||
)
|
||||
)
|
||||
return summaries
|
||||
|
||||
def get_workflow_variables(self, project_id: Optional[str] = None) -> WorkflowVariablesResponse:
|
||||
path = settings.AGENT_WORKFLOW_VARIABLES_FILE
|
||||
if path.exists():
|
||||
raw = _read_json(path)
|
||||
else:
|
||||
raw = {
|
||||
"builtIn": [
|
||||
{"ref": "workflow.goal", "label": "工作流目标文本", "description": ""},
|
||||
{"ref": "workflow.boundWorldbook", "label": "绑定世界书摘要", "description": ""},
|
||||
{"ref": "workflow.boundCharacter", "label": "绑定角色卡摘要", "description": ""},
|
||||
],
|
||||
"dynamicSuffixes": [
|
||||
{"suffix": ".output", "labelPattern": "{displayName} · 上轮产物"},
|
||||
{"suffix": ".entryDraft", "labelPattern": "{displayName} · 条目草稿"},
|
||||
],
|
||||
}
|
||||
built_in = [
|
||||
WorkflowVariableDef(**item) for item in raw.get("builtIn", [])
|
||||
]
|
||||
dynamic: List[WorkflowVariableDef] = []
|
||||
suffixes = raw.get("dynamicSuffixes") or [
|
||||
{"suffix": ".output", "labelPattern": "{displayName} · 世界书条目"},
|
||||
]
|
||||
if project_id:
|
||||
try:
|
||||
project = self.get_project(project_id)
|
||||
for node in project.pipeline.nodes:
|
||||
if not node.enabled:
|
||||
continue
|
||||
if node.skillId != "studio.worldbook_entry":
|
||||
continue
|
||||
for suffix_def in suffixes:
|
||||
suffix = suffix_def.get("suffix", ".output")
|
||||
if suffix != ".output":
|
||||
continue
|
||||
pattern = suffix_def.get(
|
||||
"labelPattern", "{displayName} · 世界书条目"
|
||||
)
|
||||
ref = f"{node.id}{suffix}"
|
||||
label = pattern.replace("{displayName}", node.displayName)
|
||||
dynamic.append(
|
||||
WorkflowVariableDef(ref=ref, label=label, description="")
|
||||
)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return WorkflowVariablesResponse(builtIn=built_in, dynamic=dynamic)
|
||||
|
||||
def get_skill_templates(self) -> Dict[str, Any]:
|
||||
path = settings.AGENT_SKILL_TEMPLATES_FILE
|
||||
if not path.exists():
|
||||
raise FileNotFoundError("skill_templates.json not found")
|
||||
return _read_json(path)
|
||||
|
||||
def get_niches(self) -> Dict[str, Any]:
|
||||
path = settings.AGENT_NICHES_FILE
|
||||
if not path.exists():
|
||||
return {"niches": []}
|
||||
return _read_json(path)
|
||||
|
||||
def _unique_project_id(self, base_id: str) -> str:
|
||||
candidate = base_id
|
||||
n = 1
|
||||
while self._project_dir(candidate).exists():
|
||||
candidate = f"{base_id}-{n}"
|
||||
n += 1
|
||||
return candidate
|
||||
|
||||
def create_project(self, req: CreateStudioProjectRequest) -> StudioProject:
|
||||
template_id = req.template_id or DEFAULT_TEMPLATE_ID
|
||||
template_dir = self.templates_root / template_id
|
||||
if not template_dir.exists():
|
||||
raise FileNotFoundError(f"Studio template not found: {template_id}")
|
||||
|
||||
base_id = req.project_id or _slugify(req.name)
|
||||
project_id = self._unique_project_id(base_id)
|
||||
dest = self._project_dir(project_id)
|
||||
dest.mkdir(parents=True, exist_ok=False)
|
||||
|
||||
template_meta = _read_json(template_dir / "meta.json")
|
||||
template_pipeline = _read_json(template_dir / "pipeline.json")
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
meta = {
|
||||
"id": project_id,
|
||||
"name": req.name,
|
||||
"description": template_meta.get("description", ""),
|
||||
"templateId": template_id,
|
||||
"characterId": None,
|
||||
"worldbookId": None,
|
||||
"createdAt": now,
|
||||
"updatedAt": now,
|
||||
}
|
||||
_write_json(dest / "meta.json", meta)
|
||||
_write_json(dest / "pipeline.json", _normalize_pipeline_dict(template_pipeline))
|
||||
return self.get_project(project_id)
|
||||
|
||||
def delete_project(self, project_id: str) -> None:
|
||||
project_dir = self._project_dir(project_id)
|
||||
if not project_dir.exists():
|
||||
raise FileNotFoundError(f"Studio project not found: {project_id}")
|
||||
shutil.rmtree(project_dir)
|
||||
runs_dir = settings.AGENT_STUDIO_RUNS_PATH / project_id
|
||||
if runs_dir.exists():
|
||||
shutil.rmtree(runs_dir)
|
||||
|
||||
def ensure_default_project(self) -> None:
|
||||
"""Copy example template into default project if missing."""
|
||||
default_dir = self._project_dir("default")
|
||||
if default_dir.exists():
|
||||
return
|
||||
template_dir = self.templates_root / DEFAULT_TEMPLATE_ID
|
||||
if not template_dir.exists():
|
||||
logger.warning("builtin.studio.example template missing; skip default project seed")
|
||||
return
|
||||
default_dir.mkdir(parents=True, exist_ok=True)
|
||||
template_meta = _read_json(template_dir / "meta.json")
|
||||
now = datetime.now().isoformat()
|
||||
meta = {
|
||||
"id": "default",
|
||||
"name": "示例角色项目",
|
||||
"description": template_meta.get("description", ""),
|
||||
"templateId": DEFAULT_TEMPLATE_ID,
|
||||
"characterId": None,
|
||||
"worldbookId": None,
|
||||
"createdAt": now,
|
||||
"updatedAt": now,
|
||||
}
|
||||
_write_json(default_dir / "meta.json", meta)
|
||||
shutil.copy2(template_dir / "pipeline.json", default_dir / "pipeline.json")
|
||||
|
||||
|
||||
studio_project_service = StudioProjectService()
|
||||
|
||||
try:
|
||||
studio_project_service.ensure_default_project()
|
||||
except Exception as _seed_err:
|
||||
logger.warning("Studio default project seed skipped: %s", _seed_err)
|
||||
1104
backend/services/studio_run_service.py
Normal file
1104
backend/services/studio_run_service.py
Normal file
File diff suppressed because it is too large
Load Diff
427
backend/services/studio_step_respond.py
Normal file
427
backend/services/studio_step_respond.py
Normal file
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
Studio worldbook step LLM responder (R3/R4).
|
||||
|
||||
Assembles R2 context blocks + short step dialogue, calls LLM for structured JSON,
|
||||
returns thinking, draft, questions, and evaluation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
||||
|
||||
from models.studio_models import (
|
||||
LastToolResponse,
|
||||
PromptBlock,
|
||||
StudioNode,
|
||||
StepMessage,
|
||||
ToolQuestionOption,
|
||||
)
|
||||
from services.studio_context_service import assemble_prompt_blocks
|
||||
from utils.llm_client import LLMClient
|
||||
|
||||
_llm_client = LLMClient()
|
||||
|
||||
_JSON_FENCE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
|
||||
|
||||
|
||||
def resolve_api_config(
|
||||
profile_id: Optional[str],
|
||||
api_config: Optional[Dict[str, str]],
|
||||
) -> Dict[str, str]:
|
||||
"""Merge frontend apiConfig with stored profile mainLLM key (same as chat WS)."""
|
||||
resolved = dict(api_config or {})
|
||||
if profile_id:
|
||||
try:
|
||||
try:
|
||||
from api.routes.apiConfigRoute import load_profile
|
||||
except ImportError:
|
||||
from backend.api.routes.apiConfigRoute import load_profile
|
||||
|
||||
profile = load_profile(profile_id)
|
||||
if profile:
|
||||
main_llm = profile.get("apis", {}).get("mainLLM", {})
|
||||
if main_llm.get("apiUrl") and not resolved.get("api_url"):
|
||||
resolved["api_url"] = main_llm.get("apiUrl", "")
|
||||
if main_llm.get("model") and not resolved.get("model"):
|
||||
resolved["model"] = main_llm.get("model", "")
|
||||
api_key = main_llm.get("apiKey", "")
|
||||
if api_key:
|
||||
resolved["api_key"] = api_key
|
||||
except Exception as exc:
|
||||
print(f"[StudioStepRespond] 加载 API 配置失败: {exc}")
|
||||
return resolved
|
||||
|
||||
|
||||
def _blocks_to_context_text(blocks: List[PromptBlock]) -> str:
|
||||
sections: List[str] = []
|
||||
for block in blocks:
|
||||
sections.append(f"## {block.label}\n{block.content}")
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
def _build_system_prompt(node: StudioNode) -> str:
|
||||
insertion = (node.config or {}).get("insertion") or {}
|
||||
key = insertion.get("key") or "(未配置关键词)"
|
||||
comment = insertion.get("comment") or ""
|
||||
|
||||
return f"""你是 Studio 创作助手,负责为当前流水线步骤生成或修订世界书条目草稿。
|
||||
|
||||
当前步骤:{node.displayName}
|
||||
目标关键词:{key}
|
||||
备注:{comment or "(无)"}
|
||||
|
||||
你必须只输出一个 JSON 对象(不要 markdown 代码块外的其他文字),字段如下:
|
||||
{{
|
||||
"thinking": "你的内部思考过程(逐步推理,中文)",
|
||||
"currentProduct": "世界书条目正文(纯文本或 Markdown,可直接写入条目 content)",
|
||||
"questions": [
|
||||
{{
|
||||
"question": "需要用户澄清的问题",
|
||||
"options": ["选项A", "选项B", "选项C"]
|
||||
}}
|
||||
],
|
||||
"evaluation": "对照评价维度的自检与优化建议(中文,面向用户)"
|
||||
}}
|
||||
|
||||
规则:
|
||||
1. currentProduct 必须是完整、可注入世界书的条目正文。
|
||||
2. questions 为 0–3 条;每条至少 2 个 options;若无需澄清则 questions 为空数组。
|
||||
3. evaluation 需引用上下文中的评价标准,给出具体、可操作的反馈。
|
||||
4. 若用户要求修改,在 currentProduct 中输出修订后的完整条目,而非仅说明改了什么。
|
||||
5. 全部字段使用中文(专有名词除外)。"""
|
||||
|
||||
|
||||
def _dialogue_to_langchain(
|
||||
step_messages: List[StepMessage],
|
||||
) -> List[Any]:
|
||||
messages: List[Any] = []
|
||||
for msg in step_messages:
|
||||
if msg.role == "user":
|
||||
messages.append(HumanMessage(content=msg.content))
|
||||
elif msg.role == "assistant":
|
||||
messages.append(AIMessage(content=msg.content))
|
||||
return messages
|
||||
|
||||
|
||||
def _build_llm_messages(
|
||||
node: StudioNode,
|
||||
prompt_blocks: List[PromptBlock],
|
||||
step_messages: List[StepMessage],
|
||||
user_message: str,
|
||||
) -> List[Any]:
|
||||
context_text = _blocks_to_context_text(prompt_blocks)
|
||||
system_prompt = _build_system_prompt(node)
|
||||
|
||||
messages: List[Any] = [SystemMessage(content=system_prompt)]
|
||||
messages.append(
|
||||
HumanMessage(
|
||||
content=f"以下为当前步骤上下文(不含完整聊天历史):\n\n{context_text}"
|
||||
)
|
||||
)
|
||||
messages.extend(_dialogue_to_langchain(step_messages))
|
||||
messages.append(HumanMessage(content=user_message))
|
||||
return messages
|
||||
|
||||
|
||||
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 _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 _decode_json_string_partial(raw: str) -> str:
|
||||
"""Decode a possibly incomplete JSON string body (no surrounding quotes)."""
|
||||
out: List[str] = []
|
||||
i = 0
|
||||
while i < len(raw):
|
||||
if raw[i] == "\\" and i + 1 < len(raw):
|
||||
nxt = raw[i + 1]
|
||||
if nxt == "n":
|
||||
out.append("\n")
|
||||
elif nxt == "t":
|
||||
out.append("\t")
|
||||
elif nxt == "r":
|
||||
out.append("\r")
|
||||
elif nxt == '"':
|
||||
out.append('"')
|
||||
elif nxt == "\\":
|
||||
out.append("\\")
|
||||
elif nxt == "/":
|
||||
out.append("/")
|
||||
elif nxt == "u" and i + 5 < len(raw):
|
||||
try:
|
||||
out.append(chr(int(raw[i + 2 : i + 6], 16)))
|
||||
i += 6
|
||||
continue
|
||||
except ValueError:
|
||||
out.append(nxt)
|
||||
else:
|
||||
out.append(nxt)
|
||||
i += 2
|
||||
else:
|
||||
out.append(raw[i])
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _extract_partial_thinking(raw: str) -> Optional[str]:
|
||||
"""Best-effort extraction of thinking field from incomplete JSON stream."""
|
||||
marker = '"thinking"'
|
||||
idx = raw.find(marker)
|
||||
if idx < 0:
|
||||
return None
|
||||
|
||||
colon = raw.find(":", idx + len(marker))
|
||||
if colon < 0:
|
||||
return None
|
||||
|
||||
rest = raw[colon + 1 :].lstrip()
|
||||
if not rest.startswith('"'):
|
||||
return None
|
||||
|
||||
body_start = 1
|
||||
i = body_start
|
||||
while i < len(rest):
|
||||
ch = rest[i]
|
||||
if ch == '"':
|
||||
break
|
||||
if ch == "\\":
|
||||
i += 2
|
||||
continue
|
||||
i += 1
|
||||
|
||||
partial = rest[body_start:i]
|
||||
if not partial:
|
||||
return None
|
||||
return _decode_json_string_partial(partial)
|
||||
|
||||
|
||||
def _normalize_draft(
|
||||
current_product: Any,
|
||||
node: StudioNode,
|
||||
existing_draft: Optional[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
draft: Dict[str, Any] = dict(existing_draft or {})
|
||||
insertion = (node.config or {}).get("insertion") or {}
|
||||
|
||||
if isinstance(current_product, str):
|
||||
draft["entryContent"] = current_product.strip()
|
||||
elif isinstance(current_product, dict):
|
||||
draft.update(current_product)
|
||||
if "entryContent" not in draft and "content" in draft:
|
||||
draft["entryContent"] = draft["content"]
|
||||
else:
|
||||
draft["entryContent"] = str(current_product)
|
||||
|
||||
if insertion.get("key"):
|
||||
draft["insertionKey"] = insertion["key"]
|
||||
if insertion.get("comment"):
|
||||
draft["insertionComment"] = insertion["comment"]
|
||||
draft["nodeId"] = node.id
|
||||
draft["displayName"] = node.displayName
|
||||
return draft
|
||||
|
||||
|
||||
def _normalize_questions(raw: Any) -> List[ToolQuestionOption]:
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
result: List[ToolQuestionOption] = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
question = (item.get("question") or "").strip()
|
||||
if not question:
|
||||
continue
|
||||
options = [
|
||||
str(o).strip()
|
||||
for o in (item.get("options") or [])
|
||||
if str(o).strip()
|
||||
]
|
||||
if len(options) < 2:
|
||||
continue
|
||||
result.append(ToolQuestionOption(question=question, options=options))
|
||||
return result[:3]
|
||||
|
||||
|
||||
def _assistant_message_text(parsed: Dict[str, Any]) -> str:
|
||||
evaluation = (parsed.get("evaluation") or "").strip()
|
||||
if evaluation:
|
||||
return evaluation
|
||||
product = parsed.get("currentProduct")
|
||||
if isinstance(product, str) and product.strip():
|
||||
preview = product.strip()
|
||||
if len(preview) > 400:
|
||||
preview = preview[:400] + "…"
|
||||
return f"已更新条目草稿:\n\n{preview}"
|
||||
return "已处理您的消息,请查看左侧目前产物。"
|
||||
|
||||
|
||||
def _build_turn_result(
|
||||
parsed: Dict[str, Any],
|
||||
*,
|
||||
node: StudioNode,
|
||||
user_message: str,
|
||||
existing_draft: Optional[Dict[str, Any]],
|
||||
) -> Tuple[Dict[str, Any], LastToolResponse, StepMessage, StepMessage]:
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
last_draft = _normalize_draft(
|
||||
parsed.get("currentProduct"),
|
||||
node,
|
||||
existing_draft,
|
||||
)
|
||||
last_tool_response = LastToolResponse(
|
||||
thinking=(parsed.get("thinking") or "").strip() or None,
|
||||
evaluation=(parsed.get("evaluation") or "").strip() or None,
|
||||
questions=_normalize_questions(parsed.get("questions")),
|
||||
generatedAt=now,
|
||||
)
|
||||
|
||||
user_step_msg = StepMessage(
|
||||
id=f"msg-{uuid.uuid4().hex[:12]}",
|
||||
role="user",
|
||||
content=user_message,
|
||||
createdAt=now,
|
||||
)
|
||||
assistant_step_msg = StepMessage(
|
||||
id=f"msg-{uuid.uuid4().hex[:12]}",
|
||||
role="assistant",
|
||||
content=_assistant_message_text(parsed),
|
||||
createdAt=now,
|
||||
)
|
||||
|
||||
return last_draft, last_tool_response, user_step_msg, assistant_step_msg
|
||||
|
||||
|
||||
async def studio_step_respond(
|
||||
*,
|
||||
node: StudioNode,
|
||||
prompt_blocks: List[PromptBlock],
|
||||
step_messages: List[StepMessage],
|
||||
user_message: str,
|
||||
existing_draft: Optional[Dict[str, Any]],
|
||||
api_config: Dict[str, str],
|
||||
stream: bool = False,
|
||||
) -> Tuple[Dict[str, Any], LastToolResponse, StepMessage, StepMessage]:
|
||||
"""
|
||||
Execute one worldbook step turn (non-streaming).
|
||||
|
||||
Returns (last_draft, last_tool_response, user_step_msg, assistant_step_msg).
|
||||
"""
|
||||
_validate_api_config(api_config)
|
||||
messages = _build_llm_messages(node, prompt_blocks, step_messages, user_message)
|
||||
model = api_config.get("model") or "gpt-4o-mini"
|
||||
|
||||
if stream:
|
||||
print("[StudioStepRespond] stream=True 应使用 studio_step_respond_stream")
|
||||
|
||||
response = await _llm_client.chat_completion(
|
||||
messages=messages,
|
||||
api_url=api_config.get("api_url", ""),
|
||||
api_key=api_config.get("api_key", ""),
|
||||
model=model,
|
||||
temperature=0.7,
|
||||
max_tokens=8000,
|
||||
request_timeout=120,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
raw_content = ""
|
||||
if isinstance(response, dict):
|
||||
raw_content = (
|
||||
response.get("choices", [{}])[0]
|
||||
.get("message", {})
|
||||
.get("content", "")
|
||||
)
|
||||
else:
|
||||
raw_content = str(response)
|
||||
|
||||
parsed = _extract_json(raw_content)
|
||||
return _build_turn_result(
|
||||
parsed,
|
||||
node=node,
|
||||
user_message=user_message,
|
||||
existing_draft=existing_draft,
|
||||
)
|
||||
|
||||
|
||||
async def studio_step_respond_stream(
|
||||
*,
|
||||
node: StudioNode,
|
||||
prompt_blocks: List[PromptBlock],
|
||||
step_messages: List[StepMessage],
|
||||
user_message: str,
|
||||
existing_draft: Optional[Dict[str, Any]],
|
||||
api_config: Dict[str, str],
|
||||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||||
"""
|
||||
Stream thinking field while LLM generates structured JSON (R4).
|
||||
|
||||
Yields:
|
||||
- {"type": "thinking_delta", "content": "..."}
|
||||
- {"type": "complete", "last_draft", "last_tool_response", "user_msg", "assistant_msg"}
|
||||
"""
|
||||
_validate_api_config(api_config)
|
||||
messages = _build_llm_messages(node, prompt_blocks, step_messages, user_message)
|
||||
model = api_config.get("model") or "gpt-4o-mini"
|
||||
|
||||
accumulated = ""
|
||||
last_thinking = ""
|
||||
|
||||
async for chunk in _llm_client.stream_chat(
|
||||
messages=messages,
|
||||
api_url=api_config.get("api_url", ""),
|
||||
api_key=api_config.get("api_key", ""),
|
||||
model=model,
|
||||
temperature=0.7,
|
||||
max_tokens=8000,
|
||||
request_timeout=120,
|
||||
):
|
||||
if chunk.get("type") != "chunk":
|
||||
continue
|
||||
accumulated += chunk.get("content") or ""
|
||||
partial = _extract_partial_thinking(accumulated)
|
||||
if partial and partial != last_thinking:
|
||||
last_thinking = partial
|
||||
yield {"type": "thinking_delta", "content": partial}
|
||||
|
||||
parsed = _extract_json(accumulated)
|
||||
last_draft, last_tool_response, user_msg, assistant_msg = _build_turn_result(
|
||||
parsed,
|
||||
node=node,
|
||||
user_message=user_message,
|
||||
existing_draft=existing_draft,
|
||||
)
|
||||
|
||||
yield {
|
||||
"type": "complete",
|
||||
"last_draft": last_draft,
|
||||
"last_tool_response": last_tool_response.model_dump(mode="json"),
|
||||
"user_msg": user_msg.model_dump(mode="json"),
|
||||
"assistant_msg": assistant_msg.model_dump(mode="json"),
|
||||
}
|
||||
49
backend/services/tool_registry.py
Normal file
49
backend/services/tool_registry.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Tool registry for workflow engine steps.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
|
||||
try:
|
||||
from backend.models.agent import TurnContext, ToolSpec
|
||||
except ImportError:
|
||||
from models.agent import TurnContext, ToolSpec
|
||||
|
||||
ToolHandler = Callable[[TurnContext], Awaitable[None]]
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._tools: Dict[str, ToolHandler] = {}
|
||||
self._specs: Dict[str, ToolSpec] = {}
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
handler: ToolHandler,
|
||||
*,
|
||||
description: str = "",
|
||||
parameters: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
self._tools[name] = handler
|
||||
self._specs[name] = ToolSpec(
|
||||
name=name,
|
||||
description=description,
|
||||
parameters=parameters or {},
|
||||
)
|
||||
|
||||
def get(self, name: str) -> ToolHandler:
|
||||
if name not in self._tools:
|
||||
raise KeyError(f"Unknown tool: {name}")
|
||||
return self._tools[name]
|
||||
|
||||
def list_specs(self) -> list[ToolSpec]:
|
||||
return list(self._specs.values())
|
||||
|
||||
async def execute(self, name: str, ctx: TurnContext) -> None:
|
||||
handler = self.get(name)
|
||||
await handler(ctx)
|
||||
|
||||
|
||||
default_tool_registry = ToolRegistry()
|
||||
1
backend/services/tools/__init__.py
Normal file
1
backend/services/tools/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Workflow chat tools package."""
|
||||
261
backend/services/tools/chat_tools.py
Normal file
261
backend/services/tools/chat_tools.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
Chat workflow tools extracted from ChatWorkflowService.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List
|
||||
|
||||
try:
|
||||
from backend.models.agent import TurnContext
|
||||
from backend.models.internal import CharacterCard, TokenUsageStatus
|
||||
from backend.models.regex_rules import RegexPlacement
|
||||
from backend.services.character_service import CharacterService
|
||||
from backend.services.regex_service import regex_service
|
||||
from backend.services.task_queue_manager import TaskType, task_queue_manager
|
||||
from backend.services.token_usage_service import token_usage_service
|
||||
from backend.core.config import settings
|
||||
except ImportError:
|
||||
from models.agent import TurnContext
|
||||
from models.internal import CharacterCard, TokenUsageStatus
|
||||
from models.regex_rules import RegexPlacement
|
||||
from services.character_service import CharacterService
|
||||
from services.regex_service import regex_service
|
||||
from services.task_queue_manager import TaskType, task_queue_manager
|
||||
from services.token_usage_service import token_usage_service
|
||||
from core.config import settings
|
||||
|
||||
|
||||
_character_service = CharacterService()
|
||||
_workflow_service = None
|
||||
|
||||
|
||||
def _get_workflow_service():
|
||||
"""Lazy init to avoid circular import with chat_workflow_service."""
|
||||
global _workflow_service
|
||||
if _workflow_service is None:
|
||||
try:
|
||||
from backend.services.chat_workflow_service import ChatWorkflowService
|
||||
except ImportError:
|
||||
from services.chat_workflow_service import ChatWorkflowService
|
||||
_workflow_service = ChatWorkflowService()
|
||||
return _workflow_service
|
||||
|
||||
|
||||
async def regex_apply_user_input(ctx: TurnContext) -> None:
|
||||
processed = regex_service.apply_rules_by_placement(
|
||||
text=ctx.user_message,
|
||||
placement=RegexPlacement.USER_INPUT.value,
|
||||
character_name=ctx.current_role,
|
||||
preset_name=ctx.preset_name,
|
||||
message_depth=0,
|
||||
is_for_llm=True,
|
||||
is_markdown_rendered=False,
|
||||
)
|
||||
if processed != ctx.user_message:
|
||||
print("[WorkflowTool] Applied user-input regex rules")
|
||||
ctx.user_message = processed
|
||||
|
||||
|
||||
async def load_character(ctx: TurnContext) -> None:
|
||||
character_data = ctx.request_data.get("characterData")
|
||||
if not character_data:
|
||||
character = _character_service.get_character_by_name(ctx.current_role)
|
||||
if not character:
|
||||
raise ValueError(f"角色 '{ctx.current_role}' 不存在")
|
||||
else:
|
||||
character = CharacterCard(**character_data)
|
||||
ctx.character = character
|
||||
print(f"[WorkflowTool] Loaded character: {character.name}")
|
||||
|
||||
|
||||
async def activate_worldbook(ctx: TurnContext) -> None:
|
||||
svc = _get_workflow_service()
|
||||
active_entries = await svc._collect_and_activate_worldbooks(
|
||||
ctx.request_data,
|
||||
ctx.character,
|
||||
)
|
||||
ctx.active_entries = active_entries
|
||||
print(f"[WorkflowTool] Activated {len(active_entries)} worldbook entries")
|
||||
|
||||
if ctx.callbacks and ctx.callbacks.on_worldbook_active:
|
||||
entries_payload = [
|
||||
entry.model_dump() if hasattr(entry, "model_dump") else entry
|
||||
for entry in active_entries
|
||||
]
|
||||
await ctx.callbacks.on_worldbook_active(entries_payload)
|
||||
|
||||
|
||||
async def load_chat_history(ctx: TurnContext) -> None:
|
||||
svc = _get_workflow_service()
|
||||
chat_history = await svc._load_chat_history(
|
||||
ctx.current_role,
|
||||
ctx.current_chat,
|
||||
)
|
||||
ctx.chat_history = chat_history
|
||||
print(f"[WorkflowTool] Loaded {len(chat_history)} history messages")
|
||||
|
||||
|
||||
async def build_prompt_messages(ctx: TurnContext) -> None:
|
||||
svc = _get_workflow_service()
|
||||
prompt_messages = svc._assemble_prompt(
|
||||
ctx.character,
|
||||
ctx.chat_history,
|
||||
ctx.user_message,
|
||||
ctx.active_entries,
|
||||
ctx.request_data,
|
||||
)
|
||||
ctx.prompt_messages = prompt_messages
|
||||
print(f"[WorkflowTool] Built {len(prompt_messages)} prompt messages")
|
||||
|
||||
|
||||
async def llm_main_reply(ctx: TurnContext) -> None:
|
||||
svc = _get_workflow_service()
|
||||
api_config = ctx.request_data.get("apiConfig", {})
|
||||
preset_config = ctx.request_data.get("presetConfig", {})
|
||||
|
||||
if ctx.stream:
|
||||
if not api_config.get("api_key"):
|
||||
raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥")
|
||||
|
||||
generated_content = ""
|
||||
chunk_count = 0
|
||||
start_time = time.time()
|
||||
|
||||
async for chunk_dict in svc.llm_client.stream_chat(
|
||||
messages=ctx.prompt_messages,
|
||||
api_url=api_config.get("api_url", ""),
|
||||
api_key=api_config.get("api_key", ""),
|
||||
model=api_config.get("model", ""),
|
||||
temperature=preset_config.get("parameters", {}).get("temperature", 1.0),
|
||||
max_tokens=preset_config.get("parameters", {}).get("max_tokens", 30000),
|
||||
request_timeout=preset_config.get("parameters", {}).get("request_timeout", 60),
|
||||
):
|
||||
if isinstance(chunk_dict, dict):
|
||||
if chunk_dict.get("type") == "chunk":
|
||||
chunk_content = chunk_dict.get("content", "")
|
||||
elif chunk_dict.get("type") == "usage":
|
||||
continue
|
||||
else:
|
||||
chunk_content = chunk_dict.get("content", str(chunk_dict))
|
||||
else:
|
||||
chunk_content = str(chunk_dict)
|
||||
|
||||
generated_content += chunk_content
|
||||
chunk_count += 1
|
||||
|
||||
if ctx.callbacks and ctx.callbacks.on_chunk:
|
||||
await ctx.callbacks.on_chunk(chunk_content)
|
||||
|
||||
ctx.duration = time.time() - start_time
|
||||
ctx.generated_content = generated_content
|
||||
ctx.token_usage = {
|
||||
"prompt_tokens": len(str(ctx.prompt_messages)) // 4,
|
||||
"completion_tokens": len(generated_content) // 4,
|
||||
"total_tokens": (len(str(ctx.prompt_messages)) // 4)
|
||||
+ (len(generated_content) // 4),
|
||||
}
|
||||
print(
|
||||
f"[WorkflowTool] Stream LLM complete: {chunk_count} chunks, "
|
||||
f"{len(generated_content)} chars"
|
||||
)
|
||||
else:
|
||||
result = await svc._generate_response(
|
||||
ctx.prompt_messages,
|
||||
api_config,
|
||||
preset_config,
|
||||
stream=False,
|
||||
)
|
||||
ctx.generated_content = result["content"]
|
||||
ctx.token_usage = result.get("usage", {})
|
||||
ctx.duration = result.get("duration", 0.0)
|
||||
print(f"[WorkflowTool] LLM complete: {len(ctx.generated_content)} chars")
|
||||
|
||||
|
||||
async def regex_apply_ai_output(ctx: TurnContext) -> None:
|
||||
processed = regex_service.apply_rules_by_placement(
|
||||
text=ctx.generated_content,
|
||||
placement=RegexPlacement.AI_OUTPUT.value,
|
||||
character_name=ctx.current_role,
|
||||
preset_name=ctx.preset_name,
|
||||
message_depth=0,
|
||||
is_for_llm=False,
|
||||
is_markdown_rendered=False,
|
||||
)
|
||||
if processed != ctx.generated_content:
|
||||
print("[WorkflowTool] Applied AI-output regex rules")
|
||||
ctx.generated_content = processed
|
||||
|
||||
|
||||
async def record_token_usage(ctx: TurnContext) -> None:
|
||||
chat_id = f"{ctx.current_role}/{ctx.current_chat}"
|
||||
floor = ctx.request_data.get("floor", 0)
|
||||
api_config = ctx.request_data.get("apiConfig", {})
|
||||
|
||||
try:
|
||||
await token_usage_service.record_usage(
|
||||
chat_id=chat_id,
|
||||
role_name=ctx.current_role,
|
||||
chat_name=ctx.current_chat,
|
||||
prompt_tokens=ctx.token_usage.get("prompt_tokens", 0),
|
||||
completion_tokens=ctx.token_usage.get("completion_tokens", 0),
|
||||
total_tokens=ctx.token_usage.get("total_tokens", 0),
|
||||
status=TokenUsageStatus.COMPLETED,
|
||||
floor=floor + 1,
|
||||
duration=ctx.duration,
|
||||
model=api_config.get("model"),
|
||||
api_provider="openai",
|
||||
api_url=api_config.get("api_url"),
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"[WorkflowTool] Token usage recording failed: {exc}")
|
||||
|
||||
|
||||
async def enqueue_parallel_tasks(ctx: TurnContext) -> None:
|
||||
chat_id = f"{ctx.current_role}/{ctx.current_chat}"
|
||||
options = ctx.request_data.get("options", {})
|
||||
image_task_id = None
|
||||
table_task_id = None
|
||||
|
||||
if options.get("imageWorkflow", False):
|
||||
image_task_id = f"img_{uuid.uuid4().hex[:8]}"
|
||||
await task_queue_manager.add_task(image_task_id, TaskType.IMAGE_WORKFLOW, chat_id)
|
||||
|
||||
if options.get("dynamicTable", False):
|
||||
table_task_id = f"tbl_{uuid.uuid4().hex[:8]}"
|
||||
await task_queue_manager.add_task(table_task_id, TaskType.DYNAMIC_TABLE, chat_id)
|
||||
|
||||
ctx.task_ids = {
|
||||
"imageWorkflow": image_task_id,
|
||||
"dynamicTable": table_task_id,
|
||||
}
|
||||
|
||||
if ctx.callbacks and ctx.callbacks.on_tasks_created:
|
||||
if image_task_id or table_task_id:
|
||||
await ctx.callbacks.on_tasks_created(ctx.task_ids)
|
||||
|
||||
# Fire-and-forget parallel workers (same as legacy service)
|
||||
svc = _get_workflow_service()
|
||||
asyncio.create_task(
|
||||
svc._start_parallel_tasks(
|
||||
ctx.request_data,
|
||||
ctx.generated_content,
|
||||
image_task_id,
|
||||
table_task_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def register_chat_tools(registry) -> None:
|
||||
"""Register all chat workflow tools on the given registry."""
|
||||
registry.register("regex_apply_user_input", regex_apply_user_input, description="Apply user-input regex")
|
||||
registry.register("load_character", load_character, description="Load character card")
|
||||
registry.register("activate_worldbook", activate_worldbook, description="Activate worldbook entries")
|
||||
registry.register("load_chat_history", load_chat_history, description="Load chat history")
|
||||
registry.register("build_prompt_messages", build_prompt_messages, description="Assemble LLM prompt")
|
||||
registry.register("llm_main_reply", llm_main_reply, description="Call main LLM (supports stream)")
|
||||
registry.register("regex_apply_ai_output", regex_apply_ai_output, description="Apply AI-output regex")
|
||||
registry.register("record_token_usage", record_token_usage, description="Persist token usage")
|
||||
registry.register("enqueue_parallel_tasks", enqueue_parallel_tasks, description="Enqueue parallel tasks")
|
||||
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
|
||||
170
backend/services/workflow_engine.py
Normal file
170
backend/services/workflow_engine.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Workflow engine – orchestrates template loading, state machine execution, and run persistence.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
||||
|
||||
try:
|
||||
from backend.core.config import settings
|
||||
from backend.models.agent import (
|
||||
ChatRunBinding,
|
||||
ChatTurnResult,
|
||||
RunEvent,
|
||||
RunStatus,
|
||||
TurnCallbacks,
|
||||
TurnContext,
|
||||
WorkflowRun,
|
||||
WorkflowTemplate,
|
||||
WorkflowTemplateKind,
|
||||
)
|
||||
from backend.services.state_machine_runner import StateMachineRunner
|
||||
from backend.services.tool_registry import ToolRegistry, default_tool_registry
|
||||
from backend.services.tools.chat_tools import register_chat_tools
|
||||
except ImportError:
|
||||
from core.config import settings
|
||||
from models.agent import (
|
||||
ChatRunBinding,
|
||||
ChatTurnResult,
|
||||
RunEvent,
|
||||
RunStatus,
|
||||
TurnCallbacks,
|
||||
TurnContext,
|
||||
WorkflowRun,
|
||||
WorkflowTemplate,
|
||||
WorkflowTemplateKind,
|
||||
)
|
||||
from services.state_machine_runner import StateMachineRunner
|
||||
from services.tool_registry import ToolRegistry, default_tool_registry
|
||||
from services.tools.chat_tools import register_chat_tools
|
||||
|
||||
|
||||
class WorkflowEngine:
|
||||
def __init__(self, registry: Optional[ToolRegistry] = None) -> None:
|
||||
self.registry = registry or default_tool_registry
|
||||
if not self.registry.list_specs():
|
||||
register_chat_tools(self.registry)
|
||||
|
||||
def _template_dir(self, template_id: str) -> Path:
|
||||
return settings.AGENT_TEMPLATES_PATH / template_id
|
||||
|
||||
def load_template(self, template_id: str = WorkflowTemplateKind.BUILTIN_CHAT.value) -> WorkflowTemplate:
|
||||
template_path = self._template_dir(template_id) / "template.json"
|
||||
with open(template_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return WorkflowTemplate(**data)
|
||||
|
||||
def _run_dir(self, role_name: str, chat_name: str) -> Path:
|
||||
return settings.AGENT_RUNS_PATH / "chat" / role_name / chat_name
|
||||
|
||||
def _persist_run(self, run: WorkflowRun, events: List[RunEvent]) -> None:
|
||||
run_dir = self._run_dir(run.binding.role_name, run.binding.chat_name)
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
run_file = run_dir / "run.json"
|
||||
run.finished_at = datetime.now().isoformat()
|
||||
with open(run_file, "w", encoding="utf-8") as f:
|
||||
json.dump(run.model_dump(), f, ensure_ascii=False, indent=2)
|
||||
|
||||
events_file = run_dir / "events.jsonl"
|
||||
with open(events_file, "a", encoding="utf-8") as f:
|
||||
for event in events:
|
||||
f.write(json.dumps(event.model_dump(), ensure_ascii=False) + "\n")
|
||||
|
||||
async def run_turn(
|
||||
self,
|
||||
request_data: Dict[str, Any],
|
||||
*,
|
||||
stream: bool = False,
|
||||
on_chunk: Optional[Callable[[str], Awaitable[None]]] = None,
|
||||
on_worldbook_active: Optional[Callable[[List[Any]], Awaitable[None]]] = None,
|
||||
on_tasks_created: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
||||
template_id: str = WorkflowTemplateKind.BUILTIN_CHAT.value,
|
||||
) -> ChatTurnResult:
|
||||
current_role = request_data.get("currentRole", "")
|
||||
current_chat = request_data.get("currentChat", "")
|
||||
user_message = request_data.get("mes", "")
|
||||
|
||||
if not current_role or not user_message:
|
||||
return ChatTurnResult(
|
||||
success=False,
|
||||
error="缺少必要的参数:currentRole 或 mes",
|
||||
workflow_template_id=template_id,
|
||||
)
|
||||
|
||||
preset_config = request_data.get("presetConfig", {})
|
||||
preset_name = preset_config.get("selectedPreset")
|
||||
|
||||
run_id = uuid.uuid4().hex
|
||||
binding = ChatRunBinding(
|
||||
role_name=current_role,
|
||||
chat_name=current_chat or "",
|
||||
template_id=template_id,
|
||||
)
|
||||
run = WorkflowRun(
|
||||
id=run_id,
|
||||
template_id=template_id,
|
||||
binding=binding,
|
||||
status=RunStatus.PENDING,
|
||||
)
|
||||
|
||||
callbacks = TurnCallbacks(
|
||||
on_chunk=on_chunk,
|
||||
on_worldbook_active=on_worldbook_active,
|
||||
on_tasks_created=on_tasks_created,
|
||||
)
|
||||
|
||||
ctx = TurnContext(
|
||||
request_data=request_data,
|
||||
template_id=template_id,
|
||||
run_id=run_id,
|
||||
stream=stream,
|
||||
callbacks=callbacks,
|
||||
current_role=current_role,
|
||||
current_chat=current_chat or "",
|
||||
user_message=user_message,
|
||||
preset_name=preset_name,
|
||||
)
|
||||
|
||||
template = self.load_template(template_id)
|
||||
sm_path = self._template_dir(template_id) / template.state_machine_path
|
||||
runner = StateMachineRunner.from_file(sm_path, self.registry)
|
||||
|
||||
try:
|
||||
events = await runner.run(run, ctx)
|
||||
self._persist_run(run, events)
|
||||
|
||||
active_entries = [
|
||||
entry.model_dump() if hasattr(entry, "model_dump") else entry
|
||||
for entry in ctx.active_entries
|
||||
]
|
||||
|
||||
return ChatTurnResult(
|
||||
success=True,
|
||||
content=ctx.generated_content,
|
||||
active_entries=active_entries,
|
||||
task_ids=ctx.task_ids,
|
||||
run_id=run_id,
|
||||
workflow_template_id=template_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
run.status = RunStatus.FAILED
|
||||
run.error = str(exc)
|
||||
try:
|
||||
self._persist_run(run, [])
|
||||
except Exception:
|
||||
pass
|
||||
return ChatTurnResult(
|
||||
success=False,
|
||||
error=f"工作流执行失败: {exc}",
|
||||
run_id=run_id,
|
||||
workflow_template_id=template_id,
|
||||
)
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
workflow_engine = WorkflowEngine()
|
||||
@@ -232,6 +232,32 @@ class WorldBookService:
|
||||
|
||||
raise FileNotFoundError(f"Entry '{uid}' not found in worldbook '{name}'")
|
||||
|
||||
@staticmethod
|
||||
def append_entry(name: str, entry_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
在世界书中追加条目(规范化后写入,与 Chat 侧条目格式一致)。
|
||||
|
||||
Args:
|
||||
name: 世界书名称(文件名,不含 .json)
|
||||
entry_data: 条目字段(content、comment、activationType、position 等)
|
||||
|
||||
Returns:
|
||||
写入后的规范化条目
|
||||
"""
|
||||
data = WorldBookService._load_worldbook(name)
|
||||
if not data:
|
||||
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||
|
||||
if not isinstance(data.get("entries"), list):
|
||||
data["entries"] = []
|
||||
|
||||
normalized = WorldBookConverter.normalize_entry(entry_data)
|
||||
data["entries"].append(normalized)
|
||||
now = int(datetime.now().timestamp())
|
||||
data["updatedAt"] = now
|
||||
WorldBookService._save_worldbook(name, data)
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def create_entry(name: str, entry_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
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 章内并给出明确反击预期。"
|
||||
}
|
||||
]
|
||||
}
|
||||
16
data/agent/niches.json
Normal file
16
data/agent/niches.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"niches": [
|
||||
{
|
||||
"id": "aesthetic_tone",
|
||||
"label": "整体美学",
|
||||
"description": "视觉、氛围、叙事基调等宏观美学设定",
|
||||
"suggestedStepGoal": "描述角色的整体美学:色调、材质、氛围、叙事基调,供后续人设与世界书条目引用。"
|
||||
},
|
||||
{
|
||||
"id": "persona_detail",
|
||||
"label": "具体人设",
|
||||
"description": "性格、口癖、关系、行为模式等可扮演细节",
|
||||
"suggestedStepGoal": "在整体美学基础上,细化可扮演的人设:性格、动机、口癖、与他人关系。"
|
||||
}
|
||||
]
|
||||
}
|
||||
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'"
|
||||
}
|
||||
75
data/agent/skill_templates.json
Normal file
75
data/agent/skill_templates.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"templates": [
|
||||
{
|
||||
"skillId": "studio.init_bind",
|
||||
"displayName": "创建并绑定",
|
||||
"description": "创建角色卡与世界书,并绑定到当前 Studio 项目",
|
||||
"displayParams": [
|
||||
{
|
||||
"key": "characterName",
|
||||
"label": "角色卡名称",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"placeholder": "例如:帝国骑士维尔"
|
||||
},
|
||||
{
|
||||
"key": "worldbookName",
|
||||
"label": "世界书名称",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"placeholder": "例如:维尔的世界观"
|
||||
}
|
||||
],
|
||||
"configWhitelist": [],
|
||||
"artifacts": [],
|
||||
"supportsLoopUntilSatisfied": false,
|
||||
"supportsInputs": false,
|
||||
"supportsInsertion": false,
|
||||
"supportsScoring": false,
|
||||
"runControls": []
|
||||
},
|
||||
{
|
||||
"skillId": "studio.worldbook_entry",
|
||||
"displayName": "创作世界书条目",
|
||||
"description": "根据 stepGoal 与上文引用,生成并写入世界书条目",
|
||||
"displayParams": [],
|
||||
"configWhitelist": [
|
||||
"stepGoal",
|
||||
"thinkingPrompt",
|
||||
"insertion.position",
|
||||
"insertion.activationType",
|
||||
"insertion.key",
|
||||
"insertion.keysecondary",
|
||||
"insertion.ragConfig",
|
||||
"insertion.comment",
|
||||
"scoring"
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"type": "worldbook.entries",
|
||||
"displayName": "世界书条目"
|
||||
}
|
||||
],
|
||||
"supportsLoopUntilSatisfied": true,
|
||||
"supportsInputs": true,
|
||||
"supportsInsertion": true,
|
||||
"supportsScoring": true,
|
||||
"configDefaults": {
|
||||
"stepGoal": "",
|
||||
"thinkingPrompt": "====== 思考流程 ======\nStep1: 简短确认任务性质(新设计/修改)\nStep2: 阅读绑定角色/世界书与上文引用\nStep3: 按步骤目标起草世界书条目\nStep4: 对照评价维度自检并优化表述\n\n(核心目的、评价标准与优化建议由系统在运行时自动注入,无需在此填写)",
|
||||
"insertion": {
|
||||
"position": 1,
|
||||
"activationType": "permanent",
|
||||
"key": "",
|
||||
"keysecondary": "",
|
||||
"comment": ""
|
||||
},
|
||||
"scoring": {
|
||||
"enabled": true,
|
||||
"dimensions": []
|
||||
}
|
||||
},
|
||||
"runControls": ["undo", "reroll", "interrupt", "incrementalSave", "overwriteSave", "questions"]
|
||||
}
|
||||
]
|
||||
}
|
||||
10
data/agent/studio_projects/default/meta.json
Normal file
10
data/agent/studio_projects/default/meta.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "default",
|
||||
"name": "单人类角色卡",
|
||||
"description": "从创建绑定到世界书条目的默认三步流水线,可在工作流编辑页复制并修改。",
|
||||
"templateId": "builtin.studio.example",
|
||||
"characterId": "f04ba2d6-1ffd-4c33-9cfe-cf6fd026c175",
|
||||
"worldbookId": "8682f790-1b9d-4842-826b-c07764be6b9b",
|
||||
"createdAt": "2026-05-31T00:00:00",
|
||||
"updatedAt": "2026-05-31T13:11:55.525055"
|
||||
}
|
||||
122
data/agent/studio_projects/default/pipeline.json
Normal file
122
data/agent/studio_projects/default/pipeline.json
Normal file
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"workflowGoal": "设计一个单人角色:先绑定角色卡与世界书,再迭代整体美学与具体人设,写入世界书条目。",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "init",
|
||||
"skillId": "studio.init_bind",
|
||||
"displayName": "创建并绑定",
|
||||
"enabled": true,
|
||||
"loopUntilSatisfied": false,
|
||||
"config": {},
|
||||
"displayParams": [
|
||||
{
|
||||
"key": "characterName",
|
||||
"label": "角色卡名称",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"placeholder": ""
|
||||
},
|
||||
{
|
||||
"key": "worldbookName",
|
||||
"label": "世界书名称",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"placeholder": ""
|
||||
}
|
||||
],
|
||||
"inputs": []
|
||||
},
|
||||
{
|
||||
"id": "aesthetic",
|
||||
"skillId": "studio.worldbook_entry",
|
||||
"displayName": "整体美学",
|
||||
"enabled": true,
|
||||
"niche": "aesthetic_tone",
|
||||
"loopUntilSatisfied": true,
|
||||
"config": {
|
||||
"stepGoal": "产出角色的整体美学设定:视觉风格、氛围、叙事基调,供后续人设步骤引用。",
|
||||
"thinkingPrompt": "step1:首先思考整个故事是怎么样的\nstep2:然后思考如何展示\nstep3:选中核心爽点",
|
||||
"insertion": {
|
||||
"position": 1,
|
||||
"activationType": "permanent",
|
||||
"key": "整体美学",
|
||||
"comment": "Studio · 整体美学"
|
||||
},
|
||||
"scoring": {
|
||||
"enabled": true,
|
||||
"dimensions": [
|
||||
{
|
||||
"id": "authenticity",
|
||||
"name": "真实性",
|
||||
"criteria": "美学设定是否自洽、可感知,而非空泛形容词堆砌。"
|
||||
},
|
||||
{
|
||||
"id": "fit",
|
||||
"name": "贴合度",
|
||||
"criteria": "是否覆盖色调/材质/氛围/叙事基调;是否可与后续人设衔接;表述是否简洁可注入世界书。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"displayParams": [],
|
||||
"inputs": [
|
||||
{
|
||||
"ref": "workflow.goal",
|
||||
"label": "工作流目标文本",
|
||||
"optional": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "persona",
|
||||
"skillId": "studio.worldbook_entry",
|
||||
"displayName": "具体人设",
|
||||
"enabled": true,
|
||||
"niche": "persona_detail",
|
||||
"loopUntilSatisfied": true,
|
||||
"config": {
|
||||
"stepGoal": "在整体美学基础上,写出可扮演的人设:性格、动机、口癖、关系与行为模式。",
|
||||
"thinkingPrompt": "",
|
||||
"insertion": {
|
||||
"position": 1,
|
||||
"activationType": "keyword",
|
||||
"key": "具体人设",
|
||||
"comment": "Studio · 具体人设"
|
||||
},
|
||||
"scoring": {
|
||||
"enabled": true,
|
||||
"dimensions": [
|
||||
{
|
||||
"id": "authenticity",
|
||||
"name": "真实性",
|
||||
"criteria": "人设细节是否具体、可扮演,动机与行为是否一致。"
|
||||
},
|
||||
{
|
||||
"id": "fit",
|
||||
"name": "贴合度",
|
||||
"criteria": "是否与人设目标一致;是否与整体美学一致;是否避免与已有条目冲突。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"displayParams": [],
|
||||
"inputs": [
|
||||
{
|
||||
"ref": "workflow.goal",
|
||||
"label": "工作流目标文本",
|
||||
"optional": false
|
||||
},
|
||||
{
|
||||
"ref": "aesthetic.output",
|
||||
"label": "整体美学 · 上轮产物",
|
||||
"optional": false
|
||||
},
|
||||
{
|
||||
"ref": "persona.output",
|
||||
"label": "具体人设 · 上轮产物",
|
||||
"optional": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Chat Reply Skill
|
||||
|
||||
Minimal skill placeholder for the builtin.chat workflow template.
|
||||
|
||||
This skill orchestrates a single chat turn: load character, activate worldbooks, assemble prompt, call LLM, apply regex, record usage, and enqueue parallel tasks.
|
||||
@@ -0,0 +1,3 @@
|
||||
id: chat_reply
|
||||
name: Chat Reply
|
||||
description: Minimal chat reply skill for builtin.chat template
|
||||
41
data/agent/templates/builtin.chat/state_machine.json
Normal file
41
data/agent/templates/builtin.chat/state_machine.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"initial": "regex_apply_user_input",
|
||||
"states": {
|
||||
"regex_apply_user_input": {
|
||||
"tool": "regex_apply_user_input",
|
||||
"next": "load_character"
|
||||
},
|
||||
"load_character": {
|
||||
"tool": "load_character",
|
||||
"next": "activate_worldbook"
|
||||
},
|
||||
"activate_worldbook": {
|
||||
"tool": "activate_worldbook",
|
||||
"next": "load_chat_history"
|
||||
},
|
||||
"load_chat_history": {
|
||||
"tool": "load_chat_history",
|
||||
"next": "build_prompt_messages"
|
||||
},
|
||||
"build_prompt_messages": {
|
||||
"tool": "build_prompt_messages",
|
||||
"next": "llm_main_reply"
|
||||
},
|
||||
"llm_main_reply": {
|
||||
"tool": "llm_main_reply",
|
||||
"next": "regex_apply_ai_output"
|
||||
},
|
||||
"regex_apply_ai_output": {
|
||||
"tool": "regex_apply_ai_output",
|
||||
"next": "record_token_usage"
|
||||
},
|
||||
"record_token_usage": {
|
||||
"tool": "record_token_usage",
|
||||
"next": "enqueue_parallel_tasks"
|
||||
},
|
||||
"enqueue_parallel_tasks": {
|
||||
"tool": "enqueue_parallel_tasks",
|
||||
"next": "end"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
data/agent/templates/builtin.chat/template.json
Normal file
16
data/agent/templates/builtin.chat/template.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "builtin.chat",
|
||||
"kind": "builtin.chat",
|
||||
"name": "Builtin Chat Reply",
|
||||
"description": "Default single-turn chat workflow migrated from ChatWorkflowService",
|
||||
"version": "1.0.0",
|
||||
"state_machine_path": "state_machine.json",
|
||||
"skills": [
|
||||
{
|
||||
"id": "chat_reply",
|
||||
"name": "Chat Reply",
|
||||
"description": "Main chat reply skill",
|
||||
"path": "skill/chat_reply"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
data/agent/templates/builtin.studio.example/meta.json
Normal file
6
data/agent/templates/builtin.studio.example/meta.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "builtin.studio.example",
|
||||
"name": "世界书条目创建",
|
||||
"description": "从创建绑定到世界书条目的默认三步流水线,可在工作流编辑页复制并修改。",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
102
data/agent/templates/builtin.studio.example/pipeline.json
Normal file
102
data/agent/templates/builtin.studio.example/pipeline.json
Normal file
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"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": "整体美学",
|
||||
"enabled": true,
|
||||
"loopUntilSatisfied": true,
|
||||
"config": {
|
||||
"stepGoal": "产出角色的整体美学设定:视觉风格、氛围、叙事基调,供后续人设步骤引用。",
|
||||
"thinkingPrompt": "====== 思考流程 ======\nStep1: 简短确认任务性质(新设计/修改)\nStep2: 阅读绑定角色/世界书与上文引用\nStep3: 按步骤目标起草世界书条目\nStep4: 对照评价维度自检并优化表述\n\n(核心目的、评价标准与优化建议由系统在运行时自动注入,无需在此填写)",
|
||||
"insertion": {
|
||||
"position": 1,
|
||||
"activationType": "permanent",
|
||||
"key": "整体美学",
|
||||
"comment": "Studio · 整体美学"
|
||||
},
|
||||
"scoring": {
|
||||
"enabled": true,
|
||||
"dimensions": [
|
||||
{
|
||||
"id": "authenticity",
|
||||
"name": "真实性",
|
||||
"criteria": "美学设定是否自洽、可感知,而非空泛形容词堆砌。"
|
||||
},
|
||||
{
|
||||
"id": "fit",
|
||||
"name": "贴合度",
|
||||
"criteria": "是否覆盖色调/材质/氛围/叙事基调;是否可与后续人设衔接;表述是否简洁可注入世界书。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"displayParams": [],
|
||||
"inputs": []
|
||||
},
|
||||
{
|
||||
"id": "persona",
|
||||
"skillId": "studio.worldbook_entry",
|
||||
"displayName": "具体人设",
|
||||
"enabled": true,
|
||||
"loopUntilSatisfied": true,
|
||||
"config": {
|
||||
"stepGoal": "在整体美学基础上,写出可扮演的人设:性格、动机、口癖、关系与行为模式。",
|
||||
"thinkingPrompt": "====== 思考流程 ======\nStep1: 简短确认任务性质(新设计/修改)\nStep2: 阅读绑定角色/世界书与上文引用\nStep3: 按步骤目标起草世界书条目\nStep4: 对照评价维度自检并优化表述\n\n(核心目的、评价标准与优化建议由系统在运行时自动注入,无需在此填写)",
|
||||
"insertion": {
|
||||
"position": 1,
|
||||
"activationType": "keyword",
|
||||
"key": "具体人设",
|
||||
"comment": "Studio · 具体人设"
|
||||
},
|
||||
"scoring": {
|
||||
"enabled": true,
|
||||
"dimensions": [
|
||||
{
|
||||
"id": "authenticity",
|
||||
"name": "真实性",
|
||||
"criteria": "人设细节是否具体、可扮演,动机与行为是否一致。"
|
||||
},
|
||||
{
|
||||
"id": "fit",
|
||||
"name": "贴合度",
|
||||
"criteria": "是否与人设目标一致;是否与整体美学一致;是否避免与已有条目冲突。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"displayParams": [],
|
||||
"inputs": [
|
||||
{
|
||||
"ref": "aesthetic.output",
|
||||
"label": "整体美学 · 世界书条目"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
56
data/agent/workflow_variables.json
Normal file
56
data/agent/workflow_variables.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"builtIn": [
|
||||
{
|
||||
"ref": "workflow.goal",
|
||||
"label": "工作流目标文本",
|
||||
"description": "当前 Studio 项目的 workflowGoal 全文(系统自动注入,不可手动选择)",
|
||||
"autoInjected": true
|
||||
},
|
||||
{
|
||||
"ref": "workflow.boundWorldbook",
|
||||
"label": "绑定世界书摘要",
|
||||
"description": "项目绑定的世界书 meta / 摘要(系统自动注入,不可手动选择)",
|
||||
"autoInjected": true
|
||||
},
|
||||
{
|
||||
"ref": "workflow.boundCharacter",
|
||||
"label": "绑定角色卡摘要",
|
||||
"description": "项目绑定的角色卡摘要(系统自动注入,不可手动选择)",
|
||||
"autoInjected": true
|
||||
}
|
||||
],
|
||||
"dynamicSuffixes": [
|
||||
{
|
||||
"suffix": ".output",
|
||||
"labelPattern": "{displayName} · 世界书条目"
|
||||
}
|
||||
],
|
||||
"autoInjectedContext": [
|
||||
"目前产物",
|
||||
"思考流程",
|
||||
"核心目的",
|
||||
"评价标准与优化建议"
|
||||
],
|
||||
"autoInjectedContextDefs": [
|
||||
{
|
||||
"id": "currentProduct",
|
||||
"label": "目前产物",
|
||||
"description": "当前步骤已生成的世界书条目草稿或最新版本,供模型在迭代修改时对照与延续。"
|
||||
},
|
||||
{
|
||||
"id": "thinkingFlow",
|
||||
"label": "思考流程",
|
||||
"description": "本步骤配置的 thinkingPrompt,引导模型按既定步骤推理与自检。"
|
||||
},
|
||||
{
|
||||
"id": "coreGoal",
|
||||
"label": "核心目的",
|
||||
"description": "本步骤的 stepGoal(步骤目标),明确本步要产出的内容与边界。"
|
||||
},
|
||||
{
|
||||
"id": "scoringCriteria",
|
||||
"label": "评价标准与优化建议",
|
||||
"description": "本步骤启用的 scoring 评价维度及准则,用于模型自检与优化表述。"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
docker-compose.dev.yml
Normal file
10
docker-compose.dev.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
# 开发环境 override(可选)
|
||||
# 用法: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
#
|
||||
# 与主 compose 策略一致:源码 volume 挂载 + HMR/reload,启动时不跑 npm install。
|
||||
# 依赖变更: scripts/docker-rebuild.ps1 -Service frontend
|
||||
# 或 docker compose exec frontend npm install && docker compose restart frontend
|
||||
|
||||
services:
|
||||
frontend:
|
||||
command: npm run dev -- --host 0.0.0.0
|
||||
@@ -36,10 +36,11 @@ services:
|
||||
- "23338:5173"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
- node_modules:/app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
|
||||
# 依赖在镜像构建时写入 node_modules volume;package.json 变更见 docs/DOCKER_DEV.md
|
||||
command: npm run dev -- --host 0.0.0.0
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
227
docs/DOCKER_DEV.md
Normal file
227
docs/DOCKER_DEV.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# Docker 开发指南(Windows / Docker Desktop)
|
||||
|
||||
本文说明如何在 **不重启 Docker Desktop** 的前提下进行日常开发。绝大多数代码改动无需任何容器操作;需要时只需重启**单个容器**。
|
||||
|
||||
---
|
||||
|
||||
## 核心原则
|
||||
|
||||
| 场景 | 需要做什么 | 是否需要重启 Docker Desktop |
|
||||
|------|-----------|---------------------------|
|
||||
| 修改 Python 后端代码 | **什么都不做**(uvicorn `--reload` 自动重载) | ❌ 不需要 |
|
||||
| 修改 React 前端代码 | **什么都不做**(Vite HMR 热更新) | ❌ 不需要 |
|
||||
| 容器异常 / 需要刷新进程 | `docker compose restart backend` 或 `frontend` | ❌ 不需要 |
|
||||
| 修改 `Dockerfile` 或依赖文件 | `docker compose up -d --build <service>` | ❌ 不需要 |
|
||||
| Docker 引擎崩溃 / 端口被占用且无法释放 | 见下文「极少需要重启 Docker Desktop」 | ⚠️ 极少需要 |
|
||||
|
||||
**日常开发不要重启 Docker Desktop。** 那是 Windows + WSL2 下最慢、最打断节奏的操作。
|
||||
|
||||
---
|
||||
|
||||
## 端口对照表
|
||||
|
||||
与 `docker-compose.yml` 一致:
|
||||
|
||||
| 服务 | 容器内端口 | 宿主机端口 | 访问地址 |
|
||||
|------|-----------|-----------|---------|
|
||||
| backend | 8000 | **23337** | http://localhost:23337 |
|
||||
| frontend | 5173 | **23338** | http://localhost:23338 |
|
||||
|
||||
健康检查:`http://localhost:23337/health`
|
||||
|
||||
---
|
||||
|
||||
## 代码改动:自动生效
|
||||
|
||||
### 后端(FastAPI + uvicorn)
|
||||
|
||||
- 启动命令:`uvicorn main:app --host 0.0.0.0 --port 8000 --reload`
|
||||
- 源码通过 volume 挂载:`./backend` → `/app`
|
||||
- 保存 `.py` 文件后,uvicorn 自动检测并重载,**无需重启容器**
|
||||
|
||||
### 前端(Vite + React)
|
||||
|
||||
- 启动命令:`npm run dev -- --host 0.0.0.0`
|
||||
- 源码通过 volume 挂载:`./frontend` → `/app`
|
||||
- `node_modules` 保存在独立 volume 中,不随宿主机目录覆盖
|
||||
- 保存 `.jsx` / `.css` 等文件后,Vite HMR 自动更新浏览器,**无需重启容器**
|
||||
|
||||
---
|
||||
|
||||
## 何时只需重启容器(不是 Docker Desktop)
|
||||
|
||||
以下情况用 `scripts/docker-restart.ps1` 或 `docker compose restart` 即可:
|
||||
|
||||
- 修改了环境变量(`docker-compose.yml` 中的 `environment`)并已 `docker compose up -d`
|
||||
- 容器内进程卡死、内存泄漏
|
||||
- 前端 HMR 断开、WebSocket 连接异常
|
||||
- 后端 reload 失败(极少数语法错误导致 worker 无法恢复)
|
||||
|
||||
```powershell
|
||||
# 重启单个服务
|
||||
.\scripts\docker-restart.ps1 -Service backend
|
||||
.\scripts\docker-restart.ps1 -Service frontend
|
||||
|
||||
# 重启全部
|
||||
.\scripts\docker-restart.ps1 -Service all
|
||||
|
||||
# 或直接
|
||||
docker compose restart backend
|
||||
docker compose restart frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 何时需要重新构建镜像
|
||||
|
||||
以下变更需要 **rebuild**,仍然 **不需要重启 Docker Desktop**:
|
||||
|
||||
| 变更内容 | 命令 |
|
||||
|---------|------|
|
||||
| `backend/requirements.txt` | `.\scripts\docker-rebuild.ps1 -Service backend` |
|
||||
| `backend/Dockerfile` | 同上 |
|
||||
| `frontend/package.json` / `package-lock.json` | `.\scripts\docker-rebuild.ps1 -Service frontend` |
|
||||
| `frontend/Dockerfile` | 同上 |
|
||||
|
||||
```powershell
|
||||
# 等价命令
|
||||
docker compose up -d --build backend
|
||||
docker compose up -d --build frontend
|
||||
```
|
||||
|
||||
### 前端依赖变更(package.json 改了但不想 rebuild)
|
||||
|
||||
若只新增了 npm 包、尚未 rebuild 镜像,可在运行中的容器内安装一次:
|
||||
|
||||
```powershell
|
||||
docker compose exec frontend npm install
|
||||
docker compose restart frontend
|
||||
```
|
||||
|
||||
首次 `docker compose up` 时,镜像构建阶段会执行 `npm install`,依赖写入 `node_modules` volume,之后日常启动**不再**每次 `npm install`。
|
||||
|
||||
---
|
||||
|
||||
## 极少需要重启 Docker Desktop 的情况
|
||||
|
||||
仅在以下情况才考虑重启 Docker Desktop 或 WSL:
|
||||
|
||||
1. **Docker 引擎无响应** — `docker ps` 一直挂起或报错 `Cannot connect to the Docker daemon`
|
||||
2. **端口被占用且 compose down 无法释放** — 例如 23337/23338 被僵尸进程占用
|
||||
3. **WSL2 后端异常** — 内存耗尽、磁盘满、网络栈故障
|
||||
|
||||
**优先尝试的替代方案(由轻到重):**
|
||||
|
||||
```powershell
|
||||
# 1. 停止并重新启动 compose 栈(不碰 Docker Desktop)
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
|
||||
# 2. 查看日志定位问题
|
||||
.\scripts\docker-logs.ps1
|
||||
.\scripts\docker-logs.ps1 -Service backend
|
||||
|
||||
# 3. 仅当 Docker 完全无响应时,关闭 WSL(会连带重启 Docker 引擎)
|
||||
wsl --shutdown
|
||||
# 然后重新打开 Docker Desktop
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 更快的日常开发方式(推荐)
|
||||
|
||||
Docker 适合「全栈联调 / 验收环境」。纯改代码时,**本地直接跑**通常更快:
|
||||
|
||||
### 后端(本地)
|
||||
|
||||
```powershell
|
||||
python -m venv venv
|
||||
.\venv\Scripts\Activate.ps1
|
||||
pip install -r backend\requirements.txt
|
||||
cd backend
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 前端(本地)
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
本地开发时前端默认代理到本地后端;Docker 栈仅在需要容器化联调时使用。
|
||||
|
||||
---
|
||||
|
||||
## 辅助脚本速查
|
||||
|
||||
所有脚本位于 `scripts/`,在项目根目录执行:
|
||||
|
||||
| 脚本 | 用途 |
|
||||
|------|------|
|
||||
| `docker-up.ps1` | 后台启动全部服务 |
|
||||
| `docker-restart.ps1` | 重启 backend / frontend / all(**不**重启 Docker Desktop) |
|
||||
| `docker-logs.ps1` | 跟踪日志(可选 `-Service backend`) |
|
||||
| `docker-rebuild.ps1` | 重新构建并启动指定服务 |
|
||||
|
||||
### 典型一日工作流
|
||||
|
||||
```powershell
|
||||
# 早上第一次
|
||||
.\scripts\docker-up.ps1
|
||||
|
||||
# 白天改代码 — 保存即可,backend/frontend 自动更新
|
||||
|
||||
# 偶尔 HMR 或 reload 异常
|
||||
.\scripts\docker-restart.ps1 -Service frontend
|
||||
|
||||
# 改了 requirements.txt
|
||||
.\scripts\docker-rebuild.ps1 -Service backend
|
||||
|
||||
# 下班
|
||||
docker compose down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 使用 docker-compose.dev.yml(可选)
|
||||
|
||||
开发环境可使用 override 文件,与主 compose 合并:
|
||||
|
||||
```powershell
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
内容与主文件优化策略一致;便于将来追加仅开发用的配置而不改动默认 compose。
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 改了前端代码但页面没更新?
|
||||
|
||||
1. 确认保存了文件
|
||||
2. 浏览器硬刷新(Ctrl+Shift+R)
|
||||
3. `.\scripts\docker-restart.ps1 -Service frontend`
|
||||
4. 查看日志:`.\scripts\docker-logs.ps1 -Service frontend`
|
||||
|
||||
### Q: 改了后端代码但 API 行为没变?
|
||||
|
||||
1. 查看 backend 日志是否有 reload 报错
|
||||
2. `.\scripts\docker-restart.ps1 -Service backend`
|
||||
3. 若改了依赖,需 rebuild
|
||||
|
||||
### Q: 每次启动 frontend 都很慢?
|
||||
|
||||
旧版 compose 在每次容器启动时执行 `npm install`。当前配置已改为直接 `npm run dev`;依赖在**镜像构建时**或**手动 exec npm install** 时装入 volume。若仍慢,检查是否误删了 `node_modules` volume:
|
||||
|
||||
```powershell
|
||||
docker volume ls | Select-String node_modules
|
||||
```
|
||||
|
||||
### Q: 必须重启 Docker Desktop 吗?
|
||||
|
||||
**正常代码编辑:不需要。**
|
||||
**依赖 / Dockerfile 变更:rebuild 容器即可。**
|
||||
**只有 Docker 引擎本身故障时才考虑重启 Docker Desktop 或 `wsl --shutdown`。**
|
||||
@@ -1,10 +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
|
||||
@@ -18,6 +24,7 @@ function App() {
|
||||
sidebarMode,
|
||||
isSidebarHovered,
|
||||
colorTheme,
|
||||
activePage,
|
||||
setLayoutMode,
|
||||
setSidebarMode,
|
||||
setSidebarHovered,
|
||||
@@ -187,34 +194,85 @@ 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, 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 />}
|
||||
|
||||
{/* 主内容容器 */}
|
||||
<div className="main-container">
|
||||
{/* 左侧栏 - 智能模式下悬停展开 */}
|
||||
<div
|
||||
className={`sidebar-left-wrapper sidebar-mode-${sidebarMode} ${sidebarMode === 'smart' && isSidebarHovered ? 'sidebar-expanded' : ''}`}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<SideBarLeft />
|
||||
</div>
|
||||
{activePage === 'chat' ? (
|
||||
<div className="main-container">
|
||||
{/* 左侧栏 - 智能模式下悬停展开 */}
|
||||
<div
|
||||
className={`sidebar-left-wrapper sidebar-mode-${sidebarMode} ${sidebarMode === 'smart' && isSidebarHovered ? 'sidebar-expanded' : ''}`}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<SideBarLeft />
|
||||
</div>
|
||||
|
||||
{/* 中间栏:聊天框 */}
|
||||
<div className="chat-area-wrapper">
|
||||
<div className="chat-area">
|
||||
<ChatBox />
|
||||
{/* 中间栏:聊天框 */}
|
||||
<div className="chat-area-wrapper">
|
||||
<div className="chat-area">
|
||||
<ChatBox />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧栏 */}
|
||||
<div className="sidebar-right-wrapper">
|
||||
<SideBarRight />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧栏 */}
|
||||
<div className="sidebar-right-wrapper">
|
||||
<SideBarRight />
|
||||
) : activePage === 'studio_edit' ? (
|
||||
<div className="main-container studio-container">
|
||||
<StudioEditPage />
|
||||
</div>
|
||||
</div>
|
||||
) : activePage === 'studio_run' ? (
|
||||
<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} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ const useAppLayoutStore = create(
|
||||
// 颜色主题:'light' | 'dark'
|
||||
colorTheme: 'dark',
|
||||
|
||||
// 当前页面:'chat' | 'studio_edit' | 'studio_run' | 'novel' | 'room'
|
||||
activePage: 'chat',
|
||||
|
||||
// ==================== Actions ====================
|
||||
|
||||
/**
|
||||
@@ -48,6 +51,14 @@ const useAppLayoutStore = create(
|
||||
set({ isSidebarHovered: hovered });
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置当前页面
|
||||
* @param {string} page - 'chat' | 'studio_edit' | 'studio_run' | 'novel' | 'room'
|
||||
*/
|
||||
setActivePage: (page) => {
|
||||
set({ activePage: page });
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置颜色主题
|
||||
* @param {string} theme - 主题名
|
||||
@@ -78,16 +89,26 @@ const useAppLayoutStore = create(
|
||||
layoutMode: 'chat',
|
||||
sidebarMode: 'both',
|
||||
isSidebarHovered: false,
|
||||
colorTheme: 'dark'
|
||||
colorTheme: 'dark',
|
||||
activePage: 'chat',
|
||||
});
|
||||
}
|
||||
}),
|
||||
{
|
||||
name: 'app-layout-storage', // localStorage key
|
||||
name: 'app-layout-storage',
|
||||
version: 1,
|
||||
migrate: (persistedState) => {
|
||||
if (!persistedState) return persistedState;
|
||||
if (persistedState.activePage === 'studio') {
|
||||
return { ...persistedState, activePage: 'studio_edit' };
|
||||
}
|
||||
return persistedState;
|
||||
},
|
||||
partialize: (state) => ({
|
||||
layoutMode: state.layoutMode,
|
||||
sidebarMode: state.sidebarMode,
|
||||
colorTheme: state.colorTheme
|
||||
colorTheme: state.colorTheme,
|
||||
activePage: state.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
@@ -88,11 +88,14 @@ const useCharacterStore = create(
|
||||
}
|
||||
},
|
||||
|
||||
// 选择角色
|
||||
// 选择角色(传 null 清除选中)
|
||||
selectCharacter: (character) => {
|
||||
// 使用函数式更新避免不必要的重渲染
|
||||
set((state) => {
|
||||
// 如果选中的是同一个角色,不更新状态
|
||||
if (!character) {
|
||||
return state.selectedCharacter === null
|
||||
? state
|
||||
: { selectedCharacter: null };
|
||||
}
|
||||
if (state.selectedCharacter?.id === character.id) {
|
||||
return state;
|
||||
}
|
||||
@@ -100,6 +103,8 @@ const useCharacterStore = create(
|
||||
});
|
||||
},
|
||||
|
||||
clearSelectedCharacter: () => set({ selectedCharacter: null }),
|
||||
|
||||
// 创建角色
|
||||
createCharacter: async (characterData) => {
|
||||
try {
|
||||
@@ -151,6 +156,18 @@ const useCharacterStore = create(
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete character');
|
||||
|
||||
set((state) => {
|
||||
const next = {};
|
||||
if (state.selectedCharacter?.name === name) {
|
||||
next.selectedCharacter = null;
|
||||
}
|
||||
if (state.characterChats[name]) {
|
||||
const { [name]: _removed, ...restChats } = state.characterChats;
|
||||
next.characterChats = restChats;
|
||||
}
|
||||
return Object.keys(next).length ? next : state;
|
||||
});
|
||||
|
||||
// 刷新列表
|
||||
await get().fetchCharacters();
|
||||
} catch (error) {
|
||||
|
||||
917
frontend/src/Store/Studio/StudioSlice.jsx
Normal file
917
frontend/src/Store/Studio/StudioSlice.jsx
Normal file
@@ -0,0 +1,917 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const DEFAULT_TEMPLATE_ID = 'builtin.studio.example';
|
||||
|
||||
async function resolveStudioApiConfig() {
|
||||
let profileId = null;
|
||||
let apiConfig = {};
|
||||
try {
|
||||
const { default: useApiConfigStore } = await import('../SideBarLeft/ApiConfigSlice');
|
||||
const apiState = useApiConfigStore.getState();
|
||||
profileId = apiState.currentProfile?.id || null;
|
||||
const mainLLM = apiState.currentProfile?.apis?.mainLLM;
|
||||
if (mainLLM) {
|
||||
apiConfig = {
|
||||
api_url: mainLLM.apiUrl || '',
|
||||
model: mainLLM.model || '',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* optional store */
|
||||
}
|
||||
return { profileId, apiConfig };
|
||||
}
|
||||
|
||||
async function consumeStudioStreamResponse(res, set, signal) {
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let finalRun = null;
|
||||
|
||||
const abortHandler = () => {
|
||||
reader.cancel().catch(() => {});
|
||||
};
|
||||
signal?.addEventListener('abort', abortHandler);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
const event = JSON.parse(line);
|
||||
if (event.type === 'thinking_delta') {
|
||||
set({ runStreamingThinking: event.content || '' });
|
||||
} else if (event.type === 'complete') {
|
||||
finalRun = event.run;
|
||||
} else if (event.type === 'error') {
|
||||
throw new Error(event.detail || '流式处理失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
const event = JSON.parse(buffer);
|
||||
if (event.type === 'complete') {
|
||||
finalRun = event.run;
|
||||
} else if (event.type === 'error') {
|
||||
throw new Error(event.detail || '流式处理失败');
|
||||
} else if (event.type === 'thinking_delta') {
|
||||
set({ runStreamingThinking: event.content || '' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalRun) {
|
||||
throw new Error('流式响应未完成');
|
||||
}
|
||||
return finalRun;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
}
|
||||
|
||||
async function parseStudioErrorResponse(res) {
|
||||
let detail = await res.text();
|
||||
try {
|
||||
const parsed = JSON.parse(detail);
|
||||
detail = parsed.detail || detail;
|
||||
} catch {
|
||||
/* keep raw text */
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
let runAbortController = null;
|
||||
|
||||
function beginRunRequest(set) {
|
||||
if (runAbortController) {
|
||||
runAbortController.abort();
|
||||
}
|
||||
runAbortController = new AbortController();
|
||||
set({ runMessaging: true, runStreamingThinking: null, runError: null });
|
||||
return runAbortController;
|
||||
}
|
||||
|
||||
function finishRunRequest(set, patch = {}) {
|
||||
runAbortController = null;
|
||||
set({ runMessaging: false, runStreamingThinking: null, ...patch });
|
||||
}
|
||||
|
||||
function isRunAbortError(error) {
|
||||
return error?.name === 'AbortError' || error?.message === 'Aborted';
|
||||
}
|
||||
|
||||
function migrateScoringInNode(node) {
|
||||
const scoring = node.config?.scoring;
|
||||
if (!scoring || scoring.dimensions?.length) return node;
|
||||
if (!scoring.rubric) return node;
|
||||
return {
|
||||
...node,
|
||||
config: {
|
||||
...node.config,
|
||||
scoring: {
|
||||
...scoring,
|
||||
dimensions: [
|
||||
{
|
||||
id: 'default',
|
||||
name: '综合质量',
|
||||
criteria: scoring.rubric,
|
||||
},
|
||||
],
|
||||
rubric: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function migratePipeline(pipeline) {
|
||||
if (!pipeline) return pipeline;
|
||||
return {
|
||||
...pipeline,
|
||||
nodes: (pipeline.nodes || []).map(migrateScoringInNode),
|
||||
};
|
||||
}
|
||||
|
||||
const useStudioStore = create((set, get) => ({
|
||||
projects: [],
|
||||
workflowTemplates: [],
|
||||
workflowVariables: { builtIn: [], dynamic: [] },
|
||||
currentProjectId: null,
|
||||
meta: null,
|
||||
pipeline: null,
|
||||
skillTemplates: [],
|
||||
selectedNodeId: null,
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: null,
|
||||
saveMessage: null,
|
||||
|
||||
// Studio run (R0)
|
||||
runProjectId: null,
|
||||
runs: [],
|
||||
currentRunId: null,
|
||||
currentRun: null,
|
||||
runLoading: false,
|
||||
runCreating: false,
|
||||
runAdvancing: false,
|
||||
runMessaging: false,
|
||||
runStreamingThinking: null,
|
||||
runError: null,
|
||||
runSessionEntered: false,
|
||||
|
||||
bindingEditorOpen: false,
|
||||
bindingEditorHighlightUid: null,
|
||||
bindingEditorInitialTab: 'character',
|
||||
bindingEditorSaveNotice: '',
|
||||
|
||||
setSelectedNodeId: (id) => set({ selectedNodeId: id }),
|
||||
clearSaveMessage: () => set({ saveMessage: null, error: null }),
|
||||
|
||||
setMetaLocal: (patch) => {
|
||||
const { meta } = get();
|
||||
if (!meta) return;
|
||||
set({ meta: { ...meta, ...patch } });
|
||||
},
|
||||
|
||||
setPipelineLocal: (pipeline) => set({ pipeline: migratePipeline(pipeline) }),
|
||||
|
||||
updateNode: (nodeId, patch) => {
|
||||
const { pipeline } = get();
|
||||
if (!pipeline) return;
|
||||
const nodes = pipeline.nodes.map((n) =>
|
||||
n.id === nodeId ? { ...n, ...patch } : n
|
||||
);
|
||||
set({ pipeline: { ...pipeline, nodes } });
|
||||
},
|
||||
|
||||
updateNodeConfig: (nodeId, configPatch) => {
|
||||
const { pipeline } = get();
|
||||
if (!pipeline) return;
|
||||
const nodes = pipeline.nodes.map((n) =>
|
||||
n.id === nodeId
|
||||
? { ...n, config: { ...(n.config || {}), ...configPatch } }
|
||||
: n
|
||||
);
|
||||
set({ pipeline: { ...pipeline, nodes } });
|
||||
},
|
||||
|
||||
reorderNodes: (fromIndex, toIndex) => {
|
||||
const { pipeline } = get();
|
||||
if (!pipeline || fromIndex === toIndex) return;
|
||||
const nodes = [...pipeline.nodes];
|
||||
const [moved] = nodes.splice(fromIndex, 1);
|
||||
nodes.splice(toIndex, 0, moved);
|
||||
set({ pipeline: { ...pipeline, nodes } });
|
||||
},
|
||||
|
||||
addNode: (skillId, displayName) => {
|
||||
const { pipeline, skillTemplates } = get();
|
||||
if (!pipeline) return;
|
||||
const tpl = skillTemplates.find((t) => t.skillId === skillId);
|
||||
const id = `node-${Date.now()}`;
|
||||
const base = {
|
||||
id,
|
||||
skillId,
|
||||
displayName: displayName || tpl?.displayName || skillId,
|
||||
enabled: true,
|
||||
config: {},
|
||||
displayParams: tpl?.displayParams ? [...tpl.displayParams] : [],
|
||||
inputs: [],
|
||||
};
|
||||
if (tpl?.configDefaults) {
|
||||
base.config = JSON.parse(JSON.stringify(tpl.configDefaults));
|
||||
}
|
||||
if (skillId === 'studio.worldbook_entry') {
|
||||
base.loopUntilSatisfied = false;
|
||||
}
|
||||
const nodes = [...pipeline.nodes, base];
|
||||
set({
|
||||
pipeline: { ...pipeline, nodes },
|
||||
selectedNodeId: id,
|
||||
});
|
||||
},
|
||||
|
||||
removeNode: (nodeId) => {
|
||||
const { pipeline, selectedNodeId } = get();
|
||||
if (!pipeline) return;
|
||||
const nodes = pipeline.nodes.filter((n) => n.id !== nodeId);
|
||||
set({
|
||||
pipeline: { ...pipeline, nodes },
|
||||
selectedNodeId:
|
||||
selectedNodeId === nodeId
|
||||
? nodes[0]?.id ?? null
|
||||
: selectedNodeId,
|
||||
});
|
||||
},
|
||||
|
||||
fetchProjects: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const res = await fetch('/api/studio/projects');
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const projects = await res.json();
|
||||
set({ projects, loading: false });
|
||||
return projects;
|
||||
} catch (e) {
|
||||
set({ loading: false, error: e.message || '加载项目列表失败' });
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
fetchWorkflowTemplates: async () => {
|
||||
try {
|
||||
const res = await fetch('/api/studio/templates');
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const templates = await res.json();
|
||||
set({ workflowTemplates: templates });
|
||||
return templates;
|
||||
} catch (e) {
|
||||
set({ error: e.message || '加载工作流模板失败' });
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
fetchWorkflowVariables: async (projectId) => {
|
||||
try {
|
||||
const qs = projectId
|
||||
? `?projectId=${encodeURIComponent(projectId)}`
|
||||
: '';
|
||||
const res = await fetch(`/api/studio/variables${qs}`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
set({ workflowVariables: data });
|
||||
return data;
|
||||
} catch (e) {
|
||||
set({ error: e.message || '加载工作流变量失败' });
|
||||
return { builtIn: [], dynamic: [] };
|
||||
}
|
||||
},
|
||||
|
||||
fetchSkillTemplates: async () => {
|
||||
try {
|
||||
const res = await fetch('/api/studio/skill-templates');
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const tplData = await res.json();
|
||||
set({ skillTemplates: tplData.templates || [] });
|
||||
} catch (e) {
|
||||
set({ error: e.message || '加载技能模板失败' });
|
||||
}
|
||||
},
|
||||
|
||||
fetchProject: async (projectId) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const res = await fetch(`/api/studio/projects/${encodeURIComponent(projectId)}`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
const pipeline = migratePipeline(data.pipeline);
|
||||
set({
|
||||
currentProjectId: data.meta.id,
|
||||
meta: data.meta,
|
||||
pipeline,
|
||||
selectedNodeId: pipeline?.nodes?.[0]?.id ?? null,
|
||||
loading: false,
|
||||
});
|
||||
await get().fetchWorkflowVariables(projectId);
|
||||
return { ...data, pipeline };
|
||||
} catch (e) {
|
||||
set({ loading: false, error: e.message || '加载项目失败' });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
savePipeline: async () => {
|
||||
const { currentProjectId, pipeline } = get();
|
||||
if (!currentProjectId || !pipeline) return false;
|
||||
set({ saving: true, error: null, saveMessage: null });
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/studio/projects/${encodeURIComponent(currentProjectId)}/pipeline`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(pipeline),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
set({
|
||||
meta: data.meta,
|
||||
pipeline: migratePipeline(data.pipeline),
|
||||
saving: false,
|
||||
saveMessage: '已保存到本地',
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
set({
|
||||
saving: false,
|
||||
error: e.message || '保存失败',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
createProject: async (name, templateId = DEFAULT_TEMPLATE_ID) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const res = await fetch('/api/studio/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, template_id: templateId }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
await get().fetchProjects();
|
||||
const pipeline = migratePipeline(data.pipeline);
|
||||
set({
|
||||
currentProjectId: data.meta.id,
|
||||
meta: data.meta,
|
||||
pipeline,
|
||||
selectedNodeId: pipeline?.nodes?.[0]?.id ?? null,
|
||||
loading: false,
|
||||
});
|
||||
await get().fetchWorkflowVariables(data.meta.id);
|
||||
return data;
|
||||
} catch (e) {
|
||||
set({ loading: false, error: e.message || '创建项目失败' });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
updateProjectMeta: async (projectId, { name, description } = {}) => {
|
||||
if (!projectId) return null;
|
||||
set({ saving: true, error: null, saveMessage: null });
|
||||
try {
|
||||
const body = {};
|
||||
if (name !== undefined) body.name = name;
|
||||
if (description !== undefined) body.description = description;
|
||||
const res = await fetch(
|
||||
`/api/studio/projects/${encodeURIComponent(projectId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
const pipeline = migratePipeline(data.pipeline);
|
||||
set({
|
||||
meta: data.meta,
|
||||
pipeline,
|
||||
saving: false,
|
||||
saveMessage: '项目信息已保存',
|
||||
});
|
||||
await get().fetchProjects();
|
||||
return data;
|
||||
} catch (e) {
|
||||
set({
|
||||
saving: false,
|
||||
error: e.message || '保存项目信息失败',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
renameProject: async (projectId, newName) => {
|
||||
const trimmed = (newName || '').trim();
|
||||
if (!projectId || !trimmed) return null;
|
||||
return get().updateProjectMeta(projectId, { name: trimmed });
|
||||
},
|
||||
|
||||
deleteProject: async (projectId) => {
|
||||
if (!projectId) return false;
|
||||
set({ loading: true, error: null, saveMessage: null });
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/studio/projects/${encodeURIComponent(projectId)}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const projects = await get().fetchProjects();
|
||||
const nextId = projects[0]?.id ?? null;
|
||||
if (nextId) {
|
||||
await get().fetchProject(nextId);
|
||||
} else {
|
||||
set({
|
||||
currentProjectId: null,
|
||||
meta: null,
|
||||
pipeline: null,
|
||||
selectedNodeId: null,
|
||||
});
|
||||
}
|
||||
if (get().runProjectId === projectId) {
|
||||
set({ runProjectId: nextId, runs: [], currentRunId: null, currentRun: null });
|
||||
}
|
||||
set({ loading: false, saveMessage: '项目已删除' });
|
||||
return true;
|
||||
} catch (e) {
|
||||
set({ loading: false, error: e.message || '删除项目失败' });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
initStudio: async () => {
|
||||
await Promise.all([
|
||||
get().fetchSkillTemplates(),
|
||||
get().fetchWorkflowTemplates(),
|
||||
get().fetchProjects(),
|
||||
]);
|
||||
const projects = get().projects;
|
||||
const id =
|
||||
get().currentProjectId ||
|
||||
projects[0]?.id ||
|
||||
'default';
|
||||
if (id) {
|
||||
await get().fetchProject(id);
|
||||
}
|
||||
},
|
||||
|
||||
setRunProjectId: (projectId) => {
|
||||
set({
|
||||
runProjectId: projectId,
|
||||
currentRunId: null,
|
||||
currentRun: null,
|
||||
runs: [],
|
||||
runSessionEntered: false,
|
||||
});
|
||||
},
|
||||
|
||||
setRunSessionEntered: (entered) => set({ runSessionEntered: !!entered }),
|
||||
|
||||
openBindingEditor: ({ highlightUid = null, tab = 'character', notice = '' } = {}) =>
|
||||
set({
|
||||
bindingEditorOpen: true,
|
||||
bindingEditorHighlightUid: highlightUid,
|
||||
bindingEditorInitialTab: tab,
|
||||
bindingEditorSaveNotice: notice,
|
||||
}),
|
||||
|
||||
closeBindingEditor: () =>
|
||||
set({
|
||||
bindingEditorOpen: false,
|
||||
bindingEditorHighlightUid: null,
|
||||
bindingEditorSaveNotice: '',
|
||||
}),
|
||||
|
||||
clearBindingEditorNotice: () => set({ bindingEditorSaveNotice: '' }),
|
||||
|
||||
fetchRuns: async (projectId) => {
|
||||
if (!projectId) return [];
|
||||
set({ runLoading: true, runError: null });
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/studio/projects/${encodeURIComponent(projectId)}/runs`
|
||||
);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const runs = await res.json();
|
||||
set({ runs, runLoading: false });
|
||||
return runs;
|
||||
} catch (e) {
|
||||
set({
|
||||
runLoading: false,
|
||||
runError: e.message || '加载运行列表失败',
|
||||
});
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
fetchRun: async (projectId, runId) => {
|
||||
if (!projectId || !runId) return null;
|
||||
set({ runLoading: true, runError: null });
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/studio/projects/${encodeURIComponent(projectId)}/runs/${encodeURIComponent(runId)}`
|
||||
);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const run = await res.json();
|
||||
set({
|
||||
currentRunId: runId,
|
||||
currentRun: run,
|
||||
runLoading: false,
|
||||
runSessionEntered: false,
|
||||
});
|
||||
return run;
|
||||
} catch (e) {
|
||||
set({
|
||||
runLoading: false,
|
||||
runError: e.message || '加载运行详情失败',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
createRun: async (projectId) => {
|
||||
if (!projectId) return null;
|
||||
set({ runCreating: true, runError: null });
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/studio/projects/${encodeURIComponent(projectId)}/runs`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const run = await res.json();
|
||||
await get().fetchRuns(projectId);
|
||||
set({
|
||||
currentRunId: run.id,
|
||||
currentRun: run,
|
||||
runCreating: false,
|
||||
runSessionEntered: false,
|
||||
});
|
||||
return run;
|
||||
} catch (e) {
|
||||
set({
|
||||
runCreating: false,
|
||||
runError: e.message || '创建运行失败',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
selectRun: async (runId) => {
|
||||
const projectId = get().runProjectId;
|
||||
if (!projectId || !runId) return null;
|
||||
return get().fetchRun(projectId, runId);
|
||||
},
|
||||
|
||||
advanceRun: async (displayParams, { saveMode = 'advance' } = {}) => {
|
||||
const { runProjectId, currentRunId } = get();
|
||||
if (!runProjectId || !currentRunId) return null;
|
||||
set({ runAdvancing: true, runError: null });
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/studio/projects/${encodeURIComponent(runProjectId)}/runs/${encodeURIComponent(currentRunId)}/advance`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ displayParams, saveMode }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
let detail = await res.text();
|
||||
try {
|
||||
const parsed = JSON.parse(detail);
|
||||
detail = parsed.detail || detail;
|
||||
} catch {
|
||||
/* keep raw text */
|
||||
}
|
||||
throw new Error(detail || '推进运行失败');
|
||||
}
|
||||
const run = await res.json();
|
||||
set({
|
||||
currentRun: run,
|
||||
runAdvancing: false,
|
||||
});
|
||||
await get().fetchRuns(runProjectId);
|
||||
return run;
|
||||
} catch (e) {
|
||||
set({
|
||||
runAdvancing: false,
|
||||
runError: e.message || '推进运行失败',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
switchRunNode: async (nodeId) => {
|
||||
const { runProjectId, currentRunId } = get();
|
||||
if (!runProjectId || !currentRunId || !nodeId) return null;
|
||||
set({ runLoading: true, runError: null });
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/studio/projects/${encodeURIComponent(runProjectId)}/runs/${encodeURIComponent(currentRunId)}/switch-node`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nodeId }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
let detail = await res.text();
|
||||
try {
|
||||
const parsed = JSON.parse(detail);
|
||||
detail = parsed.detail || detail;
|
||||
} catch {
|
||||
/* keep raw */
|
||||
}
|
||||
throw new Error(detail || '切换节点失败');
|
||||
}
|
||||
const run = await res.json();
|
||||
set({ currentRun: run, runLoading: false });
|
||||
return run;
|
||||
} catch (e) {
|
||||
set({
|
||||
runLoading: false,
|
||||
runError: e.message || '切换节点失败',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
saveWorldbookRun: async (saveMode) => {
|
||||
const { runProjectId, currentRunId } = get();
|
||||
if (!runProjectId || !currentRunId) return null;
|
||||
const mode = saveMode === 'overwrite' ? 'overwrite' : 'incremental';
|
||||
set({ runAdvancing: true, runError: null });
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/studio/projects/${encodeURIComponent(runProjectId)}/runs/${encodeURIComponent(currentRunId)}/save`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
let detail = await res.text();
|
||||
try {
|
||||
const parsed = JSON.parse(detail);
|
||||
detail = parsed.detail || detail;
|
||||
} catch {
|
||||
/* keep raw text */
|
||||
}
|
||||
throw new Error(detail || '保存失败');
|
||||
}
|
||||
const run = await res.json();
|
||||
const nodeState = run.nodeStates?.find((n) => n.nodeId === run.currentNodeId);
|
||||
const writtenUid = nodeState?.lastDraft?.writtenEntryUid || null;
|
||||
const notice =
|
||||
mode === 'overwrite'
|
||||
? '已覆盖保存到世界书'
|
||||
: '已增量保存到世界书';
|
||||
set({
|
||||
currentRun: run,
|
||||
runAdvancing: false,
|
||||
bindingEditorOpen: true,
|
||||
bindingEditorHighlightUid: writtenUid,
|
||||
bindingEditorInitialTab: 'worldbook',
|
||||
bindingEditorSaveNotice: notice,
|
||||
});
|
||||
await get().fetchRuns(runProjectId);
|
||||
return { run, writtenUid };
|
||||
} catch (e) {
|
||||
set({
|
||||
runAdvancing: false,
|
||||
runError: e.message || '保存失败',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
sendRunMessage: async (content, { stream = false } = {}) => {
|
||||
const { runProjectId, currentRunId } = get();
|
||||
if (!runProjectId || !currentRunId) return null;
|
||||
const controller = beginRunRequest(set);
|
||||
try {
|
||||
const { profileId, apiConfig } = await resolveStudioApiConfig();
|
||||
|
||||
const res = await fetch(
|
||||
`/api/studio/projects/${encodeURIComponent(runProjectId)}/runs/${encodeURIComponent(currentRunId)}/message`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content,
|
||||
stream,
|
||||
profileId,
|
||||
apiConfig,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error((await parseStudioErrorResponse(res)) || '发送消息失败');
|
||||
}
|
||||
|
||||
if (stream && res.body) {
|
||||
const finalRun = await consumeStudioStreamResponse(res, set, controller.signal);
|
||||
finishRunRequest(set, { currentRun: finalRun });
|
||||
return finalRun;
|
||||
}
|
||||
|
||||
const run = await res.json();
|
||||
finishRunRequest(set, { currentRun: run });
|
||||
return run;
|
||||
} catch (e) {
|
||||
if (isRunAbortError(e)) {
|
||||
finishRunRequest(set);
|
||||
return null;
|
||||
}
|
||||
finishRunRequest(set, { runError: e.message || '发送消息失败' });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
interruptRun: () => {
|
||||
if (runAbortController) {
|
||||
runAbortController.abort();
|
||||
runAbortController = null;
|
||||
}
|
||||
set({ runMessaging: false, runStreamingThinking: null });
|
||||
},
|
||||
|
||||
undoRun: async () => {
|
||||
const { runProjectId, currentRunId } = get();
|
||||
if (!runProjectId || !currentRunId) return null;
|
||||
const controller = beginRunRequest(set);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/studio/projects/${encodeURIComponent(runProjectId)}/runs/${encodeURIComponent(currentRunId)}/undo`,
|
||||
{ method: 'POST', signal: controller.signal }
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error((await parseStudioErrorResponse(res)) || '回退失败');
|
||||
}
|
||||
const run = await res.json();
|
||||
finishRunRequest(set, { currentRun: run });
|
||||
return run;
|
||||
} catch (e) {
|
||||
if (isRunAbortError(e)) {
|
||||
finishRunRequest(set);
|
||||
return null;
|
||||
}
|
||||
finishRunRequest(set, { runError: e.message || '回退失败' });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
rerollRun: async ({ stream = false } = {}) => {
|
||||
const { runProjectId, currentRunId } = get();
|
||||
if (!runProjectId || !currentRunId) return null;
|
||||
const controller = beginRunRequest(set);
|
||||
try {
|
||||
const { profileId, apiConfig } = await resolveStudioApiConfig();
|
||||
|
||||
const res = await fetch(
|
||||
`/api/studio/projects/${encodeURIComponent(runProjectId)}/runs/${encodeURIComponent(currentRunId)}/reroll`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
stream,
|
||||
profileId,
|
||||
apiConfig,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error((await parseStudioErrorResponse(res)) || '重 roll 失败');
|
||||
}
|
||||
|
||||
if (stream && res.body) {
|
||||
const finalRun = await consumeStudioStreamResponse(res, set, controller.signal);
|
||||
finishRunRequest(set, { currentRun: finalRun });
|
||||
return finalRun;
|
||||
}
|
||||
|
||||
const run = await res.json();
|
||||
finishRunRequest(set, { currentRun: run });
|
||||
return run;
|
||||
} catch (e) {
|
||||
if (isRunAbortError(e)) {
|
||||
finishRunRequest(set);
|
||||
return null;
|
||||
}
|
||||
finishRunRequest(set, { runError: e.message || '重 roll 失败' });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
deleteRun: async (runId) => {
|
||||
const projectId = get().runProjectId;
|
||||
if (!projectId || !runId) return false;
|
||||
set({ runLoading: true, runError: null });
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/studio/projects/${encodeURIComponent(projectId)}/runs/${encodeURIComponent(runId)}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const runs = await get().fetchRuns(projectId);
|
||||
const { currentRunId } = get();
|
||||
if (currentRunId === runId) {
|
||||
const nextId = runs[0]?.id ?? null;
|
||||
if (nextId) {
|
||||
await get().fetchRun(projectId, nextId);
|
||||
} else {
|
||||
set({ currentRunId: null, currentRun: null, runLoading: false });
|
||||
}
|
||||
} else {
|
||||
set({ runLoading: false });
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
set({
|
||||
runLoading: false,
|
||||
runError: e.message || '删除运行失败',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
renameRun: async (runId, title) => {
|
||||
const projectId = get().runProjectId;
|
||||
if (!projectId || !runId) return null;
|
||||
set({ runError: null });
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/studio/projects/${encodeURIComponent(projectId)}/runs/${encodeURIComponent(runId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
let detail = await res.text();
|
||||
try {
|
||||
const parsed = JSON.parse(detail);
|
||||
detail = parsed.detail || detail;
|
||||
} catch {
|
||||
/* keep raw text */
|
||||
}
|
||||
throw new Error(detail || '重命名失败');
|
||||
}
|
||||
const run = await res.json();
|
||||
if (get().currentRunId === runId) {
|
||||
set({ currentRun: run });
|
||||
}
|
||||
await get().fetchRuns(projectId);
|
||||
return run;
|
||||
} catch (e) {
|
||||
set({ runError: e.message || '重命名失败' });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
initStudioRun: async () => {
|
||||
await Promise.all([
|
||||
get().fetchProjects(),
|
||||
get().fetchSkillTemplates(),
|
||||
]);
|
||||
const projects = get().projects;
|
||||
const projectId =
|
||||
get().runProjectId ||
|
||||
get().currentProjectId ||
|
||||
projects[0]?.id ||
|
||||
'default';
|
||||
set({ runProjectId: projectId });
|
||||
await get().fetchWorkflowVariables(projectId);
|
||||
const runs = await get().fetchRuns(projectId);
|
||||
const runId = get().currentRunId || runs[0]?.id || null;
|
||||
if (runId) {
|
||||
await get().fetchRun(projectId, runId);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
export default useStudioStore;
|
||||
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
46
frontend/src/components/PlaceholderPage/PlaceholderPage.css
Normal file
46
frontend/src/components/PlaceholderPage/PlaceholderPage.css
Normal file
@@ -0,0 +1,46 @@
|
||||
.placeholder-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
padding: var(--spacing-xl);
|
||||
background: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
.placeholder-card {
|
||||
text-align: center;
|
||||
max-width: 420px;
|
||||
padding: var(--spacing-xl) var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-light);
|
||||
background: var(--color-bg-secondary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
font-size: 3rem;
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-md);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.placeholder-title {
|
||||
margin: 0 0 var(--spacing-sm);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.placeholder-subtitle {
|
||||
margin: 0 0 var(--spacing-md);
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.placeholder-message {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-tertiary, var(--color-text-secondary));
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
43
frontend/src/components/PlaceholderPage/PlaceholderPage.jsx
Normal file
43
frontend/src/components/PlaceholderPage/PlaceholderPage.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import './PlaceholderPage.css';
|
||||
|
||||
const PLACEHOLDER_META = {
|
||||
studio: {
|
||||
title: '角色 / 世界书工作室',
|
||||
subtitle: '用途1 · Character & Worldbook Studio',
|
||||
icon: '🎭',
|
||||
},
|
||||
novel: {
|
||||
title: '爽文模式',
|
||||
subtitle: '用途3 · Novel Mode',
|
||||
icon: '📖',
|
||||
},
|
||||
room: {
|
||||
title: '多角色房间',
|
||||
subtitle: '用途4 · Multi-Character Room',
|
||||
icon: '🏠',
|
||||
},
|
||||
};
|
||||
|
||||
function PlaceholderPage({ page }) {
|
||||
const meta = PLACEHOLDER_META[page] || {
|
||||
title: 'Coming Soon',
|
||||
subtitle: '',
|
||||
icon: '🚧',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="placeholder-page">
|
||||
<div className="placeholder-card">
|
||||
<span className="placeholder-icon" aria-hidden="true">{meta.icon}</span>
|
||||
<h1 className="placeholder-title">{meta.title}</h1>
|
||||
{meta.subtitle && (
|
||||
<p className="placeholder-subtitle">{meta.subtitle}</p>
|
||||
)}
|
||||
<p className="placeholder-message">Coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PlaceholderPage;
|
||||
1
frontend/src/components/PlaceholderPage/index.js
Normal file
1
frontend/src/components/PlaceholderPage/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './PlaceholderPage';
|
||||
@@ -132,6 +132,8 @@
|
||||
|
||||
/* 角色卡片列表 - 网格布局 */
|
||||
.character-list {
|
||||
position: relative;
|
||||
z-index: var(--z-base-content);
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
@@ -318,14 +320,16 @@
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
position: relative; /* 为悬浮工具栏提供定位上下文 */
|
||||
position: relative;
|
||||
z-index: var(--z-base-content);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* 悬浮工具栏 */
|
||||
.floating-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-top-bar); /* ✅ 组件层 - TopBar 同级 */
|
||||
z-index: 2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
|
||||
@@ -238,11 +238,20 @@ const CharacterCard = () => {
|
||||
try {
|
||||
await deleteCharacter(name);
|
||||
|
||||
// ✅ 删除成功后自动切换到角色分页
|
||||
cancelEditing();
|
||||
selectCharacter(null);
|
||||
|
||||
const chatBox = useChatBoxStore.getState();
|
||||
if (chatBox.currentRole === name) {
|
||||
chatBox.setCurrentRole(null);
|
||||
chatBox.setCurrentChat(null);
|
||||
chatBox.setCharacterName('');
|
||||
}
|
||||
|
||||
const { setActiveTab } = useSideBarLeftStore.getState();
|
||||
setActiveTab('character');
|
||||
|
||||
console.log('[CharacterCard] 角色已删除,已切换到角色分页');
|
||||
console.log('[CharacterCard] 角色已删除,已返回角色选择列表');
|
||||
} catch (err) {
|
||||
console.error('删除失败:', err);
|
||||
}
|
||||
|
||||
195
frontend/src/components/Studio/StudioBindingEditorPopup.css
Normal file
195
frontend/src/components/Studio/StudioBindingEditorPopup.css
Normal file
@@ -0,0 +1,195 @@
|
||||
.studio-binding-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
min-height: 240px;
|
||||
min-width: min(92vw, 420px);
|
||||
}
|
||||
|
||||
.studio-binding-editor__tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding-bottom: var(--spacing-xs);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.studio-binding-editor__tab {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.studio-binding-editor__tab.is-active {
|
||||
border-color: var(--color-border);
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.studio-binding-editor__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
max-height: min(55vh, 480px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.studio-binding-editor__status,
|
||||
.studio-binding-editor__empty,
|
||||
.studio-binding-editor__hint {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.studio-binding-editor__status--error {
|
||||
color: #f5576c;
|
||||
}
|
||||
|
||||
.studio-binding-editor__hint--success {
|
||||
color: #22c55e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.studio-binding-editor__hero {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.studio-binding-editor__avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.studio-binding-editor__avatar--placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg-tertiary);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.studio-binding-editor__name {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.studio-binding-editor__meta {
|
||||
margin: 2px 0 0;
|
||||
font-size: 0.68rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.studio-binding-editor__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.studio-binding-editor__field-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.studio-binding-editor__input,
|
||||
.studio-binding-editor__textarea {
|
||||
width: 100%;
|
||||
padding: var(--spacing-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border-light);
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.studio-binding-editor__save-btn {
|
||||
align-self: flex-start;
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-accent);
|
||||
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.studio-binding-editor__save-btn:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.studio-binding-editor__save-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.studio-binding-editor__entries {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.studio-binding-editor__entry {
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.studio-binding-editor__entry.is-highlighted {
|
||||
border-color: #22c55e;
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, #22c55e 35%, transparent);
|
||||
}
|
||||
|
||||
.studio-binding-editor__entry-head {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border: none;
|
||||
background: var(--color-bg-tertiary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.studio-binding-editor__entry-head:hover {
|
||||
background: color-mix(in srgb, var(--color-accent) 8%, var(--color-bg-tertiary));
|
||||
}
|
||||
|
||||
.studio-binding-editor__entry-title {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.studio-binding-editor__entry-meta {
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.studio-binding-editor__entry-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-sm);
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
387
frontend/src/components/Studio/StudioBindingEditorPopup.jsx
Normal file
387
frontend/src/components/Studio/StudioBindingEditorPopup.jsx
Normal file
@@ -0,0 +1,387 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import useCharacterStore from '../../Store/SideBarLeft/CharacterSlice';
|
||||
import StudioInsertionPopup from './StudioInsertionPopup';
|
||||
|
||||
import './StudioBindingEditorPopup.css';
|
||||
|
||||
const CHARACTER_FIELDS = [
|
||||
{ key: 'description', label: '描述', rows: 5 },
|
||||
{ key: 'personality', label: '性格', rows: 3 },
|
||||
{ key: 'scenario', label: '场景', rows: 3 },
|
||||
{ key: 'first_mes', label: '开场白', rows: 4 },
|
||||
{ key: 'mes_example', label: '对话示例', rows: 4 },
|
||||
];
|
||||
|
||||
function entryLabel(entry) {
|
||||
if (entry.comment?.trim()) return entry.comment.trim();
|
||||
if (Array.isArray(entry.key) && entry.key.length) return entry.key.join(', ');
|
||||
return '未命名条目';
|
||||
}
|
||||
|
||||
function StudioBindingEditorPopup({
|
||||
open,
|
||||
characterName,
|
||||
worldbookName,
|
||||
highlightEntryUid = null,
|
||||
initialTab = 'character',
|
||||
onClose,
|
||||
}) {
|
||||
const updateCharacter = useCharacterStore((s) => s.updateCharacter);
|
||||
const fetchCharacters = useCharacterStore((s) => s.fetchCharacters);
|
||||
|
||||
const [tab, setTab] = useState(initialTab);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [saveHint, setSaveHint] = useState('');
|
||||
const [character, setCharacter] = useState(null);
|
||||
const [characterForm, setCharacterForm] = useState(null);
|
||||
const [characterSaving, setCharacterSaving] = useState(false);
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [expandedEntryUid, setExpandedEntryUid] = useState(null);
|
||||
const [entryForms, setEntryForms] = useState({});
|
||||
const [entrySavingUid, setEntrySavingUid] = useState(null);
|
||||
const highlightRef = useRef(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!characterName && !worldbookName) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const tasks = [];
|
||||
|
||||
if (characterName) {
|
||||
tasks.push(
|
||||
fetch(`/api/characters/${encodeURIComponent(characterName)}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error('加载角色卡失败');
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setCharacter(data);
|
||||
setCharacterForm({
|
||||
description: data.description || '',
|
||||
personality: data.personality || '',
|
||||
scenario: data.scenario || '',
|
||||
first_mes: data.first_mes || '',
|
||||
mes_example: data.mes_example || '',
|
||||
});
|
||||
})
|
||||
);
|
||||
} else {
|
||||
setCharacter(null);
|
||||
setCharacterForm(null);
|
||||
}
|
||||
|
||||
if (worldbookName) {
|
||||
tasks.push(
|
||||
fetch(
|
||||
`/api/worldbooks/${encodeURIComponent(worldbookName)}/entries?page=1&page_size=100`
|
||||
)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error('加载世界书条目失败');
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
const list = data.entries || [];
|
||||
setEntries(list);
|
||||
const forms = {};
|
||||
list.forEach((entry) => {
|
||||
forms[entry.uid] = {
|
||||
comment: entry.comment || '',
|
||||
content: entry.content || '',
|
||||
key: Array.isArray(entry.key) ? entry.key.join(', ') : (entry.key || ''),
|
||||
};
|
||||
});
|
||||
setEntryForms(forms);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
setEntries([]);
|
||||
setEntryForms({});
|
||||
}
|
||||
|
||||
await Promise.all(tasks);
|
||||
} catch (e) {
|
||||
setError(e.message || '加载绑定资源失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [characterName, worldbookName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTab(initialTab);
|
||||
setSaveHint('');
|
||||
setExpandedEntryUid(highlightEntryUid || null);
|
||||
loadData();
|
||||
}
|
||||
}, [open, initialTab, highlightEntryUid, loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !highlightEntryUid) return undefined;
|
||||
const timer = setTimeout(() => {
|
||||
highlightRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 200);
|
||||
return () => clearTimeout(timer);
|
||||
}, [open, highlightEntryUid, entries.length, loading]);
|
||||
|
||||
const handleCharacterFieldChange = (key, value) => {
|
||||
setCharacterForm((prev) => ({ ...prev, [key]: value }));
|
||||
setSaveHint('');
|
||||
};
|
||||
|
||||
const handleSaveCharacter = async () => {
|
||||
if (!characterName || !character || !characterForm) return;
|
||||
setCharacterSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await updateCharacter(characterName, {
|
||||
...character,
|
||||
...characterForm,
|
||||
});
|
||||
await fetchCharacters();
|
||||
setSaveHint('角色卡已保存');
|
||||
setTimeout(() => setSaveHint(''), 2500);
|
||||
} catch (e) {
|
||||
setError(e.message || '保存角色卡失败');
|
||||
} finally {
|
||||
setCharacterSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEntryFieldChange = (uid, key, value) => {
|
||||
setEntryForms((prev) => ({
|
||||
...prev,
|
||||
[uid]: { ...prev[uid], [key]: value },
|
||||
}));
|
||||
setSaveHint('');
|
||||
};
|
||||
|
||||
const handleSaveEntry = async (uid) => {
|
||||
if (!worldbookName) return;
|
||||
const form = entryForms[uid];
|
||||
if (!form) return;
|
||||
setEntrySavingUid(uid);
|
||||
setError('');
|
||||
try {
|
||||
const keyList = form.key
|
||||
.split(/[,,]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const res = await fetch(
|
||||
`/api/worldbooks/${encodeURIComponent(worldbookName)}/entries/${encodeURIComponent(uid)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
comment: form.comment,
|
||||
content: form.content,
|
||||
key: keyList,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const detail = await res.text();
|
||||
throw new Error(detail || '保存条目失败');
|
||||
}
|
||||
setSaveHint('世界书条目已保存');
|
||||
setTimeout(() => setSaveHint(''), 2500);
|
||||
await loadData();
|
||||
setExpandedEntryUid(uid);
|
||||
} catch (e) {
|
||||
setError(e.message || '保存条目失败');
|
||||
} finally {
|
||||
setEntrySavingUid(null);
|
||||
}
|
||||
};
|
||||
|
||||
const title = characterName ? `绑定编辑 · ${characterName}` : '绑定编辑';
|
||||
|
||||
return (
|
||||
<StudioInsertionPopup
|
||||
open={open}
|
||||
title={title}
|
||||
defaultExpanded
|
||||
onClose={onClose}
|
||||
>
|
||||
{() => (
|
||||
<div className="studio-binding-editor">
|
||||
<div className="studio-binding-editor__tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'character'}
|
||||
className={`studio-binding-editor__tab${tab === 'character' ? ' is-active' : ''}`}
|
||||
onClick={() => setTab('character')}
|
||||
>
|
||||
角色卡
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'worldbook'}
|
||||
className={`studio-binding-editor__tab${tab === 'worldbook' ? ' is-active' : ''}`}
|
||||
onClick={() => setTab('worldbook')}
|
||||
>
|
||||
世界书
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{saveHint ? (
|
||||
<p className="studio-binding-editor__hint studio-binding-editor__hint--success">
|
||||
{saveHint}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="studio-binding-editor__status">加载中…</p>
|
||||
) : error ? (
|
||||
<p className="studio-binding-editor__status studio-binding-editor__status--error">
|
||||
{error}
|
||||
</p>
|
||||
) : tab === 'character' ? (
|
||||
<div className="studio-binding-editor__panel">
|
||||
{!character || !characterForm ? (
|
||||
<p className="studio-binding-editor__empty">暂无绑定角色卡</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="studio-binding-editor__hero">
|
||||
{character.avatar ? (
|
||||
<img
|
||||
src={character.avatar}
|
||||
alt=""
|
||||
className="studio-binding-editor__avatar"
|
||||
/>
|
||||
) : (
|
||||
<div className="studio-binding-editor__avatar studio-binding-editor__avatar--placeholder">
|
||||
🎭
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="studio-binding-editor__name">{character.name}</h3>
|
||||
{worldbookName ? (
|
||||
<p className="studio-binding-editor__meta">世界书:{worldbookName}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{CHARACTER_FIELDS.map(({ key, label, rows }) => (
|
||||
<label key={key} className="studio-binding-editor__field">
|
||||
<span className="studio-binding-editor__field-label">{label}</span>
|
||||
<textarea
|
||||
className="studio-binding-editor__textarea"
|
||||
rows={rows}
|
||||
value={characterForm[key] || ''}
|
||||
onChange={(e) => handleCharacterFieldChange(key, e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="studio-binding-editor__save-btn"
|
||||
onClick={handleSaveCharacter}
|
||||
disabled={characterSaving}
|
||||
>
|
||||
{characterSaving ? '保存中…' : '保存角色卡'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="studio-binding-editor__panel">
|
||||
{!worldbookName ? (
|
||||
<p className="studio-binding-editor__empty">暂无绑定世界书</p>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="studio-binding-editor__empty">
|
||||
世界书「{worldbookName}」暂无条目
|
||||
</p>
|
||||
) : (
|
||||
<ul className="studio-binding-editor__entries">
|
||||
{entries.map((entry) => {
|
||||
const expanded = expandedEntryUid === entry.uid;
|
||||
const highlighted = highlightEntryUid === entry.uid;
|
||||
const form = entryForms[entry.uid] || {
|
||||
comment: entry.comment || '',
|
||||
content: entry.content || '',
|
||||
key: Array.isArray(entry.key) ? entry.key.join(', ') : '',
|
||||
};
|
||||
return (
|
||||
<li
|
||||
key={entry.uid}
|
||||
ref={highlighted ? highlightRef : null}
|
||||
className={`studio-binding-editor__entry${highlighted ? ' is-highlighted' : ''}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="studio-binding-editor__entry-head"
|
||||
onClick={() =>
|
||||
setExpandedEntryUid(expanded ? null : entry.uid)
|
||||
}
|
||||
>
|
||||
<span className="studio-binding-editor__entry-title">
|
||||
{entryLabel(entry)}
|
||||
</span>
|
||||
<span className="studio-binding-editor__entry-meta">
|
||||
{entry.activationType || '—'} · order {entry.order ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
{expanded ? (
|
||||
<div className="studio-binding-editor__entry-body">
|
||||
<label className="studio-binding-editor__field">
|
||||
<span className="studio-binding-editor__field-label">备注</span>
|
||||
<input
|
||||
type="text"
|
||||
className="studio-binding-editor__input"
|
||||
value={form.comment}
|
||||
onChange={(e) =>
|
||||
handleEntryFieldChange(entry.uid, 'comment', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-binding-editor__field">
|
||||
<span className="studio-binding-editor__field-label">关键词</span>
|
||||
<input
|
||||
type="text"
|
||||
className="studio-binding-editor__input"
|
||||
value={form.key}
|
||||
onChange={(e) =>
|
||||
handleEntryFieldChange(entry.uid, 'key', e.target.value)
|
||||
}
|
||||
placeholder="逗号分隔"
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-binding-editor__field">
|
||||
<span className="studio-binding-editor__field-label">内容</span>
|
||||
<textarea
|
||||
className="studio-binding-editor__textarea"
|
||||
rows={6}
|
||||
value={form.content}
|
||||
onChange={(e) =>
|
||||
handleEntryFieldChange(entry.uid, 'content', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="studio-binding-editor__save-btn"
|
||||
onClick={() => handleSaveEntry(entry.uid)}
|
||||
disabled={entrySavingUid === entry.uid}
|
||||
>
|
||||
{entrySavingUid === entry.uid ? '保存中…' : '保存条目'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</StudioInsertionPopup>
|
||||
);
|
||||
}
|
||||
|
||||
export default StudioBindingEditorPopup;
|
||||
165
frontend/src/components/Studio/StudioBindingPreviewPopup.css
Normal file
165
frontend/src/components/Studio/StudioBindingPreviewPopup.css
Normal file
@@ -0,0 +1,165 @@
|
||||
.studio-binding-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.studio-binding-preview__tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding-bottom: var(--spacing-xs);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.studio-binding-preview__tab {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.studio-binding-preview__tab.is-active {
|
||||
border-color: var(--color-border);
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.studio-binding-preview__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
max-height: min(50vh, 420px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.studio-binding-preview__status {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.studio-binding-preview__status--error {
|
||||
color: #f5576c;
|
||||
}
|
||||
|
||||
.studio-binding-preview__empty {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.studio-binding-preview__hero {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.studio-binding-preview__avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.studio-binding-preview__avatar--placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg-tertiary);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.studio-binding-preview__name {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.studio-binding-preview__meta {
|
||||
margin: 2px 0 0;
|
||||
font-size: 0.68rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.studio-binding-preview__field-label {
|
||||
margin: 0 0 4px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.studio-binding-preview__field-value {
|
||||
margin: 0;
|
||||
padding: var(--spacing-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border-light);
|
||||
background: var(--color-bg-tertiary);
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
max-height: 120px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.studio-binding-preview__entries {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.studio-binding-preview__entry {
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.studio-binding-preview__entry-head {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border: none;
|
||||
background: var(--color-bg-tertiary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.studio-binding-preview__entry-head:hover {
|
||||
background: color-mix(in srgb, var(--color-accent) 8%, var(--color-bg-tertiary));
|
||||
}
|
||||
|
||||
.studio-binding-preview__entry-title {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.studio-binding-preview__entry-meta {
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.studio-binding-preview__entry-content {
|
||||
margin: 0;
|
||||
padding: var(--spacing-sm);
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
background: var(--color-bg-secondary);
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
200
frontend/src/components/Studio/StudioBindingPreviewPopup.jsx
Normal file
200
frontend/src/components/Studio/StudioBindingPreviewPopup.jsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import StudioInsertionPopup from './StudioInsertionPopup';
|
||||
|
||||
import './StudioBindingPreviewPopup.css';
|
||||
|
||||
const CHARACTER_FIELDS = [
|
||||
{ key: 'description', label: '描述' },
|
||||
{ key: 'personality', label: '性格' },
|
||||
{ key: 'scenario', label: '场景' },
|
||||
{ key: 'first_mes', label: '开场白' },
|
||||
{ key: 'mes_example', label: '对话示例' },
|
||||
];
|
||||
|
||||
function StudioBindingPreviewPopup({
|
||||
open,
|
||||
characterName,
|
||||
worldbookName,
|
||||
onClose,
|
||||
}) {
|
||||
const [tab, setTab] = useState('character');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [character, setCharacter] = useState(null);
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [expandedEntryUid, setExpandedEntryUid] = useState(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!characterName && !worldbookName) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const tasks = [];
|
||||
|
||||
if (characterName) {
|
||||
tasks.push(
|
||||
fetch(`/api/characters/${encodeURIComponent(characterName)}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error('加载角色卡失败');
|
||||
return res.json();
|
||||
})
|
||||
.then(setCharacter)
|
||||
);
|
||||
} else {
|
||||
setCharacter(null);
|
||||
}
|
||||
|
||||
if (worldbookName) {
|
||||
tasks.push(
|
||||
fetch(
|
||||
`/api/worldbooks/${encodeURIComponent(worldbookName)}/entries?page=1&page_size=50`
|
||||
)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error('加载世界书条目失败');
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => setEntries(data.entries || []))
|
||||
);
|
||||
} else {
|
||||
setEntries([]);
|
||||
}
|
||||
|
||||
await Promise.all(tasks);
|
||||
} catch (e) {
|
||||
setError(e.message || '加载绑定资源失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [characterName, worldbookName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTab('character');
|
||||
setExpandedEntryUid(null);
|
||||
loadData();
|
||||
}
|
||||
}, [open, loadData]);
|
||||
|
||||
const title = characterName ? `绑定预览 · ${characterName}` : '绑定预览';
|
||||
|
||||
return (
|
||||
<StudioInsertionPopup
|
||||
open={open}
|
||||
title={title}
|
||||
defaultExpanded
|
||||
onClose={onClose}
|
||||
>
|
||||
{() => (
|
||||
<div className="studio-binding-preview">
|
||||
<div className="studio-binding-preview__tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'character'}
|
||||
className={`studio-binding-preview__tab${tab === 'character' ? ' is-active' : ''}`}
|
||||
onClick={() => setTab('character')}
|
||||
>
|
||||
角色卡
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'worldbook'}
|
||||
className={`studio-binding-preview__tab${tab === 'worldbook' ? ' is-active' : ''}`}
|
||||
onClick={() => setTab('worldbook')}
|
||||
>
|
||||
世界书
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="studio-binding-preview__status">加载中…</p>
|
||||
) : error ? (
|
||||
<p className="studio-binding-preview__status studio-binding-preview__status--error">
|
||||
{error}
|
||||
</p>
|
||||
) : tab === 'character' ? (
|
||||
<div className="studio-binding-preview__panel">
|
||||
{!character ? (
|
||||
<p className="studio-binding-preview__empty">暂无绑定角色卡</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="studio-binding-preview__hero">
|
||||
{character.avatar ? (
|
||||
<img
|
||||
src={character.avatar}
|
||||
alt=""
|
||||
className="studio-binding-preview__avatar"
|
||||
/>
|
||||
) : (
|
||||
<div className="studio-binding-preview__avatar studio-binding-preview__avatar--placeholder">
|
||||
🎭
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="studio-binding-preview__name">{character.name}</h3>
|
||||
{worldbookName ? (
|
||||
<p className="studio-binding-preview__meta">世界书:{worldbookName}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{CHARACTER_FIELDS.map(({ key, label }) => {
|
||||
const value = (character[key] || '').trim();
|
||||
if (!value) return null;
|
||||
return (
|
||||
<section key={key} className="studio-binding-preview__field">
|
||||
<h4 className="studio-binding-preview__field-label">{label}</h4>
|
||||
<pre className="studio-binding-preview__field-value">{value}</pre>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="studio-binding-preview__panel">
|
||||
{!worldbookName ? (
|
||||
<p className="studio-binding-preview__empty">暂无绑定世界书</p>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="studio-binding-preview__empty">世界书「{worldbookName}」暂无条目</p>
|
||||
) : (
|
||||
<ul className="studio-binding-preview__entries">
|
||||
{entries.map((entry) => {
|
||||
const expanded = expandedEntryUid === entry.uid;
|
||||
const comment = entry.comment || entry.key?.join?.(', ') || '未命名条目';
|
||||
return (
|
||||
<li key={entry.uid} className="studio-binding-preview__entry">
|
||||
<button
|
||||
type="button"
|
||||
className="studio-binding-preview__entry-head"
|
||||
onClick={() =>
|
||||
setExpandedEntryUid(expanded ? null : entry.uid)
|
||||
}
|
||||
>
|
||||
<span className="studio-binding-preview__entry-title">
|
||||
{comment}
|
||||
</span>
|
||||
<span className="studio-binding-preview__entry-meta">
|
||||
{entry.activationType || '—'} · order {entry.order ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
{expanded ? (
|
||||
<pre className="studio-binding-preview__entry-content">
|
||||
{entry.content || '(空内容)'}
|
||||
</pre>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</StudioInsertionPopup>
|
||||
);
|
||||
}
|
||||
|
||||
export default StudioBindingPreviewPopup;
|
||||
18
frontend/src/components/Studio/StudioContextBlockPopup.css
Normal file
18
frontend/src/components/Studio/StudioContextBlockPopup.css
Normal file
@@ -0,0 +1,18 @@
|
||||
.studio-context-block-popup__content {
|
||||
margin: 0;
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border-light);
|
||||
background: var(--color-bg-tertiary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.studio-context-block-popup__preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
33
frontend/src/components/Studio/StudioContextBlockPopup.jsx
Normal file
33
frontend/src/components/Studio/StudioContextBlockPopup.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
|
||||
import StudioInsertionPopup from './StudioInsertionPopup';
|
||||
|
||||
import './StudioContextBlockPopup.css';
|
||||
|
||||
function StudioContextBlockPopup({ open, block, onClose }) {
|
||||
if (!block) return null;
|
||||
|
||||
return (
|
||||
<StudioInsertionPopup
|
||||
open={open}
|
||||
title={block.label}
|
||||
defaultExpanded
|
||||
onClose={onClose}
|
||||
>
|
||||
{({ expanded }) =>
|
||||
expanded ? (
|
||||
<pre className="studio-context-block-popup__content">
|
||||
{block.content}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="studio-context-block-popup__preview">
|
||||
<span className="studio-insertion-popup__field-label">来源</span>
|
||||
<span className="studio-insertion-popup__field-value">{block.source}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</StudioInsertionPopup>
|
||||
);
|
||||
}
|
||||
|
||||
export default StudioContextBlockPopup;
|
||||
1198
frontend/src/components/Studio/StudioEditPage.css
Normal file
1198
frontend/src/components/Studio/StudioEditPage.css
Normal file
File diff suppressed because it is too large
Load Diff
764
frontend/src/components/Studio/StudioEditPage.jsx
Normal file
764
frontend/src/components/Studio/StudioEditPage.jsx
Normal file
@@ -0,0 +1,764 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import useStudioStore from '../../Store/Studio/StudioSlice';
|
||||
import FieldLabel from './edit/FieldLabel';
|
||||
import VariableChips from './edit/VariableChips';
|
||||
import {
|
||||
DEFAULT_THINKING_PROMPT,
|
||||
orderNodesByIds,
|
||||
sortNodesByLogic,
|
||||
} from './edit/variableUtils';
|
||||
import WorldbookInsertion from './edit/WorldbookInsertion';
|
||||
import ScoringDimensions from './edit/ScoringDimensions';
|
||||
|
||||
import './StudioEditPage.css';
|
||||
|
||||
/*
|
||||
* Node detail layout: primary config center + secondary fixed edge rail.
|
||||
* Industry refs: n8n (Parameters center + Settings sidebar), Retool inspector,
|
||||
* Figma right properties rail — main editing in fluid center, refs in ~300px rail.
|
||||
*/
|
||||
|
||||
const DEFAULT_TEMPLATE_ID = 'builtin.studio.example';
|
||||
|
||||
const WORKFLOW_GOAL_TIP =
|
||||
'此内容会在运行时被注入到 LLM 系统提示中,作为工作流整体目标,供各步骤参考。';
|
||||
|
||||
const WORKFLOW_DESC_TIP =
|
||||
'仅供人类阅读的简短说明,不会写入 LLM 提示。';
|
||||
|
||||
const NODE_SORT_STORAGE_KEY = 'studio-node-sort-mode';
|
||||
|
||||
function getStoredSortMode(projectId) {
|
||||
if (!projectId || typeof sessionStorage === 'undefined') return 'manual';
|
||||
return sessionStorage.getItem(`${NODE_SORT_STORAGE_KEY}:${projectId}`) || 'manual';
|
||||
}
|
||||
|
||||
function storeSortMode(projectId, mode) {
|
||||
if (!projectId || typeof sessionStorage === 'undefined') return;
|
||||
sessionStorage.setItem(`${NODE_SORT_STORAGE_KEY}:${projectId}`, mode);
|
||||
}
|
||||
function getSkillTemplate(skillTemplates, skillId) {
|
||||
return skillTemplates.find((t) => t.skillId === skillId);
|
||||
}
|
||||
|
||||
function StudioEditPage() {
|
||||
const {
|
||||
projects,
|
||||
workflowTemplates,
|
||||
currentProjectId,
|
||||
meta,
|
||||
pipeline,
|
||||
skillTemplates,
|
||||
selectedNodeId,
|
||||
loading,
|
||||
saving,
|
||||
error,
|
||||
saveMessage,
|
||||
setPipelineLocal,
|
||||
setMetaLocal,
|
||||
setSelectedNodeId,
|
||||
updateNode,
|
||||
updateNodeConfig,
|
||||
addNode,
|
||||
removeNode,
|
||||
fetchProject,
|
||||
savePipeline,
|
||||
createProject,
|
||||
deleteProject,
|
||||
renameProject,
|
||||
updateProjectMeta,
|
||||
clearSaveMessage,
|
||||
} = useStudioStore();
|
||||
|
||||
const [dragIndex, setDragIndex] = useState(null);
|
||||
const [showAddNode, setShowAddNode] = useState(false);
|
||||
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
|
||||
const [showRenameProjectModal, setShowRenameProjectModal] = useState(false);
|
||||
const [newNodeSkillId, setNewNodeSkillId] = useState('studio.worldbook_entry');
|
||||
const [newNodeName, setNewNodeName] = useState('');
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
const [newProjectTemplateId, setNewProjectTemplateId] = useState(DEFAULT_TEMPLATE_ID);
|
||||
const [renameProjectName, setRenameProjectName] = useState('');
|
||||
const [nodeSortMode, setNodeSortMode] = useState('manual');
|
||||
const [manualOrder, setManualOrder] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentProjectId) return;
|
||||
setNodeSortMode(getStoredSortMode(currentProjectId));
|
||||
}, [currentProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pipeline?.nodes) {
|
||||
setManualOrder([]);
|
||||
return;
|
||||
}
|
||||
setManualOrder(pipeline.nodes.map((n) => n.id));
|
||||
}, [currentProjectId, pipeline?.nodes?.length]);
|
||||
|
||||
const displayNodes = useMemo(() => {
|
||||
const nodes = pipeline?.nodes || [];
|
||||
if (nodeSortMode === 'logic') {
|
||||
return sortNodesByLogic(pipeline);
|
||||
}
|
||||
return orderNodesByIds(nodes, manualOrder);
|
||||
}, [pipeline, nodeSortMode, manualOrder]);
|
||||
|
||||
const handleSortModeChange = (mode) => {
|
||||
setNodeSortMode(mode);
|
||||
if (currentProjectId) storeSortMode(currentProjectId, mode);
|
||||
if (mode === 'manual' && pipeline?.nodes) {
|
||||
setManualOrder(pipeline.nodes.map((n) => n.id));
|
||||
}
|
||||
};
|
||||
const selectedNode = pipeline?.nodes?.find((n) => n.id === selectedNodeId) ?? null;
|
||||
const selectedTpl = selectedNode
|
||||
? getSkillTemplate(skillTemplates, selectedNode.skillId)
|
||||
: null;
|
||||
const isWorldbook = selectedNode?.skillId === 'studio.worldbook_entry';
|
||||
const insertion = selectedNode?.config?.insertion || {};
|
||||
const scoring = selectedNode?.config?.scoring || { enabled: true, dimensions: [] };
|
||||
const dimensions = scoring.dimensions || [];
|
||||
|
||||
const templateOptions =
|
||||
workflowTemplates.length > 0
|
||||
? workflowTemplates
|
||||
: [{ id: DEFAULT_TEMPLATE_ID, name: '世界书条目创建' }];
|
||||
|
||||
const handleProjectChange = async (e) => {
|
||||
const id = e.target.value;
|
||||
if (id) await fetchProject(id);
|
||||
};
|
||||
|
||||
const openNewProjectModal = () => {
|
||||
setNewProjectName('');
|
||||
setNewProjectTemplateId(templateOptions[0]?.id || DEFAULT_TEMPLATE_ID);
|
||||
setShowNewProjectModal(true);
|
||||
};
|
||||
|
||||
const openRenameProjectModal = () => {
|
||||
setRenameProjectName(meta?.name || '');
|
||||
setShowRenameProjectModal(true);
|
||||
};
|
||||
|
||||
const handleCreateProject = async (e) => {
|
||||
e.preventDefault();
|
||||
const name = newProjectName.trim() || '新项目';
|
||||
const result = await createProject(name, newProjectTemplateId);
|
||||
if (result) {
|
||||
setShowNewProjectModal(false);
|
||||
setNewProjectName('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameProject = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!currentProjectId) return;
|
||||
const name = renameProjectName.trim();
|
||||
if (!name) return;
|
||||
const result = await renameProject(currentProjectId, name);
|
||||
if (result) {
|
||||
setShowRenameProjectModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
if (!currentProjectId || !meta) return;
|
||||
const confirmed = window.confirm(
|
||||
`确定删除项目「${meta.name}」?\n\n将同时删除该项目下的所有运行记录,此操作不可撤销。`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
await deleteProject(currentProjectId);
|
||||
};
|
||||
|
||||
const handleDescriptionBlur = () => {
|
||||
if (!currentProjectId || !meta) return;
|
||||
updateProjectMeta(currentProjectId, { description: meta.description ?? '' });
|
||||
};
|
||||
|
||||
const handleDragStart = (index) => {
|
||||
if (nodeSortMode !== 'manual') return;
|
||||
setDragIndex(index);
|
||||
};
|
||||
|
||||
const handleDragOver = (e, index) => {
|
||||
if (nodeSortMode !== 'manual') return;
|
||||
e.preventDefault();
|
||||
if (dragIndex === null || dragIndex === index) return;
|
||||
|
||||
const ids = displayNodes.map((n) => n.id);
|
||||
const nextIds = [...ids];
|
||||
const [moved] = nextIds.splice(dragIndex, 1);
|
||||
nextIds.splice(index, 0, moved);
|
||||
|
||||
const nodeMap = Object.fromEntries((pipeline.nodes || []).map((n) => [n.id, n]));
|
||||
const reordered = nextIds.map((id) => nodeMap[id]).filter(Boolean);
|
||||
setPipelineLocal({ ...pipeline, nodes: reordered });
|
||||
setManualOrder(nextIds);
|
||||
setDragIndex(index);
|
||||
};
|
||||
const handleDragEnd = () => setDragIndex(null);
|
||||
|
||||
const toggleInputRef = (nodeId, ref, label) => {
|
||||
const node = pipeline.nodes.find((n) => n.id === nodeId);
|
||||
if (!node) return;
|
||||
const exists = (node.inputs || []).some((i) => i.ref === ref);
|
||||
let inputs;
|
||||
if (exists) {
|
||||
inputs = (node.inputs || []).filter((i) => i.ref !== ref);
|
||||
} else {
|
||||
inputs = [...(node.inputs || []), { ref, label }];
|
||||
}
|
||||
updateNode(nodeId, { inputs });
|
||||
};
|
||||
|
||||
const updateInsertion = (field, value) => {
|
||||
if (!selectedNode) return;
|
||||
const next = { ...(selectedNode.config?.insertion || {}), [field]: value };
|
||||
updateNodeConfig(selectedNode.id, { insertion: next });
|
||||
};
|
||||
|
||||
const updateRagConfig = (field, value) => {
|
||||
if (!selectedNode) return;
|
||||
const ragConfig = {
|
||||
...(insertion.ragConfig || {}),
|
||||
[field]: value,
|
||||
};
|
||||
updateInsertion('ragConfig', ragConfig);
|
||||
};
|
||||
|
||||
const updateScoring = (field, value) => {
|
||||
if (!selectedNode) return;
|
||||
const next = { ...(selectedNode.config?.scoring || {}), [field]: value };
|
||||
updateNodeConfig(selectedNode.id, { scoring: next });
|
||||
};
|
||||
|
||||
const addDimension = () => {
|
||||
const id = `dim-${Date.now()}`;
|
||||
updateScoring('dimensions', [
|
||||
...dimensions,
|
||||
{ id, name: '', criteria: '' },
|
||||
]);
|
||||
};
|
||||
|
||||
const updateDimension = (index, field, value) => {
|
||||
const next = dimensions.map((d, i) =>
|
||||
i === index ? { ...d, [field]: value } : d
|
||||
);
|
||||
updateScoring('dimensions', next);
|
||||
};
|
||||
|
||||
const removeDimension = (index) => {
|
||||
updateScoring(
|
||||
'dimensions',
|
||||
dimensions.filter((_, i) => i !== index)
|
||||
);
|
||||
};
|
||||
|
||||
if (loading && !pipeline) {
|
||||
return <div className="studio-edit-loading">加载中…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="studio-edit-page">
|
||||
<header className="studio-edit-top">
|
||||
<div className="studio-edit-top__label studio-edit-top__label--project">
|
||||
<span className="studio-edit-top__label-text">项目</span>
|
||||
</div>
|
||||
<div className="studio-edit-top__label studio-edit-top__label--goal">
|
||||
<FieldLabel tip={WORKFLOW_GOAL_TIP}>工作流目标</FieldLabel>
|
||||
</div>
|
||||
<div className="studio-edit-top__label studio-edit-top__label--desc">
|
||||
<FieldLabel tip={WORKFLOW_DESC_TIP}>工作流简介</FieldLabel>
|
||||
</div>
|
||||
|
||||
<div className="studio-edit-top__control studio-edit-top__control--project">
|
||||
<select
|
||||
className="studio-edit-select studio-edit-select--project"
|
||||
value={currentProjectId || ''}
|
||||
onChange={handleProjectChange}
|
||||
aria-label="选择项目"
|
||||
>
|
||||
{projects.length === 0 && <option value="">无项目</option>}
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="studio-edit-top__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="studio-edit-btn studio-edit-btn-danger studio-edit-btn-sm"
|
||||
onClick={handleDeleteProject}
|
||||
disabled={!currentProjectId || loading || saving}
|
||||
title="删除当前项目及其运行记录"
|
||||
>
|
||||
删除项目
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="studio-edit-btn studio-edit-btn-sm"
|
||||
onClick={openRenameProjectModal}
|
||||
disabled={!currentProjectId || loading || saving}
|
||||
>
|
||||
改名
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="studio-edit-btn studio-edit-btn-sm"
|
||||
onClick={openNewProjectModal}
|
||||
disabled={loading}
|
||||
>
|
||||
新建项目
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="studio-edit-top__control studio-edit-top__control--goal">
|
||||
<textarea
|
||||
className="studio-edit-textarea studio-edit-textarea--goal"
|
||||
rows={2}
|
||||
value={pipeline?.workflowGoal ?? ''}
|
||||
onChange={(e) =>
|
||||
setPipelineLocal({ ...pipeline, workflowGoal: e.target.value })
|
||||
}
|
||||
placeholder="描述本 Studio 项目要完成的整体设计目标…"
|
||||
aria-label="工作流目标"
|
||||
/>
|
||||
</div>
|
||||
<div className="studio-edit-top__control studio-edit-top__control--desc">
|
||||
<input
|
||||
type="text"
|
||||
className="studio-edit-input studio-edit-input--desc"
|
||||
value={meta?.description ?? ''}
|
||||
onChange={(e) => setMetaLocal({ description: e.target.value })}
|
||||
onBlur={handleDescriptionBlur}
|
||||
placeholder="简短备注"
|
||||
maxLength={120}
|
||||
disabled={!currentProjectId || saving}
|
||||
aria-label="工作流简介"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
{(error || saveMessage) && (
|
||||
<div
|
||||
className={`studio-edit-banner ${error ? 'error' : 'success'}`}
|
||||
role="status"
|
||||
>
|
||||
{error || saveMessage}
|
||||
<button type="button" className="studio-edit-banner-close" onClick={clearSaveMessage}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="studio-edit-main">
|
||||
<aside className="studio-edit-nodes">
|
||||
<div className="studio-edit-nodes-header">
|
||||
<h2 className="studio-edit-subtitle">节点列表</h2>
|
||||
<div className="studio-edit-nodes-header__actions">
|
||||
<div className="studio-edit-sort-toggle" role="group" aria-label="节点排序">
|
||||
<button
|
||||
type="button"
|
||||
className={`studio-edit-sort-btn${nodeSortMode === 'manual' ? ' active' : ''}`}
|
||||
onClick={() => handleSortModeChange('manual')}
|
||||
title="按拖拽/保存顺序排列"
|
||||
>
|
||||
手动
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`studio-edit-sort-btn${nodeSortMode === 'logic' ? ' active' : ''}`}
|
||||
onClick={() => handleSortModeChange('logic')}
|
||||
title="按引用依赖拓扑排序"
|
||||
>
|
||||
逻辑
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="studio-edit-btn studio-edit-btn-sm"
|
||||
onClick={() => setShowAddNode(!showAddNode)}
|
||||
>
|
||||
添加节点
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showAddNode && (
|
||||
<div className="studio-edit-add-node">
|
||||
<select
|
||||
className="studio-edit-select"
|
||||
value={newNodeSkillId}
|
||||
onChange={(e) => setNewNodeSkillId(e.target.value)}
|
||||
>
|
||||
{skillTemplates.map((t) => (
|
||||
<option key={t.skillId} value={t.skillId}>
|
||||
{t.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
className="studio-edit-input"
|
||||
placeholder="展示名"
|
||||
value={newNodeName}
|
||||
onChange={(e) => setNewNodeName(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="studio-edit-btn studio-edit-btn-sm"
|
||||
onClick={() => {
|
||||
addNode(
|
||||
newNodeSkillId,
|
||||
newNodeName ||
|
||||
getSkillTemplate(skillTemplates, newNodeSkillId)?.displayName
|
||||
);
|
||||
setNewNodeName('');
|
||||
setShowAddNode(false);
|
||||
}}
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="studio-edit-node-list">
|
||||
{displayNodes.map((node, index) => {
|
||||
const tpl = getSkillTemplate(skillTemplates, node.skillId);
|
||||
const isManualSort = nodeSortMode === 'manual';
|
||||
return (
|
||||
<li
|
||||
key={node.id}
|
||||
className={`studio-edit-node-item ${
|
||||
selectedNodeId === node.id ? 'selected' : ''
|
||||
} ${!node.enabled ? 'disabled' : ''}${!isManualSort ? ' no-drag' : ''}`}
|
||||
draggable={isManualSort}
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={() => setSelectedNodeId(node.id)}
|
||||
>
|
||||
{isManualSort ? (
|
||||
<span className="studio-edit-drag-handle" title="拖拽排序">
|
||||
⋮⋮
|
||||
</span>
|
||||
) : (
|
||||
<span className="studio-edit-drag-handle studio-edit-drag-handle--muted" title="逻辑排序模式下不可拖拽">
|
||||
↕
|
||||
</span>
|
||||
)} <div className="studio-edit-node-text">
|
||||
<span className="studio-edit-node-title">{node.displayName}</span>
|
||||
<span className="studio-edit-node-skill">
|
||||
{tpl?.displayName || node.skillId}
|
||||
</span>
|
||||
</div>
|
||||
<label
|
||||
className="studio-edit-switch"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={node.enabled}
|
||||
onChange={(e) =>
|
||||
updateNode(node.id, { enabled: e.target.checked })
|
||||
}
|
||||
/>
|
||||
<span>启用</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<section className="studio-edit-detail">
|
||||
{!selectedNode ? (
|
||||
<p className="studio-edit-empty">选择左侧节点进行编辑</p>
|
||||
) : (
|
||||
<div className="studio-edit-detail-inner">
|
||||
<div className="studio-edit-meta-strip">
|
||||
<label className="studio-edit-meta-strip__field">
|
||||
<span className="studio-edit-field-label">展示名</span>
|
||||
<input
|
||||
type="text"
|
||||
className="studio-edit-input"
|
||||
value={selectedNode.displayName}
|
||||
onChange={(e) =>
|
||||
updateNode(selectedNode.id, { displayName: e.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-edit-meta-strip__field">
|
||||
<span className="studio-edit-field-label">模板类型</span>
|
||||
<input
|
||||
type="text"
|
||||
className="studio-edit-input"
|
||||
readOnly
|
||||
value={selectedTpl?.displayName || selectedNode.skillId}
|
||||
/>
|
||||
</label>
|
||||
{selectedTpl?.artifacts?.length > 0 && (
|
||||
<span className="studio-edit-meta-strip__hint">
|
||||
产物:
|
||||
{selectedTpl.artifacts.map((a) => a.displayName).join('、')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="studio-edit-detail-body">
|
||||
<div className="studio-edit-primary">
|
||||
<div className="studio-edit-primary-card">
|
||||
<span className="studio-edit-primary-card__badge">主要设置</span>
|
||||
|
||||
{isWorldbook ? (
|
||||
<>
|
||||
<label className="studio-edit-field studio-edit-field--primary">
|
||||
<span className="studio-edit-field-label studio-edit-field-label--primary">
|
||||
<FieldLabel tip="本步骤要产出的内容目标,会注入模型系统提示">
|
||||
步骤目标
|
||||
</FieldLabel>
|
||||
</span>
|
||||
<textarea
|
||||
className="studio-edit-textarea studio-edit-textarea--primary"
|
||||
rows={5}
|
||||
value={selectedNode.config?.stepGoal ?? ''}
|
||||
onChange={(e) =>
|
||||
updateNodeConfig(selectedNode.id, {
|
||||
stepGoal: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="描述本步骤要产出的内容…"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="studio-edit-field studio-edit-field--primary">
|
||||
<span className="studio-edit-field-label studio-edit-field-label--primary">
|
||||
<FieldLabel tip="引导模型在本步骤中的思考方式、关注点与推理路径">
|
||||
思考提示词
|
||||
</FieldLabel>
|
||||
</span>
|
||||
<textarea
|
||||
className="studio-edit-textarea studio-edit-textarea--primary"
|
||||
rows={5}
|
||||
value={selectedNode.config?.thinkingPrompt ?? ''}
|
||||
onChange={(e) =>
|
||||
updateNodeConfig(selectedNode.id, {
|
||||
thinkingPrompt: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="引导模型如何思考本步骤…"
|
||||
/>
|
||||
{!selectedNode.config?.thinkingPrompt && (
|
||||
<button
|
||||
type="button"
|
||||
className="studio-edit-btn studio-edit-btn-sm studio-edit-think-default"
|
||||
onClick={() =>
|
||||
updateNodeConfig(selectedNode.id, {
|
||||
thinkingPrompt: DEFAULT_THINKING_PROMPT,
|
||||
})
|
||||
}
|
||||
>
|
||||
填入默认思考流程模板
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<ScoringDimensions
|
||||
scoring={scoring}
|
||||
dimensions={dimensions}
|
||||
onUpdateScoring={updateScoring}
|
||||
onUpdateDimension={updateDimension}
|
||||
onRemoveDimension={removeDimension}
|
||||
onAddDimension={addDimension}
|
||||
/>
|
||||
</>
|
||||
) : selectedNode.skillId === 'studio.init_bind' ? (
|
||||
<>
|
||||
<p className="studio-edit-hint">
|
||||
运行时在引导区填写;编辑页可预览字段定义。
|
||||
</p>
|
||||
<ul className="studio-edit-dp-list studio-edit-dp-list--compact">
|
||||
{(selectedNode.displayParams || []).map((dp) => (
|
||||
<li key={dp.key}>
|
||||
{dp.label} ({dp.key})
|
||||
{dp.required ? ' · 必填' : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="studio-edit-edge-rail">
|
||||
{isWorldbook ? (
|
||||
<>
|
||||
<div className="studio-edit-edge-card">
|
||||
<VariableChips
|
||||
variant="compact"
|
||||
pipeline={pipeline}
|
||||
selectedNode={selectedNode}
|
||||
onToggleRef={(ref, label) =>
|
||||
toggleInputRef(selectedNode.id, ref, label)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="studio-edit-edge-card">
|
||||
<WorldbookInsertion
|
||||
insertion={insertion}
|
||||
onUpdateInsertion={updateInsertion}
|
||||
onUpdateRagConfig={updateRagConfig}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="studio-edit-edge-card studio-edit-edge-card--empty">
|
||||
<p className="studio-edit-edge-empty-tip">创建步骤无需引用变量</p>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div className="studio-edit-detail-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="studio-edit-btn studio-edit-btn-danger"
|
||||
onClick={() => {
|
||||
if (window.confirm(`删除节点「${selectedNode.displayName}」?`)) {
|
||||
removeNode(selectedNode.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
删除节点
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="studio-edit-btn studio-edit-btn-primary"
|
||||
onClick={savePipeline}
|
||||
disabled={saving || !currentProjectId}
|
||||
>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{showNewProjectModal && (
|
||||
<div
|
||||
className="studio-modal-overlay"
|
||||
role="presentation"
|
||||
onClick={() => setShowNewProjectModal(false)}
|
||||
>
|
||||
<div
|
||||
className="studio-modal"
|
||||
role="dialog"
|
||||
aria-labelledby="new-project-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 id="new-project-title" className="studio-modal-title">
|
||||
新建项目
|
||||
</h2>
|
||||
<form onSubmit={handleCreateProject}>
|
||||
<label className="studio-edit-label-block">
|
||||
项目名称
|
||||
<input
|
||||
type="text"
|
||||
className="studio-edit-input"
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
placeholder="例如:帝国骑士维尔"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-edit-label-block">
|
||||
选择工作流模板
|
||||
<select
|
||||
className="studio-edit-select"
|
||||
value={newProjectTemplateId}
|
||||
onChange={(e) => setNewProjectTemplateId(e.target.value)}
|
||||
>
|
||||
{templateOptions.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="studio-modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="studio-edit-btn"
|
||||
onClick={() => setShowNewProjectModal(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="studio-edit-btn studio-edit-btn-primary"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '创建中…' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showRenameProjectModal && (
|
||||
<div
|
||||
className="studio-modal-overlay"
|
||||
role="presentation"
|
||||
onClick={() => setShowRenameProjectModal(false)}
|
||||
>
|
||||
<div
|
||||
className="studio-modal"
|
||||
role="dialog"
|
||||
aria-labelledby="rename-project-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 id="rename-project-title" className="studio-modal-title">
|
||||
改名
|
||||
</h2>
|
||||
<form onSubmit={handleRenameProject}>
|
||||
<label className="studio-edit-label-block">
|
||||
项目名称
|
||||
<input
|
||||
type="text"
|
||||
className="studio-edit-input"
|
||||
value={renameProjectName}
|
||||
onChange={(e) => setRenameProjectName(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
maxLength={120}
|
||||
/>
|
||||
</label>
|
||||
<div className="studio-modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="studio-edit-btn"
|
||||
onClick={() => setShowRenameProjectModal(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="studio-edit-btn studio-edit-btn-primary"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? '保存中…' : '确定'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StudioEditPage;
|
||||
107
frontend/src/components/Studio/StudioInsertionPopup.css
Normal file
107
frontend/src/components/Studio/StudioInsertionPopup.css
Normal file
@@ -0,0 +1,107 @@
|
||||
.studio-insertion-popup {
|
||||
position: fixed;
|
||||
z-index: 1200;
|
||||
width: 280px;
|
||||
max-width: calc(100vw - 32px);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.studio-insertion-popup.is-expanded {
|
||||
width: min(480px, calc(100vw - 32px));
|
||||
max-height: min(75vh, 560px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.studio-insertion-popup__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.studio-insertion-popup__header:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.studio-insertion-popup__title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.studio-insertion-popup__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.studio-insertion-popup__btn {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-secondary);
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.studio-insertion-popup__btn:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.studio-insertion-popup__btn--close {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.studio-insertion-popup__body {
|
||||
padding: var(--spacing-sm);
|
||||
overflow: auto;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.studio-insertion-popup.is-expanded .studio-insertion-popup__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.studio-insertion-popup__preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.studio-insertion-popup__field-label {
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.studio-insertion-popup__field-value {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.studio-insertion-popup__full {
|
||||
margin: 0;
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border-light);
|
||||
background: var(--color-bg-tertiary);
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.72rem;
|
||||
max-height: none;
|
||||
}
|
||||
95
frontend/src/components/Studio/StudioInsertionPopup.jsx
Normal file
95
frontend/src/components/Studio/StudioInsertionPopup.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import './StudioInsertionPopup.css';
|
||||
|
||||
const DEFAULT_POS = { x: 72, y: 96 };
|
||||
|
||||
function StudioInsertionPopup({ open, title, onClose, defaultExpanded = false, children }) {
|
||||
const popupRef = useRef(null);
|
||||
const dragRef = useRef(null);
|
||||
const [pos, setPos] = useState(DEFAULT_POS);
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setExpanded(defaultExpanded);
|
||||
setPos(DEFAULT_POS);
|
||||
}
|
||||
}, [open, defaultExpanded]);
|
||||
|
||||
const handleDragStart = useCallback((e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const origin = { ...pos };
|
||||
|
||||
const onMove = (ev) => {
|
||||
setPos({
|
||||
x: origin.x + (ev.clientX - startX),
|
||||
y: origin.y + (ev.clientY - startY),
|
||||
});
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
dragRef.current = null;
|
||||
};
|
||||
|
||||
dragRef.current = { startX, startY, origin };
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
}, [pos]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (dragRef.current) {
|
||||
window.removeEventListener('mousemove', () => {});
|
||||
window.removeEventListener('mouseup', () => {});
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popupRef}
|
||||
className={`studio-insertion-popup${expanded ? ' is-expanded' : ''}`}
|
||||
style={{ left: pos.x, top: pos.y }}
|
||||
role="dialog"
|
||||
aria-label={title}
|
||||
>
|
||||
<div
|
||||
className="studio-insertion-popup__header"
|
||||
onMouseDown={handleDragStart}
|
||||
>
|
||||
<span className="studio-insertion-popup__title">{title}</span>
|
||||
<div className="studio-insertion-popup__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="studio-insertion-popup__btn"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
title={expanded ? '收起详情' : '展开详情'}
|
||||
>
|
||||
{expanded ? '收起' : '展开'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="studio-insertion-popup__btn studio-insertion-popup__btn--close"
|
||||
onClick={onClose}
|
||||
aria-label="关闭"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="studio-insertion-popup__body">
|
||||
{children({ expanded })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StudioInsertionPopup;
|
||||
70
frontend/src/components/Studio/StudioPage.css
Normal file
70
frontend/src/components/Studio/StudioPage.css
Normal file
@@ -0,0 +1,70 @@
|
||||
.studio-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.studio-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-lg);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
background: var(--color-bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.studio-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.studio-header-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.studio-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.studio-tabs {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.studio-tab {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.studio-tab:hover {
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border-focus);
|
||||
}
|
||||
|
||||
.studio-tab.active {
|
||||
background: var(--color-accent-light);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.studio-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
34
frontend/src/components/Studio/StudioPage.jsx
Normal file
34
frontend/src/components/Studio/StudioPage.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import useAppLayoutStore from '../../Store/AppLayoutSlice';
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
* 兼容旧路由:studio → studio_edit
|
||||
|
||||
*/
|
||||
|
||||
function StudioPage() {
|
||||
|
||||
const setActivePage = useAppLayoutStore((s) => s.setActivePage);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
setActivePage('studio_edit');
|
||||
|
||||
}, [setActivePage]);
|
||||
|
||||
|
||||
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default StudioPage;
|
||||
|
||||
391
frontend/src/components/Studio/StudioRunChat.css
Normal file
391
frontend/src/components/Studio/StudioRunChat.css
Normal file
@@ -0,0 +1,391 @@
|
||||
.studio-run-chat {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.studio-run-chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.studio-run-chat-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.studio-run-chat-message {
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: var(--radius-md);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.studio-run-chat-message.role-user {
|
||||
align-self: flex-end;
|
||||
max-width: 85%;
|
||||
background: linear-gradient(to right, rgba(102, 126, 234, 0.08), rgba(102, 126, 234, 0.15));
|
||||
border-left: 3px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.studio-run-chat-message.role-assistant,
|
||||
.studio-run-chat-message.role-system {
|
||||
align-self: stretch;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
[data-color-theme='dark'] .studio-run-chat-message.role-assistant,
|
||||
[data-color-theme='dark'] .studio-run-chat-message.role-system {
|
||||
background-color: rgba(45, 45, 48, 0.5);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.studio-run-chat-focus {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
min-height: min(40vh, 320px);
|
||||
}
|
||||
|
||||
.studio-run-chat-thinking,
|
||||
.studio-run-chat-summary {
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-light);
|
||||
background: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
[data-color-theme='dark'] .studio-run-chat-thinking,
|
||||
[data-color-theme='dark'] .studio-run-chat-summary {
|
||||
background-color: rgba(45, 45, 48, 0.5);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.studio-run-chat-thinking--pending {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.studio-run-chat-block-label {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.studio-run-chat-block-body {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.studio-run-chat-message-role {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.studio-run-chat-message-body {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.studio-run-chat-plain {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Input area — aligned with ChatBox */
|
||||
.studio-run-chat-input-wrapper {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
flex-shrink: 0;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
background-color: var(--color-bg-primary);
|
||||
z-index: var(--z-divider);
|
||||
}
|
||||
|
||||
.studio-run-chat-input-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background-color: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.studio-run-chat-input-container:focus-within {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
[data-color-theme='dark'] .studio-run-chat-input-container {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-color-theme='dark'] .studio-run-chat-input-container:focus-within {
|
||||
box-shadow: 0 0 0 1px var(--color-accent);
|
||||
}
|
||||
|
||||
[data-color-theme='dark'] .studio-run-chat-options-toggle:hover,
|
||||
[data-color-theme='dark'] .studio-run-chat-options-toggle.active {
|
||||
background-color: var(--color-accent-ultra-light);
|
||||
}
|
||||
|
||||
[data-color-theme='dark'] .studio-run-chat-textarea:focus {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.studio-run-chat-options-wrapper {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.studio-run-chat-options-toggle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.studio-run-chat-options-toggle:hover {
|
||||
background-color: rgba(102, 126, 234, 0.08);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.studio-run-chat-options-toggle.active {
|
||||
background-color: rgba(102, 126, 234, 0.12);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.studio-run-chat-options {
|
||||
position: absolute;
|
||||
bottom: calc(100% + var(--spacing-sm));
|
||||
left: 0;
|
||||
min-width: 220px;
|
||||
padding: var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-bg-elevated);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
z-index: var(--z-dropdown-menu);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.studio-run-chat-options-title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.studio-run-chat-render-btn {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.studio-run-chat-render-btn:hover {
|
||||
background-color: rgba(102, 126, 234, 0.08);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.studio-run-chat-option-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all 0.15s ease;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.studio-run-chat-option-checkbox:hover {
|
||||
background-color: rgba(102, 126, 234, 0.06);
|
||||
}
|
||||
|
||||
.studio-run-chat-option-checkbox input[type="checkbox"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.studio-run-chat-checkmark {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.studio-run-chat-option-checkbox:hover .studio-run-chat-checkmark {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.studio-run-chat-option-checkbox input:checked + .studio-run-chat-checkmark {
|
||||
background-color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.studio-run-chat-option-checkbox input:checked + .studio-run-chat-checkmark::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.studio-run-chat-option-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.studio-run-chat-option-unavailable {
|
||||
display: block;
|
||||
padding: var(--spacing-xs) 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.studio-run-chat-pending {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.studio-run-chat-message.is-pending {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.studio-run-chat-input-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.studio-run-chat-textarea {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background-color: transparent;
|
||||
color: var(--color-text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
resize: none;
|
||||
overflow-y: auto;
|
||||
min-height: 36px;
|
||||
max-height: 300px;
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
height: auto;
|
||||
field-sizing: content;
|
||||
}
|
||||
|
||||
.studio-run-chat-textarea:focus {
|
||||
outline: none;
|
||||
background-color: rgba(102, 126, 234, 0.03);
|
||||
}
|
||||
|
||||
.studio-run-chat-textarea::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.studio-run-chat-send {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.2);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.studio-run-chat-send:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #5568d3 0%, #6a3f8f 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.studio-run-chat-send:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.studio-run-chat-send:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.studio-run-chat-send--stopping {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
animation: studio-run-chat-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes studio-run-chat-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
229
frontend/src/components/Studio/StudioRunChat.jsx
Normal file
229
frontend/src/components/Studio/StudioRunChat.jsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import MarkdownRenderer from '../shared/MarkdownRenderer';
|
||||
|
||||
import './StudioRunChat.css';
|
||||
|
||||
const RENDER_MODES = ['none', 'markdown', 'html'];
|
||||
const RENDER_MODE_LABELS = {
|
||||
none: '📄 纯文本',
|
||||
html: '🌐 HTML',
|
||||
markdown: '📝 Markdown',
|
||||
};
|
||||
|
||||
function renderBody(text, renderMode, isUser) {
|
||||
if (!text) return null;
|
||||
if (renderMode === 'html' && !isUser) {
|
||||
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(text);
|
||||
if (hasHtmlTags) {
|
||||
return <div dangerouslySetInnerHTML={{ __html: text }} />;
|
||||
}
|
||||
return <div className="studio-run-chat-plain">{text}</div>;
|
||||
}
|
||||
if (renderMode === 'markdown') {
|
||||
return <MarkdownRenderer content={text} />;
|
||||
}
|
||||
return <div className="studio-run-chat-plain">{text}</div>;
|
||||
}
|
||||
|
||||
function StudioRunChat({
|
||||
stepMessages = [],
|
||||
thinking,
|
||||
evaluation,
|
||||
inputValue,
|
||||
onInputChange,
|
||||
onSend,
|
||||
onInterrupt,
|
||||
disabled = false,
|
||||
sending = false,
|
||||
canInterrupt = false,
|
||||
streamOutput: _streamOutput = false,
|
||||
onStreamOutputChange: _onStreamOutputChange,
|
||||
placeholder = '输入消息…',
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const focusAnchorRef = useRef(null);
|
||||
const optionsRef = useRef(null);
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
const [renderMode, setRenderMode] = useState('markdown');
|
||||
|
||||
const summaryText = evaluation || null;
|
||||
|
||||
const hasFocusContent = thinking || summaryText || sending;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const anchor = focusAnchorRef.current;
|
||||
if (!container || !anchor) return;
|
||||
container.scrollTop = Math.max(0, anchor.offsetTop - container.offsetTop);
|
||||
}, [stepMessages, thinking, evaluation, sending]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (
|
||||
optionsRef.current &&
|
||||
!optionsRef.current.contains(event.target) &&
|
||||
!event.target.closest('.studio-run-chat-options-toggle')
|
||||
) {
|
||||
setShowOptions(false);
|
||||
}
|
||||
};
|
||||
if (showOptions) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showOptions]);
|
||||
|
||||
const handleInputHeight = (e) => {
|
||||
const textarea = e.target;
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 300)}px`;
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (sending && canInterrupt && onInterrupt) {
|
||||
onInterrupt();
|
||||
return;
|
||||
}
|
||||
if (disabled || sending || !inputValue.trim()) return;
|
||||
onSend(inputValue.trim());
|
||||
const textarea = e.target.querySelector('.studio-run-chat-textarea');
|
||||
if (textarea) textarea.style.height = 'auto';
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (sending && canInterrupt && onInterrupt) {
|
||||
onInterrupt();
|
||||
return;
|
||||
}
|
||||
if (!disabled && !sending && inputValue.trim()) {
|
||||
onSend(inputValue.trim());
|
||||
e.target.style.height = 'auto';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendOrStop = () => {
|
||||
if (sending && canInterrupt && onInterrupt) {
|
||||
onInterrupt();
|
||||
return;
|
||||
}
|
||||
if (disabled || sending || !inputValue.trim()) return;
|
||||
onSend(inputValue.trim());
|
||||
const textarea = document.querySelector('.studio-run-chat-textarea');
|
||||
if (textarea) textarea.style.height = 'auto';
|
||||
};
|
||||
|
||||
const showStop = sending && canInterrupt && onInterrupt;
|
||||
|
||||
const cycleRenderMode = () => {
|
||||
const idx = RENDER_MODES.indexOf(renderMode);
|
||||
setRenderMode(RENDER_MODES[(idx + 1) % RENDER_MODES.length]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="studio-run-chat">
|
||||
<div className="studio-run-chat-messages" ref={containerRef}>
|
||||
<div ref={focusAnchorRef} className="studio-run-chat-focus">
|
||||
{!hasFocusContent ? (
|
||||
<div className="studio-run-chat-empty">
|
||||
暂无本轮对话,在下方输入开始交流
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{sending && !thinking && (
|
||||
<div className="studio-run-chat-thinking studio-run-chat-thinking--pending">
|
||||
<span className="studio-run-chat-block-label">思考</span>
|
||||
<span className="studio-run-chat-pending">正在等待模型回复…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{thinking && (
|
||||
<div className="studio-run-chat-thinking">
|
||||
<span className="studio-run-chat-block-label">思考</span>
|
||||
<div className="studio-run-chat-block-body">
|
||||
{renderBody(thinking, renderMode, false)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summaryText && (
|
||||
<div className="studio-run-chat-summary">
|
||||
<span className="studio-run-chat-block-label">评价与修改建议</span>
|
||||
<div className="studio-run-chat-block-body">
|
||||
{renderBody(summaryText, renderMode, false)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="studio-run-chat-input-wrapper" onSubmit={handleSubmit}>
|
||||
<div className="studio-run-chat-input-container">
|
||||
<div className="studio-run-chat-options-wrapper" ref={optionsRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`studio-run-chat-options-toggle${showOptions ? ' active' : ''}`}
|
||||
title="展开选项"
|
||||
onClick={() => setShowOptions((v) => !v)}
|
||||
>
|
||||
{showOptions ? '×' : '≡'}
|
||||
</button>
|
||||
{showOptions && (
|
||||
<div className="studio-run-chat-options">
|
||||
<div className="studio-run-chat-options-title">显示</div>
|
||||
<button
|
||||
type="button"
|
||||
className="studio-run-chat-render-btn"
|
||||
onClick={cycleRenderMode}
|
||||
title={`当前:${RENDER_MODE_LABELS[renderMode]}`}
|
||||
>
|
||||
{RENDER_MODE_LABELS[renderMode]}
|
||||
</button>
|
||||
<div className="studio-run-chat-options-title">功能</div>
|
||||
<span
|
||||
className="studio-run-chat-option-unavailable"
|
||||
title="暂不支持"
|
||||
>
|
||||
流式输出 · 暂不支持
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="studio-run-chat-input-area">
|
||||
<textarea
|
||||
className="studio-run-chat-textarea"
|
||||
rows={1}
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
onInputChange(e.target.value);
|
||||
handleInputHeight(e);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
aria-label="输入消息"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`studio-run-chat-send${showStop ? ' studio-run-chat-send--stopping' : ''}`}
|
||||
onClick={handleSendOrStop}
|
||||
disabled={!showStop && (disabled || sending || !inputValue.trim())}
|
||||
aria-label={showStop ? '中断' : '发送'}
|
||||
title={showStop ? '中断' : '发送'}
|
||||
>
|
||||
{showStop ? '■' : '>'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StudioRunChat;
|
||||
98
frontend/src/components/Studio/StudioRunNodeGraph.css
Normal file
98
frontend/src/components/Studio/StudioRunNodeGraph.css
Normal file
@@ -0,0 +1,98 @@
|
||||
.studio-run-graph {
|
||||
position: relative;
|
||||
height: 240px;
|
||||
min-height: 200px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-light);
|
||||
background: var(--color-bg-secondary);
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.studio-run-graph--center {
|
||||
flex: 1;
|
||||
min-height: 280px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.studio-run-graph:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.studio-run-graph__svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.studio-run-graph__edge {
|
||||
fill: none;
|
||||
stroke: var(--color-border);
|
||||
stroke-width: 1.5;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.studio-run-graph__node.is-selectable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.studio-run-graph__node.is-locked {
|
||||
cursor: default;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.studio-run-graph__node.is-locked .studio-run-graph__node-bg {
|
||||
stroke-dasharray: 4 3;
|
||||
}
|
||||
|
||||
.studio-run-graph__node-bg {
|
||||
fill: var(--color-bg-tertiary);
|
||||
stroke: var(--color-border-light);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.studio-run-graph__node.status-active .studio-run-graph__node-bg {
|
||||
stroke: var(--color-accent);
|
||||
fill: rgba(var(--color-accent-rgb, 59, 130, 246), 0.1);
|
||||
}
|
||||
|
||||
.studio-run-graph__node.status-completed .studio-run-graph__node-bg {
|
||||
stroke: #22c55e;
|
||||
}
|
||||
|
||||
.studio-run-graph__node.is-selected .studio-run-graph__node-bg {
|
||||
stroke-width: 2.5;
|
||||
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.12));
|
||||
}
|
||||
|
||||
.studio-run-graph__node.is-current .studio-run-graph__node-bg {
|
||||
stroke: var(--color-accent);
|
||||
}
|
||||
|
||||
.studio-run-graph__node-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
fill: var(--color-text-primary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.studio-run-graph__node-status {
|
||||
font-size: 10px;
|
||||
fill: var(--color-text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.studio-run-graph__hint {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 6px;
|
||||
font-size: 0.6rem;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.75;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.studio-run-graph-empty {
|
||||
padding: var(--spacing-md);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
270
frontend/src/components/Studio/StudioRunNodeGraph.jsx
Normal file
270
frontend/src/components/Studio/StudioRunNodeGraph.jsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { buildGraphLayout } from './edit/variableUtils';
|
||||
|
||||
import './StudioRunNodeGraph.css';
|
||||
|
||||
const NODE_STATUS_LABEL = {
|
||||
pending: '待执行',
|
||||
active: '进行中',
|
||||
completed: '已完成',
|
||||
skipped: '已跳过',
|
||||
};
|
||||
|
||||
function defaultEdgePath(from, to, nodeW, nodeH) {
|
||||
const x1 = from.x + nodeW / 2;
|
||||
const y1 = from.y + nodeH;
|
||||
const x2 = to.x + nodeW / 2;
|
||||
const y2 = to.y;
|
||||
const dy = Math.max(24, (y2 - y1) * 0.45);
|
||||
return `M ${x1} ${y1} C ${x1} ${y1 + dy}, ${x2} ${y2 - dy}, ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
function StudioRunNodeGraph({
|
||||
pipeline,
|
||||
nodeStates,
|
||||
currentNodeId,
|
||||
variant = 'sidebar',
|
||||
onNodeSelect,
|
||||
canSelectNode,
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const [view, setView] = useState({ x: 0, y: 0, scale: 1 });
|
||||
const [dragging, setDragging] = useState(null);
|
||||
const [selectedId, setSelectedId] = useState(currentNodeId);
|
||||
const [nodeOverrides, setNodeOverrides] = useState({});
|
||||
|
||||
const isCenter = variant === 'center';
|
||||
|
||||
const layout = useMemo(
|
||||
() => buildGraphLayout(pipeline, nodeStates, { vertical: true }),
|
||||
[pipeline, nodeStates]
|
||||
);
|
||||
|
||||
const { nodeW, nodeH } = layout;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedId(currentNodeId);
|
||||
}, [currentNodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el || !layout.width) return;
|
||||
const cw = el.clientWidth || 200;
|
||||
const ch = el.clientHeight || 160;
|
||||
const scale = Math.min(1, (cw - 16) / layout.width, (ch - 16) / layout.height);
|
||||
setView({
|
||||
x: (cw - layout.width * scale) / 2,
|
||||
y: Math.max(8, (ch - layout.height * scale) / 2),
|
||||
scale: Math.max(0.45, scale),
|
||||
});
|
||||
}, [layout.width, layout.height, pipeline, variant]);
|
||||
|
||||
const nodeMap = useMemo(
|
||||
() => Object.fromEntries(layout.nodes.map((n) => [n.id, n])),
|
||||
[layout.nodes]
|
||||
);
|
||||
|
||||
const stateMap = useMemo(
|
||||
() => Object.fromEntries((nodeStates || []).map((s) => [s.nodeId, s])),
|
||||
[nodeStates]
|
||||
);
|
||||
|
||||
const isNodeSelectable = useCallback(
|
||||
(nodeId) => {
|
||||
if (!onNodeSelect) return false;
|
||||
if (nodeId === currentNodeId) return false;
|
||||
const state = stateMap[nodeId];
|
||||
if (canSelectNode) return canSelectNode(state);
|
||||
return ['studio.worldbook_entry', 'studio.init_bind'].includes(state?.skillId);
|
||||
},
|
||||
[onNodeSelect, currentNodeId, stateMap, canSelectNode]
|
||||
);
|
||||
|
||||
const handleWheel = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? 0.92 : 1.08;
|
||||
setView((v) => ({
|
||||
...v,
|
||||
scale: Math.min(2.5, Math.max(0.35, v.scale * delta)),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleBgMouseDown = useCallback(
|
||||
(e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
setDragging({ type: 'pan', startX: e.clientX, startY: e.clientY, origin: { ...view } });
|
||||
},
|
||||
[view]
|
||||
);
|
||||
|
||||
const handleNodeMouseDown = useCallback(
|
||||
(e, nodeId) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
setSelectedId(nodeId);
|
||||
const node = nodeMap[nodeId];
|
||||
if (!node) return;
|
||||
setDragging({
|
||||
type: 'node',
|
||||
nodeId,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origin: { x: node.x, y: node.y },
|
||||
moved: false,
|
||||
});
|
||||
},
|
||||
[nodeMap]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return undefined;
|
||||
|
||||
const onMove = (e) => {
|
||||
if (dragging.type === 'pan') {
|
||||
setView((v) => ({
|
||||
...v,
|
||||
x: dragging.origin.x + (e.clientX - dragging.startX),
|
||||
y: dragging.origin.y + (e.clientY - dragging.startY),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (dragging.type === 'node') {
|
||||
const dx = e.clientX - dragging.startX;
|
||||
const dy = e.clientY - dragging.startY;
|
||||
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) {
|
||||
dragging.moved = true;
|
||||
}
|
||||
const scaleDx = dx / view.scale;
|
||||
const scaleDy = dy / view.scale;
|
||||
setNodeOverrides((prev) => ({
|
||||
...prev,
|
||||
[dragging.nodeId]: {
|
||||
x: dragging.origin.x + scaleDx,
|
||||
y: dragging.origin.y + scaleDy,
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
if (
|
||||
dragging?.type === 'node'
|
||||
&& !dragging.moved
|
||||
&& onNodeSelect
|
||||
&& isNodeSelectable(dragging.nodeId)
|
||||
) {
|
||||
onNodeSelect(dragging.nodeId);
|
||||
}
|
||||
setDragging(null);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [dragging, view.scale, onNodeSelect, isNodeSelectable]);
|
||||
|
||||
useEffect(() => {
|
||||
setNodeOverrides({});
|
||||
}, [pipeline, nodeStates]);
|
||||
|
||||
const positionedNodes = layout.nodes.map((n) => ({
|
||||
...n,
|
||||
...(nodeOverrides[n.id] || {}),
|
||||
}));
|
||||
|
||||
const posMap = Object.fromEntries(positionedNodes.map((n) => [n.id, n]));
|
||||
|
||||
const pathFn = layout.edgePath || defaultEdgePath;
|
||||
|
||||
if (!layout.nodes.length) {
|
||||
return <div className="studio-run-graph-empty">暂无节点</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`studio-run-graph${isCenter ? ' studio-run-graph--center' : ''}`}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleBgMouseDown}
|
||||
>
|
||||
<svg
|
||||
className="studio-run-graph__svg"
|
||||
width="100%"
|
||||
height="100%"
|
||||
aria-label="节点依赖进度图"
|
||||
>
|
||||
<g transform={`translate(${view.x},${view.y}) scale(${view.scale})`}>
|
||||
<defs>
|
||||
<marker
|
||||
id="studio-run-graph-arrow"
|
||||
markerWidth="8"
|
||||
markerHeight="8"
|
||||
refX="4"
|
||||
refY="4"
|
||||
orient="auto"
|
||||
>
|
||||
<path d="M0,0 L0,8 L8,4 z" fill="var(--color-text-muted)" />
|
||||
</marker>
|
||||
</defs>
|
||||
{layout.edges.map(({ from, to }) => {
|
||||
const a = posMap[from];
|
||||
const b = posMap[to];
|
||||
if (!a || !b) return null;
|
||||
return (
|
||||
<path
|
||||
key={`${from}-${to}`}
|
||||
className="studio-run-graph__edge"
|
||||
d={pathFn(a, b, nodeW, nodeH)}
|
||||
markerEnd="url(#studio-run-graph-arrow)"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{positionedNodes.map((node) => {
|
||||
const selectable = isNodeSelectable(node.id);
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
transform={`translate(${node.x},${node.y})`}
|
||||
className={`studio-run-graph__node status-${node.status}${selectedId === node.id ? ' is-selected' : ''}${currentNodeId === node.id ? ' is-current' : ''}${selectable ? ' is-selectable' : ' is-locked'}`}
|
||||
onMouseDown={(e) => handleNodeMouseDown(e, node.id)}
|
||||
>
|
||||
<rect
|
||||
className="studio-run-graph__node-bg"
|
||||
width={nodeW}
|
||||
height={nodeH}
|
||||
rx="8"
|
||||
/>
|
||||
<text
|
||||
className="studio-run-graph__node-label"
|
||||
x={nodeW / 2}
|
||||
y={nodeH / 2 - 6}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{node.label.length > 8 ? `${node.label.slice(0, 7)}…` : node.label}
|
||||
</text>
|
||||
<text
|
||||
className="studio-run-graph__node-status"
|
||||
x={nodeW / 2}
|
||||
y={nodeH / 2 + 12}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{NODE_STATUS_LABEL[node.status] || node.status}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
<div className="studio-run-graph__hint">
|
||||
依赖关系树 · 滚轮缩放 · 拖拽平移 · 点击切换步骤
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StudioRunNodeGraph;
|
||||
1292
frontend/src/components/Studio/StudioRunPage.css
Normal file
1292
frontend/src/components/Studio/StudioRunPage.css
Normal file
File diff suppressed because it is too large
Load Diff
1247
frontend/src/components/Studio/StudioRunPage.jsx
Normal file
1247
frontend/src/components/Studio/StudioRunPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
15
frontend/src/components/Studio/edit/FieldLabel.jsx
Normal file
15
frontend/src/components/Studio/edit/FieldLabel.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
|
||||
/** Label with optional dotted underline + native title tooltip for field hints. */
|
||||
export default function FieldLabel({ children, tip }) {
|
||||
if (tip) {
|
||||
return (
|
||||
<span className="studio-field-label" title={tip}>
|
||||
<span className="studio-field-label__text studio-field-label__text--tip">
|
||||
{children}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="studio-field-label">{children}</span>;
|
||||
}
|
||||
83
frontend/src/components/Studio/edit/ScoringDimensions.jsx
Normal file
83
frontend/src/components/Studio/edit/ScoringDimensions.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import FieldLabel from './FieldLabel';
|
||||
|
||||
export default function ScoringDimensions({
|
||||
scoring,
|
||||
dimensions,
|
||||
onUpdateScoring,
|
||||
onUpdateDimension,
|
||||
onRemoveDimension,
|
||||
onAddDimension,
|
||||
}) {
|
||||
return (
|
||||
<div className="studio-scoring-block">
|
||||
<h3 className="studio-scoring-block__title">
|
||||
<FieldLabel tip="定义评分维度及修改时的参考标准,用于循环迭代直至满意">
|
||||
评判维度与如何完善
|
||||
</FieldLabel>
|
||||
</h3>
|
||||
|
||||
<label className="studio-edit-switch-row studio-edit-switch-row--compact">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scoring.enabled ?? true}
|
||||
onChange={(e) => onUpdateScoring('enabled', e.target.checked)}
|
||||
/>
|
||||
启用评分
|
||||
</label>
|
||||
|
||||
<div className="studio-dim-list">
|
||||
{dimensions.map((dim, index) => (
|
||||
<div key={dim.id || index} className="studio-dim-card">
|
||||
<div className="studio-dim-card__fields">
|
||||
<label className="studio-edit-field">
|
||||
<span className="studio-edit-field-label">维度名称</span>
|
||||
<input
|
||||
type="text"
|
||||
className="studio-edit-input"
|
||||
value={dim.name}
|
||||
onChange={(e) =>
|
||||
onUpdateDimension(index, 'name', e.target.value)
|
||||
}
|
||||
placeholder="例如:真实性"
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-edit-field">
|
||||
<span className="studio-edit-field-label">
|
||||
<FieldLabel tip="说明如何改进,并作为评判参考">
|
||||
如何完善(含评判参考)
|
||||
</FieldLabel>
|
||||
</span>
|
||||
<textarea
|
||||
className="studio-edit-textarea studio-edit-textarea--compact"
|
||||
rows={2}
|
||||
value={dim.criteria}
|
||||
onChange={(e) =>
|
||||
onUpdateDimension(index, 'criteria', e.target.value)
|
||||
}
|
||||
placeholder="说明如何改进,并作为评判参考…"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="studio-dim-card__remove"
|
||||
title="删除此维度"
|
||||
onClick={() => onRemoveDimension(index)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="studio-edit-btn-ghost"
|
||||
onClick={onAddDimension}
|
||||
>
|
||||
+ 新增维度
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
frontend/src/components/Studio/edit/VariableChips.jsx
Normal file
146
frontend/src/components/Studio/edit/VariableChips.jsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React, { useState } from 'react';
|
||||
import FieldLabel from './FieldLabel';
|
||||
import {
|
||||
AUTO_INJECTED_CONTEXT_ITEMS,
|
||||
buildStepOutputRef,
|
||||
detectReferenceCycles,
|
||||
dynamicChipLabel,
|
||||
dynamicTooltip,
|
||||
formatCycleWarning,
|
||||
getSelectableStepOutputRefs,
|
||||
stepOutputLabel,
|
||||
} from './variableUtils';
|
||||
|
||||
function AutoInjectedItem({ item }) {
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="studio-var-auto-chip"
|
||||
onMouseEnter={() => setShowPopover(true)}
|
||||
onMouseLeave={() => setShowPopover(false)}
|
||||
onFocus={() => setShowPopover(true)}
|
||||
onBlur={() => setShowPopover(false)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="studio-var-auto-chip__label">{item.label}</span>
|
||||
{showPopover && (
|
||||
<span className="studio-var-auto-chip__popover" role="tooltip">
|
||||
{item.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function VariableSwitch({ label, selected, onToggle, tooltip }) {
|
||||
return (
|
||||
<label
|
||||
className={`studio-var-switch ${selected ? 'studio-var-switch--on' : ''}`}
|
||||
title={tooltip}
|
||||
>
|
||||
<span className="studio-var-switch__label">{label}</span>
|
||||
<span className="studio-var-switch__track" aria-hidden="true">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="studio-var-switch__input"
|
||||
checked={selected}
|
||||
onChange={onToggle}
|
||||
/>
|
||||
<span className="studio-var-switch__thumb" />
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VariableChips({
|
||||
pipeline,
|
||||
selectedNode,
|
||||
onToggleRef,
|
||||
variant = 'default',
|
||||
}) {
|
||||
const isCompact = variant === 'compact';
|
||||
const [autoExpanded, setAutoExpanded] = useState(false);
|
||||
|
||||
const previousSteps = getSelectableStepOutputRefs(pipeline, selectedNode);
|
||||
const cycleWarning = formatCycleWarning(detectReferenceCycles(pipeline), pipeline);
|
||||
|
||||
const isChecked = (ref) =>
|
||||
(selectedNode.inputs || []).some((i) => i.ref === ref);
|
||||
|
||||
return (
|
||||
<div className={`studio-var-panel ${isCompact ? 'studio-var-panel--compact' : ''}`}>
|
||||
<h3 className="studio-var-panel__title">
|
||||
<FieldLabel tip="引用前序步骤的世界书条目;核心目的、思考流程等由系统自动注入">
|
||||
上文引用
|
||||
</FieldLabel>
|
||||
</h3>
|
||||
|
||||
{cycleWarning && (
|
||||
<p className="studio-var-cycle-warn" role="alert">
|
||||
{cycleWarning}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="studio-var-group studio-var-group--auto">
|
||||
<button
|
||||
type="button"
|
||||
className="studio-var-auto-toggle"
|
||||
onClick={() => setAutoExpanded((v) => !v)}
|
||||
aria-expanded={autoExpanded}
|
||||
>
|
||||
<span className="studio-var-group__title">系统自动注入</span>
|
||||
<span className="studio-var-auto-toggle__meta">
|
||||
{autoExpanded
|
||||
? '收起'
|
||||
: `${AUTO_INJECTED_CONTEXT_ITEMS.length} 项已默认注入`}
|
||||
</span>
|
||||
<span className="studio-var-auto-toggle__chevron" aria-hidden="true">
|
||||
{autoExpanded ? '▾' : '▸'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{autoExpanded ? (
|
||||
<div className="studio-var-auto-list">
|
||||
{AUTO_INJECTED_CONTEXT_ITEMS.map((item) => (
|
||||
<AutoInjectedItem key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="studio-edit-hint studio-var-auto-hint">
|
||||
运行时自动附带,无需手动开启。展开可查看各项含义。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="studio-var-group">
|
||||
<h4 className="studio-var-group__title">前序步骤产物</h4>
|
||||
<p className="studio-edit-hint studio-var-manual-hint">
|
||||
开关开启后,将把对应步骤的最终世界书条目注入本步上下文。
|
||||
</p>
|
||||
<div className={`studio-var-grid ${isCompact ? 'studio-var-grid--compact' : ''}`}>
|
||||
{previousSteps.length === 0 ? (
|
||||
<p className="studio-edit-hint studio-var-empty">暂无可引用的前序世界书步骤</p>
|
||||
) : (
|
||||
previousSteps.map((node) => {
|
||||
const ref = buildStepOutputRef(node.id);
|
||||
const checked = isChecked(ref);
|
||||
const label = dynamicChipLabel(ref, pipeline, stepOutputLabel(node.displayName));
|
||||
return (
|
||||
<VariableSwitch
|
||||
key={ref}
|
||||
label={label}
|
||||
selected={checked}
|
||||
tooltip={dynamicTooltip(ref, pipeline)}
|
||||
onToggle={() =>
|
||||
onToggleRef(ref, stepOutputLabel(node.displayName))
|
||||
}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
frontend/src/components/Studio/edit/WorldbookInsertion.jsx
Normal file
163
frontend/src/components/Studio/edit/WorldbookInsertion.jsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React from 'react';
|
||||
import FieldLabel from './FieldLabel';
|
||||
|
||||
const POSITION_OPTIONS = [
|
||||
{ value: 0, label: '角色定义之后' },
|
||||
{ value: 1, label: '角色定义之前' },
|
||||
{ value: 2, label: '示例对话之前' },
|
||||
{ value: 3, label: '示例对话之后' },
|
||||
{ value: 4, label: '系统提示/作者注释' },
|
||||
{ value: 5, label: '作为系统消息' },
|
||||
{ value: 6, label: '深度插入' },
|
||||
{ value: 7, label: '宏替换' },
|
||||
];
|
||||
|
||||
const ACTIVATION_OPTIONS = [
|
||||
{ value: 'permanent', label: '永久激活' },
|
||||
{ value: 'keyword', label: '关键词触发' },
|
||||
{ value: 'rag', label: 'RAG 检索' },
|
||||
];
|
||||
|
||||
export default function WorldbookInsertion({
|
||||
insertion,
|
||||
onUpdateInsertion,
|
||||
onUpdateRagConfig,
|
||||
}) {
|
||||
const activation = insertion.activationType ?? 'permanent';
|
||||
|
||||
return (
|
||||
<div className="studio-wb-insert">
|
||||
<h3 className="studio-wb-insert__title">世界书插入</h3>
|
||||
|
||||
<div className="studio-wb-insert__fields">
|
||||
<label className="studio-edit-field">
|
||||
<span className="studio-edit-field-label">
|
||||
<FieldLabel tip="条目写入角色卡时的深度位置,影响模型读取上下文的顺序">
|
||||
插入位置
|
||||
</FieldLabel>
|
||||
</span>
|
||||
<select
|
||||
className="studio-edit-select"
|
||||
value={insertion.position ?? 1}
|
||||
onChange={(e) =>
|
||||
onUpdateInsertion('position', Number(e.target.value))
|
||||
}
|
||||
>
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="studio-edit-field">
|
||||
<span className="studio-edit-field-label">
|
||||
<FieldLabel tip="永久激活始终注入;关键词/RAG 仅在匹配时注入">
|
||||
激活方式
|
||||
</FieldLabel>
|
||||
</span>
|
||||
<select
|
||||
className="studio-edit-select"
|
||||
value={activation}
|
||||
onChange={(e) =>
|
||||
onUpdateInsertion('activationType', e.target.value)
|
||||
}
|
||||
>
|
||||
{ACTIVATION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{activation === 'keyword' && (
|
||||
<>
|
||||
<label className="studio-edit-field">
|
||||
<span className="studio-edit-field-label">
|
||||
<FieldLabel tip="主关键词用于触发本条目进入上下文">主关键词</FieldLabel>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="studio-edit-input"
|
||||
value={insertion.key ?? ''}
|
||||
onChange={(e) => onUpdateInsertion('key', e.target.value)}
|
||||
placeholder="触发本条目所需主词"
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-edit-field">
|
||||
<span className="studio-edit-field-label">
|
||||
<FieldLabel tip="可选,辅助匹配多个相关词">次要关键词</FieldLabel>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="studio-edit-input"
|
||||
value={insertion.keysecondary ?? ''}
|
||||
onChange={(e) =>
|
||||
onUpdateInsertion('keysecondary', e.target.value)
|
||||
}
|
||||
placeholder="可选,辅助匹配"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activation === 'rag' && (
|
||||
<>
|
||||
<label className="studio-edit-field">
|
||||
<span className="studio-edit-field-label">RAG 库 ID</span>
|
||||
<input
|
||||
type="text"
|
||||
className="studio-edit-input"
|
||||
value={insertion.ragConfig?.libraryId ?? ''}
|
||||
onChange={(e) =>
|
||||
onUpdateRagConfig('libraryId', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-edit-field">
|
||||
<span className="studio-edit-field-label">相似度阈值</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
className="studio-edit-input"
|
||||
value={insertion.ragConfig?.threshold ?? 0.5}
|
||||
onChange={(e) =>
|
||||
onUpdateRagConfig('threshold', Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-edit-field">
|
||||
<span className="studio-edit-field-label">最大条数</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
className="studio-edit-input"
|
||||
value={insertion.ragConfig?.maxEntries ?? 3}
|
||||
onChange={(e) =>
|
||||
onUpdateRagConfig('maxEntries', Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="studio-edit-field">
|
||||
<span className="studio-edit-field-label">
|
||||
<FieldLabel tip="写入世界书条目的说明注释,便于在编辑器中识别">备注</FieldLabel>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="studio-edit-input"
|
||||
value={insertion.comment ?? ''}
|
||||
onChange={(e) => onUpdateInsertion('comment', e.target.value)}
|
||||
placeholder="写入世界书条目的说明注释"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
347
frontend/src/components/Studio/edit/variableUtils.js
Normal file
347
frontend/src/components/Studio/edit/variableUtils.js
Normal file
@@ -0,0 +1,347 @@
|
||||
/** Resolve node displayName from dynamic ref like `aesthetic.output`. */
|
||||
export function nodeDisplayNameFromRef(ref, pipeline) {
|
||||
const nodes = pipeline?.nodes || [];
|
||||
const node = nodes.find((n) => ref.startsWith(`${n.id}.`));
|
||||
return node?.displayName || ref.split('.')[0];
|
||||
}
|
||||
|
||||
/** Extract pipeline node id from a step output ref (`nodeId.output`). */
|
||||
export function parseNodeRef(ref) {
|
||||
if (!ref || typeof ref !== 'string') return null;
|
||||
const m = ref.match(/^([^.]+)\.output$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/** Locale-aware display name compare (Chinese + numeric tie-break). */
|
||||
export function compareNodeDisplayNames(a, b) {
|
||||
const nameA = a?.displayName || '';
|
||||
const nameB = b?.displayName || '';
|
||||
return nameA.localeCompare(nameB, 'zh-CN', {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Topological layer sort by `inputs[].ref` dependencies.
|
||||
* Same layer → display name (zh-CN localeCompare).
|
||||
*/
|
||||
export function sortNodesByLogic(pipeline) {
|
||||
const nodes = pipeline?.nodes || [];
|
||||
if (nodes.length <= 1) return [...nodes];
|
||||
|
||||
const edges = buildNodeDependencyEdges(pipeline);
|
||||
const depth = Object.fromEntries(nodes.map((n) => [n.id, 0]));
|
||||
const pipelineIndex = Object.fromEntries(nodes.map((n, i) => [n.id, i]));
|
||||
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
edges.forEach(({ from, to }) => {
|
||||
const next = (depth[from] || 0) + 1;
|
||||
if (next > (depth[to] || 0)) {
|
||||
depth[to] = next;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const layers = {};
|
||||
nodes.forEach((n) => {
|
||||
const d = depth[n.id] || 0;
|
||||
if (!layers[d]) layers[d] = [];
|
||||
layers[d].push(n);
|
||||
});
|
||||
|
||||
const sorted = [];
|
||||
Object.keys(layers)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.forEach((d) => {
|
||||
layers[d].sort((a, b) => {
|
||||
const byName = compareNodeDisplayNames(a, b);
|
||||
if (byName !== 0) return byName;
|
||||
return (pipelineIndex[a.id] ?? 0) - (pipelineIndex[b.id] ?? 0);
|
||||
});
|
||||
sorted.push(...layers[d]);
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/** Order node ids by manual pipeline order snapshot. */
|
||||
export function orderNodesByIds(nodes, orderIds) {
|
||||
const orderMap = Object.fromEntries((orderIds || []).map((id, i) => [id, i]));
|
||||
return [...(nodes || [])].sort((a, b) => {
|
||||
const ia = orderMap[a.id] ?? Number.MAX_SAFE_INTEGER;
|
||||
const ib = orderMap[b.id] ?? Number.MAX_SAFE_INTEGER;
|
||||
if (ia !== ib) return ia - ib;
|
||||
return compareNodeDisplayNames(a, b);
|
||||
});
|
||||
}
|
||||
|
||||
/** Dependency edges derived from `inputs[].ref` between pipeline nodes. */
|
||||
export function buildNodeDependencyEdges(pipeline) {
|
||||
const nodes = pipeline?.nodes || [];
|
||||
const nodeIds = new Set(nodes.map((n) => n.id));
|
||||
const edges = [];
|
||||
const edgeKeys = new Set();
|
||||
|
||||
nodes.forEach((node) => {
|
||||
(node.inputs || []).forEach((inp) => {
|
||||
const src = parseNodeRef(inp.ref);
|
||||
if (!src || src === node.id || !nodeIds.has(src)) return;
|
||||
const key = `${src}->${node.id}`;
|
||||
if (edgeKeys.has(key)) return;
|
||||
edgeKeys.add(key);
|
||||
edges.push({ from: src, to: node.id });
|
||||
});
|
||||
});
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
/** True if adding `fromNodeId → toNodeId` would close a dependency cycle. */
|
||||
export function wouldCreateDependencyCycle(pipeline, toNodeId, fromNodeId) {
|
||||
if (!toNodeId || !fromNodeId || toNodeId === fromNodeId) return true;
|
||||
|
||||
const edges = buildNodeDependencyEdges(pipeline);
|
||||
const visited = new Set();
|
||||
const stack = [toNodeId];
|
||||
|
||||
while (stack.length) {
|
||||
const cur = stack.pop();
|
||||
if (cur === fromNodeId) return true;
|
||||
if (visited.has(cur)) continue;
|
||||
visited.add(cur);
|
||||
edges.filter((e) => e.from === cur).forEach((e) => stack.push(e.to));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Find dependency cycles in the pipeline (node id paths). */
|
||||
export function detectReferenceCycles(pipeline) {
|
||||
const nodes = pipeline?.nodes || [];
|
||||
const nodeIds = nodes.map((n) => n.id);
|
||||
const adj = Object.fromEntries(nodeIds.map((id) => [id, []]));
|
||||
|
||||
buildNodeDependencyEdges(pipeline).forEach(({ from, to }) => {
|
||||
adj[from].push(to);
|
||||
});
|
||||
|
||||
const cycles = [];
|
||||
const visited = new Set();
|
||||
const stack = new Set();
|
||||
const path = [];
|
||||
|
||||
const dfs = (nodeId) => {
|
||||
visited.add(nodeId);
|
||||
stack.add(nodeId);
|
||||
path.push(nodeId);
|
||||
|
||||
(adj[nodeId] || []).forEach((next) => {
|
||||
if (!visited.has(next)) {
|
||||
dfs(next);
|
||||
} else if (stack.has(next)) {
|
||||
const start = path.indexOf(next);
|
||||
if (start >= 0) {
|
||||
cycles.push([...path.slice(start), next]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
path.pop();
|
||||
stack.delete(nodeId);
|
||||
};
|
||||
|
||||
nodeIds.forEach((id) => {
|
||||
if (!visited.has(id)) dfs(id);
|
||||
});
|
||||
|
||||
return cycles;
|
||||
}
|
||||
|
||||
export function formatCycleWarning(cycles, pipeline) {
|
||||
if (!cycles.length) return null;
|
||||
const nameOf = (id) =>
|
||||
pipeline?.nodes?.find((n) => n.id === id)?.displayName || id;
|
||||
const first = cycles[0];
|
||||
const chain = first.map(nameOf).join(' → ');
|
||||
return `检测到循环引用:${chain}`;
|
||||
}
|
||||
|
||||
/** Worldbook steps that may be referenced without creating a cycle. */
|
||||
export function getSelectableStepOutputRefs(pipeline, selectedNode) {
|
||||
if (!selectedNode?.id) return [];
|
||||
|
||||
return (pipeline?.nodes || []).filter(
|
||||
(n) =>
|
||||
n.enabled !== false &&
|
||||
n.skillId === 'studio.worldbook_entry' &&
|
||||
n.id !== selectedNode.id &&
|
||||
!wouldCreateDependencyCycle(pipeline, selectedNode.id, n.id)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildStepOutputRef(nodeId) {
|
||||
return `${nodeId}.output`;
|
||||
}
|
||||
|
||||
export function stepOutputLabel(displayName) {
|
||||
return `${displayName} · 世界书条目`;
|
||||
}
|
||||
|
||||
export function dynamicChipLabel(ref, pipeline, fallbackLabel) {
|
||||
const name = nodeDisplayNameFromRef(ref, pipeline);
|
||||
if (name && name !== ref.split('.')[0]) return stepOutputLabel(name);
|
||||
return fallbackLabel || stepOutputLabel(name);
|
||||
}
|
||||
|
||||
export function dynamicTooltip(ref, pipeline) {
|
||||
const name = nodeDisplayNameFromRef(ref, pipeline);
|
||||
return `引用前序步骤「${name}」的最终世界书条目 · ${ref}`;
|
||||
}
|
||||
|
||||
export const DEFAULT_THINKING_PROMPT = `====== 思考流程 ======
|
||||
Step1: 简短确认任务性质(新设计/修改)
|
||||
Step2: 阅读绑定角色/世界书与上文引用
|
||||
Step3: 按步骤目标起草世界书条目
|
||||
Step4: 对照评价维度自检并优化表述
|
||||
|
||||
(核心目的、评价标准与优化建议由系统在运行时自动注入,无需在此填写)`;
|
||||
|
||||
export const AUTO_INJECTED_CONTEXT_ITEMS = [
|
||||
{
|
||||
id: 'currentProduct',
|
||||
label: '目前产物',
|
||||
description:
|
||||
'当前步骤已生成的世界书条目草稿或最新版本,供模型在迭代修改时对照与延续。',
|
||||
},
|
||||
{
|
||||
id: 'thinkingFlow',
|
||||
label: '思考流程',
|
||||
description:
|
||||
'本步骤配置的 thinkingPrompt,引导模型按既定步骤推理与自检。',
|
||||
},
|
||||
{
|
||||
id: 'coreGoal',
|
||||
label: '核心目的',
|
||||
description:
|
||||
'本步骤的 stepGoal(步骤目标),明确本步要产出的内容与边界。',
|
||||
},
|
||||
{
|
||||
id: 'scoringCriteria',
|
||||
label: '评价标准与优化建议',
|
||||
description:
|
||||
'本步骤启用的 scoring 评价维度及准则,用于模型自检与优化表述。',
|
||||
},
|
||||
];
|
||||
|
||||
export const AUTO_INJECTED_CONTEXT_LABELS = AUTO_INJECTED_CONTEXT_ITEMS.map(
|
||||
(item) => item.label
|
||||
);
|
||||
|
||||
const NODE_W = 128;
|
||||
const NODE_H = 52;
|
||||
const GAP_X = 48;
|
||||
const GAP_Y = 64;
|
||||
const PAD = 24;
|
||||
|
||||
function verticalEdgePath(from, to) {
|
||||
const x1 = from.x + NODE_W / 2;
|
||||
const y1 = from.y + NODE_H;
|
||||
const x2 = to.x + NODE_W / 2;
|
||||
const y2 = to.y;
|
||||
const dy = Math.max(24, (y2 - y1) * 0.45);
|
||||
return `M ${x1} ${y1} C ${x1} ${y1 + dy}, ${x2} ${y2 - dy}, ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
/** Layout nodes as a vertical dependency tree for SVG progress graphs. */
|
||||
export function buildGraphLayout(pipeline, nodeStates, options = {}) {
|
||||
const vertical = options.vertical !== false;
|
||||
const nodes = (pipeline?.nodes || []).filter((n) => n.enabled !== false);
|
||||
const stateMap = Object.fromEntries(
|
||||
(nodeStates || []).map((s) => [s.nodeId, s])
|
||||
);
|
||||
const pipelineIndex = Object.fromEntries(
|
||||
nodes.map((n, i) => [n.id, i])
|
||||
);
|
||||
|
||||
const edges = buildNodeDependencyEdges(pipeline);
|
||||
|
||||
const depth = {};
|
||||
nodes.forEach((n) => {
|
||||
depth[n.id] = 0;
|
||||
});
|
||||
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
edges.forEach(({ from, to }) => {
|
||||
const next = (depth[from] || 0) + 1;
|
||||
if (next > (depth[to] || 0)) {
|
||||
depth[to] = next;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const layers = {};
|
||||
nodes.forEach((n) => {
|
||||
const d = depth[n.id] || 0;
|
||||
if (!layers[d]) layers[d] = [];
|
||||
layers[d].push(n);
|
||||
});
|
||||
|
||||
Object.values(layers).forEach((layer) => {
|
||||
layer.sort((a, b) => (pipelineIndex[a.id] ?? 0) - (pipelineIndex[b.id] ?? 0));
|
||||
});
|
||||
|
||||
const positions = {};
|
||||
Object.keys(layers)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.forEach((d) => {
|
||||
const layer = layers[d];
|
||||
layer.forEach((node, col) => {
|
||||
if (vertical) {
|
||||
positions[node.id] = {
|
||||
x: PAD + col * (NODE_W + GAP_X),
|
||||
y: PAD + d * (NODE_H + GAP_Y),
|
||||
};
|
||||
} else {
|
||||
positions[node.id] = {
|
||||
x: PAD + d * (NODE_W + GAP_X),
|
||||
y: PAD + col * (NODE_H + GAP_Y),
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const maxLayer = Math.max(0, ...Object.keys(layers).map(Number));
|
||||
const maxCols = Math.max(1, ...Object.values(layers).map((l) => l.length));
|
||||
|
||||
const width = vertical
|
||||
? PAD * 2 + maxCols * NODE_W + (maxCols - 1) * GAP_X
|
||||
: PAD * 2 + (maxLayer + 1) * NODE_W + maxLayer * GAP_X;
|
||||
const height = vertical
|
||||
? PAD * 2 + (maxLayer + 1) * NODE_H + maxLayer * GAP_Y
|
||||
: PAD * 2 + maxCols * NODE_H + (maxCols - 1) * GAP_Y;
|
||||
|
||||
return {
|
||||
nodes: nodes.map((n) => ({
|
||||
id: n.id,
|
||||
label: n.displayName,
|
||||
status: stateMap[n.id]?.status || 'pending',
|
||||
x: positions[n.id]?.x ?? PAD,
|
||||
y: positions[n.id]?.y ?? PAD,
|
||||
})),
|
||||
edges,
|
||||
width,
|
||||
height,
|
||||
nodeW: NODE_W,
|
||||
nodeH: NODE_H,
|
||||
edgePath: vertical ? verticalEdgePath : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Backend workflow variable ref → Chinese display label.
|
||||
* Mirrors data/agent/workflow_variables.json builtIn entries.
|
||||
*/
|
||||
export const BUILTIN_WORKFLOW_VARIABLE_LABELS = {
|
||||
'workflow.goal': '工作流目标文本',
|
||||
'workflow.boundWorldbook': '绑定世界书摘要',
|
||||
'workflow.boundCharacter': '绑定角色卡摘要',
|
||||
};
|
||||
|
||||
/** Build ref → label map from /api/studio/variables response. */
|
||||
export function buildWorkflowVariableLabelMap(workflowVariables) {
|
||||
const map = { ...BUILTIN_WORKFLOW_VARIABLE_LABELS };
|
||||
const builtIn = workflowVariables?.builtIn || [];
|
||||
const dynamic = workflowVariables?.dynamic || [];
|
||||
[...builtIn, ...dynamic].forEach((item) => {
|
||||
if (item?.ref && item?.label) {
|
||||
map[item.ref] = item.label;
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Chinese label for a workflow variable key shown in run preview.
|
||||
* @param {string} ref - e.g. workflow.goal or nodeId.output
|
||||
* @param {Record<string, string>} [labelMap] - optional map from buildWorkflowVariableLabelMap
|
||||
*/
|
||||
export function getWorkflowVariableLabel(ref, labelMap = BUILTIN_WORKFLOW_VARIABLE_LABELS) {
|
||||
if (!ref) return '';
|
||||
if (labelMap[ref]) return labelMap[ref];
|
||||
const outputMatch = ref.match(/^([^.]+)\.output$/);
|
||||
if (outputMatch) {
|
||||
return `${outputMatch[1]} · 世界书条目`;
|
||||
}
|
||||
return ref;
|
||||
}
|
||||
1
frontend/src/components/Studio/index.js
Normal file
1
frontend/src/components/Studio/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './StudioPage';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user