Files
AgentLab/backend/app/apps/ops.py
T

251 lines
10 KiB
Python

"""应用操作:编辑器和助手润色共用(L1 的 AppApplier 调这里)。
操作(一行一个 JSON):
{"op":"app_add","parent":"<组件 id>|<页面 id>","index":0,"component":{...}}
{"op":"app_update","id":"<组件 id>","props":{...},"bind":{...},"visible":"..."}
{"op":"app_remove","id":"<组件 id>"}
{"op":"app_move","id":"<组件 id>","parent":"...","index":0}
{"op":"app_page","page":{"id","title"}} 新增或改页面标题
{"op":"app_set","path":"entries|views|forms|slots|task_ui","key":"…","value":{...}}
只改形状,不做语义检查:绑定的节点在不在、表达式合不合法……交给应用检查(lint.py),和图操作落地后再自查是同一个分工。
同 id 的组件已经在、内容也一样的 app_add 不算改动、不转发(look §2.5.5 的去重:模型接着写时常把落过的再写一遍)。
"""
from __future__ import annotations
import copy
from typing import Any
from pydantic import ValidationError
# 落地结果和图操作共用一个形状(L1 的 Applier 接口)
from app.look.base import Applied
from app.apps.spec import Component, Entry, Slot, View
from app.apps.tree import components
OPS = ("app_add", "app_update", "app_remove", "app_move", "app_page", "app_set")
_SET_PATHS = ("entries", "views", "forms", "slots", "task_ui")
def _refuse(problem: str) -> Applied:
return Applied(False, False, problem)
def _locate(spec: dict[str, Any], cid: str) -> tuple[list[Any], int] | None:
"""组件所在的那个列表和下标。"""
for page in spec.get("pages") or []:
found = _find_in(page.get("body") or [], cid)
if found:
return found
return None
def _find_in(items: list[Any], cid: str) -> tuple[list[Any], int] | None:
for i, comp in enumerate(items):
if not isinstance(comp, dict):
continue
if comp.get("id") == cid:
return items, i
found = _find_in(comp.get("children") or [], cid)
if found:
return found
return None
def _container(spec: dict[str, Any], parent: Any) -> list[Any] | None:
"""parent 指的那个列表:页面 id → 页面的 body;组件 id → 它的 children(只有能放子组件的布局组件)。"""
from app.apps.catalog import BY_NAME
for page in spec.get("pages") or []:
if isinstance(page, dict) and page.get("id") == parent:
page.setdefault("body", [])
return page["body"]
for comp, _, _ in components(spec):
if comp.get("id") == parent:
if not (BY_NAME.get(str(comp.get("type"))) or {}).get("children"):
return None
comp.setdefault("children", [])
return comp["children"]
return None
def _insert(items: list[Any], index: Any, item: Any) -> None:
at = index if isinstance(index, int) and not isinstance(index, bool) and 0 <= index <= len(items) else len(items)
items.insert(at, item)
def _ids(spec: dict[str, Any]) -> set[str]:
return {str(c.get("id")) for c, _, _ in components(spec)} | {
str(p.get("id")) for p in spec.get("pages") or [] if isinstance(p, dict)}
def apply_app_op(spec: dict[str, Any], op: dict[str, Any]) -> Applied:
"""把一个应用操作应用到 spec(原地修改)。认不出、目标不存在的返回 Applied(False, False, problem)。"""
kind = op.get("op") if isinstance(op, dict) else None
if kind not in OPS:
return _refuse(f"不认识的应用操作:{kind}")
spec.setdefault("pages", [])
return {"app_add": _add, "app_update": _update, "app_remove": _remove, "app_move": _move, "app_page": _page,
"app_set": _set}[kind](spec, op)
def _add(spec: dict[str, Any], op: dict[str, Any]) -> Applied:
raw = op.get("component")
try:
comp = Component.model_validate(raw).model_dump(mode="json", exclude_none=True)
except ValidationError as e:
return _refuse(f"组件的写法不对:{_first(e)}")
comp = {k: v for k, v in comp.items() if v not in ({}, []) or k in ("id", "type")}
existing = _locate(spec, comp["id"])
if existing is not None:
items, i = existing
same = Component.model_validate(items[i]).model_dump(mode="json", exclude_none=True)
same = {k: v for k, v in same.items() if v not in ({}, []) or k in ("id", "type")}
return Applied(False, False) if same == comp else _refuse(f"组件「{comp['id']}」已经存在")
if comp["id"] in _ids(spec):
return _refuse(f"「{comp['id']}」已经是一个页面的名字,请给组件换一个名字")
if [c for c, _, _ in components({"pages": [{"body": [comp]}]}) if c is not comp and c.get("id") in _ids(spec)]:
return _refuse("新组件的子组件和已有的组件重名")
items = _container(spec, op.get("parent"))
if items is None:
return _refuse(f"找不到可以放组件的位置「{op.get('parent')}」:只能放进页面或布局组件")
_insert(items, op.get("index"), comp)
return Applied(True)
def _update(spec: dict[str, Any], op: dict[str, Any]) -> Applied:
found = _locate(spec, str(op.get("id")))
if found is None:
return _refuse(f"找不到组件「{op.get('id')}」")
items, i = found
before = copy.deepcopy(items[i])
comp = items[i]
if isinstance(op.get("props"), dict):
props = dict(comp.get("props") or {})
for k, v in op["props"].items():
# 写 null 表示去掉这一项
if v is None:
props.pop(k, None)
else:
props[k] = v
comp["props"] = props
for key in ("bind", "visible", "scope"):
if key in op:
if op[key] in (None, "", {}):
comp.pop(key, None)
else:
comp[key] = op[key]
try:
Component.model_validate(comp)
except ValidationError as e:
items[i] = before
return _refuse(f"改完之后组件的写法不对:{_first(e)}")
changed = comp != before
return Applied(changed, changed)
def _remove(spec: dict[str, Any], op: dict[str, Any]) -> Applied:
found = _locate(spec, str(op.get("id")))
if found is None:
return _refuse(f"找不到组件「{op.get('id')}」")
items, i = found
del items[i]
return Applied(True)
def _move(spec: dict[str, Any], op: dict[str, Any]) -> Applied:
cid = str(op.get("id"))
found = _locate(spec, cid)
if found is None:
return _refuse(f"找不到组件「{cid}」")
items, i = found
comp = items[i]
inside = {str(c.get("id")) for c, _, _ in components({"pages": [{"body": [comp]}]})}
if op.get("parent") in inside:
return _refuse("不能把组件移进它自己里面")
target = _container(spec, op.get("parent"))
if target is None:
return _refuse(f"找不到可以放组件的位置「{op.get('parent')}」:只能放进页面或布局组件")
if target is items and op.get("index") in (i, i + 1):
return Applied(False, False)
del items[i]
index = op.get("index")
if target is items and isinstance(index, int) and index > i:
index -= 1
_insert(target, index, comp)
return Applied(True)
def _page(spec: dict[str, Any], op: dict[str, Any]) -> Applied:
page = op.get("page")
if not isinstance(page, dict) or not isinstance(page.get("id"), str) or not page["id"].strip():
return _refuse("页面需要有名字")
title = page.get("title")
for existing in spec["pages"]:
if isinstance(existing, dict) and existing.get("id") == page["id"]:
if not isinstance(title, str) or existing.get("title") == title:
return Applied(False, False)
existing["title"] = title
return Applied(True)
if page["id"] in _ids(spec):
return _refuse(f"「{page['id']}」已经是一个组件的名字,请给页面换一个名字")
spec["pages"].append({"id": page["id"], "title": title if isinstance(title, str) else page["id"], "body": []})
return Applied(True)
_MODELS = {"entries": Entry, "views": View, "slots": Slot}
def _set(spec: dict[str, Any], op: dict[str, Any]) -> Applied:
from app.apps.forms import validate_form_schema
path, key, value = op.get("path"), op.get("key"), op.get("value")
if path not in _SET_PATHS:
return _refuse(f"只能设置入口、视图、表单、岗位或待办外观,不能设置「{path}」")
if not isinstance(key, str) or not key.strip():
return _refuse("需要写明设置哪一项")
if value is not None:
if path in _MODELS:
try:
value = _MODELS[path].model_validate(value if path != "entries" else {**value, "id": key}) \
.model_dump(mode="json", by_alias=True, exclude_none=True)
except (ValidationError, TypeError) as e:
return _refuse(f"写法不对:{_first(e) if isinstance(e, ValidationError) else e}")
elif path == "forms":
if problems := validate_form_schema(value):
return _refuse(f"表单不合规:{problems[0]}")
elif not isinstance(value, dict):
return _refuse("需要是一个对象")
if path == "entries":
entries = spec.setdefault("entries", [])
at = next((i for i, e in enumerate(entries) if isinstance(e, dict) and e.get("id") == key), None)
if value is None:
if at is None:
return Applied(False, False)
del entries[at]
return Applied(True)
if at is not None:
if entries[at] == value:
return Applied(False, False)
entries[at] = value
else:
entries.append(value)
return Applied(True)
table = spec.setdefault(path, {})
if value is None:
if key not in table:
return Applied(False, False)
del table[key]
return Applied(True)
if table.get(key) == value:
return Applied(False, False)
table[key] = value
return Applied(True)
def _first(e: ValidationError) -> str:
err = e.errors()[0] if e.errors() else {}
where = ".".join(str(x) for x in err.get("loc") or ())
return f"{where} {err.get('msg', '')}".strip()