feat: AI 控制台升级为顶级 Tab——DeepSeek 风格聊天界面(气泡+流式渲染+思考折叠),实时 MCP 步骤卡片(工具/参数/截图缩略),SSE 流式输出(delta/step/done/error 事件),配置模态(模型/Key/设备前端可配);工具页旧子栏移除
This commit is contained in:
+70
-29
@@ -1,17 +1,27 @@
|
||||
"""AI 控制台 API:Agent 配置(模型/Key 前端可配)+ 运行 + 状态轮询。
|
||||
"""AI 控制台 API:模型/Key 前端配置 + Agent 流式执行(SSE 事件流)。
|
||||
|
||||
Agent 在平台进程内跑(后台线程),连本机 MCP Server(8033)执行工具。
|
||||
单实例:同一时间只允许一个 Agent 运行,避免多路操作设备冲突。
|
||||
流程:
|
||||
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, jsonify, request
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from core.logger import get_logger
|
||||
from core.models import db
|
||||
@@ -26,9 +36,10 @@ _CFG_KEYS = {"api_base": "agent_api_base",
|
||||
"api_key": "agent_api_key",
|
||||
"default_serial": "agent_default_serial"}
|
||||
|
||||
# ---------- 运行状态(单实例) ----------
|
||||
# ---------- 运行状态(单实例 + 事件队列) ----------
|
||||
_run = {"id": None, "state": "idle", "prompt": "", "serial": "",
|
||||
"steps": [], "answer": "", "error": ""}
|
||||
"answer": "", "error": ""}
|
||||
_queues = {} # run_id -> queue.Queue(SSE 消费者读取)
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
@@ -47,6 +58,7 @@ 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():
|
||||
@@ -70,10 +82,11 @@ def agent_config_save():
|
||||
return jsonify({"ok": True, "msg": "已保存"})
|
||||
|
||||
|
||||
# ================== 运行 ==================
|
||||
@bp.route("/api/agent/run", methods=["POST"])
|
||||
@admin_required
|
||||
def agent_run():
|
||||
"""启动 Agent 执行指令:{prompt, serial?}。运行中返回 409。"""
|
||||
"""启动 Agent:{prompt, serial?}。运行中返回 409。"""
|
||||
data = request.json or {}
|
||||
prompt = (data.get("prompt") or "").strip()
|
||||
if not prompt:
|
||||
@@ -81,29 +94,53 @@ def agent_run():
|
||||
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(),
|
||||
steps=[], answer="", error="")
|
||||
_log.info(f"Agent 启动: {prompt[:60]} serial={_run['serial']}")
|
||||
answer="", error="")
|
||||
_queues[run_id] = queue.Queue()
|
||||
_log.info(f"Agent 启动: {prompt[:60]}")
|
||||
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")
|
||||
@bp.route("/api/agent/stream")
|
||||
@admin_required
|
||||
def agent_status():
|
||||
"""Agent 运行状态(前端轮询):{state, prompt, serial, steps, answer, error}。"""
|
||||
def agent_stream():
|
||||
"""SSE 事件流(EventSource):delta/step/done/error。"""
|
||||
run_id = request.args.get("run_id", "")
|
||||
with _lock:
|
||||
return jsonify({"ok": True, **_run})
|
||||
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"})
|
||||
|
||||
|
||||
def _shrink_image(b64, width=220, quality=50):
|
||||
"""截图降采样(web 展示用,避免大图撑爆轮询响应)。失败原样返回。"""
|
||||
"""截图降采样(SSE step 事件用,控制传输体积)。失败原样返回。"""
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(io.BytesIO(base64.b64decode(b64)))
|
||||
@@ -117,42 +154,46 @@ def _shrink_image(b64, width=220, quality=50):
|
||||
|
||||
|
||||
def _agent_thread(run_id, prompt, cfg):
|
||||
"""后台线程:跑 Agent,回调记录步骤。"""
|
||||
"""后台线程: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_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)
|
||||
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()
|
||||
# 用 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 _execute():
|
||||
await agent._load_tools()
|
||||
return await agent.run(prompt, cfg.get("default_serial") or "")
|
||||
return await agent.run_stream(prompt, cfg.get("default_serial") or "",
|
||||
on_delta=on_delta, on_tool=on_tool)
|
||||
|
||||
# 整体超时保护:模型/工具卡死时结束运行,避免单实例被永久占用
|
||||
# 整体超时保护:卡死时结束,释放单实例
|
||||
answer = asyncio.run(asyncio.wait_for(_execute(), timeout=600))
|
||||
with _lock:
|
||||
_run["state"] = "done"
|
||||
_run["answer"] = answer
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user