From eda1db1f88aae8aa8322d1b2b33dca90c0d07e0b Mon Sep 17 00:00:00 2001 From: Marcus <1922576605@qq.com> Date: Sun, 27 Sep 2026 18:03:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E6=96=B0=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=20update=5Fcanvas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/actions/plan.py | 2 +- app/actions/registry.py | 2 ++ app/actions/update_canvas.py | 30 +++++++++++++++++ app/processor/graph.py | 9 ++--- app/processor/nodes/action_node.py | 47 ++++++++++++++++----------- app/processor/nodes/fallback_node.py | 9 +++-- app/processor/nodes/scope_node.py | 6 ++++ app/processor/nodes/validator_node.py | 26 +++++++++++---- app/prompt/action.py | 7 ++-- app/prompt/action_rules.py | 5 +++ app/schemas/actions/__init__.py | 4 ++- app/schemas/actions/update_canvas.py | 17 ++++++++++ 12 files changed, 128 insertions(+), 36 deletions(-) create mode 100644 app/actions/update_canvas.py create mode 100644 app/schemas/actions/update_canvas.py diff --git a/app/actions/plan.py b/app/actions/plan.py index 64da722..05dd171 100644 --- a/app/actions/plan.py +++ b/app/actions/plan.py @@ -23,7 +23,7 @@ def plan_block_reason(state: QueryState) -> str | None: if state["requirement"].needs_clarification: return "需求尚未澄清,不能生成规划动作" if plan.canvas_changes: - return "规划包含画布修改,当前尚未接入对应动作" + return "规划中的画布修改尚未接入,当前仅支持无需规划的画布修改" if plan.resource_needs: return "规划仍有待获取资源,不能直接生成动作" additions = [item for item in plan.components if item.disposition == "add"] diff --git a/app/actions/registry.py b/app/actions/registry.py index ea87bff..e0dc44a 100644 --- a/app/actions/registry.py +++ b/app/actions/registry.py @@ -8,6 +8,7 @@ from app.actions.delete import DeleteHandler from app.actions.move import MoveHandler from app.actions.resize import ResizeHandler from app.actions.update import UpdateHandler +from app.actions.update_canvas import UpdateCanvasHandler from app.schemas.actions import Action from app.schemas.screen import Screen @@ -18,6 +19,7 @@ ACTION_HANDLERS: dict[str, BaseActionHandler[Any]] = { "move": MoveHandler(), "resize": ResizeHandler(), "update": UpdateHandler(), + "update_canvas": UpdateCanvasHandler(), } diff --git a/app/actions/update_canvas.py b/app/actions/update_canvas.py new file mode 100644 index 0000000..af11d79 --- /dev/null +++ b/app/actions/update_canvas.py @@ -0,0 +1,30 @@ +"""在大屏副本上更新画布属性,检查修改后的 Canvas 是否有效""" + +from copy import deepcopy + +from app.actions.base import BaseActionHandler +from app.schemas.actions import UpdateCanvasAction +from app.schemas.screen import Canvas, Screen + + +class UpdateCanvasHandler(BaseActionHandler[UpdateCanvasAction]): + def apply(self, screen: Screen, action: UpdateCanvasAction) -> Screen: + properties = screen.canvas.model_dump() + # 顶层字段整体替换,嵌套对象不做深度合并 + properties.update(deepcopy(action.properties)) + canvas = Canvas.model_validate(properties) + result = screen.model_copy(deep=True) + result.canvas = canvas + return result + + +if __name__ == "__main__": + screen = Screen.model_validate( + {"canvas": {"width": 1920, "height": 1080}, "nodes": []} + ) + action = UpdateCanvasAction( + properties={"backgroundColor": "#000000", "width": 1600} + ) + result = UpdateCanvasHandler().apply(screen, action) + print(result.model_dump_json(indent=2)) + print("原始画布:", screen.canvas.model_dump()) diff --git a/app/processor/graph.py b/app/processor/graph.py index d07ab79..6520c21 100644 --- a/app/processor/graph.py +++ b/app/processor/graph.py @@ -41,8 +41,8 @@ def route_after_scope(state: QueryState) -> str: # 需要规划时先获取组件能力,供规划节点选择可用组件 if state["change_scope"].needs_planning: return "component_resource_node" - # 已支持的简单修改直接生成动作,尚不支持的需求暂时结束流程 - return "action_node" if can_generate_actions(state) else END + # 已支持的简单修改直接生成动作,尚不支持的需求进入失败收尾 + return "action_node" if can_generate_actions(state) else "fallback_node" def build_graph(checkpointer=None): @@ -129,13 +129,13 @@ def build_graph(checkpointer=None): { "component_resource_node": "component_resource_node", "action_node": "action_node", - END: END, + "fallback_node": "fallback_node", }, ) workflow.add_conditional_edges( "component_resource_node", lambda state: state["component_resources"].status == "resolved", - {True: "planner_node", False: END}, + {True: "planner_node", False: "fallback_node"}, ) workflow.add_edge("planner_node", "action_node") workflow.add_conditional_edges( @@ -185,6 +185,7 @@ def build_graph(checkpointer=None): ("app.schemas.actions.move", "MoveAction"), ("app.schemas.actions.resize", "ResizeAction"), ("app.schemas.actions.update", "UpdateAction"), + ("app.schemas.actions.update_canvas", "UpdateCanvasAction"), ("app.schemas.action_validation", "ActionValidationError"), ("app.schemas.action_repair", "ActionRepairPlan"), ("app.schemas.screen", "Screen"), diff --git a/app/processor/nodes/action_node.py b/app/processor/nodes/action_node.py index eb112e0..ca4be53 100644 --- a/app/processor/nodes/action_node.py +++ b/app/processor/nodes/action_node.py @@ -16,29 +16,38 @@ from app.schemas.actions import ActionBatch, AddAction def can_generate_actions(state: QueryState) -> bool: + return action_block_reason(state) is None + + +def action_block_reason(state: QueryState) -> str | None: + # 路由和失败说明共用能力判断,避免支持范围不一致 if state.get("screen_plan") is not None: - return plan_block_reason(state) is None + return plan_block_reason(state) + requirement = state["requirement"] scope = state["change_scope"] - # 简单操作直接生成动作,复杂变更走规划分支 - return ( - requirement.intent == "modify_screen" - and not requirement.needs_clarification - and not scope.needs_planning - and not scope.resource_needs - and bool(requirement.changes) - and all( + + if requirement.needs_clarification: + return "需求尚未澄清完整,不能生成动作" + if scope.needs_planning or requirement.intent != "modify_screen": + return "当前需求需要先完成规划,不能直接生成动作" + if scope.resource_needs: + return "尚未接入所需资源查询:" + ";".join(scope.resource_needs) + if not requirement.changes: + return "需求中没有可执行的变更" + unsupported = [ + f"{change.target_scope}/{change.operation}" + for change in requirement.changes + if not ( change.target_scope == "nodes" - and change.operation - in ( - "move", - "resize", - "update", - "delete", - ) - for change in requirement.changes + and change.operation in ("move", "resize", "update", "delete") + or change.target_scope == "canvas" + and change.operation in ("update", "resize", "update_canvas") ) - ) + ] + if unsupported: + return "当前尚未支持的直接操作:" + "、".join(dict.fromkeys(unsupported)) + return None class ActionNode(BaseNode): @@ -60,7 +69,7 @@ class ActionNode(BaseNode): return updated_state if not can_generate_actions(state): raise ValueError( - "ActionNode 当前只支持无需规划和资源的 move、resize、update、delete 需求" + "ActionNode 当前只支持无需规划和资源的 move、resize、update、delete、update_canvas 需求" ) payload = { "input_context": state["input_context"].model_dump(mode="json"), diff --git a/app/processor/nodes/fallback_node.py b/app/processor/nodes/fallback_node.py index a69490e..4ab9bc0 100644 --- a/app/processor/nodes/fallback_node.py +++ b/app/processor/nodes/fallback_node.py @@ -1,4 +1,4 @@ -"""停止自动修正并保留诊断信息""" +"""统一收尾能力不足、资源缺失和动作修正失败""" from app.processor.base import BaseNode from app.processor.state import QueryState @@ -11,9 +11,12 @@ class FallbackNode(BaseNode): updated_state: QueryState = state.copy() reason = state.get("failure_reason") if not reason: - reason = f"动作校验未通过,已达到自动修正上限,共重试 {state.get('retry_count', 0)} 次" + if state.get("validation_errors"): + reason = f"动作校验未通过,已达到自动修正上限,共重试 {state.get('retry_count', 0)} 次" + else: + reason = "当前需求未能完成,缺少具体失败原因" - # actions 仅保留作诊断,不交付部分结果 + # 失败动作仅保留作诊断,validation_passed=False 表示不可交付执行 updated_state["status"] = "failed" updated_state["failure_reason"] = reason updated_state["validation_passed"] = False diff --git a/app/processor/nodes/scope_node.py b/app/processor/nodes/scope_node.py index 2898d17..38506e2 100644 --- a/app/processor/nodes/scope_node.py +++ b/app/processor/nodes/scope_node.py @@ -6,6 +6,7 @@ from langchain_core.messages import HumanMessage, SystemMessage from app.clients.llm import get_llm from app.processor.base import BaseNode +from app.processor.nodes.action_node import action_block_reason from app.processor.state import QueryState from app.prompt.scope import SCOPE_SYSTEM_PROMPT from app.schemas.change_scope import ChangeScope @@ -55,6 +56,11 @@ class ScopeNode(BaseNode): raise ValueError(f"范围遗漏了需求中的节点: {sorted(missing_ids)}") updated_state["change_scope"] = scope + # 规划分支继续获取组件能力,直接操作分支提前记录缺失能力 + reason = None if scope.needs_planning else action_block_reason(updated_state) + updated_state["failure_reason"] = reason + updated_state["status"] = "failed" if reason else "running" + return updated_state diff --git a/app/processor/nodes/validator_node.py b/app/processor/nodes/validator_node.py index da20828..dff6448 100644 --- a/app/processor/nodes/validator_node.py +++ b/app/processor/nodes/validator_node.py @@ -7,7 +7,7 @@ from app.actions.registry import apply_action from app.processor.base import BaseNode from app.processor.state import QueryState from app.schemas.action_validation import ActionValidationError, schema_errors -from app.schemas.actions import ActionBatch, AddAction +from app.schemas.actions import ActionBatch, AddAction, UpdateCanvasAction class ValidatorNode(BaseNode): @@ -60,19 +60,33 @@ class ValidatorNode(BaseNode): updated_state["simulated_screen"] = None return updated_state allowed = { - (target, change.operation) + ("nodes", target, change.operation) for change in requirement.changes + if change.target_scope == "nodes" for target in change.target_ids } - actual = {(action.target_id, action.type) for action in batch.actions} + allowed.update( + ("canvas", "", "update_canvas") + for change in requirement.changes + if change.target_scope == "canvas" + and change.operation in ("update", "resize", "update_canvas") + ) + # 画布没有组件 ID,使用独立作用域避免与同名组件混淆 + targets = [ + ("canvas", "", action.type) + if isinstance(action, UpdateCanvasAction) + else ("nodes", action.target_id, action.type) + for action in batch.actions + ] + actual = set(targets) errors = [ ActionValidationError( action_index=index, code="scope_error", - message=f"动作超出需求范围: {action.target_id} / {action.type}", + message=f"动作超出需求范围: {' / '.join(target)}", ) - for index, action in enumerate(batch.actions) - if (action.target_id, action.type) not in allowed + for index, target in enumerate(targets) + if target not in allowed ] if missing := allowed - actual: errors.append( diff --git a/app/prompt/action.py b/app/prompt/action.py index 841c5a3..df168fc 100644 --- a/app/prompt/action.py +++ b/app/prompt/action.py @@ -20,7 +20,7 @@ input_context 提供当前大屏快照、用户原文及编辑上下文 """ SIMPLE_TASK_PROMPT = """# 当前任务:直接修改 -将 requirement.changes 转换成对应动作,只修改其中指定的已有组件和属性 +将 requirement.changes 转换成对应动作,只修改其中指定的组件或画布属性 """ PLAN_TASK_PROMPT = """# 当前任务:按规划生成 @@ -52,7 +52,10 @@ def build_action_prompt( sections = [ACTION_SYSTEM_PROMPT] if screen_plan is None: sections.append(SIMPLE_TASK_PROMPT) - action_types = {change.operation for change in requirement.changes} + action_types = { + "update_canvas" if change.target_scope == "canvas" else change.operation + for change in requirement.changes + } else: sections.append(PLAN_TASK_PROMPT) action_types = set() diff --git a/app/prompt/action_rules.py b/app/prompt/action_rules.py index cc302e8..94e91b2 100644 --- a/app/prompt/action_rules.py +++ b/app/prompt/action_rules.py @@ -2,6 +2,11 @@ # 新增动作时在这里补充模型需要理解的执行语义 ACTION_RULES: dict[str, str] = { + "update_canvas": """## update_canvas:更新画布属性 +将 canvas 范围的 update、resize 或 update_canvas 需求转换为此动作,不填写 target_id +properties 只包含需要修改的画布顶层字段,宽高为最终绝对值,相对变化依据当前画布换算 +顶层字段整体替换,未提供的字段保持不变;改变画布尺寸不会自动缩放或移动组件 +""", "delete": """## delete:删除已有组件 使用真实 target_id,只删除需求明确指定的组件;规划中的 remove 对应 delete 每个目标仅删除一次,删除后不能再移动、调整尺寸或更新该目标 diff --git a/app/schemas/actions/__init__.py b/app/schemas/actions/__init__.py index 004edb1..db6b701 100644 --- a/app/schemas/actions/__init__.py +++ b/app/schemas/actions/__init__.py @@ -10,10 +10,11 @@ from .delete import DeleteAction from .move import MoveAction from .resize import ResizeAction from .update import UpdateAction +from .update_canvas import UpdateCanvasAction # 按 type 选择对应模型,不维护重复的操作名称映射 Action = Annotated[ - AddAction | DeleteAction | MoveAction | ResizeAction | UpdateAction, + AddAction | DeleteAction | MoveAction | ResizeAction | UpdateAction | UpdateCanvasAction, Field(discriminator="type"), ] action_adapter = TypeAdapter(Action) @@ -32,5 +33,6 @@ __all__ = [ "MoveAction", "ResizeAction", "UpdateAction", + "UpdateCanvasAction", "action_adapter", ] diff --git a/app/schemas/actions/update_canvas.py b/app/schemas/actions/update_canvas.py new file mode 100644 index 0000000..a37dea8 --- /dev/null +++ b/app/schemas/actions/update_canvas.py @@ -0,0 +1,17 @@ +"""仅更新画布属性,不修改组件或数据源""" + +from typing import Literal + +from pydantic import Field, JsonValue + +from app.schemas.base import NonBlankString + +from .base import ActionModel + + +class UpdateCanvasAction(ActionModel): + type: Literal["update_canvas"] = "update_canvas" + properties: dict[NonBlankString, JsonValue] = Field( + min_length=1, + description="要替换的 canvas 顶层字段,如 width、height、backgroundColor;未提供的字段保持不变", + )