feat(studio): 新增 Studio 工作流编辑/运行页,优化顶部三栏对齐

- 后端:项目/运行 API、上下文服务与数据模型
- 前端:Studio 列表、编辑页(R1/R2 布局)、运行页与节点图
- 编辑页顶部:CSS Grid 统一标签行与控件行对齐,项目按钮独立第三行
- Docker 开发配置与文档脚本

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 21:24:57 +08:00
parent bc130d98f4
commit fa6907fb8d
48 changed files with 8795 additions and 20 deletions

View 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})

View 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)

View 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()