From 05c050d2baf312c883744bf3f4461aadbd3dc3b1 Mon Sep 17 00:00:00 2001
From: Marcus <1922576605@qq.com>
Date: Thu, 8 Oct 2026 00:28:29 -0700
Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E4=BC=98=E5=8C=96=E6=89=A7?=
=?UTF-8?q?=E8=A1=8C=E6=AD=A5=E9=AA=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/actions/batch.py | 27 +++-
app/actions/layout.py | 152 ++++++++++++++++++++
app/agent/screen_agent.py | 6 +-
app/agent/skills.py | 57 +++-----
app/agent/tools.py | 71 ++++++++-
app/clients/llm.py | 1 +
app/clients/user_center.py | 44 ++++--
app/config.py | 10 +-
app/resources/materials/bar-line-chart.json | 14 ++
app/resources/materials/field_groups.json | 59 ++++++++
app/resources/materials/gauge-chart.json | 14 ++
app/resources/materials/hbar-chart.json | 30 ++++
app/resources/materials/heatmap-chart.json | 41 ++++++
app/resources/materials/radar-chart.json | 15 ++
app/schemas/actions/__init__.py | 22 +++
app/schemas/actions/layout.py | 118 +++++++++++++++
app/schemas/actions/move.py | 2 +-
skills/chart-replace/SKILL.md | 4 +-
skills/screen-design/SKILL.md | 6 +-
skills/screen-edit/SKILL.md | 1 +
20 files changed, 625 insertions(+), 69 deletions(-)
create mode 100644 app/actions/layout.py
create mode 100644 app/schemas/actions/layout.py
diff --git a/app/actions/batch.py b/app/actions/batch.py
index 048b580..a41a084 100644
--- a/app/actions/batch.py
+++ b/app/actions/batch.py
@@ -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):
diff --git a/app/actions/layout.py b/app/actions/layout.py
new file mode 100644
index 0000000..59c156f
--- /dev/null
+++ b/app/actions/layout.py
@@ -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,
+}
diff --git a/app/agent/screen_agent.py b/app/agent/screen_agent.py
index 8329e82..34b58d5 100644
--- a/app/agent/screen_agent.py
+++ b/app/agent/screen_agent.py
@@ -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"""你是大屏编辑助手,按用户的要求新建大屏,
默认先执行:缺少的细节(位置、尺寸、标题、样式等)用合理的默认值补齐
只有无法确定用户指的是哪个组件,或看不出用户要做什么时才追问;澄清记录里已经回答过的不再问
+用户要改的组件在大屏上不存在时,说明这一点并给出可行的替代做法(例如新增一个),不要让用户去选中或指出组件
只删除用户明确要求删除的组件;不确定删哪个时追问,不要猜
"""
@@ -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(
diff --git a/app/agent/skills.py b/app/agent/skills.py
index b3d5b9a..a4c63af 100644
--- a/app/agent/skills.py
+++ b/app/agent/skills.py
@@ -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 = """
-以下 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
-"""
+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'\n适用:{meta["description"]}\n\n{body}\n'
+ )
+ return (
+ "\n以下 skill 提供特定任务的做法,任务符合“适用”时按正文做,一个请求可以用到多个\n\n"
+ + "\n\n".join(sections)
+ + "\n"
+ )
diff --git a/app/agent/tools.py b/app/agent/tools.py
index c402980..96f3e3a 100644
--- a/app/agent/tools.py
+++ b/app/agent/tools.py
@@ -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,
+ )
],
}
)
diff --git a/app/clients/llm.py b/app/clients/llm.py
index df147b1..773d61f 100644
--- a/app/clients/llm.py
+++ b/app/clients/llm.py
@@ -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,
)
diff --git a/app/clients/user_center.py b/app/clients/user_center.py
index 5068b5c..18b9e93 100644
--- a/app/clients/user_center.py
+++ b/app/clients/user_center.py
@@ -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:
diff --git a/app/config.py b/app/config.py
index 0cd732c..8ac2ee0 100644
--- a/app/config.py
+++ b/app/config.py
@@ -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(
diff --git a/app/resources/materials/bar-line-chart.json b/app/resources/materials/bar-line-chart.json
index b52b8f3..2875750 100644
--- a/app/resources/materials/bar-line-chart.json
+++ b/app/resources/materials/bar-line-chart.json
@@ -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
}
]
},
diff --git a/app/resources/materials/field_groups.json b/app/resources/materials/field_groups.json
index dd247d5..fa4f5c3 100644
--- a/app/resources/materials/field_groups.json
+++ b/app/resources/materials/field_groups.json
@@ -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
}
]
},
diff --git a/app/resources/materials/gauge-chart.json b/app/resources/materials/gauge-chart.json
index b9b310f..9dc7ad8 100644
--- a/app/resources/materials/gauge-chart.json
+++ b/app/resources/materials/gauge-chart.json
@@ -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": "表盘宽度",
diff --git a/app/resources/materials/hbar-chart.json b/app/resources/materials/hbar-chart.json
index 38931ad..ff0ac84 100644
--- a/app/resources/materials/hbar-chart.json
+++ b/app/resources/materials/hbar-chart.json
@@ -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轴线颜色",
diff --git a/app/resources/materials/heatmap-chart.json b/app/resources/materials/heatmap-chart.json
index 348096e..cba7888 100644
--- a/app/resources/materials/heatmap-chart.json
+++ b/app/resources/materials/heatmap-chart.json
@@ -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
}
]
},
diff --git a/app/resources/materials/radar-chart.json b/app/resources/materials/radar-chart.json
index d04d7ac..b5889a2 100644
--- a/app/resources/materials/radar-chart.json
+++ b/app/resources/materials/radar-chart.json
@@ -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
}
]
},
diff --git a/app/schemas/actions/__init__.py b/app/schemas/actions/__init__.py
index 639f29d..d0d793f 100644
--- a/app/schemas/actions/__init__.py
+++ b/app/schemas/actions/__init__.py
@@ -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",
diff --git a/app/schemas/actions/layout.py b/app/schemas/actions/layout.py
new file mode 100644
index 0000000..0cae60e
--- /dev/null
+++ b/app/schemas/actions/layout.py
@@ -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
diff --git a/app/schemas/actions/move.py b/app/schemas/actions/move.py
index dd9f160..bd726b6 100644
--- a/app/schemas/actions/move.py
+++ b/app/schemas/actions/move.py
@@ -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(
diff --git a/skills/chart-replace/SKILL.md b/skills/chart-replace/SKILL.md
index b825728..af26846 100644
--- a/skills/chart-replace/SKILL.md
+++ b/skills/chart-replace/SKILL.md
@@ -7,8 +7,8 @@ description: 把已有组件换成另一种组件类型,例如"把折线图换
## 步骤
-1. 用 get_nodes 读取原组件的完整配置
-2. 用 list_components 选出用户要的新类型,用 get_component_materials 查看它的默认配置
+1. 同一轮一起调用 get_nodes(读取原组件的完整配置)和 get_component_materials(查看新类型的默认配置),两者互不依赖
+2. 用户没说清新类型、不确定选哪种时,才用 list_components 查看可用组件
3. 在同一批动作里先 delete 原组件,再 add 新组件:
- node.id 沿用原组件 ID,layout 沿用原组件的位置和尺寸(用户另有要求除外)
- node.type 填新类型
diff --git a/skills/screen-design/SKILL.md b/skills/screen-design/SKILL.md
index c78e55d..a8356bd 100644
--- a/skills/screen-design/SKILL.md
+++ b/skills/screen-design/SKILL.md
@@ -7,8 +7,8 @@ description: 新建大屏,或在大屏上新增组件、整体重新布局、
## 选组件
-1. 用 list_components 查看可用组件,按用户要展示的内容选类型;不编造目录中没有的组件类型
-2. 用 get_component_materials 查看所选类型的默认配置
+1. 用户说明了图表类型时,直接用 get_component_materials 查看默认配置;只有不确定选哪种时才用 list_components 查看可用组件,按用户要展示的内容选类型;不编造目录中没有的组件类型
+2. 需要的查询(get_component_materials、get_nodes 等)互不依赖,在同一轮一起调用
3. 新建大屏时,根据主题安排一个标题文本和若干图表;图表标题按要展示的内容写,不沿用物料的示例标题
## 布局
@@ -20,5 +20,5 @@ description: 新建大屏,或在大屏上新增组件、整体重新布局、
## 整体调整
-- 重新布局:用 move 和 resize 调整已有组件,不删除、不重建
+- 重新布局:用 arrange、align、distribute、set_layout 排列已有组件,不删除、不重建;只调整位置和尺寸不需要 get_nodes,大屏摘要里已有每个组件的 layout 和画布尺寸
- 统一风格:先用 get_nodes 读取组件配置,用 search_fields 找到颜色等字段,再逐个修改;画布背景用 update_canvas
diff --git a/skills/screen-edit/SKILL.md b/skills/screen-edit/SKILL.md
index 10b28a6..36a9bf4 100644
--- a/skills/screen-edit/SKILL.md
+++ b/skills/screen-edit/SKILL.md
@@ -21,5 +21,6 @@ description: 修改大屏上已有的组件或画布:改标题、文字、颜
## 位置和尺寸
+- 只改位置和尺寸不需要 get_nodes,大屏摘要里已有每个组件的 layout 和画布尺寸
- 相对变化("放大一倍""往右移 100")依据大屏摘要中的当前位置和尺寸换算
- "放到正中间"等按画布尺寸计算目标位置