diff --git a/backend/python/app/api/chat.py b/backend/python/app/api/chat.py index 40dd798..b123e88 100644 --- a/backend/python/app/api/chat.py +++ b/backend/python/app/api/chat.py @@ -146,6 +146,9 @@ def _proxy_for_url(url: str) -> str | None: host = (urlparse(url).hostname or "").lower() if "agnes-ai.com" in host or "agnes-ai.space" in host: return None + # 平台自有域名(含自建 Dify/n8n 等)必须直连,不经出网代理。 + if "qihuyun.com" in host: + return None return _outbound_proxy() @@ -352,52 +355,73 @@ async def _call_dify_agent(endpoint: str, api_key: str, query: str, new_conv_id = ext_conv_id err_msg = None wf_outputs = None - async with httpx.AsyncClient(timeout=180, proxy=_proxy_for_url(url), trust_env=False) as client: - async with client.stream("POST", url, headers=headers, json=payload) as resp: - 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: + # 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 - 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 流式返回错误" + 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: diff --git a/backend/python/app/api/market.py b/backend/python/app/api/market.py index 6723e8a..ee71b4e 100644 --- a/backend/python/app/api/market.py +++ b/backend/python/app/api/market.py @@ -35,6 +35,29 @@ def _short_err(e: Exception) -> str: return str(e)[:160] +def _post_follow(url: str, headers: dict, json_body: dict, timeout: float = 15.0): + """POST 并手动跟随 301/302/307/308 重定向(**保持 POST 方法与请求体**)。 + + 背景:Dify 若填写 http:// 地址会 301 永久重定向到 https://;httpx 内建 + follow_redirects 对 301/302 会把 POST 降级为 GET(浏览器行为),导致对 + /v1/chat-messages 变成 GET → 405 method_not_allowed。故手动跟随并保持 POST。 + 容器内 env 有坏代理(502),必须 trust_env=False 直连。 + """ + cur = url + last = None + for _ in range(4): + last = httpx.post(cur, headers=headers, json=json_body, timeout=timeout, + trust_env=False, follow_redirects=False) + if last.status_code in (301, 302, 307, 308): + loc = last.headers.get("location") + if not loc: + return last + cur = loc if loc.startswith("http") else str(httpx.URL(cur).join(loc)) + continue + return last + return last + + def verify_agent_connectivity(platform: str, endpoint: str, api_key: str) -> dict: """ 接入 Agent 前的连通性 + 鉴权校验。 @@ -58,7 +81,7 @@ def verify_agent_connectivity(platform: str, endpoint: str, api_key: str) -> dic } headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} try: - r = httpx.post(url, headers=headers, json=payload, timeout=15.0, trust_env=False) + r = _post_follow(url, headers, payload, timeout=15.0) except Exception as e: return {"ok": False, "verified": False, "detail": f"无法连接 Dify 地址:{_short_err(e)}"} if r.status_code == 200: @@ -68,6 +91,24 @@ def verify_agent_connectivity(platform: str, endpoint: str, api_key: str) -> dic if r.status_code == 404: return {"ok": False, "verified": False, "detail": "地址路径不正确(404),应为 .../v1/chat-messages"} + if r.status_code in (301, 302, 307, 308): + loc = r.headers.get("location") or "" + return {"ok": False, "verified": False, + "detail": f"地址被重定向({r.status_code})到 {loc or '未知'};" + f"请直接填写重定向后的地址(通常把 http:// 改为 https://)"} + if r.status_code == 400: + # 400 需区分两种情况: + # (a) 到达应用且鉴权通过,但 Dify 应用/工作流内部执行报错(invalid_param + Run failed) + # (b) 纯参数错误 + body = (r.text or "") + low = body.lower() + if ("run failed" in low or "error code" in low or "workflow" in low + or "node" in low or "-500" in body): + return {"ok": True, "verified": True, + "detail": "连通且 API Key 有效;但你的 Dify 应用内部执行报错:" + f"{body[:180]}。这是你自建 Dify 应用/工作流本身的问题" + "(例如代码节点沙箱缺少 python3),请在 Dify 侧修复。"} + return {"ok": False, "verified": False, "detail": f"参数错误(400):{body[:180]}"} return {"ok": False, "verified": False, "detail": f"Dify 返回状态码 {r.status_code}"} # n8n / coze / 其他:仅做可达性探测(HEAD,避免触发 webhook 副作用)