feat:接入mcp,为模型提供图表信息
This commit is contained in:
@@ -9,7 +9,9 @@ class ActionValidationError(BaseModel):
|
||||
action_index: int | None = Field(
|
||||
default=None, ge=0, description="失败动作的下标,从 0 开始,整批错误为 null"
|
||||
)
|
||||
code: Literal["schema_error", "generation_error", "scope_error", "execution_error"]
|
||||
code: Literal[
|
||||
"schema_error", "generation_error", "scope_error", "field_error", "execution_error"
|
||||
]
|
||||
message: str
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""精确更新组件 props 或 style 下的一个字段"""
|
||||
"""精确更新组件名称,或 props / style 下的一个字段"""
|
||||
|
||||
from typing import Annotated, Literal
|
||||
from typing import Annotated, Literal, Self
|
||||
|
||||
from pydantic import Field, JsonValue
|
||||
from pydantic import Field, JsonValue, model_validator
|
||||
|
||||
from app.schemas.base import NonBlankString
|
||||
|
||||
@@ -13,10 +13,21 @@ PathSegment = NonBlankString | Annotated[int, Field(ge=0)]
|
||||
|
||||
class UpdateAction(BaseAction):
|
||||
type: Literal["update"] = "update"
|
||||
section: Literal["props", "style"] = Field(description="允许更新的组件属性区域")
|
||||
section: Literal["name", "props", "style"] = Field(
|
||||
description="字段路径的第一段:name 为组件名称,props / style 为组件属性区域"
|
||||
)
|
||||
path: list[PathSegment] = Field(
|
||||
min_length=1, description="区域内的字段路径,字符串为对象键,整数为数组下标"
|
||||
)
|
||||
value: JsonValue = Field(
|
||||
description="替换该路径的值,支持任意 JSON;null 是赋值,不表示删除"
|
||||
description="字段路径第一段之后的各段,字符串为对象键,整数为数组下标;name 没有后续路径,传空列表"
|
||||
)
|
||||
value: JsonValue = Field(description="替换该字段的值,必须符合字段定义的类型和取值")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def path_matches_section(self) -> Self:
|
||||
if (self.section == "name") != (not self.path):
|
||||
raise ValueError("section 为 name 时 path 为空,props / style 至少一段路径")
|
||||
return self
|
||||
|
||||
@property
|
||||
def field_path(self) -> str:
|
||||
"""与物料字段定义一致的点路径,例如 props.option.title.text"""
|
||||
return ".".join([self.section, *map(str, self.path)])
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Literal, Self
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from app.schemas.base import ExtensibleModel, JsonObject, NonBlankString
|
||||
from app.schemas.material_field import MISSING, FieldGroup, lookup
|
||||
from app.schemas.screen import Layout, PositiveNumber
|
||||
|
||||
|
||||
@@ -26,15 +27,6 @@ class MaterialTemplate(ExtensibleModel):
|
||||
events: list[JsonObject] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MaterialSetter(ExtensibleModel):
|
||||
type: NonBlankString = Field(description="前端属性控件类型,不是完整的值类型约束")
|
||||
label: NonBlankString
|
||||
key: NonBlankString = Field(
|
||||
description="前端原始点路径,例如 props.option.series.0.itemStyle.color"
|
||||
)
|
||||
props: JsonObject = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ComponentDefinition(ExtensibleModel):
|
||||
model_config = ConfigDict(serialize_by_alias=True)
|
||||
key: NonBlankString = Field(description="目录中的唯一编号,不是 Screen 节点 ID")
|
||||
@@ -52,22 +44,26 @@ class ComponentDefinition(ExtensibleModel):
|
||||
schema_: MaterialTemplate = Field(
|
||||
alias="schema", description="完整默认物料配置,不包含实例 ID"
|
||||
)
|
||||
setters: list[MaterialSetter] = Field(default_factory=list)
|
||||
field_groups: list[NonBlankString | FieldGroup] = Field(
|
||||
default_factory=list,
|
||||
description="字段分组:字符串引用目录的共享分组,对象为本物料独有的分组;"
|
||||
"前端属性面板的 setters 由它派生,不单独保存",
|
||||
)
|
||||
eventOptions: list[JsonObject] = Field(default_factory=list)
|
||||
sample_data: bool = Field(
|
||||
default=False, description="默认配置是否含示例数据,不能视为真实业务数据"
|
||||
)
|
||||
props_schema: JsonObject | None = Field(
|
||||
default=None,
|
||||
description="可选的 props JSON Schema,未提供表示未知,不表示无限制",
|
||||
)
|
||||
style_schema: JsonObject | None = Field(
|
||||
default=None, description="可选的 style JSON Schema,仅保存定义,当前不执行校验"
|
||||
)
|
||||
usage_notes: list[NonBlankString] = Field(
|
||||
default_factory=list, description="配置、数据绑定及使用限制说明"
|
||||
)
|
||||
|
||||
def template(self) -> JsonObject:
|
||||
"""默认配置的 JSON;style 为 null 表示未设置,字段默认值按路径不存在处理"""
|
||||
data = self.schema_.model_dump(mode="json")
|
||||
if data.get("style") is None:
|
||||
data.pop("style", None)
|
||||
return data
|
||||
|
||||
@model_validator(mode="after")
|
||||
def consistent_type(self) -> Self:
|
||||
if self.type != self.schema_.type:
|
||||
@@ -77,17 +73,73 @@ class ComponentDefinition(ExtensibleModel):
|
||||
return self
|
||||
|
||||
|
||||
def _default_problem(field, template: JsonObject) -> str | None:
|
||||
"""默认值只有一个来源:默认配置有该路径时取它,没有时字段必须声明 default"""
|
||||
try:
|
||||
current = lookup(template, field.segments)
|
||||
except ValueError:
|
||||
return "的路径与默认配置结构冲突"
|
||||
if current is MISSING:
|
||||
if field.default is None:
|
||||
return "在默认配置中不存在,需要声明 default"
|
||||
error = field.value_error(field.default)
|
||||
return f"的 default {error}" if error else None
|
||||
if field.default is not None:
|
||||
return "在默认配置中已有值,不能再声明 default"
|
||||
error = field.value_error(current)
|
||||
return f"的默认配置值 {error}" if error else None
|
||||
|
||||
|
||||
class ComponentCatalog(ExtensibleModel):
|
||||
version: NonBlankString = Field(
|
||||
description="前端组件目录版本,后续用于检查是否与前端匹配"
|
||||
)
|
||||
components: list[ComponentDefinition]
|
||||
field_groups: list[FieldGroup] = Field(
|
||||
default_factory=list, description="多个物料共用的字段分组,物料按 key 引用"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def unique_keys(self) -> Self:
|
||||
keys = [component.key for component in self.components]
|
||||
if len(keys) != len(set(keys)):
|
||||
raise ValueError("组件目录 key 不能重复")
|
||||
shared = [group.key for group in self.field_groups]
|
||||
if len(shared) != len(set(shared)):
|
||||
raise ValueError("共享字段分组 key 不能重复")
|
||||
for component in self.components:
|
||||
groups = [
|
||||
group.key if isinstance(group, FieldGroup) else group
|
||||
for group in component.field_groups
|
||||
]
|
||||
if len(groups) != len(set(groups)):
|
||||
raise ValueError(f"物料 {component.key} 的字段分组 key 重复")
|
||||
if missing := [
|
||||
group
|
||||
for group in component.field_groups
|
||||
if isinstance(group, str) and group not in shared
|
||||
]:
|
||||
raise ValueError(f"物料 {component.key} 引用了不存在的共享分组: {missing}")
|
||||
if shadowed := [
|
||||
group.key
|
||||
for group in component.field_groups
|
||||
if isinstance(group, FieldGroup) and group.key in shared
|
||||
]:
|
||||
raise ValueError(f"物料 {component.key} 的独有分组与共享分组重名: {shadowed}")
|
||||
by_key = {group.key: group for group in self.field_groups}
|
||||
fields = [
|
||||
field
|
||||
for group in component.field_groups
|
||||
for field in (by_key[group] if isinstance(group, str) else group).fields
|
||||
]
|
||||
paths = [field.path for field in fields]
|
||||
if duplicated := sorted({path for path in paths if paths.count(path) > 1}):
|
||||
raise ValueError(f"物料 {component.key} 的字段路径重复: {duplicated}")
|
||||
template = component.template()
|
||||
for field in fields:
|
||||
problem = _default_problem(field, template)
|
||||
if problem:
|
||||
raise ValueError(f"物料 {component.key} 字段 {field.path} {problem}")
|
||||
return self
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""物料字段定义:分组 → 字段,每个字段带路径、含义、值类型和取值约束
|
||||
|
||||
字段定义是“组件有哪些可改属性”的唯一来源:前端属性面板的 setters、
|
||||
agent 查询字段、动作校验都由它派生,不再各自维护一份
|
||||
"""
|
||||
|
||||
from typing import Literal, Self
|
||||
|
||||
from pydantic import Field, JsonValue, model_validator
|
||||
|
||||
from app.schemas.base import ExtensibleModel, NonBlankString
|
||||
|
||||
# 字段路径的第一段:节点名称、布局、组件属性、组件样式
|
||||
FIELD_ROOTS = ("name", "layout", "props", "style")
|
||||
|
||||
ValueType = Literal[
|
||||
"string", "number", "integer", "boolean", "color", "enum", "array", "object"
|
||||
]
|
||||
# 数组元素只用基础类型,枚举和嵌套数组没有需要的场景
|
||||
ItemType = Literal["string", "number", "integer", "boolean", "color", "object"]
|
||||
|
||||
# 值类型对应的前端属性控件;array / object 没有通用控件,只能不在面板显示
|
||||
PANEL_CONTROLS: dict[str, str] = {
|
||||
"string": "input",
|
||||
"number": "number",
|
||||
"integer": "number",
|
||||
"boolean": "checkbox",
|
||||
"color": "color",
|
||||
"enum": "select",
|
||||
}
|
||||
|
||||
|
||||
# 默认配置中没有该路径时的标记,区别于值为 null
|
||||
MISSING = object()
|
||||
|
||||
# 值类型对应的 JSON 类型,用于检查默认配置里的现值
|
||||
_JSON_TYPES: dict[str, type | tuple[type, ...]] = {
|
||||
"string": str,
|
||||
"color": str,
|
||||
"number": (int, float),
|
||||
"integer": int,
|
||||
"boolean": bool,
|
||||
"array": list,
|
||||
"object": dict,
|
||||
}
|
||||
|
||||
|
||||
def _type_error(value_type: str, value: JsonValue) -> str | None:
|
||||
# bool 是 int 的子类,数值类型需要单独排除
|
||||
if not isinstance(value, _JSON_TYPES[value_type]) or (
|
||||
value_type in ("number", "integer") and isinstance(value, bool)
|
||||
):
|
||||
return f"类型必须是 {value_type}"
|
||||
return None
|
||||
|
||||
|
||||
def lookup(value: JsonValue, segments: list[str | int]):
|
||||
"""按路径取值;路径不存在返回 MISSING,路径中途遇到标量时抛 ValueError"""
|
||||
for segment in segments:
|
||||
if isinstance(value, dict) and isinstance(segment, str):
|
||||
if segment not in value:
|
||||
return MISSING
|
||||
value = value[segment]
|
||||
elif isinstance(value, list) and isinstance(segment, int):
|
||||
if segment >= len(value):
|
||||
return MISSING
|
||||
value = value[segment]
|
||||
else:
|
||||
raise ValueError(f"路径无法继续访问: {segments}")
|
||||
return value
|
||||
|
||||
|
||||
class FieldOption(ExtensibleModel):
|
||||
value: JsonValue
|
||||
label: NonBlankString
|
||||
description: NonBlankString | None = None
|
||||
|
||||
|
||||
class MaterialField(ExtensibleModel):
|
||||
path: NonBlankString = Field(
|
||||
description="点路径,与前端 setter key 一致,数字段为数组下标,例如 props.option.series.0.itemStyle.color"
|
||||
)
|
||||
label: NonBlankString = Field(description="字段名称,属性面板显示的文字")
|
||||
description: NonBlankString = Field(
|
||||
description="字段含义、在画面上的效果,以及用户通常怎么称呼它"
|
||||
)
|
||||
value_type: ValueType
|
||||
options: list[FieldOption] = Field(
|
||||
default_factory=list, description="enum 的可选值,每个值可带说明"
|
||||
)
|
||||
item_type: ItemType | None = Field(
|
||||
default=None, description="array 的元素类型"
|
||||
)
|
||||
minimum: int | float | None = None
|
||||
maximum: int | float | None = None
|
||||
unit: NonBlankString | None = Field(default=None, description="数值单位,例如 px")
|
||||
default: JsonValue = Field(
|
||||
default=None,
|
||||
description="物料默认配置中没有该路径时的默认值;有该路径时以默认配置为准,不在这里重复",
|
||||
)
|
||||
aliases: list[NonBlankString] = Field(
|
||||
default_factory=list, description="用户可能的叫法,供字段搜索使用"
|
||||
)
|
||||
editable: bool = Field(default=True, description="是否允许修改")
|
||||
panel: bool = Field(default=True, description="是否显示在前端属性面板")
|
||||
span: int | None = Field(default=None, description="属性面板栅格宽度,24 为整行")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def consistent(self) -> Self:
|
||||
if self.path.split(".")[0] not in FIELD_ROOTS:
|
||||
raise ValueError(f"字段路径必须以 {'/'.join(FIELD_ROOTS)} 开头: {self.path}")
|
||||
if (self.value_type == "enum") != bool(self.options):
|
||||
raise ValueError(f"只有 enum 字段提供 options,且 enum 必须提供: {self.path}")
|
||||
if (self.value_type == "array") != (self.item_type is not None):
|
||||
raise ValueError(f"只有 array 字段提供 item_type,且 array 必须提供: {self.path}")
|
||||
if self.panel and self.value_type not in PANEL_CONTROLS:
|
||||
raise ValueError(f"{self.value_type} 字段没有属性面板控件,panel 必须为 false: {self.path}")
|
||||
return self
|
||||
|
||||
def value_error(self, value: JsonValue) -> str | None:
|
||||
"""检查值是否符合字段类型和取值约束,符合返回 None"""
|
||||
if self.value_type == "enum":
|
||||
allowed = [option.value for option in self.options]
|
||||
return None if value in allowed else f"取值必须是 {allowed} 之一"
|
||||
if error := _type_error(self.value_type, value):
|
||||
return error
|
||||
if self.item_type is not None:
|
||||
for element in value:
|
||||
if error := _type_error(self.item_type, element):
|
||||
return f"数组元素{error}"
|
||||
if self.value_type in ("number", "integer"):
|
||||
if self.minimum is not None and value < self.minimum:
|
||||
return f"不能小于 {self.minimum}"
|
||||
if self.maximum is not None and value > self.maximum:
|
||||
return f"不能大于 {self.maximum}"
|
||||
return None
|
||||
|
||||
@property
|
||||
def segments(self) -> list[str | int]:
|
||||
# 数字段视为数组下标,与前端 setter key 的解析方式一致
|
||||
return [int(part) if part.isdigit() else part for part in self.path.split(".")]
|
||||
|
||||
|
||||
class FieldGroup(ExtensibleModel):
|
||||
key: NonBlankString = Field(description="分组编号,共享分组在整个目录内唯一")
|
||||
label: NonBlankString
|
||||
description: NonBlankString = Field(description="这一组字段管什么,供模型先看目录再查字段")
|
||||
fields: list[MaterialField]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def unique_paths(self) -> Self:
|
||||
paths = [field.path for field in self.fields]
|
||||
if len(paths) != len(set(paths)):
|
||||
raise ValueError(f"分组 {self.key} 中字段路径重复")
|
||||
return self
|
||||
@@ -30,7 +30,7 @@ class RequirementChange(ExtensibleModel):
|
||||
operation: NonBlankString = Field(description=f"需求操作。{OPERATION_GUIDE}")
|
||||
parameters: JsonObject = Field(
|
||||
default_factory=dict,
|
||||
description="需求参数,例如 delta_x=50;保留用户要求的单位与相对变化语义",
|
||||
description="需求参数,例如 delta_x=50;保留用户要求的单位与相对变化语义;属性修改用用户的说法描述改什么、改成什么,例如 {\"名字\": \"月度销售\"},不推断字段路径",
|
||||
)
|
||||
|
||||
@field_validator("operation")
|
||||
|
||||
Reference in New Issue
Block a user