feat: 新增 agentfx 任务深链与研判报告入库流程

支持 goPath=agentfx 的 iframe 嵌入问答、内置研判助手种子与报告导入协助卡,并完善深度研究流式报告能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-27 16:32:22 +08:00
1 parent 6e9e89a6cb
commit 52b9cbf707
34 files changed
+4488 -392

No files matched your search

+242 -28
View File
@@ -42,6 +42,8 @@ import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks";
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
import { textOfMessage } from "@/core/threads/utils";
import { OptType, writeYLog } from "@/core/log/y-log";
import {
@@ -75,7 +77,14 @@ import {
readActionDetail,
readCopTaskDetail,
} from "@/core/auth/task-deeplink";
import { hasTaskReportImportApproval } from "@/core/messages/utils";
import type { RwfxSqGateStartContext } from "@/strategy-components/components/task-components/rwfx-sq-gate";
import { TaskReportImportCard } from "@/strategy-components/components/task-components/TaskReportImportCard";
import {
AGENTFX_REPORT_JSON_OUTPUT_PATH,
notifyTaskReportSaved,
} from "@/strategy-components/lib/agentfx-report-schema";
import type { Message } from "@langchain/langgraph-sdk";
/** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */
const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder";
@@ -150,6 +159,36 @@ function rwfxSqReportRunContext(
};
}
/**
* 任务工作区(taskBind:3qfx/xdfx/agentfx)每次运行的隐藏任务上下文:把任务名称/
* 内容/id(含开场白原文)经 ``task_external_context`` 随 run context 发给后端,
* 由 TaskExternalContextMiddleware 在每次模型调用前注入为隐藏消息(不进聊天记录)。
* 解决「新建对话后消息历史里没有开场信息、模型不知道任务id」的问题——任何新对话、
* 旧对话续聊、上下文自恢复后模型都能拿到任务id,不需要向用户询问。
*/
function taskBindRunContext(
taskContext: TaskContext | null,
): Record<string, unknown> | undefined {
if (!taskContext?.taskId) return undefined;
const detail = readCopTaskDetail();
return {
task_external_context: {
kind: "task_bind_opening",
title: "任务开场信息",
usage:
"这是任务工作区随每次运行提供的隐藏任务上下文,不是用户的新输入。当前任务的" +
"任务id(task_id)以此为准,直接使用,不要向用户询问、确认或让用户补充任务id;" +
"回答时结合该任务背景。不要向用户说明页面注入了隐藏上下文。",
data: {
任务名称: detail?.taskName ?? "",
任务内容: (detail?.taskContent ?? "") || (detail?.overview ?? ""),
任务id: taskContext.taskId,
开场信息原文: taskContext.openingText,
},
},
};
}
export default function AgentChatPage({
embeddedAgentId,
chatBasePath,
@@ -372,6 +411,13 @@ export default function AgentChatPage({
}),
[knowledgeSelectionReady, selectedKnowledgeBaseIds, taskContext, taskId],
);
// 任务工作区(taskBind):每次运行都带隐藏任务上下文(任务id/名称/内容),
// 新建对话的消息历史里没有开场信息时模型也能拿到任务id(见 taskBindRunContext)。
// useMemo 缓存:流式期间 render 频繁,避免每次都读 sessionStorage + JSON.parse。
const taskBindRunContextValue = useMemo(
() => (taskBind && !taskGate ? taskBindRunContext(taskContext) : undefined),
[taskBind, taskGate, taskContext],
);
const [thread, sendMessage] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
assistantId: agent_id,
@@ -381,6 +427,7 @@ export default function AgentChatPage({
...(knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
: {}),
...(taskBindRunContextValue ?? {}),
},
// 任务闸门:新建 thread 打 metadata.taskId 标签,供任务对话列表(1:N)按任务过滤;
// xdfx 额外打 metadata.actionId 标签,按「行动id」划分聊天记录(见 taskThreadMetadata)。
@@ -439,6 +486,143 @@ export default function AgentChatPage({
},
});
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
// 流式 stub),成功且真实产物落地后自动撤出。
const { displayThread } = useResearchWritingFeed(threadId, thread);
// ── agentfx「入库」协助卡 ──────────────────────────────────────────────────
// 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且
// 本轮流式结束后,向消息流尾部注入「是否入库」卡片。本地沙箱下 artifacts 可能
// 记录宿主真实路径,按文件名匹配(虚拟/宿主路径都命中)。任务已有详情**不抑制**
// 卡片(入库是整体替换,由卡片文案说明覆盖语义);已导入按 thread 记忆在
// sessionStorage(刷新后不再重复询问)、「暂不入库」为本次会话内记忆。
const reportImportSessionKey = threadId
? `agentfx.reportImport.done.${threadId}`
: "";
const [reportImportHidden] = useState(() => {
if (!reportImportSessionKey || typeof window === "undefined") return false;
try {
return sessionStorage.getItem(reportImportSessionKey) === "1";
} catch {
return false;
}
});
const [reportImportDismissed, setReportImportDismissed] = useState(false);
const reportJsonArtifactReady = (thread.values.artifacts ?? []).some((path) =>
path.replace(/\\/g, "/").endsWith("/task-reports.json"),
);
const showReportImportCard =
taskBind &&
!taskGate &&
!!taskId &&
reportJsonArtifactReady &&
!thread.isLoading &&
!reportImportHidden &&
!reportImportDismissed;
const markReportImported = useCallback(() => {
// 只写会话标记不改本地 state:导入成功的卡片保持挂载展示「已入库」,
// 刷新/重进后因标记存在不再重复出现。
if (!reportImportSessionKey) return;
try {
sessionStorage.setItem(reportImportSessionKey, "1");
} catch {
/* noop */
}
}, [reportImportSessionKey]);
// ── 技能入库成功 → 通知外部宿主(iframe 嵌入场景)──────────────────────────
// 技能路径的入库在后端 bash 里执行(卡片路径由 TaskReportImportCard 直接通知)。
// 这里从消息流检测 task_report_import.py 的成功 stdout(含 "success": true 与
// task_id),代为发送 window.top.postMessage。**只在回合结束后检测**(流式过程中
// thread.messages 每个增量都会变,全量扫描会拖垮页面);只看线程里**最后一次**
// 成功的技能入库:新一次入库会取代它触发新通知;刷新/重进旧对话时它已被记忆
// (sessionStorage 按 thread 存最近已通知的消息 id),不会重复通知。
const skillSavedNotifyKey = `agentfx.reportSavedNotified.${threadId}`;
const [notifiedSkillMsgId, setNotifiedSkillMsgId] = useState<string | null>(() => {
if (typeof window === "undefined") return null;
try {
return sessionStorage.getItem(skillSavedNotifyKey);
} catch {
return null;
}
});
useEffect(() => {
if (!taskBind || thread.isLoading) return;
const importCallIds = new Set<string>();
for (const message of thread.messages) {
if (message.type !== "ai" || !message.tool_calls) continue;
for (const call of message.tool_calls) {
const command = (call.args as { command?: unknown } | undefined)?.command;
if (
typeof command === "string" &&
command.includes("task_report_import.py") &&
call.id
) {
importCallIds.add(call.id);
}
}
}
if (importCallIds.size === 0) return;
let latest: { key: string; taskId: string } | null = null;
for (const message of thread.messages) {
if (message.type !== "tool" || !message.tool_call_id) continue;
if (!importCallIds.has(message.tool_call_id)) continue;
const content = typeof message.content === "string" ? message.content : "";
if (!/"success"\s*:\s*true/.test(content)) continue;
const taskIdMatch = content.match(/"task_id"\s*:\s*"([^"]+)"/);
latest = {
key: String(message.id ?? message.tool_call_id),
taskId: taskIdMatch?.[1] ?? taskId ?? "",
};
}
if (!latest || latest.key === notifiedSkillMsgId) return;
notifyTaskReportSaved(latest.taskId);
setNotifiedSkillMsgId(latest.key);
try {
sessionStorage.setItem(skillSavedNotifyKey, latest.key);
} catch {
/* noop */
}
}, [
taskBind,
thread.isLoading,
thread.messages,
taskId,
notifiedSkillMsgId,
skillSavedNotifyKey,
]);
const displayThreadWithImportCard = useMemo(() => {
if (!showReportImportCard) return displayThread;
const cardMessage = {
id: `agentfx-report-import-${threadId}`,
type: "ai",
content: "",
additional_kwargs: {
task_report_import_approval: {
taskId,
jsonPath: AGENTFX_REPORT_JSON_OUTPUT_PATH,
},
},
} as unknown as Message;
const messages = [...displayThread.messages, cardMessage];
return {
...displayThread,
values: { ...displayThread.values, messages },
messages,
} as typeof displayThread;
}, [displayThread, showReportImportCard, taskId, threadId]);
// 写作模式续写轮:已有 md 产物(report.md 已生成)时回传 writing_artifact_path,
// 后端据此走「直接编辑画布」分支而不是再次挂载深度研究写报告管线(与
// ChatPage 的回传逻辑对齐)。
const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) =>
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
return [...(thread.values.artifacts ?? [])]
.reverse()
.find((path) => isMarkdown(path));
}, [thread.values.artifacts]);
// ── 「模板数据转换器」对话页专属调试入口(仅此 agent)──────────────────────────
// 选一个页面模板,问答跑完后点「渲染预览」:取最近一条 AI 回复里的 JSON 填进模板,
// 弹出 HTML 预览 + 结构校验信息,用于调试该智能体的转换效果。
@@ -483,9 +667,19 @@ export default function AgentChatPage({
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void sendMessage(threadId, message, { ...(extraContext ?? {}), agent_id });
// 新一轮提问:清掉上一轮深度研究报告的注入状态(中断态步骤条等)。
if (threadId) clearResearchReportStream(threadId);
const writingExtraContext =
settings.context.writing_mode && writingArtifactPath
? { writing_artifact_path: writingArtifactPath }
: {};
void sendMessage(threadId, message, {
...writingExtraContext,
...(extraContext ?? {}),
agent_id,
});
},
[sendMessage, threadId, agent_id],
[sendMessage, threadId, agent_id, settings.context.writing_mode, writingArtifactPath],
);
// 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
@@ -585,7 +779,7 @@ export default function AgentChatPage({
: "ready";
return (
<ThreadContext.Provider value={{ thread }}>
<ThreadContext.Provider value={{ thread: displayThread }}>
{/* Root: fixed-height horizontal split. ChatBox (chat + artifact
preview) lives in the left flex cell; the agent detail panel is a
separate flex sibling OUTSIDE ChatBox, so the artifact preview
@@ -618,11 +812,11 @@ export default function AgentChatPage({
</span>
</div>
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
<div className="flex min-w-0 flex-1 items-center overflow-hidden text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="flex items-center">
<div className="flex shrink-0 items-center">
{/* 智能体使用引导(chat 阶段)手动重放入口。 */}
{agentGuideEligible && (
<Tooltip content="重新播放智能体使用引导">
@@ -719,7 +913,7 @@ export default function AgentChatPage({
)}
<TokenUsageIndicator
enabled={tokenUsageEnabled}
messages={thread.messages}
messages={displayThread.messages}
/>
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
@@ -772,18 +966,22 @@ export default function AgentChatPage({
onContextChange={(context) =>
setSettings("context", context)
}
// 任务深链工作区:受控场景,隐藏「写作/记忆/参考文献」与「知识空间」。
hideContextTools={taskCtxOn}
knowledgeSelector={
<LlmWikiKnowledgeSelector
source="agent"
agentId={agent_id}
value={selectedKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
autoSelectAll={
isNewThread && pendingKnowledgeSelection.current === null
}
disabled={isDemoMode || knowledgeSelectionLoading}
label="知识空间"
/>
taskCtxOn ? undefined : (
<LlmWikiKnowledgeSelector
source="agent"
agentId={agent_id}
value={selectedKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
autoSelectAll={
isNewThread && pendingKnowledgeSelection.current === null
}
disabled={isDemoMode || knowledgeSelectionLoading}
label="知识空间"
/>
)
}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
@@ -804,19 +1002,31 @@ export default function AgentChatPage({
{/* Question index pinned to this conversation column; the
bottom inset keeps it clear of the docked input box. */}
<QuestionNavigator
messages={thread.messages}
messages={displayThread.messages}
className="top-14 right-0 bottom-40 left-0"
/>
<MessageList
className="min-h-0 flex-1"
threadId={threadId}
thread={thread}
thread={displayThreadWithImportCard}
paddingBottom={showFollowups ? 96 : 24}
tokenUsageEnabled={tokenUsageEnabled}
// 输入框在流内(MessageList 下方),按钮贴对话区底部即可。
scrollButtonClassName="bottom-2"
onClarificationSubmit={handleClarificationSubmit}
onEditResubmit={(text) => handleSubmit({ text, files: [] })}
// agentfx「入库」协助卡:消息流尾部注入的虚拟消息经此插槽渲染。
taskReportImportSlot={(message) =>
hasTaskReportImportApproval(message) && showReportImportCard && taskId ? (
<TaskReportImportCard
taskId={taskId}
threadId={threadId}
jsonPath={AGENTFX_REPORT_JSON_OUTPUT_PATH}
onImported={markReportImported}
onDismiss={() => setReportImportDismissed(true)}
/>
) : null
}
/>
<div className="from-background pointer-events-none shrink-0 bg-gradient-to-t to-transparent px-4 pb-4 pt-2">
@@ -843,16 +1053,20 @@ export default function AgentChatPage({
onContextChange={(context) =>
setSettings("context", context)
}
// 任务深链工作区:受控场景,隐藏「写作/记忆/参考文献」与「知识空间」。
hideContextTools={taskCtxOn}
knowledgeSelector={
<LlmWikiKnowledgeSelector
source="agent"
agentId={agent_id}
value={selectedKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
disabled={isDemoMode || knowledgeSelectionLoading}
readOnly
label="知识空间"
/>
taskCtxOn ? undefined : (
<LlmWikiKnowledgeSelector
source="agent"
agentId={agent_id}
value={selectedKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
disabled={isDemoMode || knowledgeSelectionLoading}
readOnly
label="知识空间"
/>
)
}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
+17 -9
View File
@@ -61,6 +61,8 @@ import {
useThreadSettings,
} from "@/core/settings";
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag";
@@ -319,33 +321,37 @@ export default function ChatPage() {
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
const multiRoundRagSelected = isMultiRoundRagProfile(settings.context);
const frontendRagSelected = isFrontendRagProfile(settings.context);
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
// 流式 stub),成功且真实产物落地后自动撤出。
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
const displayThread = useMemo(() => {
const base = researchDisplayThread;
if (
singleRoundRag.messages.length === 0 &&
multiRoundRag.messages.length === 0 &&
!singleRoundRag.isLoading &&
!multiRoundRag.isLoading
) {
return thread;
return base;
}
const messages = [
...thread.messages,
...base.messages,
...singleRoundRag.messages,
...multiRoundRag.messages,
];
return {
...thread,
values: { ...thread.values, messages },
...base,
values: { ...base.values, messages },
messages,
isLoading:
thread.isLoading || singleRoundRag.isLoading || multiRoundRag.isLoading,
} as typeof thread;
base.isLoading || singleRoundRag.isLoading || multiRoundRag.isLoading,
} as typeof base;
}, [
multiRoundRag.isLoading,
multiRoundRag.messages,
singleRoundRag.isLoading,
singleRoundRag.messages,
thread,
researchDisplayThread,
]);
const writingArtifactPath = useMemo(() => {
@@ -491,6 +497,8 @@ export default function ChatPage() {
if (settings.context.writing_mode && writingArtifactPath) {
extraContext.writing_artifact_path = writingArtifactPath;
}
// 新一轮提问:清掉上一轮深度研究报告的注入状态(中断态步骤条等)。
if (threadId) clearResearchReportStream(threadId);
void sendMessage(
threadId,
message,
@@ -697,10 +705,10 @@ export default function ChatPage() {
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
)}
>
<div className="flex w-full items-center text-sm font-medium">
<div className="flex min-w-0 flex-1 items-center overflow-hidden text-sm font-medium">
<ThreadTitle threadId={threadId} thread={displayThread} />
</div>
<div className="flex items-center gap-2">
<div className="flex shrink-0 items-center gap-2">
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
{compactChrome && !historySidebarOpen && !compactActionsHidden && (
<Button
+39 -15
View File
@@ -25,7 +25,13 @@ import {
resolveDeeplinkTaskId,
resolveTaskDeeplinkTarget,
stubCopTaskDetail,
type TaskDeeplinkTarget,
} from '@/core/auth/task-deeplink';
import {
fetchAgentfxTaskDetail,
isAgentfxGoPath,
resolveAgentfxDeeplinkTarget,
} from '@/core/auth/agentfx-deeplink';
import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login';
import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
import { isEmbeddedHost } from '@/core/embed/frame-context';
@@ -465,22 +471,40 @@ export default function LoginPage() {
// 真正无法解析(goPath 无法识别 / 单智能体未配置业务映射)才回退默认首页。
if (taskId && goPath) {
try {
// xdfx 深链 ?taskId= 传入的是「行动id」,先换成真正的「任务id」;解析失败回退原始 id。
const resolvedTaskId = await resolveDeeplinkTaskId(goPath, taskId);
const realTaskId = resolvedTaskId || taskId;
if (cancelled) return;
let detail = await fetchCopTaskDetail(realTaskId);
if (cancelled) return;
if (!detail) {
console.warn('[login] cop-task-detail missing, continue with stub', {
goPath,
taskId,
realTaskId,
});
detail = stubCopTaskDetail(realTaskId);
let target: TaskDeeplinkTarget | null = null;
let realTaskId = taskId;
if (isAgentfxGoPath(goPath)) {
// agentfx 深链(新系统 iframe 嵌入):任务详情走本系统 Gateway 的
// cop-task-detail(带刚存好的登录态);拉取失败不阻断,用仅含任务 id 的 stub 继续。
let detail = await fetchAgentfxTaskDetail(taskId);
if (cancelled) return;
if (!detail) {
console.warn('[login] agentfx cop-task-detail missing, continue with stub', {
goPath,
taskId,
});
detail = stubCopTaskDetail(taskId);
}
persistCopTaskDetail(detail);
target = await resolveAgentfxDeeplinkTarget(detail, taskId);
} else {
// xdfx 深链 ?taskId= 传入的是「行动id」,先换成真正的「任务id」;解析失败回退原始 id。
const resolvedTaskId = await resolveDeeplinkTaskId(goPath, taskId);
realTaskId = resolvedTaskId || taskId;
if (cancelled) return;
let detail = await fetchCopTaskDetail(realTaskId);
if (cancelled) return;
if (!detail) {
console.warn('[login] cop-task-detail missing, continue with stub', {
goPath,
taskId,
realTaskId,
});
detail = stubCopTaskDetail(realTaskId);
}
persistCopTaskDetail(detail);
target = await resolveTaskDeeplinkTarget(goPath, detail, realTaskId, taskId);
}
persistCopTaskDetail(detail);
const target = await resolveTaskDeeplinkTarget(goPath, detail, realTaskId, taskId);
if (cancelled) return;
if (target) {
console.info('[login] deeplink →', target.path, { goPath, realTaskId });
+26
View File
@@ -18,6 +18,11 @@ import {
TaskFxIndexResolver,
TaskFxWorkspace,
} from "@/strategy-components/components/task-components/TaskFxWorkspace";
import {
AgentFxChat,
AgentFxIndexResolver,
AgentFxWorkspace,
} from "@/strategy-components/components/task-components/AgentFxWorkspace";
import { ActionAnalyzes } from "@/strategy-components/components/task-components/ActionAnalyzes";
import KnowledgeBaseResourceManagement from "@/strategy-components/components/resource-management/KnowledgeBaseResourceManagement";
import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement";
@@ -288,6 +293,27 @@ export default function StrategyRoutes() {
}
/>
</Route>
{/* 任务深链(agentfx):新系统 iframe 嵌入的智能体问答工作区(独立复制的 3qfx lite 版,
任务详情来自本系统 cop-task-detail;业务映射用 AFX 行,见 agentfx-deeplink)。 */}
<Route
path="plan-task-agentfx/agent/:agent_id/chats"
element={
<AgentFxWorkspace taskBase="/page/strategy/task-management/plan-task-agentfx" />
}
>
<Route
index
element={
<AgentFxIndexResolver taskBase="/page/strategy/task-management/plan-task-agentfx" />
}
/>
<Route
path=":thread_id"
element={
<AgentFxChat taskBase="/page/strategy/task-management/plan-task-agentfx" />
}
/>
</Route>
<Route path="mnty-task-mechanism" element={<TaskTyFrom defaultPage="iframeXdty" title="XDTY任务" />} />
<Route path="mnty-task-plan" element={<TaskTyFrom defaultPage="iframeFaty" title="方案TY任务" />} />
<Route path="mnty-task-zsty" element={<TaskTyFrom defaultPage="iframeSysystem" title="XXTY任务" />} />