feat: 动作库查看/管理(接口 + AI 控制台「🎬 动作库」面板)+ 未沉淀灰卡提示

- 后端:GET /api/agent/actions(列表)、POST /api/agent/actions/delete、
  POST /api/agent/actions/save(新增/编辑,steps 走 _sanitize_actions 校验:
  type 白名单 + 必填 + **拒绝坐标 click_xy**),均 @admin_required
- 前端:AI 控制台右上角新增「🎬 动作库」按钮与模态框——列出 名称/app/别名/
  步骤摘要(含元素定位)/命中次数/更新时间;支持编辑(表单 + steps JSON)、删除、
  手动新建;保存失败(如写坐标)即时 toast 原因
- 未沉淀提示:本轮无可沉淀动作时也推送一张 🧠 动作经验 灰卡
  「本轮未沉淀动作(步骤以坐标定位为主,缺少可复用的元素定位信息)」
- doc/API.md:登记三个新接口
实测:列表返回自动沉淀的动作;保存合法动作 200;提交 click_xy 步骤被 400 拒绝;
删除生效
This commit is contained in:
2026-09-10 13:58:38 +08:00
parent 104964aa53
commit 91b2785c6b
4 changed files with 224 additions and 0 deletions
+93
View File
@@ -829,6 +829,94 @@ def agent_experience_keep():
db.session.commit()
return jsonify({"ok": True, "msg": f"经验 #{exp_id} 已保留"})
# ================== 动作经验库(agent_action:查看/删除/编辑) ==================
def _action_row_to_dict(r):
def _j(s, d):
try:
return json.loads(s) if s else d
except Exception:
return d
return {"id": r[0], "name": r[1], "app": r[2], "aliases": _j(r[3], []),
"params": _j(r[4], []), "steps": _j(r[5], []), "preconditions": r[6],
"hits": r[7] or 0, "updated_at": r[8] or ""}
@bp.route("/api/agent/actions")
@admin_required
def agent_actions_list():
"""动作经验库列表(命名动作 + 编辑器 schema 步骤 + 元素定位,禁坐标)。"""
with _flask_app.app_context():
_ensure_action_table()
rows = db.session.execute(db.text(
"SELECT id, name, app, aliases, params, steps, preconditions, hits, updated_at "
"FROM agent_action ORDER BY hits DESC, id DESC")).fetchall()
return jsonify({"ok": True, "actions": [_action_row_to_dict(r) for r in rows]})
@bp.route("/api/agent/actions/delete", methods=["POST"])
@admin_required
def agent_actions_delete():
aid = int((request.json or {}).get("id") or 0)
if aid <= 0:
return jsonify({"ok": False, "error": "缺少 id"}), 400
with _flask_app.app_context():
_ensure_action_table()
db.session.execute(db.text("DELETE FROM agent_action WHERE id=:i"), {"i": aid})
db.session.commit()
return jsonify({"ok": True, "msg": f"动作 #{aid} 已删除"})
@bp.route("/api/agent/actions/save", methods=["POST"])
@admin_required
def agent_actions_save():
"""新增/编辑动作:{id?, name, app?, aliases?, params?, steps, preconditions?}。
steps 可为数组或 JSON 字符串;经 _sanitize_actions 校验(白名单/必填/禁坐标)。
"""
from datetime import datetime
d = request.json or {}
steps = d.get("steps")
if isinstance(steps, str):
try:
steps = json.loads(steps)
except Exception:
return jsonify({"ok": False, "error": "steps 不是合法 JSON"}), 400
item = _sanitize_actions([{"name": d.get("name"), "app": d.get("app", ""),
"aliases": d.get("aliases") or [],
"params": d.get("params") or [],
"preconditions": d.get("preconditions", ""),
"steps": steps if isinstance(steps, list) else []}])
if not item:
return jsonify({"ok": False, "error": "动作无效:需要 name,且 steps 需为受支持的步骤"
"(click/open_app/input_text… 且带元素定位;不接受坐标)"}), 400
a = item[0]
now = datetime.now().strftime("%Y-%m-%d %H:%M")
with _flask_app.app_context():
_ensure_action_table()
aid = int(d.get("id") or 0)
if aid > 0:
db.session.execute(db.text(
"UPDATE agent_action SET name=:n, app=:a, aliases=:al, params=:p, steps=:s, "
"preconditions=:pc, updated_at=:t WHERE id=:i"),
{"n": a["name"], "a": a["app"],
"al": json.dumps(a["aliases"], ensure_ascii=False),
"p": json.dumps(a["params"], ensure_ascii=False),
"s": json.dumps(a["steps"], ensure_ascii=False),
"pc": a["preconditions"], "t": now, "i": aid})
else:
db.session.execute(db.text(
"INSERT INTO agent_action(name, app, aliases, params, steps, preconditions, "
"hits, source_prompt, created_at, updated_at) "
"VALUES(:n,:a,:al,:p,:s,:pc,0,'(人工新增)',:t,:t)"),
{"n": a["name"], "a": a["app"],
"al": json.dumps(a["aliases"], ensure_ascii=False),
"p": json.dumps(a["params"], ensure_ascii=False),
"s": json.dumps(a["steps"], ensure_ascii=False),
"pc": a["preconditions"], "t": now})
db.session.commit()
return jsonify({"ok": True, "msg": "动作已保存"})
# ================== 历史会话(DeepSeek 式:多会话持久化) ==================
# 会话 = 消息序列(JSON),一轮 run 的 user/assistant 文本完成后追加落库。
# 单表 JSON 存储(每会话几十 KB 内,无需独立消息表)。
@@ -1341,6 +1429,11 @@ def _agent_thread(run_id, prompt, serial, cfg):
"args": f"已沉淀 {n_act} 个可复用动作"
"(含元素定位,下次同类任务可直接复用)",
"image": None}))
else:
q.put(("step", {"tool": "🧠 动作经验",
"args": "本轮未沉淀动作(步骤以坐标定位为主,"
"缺少可复用的元素定位信息)",
"image": None}))
except Exception as e:
_log.warning(f"经验保存异常: {e}")
q.put(("done", {"answer": answer}))