feat(studio): 回退/重roll 与 R5 用户确认下一步

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 22:27:25 +08:00
parent f3792915a3
commit 63a32bfa7c
8 changed files with 613 additions and 82 deletions

View File

@@ -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