Files
deerflow-code/frontend-web/src/strategy-components/components/task-components/AgentFxWorkspace.tsx
T
董宏凯andCursor 52b9cbf707 feat: 新增 agentfx 任务深链与研判报告入库流程
支持 goPath=agentfx 的 iframe 嵌入问答、内置研判助手种子与报告导入协助卡,并完善深度研究流式报告能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 16:32:22 +08:00

213 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 「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>
);
}