v0.1.0: CRM/ERP 系统内测版本 - 安全加固完成
- Docker bridge 网络隔离(8000 端口封死) - Gunicorn 4 Worker 多进程 - Alembic 数据库迁移基线 - 日志轮转 20m×3 - JWT 密钥 + DB 密码 + CORS 收紧 - 3-2-1 备份链路(NAS + R740-B 冷备) - 连接池 pool_pre_ping + pool_recycle=3600
This commit is contained in:
@@ -0,0 +1,434 @@
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
# ── <think> 标签流式过滤器 ────────────────────────────────
|
||||
class _ThinkFilter:
|
||||
"""
|
||||
状态机:跨多个 SSE chunk 过滤 <think>...</think>。
|
||||
流式场景下标签可能被拆分到不同 chunk 中到达。
|
||||
"""
|
||||
def __init__(self):
|
||||
self._inside = False # 是否正处于 <think> 块内
|
||||
self._buf = "" # 缓冲可能是不完整标签的尾部
|
||||
|
||||
def feed(self, text: str) -> str:
|
||||
"""输入一段文本,返回过滤后应输出的部分"""
|
||||
out = []
|
||||
self._buf += text
|
||||
while self._buf:
|
||||
if self._inside:
|
||||
# 在 think 块内,找 </think> 结束标签
|
||||
end_pos = self._buf.lower().find("</think>")
|
||||
if end_pos >= 0:
|
||||
# 找到了,跳过 </think> 本身(8 字符)
|
||||
self._buf = self._buf[end_pos + 8:]
|
||||
self._inside = False
|
||||
elif len(self._buf) > 8 and "</think>" not in self._buf.lower():
|
||||
# 确认不含部分标签,整块吞掉
|
||||
# 保留最后 8 字符以防 </think> 跨 chunk
|
||||
self._buf = self._buf[-8:]
|
||||
break
|
||||
else:
|
||||
# 缓冲区太短或可能含部分标签,等下一个 chunk
|
||||
break
|
||||
else:
|
||||
# 不在 think 块内,找 <think> 开始标签
|
||||
start_pos = self._buf.lower().find("<think>")
|
||||
if start_pos >= 0:
|
||||
# <think> 之前的内容输出
|
||||
out.append(self._buf[:start_pos])
|
||||
# 跳过 <think> 本身(7 字符),进入 inside 状态
|
||||
self._buf = self._buf[start_pos + 7:]
|
||||
self._inside = True
|
||||
elif len(self._buf) > 7:
|
||||
# 安全输出,保留最后 7 字符防跨 chunk 的 <think>
|
||||
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 过滤 <think> 标签
|
||||
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)
|
||||
Reference in New Issue
Block a user