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

View File

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

View File

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

View File

@@ -789,6 +789,22 @@
background: color-mix(in srgb, var(--color-accent) 12%, var(--color-bg-secondary)); 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 { .studio-run-stub-note {
margin: 0; margin: 0;
font-size: 0.65rem; font-size: 0.65rem;

View File

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

View File

@@ -41,3 +41,20 @@ export function canRerollRun(activeNodeState) {
export function canAdvanceNextStep(activeNodeState) { export function canAdvanceNextStep(activeNodeState) {
return Boolean(activeNodeState?.lastDraft); 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);
}