支持 goPath=agentfx 的 iframe 嵌入问答、内置研判助手种子与报告导入协助卡,并完善深度研究流式报告能力。 Co-authored-by: Cursor <cursoragent@cursor.com>
213 lines
9.3 KiB
TypeScript
213 lines
9.3 KiB
TypeScript
/**
|
||
* 「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>
|
||
);
|
||
}
|