diff --git a/mcp_agent/agent.py b/mcp_agent/agent.py
index 826e4a6..09f4661 100644
--- a/mcp_agent/agent.py
+++ b/mcp_agent/agent.py
@@ -39,6 +39,7 @@ class Agent:
self.tools_schema = [] # OpenAI function schema
self._tool_exec = {} # name -> callable
self.messages = []
+ self.on_step = None # 可选回调 fn(step_dict),web 展示进度用
# ---------- MCP 工具桥 ----------
async def _load_tools(self):
@@ -93,12 +94,22 @@ class Agent:
except Exception as e:
return {"ok": False, "error": f"工具执行失败: {e}"}, None
# de_screenshot:图像分离(作为 image_url 追加给模型看)
+ image_b64 = None
if name == "de_screenshot" and isinstance(data, dict) and data.get("ok"):
img = (data.get("data") or {}).get("image") or {}
if img.get("data"):
text_result = {k: v for k, v in (data.get("data") or {}).items()
if k != "image"}
- return text_result, img["data"]
+ image_b64 = img["data"]
+ if self.on_step:
+ try:
+ self.on_step({"tool": name, "args": args,
+ "result": text_result if image_b64 else data,
+ "image_b64": image_b64})
+ except Exception:
+ pass
+ if image_b64:
+ return text_result, image_b64
return data, None
# ---------- 主循环 ----------
diff --git a/static/admin/base.js b/static/admin/base.js
index 27f6e3d..548f9a6 100644
--- a/static/admin/base.js
+++ b/static/admin/base.js
@@ -114,6 +114,7 @@ function showSubTab(tabId, name){
tab.querySelectorAll('.sub-tab').forEach(b=>b.classList.toggle('active', b.dataset.sub===name));
tab.querySelectorAll('.sub-panel').forEach(p=>p.classList.toggle('active', p.id===tabId+'-sub-'+name));
if(name==='groups' && typeof loadGroups==='function') loadGroups();
+ if(name==='agent' && typeof loadAgentConfig==='function') loadAgentConfig();
if(name==='devpool' && typeof loadDevPool==='function'){
loadDevPool();
if(typeof loadDiscovery==='function'){
diff --git a/static/admin/tools.js b/static/admin/tools.js
index d81aaf2..c106461 100644
--- a/static/admin/tools.js
+++ b/static/admin/tools.js
@@ -41,6 +41,89 @@ async function loadToolsDevices(force){
status.textContent = '共 '+_clipDevices.length+' 台设备';
}
+// ================== AI 控制台(模型配置 + 指令执行 + 进度轮询) ==================
+let _agentPoll = null;
+
+function loadAgentConfig(){
+ apiGet('/api/agent/config').then(r=>{
+ if(!r||!r.ok)return;
+ const b=document.getElementById('agent-api-base');
+ if(!b)return; // 面板未渲染
+ b.value=r.api_base||'https://api.deepseek.com';
+ document.getElementById('agent-model').value=r.model||'';
+ document.getElementById('agent-default-serial').value=r.default_serial||'';
+ const hint=document.getElementById('agent-key-hint');
+ hint.textContent=r.api_key_masked?('已配置 '+r.api_key_masked):'未配置 Key';
+ });
+}
+function saveAgentConfig(){
+ const body={api_base:document.getElementById('agent-api-base').value.trim(),
+ model:document.getElementById('agent-model').value.trim(),
+ default_serial:document.getElementById('agent-default-serial').value.trim()};
+ const key=document.getElementById('agent-api-key').value.trim();
+ if(key)body.api_key=key;
+ apiPost('/api/agent/config',body).then(r=>{
+ if(r&&r.ok){showToast('配置已保存','success');loadAgentConfig();}
+ else showToast('保存失败: '+((r&&r.error)||''),'error');
+ });
+}
+function runAgent(){
+ const prompt=document.getElementById('agent-prompt').value.trim();
+ if(!prompt){showToast('请输入指令','error');return;}
+ apiPost('/api/agent/run',{prompt}).then(r=>{
+ if(r&&r.ok){
+ showToast('Agent 已启动','success');
+ document.getElementById('agent-status').textContent='运行中...';
+ document.getElementById('btn-agent-run').disabled=true;
+ document.getElementById('agent-answer-wrap').style.display='none';
+ startAgentPoll();
+ }else showToast('启动失败: '+((r&&r.error)||''),'error');
+ });
+}
+function startAgentPoll(){
+ if(_agentPoll)clearInterval(_agentPoll);
+ _agentPoll=setInterval(pollAgent,2000);
+ pollAgent();
+}
+function stopAgentPoll(){
+ if(_agentPoll){clearInterval(_agentPoll);_agentPoll=null;}
+}
+function pollAgent(){
+ apiGet('/api/agent/status').then(r=>{
+ if(!r||!r.ok)return;
+ const st=document.getElementById('agent-status');
+ if(r.state==='running'){
+ st.textContent='运行中... ('+(r.steps||[]).length+' 步)';
+ }else{
+ st.textContent=r.state==='done'?'完成':'失败';
+ document.getElementById('btn-agent-run').disabled=false;
+ stopAgentPoll();
+ }
+ // 步骤流(含截图缩略)
+ const steps=document.getElementById('agent-steps');
+ const html=(r.steps||[]).map(s=>{
+ const args=esc(s.args||'');
+ const img=s.image
+ ?'
':'';
+ return '
'
+ +''+esc(s.tool||'')+' '
+ +''+args+''+img+'
';
+ }).join('')||'';
+ steps.innerHTML=html;
+ // 最终回答
+ if(r.answer){
+ const wrap=document.getElementById('agent-answer-wrap');
+ wrap.style.display='block';
+ document.getElementById('agent-answer').textContent=r.answer;
+ }
+ if(r.error){
+ st.textContent='错误: '+r.error;
+ document.getElementById('btn-agent-run').disabled=false;
+ stopAgentPoll();
+ }
+ });
+}
+
// ================== 设备自动发现(扫描 → 待连接池 → 确认) ==================
function loadDiscovery(){
apiGet('/api/devices/discovery').then(r=>{
diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html
index 9f3f432..52480c7 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
+
@@ -702,6 +703,37 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
管理设备分组,用于任务按分组执行。分组内的设备需先在设备池管理中确认(不在池内的设备不参与调度)。
+
+
+
diff --git a/web/__init__.py b/web/__init__.py
index dfd8ee3..81696b9 100644
--- a/web/__init__.py
+++ b/web/__init__.py
@@ -23,6 +23,7 @@ def register_blueprints(app):
from .devices_api import bp as devices_bp
from .apks_api import bp as apks_bp
from .tailscale_api import bp as tailscale_bp
+ from .agent_api import bp as agent_bp
for bp in (auth_bp, monitor_bp, tasks_bp, admin_bp, tools_bp,
- devices_bp, apks_bp, tailscale_bp):
+ devices_bp, apks_bp, tailscale_bp, agent_bp):
app.register_blueprint(bp)
diff --git a/web/agent_api.py b/web/agent_api.py
new file mode 100644
index 0000000..111d333
--- /dev/null
+++ b/web/agent_api.py
@@ -0,0 +1,157 @@
+"""AI 控制台 API:Agent 配置(模型/Key 前端可配)+ 运行 + 状态轮询。
+
+Agent 在平台进程内跑(后台线程),连本机 MCP Server(8033)执行工具。
+单实例:同一时间只允许一个 Agent 运行,避免多路操作设备冲突。
+"""
+import asyncio
+import base64
+import io
+import os
+import sys
+import threading
+import uuid
+
+from flask import Blueprint, jsonify, request
+
+from core.logger import get_logger
+from core.models import db
+from web.auth import admin_required
+
+_log = get_logger("web.agent")
+bp = Blueprint("agent", __name__)
+
+# ---------- 配置键(app_meta) ----------
+_CFG_KEYS = {"api_base": "agent_api_base",
+ "model": "agent_model",
+ "api_key": "agent_api_key",
+ "default_serial": "agent_default_serial"}
+
+# ---------- 运行状态(单实例) ----------
+_run = {"id": None, "state": "idle", "prompt": "", "serial": "",
+ "steps": [], "answer": "", "error": ""}
+_lock = threading.Lock()
+
+
+def _meta_get(key):
+ return db.session.execute(
+ db.text("SELECT value FROM app_meta WHERE key=:k"), {"k": key}).scalar() or ""
+
+
+def _meta_put(key, value):
+ db.session.execute(
+ db.text("INSERT OR REPLACE INTO app_meta(key,value) VALUES(:k,:v)"),
+ {"k": key, "v": str(value)})
+
+
+def _read_cfg():
+ return {k: _meta_get(v) for k, v in _CFG_KEYS.items()}
+
+
+@bp.route("/api/agent/config", methods=["GET"])
+@admin_required
+def agent_config_get():
+ """读 Agent 配置(key 打码返回)。"""
+ cfg = _read_cfg()
+ if cfg["api_key"]:
+ k = cfg["api_key"]
+ cfg["api_key_masked"] = k[:6] + "***" + k[-4:]
+ return jsonify({"ok": True, **cfg})
+
+
+@bp.route("/api/agent/config", methods=["POST"])
+@admin_required
+def agent_config_save():
+ """保存 Agent 配置:{api_base?, model?, api_key?, default_serial?} 部分更新。"""
+ data = request.json or {}
+ for key, meta_key in _CFG_KEYS.items():
+ if key in data and data[key] is not None:
+ _meta_put(meta_key, str(data[key]).strip())
+ db.session.commit()
+ return jsonify({"ok": True, "msg": "已保存"})
+
+
+@bp.route("/api/agent/run", methods=["POST"])
+@admin_required
+def agent_run():
+ """启动 Agent 执行指令:{prompt, serial?}。运行中返回 409。"""
+ data = request.json or {}
+ prompt = (data.get("prompt") or "").strip()
+ if not prompt:
+ return jsonify({"ok": False, "error": "请输入指令"}), 400
+ cfg = _read_cfg()
+ if not cfg.get("api_key"):
+ return jsonify({"ok": False, "error": "请先在配置区填写 API Key"}), 400
+ with _lock:
+ if _run["state"] == "running":
+ return jsonify({"ok": False, "error": "已有 Agent 运行中,请等待完成"}), 409
+ run_id = uuid.uuid4().hex[:8]
+ _run.update(id=run_id, state="running", prompt=prompt,
+ serial=(data.get("serial") or "").strip(),
+ steps=[], answer="", error="")
+ _log.info(f"Agent 启动: {prompt[:60]} serial={_run['serial']}")
+ threading.Thread(target=_agent_thread, args=(run_id, prompt, cfg),
+ daemon=True).start()
+ return jsonify({"ok": True, "run_id": run_id})
+
+
+@bp.route("/api/agent/status")
+@admin_required
+def agent_status():
+ """Agent 运行状态(前端轮询):{state, prompt, serial, steps, answer, error}。"""
+ with _lock:
+ return jsonify({"ok": True, **_run})
+
+
+def _shrink_image(b64, width=220, quality=50):
+ """截图降采样(web 展示用,避免大图撑爆轮询响应)。失败原样返回。"""
+ try:
+ from PIL import Image
+ img = Image.open(io.BytesIO(base64.b64decode(b64)))
+ if img.width > width:
+ img = img.resize((width, int(img.height * width / img.width)))
+ buf = io.BytesIO()
+ img.convert("RGB").save(buf, "JPEG", quality=quality)
+ return base64.b64encode(buf.getvalue()).decode()
+ except Exception:
+ return b64
+
+
+def _agent_thread(run_id, prompt, cfg):
+ """后台线程:跑 Agent,回调记录步骤。"""
+ try:
+ # 平台进程 cwd=/app(含 mcp_agent 包),进程内 import
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+ from mcp_agent.agent import Agent
+
+ def on_step(step):
+ with _lock:
+ if _run["id"] != run_id:
+ return
+ rec = {"tool": step.get("tool"), "args": str(step.get("args"))[:120]}
+ if step.get("image_b64"):
+ rec["image"] = _shrink_image(step["image_b64"])
+ # 保留最近 6 步截图,防止响应过大
+ _run["steps"] = _run["steps"][-5:]
+ _run["steps"].append(rec)
+
+ agent = Agent()
+ # 用 web 配置覆盖 Agent 默认(api key 等由前端配置)
+ agent.s.api_base = cfg.get("api_base") or agent.s.api_base
+ agent.s.model = cfg.get("model") or agent.s.model
+ agent.s.api_key = cfg.get("api_key") or agent.s.api_key
+ agent.s.default_serial = cfg.get("default_serial") or agent.s.default_serial
+ agent.on_step = on_step
+
+ async def _run():
+ await agent._load_tools()
+ return await agent.run(prompt, cfg.get("default_serial") or "")
+
+ answer = asyncio.run(_run())
+ with _lock:
+ _run["state"] = "done"
+ _run["answer"] = answer
+ except Exception as e:
+ _log.warning(f"Agent 运行异常: {e}")
+ with _lock:
+ _run["state"] = "error"
+ _run["error"] = f"{type(e).__name__}: {str(e)[:200]}"