"""
AI 智能助手路由 —— /api/chat
- POST /stream: 流式对话(SSE)
- GET /mcp/tools: 返回已注册的 MCP 工具清单
- POST /mcp/execute: 执行指定 MCP 工具
- POST /action-card/callback: Action Card 确认/取消回调
"""
from __future__ import annotations
import asyncio
import json
import re
import uuid
from typing import Any
from fastapi import APIRouter, Body, Depends
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.schemas.auth import CurrentUserPayload
from app.schemas.action_card import ActionCardCallback
from app.schemas.response import ok
# 导入 MCP 注册中心(导入 tools 模块会触发 @register_tool 装饰器注册)
from app.mcp.registry import get_tools_manifest, execute_tool, MCPToolResult
import app.mcp.tools # noqa: F401 — 触发工具注册
from app.core.action_card_queue import pop_cards
# 导入 service 层(用于 action card 回调真正执行)
from app.services import customer_service, order_service
from app.schemas.crm import CustomerCreate
from app.schemas.order import OrderCreate
router = APIRouter(prefix="/chat", tags=["AI 智能助手"])
class ChatRequest(BaseModel):
message: str
conversation_id: str = "" # Dify 会话 ID,空则新建会话
# ── Dify 可用性探测 ──────────────────────────────────────
async def _check_dify_available() -> bool:
"""检测 Dify API 是否可用(2s 超时),探测根路径即可"""
from app.core.config import settings
if not settings.DIFY_API_BASE_URL or not settings.DIFY_API_KEY:
return False
try:
import httpx
async with httpx.AsyncClient(timeout=2.0) as client:
resp = await client.get(settings.DIFY_API_BASE_URL)
return resp.status_code < 500 # 2xx/3xx/4xx 都说明服务存活
except Exception:
return False
# ── 标签流式过滤器 ────────────────────────────────
class _ThinkFilter:
"""
状态机:跨多个 SSE chunk 过滤 ...。
流式场景下标签可能被拆分到不同 chunk 中到达。
"""
def __init__(self):
self._inside = False # 是否正处于 块内
self._buf = "" # 缓冲可能是不完整标签的尾部
def feed(self, text: str) -> str:
"""输入一段文本,返回过滤后应输出的部分"""
out = []
self._buf += text
while self._buf:
if self._inside:
# 在 think 块内,找 结束标签
end_pos = self._buf.lower().find("")
if end_pos >= 0:
# 找到了,跳过 本身(8 字符)
self._buf = self._buf[end_pos + 8:]
self._inside = False
elif len(self._buf) > 8 and "" not in self._buf.lower():
# 确认不含部分标签,整块吞掉
# 保留最后 8 字符以防 跨 chunk
self._buf = self._buf[-8:]
break
else:
# 缓冲区太短或可能含部分标签,等下一个 chunk
break
else:
# 不在 think 块内,找 开始标签
start_pos = self._buf.lower().find("")
if start_pos >= 0:
# 之前的内容输出
out.append(self._buf[:start_pos])
# 跳过 本身(7 字符),进入 inside 状态
self._buf = self._buf[start_pos + 7:]
self._inside = True
elif len(self._buf) > 7:
# 安全输出,保留最后 7 字符防跨 chunk 的
out.append(self._buf[:-7])
self._buf = self._buf[-7:]
break
else:
break
return "".join(out)
# ── Dify 流式转发生成器 ──────────────────────────────────
async def _dify_stream_generator(message: str, user_id: str, conversation_id: str = ""):
"""将用户消息转发到 Dify chat-messages API,流式返回"""
from app.core.config import settings
import httpx
url = f"{settings.DIFY_API_BASE_URL}/v1/chat-messages"
headers = {
"Authorization": f"Bearer {settings.DIFY_API_KEY}",
"Content-Type": "application/json",
}
payload = {
"inputs": {},
"query": message,
"response_mode": "streaming",
"conversation_id": conversation_id,
"user": user_id,
}
try:
async with httpx.AsyncClient(timeout=settings.DIFY_TIMEOUT_MS / 1000) as client:
async with client.stream("POST", url, json=payload, headers=headers) as resp:
if resp.status_code != 200:
error_text = ""
async for chunk in resp.aiter_text():
error_text += chunk
yield f"data: {json.dumps({'type': 'text', 'content': f'Dify 返回错误 ({resp.status_code}): {error_text[:200]}'}, ensure_ascii=False)}\n\n"
return
# 用原始 text 做 SSE 解析,因为 aiter_lines 可能丢失 event 行
buf = ""
think_filter = _ThinkFilter() # 跨 chunk 过滤 标签
message_ended = False # 标记文本流是否结束
async for chunk in resp.aiter_text():
buf += chunk
# 按双换行分割完整 SSE 事件
while "\n\n" in buf:
event_block, buf = buf.split("\n\n", 1)
data_line = ""
for line in event_block.split("\n"):
if line.startswith("data: "):
data_line = line[6:]
if not data_line or data_line.strip() == "[DONE]":
continue
try:
event = json.loads(data_line)
except json.JSONDecodeError:
continue
event_type = event.get("event", "")
# ── Chatflow 模式:message 事件
if event_type == "message" and not message_ended:
answer = think_filter.feed(event.get("answer", ""))
if answer:
yield f"data: {json.dumps({'type': 'text', 'content': answer}, ensure_ascii=False)}\n\n"
# ── Agent 模式:agent_message 事件(最终文本回复)
elif event_type == "agent_message" and not message_ended:
answer = think_filter.feed(event.get("answer", ""))
if answer:
yield f"data: {json.dumps({'type': 'text', 'content': answer}, ensure_ascii=False)}\n\n"
# ── Agent 模式:agent_thought — 提取工具调用的 action_card
elif event_type == "agent_thought":
observation = event.get("observation", "")
if observation:
try:
obs_data = json.loads(observation)
for tool_key, tool_val in (obs_data.items() if isinstance(obs_data, dict) else []):
inner = tool_val
if isinstance(tool_val, str):
try:
inner = json.loads(tool_val)
except json.JSONDecodeError:
continue
if not isinstance(inner, dict):
continue
result_data = inner.get("data", inner)
if isinstance(result_data, dict):
resp_type = result_data.get("response_type", "")
result_inner = result_data.get("result", {})
if resp_type == "action_card" and isinstance(result_inner, dict):
yield f"data: {json.dumps({'type': 'action_card', 'content': result_inner.get('summary', '请确认操作'), 'card': result_inner}, ensure_ascii=False)}\n\n"
print(f"[Dify SSE] ✅ 注入 action_card: {result_inner.get('card_type', 'unknown')}")
except (json.JSONDecodeError, TypeError) as e:
print(f"[Dify SSE] observation 解析失败: {e}")
# ── message_end / 结束 — 不再 break,设置标记继续处理剩余事件
elif event_type in ("message_end", "agent_message_end"):
conv_id = event.get("conversation_id", "")
if conv_id:
yield f"data: {json.dumps({'type': 'conversation_id', 'conversation_id': conv_id}, ensure_ascii=False)}\n\n"
# 检查工具端点是否推送了 action_card
cards = await pop_cards()
for card in cards:
yield f"data: {json.dumps({'type': 'action_card', 'content': card.get('summary', '请确认操作'), 'card': card}, ensure_ascii=False)}\n\n"
print(f"[Dify SSE] ✅ 注入 action_card: {card.get('card_type', 'unknown')}")
message_ended = True
# ── 错误
elif event_type == "error":
err_msg = event.get("message", "未知错误")
yield f"data: {json.dumps({'type': 'text', 'content': f'\\n⚠️ Dify 错误: {err_msg}'}, ensure_ascii=False)}\n\n"
return # 出错直接返回
# ── 流结束后,刷新 buf 中可能剩余的事件(重要:agent_thought 可能在 message_end 之后)
if buf.strip():
for remaining_block in buf.split("\n\n"):
remaining_block = remaining_block.strip()
if not remaining_block:
continue
data_line = ""
for line in remaining_block.split("\n"):
if line.startswith("data: "):
data_line = line[6:]
if not data_line or data_line.strip() == "[DONE]":
continue
try:
event = json.loads(data_line)
if event.get("event") == "agent_thought":
observation = event.get("observation", "")
if observation:
obs_data = json.loads(observation)
for tool_key, tool_val in (obs_data.items() if isinstance(obs_data, dict) else []):
inner = tool_val
if isinstance(tool_val, str):
try:
inner = json.loads(tool_val)
except json.JSONDecodeError:
continue
if not isinstance(inner, dict):
continue
result_data = inner.get("data", inner)
if isinstance(result_data, dict) and result_data.get("response_type") == "action_card":
result_inner = result_data.get("result", {})
if isinstance(result_inner, dict):
yield f"data: {json.dumps({'type': 'action_card', 'content': result_inner.get('summary', '请确认操作'), 'card': result_inner}, ensure_ascii=False)}\n\n"
print(f"[Dify SSE] ✅ 注入 action_card (post-flush): {result_inner.get('card_type', 'unknown')}")
except (json.JSONDecodeError, TypeError):
pass
except httpx.TimeoutException:
yield f"data: {json.dumps({'type': 'text', 'content': '\n⚠️ Dify 响应超时,请稍后重试'}, ensure_ascii=False)}\n\n"
except Exception as e:
print(f"[Dify SSE] 异常: {e!s}")
yield f"data: {json.dumps({'type': 'text', 'content': f'\n⚠️ Dify 连接失败: {e!s}'}, ensure_ascii=False)}\n\n"
# ── Mock 流式生成器(降级用) ────────────────────────────
async def _mock_stream_generator(message: str, user_id: str):
"""Dify 不可用时的降级 mock 回复"""
await asyncio.sleep(0.3)
notice = "⚠️ AI 引擎暂不可用,当前为本地模拟回复。\n\n"
for char in notice:
yield f"data: {json.dumps({'type': 'text', 'content': char}, ensure_ascii=False)}\n\n"
await asyncio.sleep(0.02)
reply = f"收到指令,正在解析:【{message}】...\n来自用户: {user_id}\n"
for char in reply:
yield f"data: {json.dumps({'type': 'text', 'content': char}, ensure_ascii=False)}\n\n"
await asyncio.sleep(0.04)
# ── 1. POST /api/chat/stream — 流式对话(含意图网关) ────
@router.post("/stream", summary="流式对话接口(SSE)")
async def chat_stream(
body: ChatRequest,
current_user: CurrentUserPayload = Depends(get_current_user),
):
user_id = str(current_user.user_id)
print(f"[AI Chat] user_id={user_id}, scope={current_user.data_scope}, msg='{body.message}'")
# Step 1: 意图分类(4060 Qwen3.5-4B,5s 超时)
from app.services.intent_service import classify_intent
intent_result = await classify_intent(body.message)
intent = intent_result.get("intent", "general")
route = intent_result.get("route", "dify_agent")
print(f"[AI Chat] 意图网关: intent={intent}, route={route}")
# Step 2: 根据意图路由到不同后端
dify_available = await _check_dify_available()
if route == "dify_agent" and dify_available:
generator = _dify_stream_generator(body.message, user_id, body.conversation_id)
elif route == "dify_workflow_report" and dify_available:
# 周报走 Workflow,非流式,包装成 SSE
generator = _report_workflow_generator(body.message, user_id)
elif dify_available:
generator = _dify_stream_generator(body.message, user_id, body.conversation_id)
else:
generator = _mock_stream_generator(body.message, user_id)
return StreamingResponse(generator, media_type="text/event-stream")
# ── 周报 Workflow 生成器 ───────────────────────────
async def _report_workflow_generator(message: str, user_id: str):
"""调用周报 Workflow 并把结果包装成 SSE 流"""
from app.core.config import settings
import httpx
yield f"data: {json.dumps({'type': 'text', 'content': '📊 正在调用 AI 生成周报,请稍候...\n\n'}, ensure_ascii=False)}\n\n"
if not settings.DIFY_WORKFLOW_REPORT_KEY:
yield f"data: {json.dumps({'type': 'text', 'content': '⚠️ 周报 Workflow 未配置,请联系管理员。'}, ensure_ascii=False)}\n\n"
return
url = f"{settings.DIFY_API_BASE_URL}/v1/workflows/run"
headers = {
"Authorization": f"Bearer {settings.DIFY_WORKFLOW_REPORT_KEY}",
"Content-Type": "application/json",
}
payload = {
"inputs": {"user_id": user_id, "request": message},
"response_mode": "blocking",
"user": user_id,
}
try:
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(url, json=payload, headers=headers)
if resp.status_code == 200:
data = resp.json()
output_text = data.get("data", {}).get("outputs", {}).get("text", "周报生成完成,但未获取到内容。")
yield f"data: {json.dumps({'type': 'text', 'content': output_text}, ensure_ascii=False)}\n\n"
else:
yield f"data: {json.dumps({'type': 'text', 'content': f'⚠️ 周报 Workflow 返回错误 ({resp.status_code})'}, ensure_ascii=False)}\n\n"
except Exception as e:
yield f"data: {json.dumps({'type': 'text', 'content': f'⚠️ 周报生成失败: {e}'}, ensure_ascii=False)}\n\n"
# ── 2. GET /api/chat/mcp/tools — 工具清单 ───────────────
@router.get("/mcp/tools", summary="获取已注册 MCP 工具列表(供 Dify 配置)")
async def list_mcp_tools(
_: CurrentUserPayload = Depends(get_current_user),
) -> dict:
return ok(data=get_tools_manifest())
# ── 3. POST /api/chat/mcp/execute — 执行工具 ────────────
class MCPExecuteRequest(BaseModel):
tool_name: str
params: dict[str, Any] = {}
@router.post("/mcp/execute", summary="执行指定 MCP 工具(Dify function_call 回调)")
async def execute_mcp_tool(
body: MCPExecuteRequest,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
print(f"[MCP Execute] user_id={current_user.user_id}, tool={body.tool_name}, params={body.params}")
result: MCPToolResult = await execute_tool(body.tool_name, db, current_user, body.params)
return ok(data={
"success": result.success,
"response_type": result.response_type,
"data": result.data,
"message": result.message,
})
# ── 4. POST /api/chat/action-card/callback — 卡片回调 ──
@router.post("/action-card/callback", summary="Action Card 确认/取消回调")
async def action_card_callback(
body: ActionCardCallback,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
print(f"[Action Card] user_id={current_user.user_id}, type={body.card_type}, action={body.action_key}")
if body.action_key == "cancel":
return ok(message="操作已取消")
# 根据 card_type 路由到具体 service
if body.card_type == "create_customer":
p = body.params
result = await customer_service.create_customer(
db, current_user,
CustomerCreate(
name=p.get("name", ""),
level=p.get("level", "C"),
contact=p.get("contact"),
phone=p.get("phone"),
),
)
return ok(data=result.model_dump(mode="json"), message="客户创建成功")
elif body.card_type == "create_order":
from datetime import date
p = body.params
items_raw = p.get("items", [])
from app.schemas.order import OrderItemCreate
items = [
OrderItemCreate(
sku_id=uuid.UUID(i["sku_id"]),
qty=i["qty"],
unit_price=i["unit_price"],
)
for i in items_raw
]
result = await order_service.create_order(
db, current_user,
OrderCreate(
customer_id=uuid.UUID(p["customer_id"]),
items=items,
remark=p.get("remark"),
order_date=date.today(),
),
)
return ok(data=result.model_dump(mode="json"), message=f"订单 {result.order_no} 创建成功")
return ok(message=f"未知的卡片类型: {body.card_type}")
# ── 5. GET /api/chat/history — 对话历史 ──────────────────
@router.get("/history", summary="获取当前用户的 AI 对话历史")
async def get_chat_history(
limit: int = 50,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
from app.services import chat_service
history = await chat_service.load_history(db, current_user.user_id, limit=limit)
return ok(data=history)