From 2d0f82ee871e0ac3ddccbd8590b62be3a55f3535 Mon Sep 17 00:00:00 2001 From: moranzhi Date: Sun, 31 May 2026 23:46:35 +0800 Subject: [PATCH] Fix Studio manual node switch state transitions so revisitable steps stay accessible after switching away. Co-authored-by: Cursor --- backend/services/studio_run_service.py | 68 ++++++++++++++++--- .../src/components/Studio/StudioRunPage.jsx | 52 +++++++++++--- .../src/components/Studio/studioRunUtils.js | 34 +++++++++- 3 files changed, 133 insertions(+), 21 deletions(-) diff --git a/backend/services/studio_run_service.py b/backend/services/studio_run_service.py index d009a42..42f77ee 100644 --- a/backend/services/studio_run_service.py +++ b/backend/services/studio_run_service.py @@ -400,6 +400,60 @@ class StudioRunService: draft = state.lastDraft or {} return bool(draft.get("writtenEntryUid")) or state.status == "completed" + @staticmethod + def _init_bind_completed(state: StudioNodeRunState) -> bool: + draft = state.lastDraft or {} + return bool(draft.get("characterId") and draft.get("worldbookId")) or ( + state.status == "completed" + ) + + @staticmethod + def _node_has_draft_progress(state: StudioNodeRunState) -> bool: + """Node has user-visible progress even if not yet exported to worldbook.""" + draft = state.lastDraft or {} + if state.skillId == "studio.worldbook_entry": + return bool( + (draft.get("entryContent") or "").strip() + or draft.get("writtenEntryUid") + or state.stepMessages + ) + if state.skillId == "studio.init_bind": + return bool( + (draft.get("characterId") and draft.get("worldbookId")) + or draft.get("displayParams") + ) + return False + + @staticmethod + def _node_is_revisitable(state: StudioNodeRunState) -> bool: + if state.status in ("active", "completed"): + return True + if state.status == "pending": + return StudioRunService._node_has_draft_progress(state) + return False + + @staticmethod + def _status_after_leaving_active_node(state: StudioNodeRunState) -> str: + if state.status == "completed": + return "completed" + if state.status != "active": + return state.status + if state.skillId == "studio.worldbook_entry": + if ( + StudioRunService._node_has_written_entry(state) + or StudioRunService._node_has_draft_progress(state) + ): + return "completed" + return "pending" + if state.skillId == "studio.init_bind": + if ( + StudioRunService._init_bind_completed(state) + or StudioRunService._node_has_draft_progress(state) + ): + return "completed" + return "pending" + return state.status + def _save_worldbook_entry_only( self, project_id: str, @@ -603,16 +657,16 @@ class StudioRunService: raise ValueError(f"节点不存在:{node_id}") if not target_node.enabled: raise ValueError("该节点已禁用,无法切换") - if target_node.skillId != "studio.worldbook_entry": - raise ValueError("仅支持切换到世界书创作步骤") + if target_node.skillId not in ("studio.worldbook_entry", "studio.init_bind"): + raise ValueError("仅支持切换到初始化绑定或世界书创作步骤") target_state = next( (s for s in run.nodeStates if s.nodeId == node_id), None ) if not target_state: raise ValueError("节点状态不存在") - if target_state.status not in ("active", "completed"): - raise ValueError("仅可切换到进行中或已完成的步骤") + if not self._node_is_revisitable(target_state): + raise ValueError("仅可切换到已有进度的步骤") prev_node_id = run.currentNodeId now = datetime.now().isoformat() @@ -623,11 +677,7 @@ class StudioRunService: if state.nodeId == node_id: updated.status = "active" elif prev_node_id and state.nodeId == prev_node_id and prev_node_id != node_id: - if state.skillId == "studio.worldbook_entry": - if self._node_has_written_entry(state): - updated.status = "completed" - else: - updated.status = "pending" + updated.status = self._status_after_leaving_active_node(state) new_node_states.append(updated) new_status = ( diff --git a/frontend/src/components/Studio/StudioRunPage.jsx b/frontend/src/components/Studio/StudioRunPage.jsx index 1938eba..6fac441 100644 --- a/frontend/src/components/Studio/StudioRunPage.jsx +++ b/frontend/src/components/Studio/StudioRunPage.jsx @@ -6,7 +6,7 @@ import { buildWorkflowVariableLabelMap, getWorkflowVariableLabel, } from './edit/workflowVariableLabels'; -import { resolveBoundCharacterName, canUndoRun, canRerollRun, canAdvanceNextStep, getRunControls, hasRunControl, shouldShowFirstExport, shouldShowSaveAppendOverwrite, canSaveWorldbookEntry, canSwitchToNode } from './studioRunUtils'; +import { resolveBoundCharacterName, canUndoRun, canRerollRun, canAdvanceNextStep, getRunControls, hasRunControl, shouldShowFirstExport, shouldShowSaveAppendOverwrite, canSaveWorldbookEntry, canSwitchToNode, isInitBindCompleted } from './studioRunUtils'; import useCharacterStore from '../../Store/SideBarLeft/CharacterSlice'; import StudioContextBlockPopup from './StudioContextBlockPopup'; @@ -584,9 +584,10 @@ function StudioRunPage() { ); }, [currentRun]); - const isInitBindActive = + const isInitBindPending = activeNodeState?.status === 'active' && - activeNodeState?.skillId === 'studio.init_bind'; + activeNodeState?.skillId === 'studio.init_bind' && + !isInitBindCompleted(activeNodeState); const runControls = useMemo( () => getRunControls(activeNodeState?.skillId, skillTemplates), @@ -702,7 +703,7 @@ function StudioRunPage() { }; const handleChatSend = async (text) => { - if (!text || isInitBindActive || !isWorldbookActive || runMessaging) return; + if (!text || isInitBindPending || !isWorldbookActive || runMessaging) return; setChatInput(''); @@ -774,13 +775,25 @@ function StudioRunPage() { const handleGraphNodeSelect = useCallback( async (nodeId) => { - if (!runSessionEntered || !currentRun || nodeId === currentRun.currentNodeId) return; + if (!currentRun || nodeId === currentRun.currentNodeId) return; + if (runLoading || runAdvancing || runMessaging) return; const nodeState = currentRun.nodeStates?.find((n) => n.nodeId === nodeId); if (!canSwitchToNode(nodeState)) return; clearConfirmPending(); - await switchRunNode(nodeId); + const run = await switchRunNode(nodeId); + if (run) { + setChatInput(''); + setToolOptionIndex(0); + } }, - [runSessionEntered, currentRun, switchRunNode, clearConfirmPending] + [ + currentRun, + runLoading, + runAdvancing, + runMessaging, + switchRunNode, + clearConfirmPending, + ] ); const handleDeleteRun = async (runId) => { @@ -887,7 +900,7 @@ function StudioRunPage() { nodeStates={graphNodeStates} currentNodeId={graphCurrentNodeId} variant="sidebar" - onNodeSelect={runSessionEntered ? handleGraphNodeSelect : undefined} + onNodeSelect={currentRun ? handleGraphNodeSelect : undefined} canSelectNode={canSwitchToNode} /> ); @@ -915,6 +928,13 @@ function StudioRunPage() { activeNodeState={activeNodeState} isWorldbookActive={isWorldbookActive} /> +
+

节点进度

+

+ 点击已完成、进行中或已有草稿的步骤可切换当前焦点。 +

+ {renderNodeProgress()} +
{ - if (isInitBindActive && activePipelineNode) { + if (isInitBindPending && activePipelineNode) { return (
+

创建并绑定步骤已完成。

+

绑定信息见左侧「目前产物」。

+
+ ); + } + return (
0 + ); + } + if (nodeState.skillId === 'studio.init_bind') { + return isInitBindCompleted(nodeState) || Boolean(draft?.displayParams); + } + return false; +} + /** Whether a pipeline node may be manually selected on the run graph. */ export function canSwitchToNode(nodeState) { - if (!nodeState || nodeState.skillId !== 'studio.worldbook_entry') return false; - return nodeState.status === 'active' || nodeState.status === 'completed'; + if (!nodeState) return false; + if (!['studio.worldbook_entry', 'studio.init_bind'].includes(nodeState.skillId)) { + return false; + } + if (nodeState.status === 'active' || nodeState.status === 'completed') { + return true; + } + return nodeState.status === 'pending' && nodeHasRevisitableProgress(nodeState); } const RUN_CONTROL_ALIASES = {