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>
464 lines
18 KiB
TypeScript
464 lines
18 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,
|
||
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>
|
||
);
|
||
}
|