From fa6907fb8dbfe8e4020e6ce3426c78d283697e45 Mon Sep 17 00:00:00 2001 From: moranzhi Date: Sun, 31 May 2026 21:24:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(studio):=20=E6=96=B0=E5=A2=9E=20Studio=20?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E7=BC=96=E8=BE=91/=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E9=A1=B5=EF=BC=8C=E4=BC=98=E5=8C=96=E9=A1=B6=E9=83=A8?= =?UTF-8?q?=E4=B8=89=E6=A0=8F=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:项目/运行 API、上下文服务与数据模型 - 前端:Studio 列表、编辑页(R1/R2 布局)、运行页与节点图 - 编辑页顶部:CSS Grid 统一标签行与控件行对齐,项目按钮独立第三行 - Docker 开发配置与文档脚本 Co-authored-by: Cursor --- README.md | 33 +- backend/api/route.py | 3 +- backend/api/routes/studioRoute.py | 217 +++ backend/core/config.py | 7 + backend/models/studio_models.py | 216 +++ backend/services/studio_context_service.py | 188 +++ backend/services/studio_project_service.py | 456 +++++++ backend/services/studio_run_service.py | 322 +++++ data/agent/niches.json | 16 + data/agent/skill_templates.json | 58 + data/agent/studio_projects/default/meta.json | 10 + .../studio_projects/default/pipeline.json | 122 ++ .../builtin.studio.example/meta.json | 6 + .../builtin.studio.example/pipeline.json | 104 ++ data/agent/workflow_variables.json | 56 + docker-compose.dev.yml | 10 + docker-compose.yml | 5 +- docs/DOCKER_DEV.md | 227 ++++ frontend/src/App.jsx | 25 + frontend/src/Store/AppLayoutSlice.jsx | 14 +- frontend/src/Store/Studio/StudioSlice.jsx | 594 +++++++++ .../Studio/StudioContextBlockPopup.css | 18 + .../Studio/StudioContextBlockPopup.jsx | 33 + .../src/components/Studio/StudioEditPage.css | 1184 +++++++++++++++++ .../src/components/Studio/StudioEditPage.jsx | 795 +++++++++++ .../Studio/StudioInsertionPopup.css | 107 ++ .../Studio/StudioInsertionPopup.jsx | 95 ++ frontend/src/components/Studio/StudioPage.css | 70 + frontend/src/components/Studio/StudioPage.jsx | 34 + .../src/components/Studio/StudioRunChat.css | 240 ++++ .../src/components/Studio/StudioRunChat.jsx | 171 +++ .../components/Studio/StudioRunNodeGraph.css | 89 ++ .../components/Studio/StudioRunNodeGraph.jsx | 233 ++++ .../src/components/Studio/StudioRunPage.css | 1164 ++++++++++++++++ .../src/components/Studio/StudioRunPage.jsx | 1012 ++++++++++++++ .../src/components/Studio/edit/FieldLabel.jsx | 15 + .../Studio/edit/ScoringDimensions.jsx | 83 ++ .../components/Studio/edit/VariableChips.jsx | 146 ++ .../Studio/edit/WorldbookInsertion.jsx | 163 +++ .../components/Studio/edit/variableUtils.js | 347 +++++ .../Studio/edit/workflowVariableLabels.js | 37 + frontend/src/components/Studio/index.js | 1 + .../items/PageModeToggle/PageModeToggle.jsx | 12 +- frontend/src/index.css | 8 +- scripts/docker-logs.ps1 | 15 + scripts/docker-rebuild.ps1 | 17 + scripts/docker-restart.ps1 | 21 + scripts/docker-up.ps1 | 16 + 48 files changed, 8795 insertions(+), 20 deletions(-) create mode 100644 backend/api/routes/studioRoute.py create mode 100644 backend/models/studio_models.py create mode 100644 backend/services/studio_context_service.py create mode 100644 backend/services/studio_project_service.py create mode 100644 backend/services/studio_run_service.py create mode 100644 data/agent/niches.json create mode 100644 data/agent/skill_templates.json create mode 100644 data/agent/studio_projects/default/meta.json create mode 100644 data/agent/studio_projects/default/pipeline.json create mode 100644 data/agent/templates/builtin.studio.example/meta.json create mode 100644 data/agent/templates/builtin.studio.example/pipeline.json create mode 100644 data/agent/workflow_variables.json create mode 100644 docker-compose.dev.yml create mode 100644 docs/DOCKER_DEV.md create mode 100644 frontend/src/Store/Studio/StudioSlice.jsx create mode 100644 frontend/src/components/Studio/StudioContextBlockPopup.css create mode 100644 frontend/src/components/Studio/StudioContextBlockPopup.jsx create mode 100644 frontend/src/components/Studio/StudioEditPage.css create mode 100644 frontend/src/components/Studio/StudioEditPage.jsx create mode 100644 frontend/src/components/Studio/StudioInsertionPopup.css create mode 100644 frontend/src/components/Studio/StudioInsertionPopup.jsx create mode 100644 frontend/src/components/Studio/StudioPage.css create mode 100644 frontend/src/components/Studio/StudioPage.jsx create mode 100644 frontend/src/components/Studio/StudioRunChat.css create mode 100644 frontend/src/components/Studio/StudioRunChat.jsx create mode 100644 frontend/src/components/Studio/StudioRunNodeGraph.css create mode 100644 frontend/src/components/Studio/StudioRunNodeGraph.jsx create mode 100644 frontend/src/components/Studio/StudioRunPage.css create mode 100644 frontend/src/components/Studio/StudioRunPage.jsx create mode 100644 frontend/src/components/Studio/edit/FieldLabel.jsx create mode 100644 frontend/src/components/Studio/edit/ScoringDimensions.jsx create mode 100644 frontend/src/components/Studio/edit/VariableChips.jsx create mode 100644 frontend/src/components/Studio/edit/WorldbookInsertion.jsx create mode 100644 frontend/src/components/Studio/edit/variableUtils.js create mode 100644 frontend/src/components/Studio/edit/workflowVariableLabels.js create mode 100644 frontend/src/components/Studio/index.js create mode 100644 scripts/docker-logs.ps1 create mode 100644 scripts/docker-rebuild.ps1 create mode 100644 scripts/docker-restart.ps1 create mode 100644 scripts/docker-up.ps1 diff --git a/README.md b/README.md index 3fc18bf..6f8e0d5 100644 --- a/README.md +++ b/README.md @@ -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 +``` --- diff --git a/backend/api/route.py b/backend/api/route.py index 9434b12..9473174 100644 --- a/backend/api/route.py +++ b/backend/api/route.py @@ -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) diff --git a/backend/api/routes/studioRoute.py b/backend/api/routes/studioRoute.py new file mode 100644 index 0000000..7a5b2de --- /dev/null +++ b/backend/api/routes/studioRoute.py @@ -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)) diff --git a/backend/core/config.py b/backend/core/config.py index 639b887..2372ec2 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -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) diff --git a/backend/models/studio_models.py b/backend/models/studio_models.py new file mode 100644 index 0000000..4e5bb07 --- /dev/null +++ b/backend/models/studio_models.py @@ -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 = "" diff --git a/backend/services/studio_context_service.py b/backend/services/studio_context_service.py new file mode 100644 index 0000000..d1d7f9a --- /dev/null +++ b/backend/services/studio_context_service.py @@ -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}) diff --git a/backend/services/studio_project_service.py b/backend/services/studio_project_service.py new file mode 100644 index 0000000..4bcc88f --- /dev/null +++ b/backend/services/studio_project_service.py @@ -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) diff --git a/backend/services/studio_run_service.py b/backend/services/studio_run_service.py new file mode 100644 index 0000000..b13c277 --- /dev/null +++ b/backend/services/studio_run_service.py @@ -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() diff --git a/data/agent/niches.json b/data/agent/niches.json new file mode 100644 index 0000000..93fccf9 --- /dev/null +++ b/data/agent/niches.json @@ -0,0 +1,16 @@ +{ + "niches": [ + { + "id": "aesthetic_tone", + "label": "整体美学", + "description": "视觉、氛围、叙事基调等宏观美学设定", + "suggestedStepGoal": "描述角色的整体美学:色调、材质、氛围、叙事基调,供后续人设与世界书条目引用。" + }, + { + "id": "persona_detail", + "label": "具体人设", + "description": "性格、口癖、关系、行为模式等可扮演细节", + "suggestedStepGoal": "在整体美学基础上,细化可扮演的人设:性格、动机、口癖、与他人关系。" + } + ] +} diff --git a/data/agent/skill_templates.json b/data/agent/skill_templates.json new file mode 100644 index 0000000..745daec --- /dev/null +++ b/data/agent/skill_templates.json @@ -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 + } + ] +} diff --git a/data/agent/studio_projects/default/meta.json b/data/agent/studio_projects/default/meta.json new file mode 100644 index 0000000..5650869 --- /dev/null +++ b/data/agent/studio_projects/default/meta.json @@ -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" +} \ No newline at end of file diff --git a/data/agent/studio_projects/default/pipeline.json b/data/agent/studio_projects/default/pipeline.json new file mode 100644 index 0000000..f97018c --- /dev/null +++ b/data/agent/studio_projects/default/pipeline.json @@ -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 + } + ] + } + ] +} \ No newline at end of file diff --git a/data/agent/templates/builtin.studio.example/meta.json b/data/agent/templates/builtin.studio.example/meta.json new file mode 100644 index 0000000..5e68f32 --- /dev/null +++ b/data/agent/templates/builtin.studio.example/meta.json @@ -0,0 +1,6 @@ +{ + "id": "builtin.studio.example", + "name": "世界书条目创建", + "description": "从创建绑定到世界书条目的默认三步流水线,可在工作流编辑页复制并修改。", + "version": "1.0.0" +} diff --git a/data/agent/templates/builtin.studio.example/pipeline.json b/data/agent/templates/builtin.studio.example/pipeline.json new file mode 100644 index 0000000..e3cc281 --- /dev/null +++ b/data/agent/templates/builtin.studio.example/pipeline.json @@ -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": "整体美学 · 世界书条目" + } + ] + } + ] +} diff --git a/data/agent/workflow_variables.json b/data/agent/workflow_variables.json new file mode 100644 index 0000000..f9fa5ee --- /dev/null +++ b/data/agent/workflow_variables.json @@ -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 评价维度及准则,用于模型自检与优化表述。" + } + ] +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..54d3d86 --- /dev/null +++ b/docker-compose.dev.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 184c8c5..189a140 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 volume;package.json 变更见 docs/DOCKER_DEV.md + command: npm run dev -- --host 0.0.0.0 depends_on: backend: condition: service_healthy diff --git a/docs/DOCKER_DEV.md b/docs/DOCKER_DEV.md new file mode 100644 index 0000000..3f21dbb --- /dev/null +++ b/docs/DOCKER_DEV.md @@ -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 ` | ❌ 不需要 | +| 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`。** diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 97d870c..8ff951b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 (
{/* ✅ TopBar 不再需要 props,直接从 Store 读取状态 */} @@ -218,6 +235,14 @@ function App() {
+ ) : activePage === 'studio_edit' ? ( +
+ +
+ ) : activePage === 'studio_run' ? ( +
+ +
) : (
diff --git a/frontend/src/Store/AppLayoutSlice.jsx b/frontend/src/Store/AppLayoutSlice.jsx index 875d921..fe3b3d8 100644 --- a/frontend/src/Store/AppLayoutSlice.jsx +++ b/frontend/src/Store/AppLayoutSlice.jsx @@ -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, diff --git a/frontend/src/Store/Studio/StudioSlice.jsx b/frontend/src/Store/Studio/StudioSlice.jsx new file mode 100644 index 0000000..79617cd --- /dev/null +++ b/frontend/src/Store/Studio/StudioSlice.jsx @@ -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; diff --git a/frontend/src/components/Studio/StudioContextBlockPopup.css b/frontend/src/components/Studio/StudioContextBlockPopup.css new file mode 100644 index 0000000..881b10a --- /dev/null +++ b/frontend/src/components/Studio/StudioContextBlockPopup.css @@ -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; +} diff --git a/frontend/src/components/Studio/StudioContextBlockPopup.jsx b/frontend/src/components/Studio/StudioContextBlockPopup.jsx new file mode 100644 index 0000000..acadac5 --- /dev/null +++ b/frontend/src/components/Studio/StudioContextBlockPopup.jsx @@ -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 ( + + {({ expanded }) => + expanded ? ( +
+            {block.content}
+          
+ ) : ( +
+ 来源 + {block.source} +
+ ) + } +
+ ); +} + +export default StudioContextBlockPopup; diff --git a/frontend/src/components/Studio/StudioEditPage.css b/frontend/src/components/Studio/StudioEditPage.css new file mode 100644 index 0000000..cb046f3 --- /dev/null +++ b/frontend/src/components/Studio/StudioEditPage.css @@ -0,0 +1,1184 @@ +.studio-edit-page { + --studio-bp-wide: 1280px; + --studio-bp-ultra: 1600px; + + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.studio-edit-loading { + padding: var(--spacing-xl); + color: var(--color-text-secondary); +} + +.studio-edit-top { + --studio-top-label-h: 1.25rem; + --studio-top-control-h: 2.75rem; + + display: grid; + grid-template-columns: minmax(200px, 240px) minmax(280px, 1fr) minmax(160px, 220px); + grid-template-rows: var(--studio-top-label-h) var(--studio-top-control-h) auto; + grid-template-areas: + 'label-project label-goal label-desc' + 'ctrl-project ctrl-goal ctrl-desc' + 'actions . .'; + align-items: stretch; + gap: 6px var(--spacing-md); + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--color-border-light); + background: var(--color-bg-secondary); + flex-shrink: 0; +} + +@media (max-width: 960px) { + .studio-edit-top { + grid-template-columns: 1fr 1fr; + grid-template-rows: auto; + grid-template-areas: + 'label-project label-project' + 'ctrl-project ctrl-project' + 'actions actions' + 'label-goal label-goal' + 'ctrl-goal ctrl-goal' + 'label-desc label-desc' + 'ctrl-desc ctrl-desc'; + } +} + +.studio-edit-top__label { + display: flex; + align-items: center; + min-height: var(--studio-top-label-h); + min-width: 0; + font-size: 0.72rem; + font-weight: 500; + color: var(--color-text-muted); + line-height: 1.2; +} + +.studio-edit-top__label--project { + grid-area: label-project; +} + +.studio-edit-top__label--goal { + grid-area: label-goal; +} + +.studio-edit-top__label--desc { + grid-area: label-desc; +} + +.studio-edit-top__label-text { + font: inherit; + color: inherit; +} + +.studio-edit-top__label .studio-field-label { + font: inherit; + color: inherit; +} + +.studio-edit-top__control { + display: flex; + align-items: stretch; + min-width: 0; +} + +.studio-edit-top__control--project { + grid-area: ctrl-project; +} + +.studio-edit-top__control--goal { + grid-area: ctrl-goal; +} + +.studio-edit-top__control--desc { + grid-area: ctrl-desc; +} + +.studio-edit-top__actions { + grid-area: actions; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-xs); + padding-top: 2px; +} + +.studio-edit-select--project { + width: 100%; + min-height: var(--studio-top-control-h); + box-sizing: border-box; +} + +.studio-edit-textarea--goal { + width: 100%; + min-height: var(--studio-top-control-h); + max-height: 5.5rem; + resize: vertical; + box-sizing: border-box; + line-height: 1.45; +} + +.studio-edit-input--desc { + width: 100%; + min-height: var(--studio-top-control-h); + box-sizing: border-box; +} + +.studio-edit-label { + display: flex; + align-items: center; + gap: var(--spacing-sm); + font-size: 0.85rem; + color: var(--color-text-secondary); +} + +.studio-edit-label-block { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + font-size: 0.85rem; + color: var(--color-text-secondary); +} + +.studio-edit-select, +.studio-edit-input, +.studio-edit-textarea { + padding: var(--spacing-sm); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background: var(--color-bg-tertiary); + color: var(--color-text-primary); + font-family: inherit; + font-size: 0.875rem; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; +} + +.studio-edit-select:focus, +.studio-edit-input:focus, +.studio-edit-textarea:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 2px var(--color-accent-light); +} + +.studio-edit-textarea { + resize: vertical; + width: 100%; +} + +.studio-edit-textarea--compact { + min-height: 4rem; +} + +.studio-edit-meta-hint { + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.studio-edit-banner { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-sm) var(--spacing-lg); + font-size: 0.85rem; +} + +.studio-edit-banner.success { + background: rgba(16, 185, 129, 0.12); + color: var(--color-success); +} + +.studio-edit-banner.error { + background: var(--color-danger-light); + color: var(--color-error); +} + +.studio-edit-banner-close { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: 1.1rem; +} + +.studio-edit-main { + flex: 1; + display: grid; + grid-template-columns: minmax(260px, 320px) 1fr; + min-height: 0; + overflow: hidden; +} + +@media (min-width: 1600px) { + .studio-edit-main { + grid-template-columns: minmax(280px, 340px) 1fr; + } +} + +.studio-edit-nodes { + border-right: 1px solid var(--color-border-light); + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + background: var(--color-bg-subtle); +} + +.studio-edit-nodes-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-sm); + padding: var(--spacing-md); + flex-shrink: 0; +} + +.studio-edit-nodes-header__actions { + display: flex; + align-items: center; + gap: var(--spacing-xs); + flex-shrink: 0; +} + +.studio-edit-sort-toggle { + display: inline-flex; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + overflow: hidden; + background: var(--color-bg-secondary); +} + +.studio-edit-sort-btn { + padding: 3px 8px; + border: none; + background: transparent; + color: var(--color-text-muted); + font-size: 0.72rem; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} + +.studio-edit-sort-btn + .studio-edit-sort-btn { + border-left: 1px solid var(--color-border); +} + +.studio-edit-sort-btn:hover { + color: var(--color-text-primary); + background: var(--color-bg-tertiary); +} + +.studio-edit-sort-btn.active { + background: var(--color-accent-light); + color: var(--color-accent); + font-weight: 600; +} + +.studio-edit-subtitle { + margin: 0; + font-size: 0.9rem; + font-weight: 600; +} + +.studio-edit-add-node { + padding: 0 var(--spacing-md) var(--spacing-md); + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.studio-edit-node-list { + list-style: none; + margin: 0; + padding: var(--spacing-sm); + overflow-y: auto; + flex: 1; +} + +.studio-edit-node-item { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + margin-bottom: var(--spacing-xs); + border-radius: var(--radius-md); + border: 1px solid transparent; + border-left: 3px solid transparent; + cursor: pointer; + background: var(--color-bg-secondary); + transition: + border-color var(--transition-fast, 0.15s), + background var(--transition-fast, 0.15s); +} + +.studio-edit-node-item:hover { + border-color: var(--color-border); + background: var(--color-bg-tertiary); +} + +.studio-edit-node-item.selected { + border-left-color: var(--color-accent); + border-color: var(--color-border); + background: var(--color-accent-light); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); +} + +.studio-edit-node-item.disabled { + opacity: 0.55; +} + +.studio-edit-drag-handle { + cursor: grab; + color: var(--color-text-muted); + font-size: 0.75rem; + user-select: none; + flex-shrink: 0; + width: 1rem; + text-align: center; +} + +.studio-edit-drag-handle--muted { + cursor: default; + opacity: 0.45; +} + +.studio-edit-node-item.no-drag { + cursor: pointer; +} + +.studio-edit-node-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.studio-edit-node-title { + font-weight: 500; + font-size: 0.875rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.studio-edit-node-skill { + font-size: 0.7rem; + color: var(--color-text-muted); +} + +.studio-edit-switch { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.7rem; + color: var(--color-text-muted); + flex-shrink: 0; +} + +/* —— Right detail panel —— */ + +.studio-edit-detail { + overflow-y: auto; + min-height: 0; + background: var(--color-bg-primary); +} + +.studio-edit-detail:has(> .studio-edit-empty) { + display: flex; + align-items: center; + justify-content: center; +} + +.studio-edit-detail-inner { + box-sizing: border-box; + width: 100%; + max-width: min(1400px, 100%); + margin: 0 auto; + padding: var(--spacing-md) var(--spacing-lg) var(--spacing-xl); +} + +/* Top meta strip — display name, template, niche (full width above body) */ + +.studio-edit-meta-strip { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-sm) var(--spacing-md); + margin-bottom: var(--spacing-md); + padding-bottom: var(--spacing-md); + border-bottom: 1px solid var(--color-border-light); +} + +.studio-edit-meta-strip__field { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + flex: 1 1 160px; + max-width: 280px; +} + +.studio-edit-meta-strip__hint { + flex: 1 1 100%; + font-size: 0.72rem; + color: var(--color-text-muted); +} + +/* Primary center + edge rail (n8n / Retool / Figma pattern) */ + +.studio-edit-detail-body { + display: flex; + gap: var(--spacing-lg); + align-items: flex-start; +} + +.studio-edit-primary { + flex: 1; + min-width: 0; +} + +.studio-edit-edge-rail { + width: 300px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +@media (max-width: 1280px) and (min-width: 1101px) { + .studio-edit-edge-rail { + width: 280px; + } +} + +@media (max-width: 1100px) { + .studio-edit-detail-body { + flex-direction: column; + } + + .studio-edit-edge-rail { + width: 100%; + } +} + +@media (max-width: 1100px) and (min-width: 480px) { + .studio-edit-edge-rail { + flex-direction: row; + flex-wrap: wrap; + } + + .studio-edit-edge-rail .studio-edit-edge-card { + flex: 1 1 280px; + } +} + +.studio-edit-primary-card { + position: relative; + padding: var(--spacing-md) var(--spacing-lg); + border-radius: var(--radius-md); + border: 1px solid var(--color-border-light); + border-left: 3px solid var(--color-accent); + background: var(--color-bg-secondary); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +.studio-edit-primary-card__badge { + display: inline-block; + margin-bottom: var(--spacing-md); + padding: 2px var(--spacing-sm); + border-radius: var(--radius-sm, 4px); + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--color-accent); + background: var(--color-accent-light); +} + +.studio-edit-edge-card { + padding: var(--spacing-md); + border-radius: var(--radius-md); + border: 1px solid var(--color-border-light); + background: var(--color-bg-subtle, var(--color-bg-secondary)); +} + +.studio-edit-edge-card--empty { + display: flex; + align-items: center; + justify-content: center; + min-height: 5rem; +} + +.studio-edit-edge-empty-tip { + margin: 0; + font-size: 0.78rem; + color: var(--color-text-muted); + text-align: center; + line-height: 1.4; +} + +.studio-edit-field--primary { + margin-bottom: var(--spacing-md); +} + +.studio-edit-field-label--primary { + font-size: 0.8rem; + font-weight: 600; + color: var(--color-text-secondary); +} + +.studio-edit-textarea--primary { + min-height: 7rem; + font-size: 0.9rem; + line-height: 1.5; +} + +/* FieldLabel — dotted underline + native title tooltip */ + +.studio-field-label { + display: inline; + font: inherit; + color: inherit; +} + +.studio-field-label__text--tip { + border-bottom: 1px dotted var(--color-text-muted); + cursor: help; +} + +.studio-edit-dp-list--compact { + margin-top: var(--spacing-sm); + font-size: 0.82rem; +} + +/* Edge rail: variable chips compact */ + +.studio-var-panel__title, +.studio-wb-insert__title, +.studio-scoring-block__title { + margin: 0 0 var(--spacing-sm); + font-size: 0.82rem; + font-weight: 600; + color: var(--color-text-secondary); +} + +.studio-var-panel--compact .studio-var-group__title { + font-size: 0.72rem; +} + +.studio-var-grid--compact { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.studio-var-panel--compact .studio-var-chip__btn { + font-size: 0.72rem; + padding: 3px var(--spacing-xs); +} + +.studio-wb-insert__fields { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.studio-scoring-block { + margin-top: var(--spacing-md); + padding-top: var(--spacing-md); + border-top: 1px solid var(--color-border-light); +} + +.studio-scoring-block__title { + font-size: 0.85rem; + color: var(--color-text-primary); +} + +.studio-edit-empty { + margin: 0; + padding: var(--spacing-lg); + color: var(--color-text-muted); + font-size: 0.9rem; + text-align: center; +} + +.studio-edit-section { + padding-bottom: var(--spacing-md); + margin-bottom: var(--spacing-md); + border-bottom: 1px solid var(--color-border-light); +} + +.studio-edit-section:last-of-type { + border-bottom: none; +} + +.studio-edit-section-head { + display: flex; + align-items: flex-start; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); +} + +.studio-edit-section-icon { + flex-shrink: 0; + width: 1.5rem; + height: 1.5rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.9rem; + opacity: 0.85; +} + +.studio-edit-section-title { + margin: 0; + font-size: 0.9rem; + font-weight: 600; + color: var(--color-text-primary); +} + +.studio-edit-section-sub { + display: none; +} + +.studio-edit-form-grid { + display: grid; + gap: var(--spacing-sm) var(--spacing-md); +} + +.studio-edit-form-grid--2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +@media (max-width: 520px) { + .studio-edit-form-grid--2 { + grid-template-columns: 1fr; + } +} + +.studio-edit-field { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.studio-edit-field--full { + grid-column: 1 / -1; +} + +.studio-edit-field-label { + font-size: 0.72rem; + font-weight: 500; + color: var(--color-text-muted); +} + +.studio-edit-hint { + margin: 0 0 var(--spacing-sm); + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.studio-edit-hint--inline { + margin: var(--spacing-xs) 0 0; +} + +.studio-edit-hint-muted { + margin-top: var(--spacing-sm); + font-style: italic; +} + +.studio-edit-switch-row { + display: flex; + align-items: center; + gap: var(--spacing-sm); + margin: var(--spacing-sm) 0; + font-size: 0.85rem; +} + +.studio-edit-switch-row--compact { + margin: 0 0 var(--spacing-sm); +} + +/* Variable chips */ + +.studio-var-group { + margin-bottom: var(--spacing-sm); +} + +.studio-var-group:last-child { + margin-bottom: 0; +} + +.studio-var-group__title { + margin: 0 0 2px; + font-size: 0.78rem; + font-weight: 600; + color: var(--color-text-secondary); +} + +.studio-var-group--auto { + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius-sm); + background: rgba(var(--color-accent-rgb, 59, 130, 246), 0.06); + border: 1px dashed var(--color-border-light); +} + +.studio-var-auto-hint { + margin: 0; + font-size: 0.72rem; + line-height: 1.45; +} + +.studio-var-manual-hint { + margin: 0 0 var(--spacing-xs); + font-size: 0.68rem; + line-height: 1.4; + color: var(--color-text-muted); +} + +.studio-var-cycle-warn { + margin: 0 0 var(--spacing-xs); + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius-sm); + background: rgba(220, 53, 69, 0.1); + color: #dc3545; + font-size: 0.7rem; + line-height: 1.4; +} + +.studio-var-auto-toggle { + display: flex; + align-items: center; + gap: var(--spacing-xs); + width: 100%; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + text-align: left; + font-family: inherit; +} + +.studio-var-auto-toggle__meta { + margin-left: auto; + font-size: 0.68rem; + color: var(--color-text-muted); + font-weight: 400; +} + +.studio-var-auto-toggle__chevron { + font-size: 0.65rem; + color: var(--color-text-muted); +} + +.studio-var-auto-list { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-xs); + margin-top: var(--spacing-xs); +} + +.studio-var-auto-chip { + position: relative; + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--color-border-light); + background: var(--color-bg-secondary); + font-size: 0.68rem; + cursor: default; +} + +.studio-var-auto-chip__label { + color: var(--color-text-primary); +} + +.studio-var-auto-chip__popover { + position: absolute; + left: 50%; + bottom: calc(100% + 6px); + transform: translateX(-50%); + z-index: 20; + width: max-content; + max-width: 220px; + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius-sm); + border: 1px solid var(--color-border-light); + background: var(--color-bg-elevated, #fff); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + font-size: 0.68rem; + line-height: 1.45; + color: var(--color-text-secondary); + pointer-events: none; +} + +.studio-var-switch { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-sm); + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius-md); + border: 1px solid var(--color-border-light); + background: var(--color-bg-secondary); + cursor: pointer; + min-width: 0; +} + +.studio-var-switch--on { + border-color: var(--color-accent); + background: var(--color-accent-light); +} + +.studio-var-switch__label { + flex: 1; + min-width: 0; + font-size: 0.78rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.studio-var-switch__track { + position: relative; + flex-shrink: 0; + width: 34px; + height: 18px; + border-radius: 999px; + background: var(--color-border); + transition: background 0.15s ease; +} + +.studio-var-switch--on .studio-var-switch__track { + background: var(--color-accent); +} + +.studio-var-switch__input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.studio-var-switch__thumb { + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + transition: transform 0.15s ease; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); +} + +.studio-var-switch--on .studio-var-switch__thumb { + transform: translateX(16px); +} + +.studio-edit-think-default { + margin-top: var(--spacing-xs); +} + +.studio-var-group__sub { + display: none; +} + +.studio-var-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--spacing-xs); +} + +@media (max-width: 480px) { + .studio-var-grid { + grid-template-columns: 1fr; + } +} + +@media (min-width: 1280px) { + .studio-var-grid:not(.studio-var-grid--compact) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (min-width: 1600px) { + .studio-var-grid:not(.studio-var-grid--compact) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +.studio-var-empty { + grid-column: 1 / -1; + margin: 0; +} + +.studio-var-chip { + display: flex; + align-items: stretch; + min-width: 0; + border-radius: var(--radius-md); + border: 1px solid var(--color-border-light); + background: var(--color-bg-secondary); + overflow: hidden; + transition: + border-color var(--transition-fast, 0.15s), + background var(--transition-fast, 0.15s); +} + +.studio-var-chip--on { + border-color: var(--color-accent); + background: var(--color-accent-light); +} + +.studio-var-chip__btn { + flex: 1; + min-width: 0; + padding: var(--spacing-xs) var(--spacing-sm); + border: none; + background: transparent; + color: var(--color-text-primary); + font-size: 0.8rem; + font-family: inherit; + text-align: left; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.studio-var-chip__btn:hover { + color: var(--color-accent); +} + +.studio-var-chip--on .studio-var-chip__btn { + font-weight: 500; +} + +.studio-var-chip__opt { + flex-shrink: 0; + padding: 0 var(--spacing-xs); + border: none; + border-left: 1px solid var(--color-border-light); + background: var(--color-bg-tertiary); + font-size: 0.65rem; + color: var(--color-text-muted); + cursor: pointer; +} + +.studio-var-chip__opt--on { + color: var(--color-accent); + background: transparent; +} + +/* Scoring dimensions */ + +.studio-dim-list { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); +} + +.studio-dim-card { + display: flex; + gap: var(--spacing-sm); + align-items: flex-start; + padding: var(--spacing-sm); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); + background: var(--color-bg-secondary); +} + +.studio-dim-card__fields { + flex: 1; + display: grid; + grid-template-columns: minmax(100px, 140px) 1fr; + gap: var(--spacing-sm); + min-width: 0; +} + +@media (max-width: 560px) { + .studio-dim-card__fields { + grid-template-columns: 1fr; + } +} + +@media (min-width: 1280px) { + .studio-dim-card__fields { + grid-template-columns: minmax(120px, 160px) 1fr; + } +} + +.studio-dim-card__remove { + flex-shrink: 0; + padding: 4px var(--spacing-xs); + border: none; + background: none; + color: var(--color-danger); + font-size: 0.72rem; + cursor: pointer; +} + +.studio-dim-card__remove:hover { + text-decoration: underline; +} + +.studio-edit-btn-ghost { + padding: var(--spacing-xs) var(--spacing-sm); + border: 1px dashed var(--color-border); + border-radius: var(--radius-md); + background: transparent; + color: var(--color-text-muted); + font-size: 0.78rem; + cursor: pointer; +} + +.studio-edit-btn-ghost:hover { + border-color: var(--color-accent); + color: var(--color-accent); +} + +.studio-edit-detail-footer { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-sm); + padding-top: var(--spacing-md); + margin-top: var(--spacing-sm); + border-top: 1px solid var(--color-border-light); +} + +.studio-edit-dp-list { + margin: 0; + padding-left: var(--spacing-lg); + font-size: 0.85rem; +} + +.studio-edit-footer { + display: flex; + gap: var(--spacing-sm); + padding: var(--spacing-md) var(--spacing-lg); + border-top: 1px solid var(--color-border-light); + background: var(--color-bg-secondary); + flex-shrink: 0; +} + +.studio-edit-btn { + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background: var(--color-bg-tertiary); + color: var(--color-text-primary); + cursor: pointer; + font-size: 0.85rem; +} + +.studio-edit-btn:hover:not(:disabled) { + border-color: var(--color-accent); + color: var(--color-accent); +} + +.studio-edit-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.studio-edit-btn-primary { + background: var(--color-accent); + border-color: var(--color-accent); + color: #fff; +} + +.studio-edit-btn-primary:hover:not(:disabled) { + background: var(--color-accent-hover); + color: #fff; +} + +.studio-edit-btn-sm { + padding: 4px var(--spacing-sm); + font-size: 0.75rem; +} + +.studio-edit-btn-danger { + border-color: var(--color-danger); + color: var(--color-danger); +} + +.studio-edit-preview { + margin: 0 var(--spacing-lg) var(--spacing-md); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); + background: var(--color-bg-secondary); +} + +.studio-edit-preview pre { + margin: 0; + padding: var(--spacing-md); + font-size: 0.75rem; + overflow: auto; + max-height: 240px; +} + +.studio-edit-preview summary { + padding: var(--spacing-sm) var(--spacing-md); + cursor: pointer; + font-size: 0.85rem; +} + +.studio-edit-header { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--color-border-light); + background: var(--color-bg-secondary); + flex-shrink: 0; +} + +.studio-edit-header-icon { + font-size: 1.25rem; +} + +.studio-edit-header-title { + margin: 0; + font-size: 1.1rem; + font-weight: 600; +} + +.studio-modal-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); +} + +.studio-modal { + width: min(420px, 92vw); + padding: var(--spacing-lg); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + background: var(--color-bg-primary); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25); +} + +.studio-modal-title { + margin: 0 0 var(--spacing-md); + font-size: 1.1rem; +} + +.studio-modal-actions { + display: flex; + justify-content: flex-end; + gap: var(--spacing-sm); + margin-top: var(--spacing-lg); +} diff --git a/frontend/src/components/Studio/StudioEditPage.jsx b/frontend/src/components/Studio/StudioEditPage.jsx new file mode 100644 index 0000000..898643f --- /dev/null +++ b/frontend/src/components/Studio/StudioEditPage.jsx @@ -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
加载中…
; + } + + return ( +
+
+
+ 项目 +
+
+ 工作流目标 +
+
+ 工作流简介 +
+ +
+ +
+
+