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

@@ -25,7 +25,8 @@
"supportsLoopUntilSatisfied": false,
"supportsInputs": false,
"supportsInsertion": false,
"supportsScoring": false
"supportsScoring": false,
"runControls": []
},
{
"skillId": "studio.worldbook_entry",
@@ -67,7 +68,8 @@
"enabled": true,
"dimensions": []
}
}
},
"runControls": ["undo", "reroll", "interrupt", "nextStep", "questions"]
}
]
}

View File

@@ -22,46 +22,58 @@ async function resolveStudioApiConfig() {
return { profileId, apiConfig };
}
async function consumeStudioStreamResponse(res, set) {
async function consumeStudioStreamResponse(res, set, signal) {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let finalRun = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
const event = JSON.parse(line);
if (event.type === 'thinking_delta') {
set({ runStreamingThinking: event.content || '' });
} else if (event.type === 'complete') {
const abortHandler = () => {
reader.cancel().catch(() => {});
};
signal?.addEventListener('abort', abortHandler);
try {
while (true) {
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
const event = JSON.parse(line);
if (event.type === 'thinking_delta') {
set({ runStreamingThinking: event.content || '' });
} else if (event.type === 'complete') {
finalRun = event.run;
} else if (event.type === 'error') {
throw new Error(event.detail || '流式处理失败');
}
}
}
if (buffer.trim()) {
const event = JSON.parse(buffer);
if (event.type === 'complete') {
finalRun = event.run;
} else if (event.type === 'error') {
throw new Error(event.detail || '流式处理失败');
} else if (event.type === 'thinking_delta') {
set({ runStreamingThinking: event.content || '' });
}
}
}
if (buffer.trim()) {
const event = JSON.parse(buffer);
if (event.type === 'complete') {
finalRun = event.run;
} else if (event.type === 'error') {
throw new Error(event.detail || '流式处理失败');
} else if (event.type === 'thinking_delta') {
set({ runStreamingThinking: event.content || '' });
if (!finalRun) {
throw new Error('流式响应未完成');
}
return finalRun;
} finally {
signal?.removeEventListener('abort', abortHandler);
}
if (!finalRun) {
throw new Error('流式响应未完成');
}
return finalRun;
}
async function parseStudioErrorResponse(res) {
@@ -75,6 +87,26 @@ async function parseStudioErrorResponse(res) {
return detail;
}
let runAbortController = null;
function beginRunRequest(set) {
if (runAbortController) {
runAbortController.abort();
}
runAbortController = new AbortController();
set({ runMessaging: true, runStreamingThinking: null, runError: null });
return runAbortController;
}
function finishRunRequest(set, patch = {}) {
runAbortController = null;
set({ runMessaging: false, runStreamingThinking: null, ...patch });
}
function isRunAbortError(error) {
return error?.name === 'AbortError' || error?.message === 'Aborted';
}
function migrateScoringInNode(node) {
const scoring = node.config?.scoring;
if (!scoring || scoring.dimensions?.length) return node;
@@ -568,7 +600,7 @@ const useStudioStore = create((set, get) => ({
sendRunMessage: async (content, { stream = false } = {}) => {
const { runProjectId, currentRunId } = get();
if (!runProjectId || !currentRunId) return null;
set({ runMessaging: true, runStreamingThinking: null, runError: null });
const controller = beginRunRequest(set);
try {
const { profileId, apiConfig } = await resolveStudioApiConfig();
@@ -583,6 +615,7 @@ const useStudioStore = create((set, get) => ({
profileId,
apiConfig,
}),
signal: controller.signal,
}
);
@@ -591,57 +624,53 @@ const useStudioStore = create((set, get) => ({
}
if (stream && res.body) {
const finalRun = await consumeStudioStreamResponse(res, set);
set({
currentRun: finalRun,
runMessaging: false,
runStreamingThinking: null,
});
const finalRun = await consumeStudioStreamResponse(res, set, controller.signal);
finishRunRequest(set, { currentRun: finalRun });
return finalRun;
}
const run = await res.json();
set({
currentRun: run,
runMessaging: false,
runStreamingThinking: null,
});
finishRunRequest(set, { currentRun: run });
return run;
} catch (e) {
set({
runMessaging: false,
runStreamingThinking: null,
runError: e.message || '发送消息失败',
});
if (isRunAbortError(e)) {
finishRunRequest(set);
return null;
}
finishRunRequest(set, { runError: e.message || '发送消息失败' });
return null;
}
},
interruptRun: () => {
if (runAbortController) {
runAbortController.abort();
runAbortController = null;
}
set({ runMessaging: false, runStreamingThinking: null });
},
undoRun: async () => {
const { runProjectId, currentRunId } = get();
if (!runProjectId || !currentRunId) return null;
set({ runMessaging: true, runStreamingThinking: null, runError: null });
const controller = beginRunRequest(set);
try {
const res = await fetch(
`/api/studio/projects/${encodeURIComponent(runProjectId)}/runs/${encodeURIComponent(currentRunId)}/undo`,
{ method: 'POST' }
{ method: 'POST', signal: controller.signal }
);
if (!res.ok) {
throw new Error((await parseStudioErrorResponse(res)) || '回退失败');
}
const run = await res.json();
set({
currentRun: run,
runMessaging: false,
runStreamingThinking: null,
});
finishRunRequest(set, { currentRun: run });
return run;
} catch (e) {
set({
runMessaging: false,
runStreamingThinking: null,
runError: e.message || '回退失败',
});
if (isRunAbortError(e)) {
finishRunRequest(set);
return null;
}
finishRunRequest(set, { runError: e.message || '回退失败' });
return null;
}
},
@@ -649,7 +678,7 @@ const useStudioStore = create((set, get) => ({
rerollRun: async ({ stream = false } = {}) => {
const { runProjectId, currentRunId } = get();
if (!runProjectId || !currentRunId) return null;
set({ runMessaging: true, runStreamingThinking: null, runError: null });
const controller = beginRunRequest(set);
try {
const { profileId, apiConfig } = await resolveStudioApiConfig();
@@ -663,6 +692,7 @@ const useStudioStore = create((set, get) => ({
profileId,
apiConfig,
}),
signal: controller.signal,
}
);
@@ -671,28 +701,20 @@ const useStudioStore = create((set, get) => ({
}
if (stream && res.body) {
const finalRun = await consumeStudioStreamResponse(res, set);
set({
currentRun: finalRun,
runMessaging: false,
runStreamingThinking: null,
});
const finalRun = await consumeStudioStreamResponse(res, set, controller.signal);
finishRunRequest(set, { currentRun: finalRun });
return finalRun;
}
const run = await res.json();
set({
currentRun: run,
runMessaging: false,
runStreamingThinking: null,
});
finishRunRequest(set, { currentRun: run });
return run;
} catch (e) {
set({
runMessaging: false,
runStreamingThinking: null,
runError: e.message || '重 roll 失败',
});
if (isRunAbortError(e)) {
finishRunRequest(set);
return null;
}
finishRunRequest(set, { runError: e.message || '重 roll 失败' });
return null;
}
},
@@ -765,7 +787,10 @@ const useStudioStore = create((set, get) => ({
},
initStudioRun: async () => {
await get().fetchProjects();
await Promise.all([
get().fetchProjects(),
get().fetchSkillTemplates(),
]);
const projects = get().projects;
const projectId =
get().runProjectId ||

View File

@@ -367,3 +367,17 @@
transform: none;
box-shadow: none;
}
.studio-run-chat-send--stopping {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
animation: studio-run-chat-pulse 1.5s infinite;
}
@keyframes studio-run-chat-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}

View File

@@ -33,8 +33,10 @@ function StudioRunChat({
inputValue,
onInputChange,
onSend,
onInterrupt,
disabled = false,
sending = false,
canInterrupt = false,
streamOutput = false,
onStreamOutputChange,
placeholder = '输入消息…',
@@ -80,6 +82,10 @@ function StudioRunChat({
const handleSubmit = (e) => {
e.preventDefault();
if (sending && canInterrupt && onInterrupt) {
onInterrupt();
return;
}
if (disabled || sending || !inputValue.trim()) return;
onSend(inputValue.trim());
const textarea = e.target.querySelector('.studio-run-chat-textarea');
@@ -89,6 +95,10 @@ function StudioRunChat({
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (sending && canInterrupt && onInterrupt) {
onInterrupt();
return;
}
if (!disabled && !sending && inputValue.trim()) {
onSend(inputValue.trim());
e.target.style.height = 'auto';
@@ -96,6 +106,19 @@ function StudioRunChat({
}
};
const handleSendOrStop = () => {
if (sending && canInterrupt && onInterrupt) {
onInterrupt();
return;
}
if (disabled || sending || !inputValue.trim()) return;
onSend(inputValue.trim());
const textarea = document.querySelector('.studio-run-chat-textarea');
if (textarea) textarea.style.height = 'auto';
};
const showStop = sending && canInterrupt && onInterrupt;
const cycleRenderMode = () => {
const idx = RENDER_MODES.indexOf(renderMode);
setRenderMode(RENDER_MODES[(idx + 1) % RENDER_MODES.length]);
@@ -186,18 +209,19 @@ function StudioRunChat({
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled || sending}
disabled={disabled}
aria-label="输入消息"
/>
</div>
<button
type="submit"
className="studio-run-chat-send"
disabled={disabled || sending || !inputValue.trim()}
aria-label="发送"
title="发送"
type="button"
className={`studio-run-chat-send${showStop ? ' studio-run-chat-send--stopping' : ''}`}
onClick={handleSendOrStop}
disabled={!showStop && (disabled || sending || !inputValue.trim())}
aria-label={showStop ? '中断' : '发送'}
title={showStop ? '中断' : '发送'}
>
{sending ? '' : '>'}
{showStop ? '' : '>'}
</button>
</div>
</form>

View File

@@ -789,6 +789,22 @@
background: color-mix(in srgb, var(--color-accent) 12%, var(--color-bg-secondary));
}
.studio-run-turn-btn.is-confirm-pending {
border-color: #f5576c;
background: color-mix(in srgb, #f5576c 12%, var(--color-bg-secondary));
color: #f5576c;
animation: studio-run-confirm-pulse 1.2s infinite;
}
@keyframes studio-run-confirm-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.75;
}
}
.studio-run-stub-note {
margin: 0;
font-size: 0.65rem;

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

View File

@@ -41,3 +41,20 @@ export function canRerollRun(activeNodeState) {
export function canAdvanceNextStep(activeNodeState) {
return Boolean(activeNodeState?.lastDraft);
}
const DEFAULT_RUN_CONTROLS = {
'studio.worldbook_entry': ['undo', 'reroll', 'interrupt', 'nextStep', 'questions'],
'studio.init_bind': [],
};
/** Resolve run-page control ids for a skill template (from config or defaults). */
export function getRunControls(skillId, skillTemplates = []) {
if (!skillId) return [];
const tpl = skillTemplates.find((t) => t.skillId === skillId);
if (Array.isArray(tpl?.runControls)) return tpl.runControls;
return DEFAULT_RUN_CONTROLS[skillId] || [];
}
export function hasRunControl(skillId, control, skillTemplates = []) {
return getRunControls(skillId, skillTemplates).includes(control);
}