feat(studio): 回退/重roll 与 R5 用户确认下一步
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user