Files
auto_control/web/agent_api.py
T

243 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""AI 控制台 API:模型/Key 前端配置 + Agent 流式执行(SSE 事件流)。
流程:
POST /api/agent/run {prompt} → 启动 Agent 线程,返回 run_id
GET /api/agent/stream?run_id= → SSE 事件流(EventSource 订阅):
event: delta {text, kind: content|reasoning} 流式文本增量
event: step {tool, args, image?} 工具调用完成(MCP 步骤)
event: done {answer} 完成
event: error {message} 失败
GET/POST /api/agent/config → 配置读写(key 打码回显)
单实例:同时只允许一个 Agent 运行。
"""
import asyncio
import base64
import io
import json
import os
import queue
import sys
import threading
import uuid
from flask import Blueprint, Response, 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": "",
"answer": "", "error": "",
"history": []} # 多轮对话历史 [{role: user|assistant, content}]
_queues = {} # run_id -> queue.Queue(SSE 消费者读取)
_stop_events = {} # run_id -> threading.Event(用户中断)
_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
serial = (data.get("serial") or "").strip()
cfg = _read_cfg()
if not cfg.get("api_key"):
return jsonify({"ok": False, "error": "请先在配置区填写 API Key"}), 400
if not cfg.get("model"):
return jsonify({"ok": False, "error": "请先填写模型名"}), 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(),
answer="", error="")
# history 保留(同会话多轮对话),由前端「清空对话」调用 clear 重置
_queues[run_id] = queue.Queue()
_stop_events[run_id] = threading.Event()
_log.info(f"Agent 启动: {prompt[:60]}")
threading.Thread(target=_agent_thread,
args=(run_id, prompt, serial, cfg),
daemon=True).start()
return jsonify({"ok": True, "run_id": run_id})
@bp.route("/api/agent/stream")
@admin_required
def agent_stream():
"""SSE 事件流(EventSource):delta/step/done/error。"""
run_id = request.args.get("run_id", "")
with _lock:
if run_id != _run["id"]:
return jsonify({"ok": False, "error": "run_id 不存在"}), 404
q = _queues.get(run_id)
def gen():
while True:
try:
evt = q.get(timeout=15)
except queue.Empty:
yield ": keepalive\n\n" # 心跳防超时
continue
if evt is None:
break
kind, payload = evt
yield f"event: {kind}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
if kind in ("done", "error"):
break
return Response(gen(), mimetype="text/event-stream",
headers={"Cache-Control": "no-cache",
"X-Accel-Buffering": "no"})
@bp.route("/api/agent/stop", methods=["POST"])
@admin_required
def agent_stop():
"""中断当前运行的 Agent(下一个检查点生效,通常在数秒内)。"""
with _lock:
if _run["state"] != "running":
return jsonify({"ok": False, "error": "当前没有运行中的任务"}), 400
evt = _stop_events.get(_run["id"])
if evt:
evt.set()
_log.info("用户请求中断 Agent")
return jsonify({"ok": True, "msg": "已请求停止"})
@bp.route("/api/agent/clear", methods=["POST"])
@admin_required
def agent_clear():
"""清空对话历史。"""
with _lock:
_run["history"] = []
return jsonify({"ok": True, "msg": "已清空"})
def _shrink_image(b64, width=220, quality=50):
"""截图降采样(SSE step 事件用,控制传输体积)。失败原样返回。"""
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, serial, cfg):
"""后台线程:Agent 流式执行,事件推入队列供 SSE 消费。"""
q = _queues.get(run_id)
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_delta(text, kind):
q.put(("delta", {"text": text, "kind": kind}))
def on_tool(step):
rec = {"tool": step.get("tool"),
"args": str(step.get("args"))[:200]}
if step.get("image"):
rec["image"] = _shrink_image(step["image"])
q.put(("step", rec))
agent = Agent()
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
with _lock:
history = list(_run.get("history") or [])
target = serial or cfg.get("default_serial") or ""
stop_evt = _stop_events.get(run_id)
async def _execute():
await agent._load_tools()
return await agent.run_stream(prompt, target,
history=history,
on_delta=on_delta, on_tool=on_tool,
should_stop=lambda: bool(
stop_evt and stop_evt.is_set()))
# 整体超时保护:卡死时结束,释放单实例
answer = asyncio.run(asyncio.wait_for(_execute(), timeout=900))
with _lock:
_run["state"] = "done"
_run["answer"] = answer
# 追加本轮进历史(多轮连续性;上限 12 轮防 token 膨胀)
hist = _run.setdefault("history", [])
hist.append({"role": "user", "content": prompt[:2000]})
hist.append({"role": "assistant", "content": (answer or "")[:4000]})
_run["history"] = hist[-24:]
q.put(("done", {"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]}"
q.put(("error", {"message": str(e)[:200]}))
finally:
q.put(None) # 关闭 SSE
_queues.pop(run_id, None)
_stop_events.pop(run_id, None)