feat: inject 3q context into rwfx chat

This commit is contained in:
吉鑫升 committed 2026-07-02 10:03:34 +08:00
1 parent ba0c32dcee
commit 36a3851233
7 files changed
+162 -28

No files matched your search

+18 -6
View File
@@ -233,6 +233,12 @@ export async function fetchDashboardInputJson(
*/ */
export type SqReportStatus = "complete" | "incomplete" | "unknown"; export type SqReportStatus = "complete" | "incomplete" | "unknown";
export interface SqReportCheckResult {
status: SqReportStatus;
/** 提取后的 3q 研判情报报告;仅 status=complete 时有值。 */
report?: unknown;
}
/** /**
* rwfx(任务分析)进入时的「3q 是否完成」预检:调 selectSqReport,复用 ``extractSqReport`` * rwfx(任务分析)进入时的「3q 是否完成」预检:调 selectSqReport,复用 ``extractSqReport``
* 判定该任务是否已有有效研判情报。判定口径与大屏绘制取数完全一致(空/全 null → 无内容)。 * 判定该任务是否已有有效研判情报。判定口径与大屏绘制取数完全一致(空/全 null → 无内容)。
@@ -245,20 +251,26 @@ export type SqReportStatus = "complete" | "incomplete" | "unknown";
* (与目的树查询/行动解析的 mock 同一套机制,见 [[task-deeplink-config]]); * (与目的树查询/行动解析的 mock 同一套机制,见 [[task-deeplink-config]]);
* - ``test=false`` → 调真实接口 ``GET {VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=``。 * - ``test=false`` → 调真实接口 ``GET {VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=``。
*/ */
export async function fetchSqReportStatus(taskId: string): Promise<SqReportStatus> { export async function fetchSqReportCheck(taskId: string): Promise<SqReportCheckResult> {
if (!taskId) return "unknown"; if (!taskId) return { status: "unknown" };
const cfg = await fetchTaskDeeplinkConfig(); const cfg = await fetchTaskDeeplinkConfig();
// test=true:外部接口不可用,直接用后端 config 的假数据判定(不打真实接口)。 // test=true:外部接口不可用,直接用后端 config 的假数据判定(不打真实接口)。
if (cfg.test) { 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。 // 真实模式:打真实接口。service 拦截器统一注入鉴权并返回整包 body;空/全 null → null。
const url = sqReportUrl(); const url = sqReportUrl();
if (!url) return "unknown"; if (!url) return { status: "unknown" };
try { try {
const body = await requestCrudService.get(url, { taskId }); 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 { } catch {
return "unknown"; return { status: "unknown" };
} }
} }
export async function fetchSqReportStatus(taskId: string): Promise<SqReportStatus> {
return (await fetchSqReportCheck(taskId)).status;
}
+10 -1
View File
@@ -347,8 +347,17 @@ function lastHumanFromChatModelInput(input: unknown): string {
for (let i = flat.length - 1; i >= 0; i--) { for (let i = flat.length - 1; i >= 0; i--) {
const m = flat[i]; const m = flat[i];
if (!m || typeof m !== "object") continue; if (!m || typeof m !== "object") continue;
const type = (m as Record<string, unknown>).type ?? (m as Record<string, unknown>).role; const obj = m as Record<string, unknown>;
const type = obj.type ?? obj.role;
if (type === "human" || type === "user") { if (type === "human" || type === "user") {
const kwargs = obj.additional_kwargs;
if (
kwargs &&
typeof kwargs === "object" &&
(kwargs as Record<string, unknown>).hide_from_ui === true
) {
continue;
}
const text = textFromChatModelOutput(m); const text = textFromChatModelOutput(m);
if (text) return text; if (text) return text;
} }
+20 -4
View File
@@ -62,10 +62,26 @@ import {
readActionDetail, readActionDetail,
readCopTaskDetail, readCopTaskDetail,
} from "@/core/auth/task-deeplink"; } from "@/core/auth/task-deeplink";
import type { RwfxSqGateStartContext } from "@/strategy-components/components/task-components/rwfx-sq-gate";
/** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */ /** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */
const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder"; const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder";
function rwfxSqReportRunContext(
gateContext?: RwfxSqGateStartContext,
): Record<string, unknown> | 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({ export default function AgentChatPage({
embeddedAgentId, embeddedAgentId,
chatBasePath, chatBasePath,
@@ -288,7 +304,7 @@ export default function AgentChatPage({
}, [debugTemplateId, thread.messages]); }, [debugTemplateId, thread.messages]);
const handleSubmit = useCallback( const handleSubmit = useCallback(
(message: PromptInputMessage) => { (message: PromptInputMessage, extraContext?: Record<string, unknown>) => {
setHasStartedConversation(true); setHasStartedConversation(true);
// Remember this agent so the new-chat landing screen pre-selects it next // Remember this agent so the new-chat landing screen pre-selects it next
// time (covers both direct agent chats and the ChatPage handoff replay). // time (covers both direct agent chats and the ChatPage handoff replay).
@@ -296,7 +312,7 @@ export default function AgentChatPage({
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"), new CustomEvent("workspace:sandbox-resume-autoscroll"),
); );
void sendMessage(threadId, message, { agent_id }); void sendMessage(threadId, message, { ...(extraContext ?? {}), agent_id });
}, },
[sendMessage, threadId, 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(); const text = taskContext?.openingText?.trim();
if (text) handleSubmit({ text, files: [] }); if (text) handleSubmit({ text, files: [] }, rwfxSqReportRunContext(gateContext));
else setHasStartedConversation(true); else setHasStartedConversation(true);
}, [handleSubmit, taskContext]); }, [handleSubmit, taskContext]);
@@ -31,7 +31,10 @@ import {
type TaskPurposeResult, type TaskPurposeResult,
} from "@/core/auth/task-purpose"; } from "@/core/auth/task-purpose";
import { fetchTaskIdFromAction } from "@/core/auth/task-deeplink"; 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 { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -164,7 +167,7 @@ export function TaskPurposePanel({
}: { }: {
taskId: string; taskId: string;
/** 闸门通过(无数据 / 覆盖成功),开始问答。 */ /** 闸门通过(无数据 / 覆盖成功),开始问答。 */
onStart: () => void; onStart: (context?: RwfxSqGateStartContext) => void;
/** rwfx:查询任务目的为空数据时,自动开始第一次问答(跳过手动点「开始问答」)。 */ /** rwfx:查询任务目的为空数据时,自动开始第一次问答(跳过手动点「开始问答」)。 */
autoStartWhenEmpty?: boolean; autoStartWhenEmpty?: boolean;
}) { }) {
@@ -209,7 +212,7 @@ export function TaskPurposePanel({
const handleStart = () => { const handleStart = () => {
if (result?.hasData) setConfirmOpen(true); // 有数据 → 先确认覆盖 if (result?.hasData) setConfirmOpen(true); // 有数据 → 先确认覆盖
else onStart(); // 无数据 → 直接开始 else gateStart(onStart); // 无数据 → 经过 3q 门控后开始
}; };
const handleConfirmOverwrite = async () => { const handleConfirmOverwrite = async () => {
@@ -218,7 +221,7 @@ export function TaskPurposePanel({
const r = await deleteTaskPurpose(taskId); const r = await deleteTaskPurpose(taskId);
if (r.success) { if (r.success) {
setConfirmOpen(false); setConfirmOpen(false);
onStart(); gateStart(onStart);
} else { } else {
toast.error(r.message || "无法覆盖,请稍后再试"); toast.error(r.message || "无法覆盖,请稍后再试");
setConfirmOpen(false); setConfirmOpen(false);
@@ -1,7 +1,7 @@
/** /**
* rwfx(任务分析)专用「3q 预检门控」。 * rwfx(任务分析)专用「3q 预检门控」。
* *
* 进入任务分析工作区时调研判情报报告接口(``fetchSqReportStatus``)判断 3q 是否完成: * 进入任务分析工作区时调研判情报报告接口(``fetchSqReportCheck``)判断 3q 是否完成:
* - ``incomplete``(3q 未完成 / 无数据)→ 弹「3q 未完成,是否前往 3qfx」框(暂不 / 前往 3qfx)。 * - ``incomplete``(3q 未完成 / 无数据)→ 弹「3q 未完成,是否前往 3qfx」框(暂不 / 前往 3qfx)。
* - ``complete`` / ``unknown`` → 不打扰。 * - ``complete`` / ``unknown`` → 不打扰。
* *
@@ -37,11 +37,16 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } 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"; import { readCopTaskDetail, resolveTaskDeeplinkTarget } from "@/core/auth/task-deeplink";
type GateStatus = "loading" | "incomplete" | "ok"; type GateStatus = "loading" | "incomplete" | "ok";
export interface RwfxSqGateStartContext {
/** 已完成的 3q 研判情报报告。只随 run context 传给模型,不在页面展示。 */
sqReport?: unknown;
}
interface RwfxSqGateValue { interface RwfxSqGateValue {
/** /**
* rwfx 自动开始第一次问答的门控。 * rwfx 自动开始第一次问答的门控。
@@ -49,7 +54,7 @@ interface RwfxSqGateValue {
* - 3q 未完成 → 挂起 ``start``,弹「前往 3qfx」框;用户点「暂不」才 ``start()``,点「前往 3qfx」跳转并丢弃。 * - 3q 未完成 → 挂起 ``start``,弹「前往 3qfx」框;用户点「暂不」才 ``start()``,点「前往 3qfx」跳转并丢弃。
* 非 rwfx / 无 Provider → 直接 ``start()``(默认值)。 * 非 rwfx / 无 Provider → 直接 ``start()``(默认值)。
*/ */
gateStart: (start: () => void) => void; gateStart: (start: (context?: RwfxSqGateStartContext) => void) => void;
} }
const RwfxSqGateContext = createContext<RwfxSqGateValue>({ const RwfxSqGateContext = createContext<RwfxSqGateValue>({
@@ -72,12 +77,19 @@ export function RwfxSqGateProvider({
const [jumping, setJumping] = useState(false); const [jumping, setJumping] = useState(false);
const statusRef = useRef<GateStatus>(taskId ? "loading" : "ok"); const statusRef = useRef<GateStatus>(taskId ? "loading" : "ok");
const sqReportRef = useRef<unknown | null>(null);
// 待触发的「开始问答」回调:3q 未完成时挂起,点「暂不」/关闭弹框后触发。 // 待触发的「开始问答」回调: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。 // taskId 变化:重置门控状态并预检 3q。
useEffect(() => { useEffect(() => {
pendingStartRef.current = null; pendingStartRef.current = null;
sqReportRef.current = null;
setOpen(false); setOpen(false);
if (!taskId) { if (!taskId) {
statusRef.current = "ok"; statusRef.current = "ok";
@@ -85,43 +97,44 @@ export function RwfxSqGateProvider({
} }
statusRef.current = "loading"; statusRef.current = "loading";
let alive = true; let alive = true;
void fetchSqReportStatus(taskId).then((s) => { void fetchSqReportCheck(taskId).then((result) => {
if (!alive) return; if (!alive) return;
// complete / unknown 都按「放行」(不打扰);仅明确 incomplete 才门控。 // complete / unknown 都按「放行」(不打扰);仅明确 incomplete 才门控。
const next: GateStatus = s === "incomplete" ? "incomplete" : "ok"; const next: GateStatus = result.status === "incomplete" ? "incomplete" : "ok";
statusRef.current = next; statusRef.current = next;
sqReportRef.current = result.status === "complete" ? (result.report ?? null) : null;
if (next === "incomplete") { if (next === "incomplete") {
setOpen(true); // 进入即提醒(无论是否有挂起的自动发送) setOpen(true); // 进入即提醒(无论是否有挂起的自动发送)
} else if (pendingStartRef.current) { } else if (pendingStartRef.current) {
// 加载期间已请求自动开始 → 放行后补发。 // 加载期间已请求自动开始 → 放行后补发。
const start = pendingStartRef.current; const start = pendingStartRef.current;
pendingStartRef.current = null; pendingStartRef.current = null;
start(); start(buildStartContext());
} }
}); });
return () => { return () => {
alive = false; alive = false;
}; };
}, [taskId]); }, [buildStartContext, taskId]);
const gateStart = useCallback((start: () => void) => { const gateStart = useCallback((start: (context?: RwfxSqGateStartContext) => void) => {
const status = statusRef.current; const status = statusRef.current;
if (status === "ok") { if (status === "ok") {
start(); start(buildStartContext());
return; return;
} }
// loading / incomplete:挂起。loading 解析为 ok 时补发;incomplete 时等用户「暂不」。 // loading / incomplete:挂起。loading 解析为 ok 时补发;incomplete 时等用户「暂不」。
pendingStartRef.current = start; pendingStartRef.current = start;
if (status === "incomplete") setOpen(true); if (status === "incomplete") setOpen(true);
}, []); }, [buildStartContext]);
/** 暂不(不去 3q 分析)/ 关闭弹框 → 补发挂起的「开始问答」(无则无操作)。 */ /** 暂不(不去 3q 分析)/ 关闭弹框 → 补发挂起的「开始问答」(无则无操作)。 */
const stayAndStart = useCallback(() => { const stayAndStart = useCallback(() => {
setOpen(false); setOpen(false);
const start = pendingStartRef.current; const start = pendingStartRef.current;
pendingStartRef.current = null; pendingStartRef.current = null;
start?.(); start?.(buildStartContext());
}, []); }, [buildStartContext]);
const handleGo3qfx = async () => { const handleGo3qfx = async () => {
const detail = readCopTaskDetail(); const detail = readCopTaskDetail();
@@ -14,6 +14,7 @@ from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAge
from deerflow.agents.middlewares.setup_writing_approval_middleware import SetupWritingApprovalMiddleware from deerflow.agents.middlewares.setup_writing_approval_middleware import SetupWritingApprovalMiddleware
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware 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.title_middleware import TitleMiddleware
from deerflow.agents.middlewares.todo_middleware import TodoMiddleware from deerflow.agents.middlewares.todo_middleware import TodoMiddleware
from deerflow.agents.middlewares.token_usage_middleware import TokenUsageMiddleware from deerflow.agents.middlewares.token_usage_middleware import TokenUsageMiddleware
@@ -331,6 +332,10 @@ def _build_middlewares(
middlewares.append(KnowledgeRagMiddleware(limit=knowledge_cfg.rag_limit, default_enabled=knowledge_cfg.rag_enabled)) 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 # Add ViewImageMiddleware only for the NATIVE-vision path (main model
# ingests raw image data). In delegated mode the view_image tool already # ingests raw image data). In delegated mode the view_image tool already
# returns a text description and stores nothing in `viewed_images`, so the # returns a text description and stores nothing in `viewed_images`, so the
@@ -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"</{_TAG}>"
)
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)