Files
deerflow-code/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx
T
吉鑫升andClaude Opus 4.8 a2bb8c80a2 feat: 任务目的树表格化展示 + 切换会话停止老对话服务端 run
- task-purpose: normalizeNode 容错(未知 *Dto 兜底/文本字段改名兜底);新增 flattenPurposeTree 拍平目的树
- TaskPurposePanel: 目的树改表格展示(列数自适应),支持弹框放大查看
- threads/hooks: 新增 stopThreadRun,真正取消服务端 run(非仅断开本地 SSE)
- AgentChatPage: taskGate 下切换/卸载会话时停掉老对话服务端 run
- TaskFxWorkspace: 操作按钮(编辑/下一步/清除)改右下角常驻红色浮层

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

225 lines
8.7 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.
/**
* 任务深链(rwfx / 任务FX)的「任务工作区」:一个任务挂多个对话(1:N)。
*
* 左侧 = 该任务下的对话列表(按 metadata.taskId 过滤)+「新建对话」;
* 右下角 = 常驻浮层红色按钮(编辑 / 下一步 / 清除任务记录),absolute 定位,时刻可见、不被内容挤走;
* 右侧 = <Outlet/> 渲染选中的对话(AgentChatPage,新建对话顶部带目的树闸门面板)。
*
* - 进入 chats 索引:有历史 → 跳最新;无历史 → 跳 new(=新建,走闸门)。
* - 新建对话 = 直接跳入:navigate 到 chats/new;切换/新建时 AgentChatPage 会停掉老对话的服务端 run。
* - 清除任务记录:仅调 delete 接口(聊天对话保留)。
* - 编辑 / 下一步:window.open 配置里的 B 系统地址(?id=taskId)。
* 见 [[task-purpose]] / [[task-deeplink-config]]。
*/
import { useEffect, useMemo, useState } from "react";
import { Outlet, useLocation, useNavigate, useParams } from "react-router-dom";
import { ArrowRight, ExternalLink, 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 {
buildTreemapEditUrl,
buildTreemapNextUrl,
deleteTaskPurpose,
readTaskContext,
} from "@/core/auth/task-purpose";
import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config";
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";
function useChatsBase(taskBase: string): { taskId: string; chatsBase: string } {
const { agent_id } = useParams<{ agent_id: string }>();
const taskId = readTaskContext()?.taskId ?? "";
return { taskId, chatsBase: `${taskBase}/agent/${agent_id}/chats` };
}
/** 从路由路径里解析当前打开的会话 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;
}
/** 左侧任务对话列表 +「新建对话」。 */
function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBase: string }) {
const { pathname } = useLocation();
const navigate = useNavigate();
const { threads, isLoading } = useTaskThreads(taskId);
const currentThreadId = useMemo(() => parseThreadId(pathname, chatsBase), [pathname, chatsBase]);
return (
<div className="border-border/70 bg-card/40 flex w-60 shrink-0 flex-col border-r">
<div className="border-border/70 border-b p-2">
<Button size="sm" className="w-full" onClick={() => navigate(`${chatsBase}/new`)}>
<PlusSquare className="h-4 w-4" /> 新建对话
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-1.5">
{isLoading ? (
<div className="text-muted-foreground p-2 text-sm">加载中…</div>
) : threads.length === 0 ? (
<div className="text-muted-foreground p-2 text-sm">暂无对话</div>
) : (
threads.map((thread) => (
<button
key={thread.thread_id}
onClick={() => navigate(`${chatsBase}/${thread.thread_id}`)}
className={cn(
"hover:bg-accent flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm",
thread.thread_id === currentThreadId && "bg-accent font-medium",
)}
title={desensitize(titleOfThread(thread, "新对话"))}
>
<MessageSquare className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{desensitize(titleOfThread(thread, "新对话"))}</span>
</button>
))
)}
</div>
</div>
);
}
/**
* 右下角常驻红色操作浮层:编辑 / 下一步 / 清除任务记录。
* absolute 定位在工作区右侧面板内,z-40,时刻可见、不被聊天内容挤走。
*/
function TaskFloatingActions({ taskId }: { taskId: string }) {
const [clearOpen, setClearOpen] = useState(false);
const [clearing, setClearing] = useState(false);
const openExternal = (url: string, label: string) => {
if (url) window.open(url, "_blank", "noopener,noreferrer");
else toast.error(`未配置${label}地址`);
};
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 (
<>
<div className="pointer-events-none absolute bottom-4 right-4 z-40 flex flex-col items-end gap-2">
<Button
size="sm"
variant="destructive"
className="pointer-events-auto shadow-lg"
onClick={() => openExternal(buildTreemapEditUrl(taskId), "编辑")}
>
<ExternalLink className="h-3.5 w-3.5" /> 编辑
</Button>
<Button
size="sm"
variant="destructive"
className="pointer-events-auto shadow-lg"
onClick={() => openExternal(buildTreemapNextUrl(taskId), "下一步")}
>
<ArrowRight className="h-3.5 w-3.5" /> 下一步
</Button>
<Button
size="sm"
variant="destructive"
className="pointer-events-auto shadow-lg"
onClick={() => setClearOpen(true)}
disabled={!taskId}
>
<Trash2 className="h-3.5 w-3.5" /> 清除任务记录
</Button>
</div>
<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 + 右下角常驻红色操作浮层。 */
export function TaskFxWorkspace({ taskBase }: { taskBase: string }) {
const { taskId, chatsBase } = useChatsBase(taskBase);
// 预拉配置,保证「编辑/下一步」的同步 URL 构造能读到缓存。
useEffect(() => {
void fetchTaskDeeplinkConfig();
}, []);
return (
<div className="flex size-full min-h-0 overflow-hidden">
<TaskConversationsList taskId={taskId} chatsBase={chatsBase} />
<div className="relative min-h-0 min-w-0 flex-1">
<Outlet />
<TaskFloatingActions taskId={taskId} />
</div>
</div>
);
}
/** chats 索引:有历史对话 → 跳最新;否则 → 跳 new(走闸门)。 */
export function TaskFxIndexResolver({ taskBase }: { taskBase: string }) {
const { taskId, chatsBase } = useChatsBase(taskBase);
const navigate = useNavigate();
const { isLoading, hasThreads, latestThreadId } = useTaskThreads(taskId);
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>
);
}
/** 右侧对话宿主:AgentChatPage(taskGate 开启 → 新建对话顶部展示目的树闸门面板 + 老对话停止)。 */
export function TaskFxChat({ taskBase }: { taskBase: string }) {
const { agent_id } = useParams<{ agent_id: string }>();
return (
<ChatRuntime>
<AgentChatPage chatBasePath={`${taskBase}/agent/${agent_id}/chats`} taskGate />
</ChatRuntime>
);
}