feat: 接入新动作 update_canvas

This commit is contained in:
2026-09-27 18:03:48 +08:00
parent 216051cd8d
commit eda1db1f88
12 changed files with 128 additions and 36 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ def plan_block_reason(state: QueryState) -> str | None:
if state["requirement"].needs_clarification: if state["requirement"].needs_clarification:
return "需求尚未澄清,不能生成规划动作" return "需求尚未澄清,不能生成规划动作"
if plan.canvas_changes: if plan.canvas_changes:
return "规划包含画布修改,当前尚未接入对应动作" return "规划中的画布修改尚未接入,当前仅支持无需规划的画布修改"
if plan.resource_needs: if plan.resource_needs:
return "规划仍有待获取资源,不能直接生成动作" return "规划仍有待获取资源,不能直接生成动作"
additions = [item for item in plan.components if item.disposition == "add"] additions = [item for item in plan.components if item.disposition == "add"]
+2
View File
@@ -8,6 +8,7 @@ from app.actions.delete import DeleteHandler
from app.actions.move import MoveHandler from app.actions.move import MoveHandler
from app.actions.resize import ResizeHandler from app.actions.resize import ResizeHandler
from app.actions.update import UpdateHandler from app.actions.update import UpdateHandler
from app.actions.update_canvas import UpdateCanvasHandler
from app.schemas.actions import Action from app.schemas.actions import Action
from app.schemas.screen import Screen from app.schemas.screen import Screen
@@ -18,6 +19,7 @@ ACTION_HANDLERS: dict[str, BaseActionHandler[Any]] = {
"move": MoveHandler(), "move": MoveHandler(),
"resize": ResizeHandler(), "resize": ResizeHandler(),
"update": UpdateHandler(), "update": UpdateHandler(),
"update_canvas": UpdateCanvasHandler(),
} }
+30
View File
@@ -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())
+5 -4
View File
@@ -41,8 +41,8 @@ def route_after_scope(state: QueryState) -> str:
# 需要规划时先获取组件能力,供规划节点选择可用组件 # 需要规划时先获取组件能力,供规划节点选择可用组件
if state["change_scope"].needs_planning: if state["change_scope"].needs_planning:
return "component_resource_node" 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): def build_graph(checkpointer=None):
@@ -129,13 +129,13 @@ def build_graph(checkpointer=None):
{ {
"component_resource_node": "component_resource_node", "component_resource_node": "component_resource_node",
"action_node": "action_node", "action_node": "action_node",
END: END, "fallback_node": "fallback_node",
}, },
) )
workflow.add_conditional_edges( workflow.add_conditional_edges(
"component_resource_node", "component_resource_node",
lambda state: state["component_resources"].status == "resolved", 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_edge("planner_node", "action_node")
workflow.add_conditional_edges( workflow.add_conditional_edges(
@@ -185,6 +185,7 @@ def build_graph(checkpointer=None):
("app.schemas.actions.move", "MoveAction"), ("app.schemas.actions.move", "MoveAction"),
("app.schemas.actions.resize", "ResizeAction"), ("app.schemas.actions.resize", "ResizeAction"),
("app.schemas.actions.update", "UpdateAction"), ("app.schemas.actions.update", "UpdateAction"),
("app.schemas.actions.update_canvas", "UpdateCanvasAction"),
("app.schemas.action_validation", "ActionValidationError"), ("app.schemas.action_validation", "ActionValidationError"),
("app.schemas.action_repair", "ActionRepairPlan"), ("app.schemas.action_repair", "ActionRepairPlan"),
("app.schemas.screen", "Screen"), ("app.schemas.screen", "Screen"),
+28 -19
View File
@@ -16,29 +16,38 @@ from app.schemas.actions import ActionBatch, AddAction
def can_generate_actions(state: QueryState) -> bool: 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: if state.get("screen_plan") is not None:
return plan_block_reason(state) is None return plan_block_reason(state)
requirement = state["requirement"] requirement = state["requirement"]
scope = state["change_scope"] scope = state["change_scope"]
# 简单操作直接生成动作,复杂变更走规划分支
return ( if requirement.needs_clarification:
requirement.intent == "modify_screen" return "需求尚未澄清完整,不能生成动作"
and not requirement.needs_clarification if scope.needs_planning or requirement.intent != "modify_screen":
and not scope.needs_planning return "当前需求需要先完成规划,不能直接生成动作"
and not scope.resource_needs if scope.resource_needs:
and bool(requirement.changes) return "尚未接入所需资源查询:" + ";".join(scope.resource_needs)
and all( if not requirement.changes:
return "需求中没有可执行的变更"
unsupported = [
f"{change.target_scope}/{change.operation}"
for change in requirement.changes
if not (
change.target_scope == "nodes" change.target_scope == "nodes"
and change.operation and change.operation in ("move", "resize", "update", "delete")
in ( or change.target_scope == "canvas"
"move", and change.operation in ("update", "resize", "update_canvas")
"resize",
"update",
"delete",
)
for change in requirement.changes
) )
) ]
if unsupported:
return "当前尚未支持的直接操作:" + "、".join(dict.fromkeys(unsupported))
return None
class ActionNode(BaseNode): class ActionNode(BaseNode):
@@ -60,7 +69,7 @@ class ActionNode(BaseNode):
return updated_state return updated_state
if not can_generate_actions(state): if not can_generate_actions(state):
raise ValueError( raise ValueError(
"ActionNode 当前只支持无需规划和资源的 move、resize、update、delete 需求" "ActionNode 当前只支持无需规划和资源的 move、resize、update、delete、update_canvas 需求"
) )
payload = { payload = {
"input_context": state["input_context"].model_dump(mode="json"), "input_context": state["input_context"].model_dump(mode="json"),
+6 -3
View File
@@ -1,4 +1,4 @@
"""停止自动修正并保留诊断信息""" """统一收尾能力不足、资源缺失和动作修正失败"""
from app.processor.base import BaseNode from app.processor.base import BaseNode
from app.processor.state import QueryState from app.processor.state import QueryState
@@ -11,9 +11,12 @@ class FallbackNode(BaseNode):
updated_state: QueryState = state.copy() updated_state: QueryState = state.copy()
reason = state.get("failure_reason") reason = state.get("failure_reason")
if not 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["status"] = "failed"
updated_state["failure_reason"] = reason updated_state["failure_reason"] = reason
updated_state["validation_passed"] = False updated_state["validation_passed"] = False
+6
View File
@@ -6,6 +6,7 @@ from langchain_core.messages import HumanMessage, SystemMessage
from app.clients.llm import get_llm from app.clients.llm import get_llm
from app.processor.base import BaseNode from app.processor.base import BaseNode
from app.processor.nodes.action_node import action_block_reason
from app.processor.state import QueryState from app.processor.state import QueryState
from app.prompt.scope import SCOPE_SYSTEM_PROMPT from app.prompt.scope import SCOPE_SYSTEM_PROMPT
from app.schemas.change_scope import ChangeScope from app.schemas.change_scope import ChangeScope
@@ -55,6 +56,11 @@ class ScopeNode(BaseNode):
raise ValueError(f"范围遗漏了需求中的节点: {sorted(missing_ids)}") raise ValueError(f"范围遗漏了需求中的节点: {sorted(missing_ids)}")
updated_state["change_scope"] = scope 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 return updated_state
+20 -6
View File
@@ -7,7 +7,7 @@ from app.actions.registry import apply_action
from app.processor.base import BaseNode from app.processor.base import BaseNode
from app.processor.state import QueryState from app.processor.state import QueryState
from app.schemas.action_validation import ActionValidationError, schema_errors 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): class ValidatorNode(BaseNode):
@@ -60,19 +60,33 @@ class ValidatorNode(BaseNode):
updated_state["simulated_screen"] = None updated_state["simulated_screen"] = None
return updated_state return updated_state
allowed = { allowed = {
(target, change.operation) ("nodes", target, change.operation)
for change in requirement.changes for change in requirement.changes
if change.target_scope == "nodes"
for target in change.target_ids 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 = [ errors = [
ActionValidationError( ActionValidationError(
action_index=index, action_index=index,
code="scope_error", code="scope_error",
message=f"动作超出需求范围: {action.target_id} / {action.type}", message=f"动作超出需求范围: {' / '.join(target)}",
) )
for index, action in enumerate(batch.actions) for index, target in enumerate(targets)
if (action.target_id, action.type) not in allowed if target not in allowed
] ]
if missing := allowed - actual: if missing := allowed - actual:
errors.append( errors.append(
+5 -2
View File
@@ -20,7 +20,7 @@ input_context 提供当前大屏快照、用户原文及编辑上下文
""" """
SIMPLE_TASK_PROMPT = """# 当前任务:直接修改 SIMPLE_TASK_PROMPT = """# 当前任务:直接修改
将 requirement.changes 转换成对应动作,只修改其中指定的已有组件和属性 将 requirement.changes 转换成对应动作,只修改其中指定的组件或画布属性
""" """
PLAN_TASK_PROMPT = """# 当前任务:按规划生成 PLAN_TASK_PROMPT = """# 当前任务:按规划生成
@@ -52,7 +52,10 @@ def build_action_prompt(
sections = [ACTION_SYSTEM_PROMPT] sections = [ACTION_SYSTEM_PROMPT]
if screen_plan is None: if screen_plan is None:
sections.append(SIMPLE_TASK_PROMPT) 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: else:
sections.append(PLAN_TASK_PROMPT) sections.append(PLAN_TASK_PROMPT)
action_types = set() action_types = set()
+5
View File
@@ -2,6 +2,11 @@
# 新增动作时在这里补充模型需要理解的执行语义 # 新增动作时在这里补充模型需要理解的执行语义
ACTION_RULES: dict[str, str] = { ACTION_RULES: dict[str, str] = {
"update_canvas": """## update_canvas:更新画布属性
将 canvas 范围的 update、resize 或 update_canvas 需求转换为此动作,不填写 target_id
properties 只包含需要修改的画布顶层字段,宽高为最终绝对值,相对变化依据当前画布换算
顶层字段整体替换,未提供的字段保持不变;改变画布尺寸不会自动缩放或移动组件
""",
"delete": """## delete:删除已有组件 "delete": """## delete:删除已有组件
使用真实 target_id,只删除需求明确指定的组件;规划中的 remove 对应 delete 使用真实 target_id,只删除需求明确指定的组件;规划中的 remove 对应 delete
每个目标仅删除一次,删除后不能再移动、调整尺寸或更新该目标 每个目标仅删除一次,删除后不能再移动、调整尺寸或更新该目标
+3 -1
View File
@@ -10,10 +10,11 @@ from .delete import DeleteAction
from .move import MoveAction from .move import MoveAction
from .resize import ResizeAction from .resize import ResizeAction
from .update import UpdateAction from .update import UpdateAction
from .update_canvas import UpdateCanvasAction
# 按 type 选择对应模型,不维护重复的操作名称映射 # 按 type 选择对应模型,不维护重复的操作名称映射
Action = Annotated[ Action = Annotated[
AddAction | DeleteAction | MoveAction | ResizeAction | UpdateAction, AddAction | DeleteAction | MoveAction | ResizeAction | UpdateAction | UpdateCanvasAction,
Field(discriminator="type"), Field(discriminator="type"),
] ]
action_adapter = TypeAdapter(Action) action_adapter = TypeAdapter(Action)
@@ -32,5 +33,6 @@ __all__ = [
"MoveAction", "MoveAction",
"ResizeAction", "ResizeAction",
"UpdateAction", "UpdateAction",
"UpdateCanvasAction",
"action_adapter", "action_adapter",
] ]
+17
View File
@@ -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;未提供的字段保持不变",
)