952 lines
13 KiB
Python
952 lines
13 KiB
Python
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
|
||
# Source of truth: scripts/phase1_extract.py. Do not hand-merge back.
|
||
|
||
from app.core.credential import decrypt_secret
|
||
from app.core.observability import get_trace_id
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||
from sqlalchemy.orm import Session
|
||
from sqlalchemy import and_, desc, asc, func
|
||
from pydantic import BaseModel, Field
|
||
from typing import Optional
|
||
import uuid
|
||
from datetime import datetime, timezone, timedelta
|
||
import re
|
||
import json
|
||
import asyncio
|
||
import math
|
||
import httpx
|
||
import os
|
||
import logging
|
||
from ..database import get_db, SessionLocal
|
||
from ..models import (
|
||
|
||
|
||
|
||
User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog,
|
||
|
||
|
||
|
||
KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill,
|
||
|
||
|
||
|
||
EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation,
|
||
|
||
|
||
|
||
MCPServer,
|
||
|
||
|
||
|
||
gen_uuid
|
||
|
||
|
||
|
||
)
|
||
from ..auth import get_current_user
|
||
from ..platform_knowledge import get_platform_knowledge
|
||
from ..mcp_client import list_mcp_tools, call_mcp_tool
|
||
from ..middleware.log_service import LogService
|
||
from ..capability_registry import resolve_auto_model
|
||
from app.api.chat_utils import (_extract_text_from_json, _proxy_for_url)
|
||
|
||
def _normalize_dify_chat_url(endpoint: str) -> str:
|
||
|
||
|
||
|
||
"""把用户填写的 Dify 地址归一化为 /v1/chat-messages 端点。与 market.py 保持一致。"""
|
||
|
||
|
||
|
||
ep = (endpoint or "").rstrip("/")
|
||
|
||
|
||
|
||
if ep.endswith("/chat-messages"):
|
||
|
||
|
||
|
||
return ep
|
||
|
||
|
||
|
||
if ep.endswith("/v1"):
|
||
|
||
|
||
|
||
return ep + "/chat-messages"
|
||
|
||
|
||
|
||
return ep + "/v1/chat-messages"
|
||
|
||
async def _call_dify_agent(endpoint: str, api_key: str, query: str,
|
||
|
||
|
||
|
||
user_id: str, ext_conv_id: str | None) -> tuple:
|
||
|
||
|
||
|
||
"""调用 Dify(新版 Agent) /v1/chat-messages,流式(SSE)解析最终回答。
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
新版 Agent API 仅支持 response_mode=streaming(blocking 会 400)。事件流中
|
||
|
||
|
||
|
||
message / agent_message 事件携带增量 answer,累加即为最终回答;每个事件都带
|
||
|
||
|
||
|
||
conversation_id,用于维持多轮上下文。返回 (answer, new_conversation_id)。
|
||
|
||
|
||
|
||
"""
|
||
|
||
|
||
|
||
url = _normalize_dify_chat_url(endpoint)
|
||
|
||
|
||
|
||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||
|
||
|
||
|
||
payload = {
|
||
|
||
|
||
|
||
"inputs": {},
|
||
|
||
|
||
|
||
"query": query,
|
||
|
||
|
||
|
||
"user": (user_id or "web-user")[:64],
|
||
|
||
|
||
|
||
"response_mode": "streaming",
|
||
|
||
|
||
|
||
"conversation_id": ext_conv_id or "",
|
||
|
||
|
||
|
||
}
|
||
|
||
|
||
|
||
answer_parts = []
|
||
|
||
|
||
|
||
new_conv_id = ext_conv_id
|
||
|
||
|
||
|
||
err_msg = None
|
||
|
||
|
||
|
||
wf_outputs = None
|
||
|
||
|
||
|
||
# httpx 默认 follow_redirects=False;stream POST 若遇 301/302 会直接返回该状态码。
|
||
|
||
|
||
|
||
# 且 httpx 自动跟随会把 POST 降级为 GET(对 /v1/chat-messages 变 405)。
|
||
|
||
|
||
|
||
# 因此手动跟随 301/302/307/308,保持 POST 与请求体不变(最多 4 跳)。
|
||
|
||
|
||
|
||
async with httpx.AsyncClient(timeout=180, proxy=_proxy_for_url(url), trust_env=False,
|
||
|
||
|
||
|
||
follow_redirects=False) as client:
|
||
|
||
|
||
|
||
cur_url = url
|
||
|
||
|
||
|
||
for _redir in range(4):
|
||
|
||
|
||
|
||
async with client.stream("POST", cur_url, headers=headers, json=payload) as resp:
|
||
|
||
|
||
|
||
if resp.status_code in (301, 302, 307, 308):
|
||
|
||
|
||
|
||
loc = resp.headers.get("location") or resp.headers.get("Location")
|
||
|
||
|
||
|
||
await resp.aclose()
|
||
|
||
|
||
|
||
if not loc:
|
||
|
||
|
||
|
||
raise RuntimeError(f"Dify HTTP {resp.status_code}: 缺少重定向地址")
|
||
|
||
|
||
|
||
# 处理相对地址
|
||
|
||
|
||
|
||
if loc.startswith("/"):
|
||
|
||
|
||
|
||
from urllib.parse import urlsplit, urlunsplit
|
||
|
||
|
||
|
||
p = urlsplit(cur_url)
|
||
|
||
|
||
|
||
loc = urlunsplit((p.scheme, p.netloc, loc, "", ""))
|
||
|
||
|
||
|
||
cur_url = loc
|
||
|
||
|
||
|
||
continue
|
||
|
||
|
||
|
||
if resp.status_code != 200:
|
||
|
||
|
||
|
||
body = (await resp.aread()).decode("utf-8", "replace")
|
||
|
||
|
||
|
||
raise RuntimeError(f"Dify HTTP {resp.status_code}: {body[:300]}")
|
||
|
||
|
||
|
||
async for line in resp.aiter_lines():
|
||
|
||
|
||
|
||
if not line:
|
||
|
||
|
||
|
||
continue
|
||
|
||
|
||
|
||
line = line.strip()
|
||
|
||
|
||
|
||
if not line.startswith("data:"):
|
||
|
||
|
||
|
||
continue
|
||
|
||
|
||
|
||
data_str = line[5:].strip()
|
||
|
||
|
||
|
||
if not data_str or data_str == "[DONE]":
|
||
|
||
|
||
|
||
continue
|
||
|
||
|
||
|
||
try:
|
||
|
||
|
||
|
||
evt = json.loads(data_str)
|
||
|
||
|
||
|
||
except Exception:
|
||
|
||
|
||
|
||
continue
|
||
|
||
|
||
|
||
ev = evt.get("event")
|
||
|
||
|
||
|
||
if evt.get("conversation_id"):
|
||
|
||
|
||
|
||
new_conv_id = evt["conversation_id"]
|
||
|
||
|
||
|
||
data = evt.get("data") or {}
|
||
|
||
|
||
|
||
# 兼容三类 Dify 应用:
|
||
|
||
|
||
|
||
# 1) Chat/Agent 应用: message / agent_message 事件带增量 answer
|
||
|
||
|
||
|
||
# 2) Chatflow/Workflow 应用: text_chunk 事件带 data.text;
|
||
|
||
|
||
|
||
# 结束时 workflow_finished 带 status / outputs / error
|
||
|
||
|
||
|
||
if ev in ("message", "agent_message"):
|
||
|
||
|
||
|
||
answer_parts.append(evt.get("answer", "") or "")
|
||
|
||
|
||
|
||
elif ev == "message_replace":
|
||
|
||
|
||
|
||
# 内容审查替换:以替换后的完整 answer 为准
|
||
|
||
|
||
|
||
answer_parts = [evt.get("answer", "") or ""]
|
||
|
||
|
||
|
||
elif ev == "text_chunk":
|
||
|
||
|
||
|
||
answer_parts.append((data.get("text") or ""))
|
||
|
||
|
||
|
||
elif ev == "node_finished":
|
||
|
||
|
||
|
||
# answer / end 节点的 outputs 可能含最终文本
|
||
|
||
|
||
|
||
if data.get("node_type") in ("answer", "end") and data.get("outputs"):
|
||
|
||
|
||
|
||
wf_outputs = data.get("outputs")
|
||
|
||
|
||
|
||
if data.get("status") == "failed" and data.get("error"):
|
||
|
||
|
||
|
||
err_msg = str(data.get("error"))
|
||
|
||
|
||
|
||
elif ev == "workflow_finished":
|
||
|
||
|
||
|
||
if data.get("status") == "failed":
|
||
|
||
|
||
|
||
err_msg = str(data.get("error") or "Dify 工作流执行失败")
|
||
|
||
|
||
|
||
elif data.get("outputs"):
|
||
|
||
|
||
|
||
wf_outputs = data.get("outputs")
|
||
|
||
|
||
|
||
elif ev == "error":
|
||
|
||
|
||
|
||
err_msg = evt.get("message") or evt.get("code") or "Dify 流式返回错误"
|
||
|
||
|
||
|
||
break
|
||
|
||
|
||
|
||
else:
|
||
|
||
|
||
|
||
raise RuntimeError("Dify 重定向次数过多(>4),请直接填写重定向后的地址")
|
||
|
||
|
||
|
||
answer = "".join(answer_parts).strip()
|
||
|
||
|
||
|
||
# 流式无增量文本时,尝试从 workflow 结束 outputs 兜底提取
|
||
|
||
|
||
|
||
if not answer and wf_outputs:
|
||
|
||
|
||
|
||
answer = _extract_text_from_json(wf_outputs).strip()
|
||
|
||
|
||
|
||
if not answer and err_msg:
|
||
|
||
|
||
|
||
raise RuntimeError(err_msg)
|
||
|
||
|
||
|
||
return answer, new_conv_id
|
||
|
||
async def _call_n8n_agent(endpoint: str, api_key: str, query: str,
|
||
|
||
|
||
|
||
user_id: str, history: list) -> tuple:
|
||
|
||
|
||
|
||
"""调用 n8n 生产 Webhook (POST)。webhook 用 Respond to Webhook 节点返回 JSON,
|
||
|
||
|
||
|
||
尽力从中抽取文本回答。n8n webhook 无平台级会话态,多轮靠我们下发 history。
|
||
|
||
|
||
|
||
返回 (answer, None)。"""
|
||
|
||
|
||
|
||
url = (endpoint or "").strip()
|
||
|
||
|
||
|
||
headers = {"Content-Type": "application/json"}
|
||
|
||
|
||
|
||
if api_key:
|
||
|
||
|
||
|
||
# n8n Header Auth 自定义头名不固定,同时下发常见几种以提高兼容性
|
||
|
||
|
||
|
||
headers["Authorization"] = f"Bearer {api_key}"
|
||
|
||
|
||
|
||
headers["X-N8N-API-KEY"] = api_key
|
||
|
||
|
||
|
||
# 携带多种字段别名,兼容 n8n AI Agent(chatInput) 与自定义工作流
|
||
|
||
|
||
|
||
payload = {
|
||
|
||
|
||
|
||
"query": query,
|
||
|
||
|
||
|
||
"chatInput": query,
|
||
|
||
|
||
|
||
"message": query,
|
||
|
||
|
||
|
||
"input": query,
|
||
|
||
|
||
|
||
"user": (user_id or "web-user")[:64],
|
||
|
||
|
||
|
||
"history": history or [],
|
||
|
||
|
||
|
||
}
|
||
|
||
|
||
|
||
async with httpx.AsyncClient(timeout=180, proxy=_proxy_for_url(url), trust_env=False) as client:
|
||
|
||
|
||
|
||
resp = await client.post(url, headers=headers, json=payload)
|
||
|
||
|
||
|
||
if resp.status_code not in (200, 201):
|
||
|
||
|
||
|
||
raise RuntimeError(f"n8n Webhook HTTP {resp.status_code}: {resp.text[:300]}")
|
||
|
||
|
||
|
||
ctype = resp.headers.get("content-type", "")
|
||
|
||
|
||
|
||
if "application/json" in ctype:
|
||
|
||
|
||
|
||
try:
|
||
|
||
|
||
|
||
answer = _extract_text_from_json(resp.json())
|
||
|
||
|
||
|
||
except Exception:
|
||
|
||
|
||
|
||
answer = resp.text
|
||
|
||
|
||
|
||
else:
|
||
|
||
|
||
|
||
answer = resp.text
|
||
|
||
|
||
|
||
return (answer or "").strip(), None
|
||
|
||
async def _call_coze_agent(endpoint: str, api_key: str, query: str,
|
||
|
||
|
||
|
||
user_id: str, ext_conv_id: str | None) -> tuple:
|
||
|
||
|
||
|
||
"""调用扣子(Coze) v3 对话 API:create → 轮询 retrieve → 拉取 message list。
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
约定 agent_endpoint 存放 bot_id(纯 id),或形如
|
||
|
||
|
||
|
||
https://api.coze.cn/v3/chat?bot_id=xxx 的完整地址。默认基址 api.coze.cn。
|
||
|
||
|
||
|
||
返回 (answer, conversation_id)。
|
||
|
||
|
||
|
||
"""
|
||
|
||
|
||
|
||
ep = (endpoint or "").strip()
|
||
|
||
|
||
|
||
from urllib.parse import urlparse, parse_qs
|
||
|
||
|
||
|
||
bot_id = None
|
||
|
||
|
||
|
||
base = "https://api.coze.cn"
|
||
|
||
|
||
|
||
if ep.startswith("http"):
|
||
|
||
|
||
|
||
pr = urlparse(ep)
|
||
|
||
|
||
|
||
base = f"{pr.scheme}://{pr.netloc}"
|
||
|
||
|
||
|
||
qs = parse_qs(pr.query)
|
||
|
||
|
||
|
||
bot_id = (qs.get("bot_id") or [None])[0]
|
||
|
||
|
||
|
||
if not bot_id and "/bot/" in pr.path:
|
||
|
||
|
||
|
||
bot_id = pr.path.rstrip("/").split("/bot/")[-1]
|
||
|
||
|
||
|
||
else:
|
||
|
||
|
||
|
||
bot_id = ep # 纯 bot_id
|
||
|
||
|
||
|
||
if not bot_id:
|
||
|
||
|
||
|
||
raise RuntimeError("Coze 接入缺少 bot_id(请在 Endpoint 填入 bot_id 或带 ?bot_id= 的地址)")
|
||
|
||
|
||
|
||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||
|
||
|
||
|
||
create_url = f"{base}/v3/chat"
|
||
|
||
|
||
|
||
if ext_conv_id:
|
||
|
||
|
||
|
||
create_url += f"?conversation_id={ext_conv_id}"
|
||
|
||
|
||
|
||
payload = {
|
||
|
||
|
||
|
||
"bot_id": bot_id,
|
||
|
||
|
||
|
||
"user_id": (user_id or "web-user")[:64],
|
||
|
||
|
||
|
||
"stream": False,
|
||
|
||
|
||
|
||
"auto_save_history": True,
|
||
|
||
|
||
|
||
"additional_messages": [
|
||
|
||
|
||
|
||
{"role": "user", "content": query, "content_type": "text"}
|
||
|
||
|
||
|
||
],
|
||
|
||
|
||
|
||
}
|
||
|
||
|
||
|
||
async with httpx.AsyncClient(timeout=180, proxy=_proxy_for_url(create_url), trust_env=False) as client:
|
||
|
||
|
||
|
||
r = await client.post(create_url, headers=headers, json=payload)
|
||
|
||
|
||
|
||
if r.status_code != 200:
|
||
|
||
|
||
|
||
raise RuntimeError(f"Coze create HTTP {r.status_code}: {r.text[:300]}")
|
||
|
||
|
||
|
||
cd = r.json()
|
||
|
||
|
||
|
||
if cd.get("code") not in (0, None):
|
||
|
||
|
||
|
||
raise RuntimeError(f"Coze 错误: {cd.get('msg') or cd}")
|
||
|
||
|
||
|
||
chat = cd.get("data") or {}
|
||
|
||
|
||
|
||
chat_id = chat.get("id")
|
||
|
||
|
||
|
||
conv_id2 = chat.get("conversation_id") or ext_conv_id
|
||
|
||
|
||
|
||
if not chat_id or not conv_id2:
|
||
|
||
|
||
|
||
raise RuntimeError(f"Coze 未返回 chat_id/conversation_id: {str(cd)[:200]}")
|
||
|
||
|
||
|
||
# 轮询直到完成(最多约 3 分钟)
|
||
|
||
|
||
|
||
status = chat.get("status")
|
||
|
||
|
||
|
||
for _ in range(60):
|
||
|
||
|
||
|
||
if status == "completed":
|
||
|
||
|
||
|
||
break
|
||
|
||
|
||
|
||
if status in ("failed", "requires_action", "canceled"):
|
||
|
||
|
||
|
||
raise RuntimeError(f"Coze 对话状态 {status}")
|
||
|
||
|
||
|
||
await asyncio.sleep(3)
|
||
|
||
|
||
|
||
rr = await client.get(
|
||
|
||
|
||
|
||
f"{base}/v3/chat/retrieve?chat_id={chat_id}&conversation_id={conv_id2}",
|
||
|
||
|
||
|
||
headers=headers,
|
||
|
||
|
||
|
||
)
|
||
|
||
|
||
|
||
if rr.status_code == 200:
|
||
|
||
|
||
|
||
status = ((rr.json() or {}).get("data") or {}).get("status")
|
||
|
||
|
||
|
||
# 拉取消息列表,取 type=answer 的内容
|
||
|
||
|
||
|
||
rm = await client.get(
|
||
|
||
|
||
|
||
f"{base}/v3/chat/message/list?chat_id={chat_id}&conversation_id={conv_id2}",
|
||
|
||
|
||
|
||
headers=headers,
|
||
|
||
|
||
|
||
)
|
||
|
||
|
||
|
||
answer = ""
|
||
|
||
|
||
|
||
if rm.status_code == 200:
|
||
|
||
|
||
|
||
for m in ((rm.json() or {}).get("data") or []):
|
||
|
||
|
||
|
||
if m.get("type") == "answer" and m.get("role") == "assistant":
|
||
|
||
|
||
|
||
answer = m.get("content", "") or answer
|
||
|
||
|
||
|
||
return (answer or "").strip(), conv_id2
|
||
|
||
async def _call_external_agent(db, agent: dict, query: str, user_id: str,
|
||
|
||
|
||
|
||
ext_conversation_id: str | None,
|
||
|
||
|
||
|
||
history: list) -> tuple:
|
||
|
||
|
||
|
||
"""按平台分发到对应外部 Agent 远程调用。返回 (answer, new_ext_conversation_id)。"""
|
||
|
||
|
||
|
||
platform = (agent.get("platform") or "dify").lower()
|
||
|
||
|
||
|
||
endpoint = agent.get("agent_endpoint")
|
||
|
||
|
||
|
||
_row = db.query(MarketApp).filter(MarketApp.id == agent.get("id")).first()
|
||
_raw = _row.agent_api_key if _row else None
|
||
try:
|
||
api_key = decrypt_secret(_raw) or ""
|
||
except Exception as _e:
|
||
api_key = ""
|
||
logging.error("[credential] 解密 Agent 凭据失败 id=%s trace=%s: %s" % (agent.get("id"), get_trace_id(), _e))
|
||
|
||
|
||
|
||
if not endpoint:
|
||
|
||
|
||
|
||
raise RuntimeError("该 Agent 未配置远程调用地址(agent_endpoint)")
|
||
|
||
|
||
|
||
if platform == "dify":
|
||
|
||
|
||
|
||
return await _call_dify_agent(endpoint, api_key, query, user_id, ext_conversation_id)
|
||
|
||
|
||
|
||
if platform == "n8n":
|
||
|
||
|
||
|
||
return await _call_n8n_agent(endpoint, api_key, query, user_id, history)
|
||
|
||
|
||
|
||
if platform == "coze":
|
||
|
||
|
||
|
||
return await _call_coze_agent(endpoint, api_key, query, user_id, ext_conversation_id)
|
||
|
||
|
||
|
||
# 未知平台:默认按 Dify 协议尝试
|
||
|
||
|
||
|
||
return await _call_dify_agent(endpoint, api_key, query, user_id, ext_conversation_id)
|