diff --git a/frontend-web/src/core/auth/dashboard-input.ts b/frontend-web/src/core/auth/dashboard-input.ts index 389c4fe6..2168cbc2 100644 --- a/frontend-web/src/core/auth/dashboard-input.ts +++ b/frontend-web/src/core/auth/dashboard-input.ts @@ -233,6 +233,12 @@ export async function fetchDashboardInputJson( */ export type SqReportStatus = "complete" | "incomplete" | "unknown"; +export interface SqReportCheckResult { + status: SqReportStatus; + /** 提取后的 3q 研判情报报告;仅 status=complete 时有值。 */ + report?: unknown; +} + /** * rwfx(任务分析)进入时的「3q 是否完成」预检:调 selectSqReport,复用 ``extractSqReport`` * 判定该任务是否已有有效研判情报。判定口径与大屏绘制取数完全一致(空/全 null → 无内容)。 @@ -245,20 +251,26 @@ export type SqReportStatus = "complete" | "incomplete" | "unknown"; * (与目的树查询/行动解析的 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"; +export async function fetchSqReportCheck(taskId: string): Promise { + if (!taskId) return { status: "unknown" }; const cfg = await fetchTaskDeeplinkConfig(); // test=true:外部接口不可用,直接用后端 config 的假数据判定(不打真实接口)。 if (cfg.test) { - return extractSqReport(cfg.mock.sqReport, taskId) ? "complete" : "incomplete"; + const report = extractSqReport(cfg.mock.sqReport, taskId); + return report ? { status: "complete", report } : { status: "incomplete" }; } // 真实模式:打真实接口。service 拦截器统一注入鉴权并返回整包 body;空/全 null → null。 const url = sqReportUrl(); - if (!url) return "unknown"; + if (!url) return { status: "unknown" }; try { const body = await requestCrudService.get(url, { taskId }); - return extractSqReport(body, taskId) ? "complete" : "incomplete"; + const report = extractSqReport(body, taskId); + return report ? { status: "complete", report } : { status: "incomplete" }; } catch { - return "unknown"; + return { status: "unknown" }; } } + +export async function fetchSqReportStatus(taskId: string): Promise { + return (await fetchSqReportCheck(taskId)).status; +} diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index 14c9a942..a4284499 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -343,15 +343,24 @@ function lastHumanFromChatModelInput(input: unknown): string { if (Array.isArray(m)) m.forEach(push); else if (m != null) flat.push(m); }; - push(raw); - for (let i = flat.length - 1; i >= 0; i--) { - const m = flat[i]; - if (!m || typeof m !== "object") continue; - const type = (m as Record).type ?? (m as Record).role; - if (type === "human" || type === "user") { - const text = textFromChatModelOutput(m); - if (text) return text; - } + push(raw); + for (let i = flat.length - 1; i >= 0; i--) { + const m = flat[i]; + if (!m || typeof m !== "object") continue; + const obj = m as Record; + const type = obj.type ?? obj.role; + if (type === "human" || type === "user") { + const kwargs = obj.additional_kwargs; + if ( + kwargs && + typeof kwargs === "object" && + (kwargs as Record).hide_from_ui === true + ) { + continue; + } + const text = textFromChatModelOutput(m); + if (text) return text; + } } } catch { /* 取不到忽略 */ diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index ddb3deaa..6137696c 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -62,10 +62,26 @@ import { readActionDetail, readCopTaskDetail, } from "@/core/auth/task-deeplink"; +import type { RwfxSqGateStartContext } from "@/strategy-components/components/task-components/rwfx-sq-gate"; /** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */ const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder"; +function rwfxSqReportRunContext( + gateContext?: RwfxSqGateStartContext, +): Record | undefined { + if (gateContext?.sqReport == null) return undefined; + return { + task_external_context: { + kind: "rwfx_sq_report", + title: "三情分析(3q)研判情报报告", + usage: + "这是任务分析(rwfx)问答的隐藏业务上下文。回答时应参考这些已完成的 3q 结果,但不要向用户说明页面注入了隐藏上下文,除非用户明确询问。", + data: gateContext.sqReport, + }, + }; +} + export default function AgentChatPage({ embeddedAgentId, chatBasePath, @@ -288,7 +304,7 @@ export default function AgentChatPage({ }, [debugTemplateId, thread.messages]); const handleSubmit = useCallback( - (message: PromptInputMessage) => { + (message: PromptInputMessage, extraContext?: Record) => { setHasStartedConversation(true); // Remember this agent so the new-chat landing screen pre-selects it next // time (covers both direct agent chats and the ChatPage handoff replay). @@ -296,7 +312,7 @@ export default function AgentChatPage({ window.dispatchEvent( new CustomEvent("workspace:sandbox-resume-autoscroll"), ); - void sendMessage(threadId, message, { agent_id }); + void sendMessage(threadId, message, { ...(extraContext ?? {}), agent_id }); }, [sendMessage, threadId, agent_id], ); @@ -319,9 +335,9 @@ export default function AgentChatPage({ // 任务闸门通过(无目的数据 / 覆盖成功):发出开场信息开始问答;无开场信息则 // 仅切到对话态(隐藏面板),让用户手动输入。 - const handleGateStart = useCallback(() => { + const handleGateStart = useCallback((gateContext?: RwfxSqGateStartContext) => { const text = taskContext?.openingText?.trim(); - if (text) handleSubmit({ text, files: [] }); + if (text) handleSubmit({ text, files: [] }, rwfxSqReportRunContext(gateContext)); else setHasStartedConversation(true); }, [handleSubmit, taskContext]); diff --git a/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx b/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx index 61c494c5..3f4c6f85 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx @@ -31,7 +31,10 @@ import { type TaskPurposeResult, } from "@/core/auth/task-purpose"; import { fetchTaskIdFromAction } from "@/core/auth/task-deeplink"; -import { useRwfxSqGate } from "@/strategy-components/components/task-components/rwfx-sq-gate"; +import { + useRwfxSqGate, + type RwfxSqGateStartContext, +} from "@/strategy-components/components/task-components/rwfx-sq-gate"; import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; @@ -164,7 +167,7 @@ export function TaskPurposePanel({ }: { taskId: string; /** 闸门通过(无数据 / 覆盖成功),开始问答。 */ - onStart: () => void; + onStart: (context?: RwfxSqGateStartContext) => void; /** rwfx:查询任务目的为空数据时,自动开始第一次问答(跳过手动点「开始问答」)。 */ autoStartWhenEmpty?: boolean; }) { @@ -209,7 +212,7 @@ export function TaskPurposePanel({ const handleStart = () => { if (result?.hasData) setConfirmOpen(true); // 有数据 → 先确认覆盖 - else onStart(); // 无数据 → 直接开始 + else gateStart(onStart); // 无数据 → 经过 3q 门控后开始 }; const handleConfirmOverwrite = async () => { @@ -218,7 +221,7 @@ export function TaskPurposePanel({ const r = await deleteTaskPurpose(taskId); if (r.success) { setConfirmOpen(false); - onStart(); + gateStart(onStart); } else { toast.error(r.message || "无法覆盖,请稍后再试"); setConfirmOpen(false); diff --git a/frontend-web/src/strategy-components/components/task-components/rwfx-sq-gate.tsx b/frontend-web/src/strategy-components/components/task-components/rwfx-sq-gate.tsx index 2059dead..7ff0ac02 100644 --- a/frontend-web/src/strategy-components/components/task-components/rwfx-sq-gate.tsx +++ b/frontend-web/src/strategy-components/components/task-components/rwfx-sq-gate.tsx @@ -1,7 +1,7 @@ /** * rwfx(任务分析)专用「3q 预检门控」。 * - * 进入任务分析工作区时调研判情报报告接口(``fetchSqReportStatus``)判断 3q 是否完成: + * 进入任务分析工作区时调研判情报报告接口(``fetchSqReportCheck``)判断 3q 是否完成: * - ``incomplete``(3q 未完成 / 无数据)→ 弹「3q 未完成,是否前往 3qfx」框(暂不 / 前往 3qfx)。 * - ``complete`` / ``unknown`` → 不打扰。 * @@ -37,11 +37,16 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { fetchSqReportStatus } from "@/core/auth/dashboard-input"; +import { fetchSqReportCheck } from "@/core/auth/dashboard-input"; import { readCopTaskDetail, resolveTaskDeeplinkTarget } from "@/core/auth/task-deeplink"; type GateStatus = "loading" | "incomplete" | "ok"; +export interface RwfxSqGateStartContext { + /** 已完成的 3q 研判情报报告。只随 run context 传给模型,不在页面展示。 */ + sqReport?: unknown; +} + interface RwfxSqGateValue { /** * rwfx 自动开始第一次问答的门控。 @@ -49,7 +54,7 @@ interface RwfxSqGateValue { * - 3q 未完成 → 挂起 ``start``,弹「前往 3qfx」框;用户点「暂不」才 ``start()``,点「前往 3qfx」跳转并丢弃。 * 非 rwfx / 无 Provider → 直接 ``start()``(默认值)。 */ - gateStart: (start: () => void) => void; + gateStart: (start: (context?: RwfxSqGateStartContext) => void) => void; } const RwfxSqGateContext = createContext({ @@ -72,12 +77,19 @@ export function RwfxSqGateProvider({ const [jumping, setJumping] = useState(false); const statusRef = useRef(taskId ? "loading" : "ok"); + const sqReportRef = useRef(null); // 待触发的「开始问答」回调:3q 未完成时挂起,点「暂不」/关闭弹框后触发。 - const pendingStartRef = useRef<(() => void) | null>(null); + const pendingStartRef = useRef<((context?: RwfxSqGateStartContext) => void) | null>(null); + + const buildStartContext = useCallback((): RwfxSqGateStartContext | undefined => { + const sqReport = sqReportRef.current; + return sqReport == null ? undefined : { sqReport }; + }, []); // taskId 变化:重置门控状态并预检 3q。 useEffect(() => { pendingStartRef.current = null; + sqReportRef.current = null; setOpen(false); if (!taskId) { statusRef.current = "ok"; @@ -85,43 +97,44 @@ export function RwfxSqGateProvider({ } statusRef.current = "loading"; let alive = true; - void fetchSqReportStatus(taskId).then((s) => { + void fetchSqReportCheck(taskId).then((result) => { if (!alive) return; // complete / unknown 都按「放行」(不打扰);仅明确 incomplete 才门控。 - const next: GateStatus = s === "incomplete" ? "incomplete" : "ok"; + const next: GateStatus = result.status === "incomplete" ? "incomplete" : "ok"; statusRef.current = next; + sqReportRef.current = result.status === "complete" ? (result.report ?? null) : null; if (next === "incomplete") { setOpen(true); // 进入即提醒(无论是否有挂起的自动发送) } else if (pendingStartRef.current) { // 加载期间已请求自动开始 → 放行后补发。 const start = pendingStartRef.current; pendingStartRef.current = null; - start(); + start(buildStartContext()); } }); return () => { alive = false; }; - }, [taskId]); + }, [buildStartContext, taskId]); - const gateStart = useCallback((start: () => void) => { + const gateStart = useCallback((start: (context?: RwfxSqGateStartContext) => void) => { const status = statusRef.current; if (status === "ok") { - start(); + start(buildStartContext()); return; } // loading / incomplete:挂起。loading 解析为 ok 时补发;incomplete 时等用户「暂不」。 pendingStartRef.current = start; if (status === "incomplete") setOpen(true); - }, []); + }, [buildStartContext]); /** 暂不(不去 3q 分析)/ 关闭弹框 → 补发挂起的「开始问答」(无则无操作)。 */ const stayAndStart = useCallback(() => { setOpen(false); const start = pendingStartRef.current; pendingStartRef.current = null; - start?.(); - }, []); + start?.(buildStartContext()); + }, [buildStartContext]); const handleGo3qfx = async () => { const detail = readCopTaskDetail(); 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 47920bbb..7af94877 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 @@ -12,9 +12,10 @@ from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMid from deerflow.agents.middlewares.rag_citation_middleware import RagCitationMiddleware from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware from deerflow.agents.middlewares.setup_writing_approval_middleware import SetupWritingApprovalMiddleware -from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware -from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware -from deerflow.agents.middlewares.title_middleware import TitleMiddleware +from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware +from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware +from deerflow.agents.middlewares.task_external_context_middleware import TaskExternalContextMiddleware +from deerflow.agents.middlewares.title_middleware import TitleMiddleware from deerflow.agents.middlewares.todo_middleware import TodoMiddleware from deerflow.agents.middlewares.token_usage_middleware import TokenUsageMiddleware from deerflow.agents.middlewares.tool_error_handling_middleware import build_lead_runtime_middlewares @@ -326,12 +327,16 @@ def _build_middlewares( # to the global ``knowledge.rag_enabled`` config. No-op when knowledge is # 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 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)) - - # Add ViewImageMiddleware only for the NATIVE-vision path (main model + 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)) + + # TaskExternalContextMiddleware — inject hidden per-run business context + # (for example rwfx's completed 3q report) into the model request only. + middlewares.append(TaskExternalContextMiddleware()) + + # Add ViewImageMiddleware only for the NATIVE-vision path (main model # ingests raw image data). In delegated mode the view_image tool already # returns a text description and stores nothing in `viewed_images`, so the # middleware would otherwise inject a spurious "No images have been viewed." diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/task_external_context_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/task_external_context_middleware.py new file mode 100644 index 00000000..6ddf8f7c --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/task_external_context_middleware.py @@ -0,0 +1,76 @@ +"""Inject task-specific external context into the model request. + +The web task workspace can pass business data through LangGraph run context +without showing it in the chat transcript. This middleware converts that +context into a transient message right before model invocation, so it is +available to the agent but never persisted as user-visible conversation state. +""" + +from __future__ import annotations + +import json +from typing import Any, override + +from langchain.agents import AgentState +from langchain.agents.middleware import AgentMiddleware +from langchain_core.messages import HumanMessage + +_CONTEXT_KEY = "task_external_context" +_TAG = "task_external_context" + + +def _render_context(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value.strip() + try: + return json.dumps(value, ensure_ascii=False, indent=2, default=str).strip() + except Exception: + return str(value).strip() + + +def _task_context_from_request(request: Any) -> Any: + runtime = getattr(request, "runtime", None) + context = getattr(runtime, "context", None) + if isinstance(context, dict): + return context.get(_CONTEXT_KEY) + return None + + +def _build_context_message(value: Any) -> HumanMessage | None: + text = _render_context(value) + if not text: + return None + content = ( + f"<{_TAG}>\n" + "[系统提示:以下是业务系统随本次运行提供的隐藏任务上下文,不是用户的新输入。" + "回答当前任务时应作为背景资料参考;不要主动说明这些内容来自隐藏上下文," + "也不要在回答中暴露本标签本身。]\n\n" + f"{text}\n" + f"" + ) + return HumanMessage( + content=content, + additional_kwargs={"hide_from_ui": True, _CONTEXT_KEY: True}, + ) + + +class TaskExternalContextMiddleware(AgentMiddleware[AgentState]): + """Append hidden task context as a transient model-call message.""" + + state_schema = AgentState + + @override + async def awrap_model_call(self, request: Any, handler: Any) -> Any: + msg = _build_context_message(_task_context_from_request(request)) + if msg is not None: + request = request.override(messages=[*request.messages, msg]) + return await handler(request) + + @override + def wrap_model_call(self, request: Any, handler: Any) -> Any: + msg = _build_context_message(_task_context_from_request(request)) + if msg is not None: + request = request.override(messages=[*request.messages, msg]) + return handler(request)