feat(open-chat): 新增 /api/open/chat 开放问答接口

- 无需登录(/api/open/ 加入 auth 白名单 + CSRF 豁免),供外部系统直接调用本项目智能体做问答
- 可指定模型(model_name)与智能体(agent_name 中文名称,扫描 agents/*/config.yaml 解析为运行 id);GET /api/open/agents 列出可选名称
- 只返回最终结果(answer = 最后一段 markdown),不含中间过程/工具调用
- 可靠关闭思考:thinking_enabled=false 时下发 thinking_force_disabled,经 create_chat_model(force_disable_thinking=True) 强制注入 enable_thinking=false(及 GLM thinking.type=disabled),覆盖只声明 supports_thinking 的内网 vLLM/Qwen 模型(与生成标题关思考一致)
- 一次性问答设 is_scheduled_run 跳过自动起标题

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-22 11:21:56 +08:00
1 parent 593daa91ec
commit 2b6424b3ff
9 files changed
+337 -4

No files matched your search

@@ -243,6 +243,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block; `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/messages` - paginated messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /../messages` - thread messages with feedback; `GET /../token-usage` - aggregate tokens |
| **Feedback** (`/api/threads/{id}/runs/{rid}/feedback`) | `PUT /` - upsert feedback; `DELETE /` - delete user feedback; `POST /` - create feedback; `GET /` - list feedback; `GET /stats` - aggregate stats; `DELETE /{fid}` - delete specific |
| **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id |
| **Open Chat** (`/api/open/chat`) | **Unauthenticated** open Q&A entry for external systems (the `/api/open/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES` + CSRF-exempt in `csrf_middleware.should_check_csrf`). `POST /chat` `{message \| messages[], model_name?, agent_name?, agent_id?, thinking_enabled=false, strip_think=true, thread_id?, recursion_limit=100}` → resolves the agent by **Chinese display name** (`agent_name`, matched against each `.deer-flow/agents/*/config.yaml` `name` via `_resolve_agent_id`; explicit `agent_id` wins if given), builds the lead agent via `make_lead_agent` (so the chosen **agent's** SOUL/tools/skills all apply), `ainvoke`s it once (stateless — no checkpointer, no thread persistence; runs under the `"default"` user bucket), and returns **only the final** `{answer, model_name, agent_name, agent_id, thinking_enabled, thread_id}` (no intermediate tool-call/process). `GET /agents` lists `{name, id}` so callers know what `agent_name` to send. **Thinking off is reliable**: when `thinking_enabled=false` the route also sets `thinking_force_disabled=true` so the lead agent calls `create_chat_model(force_disable_thinking=True)`, which injects `extra_body.chat_template_kwargs.enable_thinking=false` (+ GLM `thinking.type="disabled"`) even for internal vLLM/Qwen models that only declare `supports_thinking: true` — the same trick `TitleMiddleware._force_disable_thinking` uses for title generation. Sets `is_scheduled_run=true` internally to skip the auto-title LLM call. Router `app/gateway/routers/open_chat.py`; standalone caller `test_open_chat_api.py` (repo root); tests `tests/test_open_chat.py`. |
| **Thread Shares** (`/api/thread-shares`) | Per-conversation sharing with two modes (`thread_shares.mode`). **`import`**: `POST /` `{thread_id, mode}` - create (or reuse) a revocable share code; `GET /{code}/preview` + `POST /{code}/import` - copy that conversation into the caller's account (fresh `thread_id` via `app/gateway/thread_copy.py` + new `threads_meta` row + `user-data` copy; dedup on `metadata.shared_origin`). **`view`**: a public, read-only static page ? see Public Shares below. Both modes: `GET /` - list my codes; `DELETE /{code}` - revoke. |
| **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, ?) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. |
| **Roundtable Drafts** (`/api/roundtable-drafts`) | **Strictly per-user** persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete (all owner-scoped). A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). taskId 深链会商记录 are **not** here — they live in the separate `/api/roundtable-task-drafts` store (see below) so personal history never mixes with task records. Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. Tests: `tests/test_roundtable_task_drafts.py`. |
@@ -614,6 +615,7 @@ scheduler runs once per deployment. Per run:
- `create_chat_model(name, thinking_enabled)` instantiates LLM from config via reflection
- Supports `thinking_enabled` flag with per-model `when_thinking_enabled` overrides
- `force_disable_thinking=True` (kwarg) hard-disables thinking even when the model config declares **no** `when_thinking_*`/`thinking` shape — many internal vLLM/Qwen models only set `supports_thinking: true`, so `thinking_enabled=False` alone injects nothing and they keep reasoning on. The flag injects both OpenAI-compatible disable shapes (`extra_body.chat_template_kwargs.enable_thinking=false` for vLLM/Qwen + `extra_body.thinking.type="disabled"` for Zhipu GLM), only for classes that accept `extra_body`. Mirrors `TitleMiddleware._force_disable_thinking`. The lead agent reads the runtime flag `thinking_force_disabled` and forwards it (used by `/api/open/chat`)
- Supports vLLM-style thinking toggles via `when_thinking_enabled.extra_body.chat_template_kwargs.enable_thinking` for Qwen reasoning models, while normalizing legacy `thinking` configs for backward compatibility
- Supports `supports_vision` flag for image understanding models
- Config values starting with `$` resolved as environment variables
@@ -50,6 +50,7 @@ from app.gateway.routers import (
models,
multi_agent,
notifications,
open_chat,
parallel_agent_tasks,
parallel_agents,
positions,
@@ -1454,6 +1455,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Stateless Runs API (stream/wait without a pre-existing thread)
app.include_router(runs.router)
# 开放问答接口 /api/open/chat —— 无需登录,指定模型 + 智能体做问答,可关闭思考模式
app.include_router(open_chat.router)
# --- ?????????? /page/workspace/roundtable/planning?---
# ??? app/gateway/routers/{multi_agent,intent,recommend}.py?
# ??? frontend-web/docs/multi-agent-backend-dev.md
@@ -35,6 +35,9 @@ _PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
"/redoc",
"/openapi.json",
"/api/public/",
# 开放问答接口:无需登录即可指定模型 + 智能体做问答(见 routers/open_chat.py)。
# 以 "default" 用户桶运行,只暴露受控的问答能力。
"/api/open/",
)
# Exact auth paths that are public (login/register/status check).
@@ -52,6 +52,10 @@ def should_check_csrf(request: Request) -> bool:
# PUT/POST returns 403 even though auth was bypassed earlier in the chain.
if path.startswith("/api/public/"):
return False
# 开放问答接口同样是匿名、无会话的(见 auth_middleware._PUBLIC_PATH_PREFIXES),
# 双提交 Cookie 既无 token 可比、也无会话可被伪造,豁免之。
if path.startswith("/api/open/"):
return False
return True
@@ -0,0 +1,219 @@
"""开放问答接口(无需登录)。
给外部系统一个最简单的 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 logging
import re
import uuid
from typing import Any, Literal
from fastapi import APIRouter, HTTPException
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="智能体单次问答的最大步数")
class OpenChatResponse(BaseModel):
answer: str
model_name: str | None
agent_name: str | None
agent_id: str | None
thinking_enabled: bool
thread_id: str
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
@router.post("/chat", response_model=OpenChatResponse)
async def open_chat(body: OpenChatRequest) -> OpenChatResponse:
"""指定模型 + 智能体做一次问答(无状态、可关闭思考)。"""
lc_messages = _build_lc_messages(body)
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,
}
if agent_id:
configurable["agent_id"] = agent_id
config: dict[str, Any] = {"configurable": configurable, "recursion_limit": body.recursion_limit}
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)
if not answer:
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,
)
@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)}
@@ -222,6 +222,10 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
"model_name",
"mode",
"thinking_enabled",
# 强制关闭思考(内网 vLLM/Qwen 模型只声明 supports_thinking 时,单靠
# thinking_enabled=False 不会注入 enable_thinking=false)。透传到 run config
# 后由 lead agent 交给 create_chat_model(force_disable_thinking=...)。
"thinking_force_disabled",
"reasoning_effort",
"rag_mode_enabled",
"writing_mode",
@@ -412,6 +412,11 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
resolved_app_config = app_config
thinking_enabled = cfg.get("thinking_enabled", True)
# Hard "thinking off" switch for internal vLLM/Qwen models whose config only
# declares `supports_thinking: true` (no disable shape) — passes through to
# create_chat_model(force_disable_thinking=...) so enable_thinking=false is
# actually injected. Default off → no behavior change for normal chats.
thinking_force_disabled = bool(cfg.get("thinking_force_disabled", False))
reasoning_effort = cfg.get("reasoning_effort", None)
requested_model_name: str | None = cfg.get("model_name") or cfg.get("model")
is_plan_mode = cfg.get("is_plan_mode", False)
@@ -501,7 +506,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
from deerflow.agents.middlewares.writing_setup_round_cap_middleware import WritingSetupRoundCapMiddleware
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config),
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
tools=[setup_writing, ask_clarification_tool],
middleware=[
*_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
@@ -514,7 +519,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
if is_bootstrap:
# Special bootstrap agent with minimal prompt for initial custom agent creation flow
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config),
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
tools=get_available_tools(
model_name=model_name,
subagent_enabled=subagent_enabled,
@@ -593,7 +598,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
)
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config),
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
tools=default_tools,
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
system_prompt=system_prompt,
@@ -47,11 +47,22 @@ def _enable_stream_usage_by_default(model_use_path: str, model_settings_from_con
model_settings_from_config["stream_usage"] = True
def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *, app_config: AppConfig | None = None, **kwargs) -> BaseChatModel:
def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *, app_config: AppConfig | None = None, force_disable_thinking: bool = False, **kwargs) -> BaseChatModel:
"""Create a chat model instance from the config.
Args:
name: The name of the model to create. If None, the first model in the config will be used.
thinking_enabled: Turn the model's extended thinking / reasoning on.
force_disable_thinking: Hard override that disables thinking even when the
per-model config declares no ``when_thinking_*`` shape. Many internal
vLLM / Qwen models only set ``supports_thinking: true`` (no disable
shape), so passing ``thinking_enabled=False`` alone injects nothing and
the model keeps reasoning on. When this flag is set (and thinking is
off) we additionally inject both common OpenAI-compatible disable shapes
— vLLM/Qwen ``chat_template_kwargs.enable_thinking=False`` and Zhipu GLM
``thinking.type="disabled"`` — mirroring
``TitleMiddleware._force_disable_thinking``. No effect when thinking is
enabled, and skipped for model classes that don't accept ``extra_body``.
Returns:
A chat model instance.
@@ -120,6 +131,15 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
kwargs.pop("reasoning_effort", None)
model_settings_from_config.pop("reasoning_effort", None)
# Hard override: force-disable thinking regardless of the per-model config shape.
# Injected only for OpenAI-compatible classes (those declaring `extra_body`);
# other providers would reject the kwarg, so they are skipped (they ignore it).
if force_disable_thinking and not thinking_enabled and "extra_body" in getattr(model_class, "model_fields", {}):
model_settings_from_config["extra_body"] = _deep_merge_dicts(
model_settings_from_config.get("extra_body"),
{"chat_template_kwargs": {"enable_thinking": False}, "thinking": {"type": "disabled"}},
)
_enable_stream_usage_by_default(model_config.use, model_settings_from_config)
# For Codex Responses API models: map thinking mode to reasoning_effort
+72
View File
@@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
"""开放问答接口 /api/open/chat 测试脚本(Jupyter 版)。
接口只返回「最终结果」:智能体把调研 / 工具调用全部跑完后,只把最后那段
文本(markdown)放在 answer 字段里返回 —— 不含中间过程、工具调用、思考。
用法:在 Jupyter 里把下面几段按顺序运行即可。
- 「配置」段:改成你要的模型 / 智能体 / 问题 / 是否思考
- 「调用」段:运行后 answer 变量就是最终 md 文本
无第三方依赖(纯标准库 urllib)。
"""
import json
import time
import urllib.error
import urllib.request
# ====================== 配置(按需修改这里) ======================
BASE_URL = "http://47.94.209.59:2026" # 后端地址(走 nginx 用 :2026,Gateway 直连用 :8001)
MESSAGE = "帮我调研一下 XXX,输出 markdown 报告。" # 要问的问题
MODEL_NAME = None # 模型名(config.yaml models[].name);None=默认模型
AGENT_NAME = None # 智能体中文名称;None=默认 lead agent。不确定填啥就先跑下面的「列出智能体」
THINKING_ENABLED = False # False=关闭思考(内网模型也会被强制关闭);True=开启
TIMEOUT = 600 # 请求超时(秒)
# ===============================================================
def open_chat(message, model_name=None, agent_name=None, thinking_enabled=False,
base_url=BASE_URL, timeout=TIMEOUT):
"""调用 /api/open/chat,返回最终 md 文本(只要结果,不要过程)。"""
url = base_url.rstrip("/") + "/api/open/chat"
payload = {
"message": message,
"model_name": model_name,
"agent_name": agent_name, # 按中文名称选智能体
"thinking_enabled": thinking_enabled,
}
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
headers={"Content-Type": "application/json; charset=utf-8"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
result = json.loads(resp.read().decode("utf-8"))
return result["answer"] # 只返回最终结果
# ====================== (可选)列出可用智能体名称 ======================
# 不知道 AGENT_NAME 填什么时,先跑这段看有哪些中文名:
# import urllib.request, json
# with urllib.request.urlopen(BASE_URL.rstrip("/") + "/api/open/agents", timeout=30) as r:
# for a in json.loads(r.read().decode("utf-8"))["agents"]:
# print(a["name"])
# ====================== 调用 ======================
_t0 = time.time()
answer = open_chat(MESSAGE, MODEL_NAME, AGENT_NAME, THINKING_ENABLED)
print(f"(耗时 {time.time() - _t0:.1f}s)\n")
print(answer) # answer 就是最终 markdown 文本
# ====================== (可选)在 Jupyter 里渲染成 markdown ======================
# from IPython.display import Markdown, display
# display(Markdown(answer))
# ====================== (可选)存成 .md 文件 ======================
# with open("result.md", "w", encoding="utf-8") as f:
# f.write(answer)