Files
deerflow-code/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx
T
吉鑫升andClaude Opus 4.8 dd942cdd60 feat(xdfx): 行动分析聊天记录按行动id划分 + 首问追加 action_id
xdfx 单智能体问答不再按 task_id 划分聊天记录,改按「行动id(action_id)」划分:
- TaskContext 增加 partitionByAction 标记 + taskThreadMetadata 统一产出线程标签
- xdfx 新建线程额外打 metadata.actionId,对话列表按 actionId(+agent_id) 过滤
- 仍保留 metadata.taskId 以维持后端「任务深链对话全局共享」语义
- 首次提问开场信息追加「行动id(action_id)为…」
- useTaskThreads 增加 actionId 过滤参数;useChatsBase 从上下文派生
rwfx / 3qfx 行为不变。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:00:22 +08:00

464 lines
18 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.
/**
* 任务深链的「任务工作区」:一个任务挂多个对话(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,
resolveTaskDeeplinkTarget,
type ActionDetail,
} from "@/core/auth/task-deeplink";
import { fetchSqReportStatus } from "@/core/auth/dashboard-input";
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;
/** xdfx(partitionByAction):按「行动id」划分聊天记录用;其它业务为 ""(仍按 taskId 划分)。 */
actionId: string;
chatsBase: string;
} {
const { agent_id } = useParams<{ agent_id: string }>();
const agentId = agent_id ?? "";
const ctx = readTaskContext();
const taskId = ctx?.taskId ?? "";
const actionId = ctx?.partitionByAction ? (ctx.actionId ?? "") : "";
return { taskId, agentId, actionId, 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,
actionId,
chatsBase,
business,
lite,
}: {
taskId: string;
agentId: string;
/** xdfx:按「行动id」划分聊天记录;其它业务为 ""。 */
actionId: string;
chatsBase: string;
business: TaskBusiness;
lite?: boolean;
}) {
const { pathname } = useLocation();
const navigate = useNavigate();
const { threads, isLoading } = useTaskThreads(taskId, agentId, actionId);
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>
</>
);
}
/**
* rwfx 专用「3q 完成预检」闸门:进入任务分析工作区时调研判情报报告接口
* (``GET {VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=``)。若该任务的报告为空
* (数组为空 / 内容全为 null,即 3q 未完成)→ 弹框提示「3q 未完成,是否进行 3qfx」,
* 并提供「前往 3qfx」按钮切换到**同一任务**的三情分析工作区(复用深链解析重绑任务上下文)。
* 报告正常 / 接口不可判定(缺地址、请求失败)时不打扰用户。见 [[dashboard-input]] / [[task-deeplink]]。
*/
function RwfxSqReportGate({ taskId }: { taskId: string }) {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [jumping, setJumping] = useState(false);
useEffect(() => {
if (!taskId) return;
let alive = true;
void fetchSqReportStatus(taskId).then((status) => {
// 仅「明确为空(3q 未完成)」才弹框;complete / unknown 静默放行。
if (alive && status === "incomplete") setOpen(true);
});
return () => {
alive = false;
};
}, [taskId]);
const handleGo3qfx = async () => {
const detail = readCopTaskDetail();
if (!detail || !taskId) {
toast.error("缺少任务信息,无法跳转");
return;
}
setJumping(true);
try {
// 复用深链解析:会清掉当前 rwfx 任务上下文并按 3qfx 业务重新绑定(同一 taskId)。
const target = await resolveTaskDeeplinkTarget("3qfx", detail, taskId);
if (target) {
setOpen(false);
navigate(target.path, { state: target.state });
} else {
toast.error("未配置三情分析(3qfx)对应的业务智能体");
}
} catch {
toast.error("跳转失败,请稍后再试");
} finally {
setJumping(false);
}
};
return (
<Dialog open={open} onOpenChange={(o) => !jumping && setOpen(o)}>
<DialogContent>
<DialogHeader>
<DialogTitle>3q 未完成</DialogTitle>
<DialogDescription>
未查询到该任务的研判情报报告(3q 未完成),是否前往该任务的三情分析(3qfx)?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={jumping}>
暂不
</Button>
<Button
// 弹框经 Radix Portal 渲染到 body,在 `.strategy-data` 作用域之外,故不能用
// 局部的 `--color-main`(红主题侥幸有值、蓝主题失效→按钮消失)。改用全局按主题
// 定义的品牌色 token(:root/.dark/.lightblue 都有),三套主题都能正确取色。
className="bg-[var(--workspace-brand)] text-[var(--workspace-brand-foreground)] hover:opacity-90"
onClick={handleGo3qfx}
disabled={jumping}
>
{jumping ? <Loader2 className="h-4 w-4 animate-spin" /> : "前往 3qfx"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* 任务工作区布局:左列表 + 右 Outlet。
* ``lite``(3qfx/xdfx)= 精简版:左列表无跳转按钮、无底部任务操作面板。
*/
export function TaskFxWorkspace({
taskBase,
business,
lite,
}: {
taskBase: string;
business: TaskBusiness;
lite?: boolean;
}) {
const { taskId, agentId, actionId, 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}
actionId={actionId}
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} />
{/* rwfx 专属:进入时预检 3q 是否完成,未完成则弹框引导去 3qfx。 */}
{business === "rwfx" && <RwfxSqReportGate taskId={taskId} />}
</div>
);
}
/** chats 索引:有历史对话 → 跳最新;否则 → 跳 new(rwfx 走闸门 / 3qfx/xdfx 直接带开场信息)。 */
export function TaskFxIndexResolver({ taskBase }: { taskBase: string }) {
const { taskId, agentId, actionId, chatsBase } = useChatsBase(taskBase);
const navigate = useNavigate();
const { isLoading, hasThreads, latestThreadId } = useTaskThreads(taskId, agentId, actionId);
useEffect(() => {
if (isLoading) return;
if (hasThreads && latestThreadId) {
navigate(`${chatsBase}/${latestThreadId}`, { replace: true });
} else {
// 首次进入、该任务尚无聊天对话 → /new 并打 taskAutoStart 标记:3qfx/xdfx 直接自动发开场词,
// rwfx 目的树为空时自动开始(用户主动「新建对话」走 TaskConversationsList 的 navigate,不带此标记)。
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>
);
}
/**
* 右侧对话宿主(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>
);
}