- 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>
225 lines
8.7 KiB
TypeScript
225 lines
8.7 KiB
TypeScript
/**
|
||
* 任务深链(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>
|
||
);
|
||
}
|