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,
|
PipelineDefinition,
|
||||||
RenameRunRequest,
|
RenameRunRequest,
|
||||||
RunMessageRequest,
|
RunMessageRequest,
|
||||||
|
RunRerollRequest,
|
||||||
StudioProject,
|
StudioProject,
|
||||||
StudioProjectSummary,
|
StudioProjectSummary,
|
||||||
StudioRun,
|
StudioRun,
|
||||||
@@ -249,6 +250,79 @@ async def send_studio_run_message(
|
|||||||
raise HTTPException(status_code=500, detail=f"消息处理失败:{e}")
|
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}")
|
@router.delete("/projects/{project_id}/runs/{run_id}")
|
||||||
async def delete_studio_run(project_id: str, run_id: str):
|
async def delete_studio_run(project_id: str, run_id: str):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -183,6 +183,14 @@ class PromptBlock(BaseModel):
|
|||||||
source: str = "auto" # auto | manual | workflow
|
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):
|
class StudioNodeRunState(BaseModel):
|
||||||
nodeId: str
|
nodeId: str
|
||||||
displayName: str
|
displayName: str
|
||||||
@@ -192,6 +200,7 @@ class StudioNodeRunState(BaseModel):
|
|||||||
lastDraft: Optional[Dict[str, Any]] = None
|
lastDraft: Optional[Dict[str, Any]] = None
|
||||||
lastToolResponse: Optional[LastToolResponse] = None
|
lastToolResponse: Optional[LastToolResponse] = None
|
||||||
stepMessages: List[StepMessage] = Field(default_factory=list)
|
stepMessages: List[StepMessage] = Field(default_factory=list)
|
||||||
|
turnHistory: List[TurnSnapshot] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class StudioRun(BaseModel):
|
class StudioRun(BaseModel):
|
||||||
@@ -220,6 +229,12 @@ class RunMessageRequest(BaseModel):
|
|||||||
apiConfig: Optional[Dict[str, str]] = None
|
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):
|
class RenameRunRequest(BaseModel):
|
||||||
title: str = Field(..., min_length=1, max_length=120)
|
title: str = Field(..., min_length=1, max_length=120)
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from models.studio_models import (
|
|||||||
StudioRun,
|
StudioRun,
|
||||||
StudioRunStatus,
|
StudioRunStatus,
|
||||||
StudioRunSummary,
|
StudioRunSummary,
|
||||||
|
TurnSnapshot,
|
||||||
)
|
)
|
||||||
from services.character_service import CharacterService
|
from services.character_service import CharacterService
|
||||||
from services.studio_context_service import assemble_prompt_blocks, store_context_on_run
|
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
|
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:
|
class StudioRunService:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._character_service = CharacterService()
|
self._character_service = CharacterService()
|
||||||
@@ -272,6 +311,9 @@ class StudioRunService:
|
|||||||
workflow_variables, last_draft = self._execute_init_bind(
|
workflow_variables, last_draft = self._execute_init_bind(
|
||||||
project_id, run, current_node, display_params
|
project_id, run, current_node, display_params
|
||||||
)
|
)
|
||||||
|
elif current_node.skillId == "studio.worldbook_entry":
|
||||||
|
if not current_state.lastDraft:
|
||||||
|
raise ValueError("请先生成并确认当前产物后再进入下一步")
|
||||||
else:
|
else:
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
f"技能「{current_node.skillId}」的执行尚未实现(R2+)"
|
f"技能「{current_node.skillId}」的执行尚未实现(R2+)"
|
||||||
@@ -414,15 +456,14 @@ class StudioRunService:
|
|||||||
"run": updated_run.model_dump(mode="json"),
|
"run": updated_run.model_dump(mode="json"),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _prepare_run_message(
|
def _prepare_active_worldbook_step(
|
||||||
self,
|
self,
|
||||||
project_id: str,
|
project_id: str,
|
||||||
run_id: str,
|
run_id: str,
|
||||||
trimmed: str,
|
|
||||||
) -> tuple[StudioRun, StudioNode, StudioNodeRunState, str]:
|
) -> tuple[StudioRun, StudioNode, StudioNodeRunState, str]:
|
||||||
run = self.get_run(project_id, run_id)
|
run = self.get_run(project_id, run_id)
|
||||||
if run.status != StudioRunStatus.RUNNING:
|
if run.status != StudioRunStatus.RUNNING:
|
||||||
raise ValueError("运行未处于进行中,无法发送消息")
|
raise ValueError("运行未处于进行中,无法操作")
|
||||||
|
|
||||||
current_node_id = run.currentNodeId
|
current_node_id = run.currentNodeId
|
||||||
if not current_node_id:
|
if not current_node_id:
|
||||||
@@ -445,6 +486,16 @@ class StudioRunService:
|
|||||||
|
|
||||||
return run, current_node, current_state, current_node_id
|
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(
|
def _persist_run_message_turn(
|
||||||
self,
|
self,
|
||||||
project_id: str,
|
project_id: str,
|
||||||
@@ -457,6 +508,8 @@ class StudioRunService:
|
|||||||
last_tool_response,
|
last_tool_response,
|
||||||
user_msg,
|
user_msg,
|
||||||
assistant_msg,
|
assistant_msg,
|
||||||
|
*,
|
||||||
|
push_snapshot: bool = True,
|
||||||
) -> StudioRun:
|
) -> StudioRun:
|
||||||
step_messages = list(step_messages)
|
step_messages = list(step_messages)
|
||||||
step_messages.extend([user_msg, assistant_msg])
|
step_messages.extend([user_msg, assistant_msg])
|
||||||
@@ -466,6 +519,205 @@ class StudioRunService:
|
|||||||
for state in run.nodeStates:
|
for state in run.nodeStates:
|
||||||
updated = state.model_copy()
|
updated = state.model_copy()
|
||||||
if state.nodeId == current_node_id:
|
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.lastDraft = last_draft
|
||||||
updated.lastToolResponse = last_tool_response
|
updated.lastToolResponse = last_tool_response
|
||||||
updated.stepMessages = step_messages
|
updated.stepMessages = step_messages
|
||||||
|
|||||||
@@ -2,6 +2,79 @@ import { create } from 'zustand';
|
|||||||
|
|
||||||
const DEFAULT_TEMPLATE_ID = 'builtin.studio.example';
|
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) {
|
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;
|
||||||
@@ -497,24 +570,7 @@ const useStudioStore = create((set, get) => ({
|
|||||||
if (!runProjectId || !currentRunId) return null;
|
if (!runProjectId || !currentRunId) return null;
|
||||||
set({ runMessaging: true, runStreamingThinking: null, runError: null });
|
set({ runMessaging: true, runStreamingThinking: null, runError: null });
|
||||||
try {
|
try {
|
||||||
let profileId = null;
|
const { profileId, apiConfig } = await resolveStudioApiConfig();
|
||||||
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 res = await fetch(
|
const res = await fetch(
|
||||||
`/api/studio/projects/${encodeURIComponent(runProjectId)}/runs/${encodeURIComponent(currentRunId)}/message`,
|
`/api/studio/projects/${encodeURIComponent(runProjectId)}/runs/${encodeURIComponent(currentRunId)}/message`,
|
||||||
@@ -531,56 +587,11 @@ const useStudioStore = create((set, get) => ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let detail = await res.text();
|
throw new Error((await parseStudioErrorResponse(res)) || '发送消息失败');
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(detail);
|
|
||||||
detail = parsed.detail || detail;
|
|
||||||
} catch {
|
|
||||||
/* keep raw text */
|
|
||||||
}
|
|
||||||
throw new Error(detail || '发送消息失败');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stream && res.body) {
|
if (stream && res.body) {
|
||||||
const reader = res.body.getReader();
|
const finalRun = await consumeStudioStreamResponse(res, set);
|
||||||
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('流式响应未完成');
|
|
||||||
}
|
|
||||||
|
|
||||||
set({
|
set({
|
||||||
currentRun: finalRun,
|
currentRun: finalRun,
|
||||||
runMessaging: false,
|
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) => {
|
deleteRun: async (runId) => {
|
||||||
const projectId = get().runProjectId;
|
const projectId = get().runProjectId;
|
||||||
if (!projectId || !runId) return false;
|
if (!projectId || !runId) return false;
|
||||||
|
|||||||
@@ -49,17 +49,15 @@ function StudioRunChat({
|
|||||||
|
|
||||||
const lastUserIdx = findLastUserMessageIndex(stepMessages);
|
const lastUserIdx = findLastUserMessageIndex(stepMessages);
|
||||||
const historyMessages =
|
const historyMessages =
|
||||||
lastUserIdx > 0 ? stepMessages.slice(0, lastUserIdx) : [];
|
lastUserIdx > 0
|
||||||
|
? stepMessages.slice(0, lastUserIdx).filter((msg) => msg.role === 'user')
|
||||||
|
: [];
|
||||||
const lastUserMessage =
|
const lastUserMessage =
|
||||||
lastUserIdx >= 0 ? stepMessages[lastUserIdx] : pendingUserText
|
lastUserIdx >= 0 ? stepMessages[lastUserIdx] : pendingUserText
|
||||||
? { role: 'user', content: pendingUserText }
|
? { role: 'user', content: pendingUserText }
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const summaryText =
|
const summaryText = evaluation || null;
|
||||||
evaluation ||
|
|
||||||
(lastUserIdx >= 0 && stepMessages[lastUserIdx + 1]?.role === 'assistant'
|
|
||||||
? stepMessages[lastUserIdx + 1].content
|
|
||||||
: null);
|
|
||||||
|
|
||||||
const hasFocusContent =
|
const hasFocusContent =
|
||||||
thinking || summaryText || pendingUserText || sending || lastUserMessage;
|
thinking || summaryText || pendingUserText || sending || lastUserMessage;
|
||||||
@@ -124,11 +122,9 @@ function StudioRunChat({
|
|||||||
{historyMessages.map((msg) => (
|
{historyMessages.map((msg) => (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
className={`studio-run-chat-history-item role-${msg.role}`}
|
className="studio-run-chat-history-item role-user"
|
||||||
>
|
>
|
||||||
<span className="studio-run-chat-history-role">
|
<span className="studio-run-chat-history-role">你</span>
|
||||||
{msg.role === 'user' ? '你' : '助手'}
|
|
||||||
</span>
|
|
||||||
<span className="studio-run-chat-history-text">{msg.content}</span>
|
<span className="studio-run-chat-history-text">{msg.content}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -752,6 +752,43 @@
|
|||||||
cursor: not-allowed;
|
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 {
|
.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 } from './studioRunUtils';
|
import { resolveBoundCharacterName, canUndoRun, canRerollRun, canAdvanceNextStep } from './studioRunUtils';
|
||||||
import useCharacterStore from '../../Store/SideBarLeft/CharacterSlice';
|
import useCharacterStore from '../../Store/SideBarLeft/CharacterSlice';
|
||||||
|
|
||||||
import StudioContextBlockPopup from './StudioContextBlockPopup';
|
import StudioContextBlockPopup from './StudioContextBlockPopup';
|
||||||
@@ -469,6 +469,8 @@ function StudioRunPage() {
|
|||||||
selectRun,
|
selectRun,
|
||||||
advanceRun,
|
advanceRun,
|
||||||
sendRunMessage,
|
sendRunMessage,
|
||||||
|
undoRun,
|
||||||
|
rerollRun,
|
||||||
deleteRun,
|
deleteRun,
|
||||||
renameRun,
|
renameRun,
|
||||||
} = useStudioStore();
|
} = useStudioStore();
|
||||||
@@ -629,8 +631,26 @@ function StudioRunPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNextStep = () => {
|
const handleUndo = async () => {
|
||||||
window.alert('(R5 占位:确认当前产物后将进入下一步)');
|
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) => {
|
const handleDeleteRun = async (runId) => {
|
||||||
@@ -659,6 +679,10 @@ function StudioRunPage() {
|
|||||||
const inSession = runSessionEntered && !!currentRun;
|
const inSession = runSessionEntered && !!currentRun;
|
||||||
const showToolCarousel = inSession && isWorldbookActive && toolQuestions.length > 0;
|
const showToolCarousel = inSession && isWorldbookActive && toolQuestions.length > 0;
|
||||||
const showNextStep = inSession && isWorldbookActive;
|
const showNextStep = inSession && isWorldbookActive;
|
||||||
|
const canUndo = canUndoRun(activeNodeState);
|
||||||
|
const canReroll = canRerollRun(activeNodeState);
|
||||||
|
const canNextStep = canAdvanceNextStep(activeNodeState);
|
||||||
|
const showTurnActions = inSession && isWorldbookActive;
|
||||||
|
|
||||||
const renderRunList = () => (
|
const renderRunList = () => (
|
||||||
<>
|
<>
|
||||||
@@ -881,13 +905,40 @@ function StudioRunPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showTurnActions && (
|
||||||
|
<div className="studio-run-right-section studio-run-turn-actions">
|
||||||
|
<h2 className="studio-run-section-title">回合控制</h2>
|
||||||
|
<div className="studio-run-turn-actions__row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="studio-run-turn-btn studio-run-turn-btn--undo"
|
||||||
|
onClick={handleUndo}
|
||||||
|
disabled={runAdvancing || runMessaging || !canUndo}
|
||||||
|
title={canUndo ? '回退到上一回合' : '无可回退的回合'}
|
||||||
|
>
|
||||||
|
回退
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="studio-run-turn-btn studio-run-turn-btn--reroll"
|
||||||
|
onClick={handleReroll}
|
||||||
|
disabled={runAdvancing || runMessaging || !canReroll}
|
||||||
|
title={canReroll ? '用相同输入重新生成' : '尚无用户消息'}
|
||||||
|
>
|
||||||
|
重roll
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showNextStep && (
|
{showNextStep && (
|
||||||
<div className="studio-run-right-section studio-run-right-actions">
|
<div className="studio-run-right-section studio-run-right-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="studio-run-next-btn"
|
className="studio-run-next-btn"
|
||||||
onClick={handleNextStep}
|
onClick={handleNextStep}
|
||||||
disabled={runAdvancing || runMessaging}
|
disabled={runAdvancing || runMessaging || !canNextStep}
|
||||||
|
title={canNextStep ? '确认当前产物并进入下一节点' : '请先生成当前步骤产物'}
|
||||||
>
|
>
|
||||||
下一步
|
下一步
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -26,3 +26,18 @@ export function findLastUserMessageIndex(stepMessages = []) {
|
|||||||
}
|
}
|
||||||
return -1;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user