feat:优化执行步骤
This commit is contained in:
+20
-7
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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": "表盘宽度",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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 填新类型
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,5 +21,6 @@ description: 修改大屏上已有的组件或画布:改标题、文字、颜
|
||||
|
||||
## 位置和尺寸
|
||||
|
||||
- 只改位置和尺寸不需要 get_nodes,大屏摘要里已有每个组件的 layout 和画布尺寸
|
||||
- 相对变化("放大一倍""往右移 100")依据大屏摘要中的当前位置和尺寸换算
|
||||
- "放到正中间"等按画布尺寸计算目标位置
|
||||
|
||||
Reference in New Issue
Block a user