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

@@ -122,20 +122,35 @@ npm run dev
前端将在 `http://localhost:5173` 启动,自动代理 API 请求到后端。
### Docker 部署
### Docker 开发Windows / Docker Desktop
```bash
# 一键启动
docker-compose up -d
日常改代码**不需要重启 Docker Desktop**——后端 uvicorn `--reload`、前端 Vite HMR 会自动生效。
```powershell
# 启动(项目根目录)
.\scripts\docker-up.ps1
# 仅重启容器HMR/reload 异常时)
.\scripts\docker-restart.ps1 -Service frontend # 或 backend / all
# 依赖或 Dockerfile 变更后重建
.\scripts\docker-rebuild.ps1 -Service backend
# 查看日志
docker-compose logs -f
# 停止服务
docker-compose down
.\scripts\docker-logs.ps1
```
访问 `http://localhost:80` 即可使用。
| 服务 | 地址 |
|------|------|
| 后端 API | http://localhost:23337 |
| 前端 | http://localhost:23338 |
详细说明(何时 rebuild、何时才需要重启 Docker Desktop、本地开发替代方案**[docs/DOCKER_DEV.md](./docs/DOCKER_DEV.md)**。
```powershell
# 停止服务
docker compose down
```
---

View File

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

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

View File

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

View 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 = ""

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

16
data/agent/niches.json Normal file
View File

@@ -0,0 +1,16 @@
{
"niches": [
{
"id": "aesthetic_tone",
"label": "整体美学",
"description": "视觉、氛围、叙事基调等宏观美学设定",
"suggestedStepGoal": "描述角色的整体美学:色调、材质、氛围、叙事基调,供后续人设与世界书条目引用。"
},
{
"id": "persona_detail",
"label": "具体人设",
"description": "性格、口癖、关系、行为模式等可扮演细节",
"suggestedStepGoal": "在整体美学基础上,细化可扮演的人设:性格、动机、口癖、与他人关系。"
}
]
}

View File

@@ -0,0 +1,58 @@
{
"templates": [
{
"skillId": "studio.init_bind",
"displayName": "创建并绑定",
"description": "创建角色卡与世界书,并绑定到当前 Studio 项目",
"displayParams": [
{
"key": "characterName",
"label": "角色卡名称",
"type": "text",
"required": true,
"placeholder": "例如:帝国骑士维尔"
},
{
"key": "worldbookName",
"label": "世界书名称",
"type": "text",
"required": true,
"placeholder": "例如:维尔的世界观"
}
],
"configWhitelist": [],
"artifacts": [],
"supportsLoopUntilSatisfied": false,
"supportsInputs": false,
"supportsInsertion": false,
"supportsScoring": false
},
{
"skillId": "studio.worldbook_entry",
"displayName": "创作世界书条目",
"description": "根据 stepGoal 与上文引用,生成并写入世界书条目",
"displayParams": [],
"configWhitelist": [
"stepGoal",
"thinkingPrompt",
"insertion.position",
"insertion.activationType",
"insertion.key",
"insertion.keysecondary",
"insertion.ragConfig",
"insertion.comment",
"scoring"
],
"artifacts": [
{
"type": "worldbook.entries",
"displayName": "世界书条目"
}
],
"supportsLoopUntilSatisfied": true,
"supportsInputs": true,
"supportsInsertion": true,
"supportsScoring": true
}
]
}

View File

@@ -0,0 +1,10 @@
{
"id": "default",
"name": "单人类角色卡",
"description": "从创建绑定到世界书条目的默认三步流水线,可在工作流编辑页复制并修改。",
"templateId": "builtin.studio.example",
"characterId": "f04ba2d6-1ffd-4c33-9cfe-cf6fd026c175",
"worldbookId": "8682f790-1b9d-4842-826b-c07764be6b9b",
"createdAt": "2026-05-31T00:00:00",
"updatedAt": "2026-05-31T13:11:55.525055"
}

View File

@@ -0,0 +1,122 @@
{
"workflowGoal": "设计一个单人角色:先绑定角色卡与世界书,再迭代整体美学与具体人设,写入世界书条目。",
"nodes": [
{
"id": "init",
"skillId": "studio.init_bind",
"displayName": "创建并绑定",
"enabled": true,
"loopUntilSatisfied": false,
"config": {},
"displayParams": [
{
"key": "characterName",
"label": "角色卡名称",
"type": "text",
"required": true,
"placeholder": ""
},
{
"key": "worldbookName",
"label": "世界书名称",
"type": "text",
"required": true,
"placeholder": ""
}
],
"inputs": []
},
{
"id": "aesthetic",
"skillId": "studio.worldbook_entry",
"displayName": "整体美学",
"enabled": true,
"niche": "aesthetic_tone",
"loopUntilSatisfied": true,
"config": {
"stepGoal": "产出角色的整体美学设定:视觉风格、氛围、叙事基调,供后续人设步骤引用。",
"thinkingPrompt": "step1首先思考整个故事是怎么样的\nstep2然后思考如何展示\nstep3选中核心爽点",
"insertion": {
"position": 1,
"activationType": "permanent",
"key": "整体美学",
"comment": "Studio · 整体美学"
},
"scoring": {
"enabled": true,
"dimensions": [
{
"id": "authenticity",
"name": "真实性",
"criteria": "美学设定是否自洽、可感知,而非空泛形容词堆砌。"
},
{
"id": "fit",
"name": "贴合度",
"criteria": "是否覆盖色调/材质/氛围/叙事基调;是否可与后续人设衔接;表述是否简洁可注入世界书。"
}
]
}
},
"displayParams": [],
"inputs": [
{
"ref": "workflow.goal",
"label": "工作流目标文本",
"optional": false
}
]
},
{
"id": "persona",
"skillId": "studio.worldbook_entry",
"displayName": "具体人设",
"enabled": true,
"niche": "persona_detail",
"loopUntilSatisfied": true,
"config": {
"stepGoal": "在整体美学基础上,写出可扮演的人设:性格、动机、口癖、关系与行为模式。",
"thinkingPrompt": "",
"insertion": {
"position": 1,
"activationType": "keyword",
"key": "具体人设",
"comment": "Studio · 具体人设"
},
"scoring": {
"enabled": true,
"dimensions": [
{
"id": "authenticity",
"name": "真实性",
"criteria": "人设细节是否具体、可扮演,动机与行为是否一致。"
},
{
"id": "fit",
"name": "贴合度",
"criteria": "是否与人设目标一致;是否与整体美学一致;是否避免与已有条目冲突。"
}
]
}
},
"displayParams": [],
"inputs": [
{
"ref": "workflow.goal",
"label": "工作流目标文本",
"optional": false
},
{
"ref": "aesthetic.output",
"label": "整体美学 · 上轮产物",
"optional": false
},
{
"ref": "persona.output",
"label": "具体人设 · 上轮产物",
"optional": true
}
]
}
]
}

View File

@@ -0,0 +1,6 @@
{
"id": "builtin.studio.example",
"name": "世界书条目创建",
"description": "从创建绑定到世界书条目的默认三步流水线,可在工作流编辑页复制并修改。",
"version": "1.0.0"
}

View File

@@ -0,0 +1,104 @@
{
"workflowGoal": "设计一个单人角色:先绑定角色卡与世界书,再迭代整体美学与具体人设,写入世界书条目。",
"nodes": [
{
"id": "init",
"skillId": "studio.init_bind",
"displayName": "创建并绑定",
"enabled": true,
"config": {},
"displayParams": [
{
"key": "characterName",
"label": "角色卡名称",
"type": "text",
"required": true,
"placeholder": ""
},
{
"key": "worldbookName",
"label": "世界书名称",
"type": "text",
"required": true,
"placeholder": ""
}
],
"inputs": []
},
{
"id": "aesthetic",
"skillId": "studio.worldbook_entry",
"displayName": "整体美学",
"niche": "aesthetic_tone",
"enabled": true,
"loopUntilSatisfied": true,
"config": {
"stepGoal": "产出角色的整体美学设定:视觉风格、氛围、叙事基调,供后续人设步骤引用。",
"thinkingPrompt": "====== 思考流程 ======\nStep1: 简短确认任务性质(新设计/修改)\nStep2: 阅读绑定角色/世界书与上文引用\nStep3: 按步骤目标起草世界书条目\nStep4: 对照评价维度自检并优化表述\n\n核心目的、评价标准与优化建议由系统在运行时自动注入无需在此填写",
"insertion": {
"position": 1,
"activationType": "permanent",
"key": "整体美学",
"comment": "Studio · 整体美学"
},
"scoring": {
"enabled": true,
"dimensions": [
{
"id": "authenticity",
"name": "真实性",
"criteria": "美学设定是否自洽、可感知,而非空泛形容词堆砌。"
},
{
"id": "fit",
"name": "贴合度",
"criteria": "是否覆盖色调/材质/氛围/叙事基调;是否可与后续人设衔接;表述是否简洁可注入世界书。"
}
]
}
},
"displayParams": [],
"inputs": []
},
{
"id": "persona",
"skillId": "studio.worldbook_entry",
"displayName": "具体人设",
"niche": "persona_detail",
"enabled": true,
"loopUntilSatisfied": true,
"config": {
"stepGoal": "在整体美学基础上,写出可扮演的人设:性格、动机、口癖、关系与行为模式。",
"thinkingPrompt": "====== 思考流程 ======\nStep1: 简短确认任务性质(新设计/修改)\nStep2: 阅读绑定角色/世界书与上文引用\nStep3: 按步骤目标起草世界书条目\nStep4: 对照评价维度自检并优化表述\n\n核心目的、评价标准与优化建议由系统在运行时自动注入无需在此填写",
"insertion": {
"position": 1,
"activationType": "keyword",
"key": "具体人设",
"comment": "Studio · 具体人设"
},
"scoring": {
"enabled": true,
"dimensions": [
{
"id": "authenticity",
"name": "真实性",
"criteria": "人设细节是否具体、可扮演,动机与行为是否一致。"
},
{
"id": "fit",
"name": "贴合度",
"criteria": "是否与人设目标一致;是否与整体美学一致;是否避免与已有条目冲突。"
}
]
}
},
"displayParams": [],
"inputs": [
{
"ref": "aesthetic.output",
"label": "整体美学 · 世界书条目"
}
]
}
]
}

View File

@@ -0,0 +1,56 @@
{
"builtIn": [
{
"ref": "workflow.goal",
"label": "工作流目标文本",
"description": "当前 Studio 项目的 workflowGoal 全文(系统自动注入,不可手动选择)",
"autoInjected": true
},
{
"ref": "workflow.boundWorldbook",
"label": "绑定世界书摘要",
"description": "项目绑定的世界书 meta / 摘要(系统自动注入,不可手动选择)",
"autoInjected": true
},
{
"ref": "workflow.boundCharacter",
"label": "绑定角色卡摘要",
"description": "项目绑定的角色卡摘要(系统自动注入,不可手动选择)",
"autoInjected": true
}
],
"dynamicSuffixes": [
{
"suffix": ".output",
"labelPattern": "{displayName} · 世界书条目"
}
],
"autoInjectedContext": [
"目前产物",
"思考流程",
"核心目的",
"评价标准与优化建议"
],
"autoInjectedContextDefs": [
{
"id": "currentProduct",
"label": "目前产物",
"description": "当前步骤已生成的世界书条目草稿或最新版本,供模型在迭代修改时对照与延续。"
},
{
"id": "thinkingFlow",
"label": "思考流程",
"description": "本步骤配置的 thinkingPrompt引导模型按既定步骤推理与自检。"
},
{
"id": "coreGoal",
"label": "核心目的",
"description": "本步骤的 stepGoal步骤目标明确本步要产出的内容与边界。"
},
{
"id": "scoringCriteria",
"label": "评价标准与优化建议",
"description": "本步骤启用的 scoring 评价维度及准则,用于模型自检与优化表述。"
}
]
}

10
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,10 @@
# 开发环境 override可选
# 用法: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
#
# 与主 compose 策略一致:源码 volume 挂载 + HMR/reload启动时不跑 npm install。
# 依赖变更: scripts/docker-rebuild.ps1 -Service frontend
# 或 docker compose exec frontend npm install && docker compose restart frontend
services:
frontend:
command: npm run dev -- --host 0.0.0.0

View File

@@ -36,10 +36,11 @@ services:
- "23338:5173"
volumes:
- ./frontend:/app
- /app/node_modules
- node_modules:/app/node_modules
environment:
- NODE_ENV=development
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
# 依赖在镜像构建时写入 node_modules volumepackage.json 变更见 docs/DOCKER_DEV.md
command: npm run dev -- --host 0.0.0.0
depends_on:
backend:
condition: service_healthy

227
docs/DOCKER_DEV.md Normal file
View File

@@ -0,0 +1,227 @@
# Docker 开发指南Windows / Docker Desktop
本文说明如何在 **不重启 Docker Desktop** 的前提下进行日常开发。绝大多数代码改动无需任何容器操作;需要时只需重启**单个容器**。
---
## 核心原则
| 场景 | 需要做什么 | 是否需要重启 Docker Desktop |
|------|-----------|---------------------------|
| 修改 Python 后端代码 | **什么都不做**uvicorn `--reload` 自动重载) | ❌ 不需要 |
| 修改 React 前端代码 | **什么都不做**Vite HMR 热更新) | ❌ 不需要 |
| 容器异常 / 需要刷新进程 | `docker compose restart backend``frontend` | ❌ 不需要 |
| 修改 `Dockerfile` 或依赖文件 | `docker compose up -d --build <service>` | ❌ 不需要 |
| Docker 引擎崩溃 / 端口被占用且无法释放 | 见下文「极少需要重启 Docker Desktop」 | ⚠️ 极少需要 |
**日常开发不要重启 Docker Desktop。** 那是 Windows + WSL2 下最慢、最打断节奏的操作。
---
## 端口对照表
`docker-compose.yml` 一致:
| 服务 | 容器内端口 | 宿主机端口 | 访问地址 |
|------|-----------|-----------|---------|
| backend | 8000 | **23337** | http://localhost:23337 |
| frontend | 5173 | **23338** | http://localhost:23338 |
健康检查:`http://localhost:23337/health`
---
## 代码改动:自动生效
### 后端FastAPI + uvicorn
- 启动命令:`uvicorn main:app --host 0.0.0.0 --port 8000 --reload`
- 源码通过 volume 挂载:`./backend``/app`
- 保存 `.py` 文件后uvicorn 自动检测并重载,**无需重启容器**
### 前端Vite + React
- 启动命令:`npm run dev -- --host 0.0.0.0`
- 源码通过 volume 挂载:`./frontend``/app`
- `node_modules` 保存在独立 volume 中,不随宿主机目录覆盖
- 保存 `.jsx` / `.css` 等文件后Vite HMR 自动更新浏览器,**无需重启容器**
---
## 何时只需重启容器(不是 Docker Desktop
以下情况用 `scripts/docker-restart.ps1``docker compose restart` 即可:
- 修改了环境变量(`docker-compose.yml` 中的 `environment`)并已 `docker compose up -d`
- 容器内进程卡死、内存泄漏
- 前端 HMR 断开、WebSocket 连接异常
- 后端 reload 失败(极少数语法错误导致 worker 无法恢复)
```powershell
# 重启单个服务
.\scripts\docker-restart.ps1 -Service backend
.\scripts\docker-restart.ps1 -Service frontend
# 重启全部
.\scripts\docker-restart.ps1 -Service all
# 或直接
docker compose restart backend
docker compose restart frontend
```
---
## 何时需要重新构建镜像
以下变更需要 **rebuild**,仍然 **不需要重启 Docker Desktop**
| 变更内容 | 命令 |
|---------|------|
| `backend/requirements.txt` | `.\scripts\docker-rebuild.ps1 -Service backend` |
| `backend/Dockerfile` | 同上 |
| `frontend/package.json` / `package-lock.json` | `.\scripts\docker-rebuild.ps1 -Service frontend` |
| `frontend/Dockerfile` | 同上 |
```powershell
# 等价命令
docker compose up -d --build backend
docker compose up -d --build frontend
```
### 前端依赖变更package.json 改了但不想 rebuild
若只新增了 npm 包、尚未 rebuild 镜像,可在运行中的容器内安装一次:
```powershell
docker compose exec frontend npm install
docker compose restart frontend
```
首次 `docker compose up` 时,镜像构建阶段会执行 `npm install`,依赖写入 `node_modules` volume之后日常启动**不再**每次 `npm install`
---
## 极少需要重启 Docker Desktop 的情况
仅在以下情况才考虑重启 Docker Desktop 或 WSL
1. **Docker 引擎无响应**`docker ps` 一直挂起或报错 `Cannot connect to the Docker daemon`
2. **端口被占用且 compose down 无法释放** — 例如 23337/23338 被僵尸进程占用
3. **WSL2 后端异常** — 内存耗尽、磁盘满、网络栈故障
**优先尝试的替代方案(由轻到重):**
```powershell
# 1. 停止并重新启动 compose 栈(不碰 Docker Desktop
docker compose down
docker compose up -d
# 2. 查看日志定位问题
.\scripts\docker-logs.ps1
.\scripts\docker-logs.ps1 -Service backend
# 3. 仅当 Docker 完全无响应时,关闭 WSL会连带重启 Docker 引擎)
wsl --shutdown
# 然后重新打开 Docker Desktop
```
---
## 更快的日常开发方式(推荐)
Docker 适合「全栈联调 / 验收环境」。纯改代码时,**本地直接跑**通常更快:
### 后端(本地)
```powershell
python -m venv venv
.\venv\Scripts\Activate.ps1
pip install -r backend\requirements.txt
cd backend
python main.py
```
### 前端(本地)
```powershell
cd frontend
npm install
npm run dev
```
本地开发时前端默认代理到本地后端Docker 栈仅在需要容器化联调时使用。
---
## 辅助脚本速查
所有脚本位于 `scripts/`,在项目根目录执行:
| 脚本 | 用途 |
|------|------|
| `docker-up.ps1` | 后台启动全部服务 |
| `docker-restart.ps1` | 重启 backend / frontend / all**不**重启 Docker Desktop |
| `docker-logs.ps1` | 跟踪日志(可选 `-Service backend` |
| `docker-rebuild.ps1` | 重新构建并启动指定服务 |
### 典型一日工作流
```powershell
# 早上第一次
.\scripts\docker-up.ps1
# 白天改代码 — 保存即可backend/frontend 自动更新
# 偶尔 HMR 或 reload 异常
.\scripts\docker-restart.ps1 -Service frontend
# 改了 requirements.txt
.\scripts\docker-rebuild.ps1 -Service backend
# 下班
docker compose down
```
---
## 使用 docker-compose.dev.yml可选
开发环境可使用 override 文件,与主 compose 合并:
```powershell
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
```
内容与主文件优化策略一致;便于将来追加仅开发用的配置而不改动默认 compose。
---
## 常见问题
### Q: 改了前端代码但页面没更新?
1. 确认保存了文件
2. 浏览器硬刷新Ctrl+Shift+R
3. `.\scripts\docker-restart.ps1 -Service frontend`
4. 查看日志:`.\scripts\docker-logs.ps1 -Service frontend`
### Q: 改了后端代码但 API 行为没变?
1. 查看 backend 日志是否有 reload 报错
2. `.\scripts\docker-restart.ps1 -Service backend`
3. 若改了依赖,需 rebuild
### Q: 每次启动 frontend 都很慢?
旧版 compose 在每次容器启动时执行 `npm install`。当前配置已改为直接 `npm run dev`;依赖在**镜像构建时**或**手动 exec npm install** 时装入 volume。若仍慢检查是否误删了 `node_modules` volume
```powershell
docker volume ls | Select-String node_modules
```
### Q: 必须重启 Docker Desktop 吗?
**正常代码编辑:不需要。**
**依赖 / Dockerfile 变更rebuild 容器即可。**
**只有 Docker 引擎本身故障时才考虑重启 Docker Desktop 或 `wsl --shutdown`。**

View File

@@ -5,7 +5,10 @@ import { ChatBox } from './components/Mid';
import SideBarLeft from './components/SideBarLeft';
import SideBarRight from './components/SideBarRight';
import PlaceholderPage from './components/PlaceholderPage';
import StudioEditPage from './components/Studio/StudioEditPage';
import StudioRunPage from './components/Studio/StudioRunPage';
import useAppLayoutStore from './Store/AppLayoutSlice'; // ✅ 新增
import useStudioStore from './Store/Studio/StudioSlice';
import useApiConfigStore from './Store/SideBarLeft/ApiConfigSlice'; // ✅ 引入 API 配置 Store
import usePresetStore from './Store/SideBarLeft/PresetSlice'; // ✅ 引入预设 Store
import useCharacterStore from './Store/SideBarLeft/CharacterSlice'; // ✅ 引入角色卡 Store
@@ -189,6 +192,20 @@ function App() {
});
}, []); // 仅在应用启动时执行一次
const initStudio = useStudioStore((s) => s.initStudio);
const initStudioRun = useStudioStore((s) => s.initStudioRun);
const isStudioEditPage = activePage === 'studio_edit';
const isStudioRunPage = activePage === 'studio_run';
const isStudioPage = isStudioEditPage || isStudioRunPage;
useEffect(() => {
if (isStudioEditPage) {
initStudio();
} else if (isStudioRunPage) {
initStudioRun();
}
}, [isStudioEditPage, isStudioRunPage, initStudio, initStudioRun]);
return (
<div className={`app ${layoutMode}-mode`}>
{/* ✅ TopBar 不再需要 props直接从 Store 读取状态 */}
@@ -218,6 +235,14 @@ function App() {
<SideBarRight />
</div>
</div>
) : activePage === 'studio_edit' ? (
<div className="main-container studio-container">
<StudioEditPage />
</div>
) : activePage === 'studio_run' ? (
<div className="main-container studio-container">
<StudioRunPage />
</div>
) : (
<div className="main-container placeholder-container">
<PlaceholderPage page={activePage} />

View File

@@ -22,7 +22,7 @@ const useAppLayoutStore = create(
// 颜色主题:'light' | 'dark'
colorTheme: 'dark',
// 当前页面:'chat' | 'studio' | 'novel' | 'room'
// 当前页面:'chat' | 'studio_edit' | 'studio_run' | 'novel' | 'room'
activePage: 'chat',
// ==================== Actions ====================
@@ -53,7 +53,7 @@ const useAppLayoutStore = create(
/**
* 设置当前页面
* @param {string} page - 'chat' | 'studio' | 'novel' | 'room'
* @param {string} page - 'chat' | 'studio_edit' | 'studio_run' | 'novel' | 'room'
*/
setActivePage: (page) => {
set({ activePage: page });
@@ -95,7 +95,15 @@ const useAppLayoutStore = create(
}
}),
{
name: 'app-layout-storage', // localStorage key
name: 'app-layout-storage',
version: 1,
migrate: (persistedState) => {
if (!persistedState) return persistedState;
if (persistedState.activePage === 'studio') {
return { ...persistedState, activePage: 'studio_edit' };
}
return persistedState;
},
partialize: (state) => ({
layoutMode: state.layoutMode,
sidebarMode: state.sidebarMode,

View File

@@ -0,0 +1,594 @@
import { create } from 'zustand';
const DEFAULT_TEMPLATE_ID = 'builtin.studio.example';
function migrateScoringInNode(node) {
const scoring = node.config?.scoring;
if (!scoring || scoring.dimensions?.length) return node;
if (!scoring.rubric) return node;
return {
...node,
config: {
...node.config,
scoring: {
...scoring,
dimensions: [
{
id: 'default',
name: '综合质量',
criteria: scoring.rubric,
},
],
rubric: undefined,
},
},
};
}
function migratePipeline(pipeline) {
if (!pipeline) return pipeline;
return {
...pipeline,
nodes: (pipeline.nodes || []).map(migrateScoringInNode),
};
}
const useStudioStore = create((set, get) => ({
projects: [],
workflowTemplates: [],
workflowVariables: { builtIn: [], dynamic: [] },
currentProjectId: null,
meta: null,
pipeline: null,
skillTemplates: [],
niches: [],
selectedNodeId: null,
loading: false,
saving: false,
error: null,
saveMessage: null,
// Studio run (R0)
runProjectId: null,
runs: [],
currentRunId: null,
currentRun: null,
runLoading: false,
runCreating: false,
runAdvancing: false,
runError: null,
setSelectedNodeId: (id) => set({ selectedNodeId: id }),
clearSaveMessage: () => set({ saveMessage: null, error: null }),
setMetaLocal: (patch) => {
const { meta } = get();
if (!meta) return;
set({ meta: { ...meta, ...patch } });
},
setPipelineLocal: (pipeline) => set({ pipeline: migratePipeline(pipeline) }),
updateNode: (nodeId, patch) => {
const { pipeline } = get();
if (!pipeline) return;
const nodes = pipeline.nodes.map((n) =>
n.id === nodeId ? { ...n, ...patch } : n
);
set({ pipeline: { ...pipeline, nodes } });
},
updateNodeConfig: (nodeId, configPatch) => {
const { pipeline } = get();
if (!pipeline) return;
const nodes = pipeline.nodes.map((n) =>
n.id === nodeId
? { ...n, config: { ...(n.config || {}), ...configPatch } }
: n
);
set({ pipeline: { ...pipeline, nodes } });
},
reorderNodes: (fromIndex, toIndex) => {
const { pipeline } = get();
if (!pipeline || fromIndex === toIndex) return;
const nodes = [...pipeline.nodes];
const [moved] = nodes.splice(fromIndex, 1);
nodes.splice(toIndex, 0, moved);
set({ pipeline: { ...pipeline, nodes } });
},
addNode: (skillId, displayName) => {
const { pipeline, skillTemplates } = get();
if (!pipeline) return;
const tpl = skillTemplates.find((t) => t.skillId === skillId);
const id = `node-${Date.now()}`;
const base = {
id,
skillId,
displayName: displayName || tpl?.displayName || skillId,
enabled: true,
config: {},
displayParams: tpl?.displayParams ? [...tpl.displayParams] : [],
inputs: [],
};
if (skillId === 'studio.worldbook_entry') {
base.loopUntilSatisfied = false;
base.config = {
stepGoal: '',
thinkingPrompt: `====== 思考流程 ======
Step1: 简短确认任务性质(新设计/修改)
Step2: 阅读绑定角色/世界书与上文引用
Step3: 按步骤目标起草世界书条目
Step4: 对照评价维度自检并优化表述
(核心目的、评价标准与优化建议由系统在运行时自动注入,无需在此填写)`,
insertion: {
position: 1,
activationType: 'permanent',
key: '',
keysecondary: '',
comment: '',
},
scoring: { enabled: true, dimensions: [] },
};
}
const nodes = [...pipeline.nodes, base];
set({
pipeline: { ...pipeline, nodes },
selectedNodeId: id,
});
},
removeNode: (nodeId) => {
const { pipeline, selectedNodeId } = get();
if (!pipeline) return;
const nodes = pipeline.nodes.filter((n) => n.id !== nodeId);
set({
pipeline: { ...pipeline, nodes },
selectedNodeId:
selectedNodeId === nodeId
? nodes[0]?.id ?? null
: selectedNodeId,
});
},
fetchProjects: async () => {
set({ loading: true, error: null });
try {
const res = await fetch('/api/studio/projects');
if (!res.ok) throw new Error(await res.text());
const projects = await res.json();
set({ projects, loading: false });
return projects;
} catch (e) {
set({ loading: false, error: e.message || '加载项目列表失败' });
return [];
}
},
fetchWorkflowTemplates: async () => {
try {
const res = await fetch('/api/studio/templates');
if (!res.ok) throw new Error(await res.text());
const templates = await res.json();
set({ workflowTemplates: templates });
return templates;
} catch (e) {
set({ error: e.message || '加载工作流模板失败' });
return [];
}
},
fetchWorkflowVariables: async (projectId) => {
try {
const qs = projectId
? `?projectId=${encodeURIComponent(projectId)}`
: '';
const res = await fetch(`/api/studio/variables${qs}`);
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
set({ workflowVariables: data });
return data;
} catch (e) {
set({ error: e.message || '加载工作流变量失败' });
return { builtIn: [], dynamic: [] };
}
},
fetchSkillTemplates: async () => {
try {
const [tplRes, nicheRes] = await Promise.all([
fetch('/api/studio/skill-templates'),
fetch('/api/studio/niches'),
]);
if (!tplRes.ok) throw new Error(await tplRes.text());
const tplData = await tplRes.json();
const niches = nicheRes.ok
? (await nicheRes.json()).niches || []
: [];
set({ skillTemplates: tplData.templates || [], niches });
} catch (e) {
set({ error: e.message || '加载技能模板失败' });
}
},
fetchProject: async (projectId) => {
set({ loading: true, error: null });
try {
const res = await fetch(`/api/studio/projects/${encodeURIComponent(projectId)}`);
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
const pipeline = migratePipeline(data.pipeline);
set({
currentProjectId: data.meta.id,
meta: data.meta,
pipeline,
selectedNodeId: pipeline?.nodes?.[0]?.id ?? null,
loading: false,
});
await get().fetchWorkflowVariables(projectId);
return { ...data, pipeline };
} catch (e) {
set({ loading: false, error: e.message || '加载项目失败' });
return null;
}
},
savePipeline: async () => {
const { currentProjectId, pipeline } = get();
if (!currentProjectId || !pipeline) return false;
set({ saving: true, error: null, saveMessage: null });
try {
const res = await fetch(
`/api/studio/projects/${encodeURIComponent(currentProjectId)}/pipeline`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pipeline),
}
);
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
set({
meta: data.meta,
pipeline: migratePipeline(data.pipeline),
saving: false,
saveMessage: '已保存到本地',
});
return true;
} catch (e) {
set({
saving: false,
error: e.message || '保存失败',
});
return false;
}
},
createProject: async (name, templateId = DEFAULT_TEMPLATE_ID) => {
set({ loading: true, error: null });
try {
const res = await fetch('/api/studio/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, template_id: templateId }),
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
await get().fetchProjects();
const pipeline = migratePipeline(data.pipeline);
set({
currentProjectId: data.meta.id,
meta: data.meta,
pipeline,
selectedNodeId: pipeline?.nodes?.[0]?.id ?? null,
loading: false,
});
await get().fetchWorkflowVariables(data.meta.id);
return data;
} catch (e) {
set({ loading: false, error: e.message || '创建项目失败' });
return null;
}
},
updateProjectMeta: async (projectId, { name, description } = {}) => {
if (!projectId) return null;
set({ saving: true, error: null, saveMessage: null });
try {
const body = {};
if (name !== undefined) body.name = name;
if (description !== undefined) body.description = description;
const res = await fetch(
`/api/studio/projects/${encodeURIComponent(projectId)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
);
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
const pipeline = migratePipeline(data.pipeline);
set({
meta: data.meta,
pipeline,
saving: false,
saveMessage: '项目信息已保存',
});
await get().fetchProjects();
return data;
} catch (e) {
set({
saving: false,
error: e.message || '保存项目信息失败',
});
return null;
}
},
renameProject: async (projectId, newName) => {
const trimmed = (newName || '').trim();
if (!projectId || !trimmed) return null;
return get().updateProjectMeta(projectId, { name: trimmed });
},
deleteProject: async (projectId) => {
if (!projectId) return false;
set({ loading: true, error: null, saveMessage: null });
try {
const res = await fetch(
`/api/studio/projects/${encodeURIComponent(projectId)}`,
{ method: 'DELETE' }
);
if (!res.ok) throw new Error(await res.text());
const projects = await get().fetchProjects();
const nextId = projects[0]?.id ?? null;
if (nextId) {
await get().fetchProject(nextId);
} else {
set({
currentProjectId: null,
meta: null,
pipeline: null,
selectedNodeId: null,
});
}
if (get().runProjectId === projectId) {
set({ runProjectId: nextId, runs: [], currentRunId: null, currentRun: null });
}
set({ loading: false, saveMessage: '项目已删除' });
return true;
} catch (e) {
set({ loading: false, error: e.message || '删除项目失败' });
return false;
}
},
initStudio: async () => {
await Promise.all([
get().fetchSkillTemplates(),
get().fetchWorkflowTemplates(),
get().fetchProjects(),
]);
const projects = get().projects;
const id =
get().currentProjectId ||
projects[0]?.id ||
'default';
if (id) {
await get().fetchProject(id);
}
},
setRunProjectId: (projectId) => {
set({
runProjectId: projectId,
currentRunId: null,
currentRun: null,
runs: [],
});
},
fetchRuns: async (projectId) => {
if (!projectId) return [];
set({ runLoading: true, runError: null });
try {
const res = await fetch(
`/api/studio/projects/${encodeURIComponent(projectId)}/runs`
);
if (!res.ok) throw new Error(await res.text());
const runs = await res.json();
set({ runs, runLoading: false });
return runs;
} catch (e) {
set({
runLoading: false,
runError: e.message || '加载运行列表失败',
});
return [];
}
},
fetchRun: async (projectId, runId) => {
if (!projectId || !runId) return null;
set({ runLoading: true, runError: null });
try {
const res = await fetch(
`/api/studio/projects/${encodeURIComponent(projectId)}/runs/${encodeURIComponent(runId)}`
);
if (!res.ok) throw new Error(await res.text());
const run = await res.json();
set({
currentRunId: runId,
currentRun: run,
runLoading: false,
});
return run;
} catch (e) {
set({
runLoading: false,
runError: e.message || '加载运行详情失败',
});
return null;
}
},
createRun: async (projectId) => {
if (!projectId) return null;
set({ runCreating: true, runError: null });
try {
const res = await fetch(
`/api/studio/projects/${encodeURIComponent(projectId)}/runs`,
{ method: 'POST' }
);
if (!res.ok) throw new Error(await res.text());
const run = await res.json();
await get().fetchRuns(projectId);
set({
currentRunId: run.id,
currentRun: run,
runCreating: false,
});
return run;
} catch (e) {
set({
runCreating: false,
runError: e.message || '创建运行失败',
});
return null;
}
},
selectRun: async (runId) => {
const projectId = get().runProjectId;
if (!projectId || !runId) return null;
return get().fetchRun(projectId, runId);
},
advanceRun: async (displayParams) => {
const { runProjectId, currentRunId } = get();
if (!runProjectId || !currentRunId) return null;
set({ runAdvancing: true, runError: null });
try {
const res = await fetch(
`/api/studio/projects/${encodeURIComponent(runProjectId)}/runs/${encodeURIComponent(currentRunId)}/advance`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ displayParams }),
}
);
if (!res.ok) {
let detail = await res.text();
try {
const parsed = JSON.parse(detail);
detail = parsed.detail || detail;
} catch {
/* keep raw text */
}
throw new Error(detail || '推进运行失败');
}
const run = await res.json();
set({
currentRun: run,
runAdvancing: false,
});
await get().fetchRuns(runProjectId);
return run;
} catch (e) {
set({
runAdvancing: false,
runError: e.message || '推进运行失败',
});
return null;
}
},
deleteRun: async (runId) => {
const projectId = get().runProjectId;
if (!projectId || !runId) return false;
set({ runLoading: true, runError: null });
try {
const res = await fetch(
`/api/studio/projects/${encodeURIComponent(projectId)}/runs/${encodeURIComponent(runId)}`,
{ method: 'DELETE' }
);
if (!res.ok) throw new Error(await res.text());
const runs = await get().fetchRuns(projectId);
const { currentRunId } = get();
if (currentRunId === runId) {
const nextId = runs[0]?.id ?? null;
if (nextId) {
await get().fetchRun(projectId, nextId);
} else {
set({ currentRunId: null, currentRun: null, runLoading: false });
}
} else {
set({ runLoading: false });
}
return true;
} catch (e) {
set({
runLoading: false,
runError: e.message || '删除运行失败',
});
return false;
}
},
renameRun: async (runId, title) => {
const projectId = get().runProjectId;
if (!projectId || !runId) return null;
set({ runError: null });
try {
const res = await fetch(
`/api/studio/projects/${encodeURIComponent(projectId)}/runs/${encodeURIComponent(runId)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
}
);
if (!res.ok) {
let detail = await res.text();
try {
const parsed = JSON.parse(detail);
detail = parsed.detail || detail;
} catch {
/* keep raw text */
}
throw new Error(detail || '重命名失败');
}
const run = await res.json();
if (get().currentRunId === runId) {
set({ currentRun: run });
}
await get().fetchRuns(projectId);
return run;
} catch (e) {
set({ runError: e.message || '重命名失败' });
return null;
}
},
initStudioRun: async () => {
await get().fetchProjects();
const projects = get().projects;
const projectId =
get().runProjectId ||
get().currentProjectId ||
projects[0]?.id ||
'default';
set({ runProjectId: projectId });
await get().fetchWorkflowVariables(projectId);
const runs = await get().fetchRuns(projectId);
const runId = get().currentRunId || runs[0]?.id || null;
if (runId) {
await get().fetchRun(projectId, runId);
}
},
}));
export default useStudioStore;

View File

@@ -0,0 +1,18 @@
.studio-context-block-popup__content {
margin: 0;
padding: var(--spacing-sm);
border-radius: var(--radius-sm);
border: 1px solid var(--color-border-light);
background: var(--color-bg-tertiary);
white-space: pre-wrap;
word-break: break-word;
font-size: 0.72rem;
line-height: 1.45;
max-height: none;
}
.studio-context-block-popup__preview {
display: flex;
flex-direction: column;
gap: 4px;
}

View File

@@ -0,0 +1,33 @@
import React from 'react';
import StudioInsertionPopup from './StudioInsertionPopup';
import './StudioContextBlockPopup.css';
function StudioContextBlockPopup({ open, block, onClose }) {
if (!block) return null;
return (
<StudioInsertionPopup
open={open}
title={block.label}
defaultExpanded
onClose={onClose}
>
{({ expanded }) =>
expanded ? (
<pre className="studio-context-block-popup__content">
{block.content}
</pre>
) : (
<div className="studio-context-block-popup__preview">
<span className="studio-insertion-popup__field-label">来源</span>
<span className="studio-insertion-popup__field-value">{block.source}</span>
</div>
)
}
</StudioInsertionPopup>
);
}
export default StudioContextBlockPopup;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,795 @@
import React, { useEffect, useMemo, useState } from 'react';
import useStudioStore from '../../Store/Studio/StudioSlice';
import FieldLabel from './edit/FieldLabel';
import VariableChips from './edit/VariableChips';
import {
DEFAULT_THINKING_PROMPT,
orderNodesByIds,
sortNodesByLogic,
} from './edit/variableUtils';
import WorldbookInsertion from './edit/WorldbookInsertion';
import ScoringDimensions from './edit/ScoringDimensions';
import './StudioEditPage.css';
/*
* Node detail layout: primary config center + secondary fixed edge rail.
* Industry refs: n8n (Parameters center + Settings sidebar), Retool inspector,
* Figma right properties rail — main editing in fluid center, refs in ~300px rail.
*/
const DEFAULT_TEMPLATE_ID = 'builtin.studio.example';
const WORKFLOW_GOAL_TIP =
'此内容会在运行时被注入到 LLM 系统提示中,作为工作流整体目标,供各步骤参考。';
const WORKFLOW_DESC_TIP =
'仅供人类阅读的简短说明,不会写入 LLM 提示。';
const NODE_SORT_STORAGE_KEY = 'studio-node-sort-mode';
function getStoredSortMode(projectId) {
if (!projectId || typeof sessionStorage === 'undefined') return 'manual';
return sessionStorage.getItem(`${NODE_SORT_STORAGE_KEY}:${projectId}`) || 'manual';
}
function storeSortMode(projectId, mode) {
if (!projectId || typeof sessionStorage === 'undefined') return;
sessionStorage.setItem(`${NODE_SORT_STORAGE_KEY}:${projectId}`, mode);
}
function getSkillTemplate(skillTemplates, skillId) {
return skillTemplates.find((t) => t.skillId === skillId);
}
function StudioEditPage() {
const {
projects,
workflowTemplates,
currentProjectId,
meta,
pipeline,
skillTemplates,
niches,
selectedNodeId,
loading,
saving,
error,
saveMessage,
setPipelineLocal,
setMetaLocal,
setSelectedNodeId,
updateNode,
updateNodeConfig,
addNode,
removeNode,
fetchProject,
savePipeline,
createProject,
deleteProject,
renameProject,
updateProjectMeta,
clearSaveMessage,
} = useStudioStore();
const [dragIndex, setDragIndex] = useState(null);
const [showAddNode, setShowAddNode] = useState(false);
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
const [showRenameProjectModal, setShowRenameProjectModal] = useState(false);
const [newNodeSkillId, setNewNodeSkillId] = useState('studio.worldbook_entry');
const [newNodeName, setNewNodeName] = useState('');
const [newProjectName, setNewProjectName] = useState('');
const [newProjectTemplateId, setNewProjectTemplateId] = useState(DEFAULT_TEMPLATE_ID);
const [renameProjectName, setRenameProjectName] = useState('');
const [nodeSortMode, setNodeSortMode] = useState('manual');
const [manualOrder, setManualOrder] = useState([]);
useEffect(() => {
if (!currentProjectId) return;
setNodeSortMode(getStoredSortMode(currentProjectId));
}, [currentProjectId]);
useEffect(() => {
if (!pipeline?.nodes) {
setManualOrder([]);
return;
}
setManualOrder(pipeline.nodes.map((n) => n.id));
}, [currentProjectId, pipeline?.nodes?.length]);
const displayNodes = useMemo(() => {
const nodes = pipeline?.nodes || [];
if (nodeSortMode === 'logic') {
return sortNodesByLogic(pipeline);
}
return orderNodesByIds(nodes, manualOrder);
}, [pipeline, nodeSortMode, manualOrder]);
const handleSortModeChange = (mode) => {
setNodeSortMode(mode);
if (currentProjectId) storeSortMode(currentProjectId, mode);
if (mode === 'manual' && pipeline?.nodes) {
setManualOrder(pipeline.nodes.map((n) => n.id));
}
};
const selectedNode = pipeline?.nodes?.find((n) => n.id === selectedNodeId) ?? null;
const selectedTpl = selectedNode
? getSkillTemplate(skillTemplates, selectedNode.skillId)
: null;
const isWorldbook = selectedNode?.skillId === 'studio.worldbook_entry';
const insertion = selectedNode?.config?.insertion || {};
const scoring = selectedNode?.config?.scoring || { enabled: true, dimensions: [] };
const dimensions = scoring.dimensions || [];
const templateOptions =
workflowTemplates.length > 0
? workflowTemplates
: [{ id: DEFAULT_TEMPLATE_ID, name: '世界书条目创建' }];
const handleProjectChange = async (e) => {
const id = e.target.value;
if (id) await fetchProject(id);
};
const openNewProjectModal = () => {
setNewProjectName('');
setNewProjectTemplateId(templateOptions[0]?.id || DEFAULT_TEMPLATE_ID);
setShowNewProjectModal(true);
};
const openRenameProjectModal = () => {
setRenameProjectName(meta?.name || '');
setShowRenameProjectModal(true);
};
const handleCreateProject = async (e) => {
e.preventDefault();
const name = newProjectName.trim() || '新项目';
const result = await createProject(name, newProjectTemplateId);
if (result) {
setShowNewProjectModal(false);
setNewProjectName('');
}
};
const handleRenameProject = async (e) => {
e.preventDefault();
if (!currentProjectId) return;
const name = renameProjectName.trim();
if (!name) return;
const result = await renameProject(currentProjectId, name);
if (result) {
setShowRenameProjectModal(false);
}
};
const handleDeleteProject = async () => {
if (!currentProjectId || !meta) return;
const confirmed = window.confirm(
`确定删除项目「${meta.name}」?\n\n将同时删除该项目下的所有运行记录此操作不可撤销。`
);
if (!confirmed) return;
await deleteProject(currentProjectId);
};
const handleDescriptionBlur = () => {
if (!currentProjectId || !meta) return;
updateProjectMeta(currentProjectId, { description: meta.description ?? '' });
};
const handleDragStart = (index) => {
if (nodeSortMode !== 'manual') return;
setDragIndex(index);
};
const handleDragOver = (e, index) => {
if (nodeSortMode !== 'manual') return;
e.preventDefault();
if (dragIndex === null || dragIndex === index) return;
const ids = displayNodes.map((n) => n.id);
const nextIds = [...ids];
const [moved] = nextIds.splice(dragIndex, 1);
nextIds.splice(index, 0, moved);
const nodeMap = Object.fromEntries((pipeline.nodes || []).map((n) => [n.id, n]));
const reordered = nextIds.map((id) => nodeMap[id]).filter(Boolean);
setPipelineLocal({ ...pipeline, nodes: reordered });
setManualOrder(nextIds);
setDragIndex(index);
};
const handleDragEnd = () => setDragIndex(null);
const toggleInputRef = (nodeId, ref, label) => {
const node = pipeline.nodes.find((n) => n.id === nodeId);
if (!node) return;
const exists = (node.inputs || []).some((i) => i.ref === ref);
let inputs;
if (exists) {
inputs = (node.inputs || []).filter((i) => i.ref !== ref);
} else {
inputs = [...(node.inputs || []), { ref, label }];
}
updateNode(nodeId, { inputs });
};
const updateInsertion = (field, value) => {
if (!selectedNode) return;
const next = { ...(selectedNode.config?.insertion || {}), [field]: value };
updateNodeConfig(selectedNode.id, { insertion: next });
};
const updateRagConfig = (field, value) => {
if (!selectedNode) return;
const ragConfig = {
...(insertion.ragConfig || {}),
[field]: value,
};
updateInsertion('ragConfig', ragConfig);
};
const updateScoring = (field, value) => {
if (!selectedNode) return;
const next = { ...(selectedNode.config?.scoring || {}), [field]: value };
updateNodeConfig(selectedNode.id, { scoring: next });
};
const addDimension = () => {
const id = `dim-${Date.now()}`;
updateScoring('dimensions', [
...dimensions,
{ id, name: '', criteria: '' },
]);
};
const updateDimension = (index, field, value) => {
const next = dimensions.map((d, i) =>
i === index ? { ...d, [field]: value } : d
);
updateScoring('dimensions', next);
};
const removeDimension = (index) => {
updateScoring(
'dimensions',
dimensions.filter((_, i) => i !== index)
);
};
if (loading && !pipeline) {
return <div className="studio-edit-loading">加载中</div>;
}
return (
<div className="studio-edit-page">
<header className="studio-edit-top">
<div className="studio-edit-top__label studio-edit-top__label--project">
<span className="studio-edit-top__label-text">项目</span>
</div>
<div className="studio-edit-top__label studio-edit-top__label--goal">
<FieldLabel tip={WORKFLOW_GOAL_TIP}>工作流目标</FieldLabel>
</div>
<div className="studio-edit-top__label studio-edit-top__label--desc">
<FieldLabel tip={WORKFLOW_DESC_TIP}>工作流简介</FieldLabel>
</div>
<div className="studio-edit-top__control studio-edit-top__control--project">
<select
className="studio-edit-select studio-edit-select--project"
value={currentProjectId || ''}
onChange={handleProjectChange}
aria-label="选择项目"
>
{projects.length === 0 && <option value="">无项目</option>}
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div className="studio-edit-top__control studio-edit-top__control--goal">
<textarea
className="studio-edit-textarea studio-edit-textarea--goal"
rows={2}
value={pipeline?.workflowGoal ?? ''}
onChange={(e) =>
setPipelineLocal({ ...pipeline, workflowGoal: e.target.value })
}
placeholder="描述本 Studio 项目要完成的整体设计目标…"
aria-label="工作流目标"
/>
</div>
<div className="studio-edit-top__control studio-edit-top__control--desc">
<input
type="text"
className="studio-edit-input studio-edit-input--desc"
value={meta?.description ?? ''}
onChange={(e) => setMetaLocal({ description: e.target.value })}
onBlur={handleDescriptionBlur}
placeholder="简短备注"
maxLength={120}
disabled={!currentProjectId || saving}
aria-label="工作流简介"
/>
</div>
<div className="studio-edit-top__actions">
<button
type="button"
className="studio-edit-btn studio-edit-btn-danger studio-edit-btn-sm"
onClick={handleDeleteProject}
disabled={!currentProjectId || loading || saving}
title="删除当前项目及其运行记录"
>
删除项目
</button>
<button
type="button"
className="studio-edit-btn studio-edit-btn-sm"
onClick={openRenameProjectModal}
disabled={!currentProjectId || loading || saving}
>
改名
</button>
<button
type="button"
className="studio-edit-btn studio-edit-btn-sm"
onClick={openNewProjectModal}
disabled={loading}
>
新建项目
</button>
</div>
</header>
{(error || saveMessage) && (
<div
className={`studio-edit-banner ${error ? 'error' : 'success'}`}
role="status"
>
{error || saveMessage}
<button type="button" className="studio-edit-banner-close" onClick={clearSaveMessage}>
×
</button>
</div>
)}
<div className="studio-edit-main">
<aside className="studio-edit-nodes">
<div className="studio-edit-nodes-header">
<h2 className="studio-edit-subtitle">节点列表</h2>
<div className="studio-edit-nodes-header__actions">
<div className="studio-edit-sort-toggle" role="group" aria-label="节点排序">
<button
type="button"
className={`studio-edit-sort-btn${nodeSortMode === 'manual' ? ' active' : ''}`}
onClick={() => handleSortModeChange('manual')}
title="按拖拽/保存顺序排列"
>
手动
</button>
<button
type="button"
className={`studio-edit-sort-btn${nodeSortMode === 'logic' ? ' active' : ''}`}
onClick={() => handleSortModeChange('logic')}
title="按引用依赖拓扑排序"
>
逻辑
</button>
</div>
<button
type="button"
className="studio-edit-btn studio-edit-btn-sm"
onClick={() => setShowAddNode(!showAddNode)}
>
添加节点
</button>
</div>
</div>
{showAddNode && (
<div className="studio-edit-add-node">
<select
className="studio-edit-select"
value={newNodeSkillId}
onChange={(e) => setNewNodeSkillId(e.target.value)}
>
{skillTemplates.map((t) => (
<option key={t.skillId} value={t.skillId}>
{t.displayName}
</option>
))}
</select>
<input
type="text"
className="studio-edit-input"
placeholder="展示名"
value={newNodeName}
onChange={(e) => setNewNodeName(e.target.value)}
/>
<button
type="button"
className="studio-edit-btn studio-edit-btn-sm"
onClick={() => {
addNode(
newNodeSkillId,
newNodeName ||
getSkillTemplate(skillTemplates, newNodeSkillId)?.displayName
);
setNewNodeName('');
setShowAddNode(false);
}}
>
确认
</button>
</div>
)}
<ul className="studio-edit-node-list">
{displayNodes.map((node, index) => {
const tpl = getSkillTemplate(skillTemplates, node.skillId);
const isManualSort = nodeSortMode === 'manual';
return (
<li
key={node.id}
className={`studio-edit-node-item ${
selectedNodeId === node.id ? 'selected' : ''
} ${!node.enabled ? 'disabled' : ''}${!isManualSort ? ' no-drag' : ''}`}
draggable={isManualSort}
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
onClick={() => setSelectedNodeId(node.id)}
>
{isManualSort ? (
<span className="studio-edit-drag-handle" title="拖拽排序">
</span>
) : (
<span className="studio-edit-drag-handle studio-edit-drag-handle--muted" title="逻辑排序模式下不可拖拽">
</span>
)} <div className="studio-edit-node-text">
<span className="studio-edit-node-title">{node.displayName}</span>
<span className="studio-edit-node-skill">
{tpl?.displayName || node.skillId}
</span>
</div>
<label
className="studio-edit-switch"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={node.enabled}
onChange={(e) =>
updateNode(node.id, { enabled: e.target.checked })
}
/>
<span>启用</span>
</label>
</li>
);
})}
</ul>
</aside>
<section className="studio-edit-detail">
{!selectedNode ? (
<p className="studio-edit-empty">选择左侧节点进行编辑</p>
) : (
<div className="studio-edit-detail-inner">
<div className="studio-edit-meta-strip">
<label className="studio-edit-meta-strip__field">
<span className="studio-edit-field-label">展示名</span>
<input
type="text"
className="studio-edit-input"
value={selectedNode.displayName}
onChange={(e) =>
updateNode(selectedNode.id, { displayName: e.target.value })
}
/>
</label>
<label className="studio-edit-meta-strip__field">
<span className="studio-edit-field-label">模板类型</span>
<input
type="text"
className="studio-edit-input"
readOnly
value={selectedTpl?.displayName || selectedNode.skillId}
/>
</label>
{isWorldbook && niches.length > 0 && (
<label className="studio-edit-meta-strip__field">
<span className="studio-edit-field-label">
<FieldLabel tip="选择预设领域可自动填充步骤目标建议">
预设领域
</FieldLabel>
</span>
<select
className="studio-edit-select"
value={selectedNode.niche ?? ''}
onChange={(e) => {
const nicheId = e.target.value || null;
const niche = niches.find((n) => n.id === nicheId);
updateNode(selectedNode.id, { niche: nicheId });
if (niche?.suggestedStepGoal && !selectedNode.config?.stepGoal) {
updateNodeConfig(selectedNode.id, {
stepGoal: niche.suggestedStepGoal,
});
}
}}
>
<option value=""></option>
{niches.map((n) => (
<option key={n.id} value={n.id}>
{n.label}
</option>
))}
</select>
</label>
)}
{selectedTpl?.artifacts?.length > 0 && (
<span className="studio-edit-meta-strip__hint">
产物
{selectedTpl.artifacts.map((a) => a.displayName).join('、')}
</span>
)}
</div>
<div className="studio-edit-detail-body">
<div className="studio-edit-primary">
<div className="studio-edit-primary-card">
<span className="studio-edit-primary-card__badge">主要设置</span>
{isWorldbook ? (
<>
<label className="studio-edit-field studio-edit-field--primary">
<span className="studio-edit-field-label studio-edit-field-label--primary">
<FieldLabel tip="本步骤要产出的内容目标,会注入模型系统提示">
步骤目标
</FieldLabel>
</span>
<textarea
className="studio-edit-textarea studio-edit-textarea--primary"
rows={5}
value={selectedNode.config?.stepGoal ?? ''}
onChange={(e) =>
updateNodeConfig(selectedNode.id, {
stepGoal: e.target.value,
})
}
placeholder="描述本步骤要产出的内容…"
/>
</label>
<label className="studio-edit-field studio-edit-field--primary">
<span className="studio-edit-field-label studio-edit-field-label--primary">
<FieldLabel tip="引导模型在本步骤中的思考方式、关注点与推理路径">
思考提示词
</FieldLabel>
</span>
<textarea
className="studio-edit-textarea studio-edit-textarea--primary"
rows={5}
value={selectedNode.config?.thinkingPrompt ?? ''}
onChange={(e) =>
updateNodeConfig(selectedNode.id, {
thinkingPrompt: e.target.value,
})
}
placeholder="引导模型如何思考本步骤…"
/>
{!selectedNode.config?.thinkingPrompt && (
<button
type="button"
className="studio-edit-btn studio-edit-btn-sm studio-edit-think-default"
onClick={() =>
updateNodeConfig(selectedNode.id, {
thinkingPrompt: DEFAULT_THINKING_PROMPT,
})
}
>
填入默认思考流程模板
</button>
)}
</label>
<ScoringDimensions
scoring={scoring}
dimensions={dimensions}
onUpdateScoring={updateScoring}
onUpdateDimension={updateDimension}
onRemoveDimension={removeDimension}
onAddDimension={addDimension}
/>
</>
) : selectedNode.skillId === 'studio.init_bind' ? (
<>
<p className="studio-edit-hint">
运行时在引导区填写编辑页可预览字段定义
</p>
<ul className="studio-edit-dp-list studio-edit-dp-list--compact">
{(selectedNode.displayParams || []).map((dp) => (
<li key={dp.key}>
{dp.label} ({dp.key})
{dp.required ? ' · 必填' : ''}
</li>
))}
</ul>
</>
) : null}
</div>
</div>
<aside className="studio-edit-edge-rail">
{isWorldbook ? (
<>
<div className="studio-edit-edge-card">
<VariableChips
variant="compact"
pipeline={pipeline}
selectedNode={selectedNode}
onToggleRef={(ref, label) =>
toggleInputRef(selectedNode.id, ref, label)
}
/>
</div>
<div className="studio-edit-edge-card">
<WorldbookInsertion
insertion={insertion}
onUpdateInsertion={updateInsertion}
onUpdateRagConfig={updateRagConfig}
/>
</div>
</>
) : (
<div className="studio-edit-edge-card studio-edit-edge-card--empty">
<p className="studio-edit-edge-empty-tip">创建步骤无需引用变量</p>
</div>
)}
</aside>
</div>
<div className="studio-edit-detail-footer">
<button
type="button"
className="studio-edit-btn studio-edit-btn-danger"
onClick={() => {
if (window.confirm(`删除节点「${selectedNode.displayName}」?`)) {
removeNode(selectedNode.id);
}
}}
>
删除节点
</button>
<button
type="button"
className="studio-edit-btn studio-edit-btn-primary"
onClick={savePipeline}
disabled={saving || !currentProjectId}
>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
)}
</section>
</div>
{showNewProjectModal && (
<div
className="studio-modal-overlay"
role="presentation"
onClick={() => setShowNewProjectModal(false)}
>
<div
className="studio-modal"
role="dialog"
aria-labelledby="new-project-title"
onClick={(e) => e.stopPropagation()}
>
<h2 id="new-project-title" className="studio-modal-title">
新建项目
</h2>
<form onSubmit={handleCreateProject}>
<label className="studio-edit-label-block">
项目名称
<input
type="text"
className="studio-edit-input"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder="例如:帝国骑士维尔"
autoFocus
required
/>
</label>
<label className="studio-edit-label-block">
选择工作流模板
<select
className="studio-edit-select"
value={newProjectTemplateId}
onChange={(e) => setNewProjectTemplateId(e.target.value)}
>
{templateOptions.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</label>
<div className="studio-modal-actions">
<button
type="button"
className="studio-edit-btn"
onClick={() => setShowNewProjectModal(false)}
>
取消
</button>
<button
type="submit"
className="studio-edit-btn studio-edit-btn-primary"
disabled={loading}
>
{loading ? '创建中…' : '创建'}
</button>
</div>
</form>
</div>
</div>
)}
{showRenameProjectModal && (
<div
className="studio-modal-overlay"
role="presentation"
onClick={() => setShowRenameProjectModal(false)}
>
<div
className="studio-modal"
role="dialog"
aria-labelledby="rename-project-title"
onClick={(e) => e.stopPropagation()}
>
<h2 id="rename-project-title" className="studio-modal-title">
改名
</h2>
<form onSubmit={handleRenameProject}>
<label className="studio-edit-label-block">
项目名称
<input
type="text"
className="studio-edit-input"
value={renameProjectName}
onChange={(e) => setRenameProjectName(e.target.value)}
autoFocus
required
maxLength={120}
/>
</label>
<div className="studio-modal-actions">
<button
type="button"
className="studio-edit-btn"
onClick={() => setShowRenameProjectModal(false)}
>
取消
</button>
<button
type="submit"
className="studio-edit-btn studio-edit-btn-primary"
disabled={saving}
>
{saving ? '保存中…' : '确定'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
export default StudioEditPage;

View File

@@ -0,0 +1,107 @@
.studio-insertion-popup {
position: fixed;
z-index: 1200;
width: 280px;
max-width: calc(100vw - 32px);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-bg-secondary);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
pointer-events: auto;
}
.studio-insertion-popup.is-expanded {
width: min(420px, calc(100vw - 32px));
max-height: min(70vh, 520px);
display: flex;
flex-direction: column;
}
.studio-insertion-popup__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-sm);
padding: var(--spacing-xs) var(--spacing-sm);
border-bottom: 1px solid var(--color-border-light);
background: var(--color-bg-tertiary);
border-radius: var(--radius-md) var(--radius-md) 0 0;
cursor: grab;
user-select: none;
}
.studio-insertion-popup__header:active {
cursor: grabbing;
}
.studio-insertion-popup__title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-primary);
}
.studio-insertion-popup__actions {
display: flex;
align-items: center;
gap: 4px;
}
.studio-insertion-popup__btn {
padding: 2px 8px;
border: 1px solid var(--color-border-light);
border-radius: var(--radius-sm);
background: var(--color-bg-secondary);
font-size: 0.65rem;
color: var(--color-text-muted);
cursor: pointer;
}
.studio-insertion-popup__btn:hover {
border-color: var(--color-accent);
color: var(--color-text-primary);
}
.studio-insertion-popup__btn--close {
font-size: 1rem;
line-height: 1;
padding: 0 6px;
}
.studio-insertion-popup__body {
padding: var(--spacing-sm);
overflow: auto;
font-size: 0.75rem;
}
.studio-insertion-popup.is-expanded .studio-insertion-popup__body {
flex: 1;
min-height: 0;
}
.studio-insertion-popup__preview {
display: flex;
flex-direction: column;
gap: 4px;
}
.studio-insertion-popup__field-label {
font-size: 0.65rem;
color: var(--color-text-muted);
}
.studio-insertion-popup__field-value {
font-size: 0.8rem;
color: var(--color-text-primary);
line-height: 1.4;
}
.studio-insertion-popup__full {
margin: 0;
padding: var(--spacing-sm);
border-radius: var(--radius-sm);
border: 1px solid var(--color-border-light);
background: var(--color-bg-tertiary);
white-space: pre-wrap;
font-size: 0.72rem;
max-height: none;
}

View File

@@ -0,0 +1,95 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import './StudioInsertionPopup.css';
const DEFAULT_POS = { x: 72, y: 96 };
function StudioInsertionPopup({ open, title, onClose, defaultExpanded = false, children }) {
const popupRef = useRef(null);
const dragRef = useRef(null);
const [pos, setPos] = useState(DEFAULT_POS);
const [expanded, setExpanded] = useState(defaultExpanded);
useEffect(() => {
if (open) {
setExpanded(defaultExpanded);
setPos(DEFAULT_POS);
}
}, [open, defaultExpanded]);
const handleDragStart = useCallback((e) => {
if (e.button !== 0) return;
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
const origin = { ...pos };
const onMove = (ev) => {
setPos({
x: origin.x + (ev.clientX - startX),
y: origin.y + (ev.clientY - startY),
});
};
const onUp = () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
dragRef.current = null;
};
dragRef.current = { startX, startY, origin };
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}, [pos]);
useEffect(() => {
return () => {
if (dragRef.current) {
window.removeEventListener('mousemove', () => {});
window.removeEventListener('mouseup', () => {});
}
};
}, []);
if (!open) return null;
return (
<div
ref={popupRef}
className={`studio-insertion-popup${expanded ? ' is-expanded' : ''}`}
style={{ left: pos.x, top: pos.y }}
role="dialog"
aria-label={title}
>
<div
className="studio-insertion-popup__header"
onMouseDown={handleDragStart}
>
<span className="studio-insertion-popup__title">{title}</span>
<div className="studio-insertion-popup__actions">
<button
type="button"
className="studio-insertion-popup__btn"
onClick={() => setExpanded((v) => !v)}
title={expanded ? '收起详情' : '展开详情'}
>
{expanded ? '收起' : '展开'}
</button>
<button
type="button"
className="studio-insertion-popup__btn studio-insertion-popup__btn--close"
onClick={onClose}
aria-label="关闭"
>
×
</button>
</div>
</div>
<div className="studio-insertion-popup__body">
{children({ expanded })}
</div>
</div>
);
}
export default StudioInsertionPopup;

View File

@@ -0,0 +1,70 @@
.studio-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--color-bg-primary);
color: var(--color-text-primary);
}
.studio-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-lg);
padding: var(--spacing-md) var(--spacing-lg);
border-bottom: 1px solid var(--color-border-light);
background: var(--color-bg-secondary);
flex-shrink: 0;
}
.studio-header-title {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.studio-header-icon {
font-size: 1.25rem;
}
.studio-title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.studio-tabs {
display: flex;
gap: var(--spacing-xs);
}
.studio-tab {
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
cursor: pointer;
font-size: 0.875rem;
transition: background var(--transition-fast), color var(--transition-fast);
}
.studio-tab:hover {
color: var(--color-text-primary);
border-color: var(--color-border-focus);
}
.studio-tab.active {
background: var(--color-accent-light);
border-color: var(--color-accent);
color: var(--color-accent);
}
.studio-body {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,34 @@
import React, { useEffect } from 'react';
import useAppLayoutStore from '../../Store/AppLayoutSlice';
/**
* 兼容旧路由studio → studio_edit
*/
function StudioPage() {
const setActivePage = useAppLayoutStore((s) => s.setActivePage);
useEffect(() => {
setActivePage('studio_edit');
}, [setActivePage]);
return null;
}
export default StudioPage;

View File

@@ -0,0 +1,240 @@
.studio-run-chat {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.studio-run-chat-messages {
flex: 1;
overflow-y: auto;
padding: var(--spacing-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
min-height: 0;
}
.studio-run-chat-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.studio-run-chat-message {
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--radius-md);
line-height: 1.6;
}
.studio-run-chat-message.role-user {
align-self: flex-end;
max-width: 85%;
background: linear-gradient(to right, rgba(102, 126, 234, 0.08), rgba(102, 126, 234, 0.15));
border-left: 3px solid var(--color-accent);
}
.studio-run-chat-message.role-assistant,
.studio-run-chat-message.role-system {
align-self: stretch;
background: rgba(255, 255, 255, 0.6);
}
.studio-run-chat-message-role {
display: block;
font-size: 0.7rem;
color: var(--color-text-muted);
margin-bottom: var(--spacing-xs);
}
.studio-run-chat-message-body {
font-size: 0.95rem;
}
.studio-run-chat-plain {
white-space: pre-wrap;
}
/* Input area — aligned with ChatBox */
.studio-run-chat-input-wrapper {
position: sticky;
bottom: 0;
left: 0;
right: 0;
flex-shrink: 0;
padding: var(--spacing-md) var(--spacing-lg);
border-top: 1px solid var(--color-border-light);
background-color: var(--color-bg-primary);
z-index: var(--z-divider);
}
.studio-run-chat-input-container {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-xs) var(--spacing-sm);
background-color: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.2s ease;
}
.studio-run-chat-input-container:focus-within {
border-color: var(--color-accent);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.studio-run-chat-options-wrapper {
position: relative;
flex-shrink: 0;
display: flex;
align-items: center;
}
.studio-run-chat-options-toggle {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: var(--radius-md);
color: var(--color-text-muted);
font-size: 1.3rem;
line-height: 1;
cursor: pointer;
transition: all 0.2s ease;
}
.studio-run-chat-options-toggle:hover {
background-color: rgba(102, 126, 234, 0.08);
color: var(--color-accent);
}
.studio-run-chat-options-toggle.active {
background-color: rgba(102, 126, 234, 0.12);
color: var(--color-accent);
}
.studio-run-chat-options {
position: absolute;
bottom: calc(100% + var(--spacing-sm));
left: 0;
min-width: 220px;
padding: var(--spacing-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg-elevated);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
z-index: var(--z-dropdown-menu);
backdrop-filter: blur(10px);
}
.studio-run-chat-options-title {
font-size: 0.7rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: var(--spacing-xs) var(--spacing-sm);
margin-bottom: var(--spacing-xs);
}
.studio-run-chat-render-btn {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
background: transparent;
color: var(--color-text-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: 0.85rem;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: all 0.15s ease;
}
.studio-run-chat-render-btn:hover {
background-color: rgba(102, 126, 234, 0.08);
border-color: var(--color-accent);
color: var(--color-accent);
}
.studio-run-chat-input-area {
flex: 1;
min-width: 0;
position: relative;
display: flex;
align-items: center;
}
.studio-run-chat-textarea {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: none;
border-radius: var(--radius-md);
background-color: transparent;
color: var(--color-text-primary);
font-family: inherit;
font-size: 0.95rem;
resize: none;
overflow-y: auto;
min-height: 36px;
max-height: 300px;
line-height: 1.6;
display: block;
height: auto;
field-sizing: content;
}
.studio-run-chat-textarea:focus {
outline: none;
background-color: rgba(102, 126, 234, 0.03);
}
.studio-run-chat-textarea::placeholder {
color: var(--color-text-muted);
opacity: 0.5;
}
.studio-run-chat-send {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border: none;
border-radius: var(--radius-md);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
font-size: 1.3rem;
line-height: 1;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.2);
align-self: center;
}
.studio-run-chat-send:hover:not(:disabled) {
background: linear-gradient(135deg, #5568d3 0%, #6a3f8f 100%);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(102, 126, 234, 0.3);
}
.studio-run-chat-send:active:not(:disabled) {
transform: translateY(0);
}
.studio-run-chat-send:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
box-shadow: none;
}

View File

@@ -0,0 +1,171 @@
import React, { useEffect, useRef, useState } from 'react';
import MarkdownRenderer from '../shared/MarkdownRenderer';
import './StudioRunChat.css';
const RENDER_MODES = ['none', 'markdown', 'html'];
const RENDER_MODE_LABELS = {
none: '📄 纯文本',
html: '🌐 HTML',
markdown: '📝 Markdown',
};
function renderMessageBody(text, renderMode, isUser) {
if (renderMode === 'html' && !isUser) {
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(text);
if (hasHtmlTags) {
return <div dangerouslySetInnerHTML={{ __html: text }} />;
}
return <div className="studio-run-chat-plain">{text}</div>;
}
if (renderMode === 'markdown') {
return <MarkdownRenderer content={text} />;
}
return <div className="studio-run-chat-plain">{text}</div>;
}
function StudioRunChat({
messages,
inputValue,
onInputChange,
onSend,
disabled = false,
placeholder = '输入消息…',
sending = false,
}) {
const messagesEndRef = useRef(null);
const messagesContainerRef = useRef(null);
const optionsRef = useRef(null);
const [showOptions, setShowOptions] = useState(false);
const [renderMode, setRenderMode] = useState('markdown');
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
const handleClickOutside = (event) => {
if (
optionsRef.current &&
!optionsRef.current.contains(event.target) &&
!event.target.closest('.studio-run-chat-options-toggle')
) {
setShowOptions(false);
}
};
if (showOptions) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showOptions]);
const handleInputHeight = (e) => {
const textarea = e.target;
textarea.style.height = 'auto';
textarea.style.height = `${Math.min(textarea.scrollHeight, 300)}px`;
};
const handleSubmit = (e) => {
e.preventDefault();
if (disabled || sending || !inputValue.trim()) return;
onSend(inputValue.trim());
const textarea = e.target.querySelector('.studio-run-chat-textarea');
if (textarea) textarea.style.height = 'auto';
};
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!disabled && !sending && inputValue.trim()) {
onSend(inputValue.trim());
e.target.style.height = 'auto';
}
}
};
const cycleRenderMode = () => {
const idx = RENDER_MODES.indexOf(renderMode);
setRenderMode(RENDER_MODES[(idx + 1) % RENDER_MODES.length]);
};
const roleLabel = (role) => {
if (role === 'user') return '你';
if (role === 'system') return '系统';
return '助手';
};
return (
<div className="studio-run-chat">
<div className="studio-run-chat-messages" ref={messagesContainerRef}>
{messages.length === 0 ? (
<div className="studio-run-chat-empty">暂无对话在下方输入开始交流</div>
) : (
messages.map((msg) => (
<div key={msg.id} className={`studio-run-chat-message role-${msg.role}`}>
<span className="studio-run-chat-message-role">{roleLabel(msg.role)}</span>
<div className="studio-run-chat-message-body">
{renderMessageBody(msg.text, renderMode, msg.role === 'user')}
</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
<form className="studio-run-chat-input-wrapper" onSubmit={handleSubmit}>
<div className="studio-run-chat-input-container">
<div className="studio-run-chat-options-wrapper" ref={optionsRef}>
<button
type="button"
className={`studio-run-chat-options-toggle${showOptions ? ' active' : ''}`}
title="展开选项"
onClick={() => setShowOptions((v) => !v)}
>
{showOptions ? '×' : '≡'}
</button>
{showOptions && (
<div className="studio-run-chat-options">
<div className="studio-run-chat-options-title">显示</div>
<button
type="button"
className="studio-run-chat-render-btn"
onClick={cycleRenderMode}
title={`当前:${RENDER_MODE_LABELS[renderMode]}`}
>
{RENDER_MODE_LABELS[renderMode]}
</button>
</div>
)}
</div>
<div className="studio-run-chat-input-area">
<textarea
className="studio-run-chat-textarea"
rows={1}
value={inputValue}
onChange={(e) => {
onInputChange(e.target.value);
handleInputHeight(e);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled || sending}
aria-label="输入消息"
/>
</div>
<button
type="submit"
className="studio-run-chat-send"
disabled={disabled || sending || !inputValue.trim()}
aria-label="发送"
title="发送"
>
{sending ? '…' : '>'}
</button>
</div>
</form>
</div>
);
}
export default StudioRunChat;

View File

@@ -0,0 +1,89 @@
.studio-run-graph {
position: relative;
height: 240px;
min-height: 200px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-light);
background: var(--color-bg-secondary);
overflow: hidden;
cursor: grab;
}
.studio-run-graph--center {
flex: 1;
min-height: 280px;
height: auto;
}
.studio-run-graph:active {
cursor: grabbing;
}
.studio-run-graph__svg {
display: block;
}
.studio-run-graph__edge {
fill: none;
stroke: var(--color-border);
stroke-width: 1.5;
opacity: 0.85;
}
.studio-run-graph__node {
cursor: pointer;
}
.studio-run-graph__node-bg {
fill: var(--color-bg-tertiary);
stroke: var(--color-border-light);
stroke-width: 1.5;
}
.studio-run-graph__node.status-active .studio-run-graph__node-bg {
stroke: var(--color-accent);
fill: rgba(var(--color-accent-rgb, 59, 130, 246), 0.1);
}
.studio-run-graph__node.status-completed .studio-run-graph__node-bg {
stroke: #22c55e;
}
.studio-run-graph__node.is-selected .studio-run-graph__node-bg {
stroke-width: 2.5;
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.12));
}
.studio-run-graph__node.is-current .studio-run-graph__node-bg {
stroke: var(--color-accent);
}
.studio-run-graph__node-label {
font-size: 12px;
font-weight: 600;
fill: var(--color-text-primary);
pointer-events: none;
}
.studio-run-graph__node-status {
font-size: 10px;
fill: var(--color-text-muted);
pointer-events: none;
}
.studio-run-graph__hint {
position: absolute;
bottom: 4px;
right: 6px;
font-size: 0.6rem;
color: var(--color-text-muted);
opacity: 0.75;
pointer-events: none;
}
.studio-run-graph-empty {
padding: var(--spacing-md);
font-size: 0.75rem;
color: var(--color-text-muted);
text-align: center;
}

View File

@@ -0,0 +1,233 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { buildGraphLayout } from './edit/variableUtils';
import './StudioRunNodeGraph.css';
const NODE_STATUS_LABEL = {
pending: '待执行',
active: '进行中',
completed: '已完成',
skipped: '已跳过',
};
function defaultEdgePath(from, to, nodeW, nodeH) {
const x1 = from.x + nodeW / 2;
const y1 = from.y + nodeH;
const x2 = to.x + nodeW / 2;
const y2 = to.y;
const dy = Math.max(24, (y2 - y1) * 0.45);
return `M ${x1} ${y1} C ${x1} ${y1 + dy}, ${x2} ${y2 - dy}, ${x2} ${y2}`;
}
function StudioRunNodeGraph({
pipeline,
nodeStates,
currentNodeId,
variant = 'sidebar',
}) {
const containerRef = useRef(null);
const [view, setView] = useState({ x: 0, y: 0, scale: 1 });
const [dragging, setDragging] = useState(null);
const [selectedId, setSelectedId] = useState(currentNodeId);
const [nodeOverrides, setNodeOverrides] = useState({});
const isCenter = variant === 'center';
const layout = useMemo(
() => buildGraphLayout(pipeline, nodeStates, { vertical: true }),
[pipeline, nodeStates]
);
const { nodeW, nodeH } = layout;
useEffect(() => {
setSelectedId(currentNodeId);
}, [currentNodeId]);
useEffect(() => {
const el = containerRef.current;
if (!el || !layout.width) return;
const cw = el.clientWidth || 200;
const ch = el.clientHeight || 160;
const scale = Math.min(1, (cw - 16) / layout.width, (ch - 16) / layout.height);
setView({
x: (cw - layout.width * scale) / 2,
y: Math.max(8, (ch - layout.height * scale) / 2),
scale: Math.max(0.45, scale),
});
}, [layout.width, layout.height, pipeline, variant]);
const nodeMap = useMemo(
() => Object.fromEntries(layout.nodes.map((n) => [n.id, n])),
[layout.nodes]
);
const handleWheel = useCallback((e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.92 : 1.08;
setView((v) => ({
...v,
scale: Math.min(2.5, Math.max(0.35, v.scale * delta)),
}));
}, []);
const handleBgMouseDown = useCallback(
(e) => {
if (e.button !== 0) return;
e.preventDefault();
setDragging({ type: 'pan', startX: e.clientX, startY: e.clientY, origin: { ...view } });
},
[view]
);
const handleNodeMouseDown = useCallback(
(e, nodeId) => {
if (e.button !== 0) return;
e.stopPropagation();
setSelectedId(nodeId);
const node = nodeMap[nodeId];
if (!node) return;
setDragging({
type: 'node',
nodeId,
startX: e.clientX,
startY: e.clientY,
origin: { x: node.x, y: node.y },
});
},
[nodeMap]
);
useEffect(() => {
if (!dragging) return undefined;
const onMove = (e) => {
if (dragging.type === 'pan') {
setView((v) => ({
...v,
x: dragging.origin.x + (e.clientX - dragging.startX),
y: dragging.origin.y + (e.clientY - dragging.startY),
}));
return;
}
if (dragging.type === 'node') {
const dx = (e.clientX - dragging.startX) / view.scale;
const dy = (e.clientY - dragging.startY) / view.scale;
setNodeOverrides((prev) => ({

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
import React from 'react';
/** Label with optional dotted underline + native title tooltip for field hints. */
export default function FieldLabel({ children, tip }) {
if (tip) {
return (
<span className="studio-field-label" title={tip}>
<span className="studio-field-label__text studio-field-label__text--tip">
{children}
</span>
</span>
);
}
return <span className="studio-field-label">{children}</span>;
}

View File

@@ -0,0 +1,83 @@
import React from 'react';
import FieldLabel from './FieldLabel';
export default function ScoringDimensions({
scoring,
dimensions,
onUpdateScoring,
onUpdateDimension,
onRemoveDimension,
onAddDimension,
}) {
return (
<div className="studio-scoring-block">
<h3 className="studio-scoring-block__title">
<FieldLabel tip="定义评分维度及修改时的参考标准,用于循环迭代直至满意">
评判维度与如何完善
</FieldLabel>
</h3>
<label className="studio-edit-switch-row studio-edit-switch-row--compact">
<input
type="checkbox"
checked={scoring.enabled ?? true}
onChange={(e) => onUpdateScoring('enabled', e.target.checked)}
/>
启用评分
</label>
<div className="studio-dim-list">
{dimensions.map((dim, index) => (
<div key={dim.id || index} className="studio-dim-card">
<div className="studio-dim-card__fields">
<label className="studio-edit-field">
<span className="studio-edit-field-label">维度名称</span>
<input
type="text"
className="studio-edit-input"
value={dim.name}
onChange={(e) =>
onUpdateDimension(index, 'name', e.target.value)
}
placeholder="例如:真实性"
/>
</label>
<label className="studio-edit-field">
<span className="studio-edit-field-label">
<FieldLabel tip="说明如何改进,并作为评判参考">
如何完善含评判参考
</FieldLabel>
</span>
<textarea
className="studio-edit-textarea studio-edit-textarea--compact"
rows={2}
value={dim.criteria}
onChange={(e) =>
onUpdateDimension(index, 'criteria', e.target.value)
}
placeholder="说明如何改进,并作为评判参考…"
/>
</label>
</div>
<button
type="button"
className="studio-dim-card__remove"
title="删除此维度"
onClick={() => onRemoveDimension(index)}
>
删除
</button>
</div>
))}
</div>
<button
type="button"
className="studio-edit-btn-ghost"
onClick={onAddDimension}
>
+ 新增维度
</button>
</div>
);
}

View File

@@ -0,0 +1,146 @@
import React, { useState } from 'react';
import FieldLabel from './FieldLabel';
import {
AUTO_INJECTED_CONTEXT_ITEMS,
buildStepOutputRef,
detectReferenceCycles,
dynamicChipLabel,
dynamicTooltip,
formatCycleWarning,
getSelectableStepOutputRefs,
stepOutputLabel,
} from './variableUtils';
function AutoInjectedItem({ item }) {
const [showPopover, setShowPopover] = useState(false);
return (
<span
className="studio-var-auto-chip"
onMouseEnter={() => setShowPopover(true)}
onMouseLeave={() => setShowPopover(false)}
onFocus={() => setShowPopover(true)}
onBlur={() => setShowPopover(false)}
tabIndex={0}
>
<span className="studio-var-auto-chip__label">{item.label}</span>
{showPopover && (
<span className="studio-var-auto-chip__popover" role="tooltip">
{item.description}
</span>
)}
</span>
);
}
function VariableSwitch({ label, selected, onToggle, tooltip }) {
return (
<label
className={`studio-var-switch ${selected ? 'studio-var-switch--on' : ''}`}
title={tooltip}
>
<span className="studio-var-switch__label">{label}</span>
<span className="studio-var-switch__track" aria-hidden="true">
<input
type="checkbox"
className="studio-var-switch__input"
checked={selected}
onChange={onToggle}
/>
<span className="studio-var-switch__thumb" />
</span>
</label>
);
}
export default function VariableChips({
pipeline,
selectedNode,
onToggleRef,
variant = 'default',
}) {
const isCompact = variant === 'compact';
const [autoExpanded, setAutoExpanded] = useState(false);
const previousSteps = getSelectableStepOutputRefs(pipeline, selectedNode);
const cycleWarning = formatCycleWarning(detectReferenceCycles(pipeline), pipeline);
const isChecked = (ref) =>
(selectedNode.inputs || []).some((i) => i.ref === ref);
return (
<div className={`studio-var-panel ${isCompact ? 'studio-var-panel--compact' : ''}`}>
<h3 className="studio-var-panel__title">
<FieldLabel tip="引用前序步骤的世界书条目;核心目的、思考流程等由系统自动注入">
上文引用
</FieldLabel>
</h3>
{cycleWarning && (
<p className="studio-var-cycle-warn" role="alert">
{cycleWarning}
</p>
)}
<div className="studio-var-group studio-var-group--auto">
<button
type="button"
className="studio-var-auto-toggle"
onClick={() => setAutoExpanded((v) => !v)}
aria-expanded={autoExpanded}
>
<span className="studio-var-group__title">系统自动注入</span>
<span className="studio-var-auto-toggle__meta">
{autoExpanded
? '收起'
: `${AUTO_INJECTED_CONTEXT_ITEMS.length} 项已默认注入`}
</span>
<span className="studio-var-auto-toggle__chevron" aria-hidden="true">
{autoExpanded ? '▾' : '▸'}
</span>
</button>
{autoExpanded ? (
<div className="studio-var-auto-list">
{AUTO_INJECTED_CONTEXT_ITEMS.map((item) => (
<AutoInjectedItem key={item.id} item={item} />
))}
</div>
) : (
<p className="studio-edit-hint studio-var-auto-hint">
运行时自动附带无需手动开启展开可查看各项含义
</p>
)}
</div>
<div className="studio-var-group">
<h4 className="studio-var-group__title">前序步骤产物</h4>
<p className="studio-edit-hint studio-var-manual-hint">
开关开启后将把对应步骤的最终世界书条目注入本步上下文
</p>
<div className={`studio-var-grid ${isCompact ? 'studio-var-grid--compact' : ''}`}>
{previousSteps.length === 0 ? (
<p className="studio-edit-hint studio-var-empty">暂无可引用的前序世界书步骤</p>
) : (
previousSteps.map((node) => {
const ref = buildStepOutputRef(node.id);
const checked = isChecked(ref);
const label = dynamicChipLabel(ref, pipeline, stepOutputLabel(node.displayName));
return (
<VariableSwitch
key={ref}
label={label}
selected={checked}
tooltip={dynamicTooltip(ref, pipeline)}
onToggle={() =>
onToggleRef(ref, stepOutputLabel(node.displayName))
}
/>
);
})
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,163 @@
import React from 'react';
import FieldLabel from './FieldLabel';
const POSITION_OPTIONS = [
{ value: 0, label: '角色定义之后' },
{ value: 1, label: '角色定义之前' },
{ value: 2, label: '示例对话之前' },
{ value: 3, label: '示例对话之后' },
{ value: 4, label: '系统提示/作者注释' },
{ value: 5, label: '作为系统消息' },
{ value: 6, label: '深度插入' },
{ value: 7, label: '宏替换' },
];
const ACTIVATION_OPTIONS = [
{ value: 'permanent', label: '永久激活' },
{ value: 'keyword', label: '关键词触发' },
{ value: 'rag', label: 'RAG 检索' },
];
export default function WorldbookInsertion({
insertion,
onUpdateInsertion,
onUpdateRagConfig,
}) {
const activation = insertion.activationType ?? 'permanent';
return (
<div className="studio-wb-insert">
<h3 className="studio-wb-insert__title">世界书插入</h3>
<div className="studio-wb-insert__fields">
<label className="studio-edit-field">
<span className="studio-edit-field-label">
<FieldLabel tip="条目写入角色卡时的深度位置,影响模型读取上下文的顺序">
插入位置
</FieldLabel>
</span>
<select
className="studio-edit-select"
value={insertion.position ?? 1}
onChange={(e) =>
onUpdateInsertion('position', Number(e.target.value))
}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<label className="studio-edit-field">
<span className="studio-edit-field-label">
<FieldLabel tip="永久激活始终注入;关键词/RAG 仅在匹配时注入">
激活方式
</FieldLabel>
</span>
<select
className="studio-edit-select"
value={activation}
onChange={(e) =>
onUpdateInsertion('activationType', e.target.value)
}
>
{ACTIVATION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
{activation === 'keyword' && (
<>
<label className="studio-edit-field">
<span className="studio-edit-field-label">
<FieldLabel tip="主关键词用于触发本条目进入上下文">主关键词</FieldLabel>
</span>
<input
type="text"
className="studio-edit-input"
value={insertion.key ?? ''}
onChange={(e) => onUpdateInsertion('key', e.target.value)}
placeholder="触发本条目所需主词"
/>
</label>
<label className="studio-edit-field">
<span className="studio-edit-field-label">
<FieldLabel tip="可选,辅助匹配多个相关词">次要关键词</FieldLabel>
</span>
<input
type="text"
className="studio-edit-input"
value={insertion.keysecondary ?? ''}
onChange={(e) =>
onUpdateInsertion('keysecondary', e.target.value)
}
placeholder="可选,辅助匹配"
/>
</label>
</>
)}
{activation === 'rag' && (
<>
<label className="studio-edit-field">
<span className="studio-edit-field-label">RAG ID</span>
<input
type="text"
className="studio-edit-input"
value={insertion.ragConfig?.libraryId ?? ''}
onChange={(e) =>
onUpdateRagConfig('libraryId', e.target.value)
}
/>
</label>
<label className="studio-edit-field">
<span className="studio-edit-field-label">相似度阈值</span>
<input
type="number"
step="0.01"
min="0"
max="1"
className="studio-edit-input"
value={insertion.ragConfig?.threshold ?? 0.5}
onChange={(e) =>
onUpdateRagConfig('threshold', Number(e.target.value))
}
/>
</label>
<label className="studio-edit-field">
<span className="studio-edit-field-label">最大条数</span>
<input
type="number"
min="1"
className="studio-edit-input"
value={insertion.ragConfig?.maxEntries ?? 3}
onChange={(e) =>
onUpdateRagConfig('maxEntries', Number(e.target.value))
}
/>
</label>
</>
)}
<label className="studio-edit-field">
<span className="studio-edit-field-label">
<FieldLabel tip="写入世界书条目的说明注释,便于在编辑器中识别">备注</FieldLabel>
</span>
<input
type="text"
className="studio-edit-input"
value={insertion.comment ?? ''}
onChange={(e) => onUpdateInsertion('comment', e.target.value)}
placeholder="写入世界书条目的说明注释"
/>
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,347 @@
/** Resolve node displayName from dynamic ref like `aesthetic.output`. */
export function nodeDisplayNameFromRef(ref, pipeline) {
const nodes = pipeline?.nodes || [];
const node = nodes.find((n) => ref.startsWith(`${n.id}.`));
return node?.displayName || ref.split('.')[0];
}
/** Extract pipeline node id from a step output ref (`nodeId.output`). */
export function parseNodeRef(ref) {
if (!ref || typeof ref !== 'string') return null;
const m = ref.match(/^([^.]+)\.output$/);
return m ? m[1] : null;
}
/** Locale-aware display name compare (Chinese + numeric tie-break). */
export function compareNodeDisplayNames(a, b) {
const nameA = a?.displayName || '';
const nameB = b?.displayName || '';
return nameA.localeCompare(nameB, 'zh-CN', {
numeric: true,
sensitivity: 'base',
});
}
/**
* Topological layer sort by `inputs[].ref` dependencies.
* Same layer → display name (zh-CN localeCompare).
*/
export function sortNodesByLogic(pipeline) {
const nodes = pipeline?.nodes || [];
if (nodes.length <= 1) return [...nodes];
const edges = buildNodeDependencyEdges(pipeline);
const depth = Object.fromEntries(nodes.map((n) => [n.id, 0]));
const pipelineIndex = Object.fromEntries(nodes.map((n, i) => [n.id, i]));
let changed = true;
while (changed) {
changed = false;
edges.forEach(({ from, to }) => {
const next = (depth[from] || 0) + 1;
if (next > (depth[to] || 0)) {
depth[to] = next;
changed = true;
}
});
}
const layers = {};
nodes.forEach((n) => {
const d = depth[n.id] || 0;
if (!layers[d]) layers[d] = [];
layers[d].push(n);
});
const sorted = [];
Object.keys(layers)
.map(Number)
.sort((a, b) => a - b)
.forEach((d) => {
layers[d].sort((a, b) => {
const byName = compareNodeDisplayNames(a, b);
if (byName !== 0) return byName;
return (pipelineIndex[a.id] ?? 0) - (pipelineIndex[b.id] ?? 0);
});
sorted.push(...layers[d]);
});
return sorted;
}
/** Order node ids by manual pipeline order snapshot. */
export function orderNodesByIds(nodes, orderIds) {
const orderMap = Object.fromEntries((orderIds || []).map((id, i) => [id, i]));
return [...(nodes || [])].sort((a, b) => {
const ia = orderMap[a.id] ?? Number.MAX_SAFE_INTEGER;
const ib = orderMap[b.id] ?? Number.MAX_SAFE_INTEGER;
if (ia !== ib) return ia - ib;
return compareNodeDisplayNames(a, b);
});
}
/** Dependency edges derived from `inputs[].ref` between pipeline nodes. */
export function buildNodeDependencyEdges(pipeline) {
const nodes = pipeline?.nodes || [];
const nodeIds = new Set(nodes.map((n) => n.id));
const edges = [];
const edgeKeys = new Set();
nodes.forEach((node) => {
(node.inputs || []).forEach((inp) => {
const src = parseNodeRef(inp.ref);
if (!src || src === node.id || !nodeIds.has(src)) return;
const key = `${src}->${node.id}`;
if (edgeKeys.has(key)) return;
edgeKeys.add(key);
edges.push({ from: src, to: node.id });
});
});
return edges;
}
/** True if adding `fromNodeId → toNodeId` would close a dependency cycle. */
export function wouldCreateDependencyCycle(pipeline, toNodeId, fromNodeId) {
if (!toNodeId || !fromNodeId || toNodeId === fromNodeId) return true;
const edges = buildNodeDependencyEdges(pipeline);
const visited = new Set();
const stack = [toNodeId];
while (stack.length) {
const cur = stack.pop();
if (cur === fromNodeId) return true;
if (visited.has(cur)) continue;
visited.add(cur);
edges.filter((e) => e.from === cur).forEach((e) => stack.push(e.to));
}
return false;
}
/** Find dependency cycles in the pipeline (node id paths). */
export function detectReferenceCycles(pipeline) {
const nodes = pipeline?.nodes || [];
const nodeIds = nodes.map((n) => n.id);
const adj = Object.fromEntries(nodeIds.map((id) => [id, []]));
buildNodeDependencyEdges(pipeline).forEach(({ from, to }) => {
adj[from].push(to);
});
const cycles = [];
const visited = new Set();
const stack = new Set();
const path = [];
const dfs = (nodeId) => {
visited.add(nodeId);
stack.add(nodeId);
path.push(nodeId);
(adj[nodeId] || []).forEach((next) => {
if (!visited.has(next)) {
dfs(next);
} else if (stack.has(next)) {
const start = path.indexOf(next);
if (start >= 0) {
cycles.push([...path.slice(start), next]);
}
}
});
path.pop();
stack.delete(nodeId);
};
nodeIds.forEach((id) => {
if (!visited.has(id)) dfs(id);
});
return cycles;
}
export function formatCycleWarning(cycles, pipeline) {
if (!cycles.length) return null;
const nameOf = (id) =>
pipeline?.nodes?.find((n) => n.id === id)?.displayName || id;
const first = cycles[0];
const chain = first.map(nameOf).join(' → ');
return `检测到循环引用:${chain}`;
}
/** Worldbook steps that may be referenced without creating a cycle. */
export function getSelectableStepOutputRefs(pipeline, selectedNode) {
if (!selectedNode?.id) return [];
return (pipeline?.nodes || []).filter(
(n) =>
n.enabled !== false &&
n.skillId === 'studio.worldbook_entry' &&
n.id !== selectedNode.id &&
!wouldCreateDependencyCycle(pipeline, selectedNode.id, n.id)
);
}
export function buildStepOutputRef(nodeId) {
return `${nodeId}.output`;
}
export function stepOutputLabel(displayName) {
return `${displayName} · 世界书条目`;
}
export function dynamicChipLabel(ref, pipeline, fallbackLabel) {
const name = nodeDisplayNameFromRef(ref, pipeline);
if (name && name !== ref.split('.')[0]) return stepOutputLabel(name);
return fallbackLabel || stepOutputLabel(name);
}
export function dynamicTooltip(ref, pipeline) {
const name = nodeDisplayNameFromRef(ref, pipeline);
return `引用前序步骤「${name}」的最终世界书条目 · ${ref}`;
}
export const DEFAULT_THINKING_PROMPT = `====== 思考流程 ======
Step1: 简短确认任务性质(新设计/修改)
Step2: 阅读绑定角色/世界书与上文引用
Step3: 按步骤目标起草世界书条目
Step4: 对照评价维度自检并优化表述
(核心目的、评价标准与优化建议由系统在运行时自动注入,无需在此填写)`;
export const AUTO_INJECTED_CONTEXT_ITEMS = [
{
id: 'currentProduct',
label: '目前产物',
description:
'当前步骤已生成的世界书条目草稿或最新版本,供模型在迭代修改时对照与延续。',
},
{
id: 'thinkingFlow',
label: '思考流程',
description:
'本步骤配置的 thinkingPrompt引导模型按既定步骤推理与自检。',
},
{
id: 'coreGoal',
label: '核心目的',
description:
'本步骤的 stepGoal步骤目标明确本步要产出的内容与边界。',
},
{
id: 'scoringCriteria',
label: '评价标准与优化建议',
description:
'本步骤启用的 scoring 评价维度及准则,用于模型自检与优化表述。',
},
];
export const AUTO_INJECTED_CONTEXT_LABELS = AUTO_INJECTED_CONTEXT_ITEMS.map(
(item) => item.label
);
const NODE_W = 128;
const NODE_H = 52;
const GAP_X = 48;
const GAP_Y = 64;
const PAD = 24;
function verticalEdgePath(from, to) {
const x1 = from.x + NODE_W / 2;
const y1 = from.y + NODE_H;
const x2 = to.x + NODE_W / 2;
const y2 = to.y;
const dy = Math.max(24, (y2 - y1) * 0.45);
return `M ${x1} ${y1} C ${x1} ${y1 + dy}, ${x2} ${y2 - dy}, ${x2} ${y2}`;
}
/** Layout nodes as a vertical dependency tree for SVG progress graphs. */
export function buildGraphLayout(pipeline, nodeStates, options = {}) {
const vertical = options.vertical !== false;
const nodes = (pipeline?.nodes || []).filter((n) => n.enabled !== false);
const stateMap = Object.fromEntries(
(nodeStates || []).map((s) => [s.nodeId, s])
);
const pipelineIndex = Object.fromEntries(
nodes.map((n, i) => [n.id, i])
);
const edges = buildNodeDependencyEdges(pipeline);
const depth = {};
nodes.forEach((n) => {
depth[n.id] = 0;
});
let changed = true;
while (changed) {
changed = false;
edges.forEach(({ from, to }) => {
const next = (depth[from] || 0) + 1;
if (next > (depth[to] || 0)) {
depth[to] = next;
changed = true;
}
});
}
const layers = {};
nodes.forEach((n) => {
const d = depth[n.id] || 0;
if (!layers[d]) layers[d] = [];
layers[d].push(n);
});
Object.values(layers).forEach((layer) => {
layer.sort((a, b) => (pipelineIndex[a.id] ?? 0) - (pipelineIndex[b.id] ?? 0));
});
const positions = {};
Object.keys(layers)
.map(Number)
.sort((a, b) => a - b)
.forEach((d) => {
const layer = layers[d];
layer.forEach((node, col) => {
if (vertical) {
positions[node.id] = {
x: PAD + col * (NODE_W + GAP_X),
y: PAD + d * (NODE_H + GAP_Y),
};
} else {
positions[node.id] = {
x: PAD + d * (NODE_W + GAP_X),
y: PAD + col * (NODE_H + GAP_Y),
};
}
});
});
const maxLayer = Math.max(0, ...Object.keys(layers).map(Number));
const maxCols = Math.max(1, ...Object.values(layers).map((l) => l.length));
const width = vertical
? PAD * 2 + maxCols * NODE_W + (maxCols - 1) * GAP_X
: PAD * 2 + (maxLayer + 1) * NODE_W + maxLayer * GAP_X;
const height = vertical
? PAD * 2 + (maxLayer + 1) * NODE_H + maxLayer * GAP_Y
: PAD * 2 + maxCols * NODE_H + (maxCols - 1) * GAP_Y;
return {
nodes: nodes.map((n) => ({
id: n.id,
label: n.displayName,
status: stateMap[n.id]?.status || 'pending',
x: positions[n.id]?.x ?? PAD,
y: positions[n.id]?.y ?? PAD,
})),
edges,
width,
height,
nodeW: NODE_W,
nodeH: NODE_H,
edgePath: vertical ? verticalEdgePath : null,
};
}

View File

@@ -0,0 +1,37 @@
/**
* Backend workflow variable ref → Chinese display label.
* Mirrors data/agent/workflow_variables.json builtIn entries.
*/
export const BUILTIN_WORKFLOW_VARIABLE_LABELS = {
'workflow.goal': '工作流目标文本',
'workflow.boundWorldbook': '绑定世界书摘要',
'workflow.boundCharacter': '绑定角色卡摘要',
};
/** Build ref → label map from /api/studio/variables response. */
export function buildWorkflowVariableLabelMap(workflowVariables) {
const map = { ...BUILTIN_WORKFLOW_VARIABLE_LABELS };
const builtIn = workflowVariables?.builtIn || [];
const dynamic = workflowVariables?.dynamic || [];
[...builtIn, ...dynamic].forEach((item) => {
if (item?.ref && item?.label) {
map[item.ref] = item.label;
}
});
return map;
}
/**
* Resolve Chinese label for a workflow variable key shown in run preview.
* @param {string} ref - e.g. workflow.goal or nodeId.output
* @param {Record<string, string>} [labelMap] - optional map from buildWorkflowVariableLabelMap
*/
export function getWorkflowVariableLabel(ref, labelMap = BUILTIN_WORKFLOW_VARIABLE_LABELS) {
if (!ref) return '';
if (labelMap[ref]) return labelMap[ref];
const outputMatch = ref.match(/^([^.]+)\.output$/);
if (outputMatch) {
return `${outputMatch[1]} · 世界书条目`;
}
return ref;
}

View File

@@ -0,0 +1 @@
export { default } from './StudioPage';

View File

@@ -4,7 +4,8 @@ import './PageModeToggle.css';
const PAGE_MODES = [
{ id: 'chat', label: '聊天', icon: '💬' },
{ id: 'studio', label: '工作', icon: '🎭' },
{ id: 'studio_edit', label: '工作流编辑', icon: '✏️' },
{ id: 'studio_run', label: '创作运行', icon: '🚀' },
{ id: 'novel', label: '爽文', icon: '📖' },
{ id: 'room', label: '房间', icon: '🏠' },
];
@@ -36,7 +37,10 @@ const PageModeToggle = () => {
setIsOpen(false);
};
const currentMode = PAGE_MODES.find((m) => m.id === activePage) || PAGE_MODES[0];
const resolvedPage = activePage === 'studio' ? 'studio_edit' : activePage;
const currentMode =
PAGE_MODES.find((m) => m.id === resolvedPage) || PAGE_MODES[0];
return (
<div className="page-mode-toggle-wrapper" ref={panelRef}>
@@ -61,8 +65,8 @@ const PageModeToggle = () => {
key={mode.id}
type="button"
role="option"
aria-selected={activePage === mode.id}
className={`page-mode-item ${activePage === mode.id ? 'active' : ''}`}
aria-selected={resolvedPage === mode.id}
className={`page-mode-item ${resolvedPage === mode.id ? 'active' : ''}`}
onClick={() => handleModeSelect(mode.id)}
>
<span className="page-mode-item-icon">{mode.icon}</span>

View File

@@ -27,10 +27,16 @@
margin-top: 0; /* Ensure panels start from top */
}
.main-container.placeholder-container {
.main-container.placeholder-container,
.main-container.studio-container {
flex-direction: column;
}
.main-container.studio-container {
display: flex;
min-height: 0;
}
/* Smooth transitions for theme changes - only apply to specific properties */
.app {
transition: background-color var(--transition-normal),

15
scripts/docker-logs.ps1 Normal file
View File

@@ -0,0 +1,15 @@
# 跟踪 Docker Compose 日志
param(
[ValidateSet("backend", "frontend", "")]
[string]$Service = ""
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
Set-Location $Root
if ($Service) {
docker compose logs -f $Service
} else {
docker compose logs -f
}

View File

@@ -0,0 +1,17 @@
# 重新构建并启动指定服务(依赖或 Dockerfile 变更时使用)
param(
[Parameter(Mandatory = $true)]
[ValidateSet("backend", "frontend")]
[string]$Service
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
Set-Location $Root
Write-Host "Rebuilding and starting $Service..." -ForegroundColor Cyan
docker compose up -d --build $Service
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "Done. $Service rebuilt and running." -ForegroundColor Green

View File

@@ -0,0 +1,21 @@
# 重启容器backend / frontend / all无需重启 Docker Desktop
param(
[ValidateSet("backend", "frontend", "all")]
[string]$Service = "all"
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
Set-Location $Root
if ($Service -eq "all") {
Write-Host "Restarting backend and frontend..." -ForegroundColor Cyan
docker compose restart backend frontend
} else {
Write-Host "Restarting $Service..." -ForegroundColor Cyan
docker compose restart $Service
}
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "Done. Docker Desktop was NOT restarted." -ForegroundColor Green

16
scripts/docker-up.ps1 Normal file
View File

@@ -0,0 +1,16 @@
# 后台启动 Docker Compose 栈(不重启 Docker Desktop
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
Set-Location $Root
Write-Host "Starting services..." -ForegroundColor Cyan
docker compose up -d
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host ""
Write-Host "Services running:" -ForegroundColor Green
Write-Host " Backend: http://localhost:23337"
Write-Host " Frontend: http://localhost:23338"
Write-Host ""
Write-Host "Logs: .\scripts\docker-logs.ps1"