- 后端:项目/运行 API、上下文服务与数据模型 - 前端:Studio 列表、编辑页(R1/R2 布局)、运行页与节点图 - 编辑页顶部:CSS Grid 统一标签行与控件行对齐,项目按钮独立第三行 - Docker 开发配置与文档脚本 Co-authored-by: Cursor <cursoragent@cursor.com>
457 lines
16 KiB
Python
457 lines
16 KiB
Python
"""
|
|
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)
|