diff --git a/backend/api/routes/studioRoute.py b/backend/api/routes/studioRoute.py index 2e56ed7..a583a43 100644 --- a/backend/api/routes/studioRoute.py +++ b/backend/api/routes/studioRoute.py @@ -11,6 +11,7 @@ from models.studio_models import ( PipelineDefinition, RenameRunRequest, RunMessageRequest, + RunRerollRequest, StudioProject, StudioProjectSummary, StudioRun, @@ -249,6 +250,79 @@ async def send_studio_run_message( raise HTTPException(status_code=500, detail=f"消息处理失败:{e}") +@router.post("/projects/{project_id}/runs/{run_id}/undo", response_model=StudioRun) +async def undo_studio_run(project_id: str, run_id: str): + try: + return studio_run_service.undo_run(project_id, run_id) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error( + "Failed to undo studio run %s/%s: %s", project_id, run_id, e + ) + raise HTTPException(status_code=500, detail=f"回退失败:{e}") + + +@router.post("/projects/{project_id}/runs/{run_id}/reroll") +async def reroll_studio_run( + project_id: str, run_id: str, req: RunRerollRequest +): + if req.stream: + async def ndjson_stream(): + try: + async for event in studio_run_service.reroll_run_stream( + project_id, + run_id, + profile_id=req.profileId, + api_config=req.apiConfig, + ): + yield json.dumps(event, ensure_ascii=False) + "\n" + except FileNotFoundError as e: + yield json.dumps( + {"type": "error", "detail": str(e)}, ensure_ascii=False + ) + "\n" + except ValueError as e: + yield json.dumps( + {"type": "error", "detail": str(e)}, ensure_ascii=False + ) + "\n" + except Exception as e: + logger.error( + "Failed to stream studio run reroll %s/%s: %s", + project_id, + run_id, + e, + ) + yield json.dumps( + {"type": "error", "detail": f"重 roll 失败:{e}"}, + ensure_ascii=False, + ) + "\n" + + return StreamingResponse( + ndjson_stream(), + media_type="application/x-ndjson", + ) + + try: + return await studio_run_service.reroll_run( + project_id, + run_id, + stream=req.stream, + profile_id=req.profileId, + api_config=req.apiConfig, + ) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error( + "Failed to reroll studio run %s/%s: %s", project_id, run_id, e + ) + raise HTTPException(status_code=500, detail=f"重 roll 失败:{e}") + + @router.delete("/projects/{project_id}/runs/{run_id}") async def delete_studio_run(project_id: str, run_id: str): try: diff --git a/backend/models/studio_models.py b/backend/models/studio_models.py index caa5986..97ac1bc 100644 --- a/backend/models/studio_models.py +++ b/backend/models/studio_models.py @@ -183,6 +183,14 @@ class PromptBlock(BaseModel): source: str = "auto" # auto | manual | workflow +class TurnSnapshot(BaseModel): + """State captured before each LLM turn (for undo).""" + lastDraft: Optional[Dict[str, Any]] = None + lastToolResponse: Optional[LastToolResponse] = None + stepMessages: List[StepMessage] = Field(default_factory=list) + timestamp: Optional[str] = None + + class StudioNodeRunState(BaseModel): nodeId: str displayName: str @@ -192,6 +200,7 @@ class StudioNodeRunState(BaseModel): lastDraft: Optional[Dict[str, Any]] = None lastToolResponse: Optional[LastToolResponse] = None stepMessages: List[StepMessage] = Field(default_factory=list) + turnHistory: List[TurnSnapshot] = Field(default_factory=list) class StudioRun(BaseModel): @@ -220,6 +229,12 @@ class RunMessageRequest(BaseModel): apiConfig: Optional[Dict[str, str]] = None +class RunRerollRequest(BaseModel): + stream: bool = False + profileId: Optional[str] = None + apiConfig: Optional[Dict[str, str]] = None + + class RenameRunRequest(BaseModel): title: str = Field(..., min_length=1, max_length=120) diff --git a/backend/services/studio_run_service.py b/backend/services/studio_run_service.py index 4a4ef0c..9642d9c 100644 --- a/backend/services/studio_run_service.py +++ b/backend/services/studio_run_service.py @@ -20,6 +20,7 @@ from models.studio_models import ( StudioRun, StudioRunStatus, StudioRunSummary, + TurnSnapshot, ) from services.character_service import CharacterService from services.studio_context_service import assemble_prompt_blocks, store_context_on_run @@ -92,6 +93,44 @@ def _next_enabled_node_id(pipeline: PipelineDefinition, after_node_id: str) -> O return None +def _snapshot_node_state(state: StudioNodeRunState) -> TurnSnapshot: + return TurnSnapshot( + lastDraft=copy.deepcopy(state.lastDraft) if state.lastDraft else None, + lastToolResponse=( + state.lastToolResponse.model_copy() + if state.lastToolResponse + else None + ), + stepMessages=[m.model_copy() for m in (state.stepMessages or [])], + timestamp=datetime.now().isoformat(), + ) + + +def _apply_snapshot( + state: StudioNodeRunState, snapshot: TurnSnapshot +) -> StudioNodeRunState: + updated = state.model_copy() + updated.lastDraft = ( + copy.deepcopy(snapshot.lastDraft) if snapshot.lastDraft else None + ) + updated.lastToolResponse = ( + snapshot.lastToolResponse.model_copy() + if snapshot.lastToolResponse + else None + ) + updated.stepMessages = [ + m.model_copy() for m in (snapshot.stepMessages or []) + ] + return updated + + +def _find_last_user_message_index(step_messages: list) -> int: + for i in range(len(step_messages) - 1, -1, -1): + if step_messages[i].role == "user": + return i + return -1 + + class StudioRunService: def __init__(self) -> None: self._character_service = CharacterService() @@ -272,6 +311,9 @@ class StudioRunService: workflow_variables, last_draft = self._execute_init_bind( project_id, run, current_node, display_params ) + elif current_node.skillId == "studio.worldbook_entry": + if not current_state.lastDraft: + raise ValueError("请先生成并确认当前产物后再进入下一步") else: raise NotImplementedError( f"技能「{current_node.skillId}」的执行尚未实现(R2+)" @@ -414,15 +456,14 @@ class StudioRunService: "run": updated_run.model_dump(mode="json"), } - def _prepare_run_message( + def _prepare_active_worldbook_step( self, project_id: str, run_id: str, - trimmed: str, ) -> tuple[StudioRun, StudioNode, StudioNodeRunState, str]: run = self.get_run(project_id, run_id) if run.status != StudioRunStatus.RUNNING: - raise ValueError("运行未处于进行中,无法发送消息") + raise ValueError("运行未处于进行中,无法操作") current_node_id = run.currentNodeId if not current_node_id: @@ -445,6 +486,16 @@ class StudioRunService: return run, current_node, current_state, current_node_id + def _prepare_run_message( + self, + project_id: str, + run_id: str, + trimmed: str, + ) -> tuple[StudioRun, StudioNode, StudioNodeRunState, str]: + if not trimmed: + raise ValueError("消息内容不能为空") + return self._prepare_active_worldbook_step(project_id, run_id) + def _persist_run_message_turn( self, project_id: str, @@ -457,6 +508,8 @@ class StudioRunService: last_tool_response, user_msg, assistant_msg, + *, + push_snapshot: bool = True, ) -> StudioRun: step_messages = list(step_messages) step_messages.extend([user_msg, assistant_msg]) @@ -466,6 +519,205 @@ class StudioRunService: for state in run.nodeStates: updated = state.model_copy() if state.nodeId == current_node_id: + if push_snapshot: + turn_history = list(state.turnHistory or []) + turn_history.append(_snapshot_node_state(state)) + updated.turnHistory = turn_history + updated.lastDraft = last_draft + updated.lastToolResponse = last_tool_response + updated.stepMessages = step_messages + new_node_states.append(updated) + + updated_run = run.model_copy( + update={ + "nodeStates": new_node_states, + "lastPromptBlocks": prompt_blocks, + "updatedAt": now, + } + ) + updated_run = store_context_on_run(updated_run, current_node_id) + self._save_run(project_id, run_id, updated_run) + return updated_run + + def undo_run(self, project_id: str, run_id: str) -> StudioRun: + """Restore the active step to the state before the last LLM turn.""" + run, _, current_state, current_node_id = self._prepare_active_worldbook_step( + project_id, run_id + ) + + turn_history = list(current_state.turnHistory or []) + if not turn_history: + raise ValueError("无可回退的回合") + + snapshot = turn_history.pop() + restored_state = _apply_snapshot(current_state, snapshot) + restored_state.turnHistory = turn_history + + now = datetime.now().isoformat() + new_node_states: list[StudioNodeRunState] = [] + for state in run.nodeStates: + if state.nodeId == current_node_id: + new_node_states.append(restored_state) + else: + new_node_states.append(state.model_copy()) + + updated_run = run.model_copy( + update={ + "nodeStates": new_node_states, + "updatedAt": now, + } + ) + updated_run = store_context_on_run(updated_run, current_node_id) + self._save_run(project_id, run_id, updated_run) + return updated_run + + async def reroll_run( + self, + project_id: str, + run_id: str, + *, + stream: bool = False, + profile_id: Optional[str] = None, + api_config: Optional[Dict[str, str]] = None, + ) -> StudioRun: + """Re-run the last user turn with identical inputs (no new user message).""" + run, current_node, current_state, current_node_id = ( + self._prepare_active_worldbook_step(project_id, run_id) + ) + + step_messages_all = list(current_state.stepMessages or []) + last_user_idx = _find_last_user_message_index(step_messages_all) + if last_user_idx < 0: + raise ValueError("当前步骤尚无用户消息,无法重 roll") + + user_content = step_messages_all[last_user_idx].content + step_messages = step_messages_all[:last_user_idx] + + pre_turn_draft = current_state.lastDraft + if current_state.turnHistory: + pre_turn_draft = current_state.turnHistory[-1].lastDraft + + resolved_api = resolve_api_config(profile_id, api_config) + prompt_blocks = assemble_prompt_blocks(run, current_node_id) + + last_draft, last_tool_response, user_msg, assistant_msg = ( + await studio_step_respond( + node=current_node, + prompt_blocks=prompt_blocks, + step_messages=step_messages, + user_message=user_content, + existing_draft=pre_turn_draft, + api_config=resolved_api, + stream=stream, + ) + ) + + return self._persist_reroll_turn( + project_id, + run_id, + run, + current_node_id, + current_state, + prompt_blocks, + step_messages, + last_draft, + last_tool_response, + user_msg, + assistant_msg, + ) + + async def reroll_run_stream( + self, + project_id: str, + run_id: str, + *, + profile_id: Optional[str] = None, + api_config: Optional[Dict[str, str]] = None, + ) -> AsyncGenerator[Dict[str, Any], None]: + """Stream thinking during reroll, then persist replaced turn.""" + run, current_node, current_state, current_node_id = ( + self._prepare_active_worldbook_step(project_id, run_id) + ) + + step_messages_all = list(current_state.stepMessages or []) + last_user_idx = _find_last_user_message_index(step_messages_all) + if last_user_idx < 0: + raise ValueError("当前步骤尚无用户消息,无法重 roll") + + user_content = step_messages_all[last_user_idx].content + step_messages = step_messages_all[:last_user_idx] + + pre_turn_draft = current_state.lastDraft + if current_state.turnHistory: + pre_turn_draft = current_state.turnHistory[-1].lastDraft + + resolved_api = resolve_api_config(profile_id, api_config) + prompt_blocks = assemble_prompt_blocks(run, current_node_id) + + async for event in studio_step_respond_stream( + node=current_node, + prompt_blocks=prompt_blocks, + step_messages=step_messages, + user_message=user_content, + existing_draft=pre_turn_draft, + api_config=resolved_api, + ): + if event.get("type") == "thinking_delta": + yield event + continue + + if event.get("type") == "complete": + from models.studio_models import LastToolResponse, StepMessage + + last_draft = event["last_draft"] + last_tool_response = LastToolResponse(**event["last_tool_response"]) + user_msg = StepMessage(**event["user_msg"]) + assistant_msg = StepMessage(**event["assistant_msg"]) + + updated_run = self._persist_reroll_turn( + project_id, + run_id, + run, + current_node_id, + current_state, + prompt_blocks, + step_messages, + last_draft, + last_tool_response, + user_msg, + assistant_msg, + ) + yield { + "type": "complete", + "run": updated_run.model_dump(mode="json"), + } + + def _persist_reroll_turn( + self, + project_id: str, + run_id: str, + run: StudioRun, + current_node_id: str, + current_state: StudioNodeRunState, + prompt_blocks: list, + step_messages: list, + last_draft: Dict[str, Any], + last_tool_response, + user_msg, + assistant_msg, + ) -> StudioRun: + """Replace the last turn; snapshot current state so reroll is undoable.""" + step_messages = list(step_messages) + step_messages.extend([user_msg, assistant_msg]) + now = datetime.now().isoformat() + + new_node_states: list[StudioNodeRunState] = [] + for state in run.nodeStates: + updated = state.model_copy() + if state.nodeId == current_node_id: + turn_history = list(current_state.turnHistory or []) + turn_history.append(_snapshot_node_state(current_state)) + updated.turnHistory = turn_history updated.lastDraft = last_draft updated.lastToolResponse = last_tool_response updated.stepMessages = step_messages diff --git a/frontend/src/Store/Studio/StudioSlice.jsx b/frontend/src/Store/Studio/StudioSlice.jsx index beee5f9..23958e1 100644 --- a/frontend/src/Store/Studio/StudioSlice.jsx +++ b/frontend/src/Store/Studio/StudioSlice.jsx @@ -2,6 +2,79 @@ import { create } from 'zustand'; const DEFAULT_TEMPLATE_ID = 'builtin.studio.example'; +async function resolveStudioApiConfig() { + let profileId = null; + let apiConfig = {}; + try { + const { default: useApiConfigStore } = await import('../SideBarLeft/ApiConfigSlice'); + const apiState = useApiConfigStore.getState(); + profileId = apiState.currentProfile?.id || null; + const mainLLM = apiState.currentProfile?.apis?.mainLLM; + if (mainLLM) { + apiConfig = { + api_url: mainLLM.apiUrl || '', + model: mainLLM.model || '', + }; + } + } catch { + /* optional store */ + } + return { profileId, apiConfig }; +} + +async function consumeStudioStreamResponse(res, set) { + 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') { + 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 (!finalRun) { + throw new Error('流式响应未完成'); + } + return finalRun; +} + +async function parseStudioErrorResponse(res) { + let detail = await res.text(); + try { + const parsed = JSON.parse(detail); + detail = parsed.detail || detail; + } catch { + /* keep raw text */ + } + return detail; +} + function migrateScoringInNode(node) { const scoring = node.config?.scoring; if (!scoring || scoring.dimensions?.length) return node; @@ -497,24 +570,7 @@ const useStudioStore = create((set, get) => ({ if (!runProjectId || !currentRunId) return null; set({ runMessaging: true, runStreamingThinking: null, runError: null }); try { - let profileId = null; - let apiConfig = {}; - try { - const { default: useApiConfigStore } = await import( - '../SideBarLeft/ApiConfigSlice' - ); - const apiState = useApiConfigStore.getState(); - profileId = apiState.currentProfile?.id || null; - const mainLLM = apiState.currentProfile?.apis?.mainLLM; - if (mainLLM) { - apiConfig = { - api_url: mainLLM.apiUrl || '', - model: mainLLM.model || '', - }; - } - } catch { - /* optional store */ - } + const { profileId, apiConfig } = await resolveStudioApiConfig(); const res = await fetch( `/api/studio/projects/${encodeURIComponent(runProjectId)}/runs/${encodeURIComponent(currentRunId)}/message`, @@ -531,56 +587,11 @@ const useStudioStore = create((set, get) => ({ ); if (!res.ok) { - let detail = await res.text(); - try { - const parsed = JSON.parse(detail); - detail = parsed.detail || detail; - } catch { - /* keep raw text */ - } - throw new Error(detail || '发送消息失败'); + throw new Error((await parseStudioErrorResponse(res)) || '发送消息失败'); } if (stream && res.body) { - 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') { - 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 (!finalRun) { - throw new Error('流式响应未完成'); - } - + const finalRun = await consumeStudioStreamResponse(res, set); set({ currentRun: finalRun, runMessaging: false, @@ -606,6 +617,86 @@ const useStudioStore = create((set, get) => ({ } }, + undoRun: async () => { + const { runProjectId, currentRunId } = get(); + if (!runProjectId || !currentRunId) return null; + set({ runMessaging: true, runStreamingThinking: null, runError: null }); + try { + const res = await fetch( + `/api/studio/projects/${encodeURIComponent(runProjectId)}/runs/${encodeURIComponent(currentRunId)}/undo`, + { method: 'POST' } + ); + if (!res.ok) { + throw new Error((await parseStudioErrorResponse(res)) || '回退失败'); + } + const run = await res.json(); + set({ + currentRun: run, + runMessaging: false, + runStreamingThinking: null, + }); + return run; + } catch (e) { + set({ + runMessaging: false, + runStreamingThinking: null, + runError: e.message || '回退失败', + }); + return null; + } + }, + + rerollRun: async ({ stream = false } = {}) => { + const { runProjectId, currentRunId } = get(); + if (!runProjectId || !currentRunId) return null; + set({ runMessaging: true, runStreamingThinking: null, runError: null }); + try { + const { profileId, apiConfig } = await resolveStudioApiConfig(); + + const res = await fetch( + `/api/studio/projects/${encodeURIComponent(runProjectId)}/runs/${encodeURIComponent(currentRunId)}/reroll`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + stream, + profileId, + apiConfig, + }), + } + ); + + if (!res.ok) { + throw new Error((await parseStudioErrorResponse(res)) || '重 roll 失败'); + } + + if (stream && res.body) { + const finalRun = await consumeStudioStreamResponse(res, set); + set({ + currentRun: finalRun, + runMessaging: false, + runStreamingThinking: null, + }); + return finalRun; + } + + const run = await res.json(); + set({ + currentRun: run, + runMessaging: false, + runStreamingThinking: null, + }); + return run; + } catch (e) { + set({ + runMessaging: false, + runStreamingThinking: null, + runError: e.message || '重 roll 失败', + }); + return null; + } + }, + deleteRun: async (runId) => { const projectId = get().runProjectId; if (!projectId || !runId) return false; diff --git a/frontend/src/components/Studio/StudioRunChat.jsx b/frontend/src/components/Studio/StudioRunChat.jsx index 2935759..2fe863d 100644 --- a/frontend/src/components/Studio/StudioRunChat.jsx +++ b/frontend/src/components/Studio/StudioRunChat.jsx @@ -49,17 +49,15 @@ function StudioRunChat({ const lastUserIdx = findLastUserMessageIndex(stepMessages); const historyMessages = - lastUserIdx > 0 ? stepMessages.slice(0, lastUserIdx) : []; + lastUserIdx > 0 + ? stepMessages.slice(0, lastUserIdx).filter((msg) => msg.role === 'user') + : []; const lastUserMessage = lastUserIdx >= 0 ? stepMessages[lastUserIdx] : pendingUserText ? { role: 'user', content: pendingUserText } : null; - const summaryText = - evaluation || - (lastUserIdx >= 0 && stepMessages[lastUserIdx + 1]?.role === 'assistant' - ? stepMessages[lastUserIdx + 1].content - : null); + const summaryText = evaluation || null; const hasFocusContent = thinking || summaryText || pendingUserText || sending || lastUserMessage; @@ -124,11 +122,9 @@ function StudioRunChat({ {historyMessages.map((msg) => (
- - {msg.role === 'user' ? '你' : '助手'} - + {msg.content}
))} diff --git a/frontend/src/components/Studio/StudioRunPage.css b/frontend/src/components/Studio/StudioRunPage.css index 7fcd161..0ae6140 100644 --- a/frontend/src/components/Studio/StudioRunPage.css +++ b/frontend/src/components/Studio/StudioRunPage.css @@ -752,6 +752,43 @@ cursor: not-allowed; } +.studio-run-turn-actions__row { + display: flex; + gap: var(--spacing-xs); +} + +.studio-run-turn-btn { + flex: 1; + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); + background: var(--color-bg-secondary); + color: var(--color-text-primary); + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.studio-run-turn-btn:hover:not(:disabled) { + background: var(--color-bg-tertiary); + border-color: var(--color-border); +} + +.studio-run-turn-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.studio-run-turn-btn--reroll { + border-color: var(--color-accent); + color: var(--color-accent); +} + +.studio-run-turn-btn--reroll:hover:not(:disabled) { + background: color-mix(in srgb, var(--color-accent) 12%, var(--color-bg-secondary)); +} + .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 f328255..e1f386a 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 } from './studioRunUtils'; +import { resolveBoundCharacterName, canUndoRun, canRerollRun, canAdvanceNextStep } from './studioRunUtils'; import useCharacterStore from '../../Store/SideBarLeft/CharacterSlice'; import StudioContextBlockPopup from './StudioContextBlockPopup'; @@ -469,6 +469,8 @@ function StudioRunPage() { selectRun, advanceRun, sendRunMessage, + undoRun, + rerollRun, deleteRun, renameRun, } = useStudioStore(); @@ -629,8 +631,26 @@ function StudioRunPage() { } }; - const handleNextStep = () => { - window.alert('(R5 占位:确认当前产物后将进入下一步)'); + const handleUndo = async () => { + if (!isWorldbookActive || runMessaging || !canUndoRun(activeNodeState)) return; + await undoRun(); + }; + + const handleReroll = async () => { + if (!isWorldbookActive || runMessaging || !canRerollRun(activeNodeState)) return; + await rerollRun({ stream: streamOutput }); + }; + + const handleNextStep = async () => { + if (!isWorldbookActive || runAdvancing || runMessaging || !canAdvanceNextStep(activeNodeState)) { + return; + } + const run = await advanceRun({}); + if (run) { + setChatInput(''); + setToolOptionIndex(0); + setPendingUserText(null); + } }; const handleDeleteRun = async (runId) => { @@ -659,6 +679,10 @@ function StudioRunPage() { const inSession = runSessionEntered && !!currentRun; const showToolCarousel = inSession && isWorldbookActive && toolQuestions.length > 0; const showNextStep = inSession && isWorldbookActive; + const canUndo = canUndoRun(activeNodeState); + const canReroll = canRerollRun(activeNodeState); + const canNextStep = canAdvanceNextStep(activeNodeState); + const showTurnActions = inSession && isWorldbookActive; const renderRunList = () => ( <> @@ -881,13 +905,40 @@ function StudioRunPage() { /> )} + {showTurnActions && ( +
+

回合控制

+
+ + +
+
+ )} + {showNextStep && (
diff --git a/frontend/src/components/Studio/studioRunUtils.js b/frontend/src/components/Studio/studioRunUtils.js index 8ba7fc0..d152de3 100644 --- a/frontend/src/components/Studio/studioRunUtils.js +++ b/frontend/src/components/Studio/studioRunUtils.js @@ -26,3 +26,18 @@ export function findLastUserMessageIndex(stepMessages = []) { } return -1; } + +/** Whether undo is available for the active node state. */ +export function canUndoRun(activeNodeState) { + return (activeNodeState?.turnHistory?.length ?? 0) > 0; +} + +/** Whether reroll is available (at least one user message in current step). */ +export function canRerollRun(activeNodeState) { + return findLastUserMessageIndex(activeNodeState?.stepMessages) >= 0; +} + +/** Whether the user may advance to the next pipeline node (R5). */ +export function canAdvanceNextStep(activeNodeState) { + return Boolean(activeNodeState?.lastDraft); +}