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