From 2b6424b3fffad12b2caa9b54a08cf0ba08a0a3e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Mon, 22 Jun 2026 11:20:35 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat(open-chat):=20=E6=96=B0=E5=A2=9E=20/ap?= =?UTF-8?q?i/open/chat=20=E5=BC=80=E6=94=BE=E9=97=AE=E7=AD=94=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 无需登录(/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) --- offline-backend-20260512/backend/CLAUDE.md | 2 + .../backend/app/gateway/app.py | 4 + .../backend/app/gateway/auth_middleware.py | 3 + .../backend/app/gateway/csrf_middleware.py | 4 + .../backend/app/gateway/routers/open_chat.py | 219 ++++++++++++++++++ .../backend/app/gateway/services.py | 4 + .../deerflow/agents/lead_agent/agent.py | 11 +- .../harness/deerflow/models/factory.py | 22 +- test_open_chat_api.py | 72 ++++++ 9 files changed, 337 insertions(+), 4 deletions(-) create mode 100644 offline-backend-20260512/backend/app/gateway/routers/open_chat.py create mode 100644 test_open_chat_api.py 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) From 16ba5deb0eaaafb2397d25dfe2e54023d448a0ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Mon, 22 Jun 2026 11:30:15 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat(agents):=20=E6=99=BA=E8=83=BD=E4=BD=93?= =?UTF-8?q?=E5=BD=92=E5=B1=9E/=E5=8F=91=E5=B8=83=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E5=89=8D=E5=A2=9E=E5=8A=A0=E3=80=8C=E8=B4=A6=E5=8F=B7=E5=B7=B2?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E3=80=8D=E6=8F=90=E9=86=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 useLiveAccount 钩子:跟踪「实时」登录账号(随 storage/focus 刷新), 并与组件挂载时捕获的账号对比;token 存 localStorage 会被同浏览器其他标签页 共享,另一标签页登录会静默替换本页 API 所用账号 - agent-card / agent-detail-panel / publish-square-picker-dialog / NewAgentPage 接入该钩子,切换账号后对归属/发布类操作做提醒,避免误归属 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workspace/agents/agent-card.tsx | 44 +++++++++- .../workspace/agents/agent-detail-panel.tsx | 44 +++++++++- .../publish-square-picker-dialog.tsx | 6 +- .../src/core/auth/use-live-account.ts | 82 +++++++++++++++++++ frontend-web/src/pages/NewAgentPage.tsx | 52 +++++++++++- 5 files changed, 221 insertions(+), 7 deletions(-) create mode 100644 frontend-web/src/core/auth/use-live-account.ts diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index cfd7e8c2..d80487c2 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -77,6 +77,7 @@ import { import { getAgent } from "@/core/agents/api"; import type { Agent, AgentQuestion, UpdateAgentRequest } from "@/core/agents"; import { getStoredAuth } from "@/core/auth"; +import { useLiveAccount } from "@/core/auth/use-live-account"; import { useI18n } from "@/core/i18n/hooks"; import { useSkills } from "@/core/skills/hooks"; import { formatDateTime } from "@/core/utils/datetime"; @@ -246,6 +247,8 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar const storedAuth = useMemo(() => getStoredAuth(), []); const currentUserId = storedAuth?.user_id ?? null; + // 发布请求实际以「实时」token 执行 —— 用 live 账号来提示发布者并检测账号是否被切换。 + const live = useLiveAccount(); const isAdmin = storedAuth?.system_role === "admin"; const isBuiltin = agent.builtin; const isOwned = @@ -414,18 +417,38 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar setPublishPickerOpen(true); return; } + if (!warnIfAccountSwitched()) { + return; + } try { await updateAgent.mutateAsync({ id: agent.id, request: { published: !agent.published }, }); - toast.success(agent.published ? "已取消发布" : "已发布"); + toast.success( + agent.published ? "已取消发布" : `已发布(操作账号:${live.displayName})`, + ); } catch (err) { toast.error(err instanceof Error ? err.message : String(err)); } } + // 发布操作以实时 token 执行。若会话中途账号被切换(常见于另一标签页登录了别的 + // 账号),先弹一次确认 —— 避免误以本人之外的账号执行发布。返回 false = 用户取消。 + function warnIfAccountSwitched(): boolean { + if (!live.switched) { + return true; + } + return window.confirm( + `当前登录账号已从「${live.mountDisplayName}」切换为「${live.displayName}」。\n` + + `本次发布将以「${live.displayName}」执行。如需以「${live.mountDisplayName}」操作,请取消并刷新页面。\n\n仍要继续吗?`, + ); + } + async function handlePublishToSquare(squareId: string) { + if (!warnIfAccountSwitched()) { + return; + } try { await updateAgent.mutateAsync({ id: agent.id, @@ -433,7 +456,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar }); const sqName = publishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场"; - toast.success(`已发布到「${sqName}」`); + toast.success(`已发布到「${sqName}」(操作账号:${live.displayName})`); setPublishPickerOpen(false); } catch (err) { toast.error(err instanceof Error ? err.message : String(err)); @@ -1094,6 +1117,23 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar loading={updateAgent.isPending} onConfirm={(squareId) => void handlePublishToSquare(squareId)} description="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。" + accountNotice={ +
+

+ 发布操作账号: + + 「{live.displayName}」 + +

+ {live.switched && ( +

+ ⚠ 当前登录账号已从「{live.mountDisplayName}」切换为「 + {live.displayName}」。如需以「{live.mountDisplayName} + 」操作,请刷新页面确认。 +

+ )} +
+ } /> ); diff --git a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx index 9a40afc3..1273088c 100644 --- a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx +++ b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx @@ -59,6 +59,7 @@ import { type UpdateAgentRequest, } from "@/core/agents"; import { getStoredAuth } from "@/core/auth"; +import { useLiveAccount } from "@/core/auth/use-live-account"; import { isAgentSkillCandidate } from "@/core/skills"; import { useSkills } from "@/core/skills/hooks"; import { usePublishSquares } from "@/core/system-settings/hooks"; @@ -156,6 +157,8 @@ export function AgentDetailPanel({ const [deleteOpen, setDeleteOpen] = useState(false); const [publishPickerOpen, setPublishPickerOpen] = useState(false); const [detailSkillName, setDetailSkillName] = useState(null); + // 发布以实时 token 执行 —— 用 live 账号提示发布者并检测账号是否被切换。 + const live = useLiveAccount(); const updateAgent = useUpdateAgent(); const deleteAgent = useDeleteAgent(); @@ -338,19 +341,39 @@ export function AgentDetailPanel({ setPublishPickerOpen(true); return; } + if (!warnIfAccountSwitched()) { + return; + } try { await updateAgent.mutateAsync({ id: agent.id, request: { published: !agent.published }, }); - toast.success(agent.published ? "已下架" : "已发布"); + toast.success( + agent.published ? "已下架" : `已发布(操作账号:${live.displayName})`, + ); } catch (err) { toast.error(err instanceof Error ? err.message : String(err)); } } + // 发布以实时 token 执行。会话中途账号被切换(常见于另一标签页登录了别的账号)时 + // 先弹确认,避免误以本人之外的账号执行发布。返回 false = 用户取消。 + function warnIfAccountSwitched(): boolean { + if (!live.switched) { + return true; + } + return window.confirm( + `当前登录账号已从「${live.mountDisplayName}」切换为「${live.displayName}」。\n` + + `本次发布将以「${live.displayName}」执行。如需以「${live.mountDisplayName}」操作,请取消并刷新页面。\n\n仍要继续吗?`, + ); + } + async function handlePublishToSquare(squareId: string) { if (!agent) return; + if (!warnIfAccountSwitched()) { + return; + } try { await updateAgent.mutateAsync({ id: agent.id, @@ -358,7 +381,7 @@ export function AgentDetailPanel({ }); const sqName = publishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场"; - toast.success(`已发布到「${sqName}」`); + toast.success(`已发布到「${sqName}」(操作账号:${live.displayName})`); setPublishPickerOpen(false); } catch (err) { toast.error(err instanceof Error ? err.message : String(err)); @@ -901,6 +924,23 @@ export function AgentDetailPanel({ loading={updateAgent.isPending} onConfirm={(squareId) => void handlePublishToSquare(squareId)} description="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。" + accountNotice={ +
+

+ 发布操作账号: + + 「{live.displayName}」 + +

+ {live.switched && ( +

+ ⚠ 当前登录账号已从「{live.mountDisplayName}」切换为「 + {live.displayName}」。如需以「{live.mountDisplayName} + 」操作,请刷新页面确认。 +

+ )} +
+ } /> ); diff --git a/frontend-web/src/components/workspace/publish-square-picker-dialog.tsx b/frontend-web/src/components/workspace/publish-square-picker-dialog.tsx index 65aa7a51..fd6fecc2 100644 --- a/frontend-web/src/components/workspace/publish-square-picker-dialog.tsx +++ b/frontend-web/src/components/workspace/publish-square-picker-dialog.tsx @@ -1,7 +1,7 @@ "use client"; import { CheckIcon } from "lucide-react"; -import { useEffect, useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { @@ -26,6 +26,8 @@ interface PublishSquarePickerDialogProps { loading?: boolean; title?: string; description?: string; + /** 可选提示节点,渲染在描述下方 —— 用于显示「本次发布将以哪个账号进行」及账号切换告警。 */ + accountNotice?: ReactNode; } /** A "publish to which square (发布广场)?" chooser. Squares are rendered as a @@ -39,6 +41,7 @@ export function PublishSquarePickerDialog({ loading, title = "选择发布广场", description = "请选择要把它发布到哪个广场。每个项目只能归属一个广场,不会重复出现。", + accountNotice, }: PublishSquarePickerDialogProps) { const [selected, setSelected] = useState( defaultSquareId || squares[0]?.id || "", @@ -58,6 +61,7 @@ export function PublishSquarePickerDialog({ {title} {description} + {accountNotice}
{squares.map((sq) => { const active = selected === sq.id; diff --git a/frontend-web/src/core/auth/use-live-account.ts b/frontend-web/src/core/auth/use-live-account.ts new file mode 100644 index 00000000..a9417694 --- /dev/null +++ b/frontend-web/src/core/auth/use-live-account.ts @@ -0,0 +1,82 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import { getAccountDisplayName, getStoredAuth } from "@/core/auth"; +import { isEmbedActive } from "@/core/embed/embed-session"; + +const AUTH_STORAGE_KEY = "deerflow.auth"; + +export interface LiveAccount { + /** 实时登录用户 id(随 storage/focus 刷新)。null = 未登录。 */ + userId: string | null; + /** 实时显示名(邮箱 @ 前缀,否则 user_id)。 */ + displayName: string; + /** 组件首次挂载时捕获的用户 id。 */ + mountUserId: string | null; + /** 组件首次挂载时捕获的显示名。 */ + mountDisplayName: string; + /** + * 当前登录账号与挂载时不一致 —— 即本视图加载后登录被切换过(通常是在另一个 + * 标签页登录了别的账号)。归属/发布类操作必须据此提醒,避免误归属。 + */ + switched: boolean; +} + +/** + * 跟踪「实时」登录账号,而不是挂载时缓存的值。 + * + * 鉴权 token 存在 localStorage(非嵌入态下被同浏览器所有标签页共享),所以在 + * 另一个标签页登录会**静默替换**本标签页 API 调用所用的 token。任何会决定归属的 + * 界面(创建/发布智能体)都必须显示请求实际将以哪个账号执行,并在会话中途被切换 + * 账号时给出提示 —— 否则会出现「用 A 账号创建却归到 B 账号名下」。 + */ +export function useLiveAccount(): LiveAccount { + const mountRef = useRef<{ id: string | null; name: string } | null>(null); + if (mountRef.current === null) { + const auth = getStoredAuth(); + mountRef.current = { + id: auth?.user_id ?? null, + name: getAccountDisplayName(auth), + }; + } + const [live, setLive] = useState(() => mountRef.current!); + + useEffect(() => { + function refresh() { + const auth = getStoredAuth(); + const id = auth?.user_id ?? null; + const name = getAccountDisplayName(auth); + setLive((prev) => + prev.id === id && prev.name === name ? prev : { id, name }, + ); + } + // 嵌入(iframe)态鉴权走按标签页的 sessionStorage、不跨标签,收不到 storage 事件 + // —— 只挂在正常态;focus/visibilitychange 兜底刷新两种形态都有效。 + function onStorage(e: StorageEvent) { + if (e.key === AUTH_STORAGE_KEY) { + refresh(); + } + } + if (!isEmbedActive()) { + window.addEventListener("storage", onStorage); + } + window.addEventListener("focus", refresh); + document.addEventListener("visibilitychange", refresh); + refresh(); + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener("focus", refresh); + document.removeEventListener("visibilitychange", refresh); + }; + }, []); + + const mount = mountRef.current!; + return { + userId: live.id, + displayName: live.name, + mountUserId: mount.id, + mountDisplayName: mount.name, + switched: live.id !== null && mount.id !== null && live.id !== mount.id, + }; +} diff --git a/frontend-web/src/pages/NewAgentPage.tsx b/frontend-web/src/pages/NewAgentPage.tsx index 4ab2564a..0906cd7c 100644 --- a/frontend-web/src/pages/NewAgentPage.tsx +++ b/frontend-web/src/pages/NewAgentPage.tsx @@ -48,6 +48,7 @@ import { updateAgent, } from "@/core/agents/api"; import { getStoredAuth } from "@/core/auth"; +import { useLiveAccount } from "@/core/auth/use-live-account"; import { useI18n } from "@/core/i18n/hooks"; import { useModels } from "@/core/models/hooks"; import { isAgentSkillCandidate, type Skill } from "@/core/skills"; @@ -96,6 +97,10 @@ export default function NewAgentPage() { const { skills, isLoading: isSkillsLoading } = useSkills(); const { models, isLoading: isModelsLoading } = useModels(); + // 创建请求的归属账号取自「实时」token(apiFetch 每次都重读 localStorage),而非 + // 本页加载时缓存的身份 —— 用 live 账号提示归属并检测账号是否在会话中途被切换。 + const live = useLiveAccount(); + // 可配置的技能限定为「内置 + 自己的 + 已发布到广场的」, // 其他用户未发布的自定义技能不可选。 const storedAuth = useMemo(() => getStoredAuth(), []); @@ -287,13 +292,37 @@ export default function NewAgentPage() { ]); const handleSaveAgent = useCallback(async () => { + // 新建时归属账号 = 创建请求所用的实时 token 对应账号。若本页加载后登录被切换 + // (常见于另一标签页登录了别的账号),现在创建会把智能体静默归到新账号名下。 + // 先弹确认,避免「用 A 账号创建却归到 B 账号」。更新已有智能体不改归属,故跳过。 + if (!agentId && live.switched) { + const ok = window.confirm( + `当前登录账号已从「${live.mountDisplayName}」切换为「${live.displayName}」。\n` + + `现在创建将把该智能体归属到「${live.displayName}」名下。\n` + + `如需归属「${live.mountDisplayName}」,请取消并刷新页面后重新登录。\n\n仍要以「${live.displayName}」创建吗?`, + ); + if (!ok) { + return; + } + } const saved = await ensureAgent(); if (!saved) { return; } - toast.success(`智能体「${saved.name}」已保存`); + toast.success( + agentId + ? `智能体「${saved.name}」已保存` + : `智能体「${saved.name}」已创建(归属账号:${live.displayName})`, + ); router.push("/page/workspace/agents"); - }, [ensureAgent, router]); + }, [ + agentId, + ensureAgent, + live.displayName, + live.mountDisplayName, + live.switched, + router, + ]); const handleChatSubmit = useCallback( async (text: string) => { @@ -661,6 +690,25 @@ export default function NewAgentPage() {

{formError}

) : null} +
+

+ {agentId ? "当前登录账号:" : "创建后归属账号:"} + + 「{live.displayName}」 + +

+ {live.switched && ( +

+ ⚠ 本页加载时为「{live.mountDisplayName}」,现已切换为「 + {live.displayName}」。 + {agentId + ? "继续保存将以当前账号执行。" + : `继续创建将归属「${live.displayName}」。`} + 建议刷新页面确认账号。 +

+ )} +
+
) : ( filteredThreads.map((thread) => { - const href = pathOf(thread); + const isBoot = isBootstrapThread(thread); + const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread); const isActive = href === pathname; const pinned = isThreadPinned(thread); const favorited = isThreadFavorite(thread); @@ -468,12 +471,22 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) { : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", )} > - + {isBoot ? ( + + ) : ( + + )} {pinned && ( )} diff --git a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx index 1273088c..615d6227 100644 --- a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx +++ b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx @@ -43,6 +43,7 @@ import { import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor"; +import { AgentRecentChats } from "@/components/workspace/agents/agent-recent-chats"; import { useArtifactsOptional } from "@/components/workspace/artifacts"; import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list"; import { SkillPickerSheet } from "@/components/workspace/agents/skill-picker-sheet"; @@ -133,6 +134,7 @@ export function AgentDetailPanel({ onAskQuestion, disabled, defaultCollapsed, + showRecentChats, }: { agent: Agent | null | undefined; agentName: string; @@ -140,6 +142,8 @@ export function AgentDetailPanel({ disabled?: boolean; /** 初始收起(任务工作区进入时默认收起智能体信息面板)。 */ defaultCollapsed?: boolean; + /** 在面板底部展示「有关此智能体的最近对话」(仅智能体问答页开启,任务工作区不展示)。 */ + showRecentChats?: boolean; }) { const router = useRouter(); const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false); @@ -794,6 +798,9 @@ export function AgentDetailPanel({ onViewDetail={setDetailSkillName} /> + + {/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */} + {showRecentChats && !editing && } ); diff --git a/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx b/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx new file mode 100644 index 00000000..de416639 --- /dev/null +++ b/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { MessageSquareIcon } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +import { useThreadsPage } from "@/core/threads/hooks"; +import { pathOfThread, titleOfThread } from "@/core/threads/utils"; +import { desensitize } from "@/lib/desensitize"; +import { cn } from "@/lib/utils"; + +const RECENT_LIMIT = 8; + +/** + * 「有关此智能体的最近对话」—— 渲染在智能体详情面板底部(右下角)。 + * 按 `metadata.agent_id` 过滤出该智能体的会话,点击直接打开对应对话。 + */ +export function AgentRecentChats({ agentId }: { agentId: string }) { + const pathname = usePathname(); + const { data } = useThreadsPage({ + page: 1, + pageSize: RECENT_LIMIT, + metadata: { agent_id: agentId }, + }); + // 该智能体的会话里同样排除调度 / 引导 / 会商类系统会话。 + const threads = (data?.threads ?? []).filter( + (th) => + th.metadata?.thread_type !== "bootstrap" && + th.metadata?.thread_type !== "scheduler" && + th.metadata?.thread_type !== "roundtable", + ); + + return ( +
+
+

+ + 最近对话 +

+ + {threads.length} 个 + +
+ {threads.length === 0 ? ( +

暂无对话

+ ) : ( +
+ {threads.map((thread) => { + const href = pathOfThread(thread); + const isActive = href === pathname; + return ( + + + + {desensitize(titleOfThread(thread, "新对话"))} + + + ); + })} +
+ )} +
+ ); +} diff --git a/frontend-web/src/components/workspace/general-qa-sidebar.tsx b/frontend-web/src/components/workspace/general-qa-sidebar.tsx index d9062d86..b06e4123 100644 --- a/frontend-web/src/components/workspace/general-qa-sidebar.tsx +++ b/frontend-web/src/components/workspace/general-qa-sidebar.tsx @@ -75,8 +75,10 @@ import { } from "@/core/threads/hooks"; import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import { + isBootstrapThread, isThreadFavorite, isThreadPinned, + listPathOfThread, pathOfThread, sortThreadsPinnedFirst, titleOfThread, @@ -213,11 +215,12 @@ export function GeneralQaSidebar() { const visibleThreads = useMemo( () => + // 智能体创建(bootstrap)会话也展示(带机器人图标、点击回创建页); + // 仅排除调度 / 会商类系统会话。 sortThreadsPinnedFirst( threads.filter( (thread) => thread.metadata?.thread_type !== "scheduler" && - thread.metadata?.thread_type !== "bootstrap" && thread.metadata?.thread_type !== "roundtable", ), ), @@ -249,9 +252,9 @@ export function GeneralQaSidebar() { let nextThreadPath = pathOfThread("new", { agent_id: agentIdFromPath }); if (threadIndex > -1) { if (threads[threadIndex + 1]) { - nextThreadPath = pathOfThread(threads[threadIndex + 1]!); + nextThreadPath = listPathOfThread(threads[threadIndex + 1]!); } else if (threads[threadIndex - 1]) { - nextThreadPath = pathOfThread(threads[threadIndex - 1]!); + nextThreadPath = listPathOfThread(threads[threadIndex - 1]!); } } void router.push(nextThreadPath); @@ -417,7 +420,9 @@ export function GeneralQaSidebar() { ) : ( filteredThreads.map((thread) => { - const isActive = pathOfThread(thread) === pathname; + const href = listPathOfThread(thread); + const isActive = href === pathname; + const isBoot = isBootstrapThread(thread); const pinned = isThreadPinned(thread); const favorited = isThreadFavorite(thread); return ( @@ -426,7 +431,7 @@ export function GeneralQaSidebar() { className="group/item relative flex items-center" > - + {isBoot ? ( + + ) : ( + + )} {pinned && ( )} diff --git a/frontend-web/src/components/workspace/recent-chat-list.tsx b/frontend-web/src/components/workspace/recent-chat-list.tsx index 702bcaa7..b996d0a3 100644 --- a/frontend-web/src/components/workspace/recent-chat-list.tsx +++ b/frontend-web/src/components/workspace/recent-chat-list.tsx @@ -1,6 +1,7 @@ "use client"; import { + Bot, ChevronDown, ChevronRight, Download, @@ -64,8 +65,10 @@ import { } from "@/core/threads/hooks"; import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import { + isBootstrapThread, isThreadFavorite, isThreadPinned, + listPathOfThread, pathOfThread, sortThreadsPinnedFirst, titleOfThread, @@ -130,9 +133,9 @@ export function RecentChatList({ }); if (threadIndex > -1) { if (threads[threadIndex + 1]) { - nextThreadPath = pathOfThread(threads[threadIndex + 1]!); + nextThreadPath = listPathOfThread(threads[threadIndex + 1]!); } else if (threads[threadIndex - 1]) { - nextThreadPath = pathOfThread(threads[threadIndex - 1]!); + nextThreadPath = listPathOfThread(threads[threadIndex - 1]!); } } void router.push(nextThreadPath); @@ -189,11 +192,12 @@ export function RecentChatList({ [t], ); + // 智能体创建(bootstrap)会话也展示在常用对话里(带机器人图标、点击回创建页); + // 仅排除调度 / 会商类系统会话。 const visibleThreads = sortThreadsPinnedFirst( threads.filter( (thread) => thread.metadata?.thread_type !== "scheduler" && - thread.metadata?.thread_type !== "bootstrap" && thread.metadata?.thread_type !== "roundtable", ), ); @@ -239,7 +243,9 @@ export function RecentChatList({
{shownThreads.map((thread) => { - const isActive = pathOfThread(thread) === pathname; + const href = listPathOfThread(thread); + const isActive = href === pathname; + const isBoot = isBootstrapThread(thread); const pinned = isThreadPinned(thread); const favorited = isThreadFavorite(thread); return ( @@ -261,17 +267,29 @@ export function RecentChatList({ > - {compact && ( - + ) : ( + compact && ( + + ) )} {pinned && ( diff --git a/frontend-web/src/core/auth/dashboard-input.ts b/frontend-web/src/core/auth/dashboard-input.ts index 145259b4..389c4fe6 100644 --- a/frontend-web/src/core/auth/dashboard-input.ts +++ b/frontend-web/src/core/auth/dashboard-input.ts @@ -18,6 +18,7 @@ import { requestCrudService } from "@/service/request-axios-service"; import { readCopTaskDetail } from "@/core/auth/task-deeplink"; +import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config"; /** 本页输入 JSON 暂存的 sessionStorage key(拉取一次后复用,避免重复请求)。 */ export const DASHBOARD_INPUT_STORAGE_KEY = "dashboard.inputJson"; @@ -221,3 +222,43 @@ export async function fetchDashboardInputJson( if (resolved) persistDashboardInputJson(resolved); return resolved; } + +/* ---------------------- rwfx 进入预检:3q 是否完成 ---------------------- */ + +/** + * 「3q(研判情报报告)是否完成」状态: + * - ``complete`` = selectSqReport 数组里有有效内容; + * - ``incomplete`` = 数组为空 / 每条 contentJson 都是 null/空(3q 未完成); + * - ``unknown`` = 缺 consumer 地址 / 请求异常(无法判定,调用方应静默放行)。 + */ +export type SqReportStatus = "complete" | "incomplete" | "unknown"; + +/** + * rwfx(任务分析)进入时的「3q 是否完成」预检:调 selectSqReport,复用 ``extractSqReport`` + * 判定该任务是否已有有效研判情报。判定口径与大屏绘制取数完全一致(空/全 null → 无内容)。 + * - 有可用内容 → ``complete``(正常); + * - 空 / 全 null → ``incomplete``(rwfx 据此弹框引导去 3qfx); + * - 缺地址 / 请求失败 → ``unknown``(不打扰用户)。 + * + * **数据源由后端配置 ``task_deeplink.test`` 决定**(线上 selectSqReport 外部不可用时): + * - ``test=true`` → 直接用 ``config.yaml`` 的 ``mock.sq_report`` 假数据判定,**不发请求** + * (与目的树查询/行动解析的 mock 同一套机制,见 [[task-deeplink-config]]); + * - ``test=false`` → 调真实接口 ``GET {VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=``。 + */ +export async function fetchSqReportStatus(taskId: string): Promise { + if (!taskId) return "unknown"; + const cfg = await fetchTaskDeeplinkConfig(); + // test=true:外部接口不可用,直接用后端 config 的假数据判定(不打真实接口)。 + if (cfg.test) { + return extractSqReport(cfg.mock.sqReport, taskId) ? "complete" : "incomplete"; + } + // 真实模式:打真实接口。service 拦截器统一注入鉴权并返回整包 body;空/全 null → null。 + const url = sqReportUrl(); + if (!url) return "unknown"; + try { + const body = await requestCrudService.get(url, { taskId }); + return extractSqReport(body, taskId) ? "complete" : "incomplete"; + } catch { + return "unknown"; + } +} diff --git a/frontend-web/src/core/auth/task-deeplink-config.ts b/frontend-web/src/core/auth/task-deeplink-config.ts index 86d5b716..be9b18ac 100644 --- a/frontend-web/src/core/auth/task-deeplink-config.ts +++ b/frontend-web/src/core/auth/task-deeplink-config.ts @@ -34,11 +34,13 @@ export interface TaskDeeplinkConfig { /** 「3q详情」详情页 URL(rwfx 目的树面板链接,跳转追加 ?id= 任务id);留空则前端提示未配置。 */ detail3qUrl: string; /** 测试假数据:purposeDetail = 原始 {state,msg,data};delete = {message,success}; - * actionDetail = 原始 {state,msg,data:{taskId,...}}。 */ + * actionDetail = 原始 {state,msg,data:{taskId,...}};sqReport = 原始 {state,msg,data:[...]} + * (rwfx 3q 预检用,test 为真时生效)。 */ mock: { purposeDetail?: unknown; delete?: { message?: string; success?: boolean }; actionDetail?: unknown; + sqReport?: unknown; }; } @@ -81,6 +83,7 @@ function normalize(body: unknown): TaskDeeplinkConfig { ? (mock.delete as { message?: string; success?: boolean }) : undefined, actionDetail: mock.action_detail, + sqReport: mock.sq_report, }, }; } diff --git a/frontend-web/src/core/threads/utils.ts b/frontend-web/src/core/threads/utils.ts index 7a3d8769..adc0ded4 100644 --- a/frontend-web/src/core/threads/utils.ts +++ b/frontend-web/src/core/threads/utils.ts @@ -81,6 +81,32 @@ export function titleOfThread(thread: AgentThread, fallback = "Untitled") { return fallback; } +/** + * 智能体创建过程产生的「引导会话」(bootstrap)。这类会话用 metadata.thread_type + * === "bootstrap" 标记,点击后应回到「创建智能体」页继续,而不是普通问答页。 + */ +export function isBootstrapThread(thread: { + metadata?: Record | null; +}): boolean { + return thread.metadata?.thread_type === "bootstrap"; +} + +/** + * bootstrap 会话点击后回到「创建智能体」页并恢复该会话。该页只挂在 + * /page/workspace 下(见 WorkspaceRoutes 的 agents/new/:thread_id),故路径固定, + * 不随当前 route base 变化。 + */ +export function bootstrapResumePath(threadId: string): string { + return `/page/workspace/agents/new/${threadId}`; +} + +/** 列表项点击目标:bootstrap 会话回创建页恢复,其余走普通问答路由。 */ +export function listPathOfThread(thread: AgentThread): string { + return isBootstrapThread(thread) + ? bootstrapResumePath(thread.thread_id) + : pathOfThread(thread); +} + export function isThreadPinned(thread: AgentThread): boolean { return (thread.metadata as Record | null)?.is_pinned === true; } diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index b8da2890..659507ba 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -469,6 +469,7 @@ export default function AgentChatPage({ disabled={thread.isLoading || isDemoMode} onAskQuestion={(text) => handleSubmit({ text, files: [] })} defaultCollapsed={taskCtxOn} + showRecentChats={!taskCtxOn} />
diff --git a/frontend-web/src/pages/NewAgentPage.tsx b/frontend-web/src/pages/NewAgentPage.tsx index 0906cd7c..bbe05b4d 100644 --- a/frontend-web/src/pages/NewAgentPage.tsx +++ b/frontend-web/src/pages/NewAgentPage.tsx @@ -7,7 +7,7 @@ import { SaveIcon, SearchIcon, } from "lucide-react"; -import { useRouter } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -76,7 +76,11 @@ function getCreateAgentErrorMessage( export default function NewAgentPage() { const { t } = useI18n(); const router = useRouter(); - const threadId = useMemo(() => uuid(), []); + // 从「常用对话」点击某条智能体创建(bootstrap)会话回到本页时,URL 带 thread_id, + // 用它恢复该会话(加载历史消息、继续对话);正常进入则新开一个会话。 + const { thread_id: resumeThreadId } = useParams<{ thread_id?: string }>(); + const generatedThreadId = useMemo(() => uuid(), []); + const threadId = resumeThreadId || generatedThreadId; const [nameInput, setNameInput] = useState(""); const [descriptionInput, setDescriptionInput] = useState(""); @@ -93,7 +97,8 @@ export default function NewAgentPage() { const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false); const [detailSkillName, setDetailSkillName] = useState(null); const [skillSearch, setSkillSearch] = useState(""); - const [threadPrepared, setThreadPrepared] = useState(false); + // 恢复已有 bootstrap 会话时该 thread 已存在,无需再 create(置为 true,跳过创建)。 + const [threadPrepared, setThreadPrepared] = useState(!!resumeThreadId); const { skills, isLoading: isSkillsLoading } = useSkills(); const { models, isLoading: isModelsLoading } = useModels(); diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index 4810ed01..66d5dce4 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -57,6 +57,12 @@ function NotebookDetailPageWithKey() { return ; } +// 恢复某条 bootstrap 会话时按 thread_id 重挂,切换会话不串台。 +function NewAgentPageWithKey() { + const { thread_id } = useParams<{ thread_id: string }>(); + return ; +} + function isInIframe(): boolean { try { return window.self !== window.top; @@ -279,6 +285,16 @@ export default function WorkspaceRoutes({ } /> + {/* 从「常用对话」点击智能体创建(bootstrap)会话回到创建页并恢复该会话。 + key 绑定 thread_id —— 切换不同 bootstrap 会话时重挂一个干净的页面。 */} + + + + } + /> } diff --git a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx index a8f699f0..2a3fb8c6 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx @@ -30,7 +30,13 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { deleteTaskPurpose, readTaskContext } from "@/core/auth/task-purpose"; -import { fetchActionDetail, readCopTaskDetail, type ActionDetail } from "@/core/auth/task-deeplink"; +import { + fetchActionDetail, + readCopTaskDetail, + resolveTaskDeeplinkTarget, + type ActionDetail, +} from "@/core/auth/task-deeplink"; +import { fetchSqReportStatus } from "@/core/auth/dashboard-input"; import { TASK_BUSINESSES, type TaskBusiness } from "@/core/auth/task-buttons"; import { useTaskThreads } from "@/core/threads/use-task-threads"; import { titleOfThread } from "@/core/threads/utils"; @@ -276,6 +282,79 @@ function TaskClearButton({ taskId }: { taskId: string }) { ); } +/** + * rwfx 专用「3q 完成预检」闸门:进入任务分析工作区时调研判情报报告接口 + * (``GET {VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=``)。若该任务的报告为空 + * (数组为空 / 内容全为 null,即 3q 未完成)→ 弹框提示「3q 未完成,是否进行 3qfx」, + * 并提供「前往 3qfx」按钮切换到**同一任务**的三情分析工作区(复用深链解析重绑任务上下文)。 + * 报告正常 / 接口不可判定(缺地址、请求失败)时不打扰用户。见 [[dashboard-input]] / [[task-deeplink]]。 + */ +function RwfxSqReportGate({ taskId }: { taskId: string }) { + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const [jumping, setJumping] = useState(false); + + useEffect(() => { + if (!taskId) return; + let alive = true; + void fetchSqReportStatus(taskId).then((status) => { + // 仅「明确为空(3q 未完成)」才弹框;complete / unknown 静默放行。 + if (alive && status === "incomplete") setOpen(true); + }); + return () => { + alive = false; + }; + }, [taskId]); + + const handleGo3qfx = async () => { + const detail = readCopTaskDetail(); + if (!detail || !taskId) { + toast.error("缺少任务信息,无法跳转"); + return; + } + setJumping(true); + try { + // 复用深链解析:会清掉当前 rwfx 任务上下文并按 3qfx 业务重新绑定(同一 taskId)。 + const target = await resolveTaskDeeplinkTarget("3qfx", detail, taskId); + if (target) { + setOpen(false); + navigate(target.path, { state: target.state }); + } else { + toast.error("未配置三情分析(3qfx)对应的业务智能体"); + } + } catch { + toast.error("跳转失败,请稍后再试"); + } finally { + setJumping(false); + } + }; + + return ( + !jumping && setOpen(o)}> + + + 3q 未完成 + + 未查询到该任务的研判情报报告(3q 未完成),是否前往该任务的三情分析(3qfx)? + + + + + + + + + ); +} + /** * 任务工作区布局:左列表 + 右 Outlet。 * ``lite``(3qfx/xdfx)= 精简版:左列表无跳转按钮、无底部任务操作面板。 @@ -305,6 +384,8 @@ export function TaskFxWorkspace({ + {/* rwfx 专属:进入时预检 3q 是否完成,未完成则弹框引导去 3qfx。 */} + {business === "rwfx" && } ); } diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index efe0577d..c6715a5e 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -393,7 +393,10 @@ query-time type, `recommended_question`. Sync only ever adds/removes `origin='position'` rows, so a user's own favorites/subscriptions are never clobbered. Default sets (no binding for a type): agents = built-ins (`user_id IS NULL`); skills = empty (square stays -full, nothing added to 我的); scheduled tasks = empty; questions = unrestricted. +full, nothing added to 我的); scheduled tasks = empty; questions = **untagged +only** (默认岗位 / 无标签绑定 只看到未打标的题目;打标的题目只在绑定了对应标签的 +岗位展示 — the router interprets `resolve_visible → None` as "show only untagged", +not "show everything"). `origin`/`position_id` columns were added to `agent_favorites` and `scheduled_task_subscriptions` — see migration `20260616_01` + `scripts/sql/column_additions_mysql.sql`. The running app also auto-adds these @@ -415,8 +418,12 @@ unassigned users inherit, else `null`); powers the top-bar 岗位 label changes: `GET /api/recommended-questions` is now filtered by the caller's position (`?all=1` bypasses for admin management) and admin can tag questions via `/api/tags/assignments/recommended_question/{id}`. -Each listed question now carries its assigned `tags` (bulk-attached via -`tag_store.list_assignments`), and the list accepts a repeatable `tag_ids` +**Default-position rule**: tags are bulk-attached to **all** questions first, +then the visibility filter applies — a position binding recommended_question 标签 +sees only those tagged questions; a 默认岗位 / 无绑定 (`resolve_visible → None`) +sees only the **untagged** questions (打标的题目不再泄漏到默认岗位). `?all=1` +(admin management) skips this filter and returns every question with its tags. +The list also accepts a repeatable `tag_ids` OR-filter — so the 推荐问题配置 page can display per-question 标签 and search by them (frontend `RecommendedQuestionsPage` + `TagSearchBar`). Tests: `tests/test_recommended_questions_tags.py`. diff --git a/offline-backend-20260512/backend/app/gateway/routers/recommended_questions.py b/offline-backend-20260512/backend/app/gateway/routers/recommended_questions.py index a267c901..cb0e5591 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/recommended_questions.py +++ b/offline-backend-20260512/backend/app/gateway/routers/recommended_questions.py @@ -67,24 +67,11 @@ async def list_recommended_questions( ) -> RecommendedQuestionsListResponse: store = _get_store(request) rows = await store.list_all() - if not all: - # Filter by the caller's 岗位 (position) visibility. ``None`` = - # unrestricted (no skill/question tag binding → show all default - # questions); a set = show only the questions carrying the bound tags. - sync = getattr(request.app.state, "position_sync", None) - if sync is not None: - try: - visible = await sync.resolve_visible(_current_user_id(request), "recommended_question") - except Exception: - logger.debug("recommended-question visibility resolve failed", exc_info=True) - visible = None - if visible is not None: - rows = [r for r in rows if r["id"] in visible] responses = [RecommendedQuestionResponse(**r) for r in rows] # Bulk-attach the tags assigned to each question so the management list can - # display them and filter by 标签. Best-effort: a missing/failing tag store - # never breaks the question list. + # display them and the 岗位 filter below can tell tagged from untagged. + # Best-effort: a missing/failing tag store never breaks the question list. tag_store = getattr(request.app.state, "tag_store", None) if tag_store is not None and responses: try: @@ -95,6 +82,24 @@ async def list_recommended_questions( for resp in responses: resp.tags = assignments.get(resp.id, []) + if not all: + # Filter by the caller's 岗位 (position) visibility: + # * ``set`` — the position binds recommended_question 标签 → show only + # the questions carrying those bound tags. + # * ``None`` — 默认岗位 / 无标签绑定 → show only **untagged** questions. + # 打标的题目只在绑定了对应标签的岗位展示,不再泄漏到默认岗位。 + sync = getattr(request.app.state, "position_sync", None) + if sync is not None: + try: + visible = await sync.resolve_visible(_current_user_id(request), "recommended_question") + except Exception: + logger.debug("recommended-question visibility resolve failed", exc_info=True) + visible = None + if visible is not None: + responses = [r for r in responses if r.id in visible] + else: + responses = [r for r in responses if not r.tags] + if tag_ids: wanted = set(tag_ids) responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/task_deeplink_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/task_deeplink_config.py index e5089c2a..535e8faf 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/task_deeplink_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/task_deeplink_config.py @@ -5,7 +5,8 @@ 随时改 ``config.yaml`` 即可(Gateway 有 mtime 热重载,无需重新构建前端)。 测试开关(线上接口未通时用假数据,互相独立): - - ``test`` → 目的树查询(``purpose_detail_url``)走 ``mock.purpose_detail`` 假数据。 + - ``test`` → 目的树查询(``purpose_detail_url``)走 ``mock.purpose_detail`` 假数据; + 同时 rwfx 进入「3q 是否完成」预检(``selectSqReport``,外部接口不可用)走 ``mock.sq_report`` 假数据。 - ``delete_test`` → 删除接口(``delete_url``)走 ``mock.delete`` 假数据(该接口尚未开发完时打开)。 - ``action_test`` → 行动详情接口(``action_detail_url``)走 ``mock.action_detail`` 假数据。 xdfx 深链传入的是「行动id」,需先调该接口拿 ``data.taskId`` 解析出真正的「任务id」。 @@ -25,7 +26,7 @@ class TaskDeeplinkConfig(BaseModel): test: bool = Field( default=False, - description="为真时前端目的树查询走 mock.purpose_detail 假数据(不打真实接口)", + description="为真时前端目的树查询走 mock.purpose_detail 假数据;同时 rwfx 进入「3q 是否完成」预检(selectSqReport)走 mock.sq_report 假数据(都不打真实接口)", ) delete_test: bool = Field( default=False, @@ -61,5 +62,5 @@ class TaskDeeplinkConfig(BaseModel): ) mock: dict[str, Any] = Field( default_factory=dict, - description="test/delete_test/action_test 为真时返回的假数据:{purpose_detail: {...}, delete: {message, success}, action_detail: {state, msg, data: {taskId, ...}}}", + description="test/delete_test/action_test 为真时返回的假数据:{purpose_detail: {...}, delete: {message, success}, action_detail: {state, msg, data: {taskId, ...}}, sq_report: {state, msg, data: [...]}}(sq_report 供 rwfx 3q 预检,test 为真时用)", ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/services/position_sync.py b/offline-backend-20260512/backend/packages/harness/deerflow/services/position_sync.py index bc3ee1e1..2a6e86e8 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/services/position_sync.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/services/position_sync.py @@ -16,8 +16,11 @@ Resolution rule (per resource type): - ``scheduled_task`` → empty (no built-in tasks) - ``skill`` → empty (the 广场 still shows every visible skill; a position only *adds* its tagged skills to the member's "我的技能") - - ``recommended_question`` → *unrestricted* (handled at query time, never - materialized — see :meth:`resolve_visible`) + - ``recommended_question`` → *untagged-only* (handled at query time, never + materialized — see :meth:`resolve_visible`). The default set is the + questions carrying **no** 标签; tagged questions surface only for a position + that binds their tag. (The recommended-questions router interprets a + ``None`` from :meth:`resolve_visible` as "show only untagged".) Materialization (``sync_user``) only ever touches rows it created itself (``origin='position'``), so a user's own favorites / subscriptions are never @@ -67,12 +70,14 @@ class PositionSyncService: return await self._positions.get_default_position_id() async def resolve_visible(self, user_id: str, resource_type: str) -> set[str] | None: - """Return the set of visible ``resource_type`` ids, or ``None`` = unrestricted. + """Return the set of visible ``resource_type`` ids, or ``None`` = no tag binding. Used by the recommended-questions filter (the only remaining query-time - visibility type; skills are now *materialized*, not filtered). ``None`` - means "no position restriction → show everything"; a set means "restrict - to exactly these ids". + visibility type; skills are now *materialized*, not filtered). A set + means "restrict to exactly these ids". ``None`` means the effective + position binds no tag for this type — the recommended-questions router + treats that as "默认岗位 → show only untagged questions" (see + ``app/gateway/routers/recommended_questions.py``). """ pid = await self._effective_position_id(user_id) if not pid: diff --git a/offline-backend-20260512/backend/tests/test_recommended_questions_tags.py b/offline-backend-20260512/backend/tests/test_recommended_questions_tags.py index f4d19e78..054d9225 100644 --- a/offline-backend-20260512/backend/tests/test_recommended_questions_tags.py +++ b/offline-backend-20260512/backend/tests/test_recommended_questions_tags.py @@ -77,3 +77,65 @@ async def test_list_without_tag_store_does_not_break() -> None: assert [q.id for q in resp.questions] == ["q1", "q2"] assert all(q.tags == [] for q in resp.questions) + + +class _FakeSync: + """Stub position-sync whose ``resolve_visible`` returns a fixed value. + + ``None`` mimics 默认岗位 / 无标签绑定; a set mimics a position that binds + recommended_question 标签. + """ + + def __init__(self, visible: set[str] | None) -> None: + self._visible = visible + + async def resolve_visible(self, _user_id, _resource_type): # noqa: ANN001 + return self._visible + + +@pytest.mark.asyncio +async def test_default_position_shows_only_untagged() -> None: + """默认岗位(resolve_visible → None)只看到未打标的题目。""" + tag_store = MemoryTagStore() + tag = await tag_store.create_tag( + {"id": "t1", "name": "理赔", "tag_type": "recommended_question"} + ) + await tag_store.set_target_tags("recommended_question", "q2", [tag["id"]]) + + req = _request(_FakeQuestionStore(_rows()), tag_store) + req.app.state.position_sync = _FakeSync(None) + resp = await rq_router.list_recommended_questions(req, all=False) + + assert [q.id for q in resp.questions] == ["q1"] + + +@pytest.mark.asyncio +async def test_bound_position_shows_only_its_tagged_questions() -> None: + """绑定了标签的岗位(resolve_visible → set)只看到对应打标的题目。""" + tag_store = MemoryTagStore() + tag = await tag_store.create_tag( + {"id": "t1", "name": "理赔", "tag_type": "recommended_question"} + ) + await tag_store.set_target_tags("recommended_question", "q2", [tag["id"]]) + + req = _request(_FakeQuestionStore(_rows()), tag_store) + req.app.state.position_sync = _FakeSync({"q2"}) + resp = await rq_router.list_recommended_questions(req, all=False) + + assert [q.id for q in resp.questions] == ["q2"] + + +@pytest.mark.asyncio +async def test_admin_view_ignores_position_filter() -> None: + """``all=True`` 管理视图照旧返回全部(含打标),不受岗位过滤影响。""" + tag_store = MemoryTagStore() + tag = await tag_store.create_tag( + {"id": "t1", "name": "理赔", "tag_type": "recommended_question"} + ) + await tag_store.set_target_tags("recommended_question", "q2", [tag["id"]]) + + req = _request(_FakeQuestionStore(_rows()), tag_store) + req.app.state.position_sync = _FakeSync(None) + resp = await rq_router.list_recommended_questions(req, all=True) + + assert [q.id for q in resp.questions] == ["q1", "q2"] diff --git a/offline-backend-20260512/config.yaml b/offline-backend-20260512/config.yaml index 5c70686c..3fd5da8e 100644 --- a/offline-backend-20260512/config.yaml +++ b/offline-backend-20260512/config.yaml @@ -331,7 +331,7 @@ summarization: # ?taskId= / ?id=),随时改这里即可(Gateway mtime 热重载,前端无需重建)。 task_deeplink: # 线上接口未通时用假数据;三个开关互相独立。 - test: true # 目的树查询走 mock.purpose_detail 假数据 + test: true # 目的树查询走 mock.purpose_detail 假数据;同时 rwfx 进入「3q 是否完成」预检走 mock.sq_report 假数据 delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完) action_test: true # 行动→任务解析走 mock.action_detail 假数据(xdfx 行动id→任务id) purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail" @@ -382,3 +382,15 @@ task_deeplink: actionName: "" actionStatus: 25 narration: "" + # test=true 时 rwfx 进入「3q 是否完成」预检(selectSqReport,外部不可用)走这份假数据。 + # 结构同真实 GET /consumer/cop/selectSqReport?taskId= 返回;前端判定 data 为空 / 每条 + # contentJson 都是 null/空 = 「3q 未完成」→ 弹框引导去 3qfx;否则视为已完成(正常,不弹框)。 + # 默认给一条有内容的数据 = 已完成(不打扰)。要测「3q 未完成」弹框:把下面 data 改成 []。 + sq_report: + state: "200" + msg: "操作成功!" + data: + - id: "sq-mock-1" + taskId: 3338 + categoryType: "敌情关键事件 JSON" + contentJson: "[{\"topic\":\"示例研判事件\",\"count\":1}]" From 6b480fdbe97d608fe42b9fa5eb2c73e0f9dd016f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Mon, 22 Jun 2026 17:17:54 +0800 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=8A=80?= =?UTF-8?q?=E8=83=BD=E5=9C=B0=E5=9D=80=E7=AE=A1=E7=90=86=E4=B8=8E=E5=BC=80?= =?UTF-8?q?=E6=94=BE=E9=97=AE=E7=AD=94=E6=8E=A5=E5=8F=A3=EF=BC=8C=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E4=BB=BB=E5=8A=A1=E5=B7=A5=E4=BD=9C=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增「技能地址管理」页面与 /api/skill-addresses 路由(扫描/预览/批量替换技能内地址) - 完善 /api/open/chat 开放问答接口与模型工厂、网关配置 - 调整任务工作区、侧边栏与路由 - 将本地 0622/ 临时目录与 __pycache__/ 加入忽略,不提交 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 6 + CLAUDE.md | 1 + frontend-web/src/components/page-sidebar.tsx | 15 + frontend-web/src/pages/SkillAddressesPage.tsx | 522 +++++++++++++++ frontend-web/src/pages/StrategyRoutes.tsx | 11 + .../api/skill-addresses.ts | 272 ++++++++ .../task-components/TaskFxWorkspace.tsx | 5 +- offline-backend-20260512/backend/CLAUDE.md | 41 +- .../backend/app/gateway/app.py | 4 + .../backend/app/gateway/routers/open_chat.py | 133 +++- .../app/gateway/routers/skill_addresses.py | 631 ++++++++++++++++++ offline-backend-20260512/backend/config.yaml | 6 + .../harness/deerflow/models/factory.py | 8 +- offline-backend-20260512/config.yaml | 6 + test_open_chat_api.py | 128 +++- 15 files changed, 1749 insertions(+), 40 deletions(-) create mode 100644 frontend-web/src/pages/SkillAddressesPage.tsx create mode 100644 frontend-web/src/strategy-components/api/skill-addresses.ts create mode 100644 offline-backend-20260512/backend/app/gateway/routers/skill_addresses.py diff --git a/.gitignore b/.gitignore index de6dbc5f..0b5f0dfe 100644 --- a/.gitignore +++ b/.gitignore @@ -104,3 +104,9 @@ docs/backups # uv lockfile (not tracked per project preference) offline-backend-20260512/backend/uv.lock + +# Local-only scratch directory (not committed) +0622/ + +# Python bytecode cache +__pycache__/ diff --git a/CLAUDE.md b/CLAUDE.md index f2dd1701..16f0ef3b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes). - **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). - **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels. +- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。页面 `pages/SkillAddressesPage.tsx`(route `/page/strategy/admin/skill-addresses`,入口「技能地址管理」在 `components/page-sidebar.tsx` 左下角设置下拉,full+collapsed 两种模式):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**撤销上次**(回滚)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。 - **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. - **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes` - **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index d01d6d6e..92b4eaa8 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -9,6 +9,7 @@ import { FolderOpen, ListTree, MousePointerClick, + Replace, Settings, ShieldAlert, User, @@ -222,6 +223,14 @@ function SecondaryNavSidebar() { 按钮管理 )} + {isAdmin && ( + navigate("/page/strategy/admin/skill-addresses")} + > + + 技能地址管理 + + )} {canManageSensitiveWords() && ( navigate("/page/strategy/admin/sensitive-words")} @@ -508,6 +517,12 @@ function LegacyAccordionSidebar({ 按钮管理 )} + {isAdmin && ( + navigate("/page/strategy/admin/skill-addresses")}> + + 技能地址管理 + + )} {canManageSensitiveWords() && ( navigate("/page/strategy/admin/sensitive-words")}> diff --git a/frontend-web/src/pages/SkillAddressesPage.tsx b/frontend-web/src/pages/SkillAddressesPage.tsx new file mode 100644 index 00000000..b1cc1642 --- /dev/null +++ b/frontend-web/src/pages/SkillAddressesPage.tsx @@ -0,0 +1,522 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Navigate } from "react-router-dom"; +import { + AlertTriangle, + ChevronDown, + ChevronRight, + Eye, + FileCode, + Globe, + Info, + Loader2, + Network, + Replace, + ScanLine, + Search, + Undo2, + Wand2, +} from "lucide-react"; +import { toast } from "sonner"; + +import { getStoredAuth } from "@/core/auth"; +import { + type AddressGroup, + type AddressKind, + type ApplyProgress, + type PreviewResult, + type Replacement, + type ScanProgress, + type ScanResult, + applyReplacementsStream, + previewReplacements, + rollbackBackup, + scanAddressesStream, + useSkillAddressBackups, +} from "@/strategy-components/api/skill-addresses"; + +const ALL_KINDS: AddressKind[] = ["url", "ip"]; + +function ProgressBar({ done, total, label }: { done: number; total: number; label: string }) { + const pct = total > 0 ? Math.round((done / total) * 100) : 0; + return ( +
+
+ + + {label} + + + {done} / {total}({pct}%) + +
+
+
+
+
+ ); +} + +function KindBadge({ kind }: { kind: AddressKind }) { + const isUrl = kind === "url"; + return ( + + {isUrl ? : } + {isUrl ? "URL" : "IP"} + + ); +} + +export default function SkillAddressesPage() { + const isAdmin = getStoredAuth()?.system_role === "admin"; + + const [kinds, setKinds] = useState(ALL_KINDS); + const [scan, setScan] = useState(null); + const [scanning, setScanning] = useState(false); + const [scanProg, setScanProg] = useState(null); + + const [edits, setEdits] = useState>({}); + const [query, setQuery] = useState(""); + const [expanded, setExpanded] = useState>(new Set()); + + const [preview, setPreview] = useState(null); + const [applying, setApplying] = useState(false); + const [applyProg, setApplyProg] = useState(null); + const [lastBackupId, setLastBackupId] = useState(null); + + const [segFind, setSegFind] = useState(""); + const [segRepl, setSegRepl] = useState(""); + + const { data: backups = [], refetch: refetchBackups } = useSkillAddressBackups(); + + const runScan = useCallback( + async (useKinds: AddressKind[]) => { + setScanning(true); + setScanProg({ done: 0, total: 0 }); + setPreview(null); + try { + const result = await scanAddressesStream({ kinds: useKinds, onProgress: setScanProg }); + setScan(result); + } catch (err) { + toast.error(err instanceof Error ? err.message : "扫描失败"); + } finally { + setScanning(false); + setScanProg(null); + } + }, + [], + ); + + // 首次进入自动扫描。 + useEffect(() => { + if (isAdmin) void runScan(ALL_KINDS); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAdmin]); + + const groups = scan?.groups ?? []; + + const toggleKind = (k: AddressKind) => { + const next = kinds.includes(k) ? kinds.filter((x) => x !== k) : [...kinds, k]; + if (next.length === 0) return; // 至少保留一种 + setKinds(next); + void runScan(next); + }; + + const setEdit = (addr: string, val: string) => setEdits((p) => ({ ...p, [addr]: val })); + + const toggleExpand = (addr: string) => + setExpanded((p) => { + const n = new Set(p); + if (n.has(addr)) n.delete(addr); + else n.add(addr); + return n; + }); + + /** 片段批量套用:把所有地址里出现的「查找片段」整体替换为「替换片段」,填入各行的「替换为」。 */ + const applySegment = () => { + const find = segFind.trim(); + if (!find) { + toast.error("请先填写要查找的片段(如 uat.4.cn)"); + return; + } + let n = 0; + setEdits((prev) => { + const next = { ...prev }; + for (const g of groups) { + if (g.address.includes(find)) { + const replaced = g.address.split(find).join(segRepl); + if (replaced !== g.address) { + next[g.address] = replaced; + n++; + } + } + } + return next; + }); + if (n === 0) toast.info(`没有地址包含片段「${find}」`); + else toast.success(`已为 ${n} 个地址套用替换(请预览后应用)`); + }; + + const buildReplacements = useCallback((): Replacement[] => { + const out: Replacement[] = []; + for (const g of groups) { + const to = (edits[g.address] ?? "").trim(); + if (to && to !== g.address) out.push({ from: g.address, to, kind: g.kind }); + } + return out; + }, [groups, edits]); + + const pendingCount = useMemo(() => buildReplacements().length, [buildReplacements]); + + const runPreview = async () => { + const repls = buildReplacements(); + if (repls.length === 0) { + toast.error("还没有填写任何「替换为」"); + return; + } + try { + const result = await previewReplacements(repls); + setPreview(result); + if (result.total === 0) toast.info("没有任何文件会被改动(请检查填写的替换值)"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "预览失败"); + } + }; + + const runApply = async () => { + if (!preview) return; + const repls = buildReplacements(); + if (repls.length === 0) return; + setApplying(true); + setApplyProg({ done: 0, totalFiles: 0 }); + try { + const result = await applyReplacementsStream(repls, { + expectedTotal: preview.expectedTotal, + backup: true, + onProgress: setApplyProg, + }); + toast.success(`已修改 ${result.changedFiles.length} 个文件,共替换 ${result.replacedTotal} 处`); + setLastBackupId(result.backupId); + setEdits({}); + setPreview(null); + await refetchBackups(); + await runScan(kinds); + } catch (err) { + toast.error(err instanceof Error ? err.message : "应用失败"); + } finally { + setApplying(false); + setApplyProg(null); + } + }; + + const runRollback = async () => { + const id = lastBackupId ?? backups[0]?.backupId; + if (!id) { + toast.error("没有可撤销的备份"); + return; + } + try { + const { restored } = await rollbackBackup(id); + toast.success(`已从备份恢复 ${restored} 个文件`); + setLastBackupId(null); + await refetchBackups(); + await runScan(kinds); + } catch (err) { + toast.error(err instanceof Error ? err.message : "撤销失败"); + } + }; + + const q = query.trim().toLowerCase(); + const visible = useMemo( + () => (!q ? groups : groups.filter((g) => g.address.toLowerCase().includes(q) || g.occurrences.some((o) => o.skill.toLowerCase().includes(q)))), + [groups, q], + ); + + const totalOccurrences = useMemo(() => groups.reduce((s, g) => s + g.count, 0), [groups]); + const canRollback = !!(lastBackupId || backups.length > 0); + + if (!isAdmin) return ; + + return ( +
+
+ {/* 顶部信息卡 */} +
+
+ +
+
+
+
+ + + 技能维护工具 + +

+ 技能地址管理 + + {groups.length} 地址 / {totalOccurrences} 处 + {scan ? ` / ${scan.scannedSkills} 技能` : ""} + +

+
+ +
+ + +
+
+ +

+ + + 扫描运行中的技能(skills/public 与{" "} + skills/custom 下所有 .md / .py),按唯一地址聚合其全部出现位置(跨 .md 与 .py)。填写「替换为」后预览命中再应用;应用前会自动磁盘备份,可一键撤销。替换为精确整串匹配,不会误伤子串。 + +

+
+
+ + {/* 进度条 */} + {scanning && scanProg && ( +
+ +
+ )} + {applying && applyProg && ( +
+ +
+ )} + + {/* 工具栏:类型过滤 + 搜索 + 片段批量套用 */} +
+
+
+ {ALL_KINDS.map((k) => ( + + ))} +
+
+ + setQuery(e.target.value)} + className="w-full rounded-xl border border-slate-200 bg-slate-50/50 py-2 pl-10 pr-4 text-sm transition-all hover:border-slate-300 focus:border-blue-500 focus:outline-none focus:ring-4 focus:ring-blue-500/5" + /> +
+
+ + {/* 片段批量套用 */} +
+ + + 片段批量套用 + + setSegFind(e.target.value)} + className="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-3 py-1.5 font-mono text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/10" + /> + → + setSegRepl(e.target.value)} + className="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-3 py-1.5 font-mono text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/10" + /> + +
+
+ + {/* 预览面板 */} + {preview && ( +
+
+
+ 预览:将改动 {preview.total} 处 + ({preview.items.filter((i) => i.count > 0).length} 个地址命中) +
+ +
+ {preview.warnings.length > 0 && ( +
+ {preview.warnings.map((w, i) => ( +
+ + {w} +
+ ))} +
+ )} +
    + {preview.items.map((it) => ( +
  • +
    + + {it.from} + → + {it.to} + {it.count} 处 +
    +
  • + ))} +
+
+ )} + + {/* 地址列表 */} +
+
+ 地址({visible.length}) + {pendingCount > 0 && ( + + )} +
+ + {scanning && !scan ? ( +
扫描中…
+ ) : visible.length === 0 ? ( +
+ {groups.length === 0 ? "未发现任何 URL / IP 地址" : "没有匹配的地址"} +
+ ) : ( +
    + {visible.map((g) => ( + toggleExpand(g.address)} + onChange={(v) => setEdit(g.address, v)} + /> + ))} +
+ )} +
+
+
+ ); +} + +function GroupRow({ + group, + value, + expanded, + onToggle, + onChange, +}: { + group: AddressGroup; + value: string; + expanded: boolean; + onToggle: () => void; + onChange: (v: string) => void; +}) { + const dirty = value.trim() !== "" && value.trim() !== group.address; + return ( +
  • +
    +
    + + + + {group.address} + + {group.count} 处 +
    + onChange(e.target.value)} + className="w-full rounded-lg border border-slate-200 bg-white px-3 py-1.5 font-mono text-sm transition-all focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/10 lg:w-96" + /> +
    + + {expanded && ( +
      + {group.occurrences.map((o, i) => ( +
    • + +
      + {o.skill} + · + + {o.file}:{o.line} + +
      + {o.snippet} +
      +
      +
    • + ))} +
    + )} +
  • + ); +} diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 0bab56b0..44b0883d 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -40,6 +40,7 @@ import LightAppListPage from "./LightAppListPage"; import LightAppManagementPage from "./LightAppManagementPage"; import MenuManagementPage from "./MenuManagementPage"; import SensitiveWordsPage from "./SensitiveWordsPage"; +import SkillAddressesPage from "./SkillAddressesPage"; import TaskButtonsPage from "./TaskButtonsPage"; import LightAppNewNotePage from "./LightAppNewNotePage"; import LightAppNoteListPage from "./LightAppNoteListPage"; @@ -393,6 +394,16 @@ export default function StrategyRoutes() { ) } /> + + ) : ( + + ) + } + /> `${getBackendBaseURL()}/api/skill-addresses`; + +export type AddressKind = "url" | "ip"; + +export interface AddressOccurrence { + skill: string; + category: string; // "public" | "custom" + file: string; // 相对技能根的 POSIX 路径 + line: number; + col: number; + snippet: string; +} + +export interface AddressGroup { + address: string; + kind: AddressKind; + count: number; + occurrences: AddressOccurrence[]; +} + +export interface ScanResult { + groups: AddressGroup[]; + scannedFiles: number; + scannedSkills: number; + kinds: string[]; + scannedAt: string; +} + +export interface Replacement { + from: string; + to: string; + kind?: AddressKind; +} + +export interface PreviewFileHit { + file: string; + line: number; + before: string; + after: string; +} + +export interface PreviewItem { + from: string; + to: string; + kind: AddressKind; + count: number; + files: PreviewFileHit[]; +} + +export interface PreviewResult { + items: PreviewItem[]; + total: number; + warnings: string[]; + expectedTotal: number; +} + +export interface ChangedFile { + file: string; + count: number; +} + +export interface ApplyResult { + changedFiles: ChangedFile[]; + replacedTotal: number; + backupId: string | null; + warnings: string[]; +} + +export interface BackupEntry { + backupId: string; + files: number; + createdAt: string; +} + +/** 扫描进度(逐技能)。 */ +export interface ScanProgress { + done: number; + total: number; + skill?: string; +} + +/** 应用进度(逐文件)。 */ +export interface ApplyProgress { + done: number; + totalFiles: number; + file?: string; +} + +// ── 错误处理 ─────────────────────────────────────────────────────────────── + +async function ensureOk(res: Response, ctx: string): Promise { + if (res.ok) return; + const data = await res.json().catch(() => null as unknown); + const detail = + data && typeof data === "object" && "detail" in data + ? String((data as { detail: unknown }).detail) + : `${ctx} 失败 (${res.status})`; + throw new Error(detail); +} + +async function readJson(res: Response, ctx: string): Promise { + await ensureOk(res, ctx); + return (await res.json()) as T; +} + +// ── 序列化辅助 ───────────────────────────────────────────────────────────── + +function toScanResult(d: any): ScanResult { + return { + groups: (d.groups ?? []) as AddressGroup[], + scannedFiles: d.scanned_files ?? 0, + scannedSkills: d.scanned_skills ?? 0, + kinds: d.kinds ?? [], + scannedAt: d.scanned_at ?? "", + }; +} + +function toPreviewResult(d: any): PreviewResult { + return { + items: (d.items ?? []).map((i: any) => ({ + from: i.from, + to: i.to, + kind: i.kind, + count: i.count ?? 0, + files: i.files ?? [], + })), + total: d.total ?? 0, + warnings: d.warnings ?? [], + expectedTotal: d.expected_total ?? 0, + }; +} + +function toApplyResult(d: any): ApplyResult { + return { + changedFiles: (d.changed_files ?? []).map((c: any) => ({ file: c.file, count: c.count })), + replacedTotal: d.replaced_total ?? 0, + backupId: d.backup_id ?? null, + warnings: d.warnings ?? [], + }; +} + +// ── NDJSON 流式读取 ──────────────────────────────────────────────────────── + +/** 逐行解析 NDJSON 流,对每个事件对象调用 onEvent。 */ +async function readNdjson(res: Response, ctx: string, onEvent: (ev: any) => void): Promise { + await ensureOk(res, ctx); + if (!res.body) throw new Error(`${ctx}:响应无数据流`); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + const flush = (chunk: string) => { + buf += chunk; + let idx: number; + while ((idx = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, idx).trim(); + buf = buf.slice(idx + 1); + if (line) onEvent(JSON.parse(line)); + } + }; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + flush(decoder.decode(value, { stream: true })); + } + const tail = (buf + decoder.decode()).trim(); + if (tail) onEvent(JSON.parse(tail)); +} + +// ── 端点 ─────────────────────────────────────────────────────────────────── + +/** 一次性扫描(兜底,无进度)。 */ +export async function scanAddresses(kinds?: AddressKind[], skill?: string): Promise { + const params = new URLSearchParams(); + if (kinds && kinds.length) params.set("kinds", kinds.join(",")); + if (skill) params.set("skill", skill); + const qs = params.toString(); + const res = await apiFetch(`${BASE()}/scan${qs ? `?${qs}` : ""}`, { method: "GET" }); + return toScanResult(await readJson(res, "扫描")); +} + +/** 流式扫描:逐技能上报进度,返回最终聚合结果。 */ +export async function scanAddressesStream( + opts: { kinds?: AddressKind[]; skill?: string; onProgress?: (p: ScanProgress) => void } = {}, +): Promise { + const params = new URLSearchParams(); + if (opts.kinds && opts.kinds.length) params.set("kinds", opts.kinds.join(",")); + if (opts.skill) params.set("skill", opts.skill); + const qs = params.toString(); + const res = await apiFetch(`${BASE()}/scan/stream${qs ? `?${qs}` : ""}`, { method: "GET" }); + let result: ScanResult | null = null; + await readNdjson(res, "扫描", (ev) => { + if (ev.type === "start") opts.onProgress?.({ done: 0, total: ev.total ?? 0 }); + else if (ev.type === "progress") opts.onProgress?.({ done: ev.done, total: ev.total, skill: ev.skill }); + else if (ev.type === "result") result = toScanResult(ev); + }); + if (!result) throw new Error("扫描未返回结果"); + return result; +} + +/** 干跑预览。 */ +export async function previewReplacements(replacements: Replacement[]): Promise { + const res = await apiFetch(`${BASE()}/preview`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ replacements }), + }); + return toPreviewResult(await readJson(res, "预览")); +} + +/** 流式应用:逐文件上报进度,返回最终结果。`expectedTotal` 做乐观锁。 */ +export async function applyReplacementsStream( + replacements: Replacement[], + opts: { expectedTotal?: number; backup?: boolean; onProgress?: (p: ApplyProgress) => void } = {}, +): Promise { + const body = { + replacements, + expected_total: opts.expectedTotal ?? null, + backup: opts.backup ?? true, + }; + const res = await apiFetch(`${BASE()}/apply/stream`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + let result: ApplyResult | null = null; + await readNdjson(res, "应用", (ev) => { + if (ev.type === "start") opts.onProgress?.({ done: 0, totalFiles: ev.total_files ?? 0 }); + else if (ev.type === "progress") opts.onProgress?.({ done: ev.done, totalFiles: ev.total_files, file: ev.file }); + else if (ev.type === "result") result = toApplyResult(ev); + }); + if (!result) throw new Error("应用未返回结果"); + return result; +} + +export async function listBackups(): Promise { + const res = await apiFetch(`${BASE()}/backups`, { method: "GET" }); + const data = await readJson<{ backups: any[] }>(res, "读取备份"); + return (data.backups ?? []).map((b) => ({ backupId: b.backup_id, files: b.files ?? 0, createdAt: b.created_at ?? "" })); +} + +export async function rollbackBackup(backupId: string): Promise<{ restored: number }> { + const res = await apiFetch(`${BASE()}/rollback/${encodeURIComponent(backupId)}`, { method: "POST" }); + const data = await readJson<{ restored_files: string[] }>(res, "撤销"); + return { restored: (data.restored_files ?? []).length }; +} + +// ── React Query ──────────────────────────────────────────────────────────── + +export const SKILL_ADDRESS_BACKUPS_KEY = "skill-address-backups"; + +export function useSkillAddressBackups() { + return useQuery({ + queryKey: [SKILL_ADDRESS_BACKUPS_KEY], + queryFn: listBackups, + refetchOnWindowFocus: false, + staleTime: 30_000, + }); +} diff --git a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx index 2a3fb8c6..f21ba0b1 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx @@ -343,7 +343,10 @@ function RwfxSqReportGate({ taskId }: { taskId: string }) { 暂不
    )} - - + + + + + + + ); diff --git a/frontend-web/src/pages/SkillAddressesPage.tsx b/frontend-web/src/pages/SkillAddressesPage.tsx index b1cc1642..4fefa80e 100644 --- a/frontend-web/src/pages/SkillAddressesPage.tsx +++ b/frontend-web/src/pages/SkillAddressesPage.tsx @@ -72,7 +72,7 @@ function KindBadge({ kind }: { kind: AddressKind }) { ); } -export default function SkillAddressesPage() { +export function SkillAddressesPanel() { const isAdmin = getStoredAuth()?.system_role === "admin"; const [kinds, setKinds] = useState(ALL_KINDS); @@ -462,6 +462,10 @@ export default function SkillAddressesPage() { ); } +export default function SkillAddressesPage() { + return ; +} + function GroupRow({ group, value, diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index df32030d..d233650f 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -556,9 +556,14 @@ snippet) and replaced together. `{skills_root}/.address-edits/{timestamp}/` (the `.`-prefixed dir is excluded from scans), then `refresh_skills_system_prompt_cache_async()` so edits take effect immediately. Registered in `app.py` after `sensitive_words.router`. -- **Frontend** — admin page `pages/SkillAddressesPage.tsx` (route - `/page/strategy/admin/skill-addresses`, entry「技能地址管理」in the bottom-left - settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes): +- **Frontend** — the panel is the reusable `SkillAddressesPanel` (exported from + `pages/SkillAddressesPage.tsx`; its default export keeps the standalone route + `/page/strategy/admin/skill-addresses` as a direct-link fallback). It is + **embedded as a「技能地址管理」tab inside the admin「技能管理」page** + `pages/AdminSkillsPage.tsx` (Tabs: 技能列表 + 技能地址管理; reached from the + 「技能管理」item in the「设置和更多」dropdown `workspace-nav-menu.tsx` → + `/page/workspace/admin/skills`) — it is no longer a separate entry in the + `components/page-sidebar.tsx` settings dropdown. The panel: auto-scan on open with a **progress bar** (扫描逐技能 / 应用逐文件 X/N), URL/IP type filter, search, a「片段批量套用」helper (e.g. all `uat.4.cn` → `prod.4.cn`), per-address「替换为」input with expandable occurrences, inline 预览 panel, 应用, From 7487395e448594db239bd1e81d1735ab3c599998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Mon, 22 Jun 2026 17:55:44 +0800 Subject: [PATCH 7/9] =?UTF-8?q?feat(skill-addresses):=20=E5=9C=B0=E5=9D=80?= =?UTF-8?q?=E6=A3=80=E7=B4=A2=E5=8D=87=E7=BA=A7=E4=B8=BA=E5=A4=9A=E5=85=B3?= =?UTF-8?q?=E9=94=AE=E8=AF=8D=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 空格分隔多个关键词,全部命中(地址或技能名任一包含)才显示,便于在大量地址中收窄 - 搜索框加清除按钮 + 更清晰的占位提示 - 过滤时列表头显示「命中 / 总数」 Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend-web/src/pages/SkillAddressesPage.tsx | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/frontend-web/src/pages/SkillAddressesPage.tsx b/frontend-web/src/pages/SkillAddressesPage.tsx index 4fefa80e..3fa96662 100644 --- a/frontend-web/src/pages/SkillAddressesPage.tsx +++ b/frontend-web/src/pages/SkillAddressesPage.tsx @@ -17,6 +17,7 @@ import { Search, Undo2, Wand2, + X, } from "lucide-react"; import { toast } from "sonner"; @@ -231,11 +232,19 @@ export function SkillAddressesPanel() { } }; - const q = query.trim().toLowerCase(); - const visible = useMemo( - () => (!q ? groups : groups.filter((g) => g.address.toLowerCase().includes(q) || g.occurrences.some((o) => o.skill.toLowerCase().includes(q)))), - [groups, q], + // 多关键词检索:空格分隔,每个关键词都要命中(地址或技能名任一包含)才显示。 + // 这样「uat 4.cn」能同时收窄到既含 uat 又含 4.cn 的地址,应对地址过多的场景。 + const tokens = useMemo( + () => query.trim().toLowerCase().split(/\s+/).filter(Boolean), + [query], ); + const visible = useMemo(() => { + if (tokens.length === 0) return groups; + return groups.filter((g) => { + const haystack = `${g.address} ${g.occurrences.map((o) => o.skill).join(" ")}`.toLowerCase(); + return tokens.every((t) => haystack.includes(t)); + }); + }, [groups, tokens]); const totalOccurrences = useMemo(() => groups.reduce((s, g) => s + g.count, 0), [groups]); const canRollback = !!(lastBackupId || backups.length > 0); @@ -332,15 +341,25 @@ export function SkillAddressesPanel() { ))} -
    +
    setQuery(e.target.value)} - className="w-full rounded-xl border border-slate-200 bg-slate-50/50 py-2 pl-10 pr-4 text-sm transition-all hover:border-slate-300 focus:border-blue-500 focus:outline-none focus:ring-4 focus:ring-blue-500/5" + className="w-full rounded-xl border border-slate-200 bg-slate-50/50 py-2 pl-10 pr-9 text-sm transition-all hover:border-slate-300 focus:border-blue-500 focus:outline-none focus:ring-4 focus:ring-blue-500/5" /> + {query && ( + + )}
    @@ -422,7 +441,7 @@ export function SkillAddressesPanel() { {/* 地址列表 */}
    - 地址({visible.length}) + 地址({visible.length}{tokens.length > 0 ? ` / ${groups.length}` : ""}) {pendingCount > 0 && (
    @@ -320,6 +335,101 @@ export function SkillAddressesPanel() {
    )} + {/* 修改记录:每次应用一条,可针对单次回退 */} + {showHistory && ( +
    +
    + + + 修改记录({backups.length}) + + +
    + {backups.length === 0 ? ( +
    + 暂无修改记录。应用一次替换后,这里会生成一条可回退的记录。 +
    + ) : ( +
      + {backups.map((b) => ( +
    • +
      +
      +
      + {fmtTime(b.createdAt)} + {b.backupId === lastBackupId && ( + 最近 + )} + + {b.files} 文件 / {b.replacedTotal} 处 + +
      + {b.replacements.length > 0 ? ( +
      + {b.replacements.slice(0, 4).map((r, i) => ( + + {r.from} + → + {r.to} + + ))} + {b.replacements.length > 4 && ( + 等 {b.replacements.length} 项 + )} +
      + ) : ( +
      {b.backupId}
      + )} +
      + +
      + {confirmRollbackId === b.backupId ? ( + <> + + + + ) : ( + + )} +
      +
      +
    • + ))} +
    + )} +
    + )} + {/* 工具栏:类型过滤 + 搜索 + 片段批量套用 */}
    diff --git a/frontend-web/src/strategy-components/api/skill-addresses.ts b/frontend-web/src/strategy-components/api/skill-addresses.ts index d52582a0..fafc387d 100644 --- a/frontend-web/src/strategy-components/api/skill-addresses.ts +++ b/frontend-web/src/strategy-components/api/skill-addresses.ts @@ -80,10 +80,18 @@ export interface ApplyResult { warnings: string[]; } +export interface BackupReplacement { + from: string; + to: string; + kind: AddressKind; +} + export interface BackupEntry { backupId: string; files: number; createdAt: string; + replacedTotal: number; + replacements: BackupReplacement[]; } /** 扫描进度(逐技能)。 */ @@ -249,7 +257,13 @@ export async function applyReplacementsStream( export async function listBackups(): Promise { const res = await apiFetch(`${BASE()}/backups`, { method: "GET" }); const data = await readJson<{ backups: any[] }>(res, "读取备份"); - return (data.backups ?? []).map((b) => ({ backupId: b.backup_id, files: b.files ?? 0, createdAt: b.created_at ?? "" })); + return (data.backups ?? []).map((b) => ({ + backupId: b.backup_id, + files: b.files ?? 0, + createdAt: b.created_at ?? "", + replacedTotal: b.replaced_total ?? 0, + replacements: (b.replacements ?? []).map((r: any) => ({ from: r.from, to: r.to, kind: r.kind })), + })); } export async function rollbackBackup(backupId: string): Promise<{ restored: number }> { diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index d233650f..e47e3ca8 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -556,6 +556,14 @@ snippet) and replaced together. `{skills_root}/.address-edits/{timestamp}/` (the `.`-prefixed dir is excluded from scans), then `refresh_skills_system_prompt_cache_async()` so edits take effect immediately. Registered in `app.py` after `sensitive_words.router`. + **Per-edit history:** each apply also writes a sibling manifest + `{skills_root}/.address-edits/{timestamp}.manifest.json` (best-effort) capturing + `{created_at, replaced_total, replacements:[{from,to,kind}], changed_files}`; the + sibling-not-child placement keeps it out of both the restore set and the file + count. `GET /backups` returns each record enriched with `replaced_total` + + `replacements` (from the manifest; falls back to dir mtime when absent), and + `POST /rollback/{backup_id}` restores a single record — so the UI shows a full + modification history and rolls back any one entry independently. - **Frontend** — the panel is the reusable `SkillAddressesPanel` (exported from `pages/SkillAddressesPage.tsx`; its default export keeps the standalone route `/page/strategy/admin/skill-addresses` as a direct-link fallback). It is @@ -567,7 +575,10 @@ snippet) and replaced together. auto-scan on open with a **progress bar** (扫描逐技能 / 应用逐文件 X/N), URL/IP type filter, search, a「片段批量套用」helper (e.g. all `uat.4.cn` → `prod.4.cn`), per-address「替换为」input with expandable occurrences, inline 预览 panel, 应用, - and 撤销上次 (rollback). Client + NDJSON stream parsing in + **multi-keyword search** (space-separated, all-must-hit), and a **修改记录** + (modification history) panel — each apply is one record (time / which addresses + changed from→to / file & replacement counts) with a per-record 回退此次 + (single-record rollback, with inline confirm). Client + NDJSON stream parsing in `strategy-components/api/skill-addresses.ts`. Scope is the running skills only; the `deploy/minimal/skills/` copy is not touched (sync separately). diff --git a/offline-backend-20260512/backend/app/gateway/routers/skill_addresses.py b/offline-backend-20260512/backend/app/gateway/routers/skill_addresses.py index 82d3f679..ed302c29 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/skill_addresses.py +++ b/offline-backend-20260512/backend/app/gateway/routers/skill_addresses.py @@ -145,10 +145,20 @@ class ApplyResponse(BaseModel): warnings: list[str] +class ReplacementSummary(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + from_: str = Field(serialization_alias="from") + to: str + kind: str + + class BackupEntry(BaseModel): backup_id: str files: int created_at: str + replaced_total: int = 0 + replacements: list[ReplacementSummary] = Field(default_factory=list) class BackupListResponse(BaseModel): @@ -292,6 +302,51 @@ def _backup_file(root: Path, backup_id: str, path: Path, rel: str) -> None: shutil.copy2(path, dest) +_MANIFEST_SUFFIX = ".manifest.json" + + +def _manifest_path(root: Path, backup_id: str) -> Path: + """Manifest lives as a *sibling* of the backup dir (``{id}.manifest.json``), + not inside it — so it is neither restored back into the skills tree on + rollback nor counted as a backed-up file.""" + + return root / _BACKUP_DIRNAME / f"{backup_id}{_MANIFEST_SUFFIX}" + + +def _write_manifest( + root: Path, + backup_id: str, + norm: list[tuple[str, str, str]], + changed: list["ChangedFile"], + replaced_total: int, + created_at: str, +) -> None: + """Record what this edit changed so the UI can show a meaningful, per-edit + history (原地址 → 新地址, 文件数, 处数) and roll a single record back.""" + + data = { + "created_at": created_at, + "replaced_total": replaced_total, + "replacements": [{"from": frm, "to": to, "kind": kind} for frm, to, kind in norm], + "changed_files": [{"file": c.file, "count": c.count} for c in changed], + } + try: # best-effort: a metadata write failure must not fail the apply itself + path = _manifest_path(root, backup_id) + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + json.dump(data, fh, ensure_ascii=False) + except OSError: + logger.warning("Failed to write skill-address backup manifest for %s", backup_id, exc_info=True) + + +def _read_manifest(root: Path, backup_id: str) -> dict | None: + try: + with open(_manifest_path(root, backup_id), "r", encoding="utf-8") as fh: + return json.load(fh) + except (OSError, json.JSONDecodeError): + return None + + # ── scan extraction shared by one-shot + stream ─────────────────────────────── @@ -475,7 +530,7 @@ def _new_backup_id() -> str: async def apply_replacements_route(request: Request, body: ApplyRequest) -> ApplyResponse: await _require_admin(request) root = _skills_root() - mapping, _norm, warnings = _build_mapping(body.replacements) + mapping, norm, warnings = _build_mapping(body.replacements) if not mapping: return ApplyResponse(changed_files=[], replaced_total=0, backup_id=None, warnings=warnings) @@ -501,6 +556,9 @@ async def apply_replacements_route(request: Request, body: ApplyRequest) -> Appl changed.append(ChangedFile(file=rel, count=cnt)) replaced_total += cnt + if backup_id: + await asyncio.to_thread(_write_manifest, root, backup_id, norm, changed, replaced_total, datetime.now(UTC).isoformat()) + await _refresh_cache_safe() logger.info("Skill address apply: %d files, %d replacements, backup=%s", len(changed), replaced_total, backup_id) return ApplyResponse(changed_files=changed, replaced_total=replaced_total, backup_id=backup_id, warnings=warnings) @@ -518,7 +576,7 @@ async def apply_replacements_stream(request: Request, body: ApplyRequest) -> Str await _require_admin(request) root = _skills_root() - mapping, _norm, warnings = _build_mapping(body.replacements) + mapping, norm, warnings = _build_mapping(body.replacements) plan: list[tuple[Path, str, int, str]] = [] current_total = 0 @@ -554,6 +612,9 @@ async def apply_replacements_stream(request: Request, body: ApplyRequest) -> Str done += 1 yield _ndjson({"type": "progress", "done": done, "total_files": len(plan), "file": rel, "count": cnt}) + if backup_id: + await asyncio.to_thread(_write_manifest, root, backup_id, norm, changed, replaced_total, datetime.now(UTC).isoformat()) + await _refresh_cache_safe() logger.info("Skill address apply(stream): %d files, %d replacements, backup=%s", len(changed), replaced_total, backup_id) result = ApplyResponse(changed_files=changed, replaced_total=replaced_total, backup_id=backup_id, warnings=warnings) @@ -573,11 +634,32 @@ def _list_backups(root: Path) -> list[BackupEntry]: if not d.is_dir(): continue file_count = sum(1 for p in d.rglob("*") if p.is_file()) - try: - created = datetime.fromtimestamp(d.stat().st_mtime, UTC).isoformat() - except OSError: - created = "" - entries.append(BackupEntry(backup_id=d.name, files=file_count, created_at=created)) + manifest = _read_manifest(root, d.name) + created = "" + replaced_total = 0 + replacements: list[ReplacementSummary] = [] + if manifest: + created = str(manifest.get("created_at") or "") + replaced_total = int(manifest.get("replaced_total") or 0) + replacements = [ + ReplacementSummary(from_=str(r.get("from", "")), to=str(r.get("to", "")), kind=str(r.get("kind", ""))) + for r in manifest.get("replacements", []) + if isinstance(r, dict) + ] + if not created: + try: + created = datetime.fromtimestamp(d.stat().st_mtime, UTC).isoformat() + except OSError: + created = "" + entries.append( + BackupEntry( + backup_id=d.name, + files=file_count, + created_at=created, + replaced_total=replaced_total, + replacements=replacements, + ) + ) entries.sort(key=lambda e: e.backup_id, reverse=True) return entries From 7c55a577adab218fdbd8eb7eafc28d7b3fd857b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Mon, 22 Jun 2026 19:36:30 +0800 Subject: [PATCH 9/9] =?UTF-8?q?feat(open-chat):=20=E5=BC=80=E6=94=BE?= =?UTF-8?q?=E9=97=AE=E7=AD=94=E6=8E=A5=E5=8F=A3=E5=9B=9E=E4=BC=A0=E7=94=9F?= =?UTF-8?q?=E6=88=90=E6=96=87=E4=BB=B6=20+=20=E4=B8=B4=E6=97=B6=E4=B8=8D?= =?UTF-8?q?=E7=95=99=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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) --- offline-backend-20260512/backend/CLAUDE.md | 2 +- .../backend/app/gateway/routers/open_chat.py | 102 +++++++++++++++++- .../deerflow/agents/lead_agent/agent.py | 16 ++- 3 files changed, 111 insertions(+), 9 deletions(-) diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index e47e3ca8..6fa38cdd 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -243,7 +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). **`POST /chat/stream`** is the **SSE streaming variant** (same request body) for **long tasks** — the blocking `/chat` produces no downstream bytes for the whole run, so a front nginx `proxy_read_timeout` (default 60s) cuts it off with a **504**; the stream keeps the connection warm (a `: keepalive` SSE comment every `_HEARTBEAT_SECONDS=15`s via a background producer-task + queue, plus `event: delta` AI-text increments and `event: progress`), and ends with **`event: result`** carrying the same final `{answer, …}` JSON (or `event: error`). Clients only need to read `event: result`; intermediate events are ignorable. Both endpoints share `_build_run_context` (config/agent/thread). `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 **three** OpenAI-compatible "thinking off" shapes into `extra_body` — top-level `enable_thinking=false` (**SiliconFlow / DashScope hybrid models** like `deepseek-ai/DeepSeek-V4-*` on `api.siliconflow.cn` — the knob the nested `chat_template_kwargs` shape does NOT reach), `chat_template_kwargs.enable_thinking=false` (vLLM/Qwen native), and `thinking.type="disabled"` (Zhipu GLM) — even for models that only declare `supports_thinking: true`. For the **web-UI path** (which sends `thinking_enabled=false` but NOT `force_disable`), SiliconFlow models need `when_thinking_disabled: {extra_body: {enable_thinking: false}}` in their `config.yaml` entry (added to `deepseek-ai/DeepSeek-V4-Flash`) for thinking-off to take effect. 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, streaming by default); tests `tests/test_open_chat.py`. | +| **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). **`POST /chat/stream`** is the **SSE streaming variant** (same request body) for **long tasks** — the blocking `/chat` produces no downstream bytes for the whole run, so a front nginx `proxy_read_timeout` (default 60s) cuts it off with a **504**; the stream keeps the connection warm (a `: keepalive` SSE comment every `_HEARTBEAT_SECONDS=15`s via a background producer-task + queue, plus `event: delta` AI-text increments and `event: progress`), and ends with **`event: result`** carrying the same final `{answer, …}` JSON (or `event: error`). Clients only need to read `event: result`; intermediate events are ignorable. Both endpoints share `_build_run_context` (config/agent/thread). `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 **three** OpenAI-compatible "thinking off" shapes into `extra_body` — top-level `enable_thinking=false` (**SiliconFlow / DashScope hybrid models** like `deepseek-ai/DeepSeek-V4-*` on `api.siliconflow.cn` — the knob the nested `chat_template_kwargs` shape does NOT reach), `chat_template_kwargs.enable_thinking=false` (vLLM/Qwen native), and `thinking.type="disabled"` (Zhipu GLM) — even for models that only declare `supports_thinking: true`. For the **web-UI path** (which sends `thinking_enabled=false` but NOT `force_disable`), SiliconFlow models need `when_thinking_disabled: {extra_body: {enable_thinking: false}}` in their `config.yaml` entry (added to `deepseek-ai/DeepSeek-V4-Flash`) for thinking-off to take effect. Sets `is_scheduled_run=true` internally to skip the auto-title LLM call. **Generated-file return (`include_files=true`, default on)**: an agent that produces its report as a file (writes to `/mnt/user-data/outputs/` + `present_files`) returns only a short summary in `answer`; to recover the real deliverable both endpoints now also return `files: [{name, virtual_path, encoding(text|base64), content}]` — `_collect_output_files` scans the run's `state['thread_data']['outputs_path']` (the open thread is stateless so artifacts can't be fetched by thread, but the endpoint runs server-side and the sandbox already wrote the files to disk), text decoded as utf-8 else base64, capped by `_MAX_OUTPUT_FILES=20` / `_MAX_OUTPUT_FILE_BYTES=5MB`. `/chat` puts `files` on the response; `/chat/stream` puts it in the `event: result` payload. A run with no answer **and** no files now 502s ("…文本答案或文件"). Router `app/gateway/routers/open_chat.py`; standalone caller `test_open_chat_api.py` (repo root, streaming by default); 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`. | diff --git a/offline-backend-20260512/backend/app/gateway/routers/open_chat.py b/offline-backend-20260512/backend/app/gateway/routers/open_chat.py index 849e0085..3dad51d2 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/open_chat.py +++ b/offline-backend-20260512/backend/app/gateway/routers/open_chat.py @@ -34,11 +34,14 @@ 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 @@ -79,6 +82,16 @@ class OpenChatRequest(BaseModel): 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): @@ -88,6 +101,7 @@ class OpenChatResponse(BaseModel): 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: @@ -171,6 +185,76 @@ def _extract_answer(result: Any, *, strip_think: bool) -> str: 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 @@ -188,6 +272,10 @@ def _build_run_context(body: OpenChatRequest) -> tuple[dict[str, Any], str | Non "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 @@ -220,8 +308,10 @@ async def open_chat(body: OpenChatRequest) -> OpenChatResponse: 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="智能体未返回任何文本答案。") + 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, @@ -230,6 +320,7 @@ async def open_chat(body: OpenChatRequest) -> OpenChatResponse: agent_id=agent_id, thinking_enabled=body.thinking_enabled, thread_id=thread_id, + files=files, ) @@ -289,8 +380,10 @@ async def _open_chat_event_stream(body: OpenChatRequest) -> AsyncIterator[str]: yield _sse(event, data) answer = _extract_answer(final_state, strip_think=strip_think) - if not answer: - yield _sse("error", {"detail": "智能体未返回任何文本答案。"}) + 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", @@ -301,6 +394,7 @@ async def _open_chat_event_stream(body: OpenChatRequest) -> AsyncIterator[str]: "agent_id": agent_id, "thinking_enabled": body.thinking_enabled, "thread_id": thread_id, + "files": [f.model_dump() for f in files], }, ) finally: 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 e2d27c2f..66cf4d88 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 @@ -306,17 +306,25 @@ def _build_middlewares( if not cfg.get("is_scheduled_run", False): middlewares.append(TitleMiddleware(app_config=resolved_app_config)) - # Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow) + # Ephemeral runs (e.g. the open Q&A API /api/open/chat) must NOT touch any + # server-side per-user state: no memory recall/retain, no knowledge-base + # recall/auto-ingest. Those runs use the shared "default" user bucket, so + # persisting their conversations there would both store records the caller + # explicitly asked NOT to keep and leak content across unrelated callers. + # Skip both write-capable middlewares entirely. + is_ephemeral = cfg.get("ephemeral", False) + + # Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow) and ephemeral runs memory_injection_enabled = cfg.get("memory_injection_enabled", True) - if not is_bootstrap and not is_writing_setup: + if not is_bootstrap and not is_writing_setup and not is_ephemeral: middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled)) # KnowledgeRagMiddleware — inject knowledge-base recall (phase 2). Gated # per-conversation by the ``knowledge_rag_enabled`` runtime flag, defaulting # to the global ``knowledge.rag_enabled`` config. No-op when knowledge is - # disabled. Skipped in bootstrap/writing-setup like MemoryMiddleware. + # disabled. Skipped in bootstrap/writing-setup/ephemeral like MemoryMiddleware. knowledge_cfg = getattr(resolved_app_config, "knowledge", None) - if not is_bootstrap and not is_writing_setup and knowledge_cfg is not None and knowledge_cfg.enabled: + if not is_bootstrap and not is_writing_setup and not is_ephemeral and knowledge_cfg is not None and knowledge_cfg.enabled: from deerflow.agents.middlewares.knowledge_rag_middleware import KnowledgeRagMiddleware middlewares.append(KnowledgeRagMiddleware(limit=knowledge_cfg.rag_limit, default_enabled=knowledge_cfg.rag_enabled))