Add Studio run interrupt, confirm actions, and template-bound controls.

AbortController cancels message/reroll streams; undo/reroll use in-place confirm; runControls in skill templates gate UI by skillId.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 23:03:39 +08:00
parent 5bfe7a733f
commit 0f50c98cf3
7 changed files with 318 additions and 110 deletions

View File

@@ -6,7 +6,7 @@ import {
buildWorkflowVariableLabelMap,
getWorkflowVariableLabel,
} from './edit/workflowVariableLabels';
import { resolveBoundCharacterName, canUndoRun, canRerollRun, canAdvanceNextStep } from './studioRunUtils';
import { resolveBoundCharacterName, canUndoRun, canRerollRun, canAdvanceNextStep, getRunControls, hasRunControl } from './studioRunUtils';
import useCharacterStore from '../../Store/SideBarLeft/CharacterSlice';
import StudioContextBlockPopup from './StudioContextBlockPopup';
@@ -27,6 +27,37 @@ const RUN_STATUS_LABEL = {
const RUN_LIST_COLLAPSE_THRESHOLD = 4;
const RUN_ITEM_HEIGHT = 56;
const CONFIRM_ACTION_TIMEOUT_MS = 4000;
function ConfirmTurnButton({
actionKey,
pendingKey,
label,
className,
disabled,
title,
onConfirm,
onRequestConfirm,
}) {
const isPending = pendingKey === actionKey;
return (
<button
type="button"
className={`${className}${isPending ? ' is-confirm-pending' : ''}`}
onClick={() => {
if (isPending) {
onConfirm();
} else {
onRequestConfirm(actionKey);
}
}}
disabled={disabled && !isPending}
title={isPending ? '再次点击确认操作' : title}
>
{isPending ? '确认' : label}
</button>
);
}
function formatRunTime(iso) {
if (!iso) return '';
@@ -471,8 +502,10 @@ function StudioRunPage() {
sendRunMessage,
undoRun,
rerollRun,
interruptRun,
deleteRun,
renameRun,
skillTemplates,
} = useStudioStore();
const sidebarMode = useAppLayoutStore((s) => s.sidebarMode);
@@ -518,6 +551,8 @@ function StudioRunPage() {
const [runListExpanded, setRunListExpanded] = useState(false);
const [renamingId, setRenamingId] = useState(null);
const [renameValue, setRenameValue] = useState('');
const [confirmPending, setConfirmPending] = useState(null);
const confirmTimeoutRef = useRef(null);
useEffect(() => {
initStudioRun();
@@ -551,9 +586,14 @@ function StudioRunPage() {
activeNodeState?.status === 'active' &&
activeNodeState?.skillId === 'studio.init_bind';
const runControls = useMemo(
() => getRunControls(activeNodeState?.skillId, skillTemplates),
[activeNodeState?.skillId, skillTemplates]
);
const isWorldbookActive =
activeNodeState?.status === 'active' &&
activeNodeState?.skillId === 'studio.worldbook_entry';
hasRunControl(activeNodeState?.skillId, 'interrupt', skillTemplates);
const toolQuestions = useMemo(
() => activeNodeState?.lastToolResponse?.questions || [],
@@ -564,6 +604,49 @@ function StudioRunPage() {
setToolOptionIndex(0);
}, [currentRunId, activeNodeState?.nodeId, toolQuestions.length]);
const clearConfirmPending = useCallback(() => {
if (confirmTimeoutRef.current) {
clearTimeout(confirmTimeoutRef.current);
confirmTimeoutRef.current = null;
}
setConfirmPending(null);
}, []);
useEffect(() => {
clearConfirmPending();
}, [currentRunId, activeNodeState?.nodeId, clearConfirmPending]);
useEffect(() => {
if (!confirmPending) return undefined;
const handleClickOutside = (event) => {
if (!event.target.closest('.studio-run-turn-btn')) {
clearConfirmPending();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [confirmPending, clearConfirmPending]);
useEffect(() => {
return () => {
if (confirmTimeoutRef.current) clearTimeout(confirmTimeoutRef.current);
};
}, []);
const requestConfirmAction = useCallback((actionKey) => {
if (confirmPending === actionKey) {
clearConfirmPending();
return true;
}
if (confirmTimeoutRef.current) clearTimeout(confirmTimeoutRef.current);
setConfirmPending(actionKey);
confirmTimeoutRef.current = setTimeout(() => {
setConfirmPending(null);
confirmTimeoutRef.current = null;
}, CONFIRM_ACTION_TIMEOUT_MS);
return false;
}, [confirmPending, clearConfirmPending]);
const graphPipeline = currentRun?.pipelineSnapshot || projectPipeline;
const graphNodeStates = currentRun?.nodeStates || [];
const graphCurrentNodeId = currentRun?.currentNodeId || null;
@@ -628,17 +711,32 @@ function StudioRunPage() {
};
const handleUndo = async () => {
if (!isWorldbookActive || runMessaging || !canUndoRun(activeNodeState)) return;
if (!hasRunControl(activeNodeState?.skillId, 'undo', skillTemplates)) return;
if (runMessaging || !canUndoRun(activeNodeState)) return;
clearConfirmPending();
await undoRun();
};
const handleReroll = async () => {
if (!isWorldbookActive || runMessaging || !canRerollRun(activeNodeState)) return;
if (!hasRunControl(activeNodeState?.skillId, 'reroll', skillTemplates)) return;
if (runMessaging || !canRerollRun(activeNodeState)) return;
clearConfirmPending();
await rerollRun({ stream: streamOutput });
};
const handleUndoClick = () => {
if (runAdvancing || runMessaging || !canUndo) return;
requestConfirmAction('undo');
};
const handleRerollClick = () => {
if (runAdvancing || runMessaging || !canReroll) return;
requestConfirmAction('reroll');
};
const handleNextStep = async () => {
if (!isWorldbookActive || runAdvancing || runMessaging || !canAdvanceNextStep(activeNodeState)) {
if (!hasRunControl(activeNodeState?.skillId, 'nextStep', skillTemplates)) return;
if (runAdvancing || runMessaging || !canAdvanceNextStep(activeNodeState)) {
return;
}
const run = await advanceRun({});
@@ -672,12 +770,16 @@ function StudioRunPage() {
const runListNeedsCollapse = runs.length > RUN_LIST_COLLAPSE_THRESHOLD;
const inSession = runSessionEntered && !!currentRun;
const showToolCarousel = inSession && isWorldbookActive && toolQuestions.length > 0;
const showNextStep = inSession && isWorldbookActive;
const showToolCarousel =
inSession && runControls.includes('questions') && toolQuestions.length > 0;
const showNextStep = inSession && runControls.includes('nextStep');
const showUndo = runControls.includes('undo');
const showReroll = runControls.includes('reroll');
const canUndo = canUndoRun(activeNodeState);
const canReroll = canRerollRun(activeNodeState);
const canNextStep = canAdvanceNextStep(activeNodeState);
const showTurnActions = inSession && isWorldbookActive;
const showTurnActions = inSession && (showUndo || showReroll);
const canInterrupt = runControls.includes('interrupt');
const renderRunList = () => (
<>
@@ -862,7 +964,9 @@ function StudioRunPage() {
inputValue={chatInput}
onInputChange={setChatInput}
onSend={handleChatSend}
disabled={!isWorldbookActive || runAdvancing || runMessaging || isInitBindActive}
onInterrupt={canInterrupt ? interruptRun : undefined}
canInterrupt={canInterrupt}
disabled={!isWorldbookActive || runAdvancing || isInitBindActive}
sending={runMessaging}
streamOutput={streamOutput}
onStreamOutputChange={setStreamOutput}
@@ -903,24 +1007,30 @@ function StudioRunPage() {
<div className="studio-run-right-section studio-run-turn-actions">
<h2 className="studio-run-section-title">回合控制</h2>
<div className="studio-run-turn-actions__row">
<button
type="button"
className="studio-run-turn-btn studio-run-turn-btn--undo"
onClick={handleUndo}
disabled={runAdvancing || runMessaging || !canUndo}
title={canUndo ? '回退到上一回合' : '无可回退的回合'}
>
回退
</button>
<button
type="button"
className="studio-run-turn-btn studio-run-turn-btn--reroll"
onClick={handleReroll}
disabled={runAdvancing || runMessaging || !canReroll}
title={canReroll ? '用相同输入重新生成' : '尚无用户消息'}
>
重roll
</button>
{showUndo ? (
<ConfirmTurnButton
actionKey="undo"
pendingKey={confirmPending}
label="回退"
className="studio-run-turn-btn studio-run-turn-btn--undo"
disabled={runAdvancing || runMessaging || !canUndo}
title={canUndo ? '回退到上一回合' : '无可回退的回合'}
onConfirm={handleUndo}
onRequestConfirm={handleUndoClick}
/>
) : null}
{showReroll ? (
<ConfirmTurnButton
actionKey="reroll"
pendingKey={confirmPending}
label="重roll"
className="studio-run-turn-btn studio-run-turn-btn--reroll"
disabled={runAdvancing || runMessaging || !canReroll}
title={canReroll ? '用相同输入重新生成' : '尚无用户消息'}
onConfirm={handleReroll}
onRequestConfirm={handleRerollClick}
/>
) : null}
</div>
</div>
)}