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