diff --git a/doc/API.md b/doc/API.md
index a7c9700..3a6232c 100644
--- a/doc/API.md
+++ b/doc/API.md
@@ -1275,6 +1275,19 @@ AI 可用设备列表(在线状态 + 是否有任务运行,前端据此把 b
**响应**:`{"ok": true, "msg": "巡检已启动,完成后刷新列表查看建议"}`
+### GET /api/agent/actions
+动作经验库列表(命名动作 + 编辑器 schema 步骤 + 元素定位、禁坐标)。仅管理员。
+响应:`{"ok":true,"actions":[{id,name,app,aliases,params,steps,preconditions,hits,updated_at}]}`。
+由任务成功后的**成功步骤**自动蒸馏沉淀;执行前按动作名/别名召回并注入 system prompt。
+
+### POST /api/agent/actions/delete
+删除动作:`{id}`。仅管理员。
+
+### POST /api/agent/actions/save
+新增/编辑动作:`{id?, name, app?, aliases?, params?, steps, preconditions?}`。仅管理员。
+`steps` 可为数组或 JSON 字符串,经服务端校验(白名单 type + 必填;**拒绝坐标 click_xy**)。
+校验失败返回 400 并附原因。
+
### POST /api/agent/experience/keep
人工保留经验(撤销"建议删除",后续巡检不再重复建议)。**请求**(JSON):`{"id": 1}`
diff --git a/static/admin/agent.js b/static/admin/agent.js
index d7bf570..a420bfc 100644
--- a/static/admin/agent.js
+++ b/static/admin/agent.js
@@ -512,6 +512,105 @@ function endRun(){
scrollChat();
}
+// ================== 动作库(可复用动作:查看/编辑/删除/新建) ==================
+let _actCache = [];
+function openActLib(){
+ document.getElementById('agent-act-overlay').style.display = 'flex';
+ loadActLib();
+}
+function closeActLib(){
+ document.getElementById('agent-act-overlay').style.display = 'none';
+}
+function _actStepBrief(a){
+ return (a.steps||[]).map(s=>{
+ const p = s.params || {};
+ const loc = p.selector_value || p.package || p.fixed_text || '';
+ return (s.type || '?') + (loc ? (':' + loc) : '');
+ }).join(' → ');
+}
+function loadActLib(){
+ const list = document.getElementById('act-lib-list');
+ apiGet('/api/agent/actions').then(r=>{
+ if(!r||!r.ok){ showToast((r&&r.error)||'加载失败','error'); return; }
+ _actCache = r.actions || [];
+ document.getElementById('act-lib-hint').textContent = '共 ' + _actCache.length + ' 个动作';
+ if(!_actCache.length){
+ list.innerHTML = '
可用 type:open_app/click/input_text/swipe/wait/key_event/group…;click 必须有 selector_value(xpath/text/resourceId/description…);不接受坐标(click_xy)。
'
+ + '
日志查看
diff --git a/web/agent_api.py b/web/agent_api.py
index 5074e55..5096059 100644
--- a/web/agent_api.py
+++ b/web/agent_api.py
@@ -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}))