"""开放问答接口(无需登录)。 给外部系统一个最简单的 HTTP 入口来「调用本项目的智能体做问答」: - 可指定 **模型**(``model_name``,对应 ``config.yaml`` 中 ``models[]`` 的 name)。 - 可指定 **智能体**(``agent_id``,对应 ``.deer-flow/agents//``;留空 = 默认 lead agent)。 - 可关闭 **大模型思考模式**(``thinking_enabled=false``,默认即关闭)。内网 vLLM/Qwen 模型仅靠 ``thinking_enabled=False`` 往往关不掉思考(其 config 只声明 ``supports_thinking: true``),本接口在关闭思考时会把 ``thinking_force_disabled`` 一并下发,经 ``create_chat_model(force_disable_thinking=True)`` 强制注入 ``extra_body.chat_template_kwargs.enable_thinking=false``(及 GLM 的 ``thinking.type=disabled``)——与「生成标题」时关闭思考的做法一致。 该路由前缀 ``/api/open/`` 已在 ``auth_middleware`` 白名单里,**绕过登录鉴权**, 以 ``"default"`` 用户桶运行,只暴露受控的问答能力(无文件/线程持久化)。 请求示例:: POST /api/open/chat { "message": "用一句话介绍一下你自己", "model_name": "deepseek-ai/DeepSeek-V4-Flash", "agent_id": null, "thinking_enabled": false } 返回:: {"answer": "...", "model_name": "...", "agent_id": null, "thinking_enabled": false, "thread_id": "open-...."} """ from __future__ import annotations import asyncio import base64 import json import logging import re import shutil import uuid from collections.abc import AsyncIterator from pathlib import Path from typing import Any, Literal from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage from pydantic import BaseModel, Field from deerflow.agents.lead_agent.agent import make_lead_agent from deerflow.config.agents_config import list_custom_agents, validate_agent_id logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/open", tags=["open"]) _THINK_TAG_RE = re.compile(r"[\s\S]*?", re.IGNORECASE) class OpenChatMessage(BaseModel): """多轮历史中的一条消息。""" role: Literal["user", "assistant", "system"] = "user" content: str class OpenChatRequest(BaseModel): """开放问答请求体。 ``message`` 与 ``messages`` 二选一:单轮直接传 ``message``;需要带上下文时传 ``messages``(完整历史,本接口本身无状态、不落库)。 """ message: str | None = Field(default=None, description="单轮问题文本;与 messages 二选一") messages: list[OpenChatMessage] | None = Field(default=None, description="多轮历史;与 message 二选一") model_name: str | None = Field(default=None, description="models[] 中的模型名;留空用默认模型") agent_name: str | None = Field(default=None, description="智能体名称(中文显示名);留空用默认 lead agent") agent_id: str | None = Field(default=None, description="智能体 id;一般不用,优先级高于 agent_name") thinking_enabled: bool = Field(default=False, description="是否开启思考;默认 False = 关闭思考(内网模型也会被强制关闭)") strip_think: bool = Field(default=True, description="关闭思考时,从答案里剥离残留的 … 块") thread_id: str | None = Field(default=None, description="可选;仅用于文件隔离,留空自动生成") recursion_limit: int = Field(default=100, ge=1, le=300, description="智能体单次问答的最大步数") include_files: bool = Field(default=True, description="是否随结果回传智能体在 outputs 目录生成的文件(名称+内容)") class OpenChatFile(BaseModel): """智能体本次运行在 /mnt/user-data/outputs 下生成的一个文件。""" name: str # outputs/ 下的相对路径,如 "报告.md" virtual_path: str # 沙箱虚拟路径,如 "/mnt/user-data/outputs/报告.md" encoding: Literal["text", "base64"] = "text" # text=可直接用的文本;base64=二进制需解码 content: str class OpenChatResponse(BaseModel): answer: str model_name: str | None agent_name: str | None agent_id: str | None thinking_enabled: bool thread_id: str files: list[OpenChatFile] = Field(default_factory=list, description="本次生成的输出文件(含内容)") def _resolve_agent_id(agent_id: str | None, agent_name: str | None) -> str | None: """把 agent_id / 中文名称 解析成运行 id。 优先级:显式 agent_id > agent_name(按 .deer-flow/agents/*/config.yaml 的 name 匹配)。 都没传 = None(默认 lead agent)。名称匹配先精确、再忽略大小写/首尾空格。 """ if agent_id and agent_id.strip(): try: return validate_agent_id(agent_id) except ValueError as exc: raise HTTPException(status_code=422, detail=f"非法的 agent_id:{exc}") from exc if not agent_name or not agent_name.strip(): return None target = agent_name.strip() agents = list_custom_agents() for a in agents: if (a.name or "").strip() == target: return a.id lowered = target.lower() for a in agents: if (a.name or "").strip().lower() == lowered: return a.id raise HTTPException(status_code=404, detail=f"未找到名称为「{agent_name}」的智能体。") def _build_lc_messages(body: OpenChatRequest) -> list[BaseMessage]: """把请求体转成 LangChain 消息列表。""" if body.messages: out: list[BaseMessage] = [] for m in body.messages: text = (m.content or "").strip() if not text: continue if m.role == "assistant": out.append(AIMessage(content=m.content)) elif m.role == "system": out.append(SystemMessage(content=m.content)) else: out.append(HumanMessage(content=m.content)) if out: return out if body.message and body.message.strip(): return [HumanMessage(content=body.message)] raise HTTPException(status_code=422, detail="必须提供非空的 'message' 或 'messages'。") def _extract_text(content: Any) -> str: """从 AIMessage.content(str 或 block 列表)抽取纯文本。""" if isinstance(content, str): return content if isinstance(content, list): parts: list[str] = [] for block in content: if isinstance(block, str): parts.append(block) elif isinstance(block, dict): text_value = block.get("text") if isinstance(text_value, str): parts.append(text_value) return "".join(parts) if parts else "" return "" def _extract_answer(result: Any, *, strip_think: bool) -> str: """从 ainvoke 的最终状态里取最后一条 AI 文本。""" messages = (result or {}).get("messages") if isinstance(result, dict) else None answer = "" if isinstance(messages, list): for msg in reversed(messages): if isinstance(msg, AIMessage): text = _extract_text(msg.content).strip() if text: answer = text break if strip_think and answer: answer = _THINK_TAG_RE.sub("", answer).strip() return answer _OUTPUTS_VIRTUAL_PREFIX = "/mnt/user-data/outputs" _MAX_OUTPUT_FILES = 20 _MAX_OUTPUT_FILE_BYTES = 5 * 1024 * 1024 # 单文件 5MB 上限,避免把大二进制塞进 JSON 响应 def _collect_output_files(state: Any, *, include: bool) -> list[OpenChatFile]: """读取本次运行在沙箱 outputs 目录生成的文件,随结果一并回传。 开放问答是无状态的(不持久化线程),客户端没法用 artifacts 路由按线程取件;但本端点 就跑在服务端进程里,沙箱把生成的文件落在 ``state['thread_data']['outputs_path']``, 所以这里直接扫描该目录,把文件内容带回(文本按 utf-8,二进制按 base64)。 受 ``_MAX_OUTPUT_FILES`` / ``_MAX_OUTPUT_FILE_BYTES`` 限制。 """ if not include or not isinstance(state, dict): return [] thread_data = state.get("thread_data") or {} outputs_path = thread_data.get("outputs_path") if not outputs_path: return [] base = Path(outputs_path) if not base.is_dir(): return [] try: entries = sorted((p for p in base.rglob("*") if p.is_file()), key=lambda p: p.as_posix()) except OSError: return [] files: list[OpenChatFile] = [] for entry in entries: if len(files) >= _MAX_OUTPUT_FILES: break try: if entry.stat().st_size > _MAX_OUTPUT_FILE_BYTES: continue data = entry.read_bytes() except OSError: continue rel = entry.relative_to(base).as_posix() try: content = data.decode("utf-8") encoding: Literal["text", "base64"] = "text" except UnicodeDecodeError: content = base64.b64encode(data).decode("ascii") encoding = "base64" files.append(OpenChatFile(name=rel, virtual_path=f"{_OUTPUTS_VIRTUAL_PREFIX}/{rel}", encoding=encoding, content=content)) return files def _cleanup_ephemeral_thread(state: Any, thread_id: str | None) -> None: """删除本次开放问答的临时线程目录(open-*),落实"聊天记录不存储"。 生成文件已在 _collect_output_files 读进内存(随响应回传),这里把磁盘上整个临时线程 目录清掉,避免无状态调用在服务端堆积残留。仅对 ``open-*`` 线程生效,best-effort。 """ if not thread_id or not str(thread_id).startswith("open-"): return thread_data = state.get("thread_data") if isinstance(state, dict) else None outputs_path = (thread_data or {}).get("outputs_path") if not outputs_path: return try: p = Path(outputs_path).resolve() # outputs_path 形如 .../threads//user-data/outputs;只删到 这层 thread_root = next((parent for parent in p.parents if parent.name == thread_id), None) if thread_root is not None and thread_root.exists(): shutil.rmtree(thread_root, ignore_errors=True) except Exception as exc: # noqa: BLE001 — 清理失败不影响返回结果 logger.warning("清理临时线程目录失败 (%s): %s", thread_id, exc) # 流式响应每隔这么久没有真实事件就发一个 SSE 注释心跳,持续制造下行字节, # 让前置反向代理(nginx proxy_read_timeout 默认 60s 等)不会把长任务判定成空闲超时。 _HEARTBEAT_SECONDS = 15.0 def _build_run_context(body: OpenChatRequest) -> tuple[dict[str, Any], str | None, str]: """把请求体翻译成 (config, agent_id, thread_id)。两个端点共用,确保关思考等行为一致。""" agent_id = _resolve_agent_id(body.agent_id, body.agent_name) thread_id = body.thread_id or f"open-{uuid.uuid4()}" configurable: dict[str, Any] = { "thread_id": thread_id, "model_name": body.model_name, "thinking_enabled": body.thinking_enabled, # 关闭思考时强制下发,确保内网模型也真的关掉(见模块 docstring)。 "thinking_force_disabled": not body.thinking_enabled, # 一次性问答:跳过自动起标题(省一次 LLM 调用)、不挂定时任务工具。 "is_scheduled_run": True, # 临时/无状态运行:跳过记忆与知识库的读写(不召回、不留存、不自动沉淀), # 做到"通过本接口的聊天记录不存储"(见 _build_middlewares 的 is_ephemeral)。 "ephemeral": True, "memory_injection_enabled": False, } if agent_id: configurable["agent_id"] = agent_id config: dict[str, Any] = {"configurable": configurable, "recursion_limit": body.recursion_limit} return config, agent_id, thread_id def _sse(event: str | None, data: Any) -> str: """序列化一条 SSE 消息。event=None 时只发 data 行。""" payload = json.dumps(data, ensure_ascii=False) head = f"event: {event}\n" if event else "" return f"{head}data: {payload}\n\n" @router.post("/chat", response_model=OpenChatResponse) async def open_chat(body: OpenChatRequest) -> OpenChatResponse: """指定模型 + 智能体做一次问答(无状态、可关闭思考)。 同步阻塞版:整段调研跑完才返回。**长任务(联网调研)请改用 /chat/stream**—— 本端点期间不产生任何下行字节,容易被前置反代的 proxy_read_timeout 掐成 504。 """ lc_messages = _build_lc_messages(body) config, agent_id, thread_id = _build_run_context(body) try: agent = make_lead_agent(config) result = await agent.ainvoke({"messages": lc_messages}, config=config) except Exception as exc: # noqa: BLE001 — 开放接口需把内部错误归一成 502 logger.exception("open_chat 调用智能体失败 (model=%s, agent=%s)", body.model_name, agent_id) raise HTTPException(status_code=502, detail=f"智能体调用失败:{exc}") from exc answer = _extract_answer(result, strip_think=body.strip_think and not body.thinking_enabled) files = _collect_output_files(result, include=body.include_files) _cleanup_ephemeral_thread(result, thread_id) # 取完文件即清理临时目录,不在服务端留存 if not answer and not files: raise HTTPException(status_code=502, detail="智能体未返回任何文本答案或文件。") return OpenChatResponse( answer=answer, model_name=body.model_name, agent_name=body.agent_name, agent_id=agent_id, thinking_enabled=body.thinking_enabled, thread_id=thread_id, files=files, ) async def _open_chat_event_stream(body: OpenChatRequest) -> AsyncIterator[str]: """驱动智能体并产出 SSE 事件流。 - 边跑边转发 AI 文本增量(``event: delta``,仅供实时观看,客户端可忽略); - 没有真实事件时每 ``_HEARTBEAT_SECONDS`` 发一个心跳注释行保活; - 全部跑完后用 ``event: result`` 给出最终(已按需剥离 的)答案; - 出错则 ``event: error``。 用「后台生产任务 + 队列 + 超时取队列」的方式把「智能体进度」与「定时心跳」解耦, 所以即便某次工具调用静默很久,心跳仍照常下行,连接不会被反代判空闲。 """ lc_messages = _build_lc_messages(body) config, agent_id, thread_id = _build_run_context(body) strip_think = body.strip_think and not body.thinking_enabled queue: asyncio.Queue[tuple[str, Any]] = asyncio.Queue() final_state: dict[str, Any] = {} async def produce() -> None: nonlocal final_state try: agent = make_lead_agent(config) async for mode, chunk in agent.astream({"messages": lc_messages}, config=config, stream_mode=["values", "messages"]): if mode == "values" and isinstance(chunk, dict): final_state = chunk await queue.put(("progress", {"messages": len(chunk.get("messages") or [])})) elif mode == "messages": msg = chunk[0] if isinstance(chunk, tuple) and chunk else chunk # 仅转发 AI 正文增量;DeepSeek 等把思考放在 additional_kwargs.reasoning_content, # 不在 content 里,所以这里天然不会泄漏 。 if isinstance(msg, AIMessage): text = _extract_text(getattr(msg, "content", "")).strip() if text: await queue.put(("delta", {"text": text})) except Exception as exc: # noqa: BLE001 — 开放接口需把内部错误归一成 error 事件 logger.exception("open_chat_stream 调用智能体失败 (model=%s, agent=%s)", body.model_name, agent_id) await queue.put(("error", {"detail": f"智能体调用失败:{exc}"})) finally: await queue.put(("__end__", None)) task = asyncio.create_task(produce()) try: while True: try: event, data = await asyncio.wait_for(queue.get(), timeout=_HEARTBEAT_SECONDS) except asyncio.TimeoutError: yield ": keepalive\n\n" # SSE 注释行,客户端忽略,仅用于保活 continue if event == "__end__": break if event == "error": yield _sse("error", data) return yield _sse(event, data) answer = _extract_answer(final_state, strip_think=strip_think) files = _collect_output_files(final_state, include=body.include_files) _cleanup_ephemeral_thread(final_state, thread_id) # 取完文件即清理临时目录,不在服务端留存 if not answer and not files: yield _sse("error", {"detail": "智能体未返回任何文本答案或文件。"}) return yield _sse( "result", { "answer": answer, "model_name": body.model_name, "agent_name": body.agent_name, "agent_id": agent_id, "thinking_enabled": body.thinking_enabled, "thread_id": thread_id, "files": [f.model_dump() for f in files], }, ) finally: if not task.done(): task.cancel() @router.post("/chat/stream") async def open_chat_stream(body: OpenChatRequest) -> StreamingResponse: """流式版 /chat:边跑边发心跳保活,跑完用 ``event: result`` 给最终答案。 解决两件事:(1) 长任务(联网调研、报告)同步阻塞会被前置反代 proxy_read_timeout 掐成 504;流式持续有字节下行,代理不再判定空闲超时。(2) 客户端只需读到 ``event: result`` 解析 ``answer`` 即可,中间的 ``delta``/``progress``/心跳都可忽略。 """ # 入参校验放在流外:非法请求直接抛 4xx,而不是开了流再报错。 _build_lc_messages(body) return StreamingResponse( _open_chat_event_stream(body), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", # 关掉 nginx 对该响应的缓冲,确保 SSE/心跳实时下行(即便前置 nginx 默认开缓冲)。 "X-Accel-Buffering": "no", }, ) @router.get("/agents") async def open_list_agents() -> dict[str, Any]: """列出可用智能体的名称(方便知道 agent_name 该填什么)。""" agents = list_custom_agents() items = [{"name": a.name, "id": a.id} for a in agents if (a.name or "").strip()] return {"agents": items, "count": len(items)}