feat(studio): 新增 Studio 工作流编辑/运行页,优化顶部三栏对齐
- 后端:项目/运行 API、上下文服务与数据模型 - 前端:Studio 列表、编辑页(R1/R2 布局)、运行页与节点图 - 编辑页顶部:CSS Grid 统一标签行与控件行对齐,项目按钮独立第三行 - Docker 开发配置与文档脚本 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
33
README.md
33
README.md
@@ -122,20 +122,35 @@ npm run dev
|
|||||||
|
|
||||||
前端将在 `http://localhost:5173` 启动,自动代理 API 请求到后端。
|
前端将在 `http://localhost:5173` 启动,自动代理 API 请求到后端。
|
||||||
|
|
||||||
### Docker 部署
|
### Docker 开发(Windows / Docker Desktop)
|
||||||
|
|
||||||
```bash
|
日常改代码**不需要重启 Docker Desktop**——后端 uvicorn `--reload`、前端 Vite HMR 会自动生效。
|
||||||
# 一键启动
|
|
||||||
docker-compose up -d
|
```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
|
.\scripts\docker-logs.ps1
|
||||||
|
|
||||||
# 停止服务
|
|
||||||
docker-compose down
|
|
||||||
```
|
```
|
||||||
|
|
||||||
访问 `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 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
|
||||||
from utils.file_utils import get_all_roles_and_chats
|
from utils.file_utils import get_all_roles_and_chats
|
||||||
from core.config import settings
|
from core.config import settings
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -18,6 +18,7 @@ router.include_router(tokenUsageRoute.router)
|
|||||||
router.include_router(imageGalleryRoute.router)
|
router.include_router(imageGalleryRoute.router)
|
||||||
router.include_router(regexRoute.router)
|
router.include_router(regexRoute.router)
|
||||||
router.include_router(chatSummaryRoute.router)
|
router.include_router(chatSummaryRoute.router)
|
||||||
|
router.include_router(studioRoute.router)
|
||||||
|
|
||||||
# ✅ 注册 WebSocket 路由(必须在 HTTP 路由之后,避免路径冲突)
|
# ✅ 注册 WebSocket 路由(必须在 HTTP 路由之后,避免路径冲突)
|
||||||
router.include_router(chatWsRoute.router)
|
router.include_router(chatWsRoute.router)
|
||||||
|
|||||||
217
backend/api/routes/studioRoute.py
Normal file
217
backend/api/routes/studioRoute.py
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from models.studio_models import (
|
||||||
|
AdvanceRunRequest,
|
||||||
|
CreateStudioProjectRequest,
|
||||||
|
PipelineDefinition,
|
||||||
|
RenameRunRequest,
|
||||||
|
StudioProject,
|
||||||
|
StudioProjectSummary,
|
||||||
|
StudioRun,
|
||||||
|
StudioRunSummary,
|
||||||
|
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
|
||||||
|
)
|
||||||
|
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.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))
|
||||||
@@ -64,6 +64,11 @@ class Settings:
|
|||||||
# Agent 工作流模板与运行记录
|
# Agent 工作流模板与运行记录
|
||||||
AGENT_TEMPLATES_PATH = DATA_PATH / "agent" / "templates"
|
AGENT_TEMPLATES_PATH = DATA_PATH / "agent" / "templates"
|
||||||
AGENT_RUNS_PATH = DATA_PATH / "agent" / "runs"
|
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"
|
||||||
|
|
||||||
def ensure_directories(self):
|
def ensure_directories(self):
|
||||||
"""确保所有配置的目录存在,如果不存在则创建"""
|
"""确保所有配置的目录存在,如果不存在则创建"""
|
||||||
@@ -78,6 +83,8 @@ class Settings:
|
|||||||
self.IMAGES_PATH,
|
self.IMAGES_PATH,
|
||||||
self.AGENT_TEMPLATES_PATH,
|
self.AGENT_TEMPLATES_PATH,
|
||||||
self.AGENT_RUNS_PATH,
|
self.AGENT_RUNS_PATH,
|
||||||
|
self.AGENT_STUDIO_PROJECTS_PATH,
|
||||||
|
self.AGENT_STUDIO_RUNS_PATH,
|
||||||
]
|
]
|
||||||
for directory in directories:
|
for directory in directories:
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
216
backend/models/studio_models.py
Normal file
216
backend/models/studio_models.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"""
|
||||||
|
Studio workflow editor data models.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Dict, List, 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 LastToolResponse(BaseModel):
|
||||||
|
"""LLM tool-call payload surfaced to the run UI (R2+)."""
|
||||||
|
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 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
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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 = ""
|
||||||
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)
|
||||||
322
backend/services/studio_run_service.py
Normal file
322
backend/services/studio_run_service.py
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
"""
|
||||||
|
Create and load Studio pipeline runs with frozen pipeline snapshots.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
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.studio_models import (
|
||||||
|
PipelineDefinition,
|
||||||
|
StudioNode,
|
||||||
|
StudioNodeRunState,
|
||||||
|
StudioRun,
|
||||||
|
StudioRunStatus,
|
||||||
|
StudioRunSummary,
|
||||||
|
)
|
||||||
|
from services.character_service import CharacterService
|
||||||
|
from services.studio_context_service import store_context_on_run
|
||||||
|
from services.studio_project_service import studio_project_service
|
||||||
|
from services.worldbook_service import worldbook_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json(path: Path) -> dict:
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_json(path: Path, data: dict) -> 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 _build_node_states(pipeline: PipelineDefinition) -> tuple[list[StudioNodeRunState], Optional[str]]:
|
||||||
|
"""First enabled node is active; other enabled nodes pending; disabled skipped."""
|
||||||
|
states: list[StudioNodeRunState] = []
|
||||||
|
first_active_id: Optional[str] = None
|
||||||
|
seen_active = False
|
||||||
|
|
||||||
|
for node in pipeline.nodes:
|
||||||
|
if not node.enabled:
|
||||||
|
status = "skipped"
|
||||||
|
elif not seen_active:
|
||||||
|
status = "active"
|
||||||
|
first_active_id = node.id
|
||||||
|
seen_active = True
|
||||||
|
else:
|
||||||
|
status = "pending"
|
||||||
|
|
||||||
|
states.append(
|
||||||
|
StudioNodeRunState(
|
||||||
|
nodeId=node.id,
|
||||||
|
displayName=node.displayName,
|
||||||
|
skillId=node.skillId,
|
||||||
|
status=status,
|
||||||
|
loopUntilSatisfied=node.loopUntilSatisfied,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return states, first_active_id
|
||||||
|
|
||||||
|
|
||||||
|
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 _next_enabled_node_id(pipeline: PipelineDefinition, after_node_id: str) -> Optional[str]:
|
||||||
|
seen = False
|
||||||
|
for node in pipeline.nodes:
|
||||||
|
if node.id == after_node_id:
|
||||||
|
seen = True
|
||||||
|
continue
|
||||||
|
if seen and node.enabled:
|
||||||
|
return node.id
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class StudioRunService:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._character_service = CharacterService()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def runs_root(self) -> Path:
|
||||||
|
return settings.AGENT_STUDIO_RUNS_PATH
|
||||||
|
|
||||||
|
def _project_runs_dir(self, project_id: str) -> Path:
|
||||||
|
return self.runs_root / project_id
|
||||||
|
|
||||||
|
def _run_dir(self, project_id: str, run_id: str) -> Path:
|
||||||
|
return self._project_runs_dir(project_id) / run_id
|
||||||
|
|
||||||
|
def _run_path(self, project_id: str, run_id: str) -> Path:
|
||||||
|
return self._run_dir(project_id, run_id) / "run.json"
|
||||||
|
|
||||||
|
def _save_run(self, project_id: str, run_id: str, run: StudioRun) -> None:
|
||||||
|
_write_json(
|
||||||
|
self._run_path(project_id, run_id),
|
||||||
|
run.model_dump(mode="json"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_run(self, project_id: str) -> StudioRun:
|
||||||
|
project = studio_project_service.get_project(project_id)
|
||||||
|
snapshot = PipelineDefinition(**copy.deepcopy(project.pipeline.model_dump()))
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
run_id = str(uuid.uuid4())
|
||||||
|
node_states, current_node_id = _build_node_states(snapshot)
|
||||||
|
|
||||||
|
has_active = current_node_id is not None
|
||||||
|
run = StudioRun(
|
||||||
|
id=run_id,
|
||||||
|
projectId=project_id,
|
||||||
|
status=StudioRunStatus.RUNNING if has_active else StudioRunStatus.PENDING,
|
||||||
|
pipelineSnapshot=snapshot,
|
||||||
|
pipelineVersionNote=now,
|
||||||
|
currentNodeId=current_node_id,
|
||||||
|
nodeStates=node_states,
|
||||||
|
workflowVariables={"workflow.goal": snapshot.workflowGoal},
|
||||||
|
createdAt=now,
|
||||||
|
updatedAt=now,
|
||||||
|
)
|
||||||
|
self._save_run(project_id, run_id, run)
|
||||||
|
return run
|
||||||
|
|
||||||
|
def list_runs(self, project_id: str) -> List[StudioRunSummary]:
|
||||||
|
root = self._project_runs_dir(project_id)
|
||||||
|
if not root.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
summaries: List[StudioRunSummary] = []
|
||||||
|
for child in sorted(root.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||||
|
if not child.is_dir():
|
||||||
|
continue
|
||||||
|
run_path = child / "run.json"
|
||||||
|
if not run_path.exists():
|
||||||
|
continue
|
||||||
|
raw = _read_json(run_path)
|
||||||
|
summaries.append(
|
||||||
|
StudioRunSummary(
|
||||||
|
id=raw.get("id", child.name),
|
||||||
|
projectId=raw.get("projectId", project_id),
|
||||||
|
status=StudioRunStatus(raw.get("status", StudioRunStatus.PENDING.value)),
|
||||||
|
currentNodeId=raw.get("currentNodeId"),
|
||||||
|
title=raw.get("title", ""),
|
||||||
|
createdAt=raw.get("createdAt", ""),
|
||||||
|
updatedAt=raw.get("updatedAt", ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
def get_run(self, project_id: str, run_id: str) -> StudioRun:
|
||||||
|
run_path = self._run_path(project_id, run_id)
|
||||||
|
if not run_path.exists():
|
||||||
|
raise FileNotFoundError(f"Studio run not found: {project_id}/{run_id}")
|
||||||
|
run = StudioRun(**_read_json(run_path))
|
||||||
|
return run
|
||||||
|
|
||||||
|
def _validate_display_params(
|
||||||
|
self, node: StudioNode, display_params: Dict[str, str]
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
normalized: Dict[str, str] = {}
|
||||||
|
for dp in node.displayParams:
|
||||||
|
raw = display_params.get(dp.key, "")
|
||||||
|
value = (raw or "").strip()
|
||||||
|
if dp.required and not value:
|
||||||
|
raise ValueError(f"缺少必填项:{dp.label}")
|
||||||
|
normalized[dp.key] = value
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _execute_init_bind(
|
||||||
|
self,
|
||||||
|
project_id: str,
|
||||||
|
run: StudioRun,
|
||||||
|
node: StudioNode,
|
||||||
|
display_params: Dict[str, str],
|
||||||
|
) -> tuple[Dict[str, Any], Dict[str, Any]]:
|
||||||
|
params = self._validate_display_params(node, display_params)
|
||||||
|
character_name = params.get("characterName", "")
|
||||||
|
worldbook_name = params.get("worldbookName", "")
|
||||||
|
|
||||||
|
if self._character_service.get_character_by_name(character_name):
|
||||||
|
raise ValueError(f"角色「{character_name}」已存在")
|
||||||
|
if worldbook_service._get_worldbook_path(worldbook_name).exists():
|
||||||
|
raise ValueError(f"世界书「{worldbook_name}」已存在")
|
||||||
|
|
||||||
|
worldbook = worldbook_service.create_worldbook(worldbook_name)
|
||||||
|
worldbook_id = worldbook["id"]
|
||||||
|
|
||||||
|
character = self._character_service.create_character(
|
||||||
|
{
|
||||||
|
"name": character_name,
|
||||||
|
"description": "",
|
||||||
|
"personality": "",
|
||||||
|
"scenario": "",
|
||||||
|
"first_mes": "",
|
||||||
|
"mes_example": "",
|
||||||
|
"categories": [],
|
||||||
|
"tags": [],
|
||||||
|
"worldInfoId": worldbook_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
character_id = character.id
|
||||||
|
|
||||||
|
studio_project_service.update_project_bindings(
|
||||||
|
project_id, character_id, worldbook_id
|
||||||
|
)
|
||||||
|
|
||||||
|
workflow_variables = dict(run.workflowVariables or {})
|
||||||
|
workflow_variables["workflow.goal"] = run.pipelineSnapshot.workflowGoal
|
||||||
|
workflow_variables["workflow.boundCharacter"] = (
|
||||||
|
f"名称:{character_name}\nID:{character_id}"
|
||||||
|
)
|
||||||
|
workflow_variables["workflow.boundWorldbook"] = (
|
||||||
|
f"名称:{worldbook_name}\nID:{worldbook_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
last_draft = {
|
||||||
|
"displayParams": params,
|
||||||
|
"characterId": character_id,
|
||||||
|
"worldbookId": worldbook_id,
|
||||||
|
"characterName": character_name,
|
||||||
|
"worldbookName": worldbook_name,
|
||||||
|
}
|
||||||
|
return workflow_variables, last_draft
|
||||||
|
|
||||||
|
def advance_run(
|
||||||
|
self,
|
||||||
|
project_id: str,
|
||||||
|
run_id: str,
|
||||||
|
display_params: Optional[Dict[str, str]] = None,
|
||||||
|
) -> StudioRun:
|
||||||
|
run = self.get_run(project_id, run_id)
|
||||||
|
if run.status not in (StudioRunStatus.RUNNING, StudioRunStatus.PENDING):
|
||||||
|
raise ValueError("运行已结束,无法继续推进")
|
||||||
|
|
||||||
|
current_node_id = run.currentNodeId
|
||||||
|
if not current_node_id:
|
||||||
|
raise ValueError("当前运行无活动节点")
|
||||||
|
|
||||||
|
current_node = _find_node(run.pipelineSnapshot, current_node_id)
|
||||||
|
if not current_node:
|
||||||
|
raise ValueError(f"节点不存在:{current_node_id}")
|
||||||
|
|
||||||
|
current_state = next(
|
||||||
|
(s for s in run.nodeStates if s.nodeId == current_node_id), None
|
||||||
|
)
|
||||||
|
if not current_state or current_state.status != "active":
|
||||||
|
raise ValueError("当前节点不可执行")
|
||||||
|
|
||||||
|
workflow_variables = dict(run.workflowVariables or {})
|
||||||
|
last_draft: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
if current_node.skillId == "studio.init_bind":
|
||||||
|
if not display_params:
|
||||||
|
raise ValueError("请填写引导表单后再提交")
|
||||||
|
workflow_variables, last_draft = self._execute_init_bind(
|
||||||
|
project_id, run, current_node, display_params
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise NotImplementedError(
|
||||||
|
f"技能「{current_node.skillId}」的执行尚未实现(R2+)"
|
||||||
|
)
|
||||||
|
|
||||||
|
next_node_id = _next_enabled_node_id(run.pipelineSnapshot, current_node_id)
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
|
||||||
|
new_node_states: list[StudioNodeRunState] = []
|
||||||
|
for state in run.nodeStates:
|
||||||
|
updated = state.model_copy()
|
||||||
|
if state.nodeId == current_node_id:
|
||||||
|
updated.status = "completed"
|
||||||
|
if last_draft is not None:
|
||||||
|
updated.lastDraft = last_draft
|
||||||
|
elif next_node_id and state.nodeId == next_node_id:
|
||||||
|
updated.status = "active"
|
||||||
|
new_node_states.append(updated)
|
||||||
|
|
||||||
|
new_status = (
|
||||||
|
StudioRunStatus.COMPLETED if not next_node_id else StudioRunStatus.RUNNING
|
||||||
|
)
|
||||||
|
updated_run = run.model_copy(
|
||||||
|
update={
|
||||||
|
"currentNodeId": next_node_id,
|
||||||
|
"nodeStates": new_node_states,
|
||||||
|
"workflowVariables": workflow_variables,
|
||||||
|
"status": new_status,
|
||||||
|
"updatedAt": now,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
updated_run = store_context_on_run(updated_run, next_node_id)
|
||||||
|
self._save_run(project_id, run_id, updated_run)
|
||||||
|
return updated_run
|
||||||
|
|
||||||
|
def delete_run(self, project_id: str, run_id: str) -> None:
|
||||||
|
run_dir = self._run_dir(project_id, run_id)
|
||||||
|
if not run_dir.exists():
|
||||||
|
raise FileNotFoundError(f"Studio run not found: {project_id}/{run_id}")
|
||||||
|
shutil.rmtree(run_dir)
|
||||||
|
|
||||||
|
def rename_run(self, project_id: str, run_id: str, title: str) -> StudioRun:
|
||||||
|
run = self.get_run(project_id, run_id)
|
||||||
|
trimmed = (title or "").strip()
|
||||||
|
if not trimmed:
|
||||||
|
raise ValueError("运行名称不能为空")
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
updated = run.model_copy(update={"title": trimmed, "updatedAt": now})
|
||||||
|
self._save_run(project_id, run_id, updated)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
studio_run_service = StudioRunService()
|
||||||
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": "在整体美学基础上,细化可扮演的人设:性格、动机、口癖、与他人关系。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
58
data/agent/skill_templates.json
Normal file
58
data/agent/skill_templates.json
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
104
data/agent/templates/builtin.studio.example/pipeline.json
Normal file
104
data/agent/templates/builtin.studio.example/pipeline.json
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
{
|
||||||
|
"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": "====== 思考流程 ======\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": "具体人设",
|
||||||
|
"niche": "persona_detail",
|
||||||
|
"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"
|
- "23338:5173"
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
- /app/node_modules
|
- node_modules:/app/node_modules
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- 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:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
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`。**
|
||||||
@@ -5,7 +5,10 @@ import { ChatBox } from './components/Mid';
|
|||||||
import SideBarLeft from './components/SideBarLeft';
|
import SideBarLeft from './components/SideBarLeft';
|
||||||
import SideBarRight from './components/SideBarRight';
|
import SideBarRight from './components/SideBarRight';
|
||||||
import PlaceholderPage from './components/PlaceholderPage';
|
import PlaceholderPage from './components/PlaceholderPage';
|
||||||
|
import StudioEditPage from './components/Studio/StudioEditPage';
|
||||||
|
import StudioRunPage from './components/Studio/StudioRunPage';
|
||||||
import useAppLayoutStore from './Store/AppLayoutSlice'; // ✅ 新增
|
import useAppLayoutStore from './Store/AppLayoutSlice'; // ✅ 新增
|
||||||
|
import useStudioStore from './Store/Studio/StudioSlice';
|
||||||
import useApiConfigStore from './Store/SideBarLeft/ApiConfigSlice'; // ✅ 引入 API 配置 Store
|
import useApiConfigStore from './Store/SideBarLeft/ApiConfigSlice'; // ✅ 引入 API 配置 Store
|
||||||
import usePresetStore from './Store/SideBarLeft/PresetSlice'; // ✅ 引入预设 Store
|
import usePresetStore from './Store/SideBarLeft/PresetSlice'; // ✅ 引入预设 Store
|
||||||
import useCharacterStore from './Store/SideBarLeft/CharacterSlice'; // ✅ 引入角色卡 Store
|
import useCharacterStore from './Store/SideBarLeft/CharacterSlice'; // ✅ 引入角色卡 Store
|
||||||
@@ -189,6 +192,20 @@ function App() {
|
|||||||
});
|
});
|
||||||
}, []); // 仅在应用启动时执行一次
|
}, []); // 仅在应用启动时执行一次
|
||||||
|
|
||||||
|
const initStudio = useStudioStore((s) => s.initStudio);
|
||||||
|
const initStudioRun = useStudioStore((s) => s.initStudioRun);
|
||||||
|
const isStudioEditPage = activePage === 'studio_edit';
|
||||||
|
const isStudioRunPage = activePage === 'studio_run';
|
||||||
|
const isStudioPage = isStudioEditPage || isStudioRunPage;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isStudioEditPage) {
|
||||||
|
initStudio();
|
||||||
|
} else if (isStudioRunPage) {
|
||||||
|
initStudioRun();
|
||||||
|
}
|
||||||
|
}, [isStudioEditPage, isStudioRunPage, initStudio, initStudioRun]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`app ${layoutMode}-mode`}>
|
<div className={`app ${layoutMode}-mode`}>
|
||||||
{/* ✅ TopBar 不再需要 props,直接从 Store 读取状态 */}
|
{/* ✅ TopBar 不再需要 props,直接从 Store 读取状态 */}
|
||||||
@@ -218,6 +235,14 @@ function App() {
|
|||||||
<SideBarRight />
|
<SideBarRight />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : activePage === 'studio_edit' ? (
|
||||||
|
<div className="main-container studio-container">
|
||||||
|
<StudioEditPage />
|
||||||
|
</div>
|
||||||
|
) : activePage === 'studio_run' ? (
|
||||||
|
<div className="main-container studio-container">
|
||||||
|
<StudioRunPage />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="main-container placeholder-container">
|
<div className="main-container placeholder-container">
|
||||||
<PlaceholderPage page={activePage} />
|
<PlaceholderPage page={activePage} />
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const useAppLayoutStore = create(
|
|||||||
// 颜色主题:'light' | 'dark'
|
// 颜色主题:'light' | 'dark'
|
||||||
colorTheme: 'dark',
|
colorTheme: 'dark',
|
||||||
|
|
||||||
// 当前页面:'chat' | 'studio' | 'novel' | 'room'
|
// 当前页面:'chat' | 'studio_edit' | 'studio_run' | 'novel' | 'room'
|
||||||
activePage: 'chat',
|
activePage: 'chat',
|
||||||
|
|
||||||
// ==================== Actions ====================
|
// ==================== Actions ====================
|
||||||
@@ -53,7 +53,7 @@ const useAppLayoutStore = create(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置当前页面
|
* 设置当前页面
|
||||||
* @param {string} page - 'chat' | 'studio' | 'novel' | 'room'
|
* @param {string} page - 'chat' | 'studio_edit' | 'studio_run' | 'novel' | 'room'
|
||||||
*/
|
*/
|
||||||
setActivePage: (page) => {
|
setActivePage: (page) => {
|
||||||
set({ activePage: page });
|
set({ activePage: page });
|
||||||
@@ -95,7 +95,15 @@ const useAppLayoutStore = create(
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
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) => ({
|
partialize: (state) => ({
|
||||||
layoutMode: state.layoutMode,
|
layoutMode: state.layoutMode,
|
||||||
sidebarMode: state.sidebarMode,
|
sidebarMode: state.sidebarMode,
|
||||||
|
|||||||
594
frontend/src/Store/Studio/StudioSlice.jsx
Normal file
594
frontend/src/Store/Studio/StudioSlice.jsx
Normal file
@@ -0,0 +1,594 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
const DEFAULT_TEMPLATE_ID = 'builtin.studio.example';
|
||||||
|
|
||||||
|
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: [],
|
||||||
|
niches: [],
|
||||||
|
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,
|
||||||
|
runError: null,
|
||||||
|
|
||||||
|
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 (skillId === 'studio.worldbook_entry') {
|
||||||
|
base.loopUntilSatisfied = false;
|
||||||
|
base.config = {
|
||||||
|
stepGoal: '',
|
||||||
|
thinkingPrompt: `====== 思考流程 ======
|
||||||
|
Step1: 简短确认任务性质(新设计/修改)
|
||||||
|
Step2: 阅读绑定角色/世界书与上文引用
|
||||||
|
Step3: 按步骤目标起草世界书条目
|
||||||
|
Step4: 对照评价维度自检并优化表述
|
||||||
|
|
||||||
|
(核心目的、评价标准与优化建议由系统在运行时自动注入,无需在此填写)`,
|
||||||
|
insertion: {
|
||||||
|
position: 1,
|
||||||
|
activationType: 'permanent',
|
||||||
|
key: '',
|
||||||
|
keysecondary: '',
|
||||||
|
comment: '',
|
||||||
|
},
|
||||||
|
scoring: { enabled: true, dimensions: [] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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 [tplRes, nicheRes] = await Promise.all([
|
||||||
|
fetch('/api/studio/skill-templates'),
|
||||||
|
fetch('/api/studio/niches'),
|
||||||
|
]);
|
||||||
|
if (!tplRes.ok) throw new Error(await tplRes.text());
|
||||||
|
const tplData = await tplRes.json();
|
||||||
|
const niches = nicheRes.ok
|
||||||
|
? (await nicheRes.json()).niches || []
|
||||||
|
: [];
|
||||||
|
set({ skillTemplates: tplData.templates || [], niches });
|
||||||
|
} 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: [],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
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) => {
|
||||||
|
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 }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
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 get().fetchProjects();
|
||||||
|
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;
|
||||||
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;
|
||||||
1184
frontend/src/components/Studio/StudioEditPage.css
Normal file
1184
frontend/src/components/Studio/StudioEditPage.css
Normal file
File diff suppressed because it is too large
Load Diff
795
frontend/src/components/Studio/StudioEditPage.jsx
Normal file
795
frontend/src/components/Studio/StudioEditPage.jsx
Normal file
@@ -0,0 +1,795 @@
|
|||||||
|
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,
|
||||||
|
niches,
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
{isWorldbook && niches.length > 0 && (
|
||||||
|
<label className="studio-edit-meta-strip__field">
|
||||||
|
<span className="studio-edit-field-label">
|
||||||
|
<FieldLabel tip="选择预设领域可自动填充步骤目标建议">
|
||||||
|
预设领域
|
||||||
|
</FieldLabel>
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className="studio-edit-select"
|
||||||
|
value={selectedNode.niche ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nicheId = e.target.value || null;
|
||||||
|
const niche = niches.find((n) => n.id === nicheId);
|
||||||
|
updateNode(selectedNode.id, { niche: nicheId });
|
||||||
|
if (niche?.suggestedStepGoal && !selectedNode.config?.stepGoal) {
|
||||||
|
updateNodeConfig(selectedNode.id, {
|
||||||
|
stepGoal: niche.suggestedStepGoal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">无</option>
|
||||||
|
{niches.map((n) => (
|
||||||
|
<option key={n.id} value={n.id}>
|
||||||
|
{n.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</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(420px, calc(100vw - 32px));
|
||||||
|
max-height: min(70vh, 520px);
|
||||||
|
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;
|
||||||
|
|
||||||
240
frontend/src/components/Studio/StudioRunChat.css
Normal file
240
frontend/src/components/Studio/StudioRunChat.css
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-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;
|
||||||
|
}
|
||||||
171
frontend/src/components/Studio/StudioRunChat.jsx
Normal file
171
frontend/src/components/Studio/StudioRunChat.jsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
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 renderMessageBody(text, renderMode, isUser) {
|
||||||
|
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({
|
||||||
|
messages,
|
||||||
|
inputValue,
|
||||||
|
onInputChange,
|
||||||
|
onSend,
|
||||||
|
disabled = false,
|
||||||
|
placeholder = '输入消息…',
|
||||||
|
sending = false,
|
||||||
|
}) {
|
||||||
|
const messagesEndRef = useRef(null);
|
||||||
|
const messagesContainerRef = useRef(null);
|
||||||
|
const optionsRef = useRef(null);
|
||||||
|
const [showOptions, setShowOptions] = useState(false);
|
||||||
|
const [renderMode, setRenderMode] = useState('markdown');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
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 (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 (!disabled && !sending && inputValue.trim()) {
|
||||||
|
onSend(inputValue.trim());
|
||||||
|
e.target.style.height = 'auto';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cycleRenderMode = () => {
|
||||||
|
const idx = RENDER_MODES.indexOf(renderMode);
|
||||||
|
setRenderMode(RENDER_MODES[(idx + 1) % RENDER_MODES.length]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleLabel = (role) => {
|
||||||
|
if (role === 'user') return '你';
|
||||||
|
if (role === 'system') return '系统';
|
||||||
|
return '助手';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="studio-run-chat">
|
||||||
|
<div className="studio-run-chat-messages" ref={messagesContainerRef}>
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="studio-run-chat-empty">暂无对话,在下方输入开始交流</div>
|
||||||
|
) : (
|
||||||
|
messages.map((msg) => (
|
||||||
|
<div key={msg.id} className={`studio-run-chat-message role-${msg.role}`}>
|
||||||
|
<span className="studio-run-chat-message-role">{roleLabel(msg.role)}</span>
|
||||||
|
<div className="studio-run-chat-message-body">
|
||||||
|
{renderMessageBody(msg.text, renderMode, msg.role === 'user')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</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 || sending}
|
||||||
|
aria-label="输入消息"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="studio-run-chat-send"
|
||||||
|
disabled={disabled || sending || !inputValue.trim()}
|
||||||
|
aria-label="发送"
|
||||||
|
title="发送"
|
||||||
|
>
|
||||||
|
{sending ? '…' : '>'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StudioRunChat;
|
||||||
89
frontend/src/components/Studio/StudioRunNodeGraph.css
Normal file
89
frontend/src/components/Studio/StudioRunNodeGraph.css
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
.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 {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
233
frontend/src/components/Studio/StudioRunNodeGraph.jsx
Normal file
233
frontend/src/components/Studio/StudioRunNodeGraph.jsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
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',
|
||||||
|
|
||||||
|
}) {
|
||||||
|
|
||||||
|
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 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 },
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
[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) / view.scale;
|
||||||
|
|
||||||
|
const dy = (e.clientY - dragging.startY) / view.scale;
|
||||||
|
|
||||||
|
setNodeOverrides((prev) => ({
|
||||||
1164
frontend/src/components/Studio/StudioRunPage.css
Normal file
1164
frontend/src/components/Studio/StudioRunPage.css
Normal file
File diff suppressed because it is too large
Load Diff
1012
frontend/src/components/Studio/StudioRunPage.jsx
Normal file
1012
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';
|
||||||
@@ -4,7 +4,8 @@ import './PageModeToggle.css';
|
|||||||
|
|
||||||
const PAGE_MODES = [
|
const PAGE_MODES = [
|
||||||
{ id: 'chat', label: '聊天', icon: '💬' },
|
{ id: 'chat', label: '聊天', icon: '💬' },
|
||||||
{ id: 'studio', label: '工作室', icon: '🎭' },
|
{ id: 'studio_edit', label: '工作流编辑', icon: '✏️' },
|
||||||
|
{ id: 'studio_run', label: '创作运行', icon: '🚀' },
|
||||||
{ id: 'novel', label: '爽文', icon: '📖' },
|
{ id: 'novel', label: '爽文', icon: '📖' },
|
||||||
{ id: 'room', label: '房间', icon: '🏠' },
|
{ id: 'room', label: '房间', icon: '🏠' },
|
||||||
];
|
];
|
||||||
@@ -36,7 +37,10 @@ const PageModeToggle = () => {
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentMode = PAGE_MODES.find((m) => m.id === activePage) || PAGE_MODES[0];
|
const resolvedPage = activePage === 'studio' ? 'studio_edit' : activePage;
|
||||||
|
|
||||||
|
const currentMode =
|
||||||
|
PAGE_MODES.find((m) => m.id === resolvedPage) || PAGE_MODES[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-mode-toggle-wrapper" ref={panelRef}>
|
<div className="page-mode-toggle-wrapper" ref={panelRef}>
|
||||||
@@ -61,8 +65,8 @@ const PageModeToggle = () => {
|
|||||||
key={mode.id}
|
key={mode.id}
|
||||||
type="button"
|
type="button"
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={activePage === mode.id}
|
aria-selected={resolvedPage === mode.id}
|
||||||
className={`page-mode-item ${activePage === mode.id ? 'active' : ''}`}
|
className={`page-mode-item ${resolvedPage === mode.id ? 'active' : ''}`}
|
||||||
onClick={() => handleModeSelect(mode.id)}
|
onClick={() => handleModeSelect(mode.id)}
|
||||||
>
|
>
|
||||||
<span className="page-mode-item-icon">{mode.icon}</span>
|
<span className="page-mode-item-icon">{mode.icon}</span>
|
||||||
|
|||||||
@@ -27,10 +27,16 @@
|
|||||||
margin-top: 0; /* Ensure panels start from top */
|
margin-top: 0; /* Ensure panels start from top */
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-container.placeholder-container {
|
.main-container.placeholder-container,
|
||||||
|
.main-container.studio-container {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.main-container.studio-container {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Smooth transitions for theme changes - only apply to specific properties */
|
/* Smooth transitions for theme changes - only apply to specific properties */
|
||||||
.app {
|
.app {
|
||||||
transition: background-color var(--transition-normal),
|
transition: background-color var(--transition-normal),
|
||||||
|
|||||||
15
scripts/docker-logs.ps1
Normal file
15
scripts/docker-logs.ps1
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# 跟踪 Docker Compose 日志
|
||||||
|
param(
|
||||||
|
[ValidateSet("backend", "frontend", "")]
|
||||||
|
[string]$Service = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||||
|
Set-Location $Root
|
||||||
|
|
||||||
|
if ($Service) {
|
||||||
|
docker compose logs -f $Service
|
||||||
|
} else {
|
||||||
|
docker compose logs -f
|
||||||
|
}
|
||||||
17
scripts/docker-rebuild.ps1
Normal file
17
scripts/docker-rebuild.ps1
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# 重新构建并启动指定服务(依赖或 Dockerfile 变更时使用)
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateSet("backend", "frontend")]
|
||||||
|
[string]$Service
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||||
|
Set-Location $Root
|
||||||
|
|
||||||
|
Write-Host "Rebuilding and starting $Service..." -ForegroundColor Cyan
|
||||||
|
docker compose up -d --build $Service
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
|
||||||
|
Write-Host "Done. $Service rebuilt and running." -ForegroundColor Green
|
||||||
21
scripts/docker-restart.ps1
Normal file
21
scripts/docker-restart.ps1
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# 重启容器(backend / frontend / all),无需重启 Docker Desktop
|
||||||
|
param(
|
||||||
|
[ValidateSet("backend", "frontend", "all")]
|
||||||
|
[string]$Service = "all"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||||
|
Set-Location $Root
|
||||||
|
|
||||||
|
if ($Service -eq "all") {
|
||||||
|
Write-Host "Restarting backend and frontend..." -ForegroundColor Cyan
|
||||||
|
docker compose restart backend frontend
|
||||||
|
} else {
|
||||||
|
Write-Host "Restarting $Service..." -ForegroundColor Cyan
|
||||||
|
docker compose restart $Service
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
|
||||||
|
Write-Host "Done. Docker Desktop was NOT restarted." -ForegroundColor Green
|
||||||
16
scripts/docker-up.ps1
Normal file
16
scripts/docker-up.ps1
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# 后台启动 Docker Compose 栈(不重启 Docker Desktop)
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||||
|
Set-Location $Root
|
||||||
|
|
||||||
|
Write-Host "Starting services..." -ForegroundColor Cyan
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Services running:" -ForegroundColor Green
|
||||||
|
Write-Host " Backend: http://localhost:23337"
|
||||||
|
Write-Host " Frontend: http://localhost:23338"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Logs: .\scripts\docker-logs.ps1"
|
||||||
Reference in New Issue
Block a user