feat: AI 控制台升级为顶级 Tab——DeepSeek 风格聊天界面(气泡+流式渲染+思考折叠),实时 MCP 步骤卡片(工具/参数/截图缩略),SSE 流式输出(delta/step/done/error 事件),配置模态(模型/Key/设备前端可配);工具页旧子栏移除

This commit is contained in:
2026-09-04 13:09:34 +08:00
parent 25ef7e7746
commit e24458a085
6 changed files with 442 additions and 198 deletions
+70 -29
View File
@@ -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)