""" 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)