Files
AgentLab/backend/app/apps/ops.py
T
ThearchyHelios 277982ead3 普通用户建应用(后端):
- 蓝图:bp_* 操作和逐卡白名单、蓝图检查、留痕、按蓝图判权限;五个场景模板加自由形(骨架、关节、应用骨架、定时任务),实例化与骨架比对;BlueprintApplier / TemplateApplier(只收模板声明过的关节)与自查;look 新类别口径卡;蓝图对话和填关节两个流式接口,不同组的关节并行填、出错的组串行重试;模板列表
- 试运行与发布单:四种起点(对话、模板、问数据的一轮、已发布版本);试运行是探索运行,草稿应用按这次运行解析、印章写试运行,流程类派给自己、不触发升级;发布单九步、预检只读、每步留痕、停下后接着做、核对卡片指纹和应用管理权、启动时整理中断的单;白话层覆盖全部问题编号
- 记录表与文件:平台托管只追加的记录表、按高水位固定数据、连接上的临时视图做行级可见、写入走动作(records_append)、表版本在发布时切换、草稿不影响线上、记录列表遮敏感列;文件按哈希存、两步上传、提交时绑定运行、下载按读权限、清理、file_text、按配方导入一期(table_import_period)
- 个人应用:存成个人应用(问数据的约束)、刷新(遮罩、只读、每日上限)、只有拥有者看得见、提交审核与共享副本、开关下发到认人接口;页面编辑的候选;隐藏组件不下发;三条新检查
2026-10-15 12:09:29 +08:00

314 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""应用操作:编辑器和助手润色共用(L1 的 AppApplier 调这里)。
操作(一行一个 JSON):
{"op":"app_add","parent":"<组件 id>|<页面 id>","index":0,"component":{...}}
{"op":"app_update","id":"<组件 id>","props":{...},"bind":{...},"visible":"...","hidden":true}
{"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")
#: 助手润色能用的操作(D-h):只改呈现。app_set 另外只放行 task_ui,和 forms 里只动外观键的改动(polish_problem)
POLISH_OPS = ("app_add", "app_update", "app_remove", "app_move", "app_page")
#: 表单里的外观键:顶层和每个字段上都只能改这几个,别的(type、required、enum、取值范围、format……)是数据约束
FORM_LOOK_KEYS = frozenset({"title", "description", "x-order", "x-widget", "x-enum-labels"})
_SET_LABEL = {"entries": "入口", "views": "视图", "slots": "岗位", "pins": "固定的版本"}
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
if "hidden" in op and op["hidden"] not in (True, False, None):
return _refuse("隐藏只能写是或否")
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]
# 隐藏(normal-user-apps N6):作者不要它,只是呈现;为真才写,取消隐藏去掉这一项(组件模型的默认值是 false)
if "hidden" in op:
if op["hidden"] is True:
comp["hidden"] = True
else:
comp.pop("hidden", None)
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()
# --------------------------------------------------------------------------
# 助手润色的边界(D-h)
# --------------------------------------------------------------------------
def polish_problem(spec: dict[str, Any], op: dict[str, Any]) -> str | None:
"""助手润色时这个操作有没有越界:越界返回交回模型的一句话,没越界返回 None。
润色只改呈现(布局、文案、控件,A8 第 2 条)。钉住的版本、入口、视图、岗位决定的是这个应用发起什么、读哪次运行、
派给谁,表单的数据约束决定收什么值——这些要人在编辑器里改、发布时过检查,助手改了会悄悄换掉应用的承诺。
编辑器自己调 apply_app_op 不受这一层限制。"""
kind = op.get("op") if isinstance(op, dict) else None
if kind != "app_set":
return None
path, key = op.get("path"), op.get("key")
if path == "task_ui":
return None
if path == "forms":
return _form_look_problem(spec, key, op.get("value"))
what = _SET_LABEL.get(str(path), str(path))
return (f"润色只改呈现,不能改{what}:{what}决定应用发起什么、读哪次运行、派给谁,要由人在编辑器里改。"
"请只用组件操作、待办外观(task_ui)和表单的外观键")
def _looks_stripped(form: Any) -> Any:
"""去掉外观键之后的表单:两份表单去掉外观键后一样,就只差在外观上。"""
if not isinstance(form, dict):
return form
out = {k: v for k, v in form.items() if k not in FORM_LOOK_KEYS}
props = form.get("properties")
if isinstance(props, dict):
out["properties"] = {name: ({k: v for k, v in field.items() if k not in FORM_LOOK_KEYS}
if isinstance(field, dict) else field) for name, field in props.items()}
return out
def _form_look_problem(spec: dict[str, Any], key: Any, value: Any) -> str | None:
forms = spec.get("forms") if isinstance(spec.get("forms"), dict) else {}
if not isinstance(key, str) or key not in forms:
return f"润色不能新增表单「{key}」:表单决定入口收什么值,要由人在编辑器里加"
if value is None:
return f"润色不能删掉表单「{key}」"
if _looks_stripped(forms[key]) != _looks_stripped(value):
return (f"表单「{key}」只能改外观({'、'.join(sorted(FORM_LOOK_KEYS))}),不能改字段、类型、必填、可选值、"
"取值范围这些数据约束")
return None