- /api/open/chat[/stream] 新增 include_files(默认开),把智能体在 outputs 目录 生成的文件(name/virtual_path/encoding(text|base64)/content)随结果回传, 解决"报告写进文件、answer 只回一段总结"导致拿不到完整内容的问题; 既无 answer 也无文件时返回 502。 - 无状态运行标记 ephemeral:lead agent 跳过记忆与知识库中间件(不召回/不留存/ 不自动沉淀),取完文件后清理临时线程目录,做到"通过本接口的聊天记录不存储"。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
433 lines
19 KiB
Python
433 lines
19 KiB
Python
"""开放问答接口(无需登录)。
|
|
|
|
给外部系统一个最简单的 HTTP 入口来「调用本项目的智能体做问答」:
|
|
|
|
- 可指定 **模型**(``model_name``,对应 ``config.yaml`` 中 ``models[]`` 的 name)。
|
|
- 可指定 **智能体**(``agent_id``,对应 ``.deer-flow/agents/<id>/``;留空 = 默认 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"<think>[\s\S]*?</think>", 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="关闭思考时,从答案里剥离残留的 <think>…</think> 块")
|
|
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/<thread_id>/user-data/outputs;只删到 <thread_id> 这层
|
|
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`` 给出最终(已按需剥离 <think> 的)答案;
|
|
- 出错则 ``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 里,所以这里天然不会泄漏 <think>。
|
|
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)}
|