feat:优化执行步骤

This commit is contained in:
2026-10-08 00:28:29 -07:00
parent 62a5b2a8f4
commit 05c050d2ba
20 changed files with 625 additions and 69 deletions
+20 -7
View File
@@ -5,10 +5,11 @@
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, UpdateAction
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
@@ -17,12 +18,15 @@ from app.schemas.screen import Screen, ScreenNode
class BatchError(ValueError):
"""整批中第 index 个动作(从 0 开始)失败,消息里带上序号和目标,模型据此修正"""
def __init__(self, index: int, action: Action, reason: str):
def __init__(self, index: int, action: AgentAction, reason: str):
self.index = index
self.reason = reason
target = getattr(action, "target_id", None)
if target is None:
target = action.node.id if isinstance(action, AddAction) else "画布"
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}")
@@ -57,13 +61,22 @@ def complete_add(action: AddAction, catalog: ComponentCatalog) -> AddAction:
def run_batch(
screen: Screen, catalog: ComponentCatalog, actions: list[Action]
screen: Screen, catalog: ComponentCatalog, actions: list[AgentAction]
) -> tuple[list[Action], Screen]:
"""从原始大屏开始按顺序模拟执行,返回前端要执行的动作和执行后的大屏;第一个错误抛 BatchError"""
"""从原始大屏开始按顺序模拟执行,返回前端要执行的动作和执行后的大屏;第一个错误抛 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):
+152
View File
@@ -0,0 +1,152 @@
"""布局意图展开:算出每个组件的目标位置和尺寸,输出前端已有的 move 和 resize
纯函数,只读大屏;只输出有变化的动作。坐标取整,相邻组件共用同一条边,避免累积误差
"""
from math import ceil
from app.schemas.actions import (
AlignAction,
ArrangeAction,
DistributeAction,
MoveAction,
ResizeAction,
SetLayoutAction,
)
from app.schemas.actions.layout import Area
from app.schemas.screen import Screen, ScreenNode
Primitive = MoveAction | ResizeAction
def _nodes(screen: Screen, ids: list[str]) -> list[ScreenNode]:
by_id = {node.id: node for node in screen.nodes}
if missing := [item for item in ids if item not in by_id]:
raise ValueError(f"目标组件不存在: {'、'.join(missing)}")
return [by_id[item] for item in ids]
def _place(node: ScreenNode, x, y, width, height) -> list[Primitive]:
"""从当前布局变成目标布局,没变的项不输出"""
layout = node.layout
result: list[Primitive] = []
if (x, y) != (layout.x, layout.y):
result.append(
MoveAction(target_id=node.id, delta_x=x - layout.x, delta_y=y - layout.y)
)
if (width, height) != (layout.width, layout.height):
result.append(ResizeAction(target_id=node.id, width=width, height=height))
return result
def _canvas_area(screen: Screen) -> Area:
return Area(x=0, y=0, width=screen.canvas.width, height=screen.canvas.height)
def _bounds(nodes: list[ScreenNode]) -> Area:
left = min(n.layout.x for n in nodes)
top = min(n.layout.y for n in nodes)
right = max(n.layout.x + n.layout.width for n in nodes)
bottom = max(n.layout.y + n.layout.height for n in nodes)
return Area(x=left, y=top, width=right - left, height=bottom - top)
def arrange(screen: Screen, action: ArrangeAction) -> list[Primitive]:
nodes = _nodes(screen, action.target_ids)
count = len(nodes)
columns = {"row": count, "column": 1, "grid": action.columns}[action.mode]
rows = ceil(count / columns)
area = action.area or _canvas_area(screen)
left = area.x + action.padding
top = area.y + action.padding
cell_w = (area.width - 2 * action.padding - action.gap * (columns - 1)) / columns
cell_h = (area.height - 2 * action.padding - action.gap * (rows - 1)) / rows
if cell_w <= 0 or cell_h <= 0:
raise ValueError(
f"区域放不下 {count} 个组件:区域 {area.width:g}x{area.height:g},"
f"边距 {action.padding:g}、间距 {action.gap:g},减小间距或边距"
)
result: list[Primitive] = []
for index, node in enumerate(nodes):
row, column = divmod(index, columns)
x0 = left + column * (cell_w + action.gap)
y0 = top + row * (cell_h + action.gap)
# 取整的是边,不是宽度,相邻单元格不会因为取整多出缝隙或重叠
x, y = round(x0), round(y0)
result += _place(node, x, y, round(x0 + cell_w) - x, round(y0 + cell_h) - y)
return result
def align(screen: Screen, action: AlignAction) -> list[Primitive]:
nodes = _nodes(screen, action.target_ids)
box = _canvas_area(screen) if action.to == "canvas" else _bounds(nodes)
result: list[Primitive] = []
for node in nodes:
layout = node.layout
x, y = layout.x, layout.y
if action.horizontal == "left":
x = box.x
elif action.horizontal == "center":
x = round(box.x + (box.width - layout.width) / 2)
elif action.horizontal == "right":
x = box.x + box.width - layout.width
if action.vertical == "top":
y = box.y
elif action.vertical == "middle":
y = round(box.y + (box.height - layout.height) / 2)
elif action.vertical == "bottom":
y = box.y + box.height - layout.height
result += _place(node, x, y, layout.width, layout.height)
return result
def distribute(screen: Screen, action: DistributeAction) -> list[Primitive]:
horizontal = action.axis == "horizontal"
nodes = sorted(
_nodes(screen, action.target_ids),
key=lambda n: n.layout.x if horizontal else n.layout.y,
)
box = action.area or _bounds(nodes)
start, span = (box.x, box.width) if horizontal else (box.y, box.height)
sizes = [n.layout.width if horizontal else n.layout.height for n in nodes]
if action.gap is not None:
gap = action.gap
elif len(nodes) < 2:
raise ValueError("摊匀间距至少需要 2 个组件")
else:
gap = (span - sum(sizes)) / (len(nodes) - 1)
if gap < 0:
raise ValueError(
f"这些组件的{'宽' if horizontal else '高'}度合计 {sum(sizes):g},"
f"超过可用的 {span:g},排不开"
)
result: list[Primitive] = []
cursor = start
for node, size in zip(nodes, sizes, strict=True):
position = round(cursor)
layout = node.layout
x, y = (position, layout.y) if horizontal else (layout.x, position)
result += _place(node, x, y, layout.width, layout.height)
cursor += size + gap
return result
def set_layout(screen: Screen, action: SetLayoutAction) -> list[Primitive]:
(node,) = _nodes(screen, [action.target_id])
layout = node.layout
return _place(
node,
layout.x if action.x is None else action.x,
layout.y if action.y is None else action.y,
layout.width if action.width is None else action.width,
layout.height if action.height is None else action.height,
)
# 布局意图的 type -> 展开函数;新增意图时在这里注册
EXPANDERS = {
"arrange": arrange,
"align": align,
"distribute": distribute,
"set_layout": set_layout,
}
+3 -3
View File
@@ -14,7 +14,7 @@ from langchain_core.messages import AIMessage, HumanMessage
from loguru import logger
from pydantic import BaseModel, Field, model_validator
from app.agent.skills import skill_middleware
from app.agent.skills import skills_prompt
from app.agent.tools import (
ScreenAgentContext,
ScreenAgentState,
@@ -37,6 +37,7 @@ SYSTEM_PROMPT = f"""你是大屏编辑助手,按用户的要求新建大屏,
<clarify_rules>
默认先执行:缺少的细节(位置、尺寸、标题、样式等)用合理的默认值补齐
只有无法确定用户指的是哪个组件,或看不出用户要做什么时才追问;澄清记录里已经回答过的不再问
用户要改的组件在大屏上不存在时,说明这一点并给出可行的替代做法(例如新增一个),不要让用户去选中或指出组件
只删除用户明确要求删除的组件;不确定删哪个时追问,不要猜
</clarify_rules>"""
@@ -107,13 +108,12 @@ async def get_screen_agent():
_agent = create_agent(
model=get_llm(),
tools=[get_nodes, apply_actions, *await get_mcp_tools()],
system_prompt=SYSTEM_PROMPT,
system_prompt=f"{SYSTEM_PROMPT}\n\n{skills_prompt()}",
# 用工具调用给出结论:试过原生结构化输出(ProviderStrategy),歧义时模型更常直接执行而不追问,评测多删了组件
response_format=ToolStrategy(AgentReply),
state_schema=ScreenAgentState,
context_schema=ScreenAgentContext,
middleware=[
*skill_middleware(),
require_tool_call,
# 超过上限时插入说明并结束,没有结构化结果,由节点按失败处理
ModelCallLimitMiddleware(
+23 -34
View File
@@ -1,44 +1,33 @@
"""skill 渐进式加载:目录追加到系统提示,正文和附件由模型用 read_file 按需读取
"""skill 正文直接写进系统提示
加载、解析和校验都由 Deep Agents 的 SkillsMiddleware 完成,这里只有配置
skill 总共几 KB,每次调用都会命中提示缓存;如果让模型自己 read_file,每个请求要多 2 次串行调用,
每次几秒,比多带这几 KB 贵得多。需要附件(references/)的 skill 出现后,再改回按需读取
"""
from pathlib import Path
from deepagents.backends.filesystem import FilesystemBackend
from deepagents.middleware.filesystem import FilesystemMiddleware
from deepagents.middleware.skills import SkillsMiddleware
from langchain.agents.middleware import AgentMiddleware
SKILLS_ROOT = Path(__file__).resolve().parents[2] / "skills"
# 必须保留三个占位符;目录每一行的格式由库固定
SKILLS_PROMPT = """<skills>
以下 skill 提供特定任务的做法。{skills_locations}{skills_load_warnings}
{skills_list}
使用方式:
1. 任务符合某个 skill 的描述时,先用 read_file 读取它的 SKILL.md 再动手,传 limit=1000(默认只读 100 行)
2. 正文中引用的文件写的是相对路径,拼在该 skill 的目录后再读取,例如 chart-replace 正文里的 references/a.md 要读 /chart-replace/references/a.md
3. 一个请求可以用到多个 skill
</skills>"""
def _split_frontmatter(text: str) -> tuple[dict[str, str], str]:
_, header, body = text.split("---", 2)
meta = {}
for line in header.strip().splitlines():
key, _, value = line.partition(":")
meta[key.strip()] = value.strip()
return meta, body.strip()
def skill_middleware(root: Path = SKILLS_ROOT) -> list[AgentMiddleware]:
# virtual_mode:路径以 skills/ 为根,拦截 ..、~ 和根目录以外的路径
backend = FilesystemBackend(root_dir=root, virtual_mode=True)
return [
FilesystemMiddleware(
backend=backend,
# 只开放读取,不提供写入、搜索和执行
tools=["read_file"],
# 关闭大结果写盘,否则会往 skills/ 里写文件
tool_token_limit_before_evict=None,
human_message_token_limit_before_evict=None,
),
# 用元组写明来源标签,裸路径 "/" 的标签会变成 Unnamed
SkillsMiddleware(
backend=backend, sources=[("/", "大屏")], system_prompt=SKILLS_PROMPT
),
]
def skills_prompt(root: Path = SKILLS_ROOT) -> str:
"""每个 skill 一段:description 说明什么时候用,正文是做法;任务符合描述时按正文做,一个请求可以用到多个"""
sections = []
for path in sorted(root.glob("*/SKILL.md")):
meta, body = _split_frontmatter(path.read_text(encoding="utf-8"))
sections.append(
f'<skill name="{meta["name"]}">\n适用:{meta["description"]}\n\n{body}\n</skill>'
)
return (
"<skills>\n以下 skill 提供特定任务的做法,任务符合“适用”时按正文做,一个请求可以用到多个\n\n"
+ "\n\n".join(sections)
+ "\n</skills>"
)
+64 -7
View File
@@ -13,7 +13,7 @@ from langgraph.types import Command
from pydantic import Field
from app.actions.batch import BatchError, run_batch
from app.schemas.actions import Action, AddAction
from app.schemas.actions import Action, AddAction, AgentAction
from app.schemas.component_resource import ComponentCatalog
from app.schemas.screen import Screen
@@ -51,21 +51,55 @@ def get_nodes(node_ids: list[str], runtime: ToolRuntime[ScreenAgentContext]) ->
}
def _result_text(actions: list[Action]) -> str:
def _result_text(
before: Screen,
after: Screen,
actions: list[Action],
undeclared_sample: frozenset[str] = frozenset(),
) -> str:
"""执行结果:除了动作数量,还给出执行后的画布和每个组件的布局
模型提交后才第一次看到结果,能自己发现位置超出画布、互相重叠、没排整齐等问题再修正,
不用靠重复提交来试;本次动过布局或新增的组件前面标 *,已删除的单独列出
"""
lines = [f"执行成功,共 {len(actions)} 个动作已生效"]
if sample := [
sample = [
action.node.id
for action in actions
if isinstance(action, AddAction) and action.sample_data
]
# 模型没写 sample_data 时是后端按物料猜的;说明这一点和怎么改,否则模型只看到“用了示例数据”,
# 以为是自己的数据没生效,会反复提交同样的内容
if guessed := [item for item in sample if item in undeclared_sample]:
lines.append(
f"未写 sample_data,按物料默认标成了示例数据:{'、'.join(guessed)};"
"如果这些组件展示的数据都来自大屏上已有的组件,把 sample_data 设为 false 重新提交整批,否则不用处理"
)
if declared := [item for item in sample if item not in undeclared_sample]:
lines.append(f"标为示例数据的新增组件:{'、'.join(declared)}")
old_layouts = {node.id: node.layout for node in before.nodes}
lines.append(
f"执行后画布 {after.canvas.width:g}x{after.canvas.height:g},组件布局(* 为本次改动):"
)
for node in after.nodes:
layout = node.layout
mark = "*" if old_layouts.get(node.id) != layout else " "
lines.append(
f"{mark} {node.id} {node.type} "
f"x={layout.x:g} y={layout.y:g} width={layout.width:g} height={layout.height:g}"
)
if removed := [
item for item in old_layouts if item not in {n.id for n in after.nodes}
]:
lines.append(f"使用物料示例数据的新增组件:{'、'.join(sample)}")
lines.append(f"已删除:{'、'.join(removed)}")
return "\n".join(lines)
@tool
def apply_actions(
actions: Annotated[
list[Action],
list[AgentAction],
# 空的一批会清掉之前成功的结果;参数校验失败不改状态,之前的结果保留
Field(min_length=1, description="至少一个动作;不需要修改时不要调用,直接回复"),
],
@@ -74,10 +108,11 @@ def apply_actions(
"""提交本次要对大屏做的全部修改
每次调用都是完整的一批,从原始大屏开始按顺序模拟执行,不在上一次调用的结果上累加
成功时返回执行后的画布和各组件布局,用它核对结果,不对再重新提交整批
失败时整批不生效,返回带动作序号的错误,修正后重新提交整批;以最后一次调用的结果为准
"""
try:
completed, _ = run_batch(
completed, after = run_batch(
runtime.context.screen, runtime.context.catalog, actions
)
except BatchError as error:
@@ -93,12 +128,34 @@ def apply_actions(
],
}
)
# 和上一次成功的提交完全一样:结果早已生效,再提交不会有变化,直接告诉模型结束,不再空转
if completed == runtime.state.get("actions"):
return Command(
update={
"messages": [
ToolMessage(
"这一批的结果和上一次提交完全相同,已经生效,不要重复提交;"
"任务完成就调用 AgentReply 回复用户,没完成就改成不同的内容",
tool_call_id=runtime.tool_call_id,
)
],
}
)
undeclared = frozenset(
action.node.id
for action in actions
if isinstance(action, AddAction)
and "sample_data" not in action.model_fields_set
)
return Command(
update={
"actions": completed,
"apply_error": None,
"messages": [
ToolMessage(_result_text(completed), tool_call_id=runtime.tool_call_id)
ToolMessage(
_result_text(runtime.context.screen, after, completed, undeclared),
tool_call_id=runtime.tool_call_id,
)
],
}
)
+1
View File
@@ -23,6 +23,7 @@ def _get_base_llm() -> ChatOpenAI:
base_url=settings.llm_base_url,
timeout=settings.llm_timeout,
max_retries=settings.llm_max_retries,
reasoning_effort=settings.llm_reasoning_effort,
)
+33 -11
View File
@@ -1,5 +1,6 @@
"""用户中心客户端:用请求携带的 token 换取当前用户,结果短时缓存"""
import asyncio
import hashlib
import time
from functools import cache
@@ -14,6 +15,9 @@ from app.schemas.user import CurrentUser
# 用户中心 401 时提示调用方用 Bearer token 重新认证
BEARER_CHALLENGE = {"WWW-Authenticate": "Bearer"}
# 瞬时故障的重试间隔(秒),长度即重试次数
RETRY_DELAYS = (0.2, 0.5)
class UserCenterClient:
def __init__(
@@ -55,23 +59,41 @@ class UserCenterClient:
del self._cache[stale]
self._cache[key] = (now + ttl, user)
async def _request_with_retry(self, token: str) -> httpx.Response:
"""校验是幂等的 GET:连接失败、超时和 5xx 属于瞬时故障,短间隔重试"""
attempts = 1 + len(RETRY_DELAYS)
for attempt in range(attempts):
try:
response = await self._get_client().get(
"/api/users/me", headers={"Authorization": f"Bearer {token}"}
)
except httpx.HTTPError as error:
logger.opt(exception=error).warning(
"用户中心请求失败 attempt={}/{}", attempt + 1, attempts
)
if attempt == attempts - 1:
raise BizException(503, "用户中心不可用") from error
else:
if response.status_code < 500:
return response
logger.warning(
"用户中心返回 HTTP {} attempt={}/{}",
response.status_code,
attempt + 1,
attempts,
)
if attempt == attempts - 1:
raise BizException(503, "用户中心不可用")
await asyncio.sleep(RETRY_DELAYS[attempt])
raise AssertionError("unreachable")
async def fetch_current_user(self, token: str) -> CurrentUser:
key = hashlib.sha256(token.encode()).hexdigest()
user = self._cached(key)
if user is not None:
return user
try:
response = await self._get_client().get(
"/api/users/me", headers={"Authorization": f"Bearer {token}"}
)
except httpx.HTTPError as error:
logger.opt(exception=error).warning("用户中心请求失败")
raise BizException(503, "用户中心不可用") from error
if response.status_code >= 500:
logger.warning("用户中心返回 HTTP {}", response.status_code)
raise BizException(503, "用户中心不可用")
response = await self._request_with_retry(token)
try:
body = response.json()
except ValueError:
+9 -1
View File
@@ -18,8 +18,16 @@ class Settings:
llm_base_url: str | None = field(
default_factory=lambda: os.getenv("LLM_BASE_URL") or None
)
llm_timeout: float = 60.0
# 统计 5000 多次调用:正常的都在 30 秒内(含上千 token 的长输出),
# 卡住的请求会一直等到超时才重试,超时越长,卡一次多等越久
llm_timeout: float = field(
default_factory=lambda: float(os.getenv("LLM_TIMEOUT", "30"))
)
llm_max_retries: int = 2
# 推理 token 占了单次调用耗时的大头;步骤都很简单,不需要深想
llm_reasoning_effort: str | None = field(
default_factory=lambda: os.getenv("LLM_REASONING_EFFORT", "low") or None
)
# Jev
typesafe_api_key: str = field(
@@ -140,6 +140,20 @@
"横轴文字颜色"
],
"panel": false
},
{
"path": "props.option.xAxis.axisLabel.fontSize",
"label": "X轴字号",
"description": "横轴类目文字的大小",
"value_type": "number",
"minimum": 8,
"maximum": 48,
"unit": "px",
"default": 12,
"aliases": [
"横轴文字大小"
],
"panel": false
}
]
},
+59
View File
@@ -176,6 +176,21 @@
"图例文字颜色"
],
"panel": false
},
{
"path": "props.option.legend.textStyle.fontSize",
"label": "图例字号",
"description": "图例中系列名称文字的大小",
"value_type": "number",
"minimum": 8,
"maximum": 48,
"unit": "px",
"default": 12,
"aliases": [
"图例大小",
"图例文字大小"
],
"panel": false
}
]
},
@@ -238,6 +253,21 @@
],
"panel": false
},
{
"path": "props.option.xAxis.axisLabel.fontSize",
"label": "X轴字号",
"description": "横轴刻度文字的大小",
"value_type": "number",
"minimum": 8,
"maximum": 48,
"unit": "px",
"default": 12,
"aliases": [
"横轴文字大小",
"X轴标签大小"
],
"panel": false
},
{
"path": "props.option.xAxis.axisLine.lineStyle.color",
"label": "X轴线颜色",
@@ -260,6 +290,21 @@
],
"panel": false
},
{
"path": "props.option.yAxis.axisLabel.fontSize",
"label": "Y轴字号",
"description": "纵轴刻度文字的大小",
"value_type": "number",
"minimum": 8,
"maximum": 48,
"unit": "px",
"default": 12,
"aliases": [
"纵轴文字大小",
"Y轴标签大小"
],
"panel": false
},
{
"path": "props.option.yAxis.splitLine.lineStyle.color",
"label": "网格线颜色",
@@ -507,6 +552,20 @@
"标签颜色"
],
"panel": false
},
{
"path": "props.option.series.0.label.fontSize",
"label": "标签字号",
"description": "数据项名称标签的文字大小",
"value_type": "number",
"minimum": 8,
"maximum": 48,
"unit": "px",
"default": 12,
"aliases": [
"标签大小"
],
"panel": false
}
]
},
+14
View File
@@ -54,6 +54,20 @@
],
"panel": false
},
{
"path": "props.option.series.0.axisLabel.fontSize",
"label": "刻度字号",
"description": "表盘刻度数字的大小",
"value_type": "number",
"minimum": 8,
"maximum": 48,
"unit": "px",
"default": 12,
"aliases": [
"刻度大小"
],
"panel": false
},
{
"path": "props.option.series.0.axisLine.lineStyle.width",
"label": "表盘宽度",
+30
View File
@@ -38,6 +38,21 @@
],
"panel": false
},
{
"path": "props.option.xAxis.axisLabel.fontSize",
"label": "X轴字号",
"description": "横轴(数值)刻度文字的大小",
"value_type": "number",
"minimum": 8,
"maximum": 48,
"unit": "px",
"default": 12,
"aliases": [
"横轴文字大小",
"数值轴文字大小"
],
"panel": false
},
{
"path": "props.option.xAxis.splitLine.lineStyle.color",
"label": "网格线颜色",
@@ -60,6 +75,21 @@
],
"panel": false
},
{
"path": "props.option.yAxis.axisLabel.fontSize",
"label": "Y轴字号",
"description": "纵轴类目名称文字的大小",
"value_type": "number",
"minimum": 8,
"maximum": 48,
"unit": "px",
"default": 12,
"aliases": [
"纵轴文字大小",
"类目文字大小"
],
"panel": false
},
{
"path": "props.option.yAxis.axisLine.lineStyle.color",
"label": "Y轴线颜色",
@@ -42,6 +42,20 @@
],
"panel": false
},
{
"path": "props.option.xAxis.axisLabel.fontSize",
"label": "X轴字号",
"description": "横轴类目文字的大小",
"value_type": "number",
"minimum": 8,
"maximum": 48,
"unit": "px",
"default": 12,
"aliases": [
"横轴文字大小"
],
"panel": false
},
{
"path": "props.option.yAxis.axisLabel.color",
"label": "Y轴文字色",
@@ -51,6 +65,20 @@
"纵轴文字颜色"
],
"panel": false
},
{
"path": "props.option.yAxis.axisLabel.fontSize",
"label": "Y轴字号",
"description": "纵轴类目文字的大小",
"value_type": "number",
"minimum": 8,
"maximum": 48,
"unit": "px",
"default": 12,
"aliases": [
"纵轴文字大小"
],
"panel": false
}
]
},
@@ -134,6 +162,19 @@
"标签颜色"
],
"panel": false
},
{
"path": "props.option.series.0.label.fontSize",
"label": "数值字号",
"description": "格子内数值文字的大小",
"value_type": "number",
"minimum": 8,
"maximum": 48,
"unit": "px",
"aliases": [
"标签大小"
],
"panel": false
}
]
},
+15
View File
@@ -99,6 +99,21 @@
"指标文字颜色"
],
"panel": false
},
{
"path": "props.option.radar.axisName.fontSize",
"label": "维度字号",
"description": "维度名称文字的大小",
"value_type": "number",
"minimum": 8,
"maximum": 48,
"unit": "px",
"default": 12,
"aliases": [
"维度大小",
"指标文字大小"
],
"panel": false
}
]
},
+22
View File
@@ -7,6 +7,7 @@ from pydantic import Field
from .add import AddAction
from .base import ActionModel, BaseAction
from .delete import DeleteAction
from .layout import AlignAction, ArrangeAction, DistributeAction, SetLayoutAction
from .move import MoveAction
from .resize import ResizeAction
from .update import UpdateAction
@@ -23,8 +24,29 @@ Action = Annotated[
Field(discriminator="type"),
]
# 模型可以提交的动作:前端协议 + 布局意图
# 布局意图由后端展开成 move 和 resize,不会返回给前端
AgentAction = Annotated[
AddAction
| DeleteAction
| MoveAction
| ResizeAction
| UpdateAction
| UpdateCanvasAction
| ArrangeAction
| AlignAction
| DistributeAction
| SetLayoutAction,
Field(discriminator="type"),
]
__all__ = [
"Action",
"AgentAction",
"AlignAction",
"ArrangeAction",
"DistributeAction",
"SetLayoutAction",
"ActionModel",
"AddAction",
"BaseAction",
+118
View File
@@ -0,0 +1,118 @@
"""布局意图:模型只说“怎么排”,坐标由后端算,展开成 move 和 resize 后才交给前端
这些动作只存在于模型提交的这一步,不会出现在返回给前端的动作里,前端协议不变
"""
from typing import Annotated, Literal, Self
from pydantic import Field, model_validator
from app.schemas.screen import Number, PositiveNumber
from .base import ActionModel, BaseAction
NonNegativeNumber = Annotated[Number, Field(ge=0)]
TargetIds = Annotated[
list[str],
Field(min_length=1, description="要排列的组件 ID,不能重复"),
]
class Area(ActionModel):
"""画布上的一块矩形区域,坐标单位 px"""
x: Number = Field(description="左上角横坐标")
y: Number = Field(description="左上角纵坐标")
width: PositiveNumber
height: PositiveNumber
class _Group(ActionModel):
target_ids: TargetIds
@model_validator(mode="after")
def unique_ids(self) -> Self:
if len(set(self.target_ids)) != len(self.target_ids):
raise ValueError("target_ids 不能重复")
return self
class ArrangeAction(_Group):
"""把一组组件平分一块区域:每个组件的位置和尺寸都由这里决定,彼此不重叠、不超出区域
用于“平分空间”“排成一行/一列”“两行两列铺满”等;按 target_ids 的顺序从左到右、从上到下放置
"""
type: Literal["arrange"] = "arrange"
mode: Literal["row", "column", "grid"] = Field(
description="row:排成一行;column:排成一列;grid:按 columns 列排成网格"
)
columns: int | None = Field(
default=None, ge=1, description="mode=grid 时每行几个,其他模式不填"
)
area: Area | None = Field(
default=None, description="要铺满的区域,省略表示整个画布"
)
gap: NonNegativeNumber = Field(default=20, description="组件之间的间距,单位 px")
padding: NonNegativeNumber = Field(
default=0, description="区域四周留出的边距,单位 px"
)
@model_validator(mode="after")
def columns_match_mode(self) -> Self:
if (self.mode == "grid") != (self.columns is not None):
raise ValueError("mode=grid 时必须填写 columns,其他模式不填")
return self
class AlignAction(_Group):
"""按基准对齐,不改变尺寸;居中、靠左、靠右、顶部对齐等"""
type: Literal["align"] = "align"
to: Literal["canvas", "selection"] = Field(
default="canvas",
description="canvas:对齐到画布;selection:对齐到这些组件合起来的外框",
)
horizontal: Literal["left", "center", "right"] | None = Field(
default=None, description="水平方向的对齐方式,省略表示不调整水平位置"
)
vertical: Literal["top", "middle", "bottom"] | None = Field(
default=None, description="垂直方向的对齐方式,省略表示不调整垂直位置"
)
@model_validator(mode="after")
def require_direction(self) -> Self:
if self.horizontal is None and self.vertical is None:
raise ValueError("align 至少提供 horizontal 或 vertical")
return self
class DistributeAction(_Group):
"""沿一个方向等间距排开,不改变尺寸;按当前位置的先后顺序排列"""
type: Literal["distribute"] = "distribute"
axis: Literal["horizontal", "vertical"]
gap: NonNegativeNumber | None = Field(
default=None,
description="固定间距,单位 px,从区域起点(没有 area 时是最前一个组件的位置)依次排开;"
"省略表示把间距摊匀,首尾两个组件的外沿不动",
)
area: Area | None = Field(
default=None, description="在这块区域内排开,省略表示用这些组件合起来的外框"
)
class SetLayoutAction(BaseAction):
"""直接设置组件的绝对位置和尺寸,只写要改的项,其余保持不变;不需要自己换算位移"""
type: Literal["set_layout"] = "set_layout"
x: Number | None = Field(default=None, description="目标左上角横坐标")
y: Number | None = Field(default=None, description="目标左上角纵坐标")
width: PositiveNumber | None = Field(default=None, description="目标宽度")
height: PositiveNumber | None = Field(default=None, description="目标高度")
@model_validator(mode="after")
def require_field(self) -> Self:
if all(v is None for v in (self.x, self.y, self.width, self.height)):
raise ValueError("set_layout 至少提供 x、y、width、height 中的一项")
return self
+1 -1
View File
@@ -10,7 +10,7 @@ from .base import BaseAction
class MoveAction(BaseAction):
"""按相对位移移动组件。目标是绝对位置时,用目标坐标减去当前坐标,例如当前 x=100、目标 x=150 时 delta_x=50"""
"""按相对位移移动组件,适合“往右挪一点”这类相对说法;目标位置是确定的用 set_layout,多个组件排列用 arrange、align、distribute,不要自己换算坐标"""
type: Literal["move"] = "move"
delta_x: Number = Field(