diff --git a/data/agent/skill_templates.json b/data/agent/skill_templates.json index ffa2ef8..0b94ccc 100644 --- a/data/agent/skill_templates.json +++ b/data/agent/skill_templates.json @@ -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"] } ] } diff --git a/frontend/src/Store/Studio/StudioSlice.jsx b/frontend/src/Store/Studio/StudioSlice.jsx index 23958e1..6ae62ef 100644 --- a/frontend/src/Store/Studio/StudioSlice.jsx +++ b/frontend/src/Store/Studio/StudioSlice.jsx @@ -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 || diff --git a/frontend/src/components/Studio/StudioRunChat.css b/frontend/src/components/Studio/StudioRunChat.css index 4a8d766..0e506d0 100644 --- a/frontend/src/components/Studio/StudioRunChat.css +++ b/frontend/src/components/Studio/StudioRunChat.css @@ -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; + } +} diff --git a/frontend/src/components/Studio/StudioRunChat.jsx b/frontend/src/components/Studio/StudioRunChat.jsx index f05d6a0..72e1d5a 100644 --- a/frontend/src/components/Studio/StudioRunChat.jsx +++ b/frontend/src/components/Studio/StudioRunChat.jsx @@ -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="输入消息" /> diff --git a/frontend/src/components/Studio/StudioRunPage.css b/frontend/src/components/Studio/StudioRunPage.css index 0ae6140..4a375c9 100644 --- a/frontend/src/components/Studio/StudioRunPage.css +++ b/frontend/src/components/Studio/StudioRunPage.css @@ -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; diff --git a/frontend/src/components/Studio/StudioRunPage.jsx b/frontend/src/components/Studio/StudioRunPage.jsx index b6174a8..a55e4ac 100644 --- a/frontend/src/components/Studio/StudioRunPage.jsx +++ b/frontend/src/components/Studio/StudioRunPage.jsx @@ -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 ( + + ); +} 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() {

回合控制

- - + {showUndo ? ( + + ) : null} + {showReroll ? ( + + ) : null}
)} diff --git a/frontend/src/components/Studio/studioRunUtils.js b/frontend/src/components/Studio/studioRunUtils.js index d152de3..54e7ff6 100644 --- a/frontend/src/components/Studio/studioRunUtils.js +++ b/frontend/src/components/Studio/studioRunUtils.js @@ -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); +}