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