feat: 新增 agentfx 任务深链与研判报告入库流程
支持 goPath=agentfx 的 iframe 嵌入问答、内置研判助手种子与报告导入协助卡,并完善深度研究流式报告能力。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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` };
|
||||
}
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+174
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user