Studio: 增量/覆盖保存、节点切换与绑定编辑弹窗

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 23:28:49 +08:00
parent 0f50c98cf3
commit 71e673a2ed
13 changed files with 1504 additions and 294 deletions

View File

@@ -376,14 +376,115 @@ class StudioRunService:
except OSError as exc:
raise ValueError(f"写入世界书失败:{exc}") from exc
def _overwrite_worldbook_entry(
self,
worldbook_name: str,
node: StudioNode,
draft: Dict[str, Any],
entry_uid: str,
) -> Dict[str, Any]:
entry_payload = self._build_worldbook_entry_payload(node, draft)
try:
return worldbook_service.update_entry(
worldbook_name, entry_uid, entry_payload
)
except FileNotFoundError as exc:
raise ValueError(f"世界书条目不存在或无法更新:{exc}") from exc
except ValueError as exc:
raise ValueError(f"覆盖世界书失败:{exc}") from exc
except OSError as exc:
raise ValueError(f"覆盖世界书失败:{exc}") from exc
@staticmethod
def _node_has_written_entry(state: StudioNodeRunState) -> bool:
draft = state.lastDraft or {}
return bool(draft.get("writtenEntryUid")) or state.status == "completed"
def _save_worldbook_entry_only(
self,
project_id: str,
run_id: str,
run: StudioRun,
node: StudioNode,
state: StudioNodeRunState,
node_id: str,
save_mode: str,
) -> StudioRun:
if not state.lastDraft:
raise ValueError("请先生成并确认当前产物后再保存")
worldbook_name = self._resolve_bound_worldbook_name(project_id, run)
draft = state.lastDraft
if save_mode == "append":
entry_payload = self._build_worldbook_entry_payload(node, draft)
if (draft.get("writtenEntryUid") or "").strip():
try:
wb_data = worldbook_service.get_worldbook(worldbook_name)
entries = wb_data.get("entries") or []
max_order = max(
(int(e.get("order", 0)) for e in entries),
default=0,
)
entry_payload["order"] = max_order + 1
except (TypeError, ValueError):
entry_payload["order"] = int(entry_payload.get("order", 100)) + 1
written_entry = worldbook_service.append_entry(
worldbook_name, entry_payload
)
else:
written_entry = self._write_worldbook_entry_on_advance(
worldbook_name, node, draft
)
elif save_mode == "overwrite":
entry_uid = (draft.get("writtenEntryUid") or "").strip()
if not entry_uid:
raise ValueError(
"尚未写入过世界书条目,请使用「下一步」完成首次导出,或使用「增量保存」"
)
written_entry = self._overwrite_worldbook_entry(
worldbook_name, node, draft, entry_uid
)
else:
raise ValueError(f"不支持的保存模式:{save_mode}")
last_draft = copy.deepcopy(draft)
last_draft["writtenEntryUid"] = written_entry.get("uid")
last_draft["writtenWorldbookName"] = worldbook_name
now = datetime.now().isoformat()
new_node_states: list[StudioNodeRunState] = []
for ns in run.nodeStates:
updated = ns.model_copy()
if ns.nodeId == node_id:
updated.lastDraft = last_draft
new_node_states.append(updated)
updated_run = run.model_copy(
update={
"nodeStates": new_node_states,
"updatedAt": now,
}
)
self._save_run(project_id, run_id, updated_run)
return updated_run
def advance_run(
self,
project_id: str,
run_id: str,
display_params: Optional[Dict[str, str]] = None,
save_mode: str = "advance",
) -> StudioRun:
run = self.get_run(project_id, run_id)
if run.status not in (StudioRunStatus.RUNNING, StudioRunStatus.PENDING):
if save_mode in ("append", "overwrite"):
if run.status not in (
StudioRunStatus.RUNNING,
StudioRunStatus.PENDING,
StudioRunStatus.COMPLETED,
):
raise ValueError("运行已结束,无法保存")
elif run.status not in (StudioRunStatus.RUNNING, StudioRunStatus.PENDING):
raise ValueError("运行已结束,无法继续推进")
current_node_id = run.currentNodeId
@@ -397,7 +498,25 @@ class StudioRunService:
current_state = next(
(s for s in run.nodeStates if s.nodeId == current_node_id), None
)
if not current_state or current_state.status != "active":
if not current_state:
raise ValueError("当前节点状态不存在")
if save_mode in ("append", "overwrite"):
if current_node.skillId != "studio.worldbook_entry":
raise ValueError("当前步骤不支持增量/覆盖保存")
if current_state.status not in ("active", "completed"):
raise ValueError("当前节点不可保存")
return self._save_worldbook_entry_only(
project_id,
run_id,
run,
current_node,
current_state,
current_node_id,
save_mode,
)
if current_state.status != "active":
raise ValueError("当前节点不可执行")
workflow_variables = dict(run.workflowVariables or {})
@@ -456,6 +575,80 @@ class StudioRunService:
self._save_run(project_id, run_id, updated_run)
return updated_run
def save_run(
self, project_id: str, run_id: str, mode: str
) -> StudioRun:
"""Save worldbook entry without advancing pipeline (incremental or overwrite)."""
mode_map = {"incremental": "append", "overwrite": "overwrite"}
if mode not in mode_map:
raise ValueError(f"不支持的保存模式:{mode}")
return self.advance_run(
project_id, run_id, save_mode=mode_map[mode]
)
def switch_run_node(
self, project_id: str, run_id: str, node_id: str
) -> StudioRun:
"""Manually focus a pipeline node (e.g. revisit a completed worldbook step)."""
run = self.get_run(project_id, run_id)
if run.status not in (
StudioRunStatus.RUNNING,
StudioRunStatus.PENDING,
StudioRunStatus.COMPLETED,
):
raise ValueError("运行已结束,无法切换节点")
target_node = _find_node(run.pipelineSnapshot, node_id)
if not target_node:
raise ValueError(f"节点不存在:{node_id}")
if not target_node.enabled:
raise ValueError("该节点已禁用,无法切换")
if target_node.skillId != "studio.worldbook_entry":
raise ValueError("仅支持切换到世界书创作步骤")
target_state = next(
(s for s in run.nodeStates if s.nodeId == node_id), None
)
if not target_state:
raise ValueError("节点状态不存在")
if target_state.status not in ("active", "completed"):
raise ValueError("仅可切换到进行中或已完成的步骤")
prev_node_id = run.currentNodeId
now = datetime.now().isoformat()
new_node_states: list[StudioNodeRunState] = []
for state in run.nodeStates:
updated = state.model_copy()
if state.nodeId == node_id:
updated.status = "active"
elif prev_node_id and state.nodeId == prev_node_id and prev_node_id != node_id:
if state.skillId == "studio.worldbook_entry":
if self._node_has_written_entry(state):
updated.status = "completed"
else:
updated.status = "pending"
new_node_states.append(updated)
new_status = (
StudioRunStatus.COMPLETED
if run.status == StudioRunStatus.COMPLETED
and not _next_enabled_node_id(run.pipelineSnapshot, node_id)
else StudioRunStatus.RUNNING
)
updated_run = run.model_copy(
update={
"currentNodeId": node_id,
"nodeStates": new_node_states,
"status": new_status,
"updatedAt": now,
}
)
updated_run = store_context_on_run(updated_run, node_id)
self._save_run(project_id, run_id, updated_run)
return updated_run
async def send_run_message(
self,
project_id: str,