Fix Studio manual node switch state transitions so revisitable steps stay accessible after switching away.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -400,6 +400,60 @@ class StudioRunService:
|
|||||||
draft = state.lastDraft or {}
|
draft = state.lastDraft or {}
|
||||||
return bool(draft.get("writtenEntryUid")) or state.status == "completed"
|
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(
|
def _save_worldbook_entry_only(
|
||||||
self,
|
self,
|
||||||
project_id: str,
|
project_id: str,
|
||||||
@@ -603,16 +657,16 @@ class StudioRunService:
|
|||||||
raise ValueError(f"节点不存在:{node_id}")
|
raise ValueError(f"节点不存在:{node_id}")
|
||||||
if not target_node.enabled:
|
if not target_node.enabled:
|
||||||
raise ValueError("该节点已禁用,无法切换")
|
raise ValueError("该节点已禁用,无法切换")
|
||||||
if target_node.skillId != "studio.worldbook_entry":
|
if target_node.skillId not in ("studio.worldbook_entry", "studio.init_bind"):
|
||||||
raise ValueError("仅支持切换到世界书创作步骤")
|
raise ValueError("仅支持切换到初始化绑定或世界书创作步骤")
|
||||||
|
|
||||||
target_state = next(
|
target_state = next(
|
||||||
(s for s in run.nodeStates if s.nodeId == node_id), None
|
(s for s in run.nodeStates if s.nodeId == node_id), None
|
||||||
)
|
)
|
||||||
if not target_state:
|
if not target_state:
|
||||||
raise ValueError("节点状态不存在")
|
raise ValueError("节点状态不存在")
|
||||||
if target_state.status not in ("active", "completed"):
|
if not self._node_is_revisitable(target_state):
|
||||||
raise ValueError("仅可切换到进行中或已完成的步骤")
|
raise ValueError("仅可切换到已有进度的步骤")
|
||||||
|
|
||||||
prev_node_id = run.currentNodeId
|
prev_node_id = run.currentNodeId
|
||||||
now = datetime.now().isoformat()
|
now = datetime.now().isoformat()
|
||||||
@@ -623,11 +677,7 @@ class StudioRunService:
|
|||||||
if state.nodeId == node_id:
|
if state.nodeId == node_id:
|
||||||
updated.status = "active"
|
updated.status = "active"
|
||||||
elif prev_node_id and state.nodeId == prev_node_id and prev_node_id != node_id:
|
elif prev_node_id and state.nodeId == prev_node_id and prev_node_id != node_id:
|
||||||
if state.skillId == "studio.worldbook_entry":
|
updated.status = self._status_after_leaving_active_node(state)
|
||||||
if self._node_has_written_entry(state):
|
|
||||||
updated.status = "completed"
|
|
||||||
else:
|
|
||||||
updated.status = "pending"
|
|
||||||
new_node_states.append(updated)
|
new_node_states.append(updated)
|
||||||
|
|
||||||
new_status = (
|
new_status = (
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
buildWorkflowVariableLabelMap,
|
buildWorkflowVariableLabelMap,
|
||||||
getWorkflowVariableLabel,
|
getWorkflowVariableLabel,
|
||||||
} from './edit/workflowVariableLabels';
|
} 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 useCharacterStore from '../../Store/SideBarLeft/CharacterSlice';
|
||||||
|
|
||||||
import StudioContextBlockPopup from './StudioContextBlockPopup';
|
import StudioContextBlockPopup from './StudioContextBlockPopup';
|
||||||
@@ -584,9 +584,10 @@ function StudioRunPage() {
|
|||||||
);
|
);
|
||||||
}, [currentRun]);
|
}, [currentRun]);
|
||||||
|
|
||||||
const isInitBindActive =
|
const isInitBindPending =
|
||||||
activeNodeState?.status === 'active' &&
|
activeNodeState?.status === 'active' &&
|
||||||
activeNodeState?.skillId === 'studio.init_bind';
|
activeNodeState?.skillId === 'studio.init_bind' &&
|
||||||
|
!isInitBindCompleted(activeNodeState);
|
||||||
|
|
||||||
const runControls = useMemo(
|
const runControls = useMemo(
|
||||||
() => getRunControls(activeNodeState?.skillId, skillTemplates),
|
() => getRunControls(activeNodeState?.skillId, skillTemplates),
|
||||||
@@ -702,7 +703,7 @@ function StudioRunPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleChatSend = async (text) => {
|
const handleChatSend = async (text) => {
|
||||||
if (!text || isInitBindActive || !isWorldbookActive || runMessaging) return;
|
if (!text || isInitBindPending || !isWorldbookActive || runMessaging) return;
|
||||||
|
|
||||||
setChatInput('');
|
setChatInput('');
|
||||||
|
|
||||||
@@ -774,13 +775,25 @@ function StudioRunPage() {
|
|||||||
|
|
||||||
const handleGraphNodeSelect = useCallback(
|
const handleGraphNodeSelect = useCallback(
|
||||||
async (nodeId) => {
|
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);
|
const nodeState = currentRun.nodeStates?.find((n) => n.nodeId === nodeId);
|
||||||
if (!canSwitchToNode(nodeState)) return;
|
if (!canSwitchToNode(nodeState)) return;
|
||||||
clearConfirmPending();
|
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) => {
|
const handleDeleteRun = async (runId) => {
|
||||||
@@ -887,7 +900,7 @@ function StudioRunPage() {
|
|||||||
nodeStates={graphNodeStates}
|
nodeStates={graphNodeStates}
|
||||||
currentNodeId={graphCurrentNodeId}
|
currentNodeId={graphCurrentNodeId}
|
||||||
variant="sidebar"
|
variant="sidebar"
|
||||||
onNodeSelect={runSessionEntered ? handleGraphNodeSelect : undefined}
|
onNodeSelect={currentRun ? handleGraphNodeSelect : undefined}
|
||||||
canSelectNode={canSwitchToNode}
|
canSelectNode={canSwitchToNode}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -915,6 +928,13 @@ function StudioRunPage() {
|
|||||||
activeNodeState={activeNodeState}
|
activeNodeState={activeNodeState}
|
||||||
isWorldbookActive={isWorldbookActive}
|
isWorldbookActive={isWorldbookActive}
|
||||||
/>
|
/>
|
||||||
|
<section className="studio-run-left-session__graph">
|
||||||
|
<h2 className="studio-run-section-title">节点进度</h2>
|
||||||
|
<p className="studio-run-hint studio-run-hint--inline">
|
||||||
|
点击已完成、进行中或已有草稿的步骤可切换当前焦点。
|
||||||
|
</p>
|
||||||
|
{renderNodeProgress()}
|
||||||
|
</section>
|
||||||
<PromptBlocksDebug
|
<PromptBlocksDebug
|
||||||
blocks={promptBlocks}
|
blocks={promptBlocks}
|
||||||
onBlockClick={setContextBlockPopup}
|
onBlockClick={setContextBlockPopup}
|
||||||
@@ -991,7 +1011,7 @@ function StudioRunPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderSessionCenter = () => {
|
const renderSessionCenter = () => {
|
||||||
if (isInitBindActive && activePipelineNode) {
|
if (isInitBindPending && activePipelineNode) {
|
||||||
return (
|
return (
|
||||||
<div className="studio-run-chat-card">
|
<div className="studio-run-chat-card">
|
||||||
<InitBindGuidanceForm
|
<InitBindGuidanceForm
|
||||||
@@ -1004,6 +1024,18 @@ function StudioRunPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
activeNodeState?.skillId === 'studio.init_bind' &&
|
||||||
|
isInitBindCompleted(activeNodeState)
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className="studio-run-chat-empty">
|
||||||
|
<p>创建并绑定步骤已完成。</p>
|
||||||
|
<p className="studio-run-hint">绑定信息见左侧「目前产物」。</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="studio-run-chat-panel">
|
<div className="studio-run-chat-panel">
|
||||||
<StudioRunChat
|
<StudioRunChat
|
||||||
@@ -1015,7 +1047,7 @@ function StudioRunPage() {
|
|||||||
onSend={handleChatSend}
|
onSend={handleChatSend}
|
||||||
onInterrupt={canInterrupt ? interruptRun : undefined}
|
onInterrupt={canInterrupt ? interruptRun : undefined}
|
||||||
canInterrupt={canInterrupt}
|
canInterrupt={canInterrupt}
|
||||||
disabled={!isWorldbookActive || runAdvancing || isInitBindActive}
|
disabled={!isWorldbookActive || runAdvancing || isInitBindPending}
|
||||||
sending={runMessaging}
|
sending={runMessaging}
|
||||||
streamOutput={streamOutput}
|
streamOutput={streamOutput}
|
||||||
onStreamOutputChange={setStreamOutput}
|
onStreamOutputChange={setStreamOutput}
|
||||||
|
|||||||
@@ -85,10 +85,40 @@ export function canSaveWorldbookEntry(activeNodeState) {
|
|||||||
return Boolean(activeNodeState?.lastDraft?.entryContent?.trim());
|
return Boolean(activeNodeState?.lastDraft?.entryContent?.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether init_bind step has finished creating character + worldbook. */
|
||||||
|
export function isInitBindCompleted(nodeState) {
|
||||||
|
const draft = nodeState?.lastDraft;
|
||||||
|
return Boolean(draft?.characterId && draft?.worldbookId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Node has saved progress and may be revisited (even if status was reset to pending). */
|
||||||
|
export function nodeHasRevisitableProgress(nodeState) {
|
||||||
|
if (!nodeState) return false;
|
||||||
|
if (nodeState.status === 'completed') return true;
|
||||||
|
const draft = nodeState.lastDraft;
|
||||||
|
if (nodeState.skillId === 'studio.worldbook_entry') {
|
||||||
|
return Boolean(
|
||||||
|
draft?.entryContent?.trim()
|
||||||
|
|| draft?.writtenEntryUid
|
||||||
|
|| (nodeState.stepMessages?.length ?? 0) > 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. */
|
/** Whether a pipeline node may be manually selected on the run graph. */
|
||||||
export function canSwitchToNode(nodeState) {
|
export function canSwitchToNode(nodeState) {
|
||||||
if (!nodeState || nodeState.skillId !== 'studio.worldbook_entry') return false;
|
if (!nodeState) return false;
|
||||||
return nodeState.status === 'active' || nodeState.status === 'completed';
|
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 = {
|
const RUN_CONTROL_ALIASES = {
|
||||||
|
|||||||
Reference in New Issue
Block a user