diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 17eb0b9b..efe0577d 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -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 diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index a32575ef..995f76b3 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -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 diff --git a/offline-backend-20260512/backend/app/gateway/auth_middleware.py b/offline-backend-20260512/backend/app/gateway/auth_middleware.py index ae124dfb..e6c84b4d 100644 --- a/offline-backend-20260512/backend/app/gateway/auth_middleware.py +++ b/offline-backend-20260512/backend/app/gateway/auth_middleware.py @@ -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). diff --git a/offline-backend-20260512/backend/app/gateway/csrf_middleware.py b/offline-backend-20260512/backend/app/gateway/csrf_middleware.py index c5494277..afc9554a 100644 --- a/offline-backend-20260512/backend/app/gateway/csrf_middleware.py +++ b/offline-backend-20260512/backend/app/gateway/csrf_middleware.py @@ -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 diff --git a/offline-backend-20260512/backend/app/gateway/routers/open_chat.py b/offline-backend-20260512/backend/app/gateway/routers/open_chat.py new file mode 100644 index 00000000..1b8f0b78 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/open_chat.py @@ -0,0 +1,219 @@ +"""开放问答接口(无需登录)。 + +给外部系统一个最简单的 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 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"[\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="智能体单次问答的最大步数") + + +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)} diff --git a/offline-backend-20260512/backend/app/gateway/services.py b/offline-backend-20260512/backend/app/gateway/services.py index 362c5b9c..d16243a3 100644 --- a/offline-backend-20260512/backend/app/gateway/services.py +++ b/offline-backend-20260512/backend/app/gateway/services.py @@ -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", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py index 00a2d1cb..e2d27c2f 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -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, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/models/factory.py b/offline-backend-20260512/backend/packages/harness/deerflow/models/factory.py index 58c33562..50096990 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/models/factory.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/models/factory.py @@ -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 diff --git a/test_open_chat_api.py b/test_open_chat_api.py new file mode 100644 index 00000000..fc50b070 --- /dev/null +++ b/test_open_chat_api.py @@ -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)