"""整批模拟执行:新增组件补全物料默认配置,再按顺序在大屏副本上执行,遇到第一个错误即停止 纯函数,不依赖 agent;agent 的 apply_actions 工具和测试共用 """ from copy import deepcopy from app.actions.layout import EXPANDERS from app.actions.registry import apply_action from app.actions.update import fit_existing_path from app.resources.material_fields import check_update_field, find_material from app.schemas.actions import Action, AddAction, AgentAction, UpdateAction from app.schemas.capabilities import DATA_BINDING_ERROR, DATA_BINDING_SUPPORTED from app.schemas.component_resource import ComponentCatalog from app.schemas.screen import Screen, ScreenNode class BatchError(ValueError): """整批中第 index 个动作(从 0 开始)失败,消息里带上序号和目标,模型据此修正""" def __init__(self, index: int, action: AgentAction, reason: str): self.index = index self.reason = reason if isinstance(action, AddAction): target = action.node.id elif ids := getattr(action, "target_ids", None): target = "、".join(ids) else: target = getattr(action, "target_id", "画布") super().__init__(f"第 {index + 1} 个动作({action.type} {target}):{reason}") def merge_config(defaults: dict, overrides: dict) -> dict: """对象逐层覆盖,数组和标量整体替换,不修改传入的数据""" result = deepcopy(defaults) for key, value in overrides.items(): result[key] = ( merge_config(result[key], value) if isinstance(result.get(key), dict) and isinstance(value, dict) else deepcopy(value) ) return result def complete_add(action: AddAction, catalog: ComponentCatalog) -> AddAction: """按 node.type 找物料补全新增组件,模型显式写出的字段优先 sample_data 模型写了就以模型为准(只有它知道是否换成了自己的数据),没写时按物料设置 """ material = find_material(catalog, action.node.type) if not DATA_BINDING_SUPPORTED and action.node.model_extra.get("dataId"): raise ValueError(DATA_BINDING_ERROR) overrides = action.node.model_dump(mode="json", exclude_unset=True) node = ScreenNode.model_validate(merge_config(material.template(), overrides)) sample_data = ( action.sample_data if "sample_data" in action.model_fields_set else material.sample_data ) return action.model_copy(update={"node": node, "sample_data": sample_data}) def run_batch( screen: Screen, catalog: ComponentCatalog, actions: list[AgentAction] ) -> tuple[list[Action], Screen]: """从原始大屏开始按顺序模拟执行,返回前端要执行的动作和执行后的大屏;第一个错误抛 BatchError 布局意图(arrange、align、distribute、set_layout)在这里按当时的大屏展开成 move 和 resize """ current = screen.model_copy(deep=True) completed: list[Action] = [] for index, action in enumerate(actions): try: if expander := EXPANDERS.get(action.type): expanded = expander(current, action) for primitive in expanded: current = apply_action(current, primitive) completed.extend(expanded) continue if isinstance(action, AddAction): action = complete_add(action, catalog) elif isinstance(action, UpdateAction): # 目标不存在时交给执行器报错 node = next( (item for item in current.nodes if item.id == action.target_id), None, ) if node is not None: check_update_field(catalog, node.type, action) # 字段定义允许、但组件中还没有的中间对象,折叠进值里再执行 action = fit_existing_path(node, action) current = apply_action(current, action) except ValueError as error: # 含 pydantic ValidationError raise BatchError(index, action, str(error)) from error completed.append(action) return completed, current