- AgentChatPage: 纯绑定深链(3qfx/xdfx)不再自动发开场信息,改由右上角「开始回答」按钮手动触发,从根上消除新建对话竞态导致的重复发送;移除 autoSentArmedRef/streamThreadId 相关逻辑 - task-deeplink.ts: 抽出 fetchActionDetail 返回整包 ActionDetail,fetchTaskIdFromAction 复用之 - TaskFxWorkspace: xdfx 左栏「当前任务」下新增「行动详情」卡片(行动名称/叙事) - StrategyRoutes/WorkspaceRoutes: 独立智能体问答整页新增最左「智能体列表」栏(showAgentList);xdfx 右侧宿主改用 TaskBindChat(精简版) - 新增 agent-chat-sidebar.tsx Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
366 lines
14 KiB
TypeScript
366 lines
14 KiB
TypeScript
/**
|
||
* 任务深链的「任务工作区」:一个任务挂多个对话(1:N)。三种业务共用本布局:
|
||
* - rwfx(任务FX,完整版):左对话列表 + 任务操作(编辑/下一步/清除记录)+ 跳转三情分析按钮;
|
||
* 右侧问答带「目的树闸门」(AgentChatPage taskGate)。
|
||
* - 3qfx(三情分析)/ xdfx(行动分析)(``lite`` 精简版):只有左对话列表(按 taskId + agentId 隔离),
|
||
* 无任务操作、无闸门;新建对话进问答页后,右上角「开始回答」按钮手动发出任务开场信息(AgentChatPage taskBind)。
|
||
* xdfx 额外在左上「当前任务」卡片下展示「行动详情」(行动名称/叙事,来自行动详情接口)。
|
||
*
|
||
* 左侧顶部 = 当前任务卡片(任务名称 + 简介,来自登录深链暂存的 copTaskDetail)+「新建对话」;
|
||
* 右侧 = <Outlet/> 渲染选中的对话。
|
||
*
|
||
* - 进入 chats 索引:有历史 → 跳最新;无历史 → 跳 new(rwfx 走闸门 / 3qfx/xdfx 直接带开场信息)。
|
||
* - 切换/新建时 AgentChatPage 会停掉老对话的服务端 run。
|
||
* - (rwfx)清除任务记录:仅调 delete 接口(聊天对话保留);编辑/下一步:window.open B 系统地址。
|
||
* 见 [[task-purpose]] / [[task-deeplink]] / [[task-deeplink-config]]。
|
||
*/
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { Outlet, useLocation, useNavigate, useParams } from "react-router-dom";
|
||
import { Loader2, MessageSquare, PlusSquare, Trash2 } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { deleteTaskPurpose, readTaskContext } from "@/core/auth/task-purpose";
|
||
import { fetchActionDetail, readCopTaskDetail, type ActionDetail } from "@/core/auth/task-deeplink";
|
||
import { TASK_BUSINESSES, type TaskBusiness } from "@/core/auth/task-buttons";
|
||
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";
|
||
import { TaskJumpBar } from "@/strategy-components/components/task-components/TaskJumpBar";
|
||
|
||
function useChatsBase(taskBase: string): { taskId: string; agentId: string; chatsBase: string } {
|
||
const { agent_id } = useParams<{ agent_id: string }>();
|
||
const agentId = agent_id ?? "";
|
||
const taskId = readTaskContext()?.taskId ?? "";
|
||
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>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* xdfx 专用「行动详情」卡片:放在「当前任务」卡片下面。用任务上下文里的「行动id」
|
||
* (xdfx 深链原始 ?taskId=)调行动详情接口,展示行动名称 + 叙事。无数据时不渲染。
|
||
*/
|
||
function ActionDetailHeader() {
|
||
const actionId = useMemo(() => readTaskContext()?.actionId ?? "", []);
|
||
const [detail, setDetail] = useState<ActionDetail | null>(null);
|
||
useEffect(() => {
|
||
if (!actionId) return;
|
||
let alive = true;
|
||
void fetchActionDetail(actionId).then((d) => {
|
||
if (alive) setDetail(d);
|
||
});
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, [actionId]);
|
||
|
||
const name = desensitize((detail?.actionName ?? "").trim());
|
||
const narration = desensitize((detail?.narration ?? "").trim());
|
||
// 行动名称为空时用「行动 #id」兜底,至少把卡片标出来(mock 行动名/叙事常为空)。
|
||
const title = name || (detail?.id ? `行动 #${detail.id}` : "");
|
||
if (!title && !narration) 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>
|
||
{title && (
|
||
<div className="text-foreground line-clamp-2 text-sm font-semibold leading-snug" title={title}>
|
||
{title}
|
||
</div>
|
||
)}
|
||
{narration && (
|
||
<div
|
||
className="text-muted-foreground mt-1 line-clamp-3 text-xs leading-relaxed"
|
||
title={narration}
|
||
>
|
||
{narration}
|
||
</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)。
|
||
* 跳转按钮已移到工作区底部「快捷跳转」栏(见 ``TaskJumpBar``)。
|
||
*/
|
||
function TaskConversationsList({
|
||
taskId,
|
||
agentId,
|
||
chatsBase,
|
||
business,
|
||
lite,
|
||
}: {
|
||
taskId: string;
|
||
agentId: string;
|
||
chatsBase: string;
|
||
business: TaskBusiness;
|
||
lite?: boolean;
|
||
}) {
|
||
const { pathname } = useLocation();
|
||
const navigate = useNavigate();
|
||
const { threads, isLoading } = useTaskThreads(taskId, agentId);
|
||
const currentThreadId = useMemo(() => parseThreadId(pathname, chatsBase), [pathname, chatsBase]);
|
||
const businessName = desensitize(TASK_BUSINESSES.find((b) => b.code === business)?.name ?? "");
|
||
|
||
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">{businessName}</span>
|
||
</div>
|
||
<TaskDetailHeader />
|
||
{/* xdfx:任务详情下面再展示行动详情 */}
|
||
{business === "xdfx" && <ActionDetailHeader />}
|
||
<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>
|
||
{!lite && <TaskClearButton taskId={taskId} />}
|
||
</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>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 「清除记录」按钮(rwfx):放在「新建对话」下面,仅调 delete 接口清空 B 系统目的策划数据
|
||
* (聊天对话保留)。带二次确认弹框。
|
||
*/
|
||
function TaskClearButton({ taskId }: { taskId: string }) {
|
||
const [clearOpen, setClearOpen] = useState(false);
|
||
const [clearing, setClearing] = useState(false);
|
||
|
||
const handleClear = async () => {
|
||
setClearing(true);
|
||
try {
|
||
const r = await deleteTaskPurpose(taskId);
|
||
if (r.success) toast.success("已清除任务记录");
|
||
else toast.error(r.message || "清除失败");
|
||
} catch {
|
||
toast.error("清除失败,请稍后再试");
|
||
} finally {
|
||
setClearing(false);
|
||
setClearOpen(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive dark:text-[#ccd2da] dark:hover:bg-[#5c0011]/35 mt-2 h-9 w-full"
|
||
onClick={() => setClearOpen(true)}
|
||
disabled={!taskId}
|
||
title="清除任务记录"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" /> 清除记录
|
||
</Button>
|
||
|
||
<Dialog open={clearOpen} onOpenChange={(o) => !clearing && setClearOpen(o)}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>清除任务记录</DialogTitle>
|
||
<DialogDescription>
|
||
将清除该任务在 B 系统中的目的策划数据(聊天对话保留),是否确认?
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setClearOpen(false)} disabled={clearing}>
|
||
取消
|
||
</Button>
|
||
<Button variant="destructive" onClick={handleClear} disabled={clearing}>
|
||
{clearing ? <Loader2 className="h-4 w-4 animate-spin" /> : "确认清除"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 任务工作区布局:左列表 + 右 Outlet。
|
||
* ``lite``(3qfx/xdfx)= 精简版:左列表无跳转按钮、无底部任务操作面板。
|
||
*/
|
||
export function TaskFxWorkspace({
|
||
taskBase,
|
||
business,
|
||
lite,
|
||
}: {
|
||
taskBase: string;
|
||
business: TaskBusiness;
|
||
lite?: boolean;
|
||
}) {
|
||
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}
|
||
business={business}
|
||
lite={lite}
|
||
/>
|
||
<div className="relative min-h-0 min-w-0 flex-1">
|
||
<Outlet />
|
||
</div>
|
||
</div>
|
||
<TaskJumpBar business={business} taskId={taskId} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** chats 索引:有历史对话 → 跳最新;否则 → 跳 new(rwfx 走闸门 / 3qfx/xdfx 直接带开场信息)。 */
|
||
export function TaskFxIndexResolver({ 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 });
|
||
}
|
||
}, [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>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 右侧对话宿主(rwfx / xdfx):AgentChatPage taskGate → 新建对话顶部展示目的树闸门面板 + 老对话停止。
|
||
* ``autoStartWhenPurposeEmpty``(rwfx):查询任务目的为空数据时自动开始第一次问答。
|
||
*/
|
||
export function TaskFxChat({
|
||
taskBase,
|
||
autoStartWhenPurposeEmpty,
|
||
}: {
|
||
taskBase: string;
|
||
autoStartWhenPurposeEmpty?: boolean;
|
||
}) {
|
||
const { agent_id } = useParams<{ agent_id: string }>();
|
||
return (
|
||
<ChatRuntime>
|
||
<AgentChatPage
|
||
chatBasePath={`${taskBase}/agent/${agent_id}/chats`}
|
||
taskGate
|
||
autoStartWhenPurposeEmpty={autoStartWhenPurposeEmpty}
|
||
/>
|
||
</ChatRuntime>
|
||
);
|
||
}
|
||
|
||
/** 右侧对话宿主(3qfx/xdfx):AgentChatPage taskBind → 仅 1:N 绑定,新建对话进问答页,右上角「开始回答」按钮手动发出开场信息。 */
|
||
export function TaskBindChat({ taskBase }: { taskBase: string }) {
|
||
const { agent_id } = useParams<{ agent_id: string }>();
|
||
return (
|
||
<ChatRuntime>
|
||
<AgentChatPage chatBasePath={`${taskBase}/agent/${agent_id}/chats`} taskBind />
|
||
</ChatRuntime>
|
||
);
|
||
}
|