feat(studio): 新增 Studio 工作流编辑/运行页,优化顶部三栏对齐
- 后端:项目/运行 API、上下文服务与数据模型 - 前端:Studio 列表、编辑页(R1/R2 布局)、运行页与节点图 - 编辑页顶部:CSS Grid 统一标签行与控件行对齐,项目按钮独立第三行 - Docker 开发配置与文档脚本 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute, charactersRoute, chatWsRoute, tokenUsageRoute, imageGalleryRoute, regexRoute, chatSummaryRoute
|
||||
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute, charactersRoute, chatWsRoute, tokenUsageRoute, imageGalleryRoute, regexRoute, chatSummaryRoute, studioRoute
|
||||
from utils.file_utils import get_all_roles_and_chats
|
||||
from core.config import settings
|
||||
from pathlib import Path
|
||||
@@ -18,6 +18,7 @@ router.include_router(tokenUsageRoute.router)
|
||||
router.include_router(imageGalleryRoute.router)
|
||||
router.include_router(regexRoute.router)
|
||||
router.include_router(chatSummaryRoute.router)
|
||||
router.include_router(studioRoute.router)
|
||||
|
||||
# ✅ 注册 WebSocket 路由(必须在 HTTP 路由之后,避免路径冲突)
|
||||
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_TEMPLATES_PATH = DATA_PATH / "agent" / "templates"
|
||||
AGENT_RUNS_PATH = DATA_PATH / "agent" / "runs"
|
||||
AGENT_STUDIO_PROJECTS_PATH = DATA_PATH / "agent" / "studio_projects"
|
||||
AGENT_STUDIO_RUNS_PATH = DATA_PATH / "agent" / "studio_runs"
|
||||
AGENT_SKILL_TEMPLATES_FILE = DATA_PATH / "agent" / "skill_templates.json"
|
||||
AGENT_NICHES_FILE = DATA_PATH / "agent" / "niches.json"
|
||||
AGENT_WORKFLOW_VARIABLES_FILE = DATA_PATH / "agent" / "workflow_variables.json"
|
||||
|
||||
def ensure_directories(self):
|
||||
"""确保所有配置的目录存在,如果不存在则创建"""
|
||||
@@ -78,6 +83,8 @@ class Settings:
|
||||
self.IMAGES_PATH,
|
||||
self.AGENT_TEMPLATES_PATH,
|
||||
self.AGENT_RUNS_PATH,
|
||||
self.AGENT_STUDIO_PROJECTS_PATH,
|
||||
self.AGENT_STUDIO_RUNS_PATH,
|
||||
]
|
||||
for directory in directories:
|
||||
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()
|
||||
Reference in New Issue
Block a user