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")依据大屏摘要中的当前位置和尺寸换算 - "放到正中间"等按画布尺寸计算目标位置