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 = '
暂无动作——跑一轮以元素/文字点击为主的任务后会自动沉淀
'; + return; + } + list.innerHTML = ''; + _actCache.forEach(a=>{ + const card = document.createElement('div'); + card.style.cssText = 'border:1px solid var(--card-line);border-radius:10px;padding:9px 13px;background:#0d1117'; + card.innerHTML = '
' + + '
' + + '
' + esc(a.name||'') + + (a.app ? '' + esc(a.app) + '' : '') + + '
' + + '
' + esc(_actStepBrief(a) || '—') + '
' + + '
别名: ' + esc((a.aliases||[]).join('、')||'—') + + ' | 命中 ' + (a.hits||0) + ' 次 | ' + esc(a.updated_at||'') + '
' + + '
' + + '
' + + '' + + '' + + '
'; + list.appendChild(card); + }); + }); +} +function actDelete(id){ + if(!confirm('确认删除该动作?删除后同类任务不再自动复用它。')) return; + apiPost('/api/agent/actions/delete',{id:id}).then(r=>{ + showToast((r&&(r.msg||r.error))||'删除失败', (r&&r.ok)?'success':'error'); + loadActLib(); + }); +} +function actNew(){ _actForm({name:'', app:'', aliases:[], params:[], steps:[]}); } +function actEdit(id){ + const a = _actCache.find(x=>x.id===id); + if(a) _actForm(a); +} +function _actForm(a){ + const list = document.getElementById('act-lib-list'); + const isNew = !a.id; + list.innerHTML = ''; + const box = document.createElement('div'); + box.style.cssText = 'border:1px solid var(--card-line);border-radius:10px;padding:12px 14px;background:#0d1117'; + box.innerHTML = + '
' + (isNew?'新建动作':'编辑动作 #'+a.id) + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '' + + '
可用 type:open_app/click/input_text/swipe/wait/key_event/group…;click 必须有 selector_value(xpath/text/resourceId/description…);不接受坐标(click_xy)。
' + + '
' + + '
'; + list.appendChild(box); +} +function actSave(id){ + const g = (i)=>document.getElementById(i).value; + const split = (s)=>String(s||'').split(/[,,]/).map(x=>x.trim()).filter(x=>x); + let steps; + try{ steps = JSON.parse(g('act-f-steps')||'[]'); } + catch(e){ showToast('steps 不是合法 JSON: '+e.message,'error'); return; } + apiPost('/api/agent/actions/save', { + id: id||0, name: g('act-f-name'), app: g('act-f-app'), + aliases: split(g('act-f-alias')), params: split(g('act-f-params')), steps: steps + }).then(r=>{ + if(!r||!r.ok){ showToast((r&&r.error)||'保存失败','error'); return; } + showToast(r.msg||'已保存','success'); + loadActLib(); + }); +} + // ================== 经验库(自进化记忆管理) ================== function openExpLib(){ document.getElementById('agent-exp-overlay').style.display = 'flex'; diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index 84a434c..3c2942e 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -480,6 +480,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
+
@@ -563,6 +564,24 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14 + + +
日志查看
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}))