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
+179 -45

No files matched your search

+18 -6
View File
@@ -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<SqReportStatus> {
if (!taskId) return "unknown";
export async function fetchSqReportCheck(taskId: string): Promise<SqReportCheckResult> {
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<SqReportStatus> {
return (await fetchSqReportCheck(taskId)).status;
}
+18 -9
View File
@@ -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<string, unknown>).type ?? (m as Record<string, unknown>).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<string, unknown>;
const type = obj.type ?? obj.role;
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);
if (text) return text;
}
}
} catch {
/* 取不到忽略 */
+20 -4
View File
@@ -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<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({
embeddedAgentId,
chatBasePath,
@@ -288,7 +304,7 @@ export default function AgentChatPage({
}, [debugTemplateId, thread.messages]);
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
(message: PromptInputMessage, extraContext?: Record<string, unknown>) => {
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]);
@@ -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);
@@ -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<RwfxSqGateValue>({
@@ -72,12 +77,19 @@ export function RwfxSqGateProvider({
const [jumping, setJumping] = useState(false);
const statusRef = useRef<GateStatus>(taskId ? "loading" : "ok");
const sqReportRef = useRef<unknown | null>(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();
@@ -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."
@@ -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)