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

@@ -12,10 +12,11 @@ export function FlipDisplay({
className?: string;
}) {
return (
<div className={cn("relative overflow-hidden", className)}>
<div className={cn("relative min-w-0 overflow-hidden", className)}>
<AnimatePresence mode="wait">
<motion.div
key={uniqueKey}
className="min-w-0 max-w-full"
initial={{ y: 8, opacity: 0 }}
animate={{ y: 2, opacity: 1 }}
exit={{ y: -8, opacity: 0 }}
@@ -4,9 +4,9 @@ import type { ChatStatus } from "ai";
import {
BrainCircuitIcon,
BookMarkedIcon,
CheckIcon,
FileTextIcon,
FolderTreeIcon,
CheckIcon,
FileTextIcon,
FolderTreeIcon,
GraduationCapIcon,
LightbulbIcon,
PaperclipIcon,
@@ -142,6 +142,7 @@ export function InputBox({
assistantId,
hideAdminRecommendedQuestions,
compact,
hideContextTools,
...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null;
@@ -149,6 +150,9 @@ export function InputBox({
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
compact?: boolean;
/** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
* 受控场景用;文件/模型等其他按钮不受影响)。 */
hideContextTools?: boolean;
disabled?: boolean;
context: Omit<
AgentThreadContext,
@@ -369,20 +373,20 @@ export function InputBox({
[onContextChange, context],
);
const handleWritingModeToggle = useCallback(() => {
const handleWritingModeToggle = useCallback(() => {
onContextChange?.({
...context,
writing_mode: !context.writing_mode,
});
}, [context, onContextChange]);
const artifactDirectoryModeEnabled =
localSettings.display.artifactDirectoryModeEnabled ?? false;
const handleArtifactDirectoryModeToggle = useCallback(() => {
setLocalSettings("display", {
artifactDirectoryModeEnabled: !artifactDirectoryModeEnabled,
});
}, [artifactDirectoryModeEnabled, setLocalSettings]);
}, [context, onContextChange]);
const artifactDirectoryModeEnabled =
localSettings.display.artifactDirectoryModeEnabled ?? false;
const handleArtifactDirectoryModeToggle = useCallback(() => {
setLocalSettings("display", {
artifactDirectoryModeEnabled: !artifactDirectoryModeEnabled,
});
}, [artifactDirectoryModeEnabled, setLocalSettings]);
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
const handleMemoryInjectionToggle = useCallback(() => {
@@ -745,11 +749,11 @@ export function InputBox({
)}
/>
)}
{!frontendRagActive && <Tooltip
{!frontendRagActive && !hideContextTools && <Tooltip
content={
context.writing_mode
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
: "开启写作模式:最终输出保存为 Markdown 文件并进入画布"
? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料"
: "开启写作模式:对话照常进行,交付时由深度研究管线撰写报告并保存为 Markdown 进入画布"
}
>
<PromptInputButton
@@ -769,38 +773,38 @@ export function InputBox({
)}
<FileTextIcon className="size-3" />
<span className="text-xs font-normal">写作</span>
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && <Tooltip
content={
artifactDirectoryModeEnabled
? "文件模式已开启:右侧会显示当前对话生成的全部文件目录"
: "开启文件模式:在右侧目录中浏览当前对话生成的全部文件"
}
>
<PromptInputButton
type="button"
aria-pressed={artifactDirectoryModeEnabled}
disabled={disabled}
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
artifactDirectoryModeEnabled
? "border border-sky-300 bg-sky-50 text-sky-700 hover:bg-sky-100 dark:border-sky-600 dark:bg-sky-950/45 dark:text-sky-200"
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleArtifactDirectoryModeToggle}
>
{artifactDirectoryModeEnabled && (
<CheckIcon className="size-3 text-sky-700 dark:text-sky-200" />
)}
<FolderTreeIcon className="size-3" />
<span className="text-xs font-normal">文件</span>
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && <Tooltip
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && <Tooltip
content={
artifactDirectoryModeEnabled
? "文件模式已开启:右侧会显示当前对话生成的全部文件目录"
: "开启文件模式:在右侧目录中浏览当前对话生成的全部文件"
}
>
<PromptInputButton
type="button"
aria-pressed={artifactDirectoryModeEnabled}
disabled={disabled}
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
artifactDirectoryModeEnabled
? "border border-sky-300 bg-sky-50 text-sky-700 hover:bg-sky-100 dark:border-sky-600 dark:bg-sky-950/45 dark:text-sky-200"
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleArtifactDirectoryModeToggle}
>
{artifactDirectoryModeEnabled && (
<CheckIcon className="size-3 text-sky-700 dark:text-sky-200" />
)}
<FolderTreeIcon className="size-3" />
<span className="text-xs font-normal">文件</span>
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && !hideContextTools && <Tooltip
content={
memoryInjectionEnabled
? "记忆注入已开启:对话前会注入历史记忆"
@@ -826,7 +830,7 @@ export function InputBox({
<span className="text-xs font-normal">记忆</span>
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && <Tooltip
{!frontendRagActive && !hideContextTools && <Tooltip
content={
ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示"
@@ -1,9 +1,9 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
BookOpenTextIcon,
CheckCircle2Icon,
ChevronDownIcon,
ChevronsDownIcon,
CheckCircle2Icon,
ChevronDownIcon,
ChevronsDownIcon,
ChevronUp,
FolderOpenIcon,
GlobeIcon,
@@ -16,7 +16,7 @@ import {
SquareTerminalIcon,
WrenchIcon,
} from "lucide-react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type AnchorHTMLAttributes } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type AnchorHTMLAttributes } from "react";
import {
ChainOfThought,
@@ -338,9 +338,9 @@ function ToolCall({
ragModeEnabled?: boolean;
}) {
const { t } = useI18n();
const displayMatch =
result !== undefined && isDisplaySamplePayload(result)
? null
const displayMatch =
result !== undefined && isDisplaySamplePayload(result)
? null
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
null);
const displayItems =
@@ -349,24 +349,24 @@ function ToolCall({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}))
: [];
const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
const wikiSearchLabel = name === "llmwiki_search"
? wikiQuery
? `Wiki 文档检索 "${wikiQuery}"`
: "Wiki 文档检索"
: null;
if (displayItems.length > 0) {
return (
<ChainOfThoughtStep
key={id}
label={
wikiSearchLabel || (displayMatch?.display.mode === "citation" || !displayMatch
? "检索参考来源"
: `获取 ${displayItems.length} 条结果`)
}
mode: item.displayMode ?? displayMatch.display.mode,
}))
: [];
const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
const wikiSearchLabel = name === "llmwiki_search"
? wikiQuery
? `Wiki 文档检索 "${wikiQuery}"`
: "Wiki 文档检索"
: null;
if (displayItems.length > 0) {
return (
<ChainOfThoughtStep
key={id}
label={
wikiSearchLabel || (displayMatch?.display.mode === "citation" || !displayMatch
? "检索参考来源"
: `获取 ${displayItems.length} 条结果`)
}
icon={SearchIcon}
>
<DisplayResultsList results={displayItems} />
@@ -390,7 +390,7 @@ function ToolCall({
mode: "citation" as const,
}));
return (
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
<DisplayResultsList results={defaultItems} />
</ChainOfThoughtStep>
);
@@ -610,64 +610,64 @@ function DeepResearchProgressStep({
? args.label
: "正在撰写研究报告";
const detail = typeof args.detail === "string" ? args.detail.trim() : "";
const thinking = typeof args.thinking === "string" ? args.thinking.trim() : "";
const active = args.status !== "complete";
const [thinkingOpen, setThinkingOpen] = useState(false);
const [autoScrollPaused, setAutoScrollPaused] = useState(false);
const thinkingContentRef = useRef<HTMLPreElement>(null);
const autoScrollEnabledRef = useRef(true);
const programmaticScrollRef = useRef(false);
const previousActiveRef = useRef(active);
const thinking = typeof args.thinking === "string" ? args.thinking.trim() : "";
const active = args.status !== "complete";
const [thinkingOpen, setThinkingOpen] = useState(false);
const [autoScrollPaused, setAutoScrollPaused] = useState(false);
const thinkingContentRef = useRef<HTMLPreElement>(null);
const autoScrollEnabledRef = useRef(true);
const programmaticScrollRef = useRef(false);
const previousActiveRef = useRef(active);
useEffect(() => {
if (active && thinking) setThinkingOpen(true);
}, [active, thinking]);
useEffect(() => {
if (active && !previousActiveRef.current) {
autoScrollEnabledRef.current = true;
setAutoScrollPaused(false);
}
previousActiveRef.current = active;
}, [active]);
const scrollToLatestThinking = useCallback(() => {
if (!autoScrollEnabledRef.current) return;
const content = thinkingContentRef.current;
if (!content) return;
// Scroll events do not identify their initiator reliably across browsers.
// Mark this assignment so it never disables following as if it were a
// user drag; a user-originated scroll away from the bottom still pauses it.
programmaticScrollRef.current = true;
content.scrollTop = content.scrollHeight;
requestAnimationFrame(() => {
programmaticScrollRef.current = false;
});
}, []);
useLayoutEffect(() => {
if (!active || !thinkingOpen || !thinking) return;
scrollToLatestThinking();
// A second frame covers the browser's delayed reflow of a growing <pre>.
const frame = requestAnimationFrame(scrollToLatestThinking);
return () => cancelAnimationFrame(frame);
}, [active, thinking, thinkingOpen, scrollToLatestThinking]);
const pauseAutoScrollOnManualScroll = (event: React.UIEvent<HTMLPreElement>) => {
if (!active || !autoScrollEnabledRef.current || programmaticScrollRef.current) return;
const { clientHeight, scrollHeight, scrollTop } = event.currentTarget;
// Being at the bottom is still the follow state. Only an actual move away
// from it (wheel, touch, keyboard or scrollbar drag) pauses auto-follow.
if (scrollTop + clientHeight >= scrollHeight - 4) return;
autoScrollEnabledRef.current = false;
setAutoScrollPaused(true);
};
const resumeAutoScroll = () => {
autoScrollEnabledRef.current = true;
setAutoScrollPaused(false);
requestAnimationFrame(scrollToLatestThinking);
};
useEffect(() => {
if (active && thinking) setThinkingOpen(true);
}, [active, thinking]);
useEffect(() => {
if (active && !previousActiveRef.current) {
autoScrollEnabledRef.current = true;
setAutoScrollPaused(false);
}
previousActiveRef.current = active;
}, [active]);
const scrollToLatestThinking = useCallback(() => {
if (!autoScrollEnabledRef.current) return;
const content = thinkingContentRef.current;
if (!content) return;
// Scroll events do not identify their initiator reliably across browsers.
// Mark this assignment so it never disables following as if it were a
// user drag; a user-originated scroll away from the bottom still pauses it.
programmaticScrollRef.current = true;
content.scrollTop = content.scrollHeight;
requestAnimationFrame(() => {
programmaticScrollRef.current = false;
});
}, []);
useLayoutEffect(() => {
if (!active || !thinkingOpen || !thinking) return;
scrollToLatestThinking();
// A second frame covers the browser's delayed reflow of a growing <pre>.
const frame = requestAnimationFrame(scrollToLatestThinking);
return () => cancelAnimationFrame(frame);
}, [active, thinking, thinkingOpen, scrollToLatestThinking]);
const pauseAutoScrollOnManualScroll = (event: React.UIEvent<HTMLPreElement>) => {
if (!active || !autoScrollEnabledRef.current || programmaticScrollRef.current) return;
const { clientHeight, scrollHeight, scrollTop } = event.currentTarget;
// Being at the bottom is still the follow state. Only an actual move away
// from it (wheel, touch, keyboard or scrollbar drag) pauses auto-follow.
if (scrollTop + clientHeight >= scrollHeight - 4) return;
autoScrollEnabledRef.current = false;
setAutoScrollPaused(true);
};
const resumeAutoScroll = () => {
autoScrollEnabledRef.current = true;
setAutoScrollPaused(false);
requestAnimationFrame(scrollToLatestThinking);
};
return (
<ChainOfThoughtStep
@@ -710,29 +710,29 @@ function DeepResearchProgressStep({
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
</p>
<div className="relative mt-1">
<pre
className="bg-muted/45 text-muted-foreground max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words"
onScroll={pauseAutoScrollOnManualScroll}
ref={thinkingContentRef}
>
{thinking}
</pre>
{active && autoScrollPaused && (
<Button
aria-label="恢复思考内容自动下滑"
className="absolute top-1/2 left-1/2 h-9 -translate-x-1/2 -translate-y-1/2 gap-1.5 bg-background/95 px-3 text-xs shadow-md backdrop-blur-sm hover:bg-background"
onClick={resumeAutoScroll}
size="sm"
title="恢复自动下滑"
type="button"
variant="outline"
>
<ChevronsDownIcon className="size-3.5" />
自动下滑
</Button>
)}
</div>
<div className="relative mt-1">
<pre
className="bg-muted/45 text-muted-foreground max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words"
onScroll={pauseAutoScrollOnManualScroll}
ref={thinkingContentRef}
>
{thinking}
</pre>
{active && autoScrollPaused && (
<Button
aria-label="恢复思考内容自动下滑"
className="absolute top-1/2 left-1/2 h-9 -translate-x-1/2 -translate-y-1/2 gap-1.5 bg-background/95 px-3 text-xs shadow-md backdrop-blur-sm hover:bg-background"
onClick={resumeAutoScroll}
size="sm"
title="恢复自动下滑"
type="button"
variant="outline"
>
<ChevronsDownIcon className="size-3.5" />
自动下滑
</Button>
)}
</div>
</CollapsibleContent>
</Collapsible>
)}
@@ -740,6 +740,13 @@ function DeepResearchProgressStep({
);
}
/** 文件卡片只显示文件名:宿主泄漏路径(F:\...\outputs\report.md)与虚拟路径
* (/mnt/user-data/outputs/report.md)统一收敛到最后一段,避免把服务端磁盘结构暴露给用户。 */
function artifactDisplayName(path: string): string {
const filename = path.replace(/\\/g, "/").split("/").filter(Boolean).at(-1);
return filename || path;
}
function ArtifactWriteToolCall({
id,
messageId,
@@ -815,7 +822,7 @@ function ArtifactWriteToolCall({
>
{(normalizedPath || sourcePath) && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{normalizedPath || sourcePath}
{artifactDisplayName(normalizedPath || (sourcePath ?? ""))}
</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
@@ -134,6 +134,7 @@ export function MessageList({
onSetupReportStructureApprove,
onSetupReportStructureReject,
researchApprovalSlot,
taskReportImportSlot,
onOpenArtifact,
onDownloadArtifact,
afterDocumentRewriteItems,
@@ -168,6 +169,12 @@ export function MessageList({
* assistant 消息行;不传该 slot 时普通对话/AI 写作零行为变化。
*/
researchApprovalSlot?: (message: Message) => React.ReactNode;
/**
* agentfx「入库」协助卡插槽:任务工作区宿主(AgentChatPage taskBind)向 displayThread
* 注入带 `additional_kwargs.task_report_import_approval` 的虚拟 ai 消息,由本插槽渲染
* 「是否入库」卡片。不传该 slot 时(普通对话等)该分组不渲染,零行为变化。
*/
taskReportImportSlot?: (message: Message) => React.ReactNode;
onOpenArtifact?: (filepath: string) => void;
/** Host override for downloading virtual/session-owned files in a present-files card. */
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
@@ -262,7 +269,7 @@ export function MessageList({
// Durable ask time per message id, used to time-stamp the AI answer (which has
// no timestamp of its own) with the time of the question that triggered it.
const turnTimes = useMemo(() => {
const turnTimes = useMemo(() => {
const map = new Map<string, string>();
let lastQuestionTime: string | undefined;
for (const message of messages) {
@@ -276,22 +283,22 @@ export function MessageList({
}
}
return map;
}, [messages, askTimes]);
// Associate each assistant answer with the nearest preceding user question.
// This powers the per-answer Word menu without changing message persistence.
const questionMarkdownByAssistantId = useMemo(() => {
const map = new Map<string, string>();
let currentQuestion = "";
for (const message of messages) {
if (message.type === "human") {
currentQuestion = extractContentFromMessage(message)?.trim() ?? "";
} else if (message.type === "ai" && message.id && currentQuestion) {
map.set(message.id, currentQuestion);
}
}
return map;
}, [messages]);
}, [messages, askTimes]);
// Associate each assistant answer with the nearest preceding user question.
// This powers the per-answer Word menu without changing message persistence.
const questionMarkdownByAssistantId = useMemo(() => {
const map = new Map<string, string>();
let currentQuestion = "";
for (const message of messages) {
if (message.type === "human") {
currentQuestion = extractContentFromMessage(message)?.trim() ?? "";
} else if (message.type === "ai" && message.id && currentQuestion) {
map.set(message.id, currentQuestion);
}
}
return map;
}, [messages]);
const [settings] = useLocalSettings();
const ragModeEnabled =
@@ -544,11 +551,11 @@ export function MessageList({
? getQuestionAnchorId(msg, messages.indexOf(msg))
: undefined
}
message={msg}
questionMarkdown={
msg.id ? questionMarkdownByAssistantId.get(msg.id) : undefined
}
isLoading={messageIsLoading}
message={msg}
questionMarkdown={
msg.id ? questionMarkdownByAssistantId.get(msg.id) : undefined
}
isLoading={messageIsLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
@@ -669,6 +676,14 @@ export function MessageList({
{researchApprovalSlot(message)}
</div>
) : null;
} else if (group.type === "assistant:task-report-import") {
// agentfx「入库」协助卡:宿主不传渲染函数时该分组不渲染。
const message = group.messages[0];
return taskReportImportSlot && message ? (
<div key={group.id} className="w-full pb-2">
{taskReportImportSlot(message)}
</div>
) : null;
} else if (group.type === "assistant:present-files") {
const files: string[] = [];
for (const message of group.messages) {
@@ -1,10 +1,10 @@
import type { BaseStream } from "@langchain/langgraph-sdk";
import { useEffect } from "react";
import { useI18n } from "@/core/i18n/hooks";
import type { AgentThreadState } from "@/core/threads";
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { useEffect } from "react";
import { useI18n } from "@/core/i18n/hooks";
import type { AgentThreadState } from "@/core/threads";
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { useThreadChat } from "./chats";
import { FlipDisplay } from "./flip-display";
@@ -17,18 +17,18 @@ export function ThreadTitle({
threadId: string;
thread: BaseStream<AgentThreadState>;
}) {
const { t } = useI18n();
const { isNewThread } = useThreadChat();
const displayTitle = sanitizeThreadTitleForDisplay(thread.values?.title);
useEffect(() => {
let _title = t.pages.untitled;
if (thread.values?.title) {
_title = displayTitle;
} else if (isNewThread) {
_title = t.pages.newChat;
}
const { t } = useI18n();
const { isNewThread } = useThreadChat();
const displayTitle = sanitizeThreadTitleForDisplay(thread.values?.title);
useEffect(() => {
let _title = t.pages.untitled;
if (thread.values?.title) {
_title = displayTitle;
} else if (isNewThread) {
_title = t.pages.newChat;
}
if (thread.isThreadLoading) {
document.title = `Loading... - ${t.pages.appName}`;
} else {
@@ -38,18 +38,21 @@ export function ThreadTitle({
isNewThread,
t.pages.newChat,
t.pages.untitled,
t.pages.appName,
thread.isThreadLoading,
thread.values,
displayTitle,
]);
t.pages.appName,
thread.isThreadLoading,
thread.values,
displayTitle,
]);
if (!thread.values?.title) {
return null;
}
return (
<FlipDisplay uniqueKey={threadId}>
{desensitize(displayTitle)}
</FlipDisplay>
);
}
const visibleTitle = desensitize(displayTitle);
return (
<FlipDisplay uniqueKey={threadId} className="min-w-0 w-full">
<span className="block truncate" title={visibleTitle}>
{visibleTitle}
</span>
</FlipDisplay>
);
}
@@ -0,0 +1,116 @@
/**
* 「agentfx」任务深链:复制自 [[task-deeplink]] 的 3qfx 单智能体模式,供新系统 iframe 嵌入
* (``${loginBase}?password=xxx&taskId=xxx&goPath=agentfx&embed=1&hideJump=1&theme=dark-blue``)。
*
* 与 3qfx 的差异(也是单独复制一份的原因,后续逻辑改动互不影响):
* 1. 任务详情不走外部 consumer(``VITE_AIPI_CONSUMER_URL``),改调本系统 Gateway 的
* ``GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=``(带 DeerFlow 登录态,任务数据
* 来自 TaskCOP 任务表,即任务列表 / 报告导入用的同一份)。
* 2. **固定使用内置智能体** ``agentfx-analyst``(后端 seeder ``_agentfx_seed.py`` 启动时补建:
* 搜集信息 → 生成 report.md + task-reports.json(14 类)→ 总结 → 前端「入库」协助卡 /
* task-report-import 技能入库),不走「配置管理」业务映射。
* 3. 路由挂在 ``plan-task-agentfx`` 前缀下,页面代码见 [[AgentFxWorkspace]]。
*
* 任务详情整包仍暂存到与 3qfx 相同的 sessionStorage 键(``login.copTaskDetail``,见
* [[persistCopTaskDetail]]):该键按标签页隔离(嵌入态无跨标签污染),工作区「当前任务」
* 卡片与 AgentChatPage 的 taskBind 自恢复逻辑都读它,保持行为一致。
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { clearTaskContext, persistTaskContext } from "@/core/auth/task-purpose";
import {
type CopTaskDetail,
type TaskDeeplinkTarget,
} from "@/core/auth/task-deeplink";
/** 深链 goPath 基础码(大小写不敏感匹配;不支持 ``-A`` 会商后缀)。 */
export const AGENTFX_GOPATH = "agentfx";
/**
* 固定使用的内置智能体 id(后端 ``_agentfx_seed.py`` 启动时补建并 upsert 进 agents 表,
* ``user_id IS NULL`` 对所有用户可见)。前端深链不查业务映射,直接进该智能体的问答。
*/
export const AGENTFX_BUILTIN_AGENT_ID = "agentfx-analyst";
/** 工作区路由前缀(与 StrategyRoutes 的 plan-task-agentfx 子树一致)。 */
export const AGENTFX_TASK_BASE = "/page/strategy/task-management/plan-task-agentfx";
/** 判断 goPath 是否为 agentfx(大小写不敏感)。 */
export function isAgentfxGoPath(goPath: string): boolean {
return goPath.trim().toLowerCase() === AGENTFX_GOPATH;
}
/**
* 拉取任务详情:本系统 Gateway 的 cop-task-detail(返回与旧 consumer 同构的
* ``{state, msg, data}`` 信封)。任务不存在 / 网络失败时返回 null,调用方(LoginPage)
* 用仅含任务 id 的 stub 兜底继续跳转。
*/
export async function fetchAgentfxTaskDetail(taskId: string): Promise<CopTaskDetail | null> {
if (!taskId) return null;
try {
// apiFetch 已统一注入 DeerFlow 登录态(Authorization / cookie),登录刚完成即可用。
const query = new URLSearchParams({ taskId });
const res = await apiFetch(
`${getBackendBaseURL()}/taskAnalyseSearch/cop-task-detail?${query.toString()}`,
{ method: "GET" },
);
if (!res.ok) {
console.warn("[agentfx-deeplink] cop-task-detail failed", { taskId, status: res.status });
return null;
}
const body = (await res.json().catch(() => null)) as { data?: unknown } | null;
const data = body && typeof body === "object" ? body.data : null;
if (!data || typeof data !== "object") return null;
const d = data as Record<string, unknown>;
return {
id: (d.id as string | number | null) ?? null,
overview: typeof d.overview === "string" ? d.overview : "",
taskDirection: typeof d.taskDirection === "string" ? d.taskDirection : "",
taskName: typeof d.taskName === "string" ? d.taskName : "",
taskContent: typeof d.taskContent === "string" ? d.taskContent : "",
};
} catch (error) {
console.warn("[agentfx-deeplink] cop-task-detail failed", { taskId, error });
return null;
}
}
/**
* 用 taskName / taskContent 拼开场信息文本;末尾附任务 id(task_id)供后端识别任务。
* 复制自 task-deeplink 的 buildOpeningText 基础三行(无 xdfx 行动追加),便于后续按新系统
* 要求单独调整格式。
*/
export function buildAgentfxOpeningText(detail: CopTaskDetail, taskId: string): string {
return [
detail.taskName ? `任务名称:${detail.taskName}` : "",
detail.taskContent ? `任务内容:${detail.taskContent}` : "",
taskId ? `任务id(task_id)为${taskId}` : "",
]
.filter(Boolean)
.join("\n");
}
/**
* 解析 agentfx 跳转目标:内置「任务研判报告智能体」的问答工作区(任务↔对话 1:N,按
* taskId + agentId 隔离)。清残留任务上下文 → 暂存任务上下文(taskId + agentId + 开场
* 信息)→ 返回 chats 索引路径,由工作区决定开最新历史对话 / 新建(首次无历史自动发
* 开场信息,见 AgentChatPage 的 taskBind)。
*/
export async function resolveAgentfxDeeplinkTarget(
detail: CopTaskDetail,
taskId: string,
): Promise<TaskDeeplinkTarget | null> {
// 清掉上一次深链可能残留的任务上下文(下面用本次任务重新写入)。
clearTaskContext();
const agentId = AGENTFX_BUILTIN_AGENT_ID;
persistTaskContext({
taskId,
agentId,
openingText: buildAgentfxOpeningText(detail, taskId),
actionId: taskId,
partitionByAction: false,
});
return { path: `${AGENTFX_TASK_BASE}/agent/${agentId}/chats` };
}
+22
View File
@@ -26,6 +26,8 @@ interface AssistantSetupReportStructureApprovalGroup extends GenericMessageGroup
interface AssistantResearchApprovalGroup extends GenericMessageGroup<"assistant:research-approval"> {}
interface AssistantTaskReportImportGroup extends GenericMessageGroup<"assistant:task-report-import"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
type MessageGroup =
@@ -38,6 +40,7 @@ type MessageGroup =
| AssistantSetupWritingApprovalGroup
| AssistantSetupReportStructureApprovalGroup
| AssistantResearchApprovalGroup
| AssistantTaskReportImportGroup
| AssistantSubagentGroup;
export function groupMessages<T>(
@@ -146,6 +149,16 @@ export function groupMessages<T>(
});
continue;
}
// agentfx「入库」协助卡:虚拟 ai 消息(additional_kwargs.task_report_import_approval),
// 由任务工作区宿主(AgentChatPage taskBind)注入,经 taskReportImportSlot 渲染。
if (hasTaskReportImportApproval(message)) {
groups.push({
id: message.id,
type: "assistant:task-report-import",
messages: [message],
});
continue;
}
if (hasPresentFiles(message)) {
groups.push({
id: message.id,
@@ -452,6 +465,15 @@ export function hasResearchApproval(message: Message) {
);
}
/** agentfx「入库」协助卡:宿主注入的虚拟 ai 消息(additional_kwargs.task_report_import_approval)。 */
export function hasTaskReportImportApproval(message: Message) {
return (
message.type === "ai" &&
typeof message.additional_kwargs?.task_report_import_approval === "object" &&
message.additional_kwargs.task_report_import_approval !== null
);
}
export function extractPresentFilesFromMessage(message: Message) {
if (message.type !== "ai" || !hasPresentFiles(message)) {
return [];
+31 -2
View File
@@ -24,6 +24,10 @@ import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
import type { AgentThread, AgentThreadState } from "./types";
import {
finalizeResearchReportStream,
ingestResearchReportEvent,
} from "./use-research-report-stream";
import {
collectHumanMessageIds,
hasServerEchoedOptimisticHuman,
@@ -774,6 +778,25 @@ export function useThreadStream({
},
onCustomEvent(event: unknown) {
markStreamActivity();
// 写作模式内联深度研究的进度帧(deep_research_report 工具经
// get_stream_writer 转发):归约进 per-thread 状态,聊天页据此注入
// 进度步骤条与 report.md 流式预览。
if (
typeof event === "object" &&
event !== null &&
"type" in event &&
event.type === "deep_research_report" &&
"event" in event &&
typeof (event as { event?: unknown }).event === "object" &&
(event as { event?: unknown }).event !== null
) {
const tid = threadIdRef.current ?? onStreamThreadId ?? null;
const frame = (event as { event: Record<string, unknown> }).event;
if (tid && typeof frame.type === "string") {
ingestResearchReportEvent(tid, frame as Parameters<typeof ingestResearchReportEvent>[1]);
}
return;
}
if (
typeof event === "object" &&
event !== null &&
@@ -912,6 +935,10 @@ export function useThreadStream({
return;
}
setStreamHoldOpen(false);
// run 真正终结(非 rejoin):若深度研究报告流仍 active(tool_finished
// 帧丢失/被取消),标记中断,避免进度条永久转圈。
const finThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
if (finThreadId) finalizeResearchReportStream(finThreadId);
listeners.current.onFinish?.(state.values);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
});
@@ -1618,8 +1645,10 @@ export function useThreadStream({
activeRunIdRef.current ?? (tid ? readActiveRunId(tid) : null);
setStreamHoldOpen(false);
activeRunIdRef.current = null;
if (tid && runId) {
persistActiveRunId(tid, runId);
if (tid) {
// 用户显式停止:深度研究报告流立即定格为中断。
finalizeResearchReportStream(tid);
if (runId) persistActiveRunId(tid, runId);
}
try {
await Promise.resolve(threadStreamRef.current?.stop());
@@ -0,0 +1,216 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ResearchReportStreamState } from "./use-research-report-stream";
import {
buildResearchFeedMessages,
buildResearchProgressToolCalls,
buildResearchReportVirtualUrl,
clearResearchReportStream,
finalizeResearchReportStream,
ingestResearchReportEvent,
peekResearchReportStream,
subscribeResearchReportStream,
} from "./use-research-report-stream";
import { buildWriteFileVirtualUrl } from "../artifacts/streaming-write-preview";
// 模块级 store 按 threadId 隔离;每个测试用独立 threadId 避免串扰。
let seq = 0;
const nextThread = () => `t-${++seq}`;
function ingestAll(threadId: string, frames: Array<Record<string, unknown>>) {
for (const frame of frames) {
ingestResearchReportEvent(
threadId,
frame as Parameters<typeof ingestResearchReportEvent>[1],
);
}
}
function streamOf(overrides: Partial<ResearchReportStreamState> = {}): ResearchReportStreamState {
return {
status: "active",
jobId: "j",
phase: "initializing",
queries: [],
completedQueries: 0,
sourceCount: 0,
reportText: "",
thinking: "",
error: null,
...overrides,
};
}
test("ingest accumulates plan/collect progress frames", () => {
const tid = nextThread();
ingestAll(tid, [
{ type: "phase_changed", jobId: "j1", phase: "planning", payload: { to: "planning" } },
{ type: "queries_planned", jobId: "j1", payload: { queries: ["q1", "q2", "q3"] } },
{ type: "phase_changed", jobId: "j1", phase: "collecting", payload: { to: "collecting" } },
{ type: "query_completed", jobId: "j1", payload: { query: "q1", resultCount: 5 } },
{ type: "query_completed", jobId: "j1", payload: { query: "q2", resultCount: 3 } },
{ type: "sources_curated", jobId: "j1", payload: { selectedIds: ["a", "b", "c", "d"] } },
]);
const state = peekResearchReportStream(tid);
assert.equal(state?.jobId, "j1");
assert.equal(state?.phase, "collecting");
assert.deepEqual(state?.queries, ["q1", "q2", "q3"]);
assert.equal(state?.completedQueries, 2);
// query_completed 累计 8,sources_curated 的 4 条选中集覆盖为最终值。
assert.equal(state?.sourceCount, 4);
assert.equal(state?.status, "active");
});
test("report_delta and report_chunk dedupe by offset when both arrive", () => {
const tid = nextThread();
ingestAll(tid, [
{ type: "report_delta", jobId: "j2", phase: "writing", payload: { delta: "第一段", offset: 0 } },
{ type: "report_chunk", jobId: "j2", phase: "writing", payload: { delta: "第一段", offset: 0 } },
{ type: "report_chunk", jobId: "j2", phase: "writing", payload: { delta: "第二段", offset: 3 } },
{ type: "report_delta", jobId: "j2", phase: "writing", payload: { delta: "第二段", offset: 3 } },
]);
assert.equal(peekResearchReportStream(tid)?.reportText, "第一段第二段");
});
test("an offset-0 frame with different content rewrites from that offset (workbench parity)", () => {
const tid = nextThread();
ingestAll(tid, [
{ type: "report_delta", jobId: "j2b", payload: { delta: "第一段第二段", offset: 0 } },
// 生产端从头重写(如 reset 后重发):内容不匹配 → 从 offset 0 覆盖。
{ type: "report_delta", jobId: "j2b", payload: { delta: "全新开头", offset: 0 } },
]);
assert.equal(peekResearchReportStream(tid)?.reportText, "全新开头");
});
test("non-report targets (sources/plan json) never pollute reportText", () => {
const tid = nextThread();
ingestAll(tid, [
{ type: "report_delta", jobId: "j3", payload: { target: "sources", delta: "[1] 来源", offset: 0 } },
{ type: "report_chunk", jobId: "j3", payload: { target: "report", delta: "# 报告", offset: 0 } },
]);
assert.equal(peekResearchReportStream(tid)?.reportText, "# 报告");
});
test("a new jobId resets prior state", () => {
const tid = nextThread();
ingestAll(tid, [
{ type: "report_delta", jobId: "old", payload: { delta: "旧报告", offset: 0 } },
{ type: "queries_planned", jobId: "new", payload: { queries: ["n1"] } },
]);
const state = peekResearchReportStream(tid);
assert.equal(state?.jobId, "new");
assert.equal(state?.reportText, "");
assert.deepEqual(state?.queries, ["n1"]);
});
test("tool_finished marks success and finalize guards only active runs", () => {
const tid = nextThread();
ingestAll(tid, [
{ type: "report_delta", jobId: "j4", payload: { delta: "正文", offset: 0 } },
{ type: "tool_finished", jobId: "j4", payload: { status: "success" } },
]);
assert.equal(peekResearchReportStream(tid)?.status, "success");
// 已 success 的状态不被 finalize 翻转。
finalizeResearchReportStream(tid);
assert.equal(peekResearchReportStream(tid)?.status, "success");
const tid2 = nextThread();
ingestAll(tid2, [
{ type: "report_delta", jobId: "j5", payload: { delta: "写到一半", offset: 0 } },
]);
finalizeResearchReportStream(tid2);
const interrupted = peekResearchReportStream(tid2);
assert.equal(interrupted?.status, "error");
assert.equal(interrupted?.error, "已中断");
clearResearchReportStream(tid2);
assert.equal(peekResearchReportStream(tid2), null);
});
test("progress tool calls reveal stages stepwise and mark interruption", () => {
const active = streamOf({
jobId: "j6",
phase: "collecting",
queries: ["q1", "q2"],
completedQueries: 1,
sourceCount: 3,
});
const calls = buildResearchProgressToolCalls(active);
assert.equal(calls.length, 2); // 拆解已完成 + 检索进行中
assert.equal(calls[0].args.status, "complete");
assert.equal(calls[1].args.status, "active");
assert.match(calls[1].args.detail, /1\/2/);
const writing = streamOf({ jobId: "j7", phase: "writing", reportText: "# x" });
assert.equal(buildResearchProgressToolCalls(writing).length, 3);
assert.equal(buildResearchProgressToolCalls(writing)[2].args.status, "active");
const failed = streamOf({ jobId: "j8", phase: "writing", status: "error", error: "模型超时" });
const failedCalls = buildResearchProgressToolCalls(failed);
assert.match(failedCalls.at(-1)?.args.label ?? "", /已中断/);
assert.match(failedCalls.at(-1)?.args.detail ?? "", /模型超时/);
});
test("feed messages attach the write_file stub only once report text exists", () => {
const noText = streamOf({ jobId: "j9", phase: "collecting" });
assert.equal(buildResearchFeedMessages(noText).length, 1);
assert.equal(buildResearchFeedMessages(noText)[0].tool_calls[0].name, "deep_research_progress");
const withText = streamOf({ jobId: "j10", phase: "writing", reportText: "# 报告正文" });
const feed = buildResearchFeedMessages(withText);
assert.equal(feed.length, 2);
const stub = feed[1].tool_calls[0];
assert.equal(stub.name, "write_file");
assert.equal(stub.args.path, "/mnt/user-data/outputs/report.md");
assert.equal(stub.args.content, "# 报告正文");
});
test("virtual URL matches the message-list ArtifactWriteToolCall URL (one suppression key)", () => {
const state = streamOf({ jobId: "j11", phase: "writing", reportText: "# x" });
// 显式自动开沙箱(useResearchWritingFeed)与消息列表里 ArtifactWriteToolCall
// 各自构造的 write-file 虚拟 URL 必须完全一致:二者共用同一 previewKey
// (`message_id:tool_call_id`),用户手动关闭一次即对两条路径同时生效。
assert.equal(
buildResearchReportVirtualUrl(state),
buildWriteFileVirtualUrl({
path: "/mnt/user-data/outputs/report.md",
messageId: "research-report-j11",
toolCallId: "research-report-j11-write",
}),
);
});
test("token-frame notifications are coalesced; terminal frames flush immediately", async () => {
const tid = nextThread();
let notifications = 0;
const unsubscribe = subscribeResearchReportStream(tid, () => {
notifications += 1;
});
try {
ingestAll(tid, [
{ type: "report_thinking", jobId: "jc", phase: "writing", payload: { delta: "思考" } },
{ type: "report_delta", jobId: "jc", phase: "writing", payload: { delta: "第一", offset: 0 } },
{ type: "report_delta", jobId: "jc", phase: "writing", payload: { delta: "第二", offset: 2 } },
{ type: "report_delta", jobId: "jc", phase: "writing", payload: { delta: "第三", offset: 4 } },
]);
// 状态同步更新(快照立即可见),但通知被合并——节流窗口内一次不发。
assert.equal(peekResearchReportStream(tid)?.reportText, "第一第二第三");
assert.equal(notifications, 0);
// 窗口(100ms)过后一次性通知。
await new Promise((resolve) => setTimeout(resolve, 200));
assert.equal(notifications, 1);
// 终态帧不等待节流窗口,立即通知。
ingestAll(tid, [{ type: "tool_finished", jobId: "jc", payload: { status: "success" } }]);
assert.equal(peekResearchReportStream(tid)?.status, "success");
assert.equal(notifications, 2);
} finally {
unsubscribe();
}
});
@@ -0,0 +1,378 @@
"use client";
/**
* 聊天「写作模式」内联深度研究报告的流式状态。
*
* 后端 `deep_research_report` 工具把引擎事件经 LangGraph custom 通道
* (`get_stream_writer`)以 `{type: "deep_research_report", event: {...}}`
* 帧发到前端 `onCustomEvent`;本模块按 threadId 归约这些帧,供聊天页注入
* 进度步骤条(`deep_research_progress` 虚拟工具调用)与 report.md 的
* `write_file` stub 流式预览(复用沙箱 write-file 虚拟 URL 机制)。
*
* 与工作台 SSE 不同:custom 事件不落库、断线不重放,刷新页面即丢——
* 报告完成后 md 已持久化在聊天线程 artifacts,由真实产物接管展示。
*/
import { useCallback, useSyncExternalStore } from "react";
/** 深度研究 basic 管线的阶段序(与后端 events.ALL_PHASES 对齐的子集)。 */
const PHASE_ORDER = [
"initializing",
"planning",
"collecting",
"curating",
"compressing",
"writing",
"done",
] as const;
function phaseRank(phase: string): number {
const index = PHASE_ORDER.indexOf(phase as (typeof PHASE_ORDER)[number]);
return index === -1 ? 0 : index;
}
export type ResearchReportStreamState = {
/** "active" | "success" | "error"(含被取消/断流)。 */
status: "active" | "success" | "error";
/** 工具调用 id(帧里的 jobId),一次写作轮一个。 */
jobId: string;
phase: string;
queries: string[];
completedQueries: number;
sourceCount: number;
/** report_delta/report_chunk 按 offset 去重拼装的报告全文。 */
reportText: string;
/** report_thinking 增量(写作思考,可选展示)。 */
thinking: string;
error: string | null;
};
type Listener = () => void;
type StoreEntry = {
state: ResearchReportStreamState | null;
listeners: Set<Listener>;
emitTimer: ReturnType<typeof setTimeout> | null;
};
const EMPTY_ACTIVE: ResearchReportStreamState = {
status: "active",
jobId: "",
phase: "initializing",
queries: [],
completedQueries: 0,
sourceCount: 0,
reportText: "",
thinking: "",
error: null,
};
const stores = new Map<string, StoreEntry>();
function getStore(threadId: string): StoreEntry {
let store = stores.get(threadId);
if (!store) {
store = { state: null, listeners: new Set(), emitTimer: null };
stores.set(threadId, store);
}
return store;
}
/**
* token 帧的通知合并间隔(ms)。report_delta / report_thinking 是 token 级
* 高频帧(每秒可达数十帧);状态本身同步更新(字符串拼接很便宜),但订阅
* 通知若逐帧发出,会让聊天页整棵树(长消息列表 + 沙箱 Markdown 预览)按
* token 频率重渲染,页面直接卡死。工作台用 requestAnimationFrame 批处理
* (DeepResearchWorkbench),聊天这里等效地按固定间隔合并——每秒最多
* 10 次通知,流式观感不受影响。终态帧(tool_finished)与
* finalize/clear 立即 flush,结束态不滞后。
*/
const EMIT_COALESCE_MS = 100;
function emitNow(store: StoreEntry) {
if (store.emitTimer !== null) {
clearTimeout(store.emitTimer);
store.emitTimer = null;
}
for (const listener of store.listeners) listener();
}
function emitCoalesced(store: StoreEntry) {
if (store.emitTimer !== null) return;
store.emitTimer = setTimeout(() => {
store.emitTimer = null;
for (const listener of store.listeners) listener();
}, EMIT_COALESCE_MS);
}
export function clearResearchReportStream(threadId: string) {
const store = getStore(threadId);
if (store.state === null) return;
store.state = null;
emitNow(store);
}
/** run 结束(成功/失败/取消/断流)时收尾仍处于 active 的状态。 */
export function finalizeResearchReportStream(threadId: string) {
const store = getStore(threadId);
if (store.state?.status !== "active") return;
store.state = { ...store.state, status: "error", error: store.state.error ?? "已中断" };
emitNow(store);
}
/** 与工作台 appendReportDelta 相同的 offset 去重追加。 */
function appendWithOffset(current: string, delta: string, offset: unknown): string {
if (!delta) return current;
if (typeof offset !== "number" || !Number.isFinite(offset) || offset < 0) return current + delta;
if (offset >= current.length) return current + delta;
if (current.slice(offset, offset + delta.length) === delta) return current;
return current.slice(0, offset) + delta;
}
/** onCustomEvent 深度研究帧的 event 载荷。 */
export type ResearchReportEventFrame = {
type: string;
phase?: string;
payload?: Record<string, unknown>;
};
export function ingestResearchReportEvent(threadId: string, frame: ResearchReportEventFrame) {
const store = getStore(threadId);
const jobId = typeof (frame as { jobId?: unknown }).jobId === "string"
? String((frame as { jobId?: unknown }).jobId)
: "";
const previous = store.state;
// 新一轮工具调用(jobId 变化)从零开始,旧状态自然淘汰。
const base: ResearchReportStreamState = previous && previous.jobId === jobId
? previous
: { ...EMPTY_ACTIVE, jobId };
const payload = frame.payload ?? {};
let next = base;
switch (frame.type) {
case "phase_changed":
next = { ...base, phase: typeof payload.to === "string" ? payload.to : base.phase };
break;
case "queries_planned":
next = {
...base,
queries: Array.isArray(payload.queries)
? payload.queries.filter((q): q is string => typeof q === "string")
: base.queries,
completedQueries: 0,
};
break;
case "query_completed":
next = {
...base,
completedQueries: base.completedQueries + 1,
sourceCount: base.sourceCount + (typeof payload.resultCount === "number" ? payload.resultCount : 0),
};
break;
case "sources_curated": {
const selected = Array.isArray(payload.selectedIds) ? payload.selectedIds.length : null;
next = {
...base,
sourceCount: selected !== null && selected > 0 ? selected : base.sourceCount,
};
break;
}
case "report_delta":
case "report_chunk": {
if (typeof payload.target === "string" && payload.target !== "report") break;
const delta = typeof payload.delta === "string" ? payload.delta : "";
const reportText = appendWithOffset(base.reportText, delta, payload.offset);
next = reportText === base.reportText ? base : { ...base, reportText };
break;
}
case "report_thinking": {
const delta = typeof payload.delta === "string" ? payload.delta : "";
next = delta ? { ...base, thinking: base.thinking + delta } : base;
break;
}
case "report_completed":
next = {
...base,
phase: "writing",
sourceCount: typeof payload.sourceCount === "number" && payload.sourceCount > 0
? payload.sourceCount
: base.sourceCount,
};
break;
case "warning":
case "error": {
const message = typeof payload.message === "string" ? payload.message : "";
if (payload.recoverable === false && message) {
next = { ...base, error: message };
}
break;
}
case "tool_finished":
next = {
...base,
status: payload.status === "success" ? "success" : "error",
error: typeof payload.error === "string" ? payload.error : base.error,
phase: payload.status === "success" ? "done" : base.phase,
};
break;
default:
break;
}
if (frame.phase && next === base) next = { ...base, phase: frame.phase };
if (next !== previous) {
store.state = next;
// 状态同步更新(peek/快照立即可见);通知按 token 帧频率合并,终态立即。
if (frame.type === "tool_finished") {
emitNow(store);
} else {
emitCoalesced(store);
}
}
}
/** 订阅某线程的报告流状态;无进行中/已结束状态时返回 null。 */
export function useResearchReportStream(threadId: string | null | undefined): ResearchReportStreamState | null {
const subscribe = useCallback(
(listener: Listener) => {
if (!threadId) return () => {};
return subscribeResearchReportStream(threadId, listener);
},
[threadId],
);
const getSnapshot = useCallback(() => (threadId ? getStore(threadId).state : null), [threadId]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
/**
* 直接订阅某线程的通知(节流合并后触发,非逐 token 帧);返回退订函数。
* `useResearchReportStream` 内部也走这里,测试可直接验证合并语义。
*/
export function subscribeResearchReportStream(threadId: string, listener: Listener): () => void {
const store = getStore(threadId);
store.listeners.add(listener);
return () => {
store.listeners.delete(listener);
};
}
/** 非订阅式快照读取(测试 / 调试用)。 */
export function peekResearchReportStream(threadId: string): ResearchReportStreamState | null {
return getStore(threadId).state;
}
// ── 聊天页注入消息构造 ────────────────────────────────────────────────────────
export const RESEARCH_REPORT_OUTPUT_PATH = "/mnt/user-data/outputs/report.md";
export type ResearchProgressToolCall = {
id: string;
name: "deep_research_progress";
args: {
label: string;
detail: string;
status: "complete" | "active";
thinking?: string;
};
};
/** 结构与工作台 buildWritingProgressToolCalls 相同;步骤固定为 basic 管线四段。 */
export function buildResearchProgressToolCalls(state: ResearchReportStreamState): ResearchProgressToolCall[] {
const rank = phaseRank(state.phase);
const finished = state.status !== "active";
const effectiveRank = finished && state.status === "success" ? PHASE_ORDER.length - 1 : rank;
const stages = [
{
id: "plan",
label: "拆解检索问题",
boundary: phaseRank("collecting"),
detail: state.queries.length > 0
? `已拆出 ${state.queries.length} 个检索子问题`
: "正在围绕课题拆解检索子问题",
},
{
id: "collect",
label: "检索参考资料",
boundary: phaseRank("compressing"),
detail: state.queries.length > 0
? `已检索 ${Math.min(state.completedQueries, state.queries.length)}/${state.queries.length} 个子问题 · 累计 ${state.sourceCount} 条资料`
: state.sourceCount > 0
? `已检索到 ${state.sourceCount} 条资料`
: "正在逐个子问题检索资料",
},
{
id: "write",
label: "撰写研究报告",
boundary: PHASE_ORDER.length,
detail: state.reportText
? "正在按「通用分析结构」逐字撰写,右侧沙箱同步预览"
: "正在整理资料并开始撰写报告",
},
];
const activeIndex = stages.findIndex((stage) => effectiveRank < stage.boundary);
const visibleLast = activeIndex === -1 ? stages.length - 1 : activeIndex;
return stages.slice(0, visibleLast + 1).map((stage, index) => {
const complete = effectiveRank >= stage.boundary;
const failed = finished && state.status === "error" && index === visibleLast && !complete;
return {
id: `research-progress-${state.jobId}-${stage.id}`,
name: "deep_research_progress" as const,
args: {
label: failed ? `${stage.label}(已中断)` : stage.label,
detail: failed && state.error ? `${stage.detail};${state.error}` : stage.detail,
status: complete ? ("complete" as const) : ("active" as const),
...(stage.id === "write" && state.thinking.trim() ? { thinking: state.thinking } : {}),
},
};
});
}
export type ResearchFeedMessage = {
id: string;
type: "ai";
content: string;
tool_calls: {
id: string;
name: string;
args: Record<string, unknown>;
}[];
};
/**
* 组装聊天页要注入的两条虚拟 ai 消息:进度步骤条 + report.md 的 write_file
* stub(沙箱 write-file 虚拟 URL 预览的数据源)。仅在报告尚无正文或仍在
* 流式时输出 stub —— 完成后由真实 artifacts 接管。
*/
export function buildResearchFeedMessages(state: ResearchReportStreamState): ResearchFeedMessage[] {
const messages: ResearchFeedMessage[] = [
{
id: `research-progress-${state.jobId}`,
type: "ai",
content: "",
tool_calls: buildResearchProgressToolCalls(state),
},
];
if (state.reportText.trim()) {
messages.push({
id: `research-report-${state.jobId}`,
type: "ai",
content: "",
tool_calls: [
{
id: `research-report-${state.jobId}-write`,
name: "write_file",
args: { path: RESEARCH_REPORT_OUTPUT_PATH, content: state.reportText },
},
],
});
}
return messages;
}
/** write-file 虚拟 URL(ArtifactFileDetail 用它从 stub 消息读流式内容)。 */
export function buildResearchReportVirtualUrl(state: ResearchReportStreamState): string {
return new URL(
`write-file:${RESEARCH_REPORT_OUTPUT_PATH}?message_id=${encodeURIComponent(`research-report-${state.jobId}`)}&tool_call_id=${encodeURIComponent(`research-report-${state.jobId}-write`)}&language=markdown`,
).toString();
}
@@ -0,0 +1,107 @@
"use client";
/**
* 聊天页「写作模式」深度研究进度注入。
*
* 把 `useResearchReportStream` 的流式状态转成 displayThread 尾部追加的
* 虚拟消息(进度步骤条 + report.md 的 write_file stub):
*
* - 进行中 / 中断:始终注入(中断态展示「已中断」,下一轮 jobId 变化自然淘汰);
* - 成功:注入到真实 report.md 产物落地为止(artifacts 出现该路径即撤出),
* 之后由真实的工具消息 + 产物卡片接管展示,避免闪烁与重复。
*
* 沙箱时机:报告首帧正文到达且仍在写入时,这里直接调 `openStreamedArtifact`
* 选中 write-file 虚拟预览并展开右侧沙箱,让报告「打开沙箱后在沙箱里边写边
* 预览」——与工作台 `useReportSandbox` 同一语义,不依赖消息列表里
* `ArtifactWriteToolCall` 的 isLast/isLoading 判定(长工具运行期间该链路不
* 稳定);用户手动关闭本条预览后不会强行弹回。完成后(真实 report.md 落
* 地、stub 撤出前)再把沙箱选中项从虚拟预览交接到真实产物。
*/
import { useEffect, useMemo, useRef } from "react";
import type { Message } from "@langchain/langgraph-sdk";
import {
artifactPreviewKey,
useArtifactsOptional,
} from "@/components/workspace/artifacts/context";
import {
buildResearchFeedMessages,
buildResearchReportVirtualUrl,
RESEARCH_REPORT_OUTPUT_PATH,
useResearchReportStream,
} from "./use-research-report-stream";
type FeedThreadLike = {
messages: Message[];
values: { artifacts?: string[] } & Record<string, unknown>;
isLoading?: boolean;
};
export function useResearchWritingFeed<T extends FeedThreadLike>(
threadId: string | null | undefined,
thread: T,
): { displayThread: T } {
const research = useResearchReportStream(threadId);
const artifacts = useArtifactsOptional();
// 一次写作轮(jobId)只在首帧正文到达时打开一次沙箱。
const autoOpenedJobRef = useRef<string | null>(null);
const handedOverJobRef = useRef<string | null>(null);
const hasRealArtifact = useMemo(
() => (thread.values?.artifacts ?? []).some((path) => path === RESEARCH_REPORT_OUTPUT_PATH),
[thread.values?.artifacts],
);
// 报告首帧正文到达且仍在写入 → 主动展开右侧沙箱并选中 write-file 虚拟
// 预览,报告在沙箱里边写边流式渲染。
useEffect(() => {
if (!research || research.status !== "active" || !research.reportText.trim()) {
return;
}
if (autoOpenedJobRef.current === research.jobId) return;
autoOpenedJobRef.current = research.jobId;
const virtualUrl = buildResearchReportVirtualUrl(research);
artifacts?.openStreamedArtifact(virtualUrl, artifactPreviewKey(virtualUrl));
}, [artifacts, research]);
// 完成交接:真实 report.md 落地、stub 即将撤出时,把沙箱从 write-file
// 虚拟预览切到真实产物(否则虚拟 URL 的内容源消失会白屏,且 chat-box 的
// 新产物自动选中因 selectedArtifact 非空而被跳过)。用户中途手动切到别
// 的产物则不打扰。
useEffect(() => {
if (!research || research.status !== "success" || !hasRealArtifact) return;
if (handedOverJobRef.current === research.jobId) return;
if (!artifacts) return;
handedOverJobRef.current = research.jobId;
const current = artifacts.selectedArtifact;
if (current !== null && current !== buildResearchReportVirtualUrl(research)) {
return;
}
artifacts.select(RESEARCH_REPORT_OUTPUT_PATH);
artifacts.setOpen(true);
}, [artifacts, hasRealArtifact, research]);
const feedMessages = useMemo(() => {
if (!research) return [];
if (research.status === "success" && hasRealArtifact) return [];
return buildResearchFeedMessages(research);
}, [hasRealArtifact, research]);
const displayThread = useMemo(() => {
if (feedMessages.length === 0) return thread;
const messages = [
...thread.messages,
...(feedMessages as unknown as Message[]),
];
return {
...thread,
values: { ...thread.values, messages },
messages,
} as T;
}, [feedMessages, thread]);
return { displayThread };
}
+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任务" />} />
@@ -83,3 +83,22 @@ export async function updateTaskReport(
});
return readResponse(res, "update task report");
}
/**
* 导入并整体替换一个任务的完整 `data` 数组(agentfx 智能体「入库」用)。
* 任务必须已存在于 TaskCOP 任务表(否则 404);文件内旧的 taskId 会被路径 id 覆盖。
*/
export async function importTaskReport(
taskId: TaskReportTaskId,
data: TaskReportRecordDraft[],
): Promise<TaskReportResponse> {
const url = `${getBackendBaseURL()}/api/task-reports/import/by-task/${encodeURIComponent(
String(taskId),
)}`;
const res = await apiFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data }),
});
return readResponse(res, "import task report");
}
@@ -0,0 +1,212 @@
/**
* 「agentfx」任务工作区:复制自 [[TaskFxWorkspace]] 的 3qfx lite 精简版,供新系统
* iframe 嵌入(goPath=agentfx 深链,见 [[agentfx-deeplink]])。单独复制一份,后续按新系统
* 需求改动互不影响 3qfx / rwfx / xdfx。
*
* 与 3qfx lite 版的差异:
* - 任务详情来自本系统 Gateway 的 cop-task-detail(登录深链时已整包暂存到
* sessionStorage 的 login.copTaskDetail,这里只读)。
* - 业务映射用 AFX 行(「配置管理」里配置要用的智能体)。
* - 无底部「快捷跳转」栏(TaskJumpBar):新系统嵌入 URL 带 hideJump=1 本就不渲染,
* 且跳转按钮体系(task-buttons)暂未配置 agentfx 业务。
*
* 结构与 3qfx 一致:左侧 = 当前任务卡片 + 任务对话列表(按 taskId + agentId 隔离的 1:N)+
* 「新建对话」;右侧 = <Outlet/> 渲染选中的对话(AgentChatPage taskBind:新建对话直接带
* 开场信息自动发出)。进入 chats 索引:有历史 → 跳最新;无历史 → 跳 new 并自动开始。
*/
import { useEffect, useMemo } from "react";
import { Outlet, useLocation, useNavigate, useParams } from "react-router-dom";
import { Loader2, MessageSquare, PlusSquare } from "lucide-react";
import { Button } from "@/components/ui/button";
import { readTaskContext } from "@/core/auth/task-purpose";
import { readCopTaskDetail } from "@/core/auth/task-deeplink";
import { useTaskThreads } from "@/core/threads/use-task-threads";
import { titleOfThread } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import AgentChatPage from "@/pages/AgentChatPage";
import { ChatRuntime } from "@/pages/WorkspaceRoutes";
/** 左列表顶部的业务名标识(3qfx 显示「三情分析」,agentfx 用固定名)。 */
const AGENTFX_BUSINESS_NAME = "智能体分析";
function useChatsBase(taskBase: string): { taskId: string; agentId: string; chatsBase: string } {
const { agent_id } = useParams<{ agent_id: string }>();
const agentId = agent_id ?? "";
const ctx = readTaskContext();
const detail = readCopTaskDetail();
const fallbackTaskId = detail?.id == null ? "" : String(detail.id).trim();
// task context 偶发丢失但任务详情仍在 sessionStorage 时,回退到详情里的 taskId,
// 避免左侧对话列表失效成「空任务态」(与 3qfx 相同的自恢复策略)。
const taskId = ctx?.taskId ?? fallbackTaskId;
return { taskId, agentId, chatsBase: `${taskBase}/agent/${agentId}/chats` };
}
/**
* 左侧顶部「当前任务」卡片:展示登录深链查询出的任务名称 + 简介(暂存在 sessionStorage 的
* copTaskDetail)。无任务详情时不渲染。
*/
function TaskDetailHeader() {
const detail = useMemo(() => readCopTaskDetail(), []);
const name = desensitize((detail?.taskName ?? "").trim());
const brief = desensitize(((detail?.overview ?? "").trim() || (detail?.taskContent ?? "").trim()));
if (!name && !brief) return null;
return (
<div className="mb-3 rounded-md border border-[var(--card-border)] bg-background/70 p-2.5 shadow-sm dark:border-[#2b8ffe]/40 dark:bg-[#0b2546]/70">
<div className="text-muted-foreground mb-1 text-[11px] font-medium">当前任务</div>
{name && (
<div
className="text-foreground line-clamp-2 text-sm font-semibold leading-snug"
title={name}
>
{name}
</div>
)}
{brief && (
<div
className="text-muted-foreground mt-1 line-clamp-3 text-xs leading-relaxed"
title={brief}
>
{brief}
</div>
)}
</div>
);
}
/** 从路由路径里解析当前打开的会话 id(布局组件读不到子路由的 :thread_id,故从 path 取)。 */
function parseThreadId(pathname: string, chatsBase: string): string | null {
if (!pathname.startsWith(chatsBase)) return null;
const seg = pathname.slice(chatsBase.length).replace(/^\/+/, "").split("/")[0];
return seg && seg !== "new" ? seg : null;
}
/** 左侧任务对话列表:顶部「当前任务」卡片 +「新建对话」;下方对话列表(1:N)。 */
function TaskConversationsList({
taskId,
agentId,
chatsBase,
}: {
taskId: string;
agentId: string;
chatsBase: string;
}) {
const { pathname } = useLocation();
const navigate = useNavigate();
const { threads, isLoading } = useTaskThreads(taskId, agentId);
const currentThreadId = useMemo(() => parseThreadId(pathname, chatsBase), [pathname, chatsBase]);
return (
<div className="bg-background/85 border-border/70 flex w-64 shrink-0 flex-col border-r shadow-[12px_0_30px_-30px_rgba(15,23,42,0.45)] backdrop-blur-xl dark:border-[#1b477e]/70 dark:bg-[#061b35]/82">
<div className="border-border/70 border-b p-3 dark:border-[#1b477e]/70">
{/* 左上角标明当前是哪个分析页面 */}
<div className="mb-3 flex items-center gap-2">
<span className="h-4 w-1 rounded-full bg-[var(--color-main)]" />
<span className="text-foreground text-sm font-semibold tracking-wide">
{AGENTFX_BUSINESS_NAME}
</span>
</div>
<TaskDetailHeader />
<div className="mb-2 flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-foreground text-sm font-semibold">任务对话</div>
<div className="text-muted-foreground mt-0.5 text-xs tabular-nums">
{isLoading ? "同步中" : `${threads.length} 个会话`}
</div>
</div>
</div>
<Button
size="sm"
className="h-9 w-full bg-[var(--color-main)] text-white shadow-sm hover:bg-[var(--brand-950)]"
onClick={() => navigate(`${chatsBase}/new`)}
>
<PlusSquare className="h-4 w-4" /> 新建对话
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{isLoading ? (
<div className="text-muted-foreground rounded-md border border-dashed p-3 text-center text-sm dark:border-[#1b477e]/60">
加载中…
</div>
) : threads.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-3 text-center text-sm dark:border-[#1b477e]/60">
暂无对话
</div>
) : (
threads.map((thread) => (
<button
key={thread.thread_id}
onClick={() => navigate(`${chatsBase}/${thread.thread_id}`)}
className={cn(
"group relative flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm transition-colors",
"hover:bg-[var(--bg-color)] dark:hover:bg-[#0d2a4d]/85",
thread.thread_id === currentThreadId &&
"bg-[var(--bg-color)] font-medium text-[var(--color-main)] shadow-sm dark:bg-[#0d2a4d] dark:text-[#9fe3ff]",
)}
title={desensitize(titleOfThread(thread, "新对话"))}
>
<span
className={cn(
"absolute inset-y-2 left-0 w-0.5 rounded-full bg-transparent transition-colors",
thread.thread_id === currentThreadId && "bg-[var(--color-main)] dark:bg-[#64cdf5]",
)}
/>
<MessageSquare className="text-muted-foreground group-hover:text-foreground h-3.5 w-3.5 shrink-0" />
<span className="truncate">{desensitize(titleOfThread(thread, "新对话"))}</span>
</button>
))
)}
</div>
</div>
);
}
/** agentfx 任务工作区布局:左列表 + 右 Outlet。 */
export function AgentFxWorkspace({ taskBase }: { taskBase: string }) {
const { taskId, agentId, chatsBase } = useChatsBase(taskBase);
return (
<div className="flex size-full min-h-0 flex-col overflow-hidden">
<div className="flex min-h-0 flex-1 overflow-hidden">
<TaskConversationsList taskId={taskId} agentId={agentId} chatsBase={chatsBase} />
<div className="relative min-h-0 min-w-0 flex-1">
<Outlet />
</div>
</div>
</div>
);
}
/** chats 索引:有历史对话 → 跳最新;否则 → 跳 new 并打 taskAutoStart 标记(自动发开场词)。 */
export function AgentFxIndexResolver({ taskBase }: { taskBase: string }) {
const { taskId, agentId, chatsBase } = useChatsBase(taskBase);
const navigate = useNavigate();
const { isLoading, hasThreads, latestThreadId } = useTaskThreads(taskId, agentId);
useEffect(() => {
if (isLoading) return;
if (hasThreads && latestThreadId) {
navigate(`${chatsBase}/${latestThreadId}`, { replace: true });
} else {
navigate(`${chatsBase}/new`, { replace: true, state: { taskAutoStart: true } });
}
}, [isLoading, hasThreads, latestThreadId, chatsBase, navigate]);
return (
<div className="text-muted-foreground flex size-full items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
);
}
/** 右侧对话宿主:AgentChatPage taskBind → 仅 1:N 绑定,新建对话自动发出任务开场信息。 */
export function AgentFxChat({ taskBase }: { taskBase: string }) {
const { agent_id } = useParams<{ agent_id: string }>();
return (
<ChatRuntime>
<AgentChatPage chatBasePath={`${taskBase}/agent/${agent_id}/chats`} taskBind />
</ChatRuntime>
);
}
@@ -0,0 +1,174 @@
"use client";
/**
* agentfx「入库」用户协助卡片:任务研判报告智能体生成 report.md + task-reports.json
* 并给出总结后,由 AgentChatPage(taskBind)注入到消息流尾部渲染本卡。
*
* 「开始入库」:读取沙箱产物 task-reports.json → 按 14 类结构校验(与后端
* task-report-import 技能同规则)→ POST /api/task-reports/import/by-task/{taskId}
* 整体替换任务详情。「暂不入库」仅收起卡片(用户之后可让智能体走技能入库)。
*/
import { useCallback, useEffect, useState } from "react";
import { CheckCircle2, Database, Loader2, TriangleAlert } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { loadArtifactContent } from "@/core/artifacts/loader";
import { importTaskReport } from "@/strategy-components/api/task-reports";
import {
AGENTFX_REPORT_JSON_OUTPUT_PATH,
notifyTaskReportSaved,
validateAgentfxReportText,
type AgentfxReportValidation,
} from "@/strategy-components/lib/agentfx-report-schema";
type Phase = "loading" | "ready" | "importing" | "done" | "error";
export function TaskReportImportCard({
taskId,
threadId,
jsonPath = AGENTFX_REPORT_JSON_OUTPUT_PATH,
onImported,
onDismiss,
}: {
taskId: string;
threadId: string;
/** 沙箱产物路径(默认智能体 SOUL 约定的 task-reports.json)。 */
jsonPath?: string;
/** 入库成功回调(宿主据此刷新任务详情状态、收起卡片)。 */
onImported?: () => void;
/** 「暂不入库」回调(宿主收起卡片)。 */
onDismiss?: () => void;
}) {
const [phase, setPhase] = useState<Phase>("loading");
const [validation, setValidation] = useState<AgentfxReportValidation | null>(null);
const [errorMsg, setErrorMsg] = useState("");
useEffect(() => {
let alive = true;
setPhase("loading");
void loadArtifactContent({ filepath: jsonPath, threadId })
.then(({ content }) => {
if (!alive) return;
if (!content) {
setErrorMsg("读取报告 JSON 失败(文件为空或不存在)");
setPhase("error");
return;
}
setValidation(validateAgentfxReportText(content, { sessionId: threadId }));
setPhase("ready");
})
.catch((error) => {
if (!alive) return;
setErrorMsg(error instanceof Error ? error.message : "读取报告 JSON 失败");
setPhase("error");
});
return () => {
alive = false;
};
}, [jsonPath, threadId]);
const handleImport = useCallback(async () => {
if (!validation || !validation.ok) return;
setPhase("importing");
try {
const response = await importTaskReport(taskId, validation.records);
const count = response.data?.length ?? 0;
setPhase("done");
toast.success(`入库成功:已写入 ${count} 条任务详情`);
// iframe 嵌入场景:通知外部宿主系统任务详情已保存。
notifyTaskReportSaved(taskId);
onImported?.();
} catch (error) {
const message = error instanceof Error ? error.message : "入库失败";
setErrorMsg(message);
setPhase("error");
toast.error(`入库失败:${message}`);
}
}, [onImported, taskId, validation]);
return (
<div className="border-border/70 bg-background/85 mx-auto w-full max-w-[760px] rounded-xl border p-4 shadow-sm backdrop-blur-xl dark:border-[#1b477e]/70 dark:bg-[#061b35]/82">
<div className="flex items-start gap-3">
<div className="bg-[var(--color-main)]/10 text-[var(--color-main)] mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<Database className="h-4.5 w-4.5" />
</div>
<div className="min-w-0 flex-1">
<div className="text-foreground text-sm font-semibold">研判报告已生成,是否入库?</div>
<div className="text-muted-foreground mt-1 text-xs leading-relaxed">
入库会把结构化结果(任务详情,14 个分类)整体写入任务
<span className="text-foreground mx-1 font-medium">{taskId}</span>
,已有详情会被覆盖;报告文件(report.md / task-reports.json)保留在沙箱中。
</div>
{phase === "loading" && (
<div className="text-muted-foreground mt-3 flex items-center gap-1.5 text-xs">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> 正在校验报告 JSON…
</div>
)}
{phase === "ready" && validation && (
<div className="mt-3 space-y-1.5 text-xs">
{validation.ok ? (
<div className="text-muted-foreground flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
校验通过:{validation.presentCategories.length} / 14 个分类
{validation.missingCategories.length > 0 && (
<span>(缺少:{validation.missingCategories.join("、")})</span>
)}
</div>
) : (
<div className="text-muted-foreground flex items-start gap-1.5">
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
<span>
{validation.records.length > 0
? `校验发现 ${validation.errors.length} 处问题,可导入 ${validation.records.length} 条合法记录:`
: "校验未通过:"}
{validation.errors.slice(0, 3).join(";")}
{validation.errors.length > 3 && ` 等 ${validation.errors.length} 处`}
</span>
</div>
)}
</div>
)}
{phase === "error" && (
<div className="text-muted-foreground mt-3 flex items-start gap-1.5 text-xs">
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
<span>{errorMsg || "发生错误"}</span>
</div>
)}
{phase === "done" && (
<div className="text-muted-foreground mt-3 flex items-center gap-1.5 text-xs">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" /> 已入库;可在任务管理侧查看任务详情。
</div>
)}
{(phase === "ready" || phase === "error") && (
<div className="mt-3 flex items-center gap-2">
<Button
size="sm"
className="h-8 bg-[var(--color-main)] text-white shadow-sm hover:bg-[var(--brand-950)]"
disabled={phase !== "ready" || !validation?.ok}
onClick={() => void handleImport()}
>
<Database className="h-3.5 w-3.5" /> 开始入库
</Button>
<Button size="sm" variant="ghost" className="h-8" onClick={onDismiss}>
暂不入库
</Button>
</div>
)}
{phase === "importing" && (
<div className="text-muted-foreground mt-3 flex items-center gap-1.5 text-xs">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> 正在入库…
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,150 @@
/**
* agentfx「任务研判报告」的结构校验(前端版)。
*
* 与后端技能脚本 `task-report-import/scripts/task_report_import.py` 保持同一套规则:
* 14 个固定 `categoryType`、`contentJson` 必须是合法 JSON(对象自动序列化为字符串)。
* 协助卡点「开始入库」时先用它把智能体生成的 `task-reports.json`(沙箱产物)校验成
* 与导入示例一致的结构,再 POST 到 `/api/task-reports/import/by-task/{taskId}`。
*/
import type { TaskReportRecordDraft } from "@/strategy-components/api/task-reports";
/** 智能体(SOUL.md 约定)写出的两份沙箱产物路径。 */
export const AGENTFX_REPORT_MD_OUTPUT_PATH = "/mnt/user-data/outputs/report.md";
export const AGENTFX_REPORT_JSON_OUTPUT_PATH = "/mnt/user-data/outputs/task-reports.json";
/** 14 个固定分类(与任务详情导入示例一致,均带 “ JSON” 后缀)。 */
export const AGENTFX_REPORT_CATEGORY_TYPES: readonly string[] = [
"信息环境 JSON",
"人文环境 JSON",
"敌情关键事件 JSON",
"敌情对手叙事 JSON",
"敌情目标 JSON",
"我情口径 JSON",
"我情叙事 JSON",
"我情上级要求 JSON",
"条件与限制 JSON",
"优劣势分析 JSON",
"风险及应对 JSON",
"行动方案 JSON",
"处置建议 JSON",
"决心建议 JSON",
];
export interface AgentfxReportValidation {
/** 至少有一条合法记录且无校验错误。 */
ok: boolean;
/** 可直接交给 importTaskReport 的记录(id/时间由后端自动补齐)。 */
records: TaskReportRecordDraft[];
presentCategories: string[];
missingCategories: string[];
errors: string[];
}
/**
* 入库成功后通知外部宿主系统(iframe 嵌入场景):向 window.top 广播
* ``{source: "magent-web", type: "task-report:saved", taskId}``。卡片入库与
* 技能入库两条路径成功后都会调用;未嵌入时 window.top 即自身,无人监听则自然空操作。
*/
export function notifyTaskReportSaved(taskId: string | number): void {
try {
window.top?.postMessage(
{
source: "magent-web",
type: "task-report:saved",
taskId: String(taskId),
},
"*",
);
} catch {
/* 跨域受限环境访问 window.top 异常时静默忽略 */
}
}
function stringifyContent(value: unknown): string | null {
if (typeof value === "string") {
if (!value.trim()) return null;
try {
// 归一化:parse 后重新序列化,把模型可能写的 \uXXXX 转义还原为汉字原文
// (JSON.stringify 不会 ASCII 转义非 ASCII 字符)。
return JSON.stringify(JSON.parse(value));
} catch {
return null;
}
}
if (value !== null && typeof value === "object") {
return JSON.stringify(value);
}
return null;
}
/**
* 校验智能体生成的报告 JSON 文本:接受裸数组或 `{data|records: [...]}` 信封;
* 每条记录必须有合法 `categoryType`(14 类之一)与可解析的 `contentJson`。
*/
export function validateAgentfxReportText(
raw: string,
options?: { sessionId?: string },
): AgentfxReportValidation {
const result: AgentfxReportValidation = {
ok: false,
records: [],
presentCategories: [],
missingCategories: [],
errors: [],
};
let payload: unknown;
try {
payload = JSON.parse(raw);
} catch {
result.errors.push("报告文件不是合法 JSON");
return result;
}
let rawRecords: unknown[] | null = null;
if (Array.isArray(payload)) {
rawRecords = payload;
} else if (payload && typeof payload === "object") {
const envelope = payload as { data?: unknown; records?: unknown };
if (Array.isArray(envelope.data)) rawRecords = envelope.data;
else if (Array.isArray(envelope.records)) rawRecords = envelope.records;
}
if (!rawRecords) {
result.errors.push("JSON 必须是数组,或包含 data/records 数组的对象");
return result;
}
const present = new Set<string>();
rawRecords.forEach((item, index) => {
const label = `第 ${index + 1} 条`;
if (!item || typeof item !== "object" || Array.isArray(item)) {
result.errors.push(`${label}不是对象`);
return;
}
const record = item as Record<string, unknown>;
const category = String(record.categoryType ?? record.category_type ?? "").trim();
if (!AGENTFX_REPORT_CATEGORY_TYPES.includes(category)) {
result.errors.push(`${label} categoryType 非法:${category || "(空)"}`);
return;
}
const content = stringifyContent(
"contentJson" in record ? record.contentJson : record.content_json,
);
if (content === null) {
result.errors.push(`${label}(${category})contentJson 缺失或不是合法 JSON`);
return;
}
present.add(category);
result.records.push({
contentJson: content,
categoryType: category,
sessionId: options?.sessionId ?? "",
});
});
result.presentCategories = [...present];
result.missingCategories = AGENTFX_REPORT_CATEGORY_TYPES.filter((c) => !present.has(c));
result.ok = result.records.length > 0 && result.errors.length === 0;
return result;
}