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