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:
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user