Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
# Conflicts: # CLAUDE.md
This commit is contained in:
commit
6a8f118ad0
32 files changed
+1729
-181
No files matched your search
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 任务工作区可配置跳转按钮(按钮管理)API 客户端 + React Query hooks。
|
||||
*
|
||||
* 后端:`/api/task-buttons`(见 app/gateway/routers/task_buttons.py)。
|
||||
* 全局共享、无用户隔离;读对所有登录用户开放(工作区要用),写仅 admin(整体替换)。
|
||||
* snake_case ⇄ camelCase 在此边界收敛。见 [[task-buttons]]。
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import type {
|
||||
TaskBusiness,
|
||||
TaskButton,
|
||||
TaskButtonIdKind,
|
||||
TaskButtonLinkType,
|
||||
} from "@/core/auth/task-buttons";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/task-buttons`;
|
||||
|
||||
interface ServerButton {
|
||||
id: string;
|
||||
business: string;
|
||||
label: string;
|
||||
link_type: string;
|
||||
target: string;
|
||||
append_task_id: boolean;
|
||||
id_param: string;
|
||||
id_kind: string;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
function toButton(s: ServerButton): TaskButton {
|
||||
return {
|
||||
id: s.id,
|
||||
business: (s.business as TaskBusiness) ?? "rwfx",
|
||||
label: s.label ?? "",
|
||||
linkType: (s.link_type as TaskButtonLinkType) ?? "url",
|
||||
target: s.target ?? "",
|
||||
appendTaskId: s.append_task_id ?? true,
|
||||
idParam: s.id_param || "id",
|
||||
idKind: (s.id_kind as TaskButtonIdKind) ?? "task",
|
||||
enabled: s.enabled ?? true,
|
||||
sortOrder: s.sort_order ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function toBody(b: TaskButton): ServerButton {
|
||||
return {
|
||||
id: b.id,
|
||||
business: b.business,
|
||||
label: b.label,
|
||||
link_type: b.linkType,
|
||||
target: b.target,
|
||||
append_task_id: b.appendTaskId,
|
||||
id_param: b.idParam,
|
||||
id_kind: b.idKind,
|
||||
enabled: b.enabled,
|
||||
sort_order: b.sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null as unknown);
|
||||
const detail =
|
||||
data && typeof data === "object" && "detail" in data
|
||||
? String((data as { detail: unknown }).detail)
|
||||
: `${ctx} failed (${res.status})`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export async function listTaskButtons(): Promise<TaskButton[]> {
|
||||
const res = await apiFetch(BASE(), { method: "GET" });
|
||||
const data = await readJson<{ buttons: ServerButton[] }>(res, "list task buttons");
|
||||
return (data.buttons ?? []).map(toButton);
|
||||
}
|
||||
|
||||
export async function replaceTaskButtons(buttons: TaskButton[]): Promise<TaskButton[]> {
|
||||
const res = await apiFetch(BASE(), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ buttons: buttons.map(toBody) }),
|
||||
});
|
||||
const data = await readJson<{ buttons: ServerButton[] }>(res, "save task buttons");
|
||||
return (data.buttons ?? []).map(toButton);
|
||||
}
|
||||
|
||||
// ── React Query hooks(工作区 + 管理页共用缓存) ────────────────────────────
|
||||
|
||||
const TASK_BUTTONS_KEY = "task-buttons";
|
||||
|
||||
export function useTaskButtons() {
|
||||
return useQuery({
|
||||
queryKey: [TASK_BUTTONS_KEY],
|
||||
queryFn: listTaskButtons,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTaskButtonActions() {
|
||||
const queryClient = useQueryClient();
|
||||
return {
|
||||
save: useMutation({
|
||||
mutationFn: replaceTaskButtons,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: [TASK_BUTTONS_KEY] }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
+230
-85
@@ -1,20 +1,22 @@
|
||||
/**
|
||||
* 任务深链(rwfx / 任务FX)的「任务工作区」:一个任务挂多个对话(1:N)。
|
||||
* 任务深链的「任务工作区」:一个任务挂多个对话(1:N)。三种业务共用本布局:
|
||||
* - rwfx(任务FX,完整版):左对话列表 + 任务操作(编辑/下一步/清除记录)+ 跳转三情分析按钮;
|
||||
* 右侧问答带「目的树闸门」(AgentChatPage taskGate)。
|
||||
* - 3qfx(三情分析)/ xdfx(行动分析)(``lite`` 精简版):只有左对话列表(按 taskId + agentId 隔离),
|
||||
* 无任务操作、无闸门;新建对话直接带任务开场信息自动发出(AgentChatPage taskBind)。
|
||||
*
|
||||
* 左侧 = 该任务下的对话列表(按 metadata.taskId 过滤)+「新建对话」;
|
||||
* 右下角 = 常驻浮动操作条(编辑 / 下一步 / 清除任务记录),absolute 定位,时刻可见、不被内容挤走;
|
||||
* 右侧 = <Outlet/> 渲染选中的对话(AgentChatPage,新建对话顶部带目的树闸门面板)。
|
||||
* 左侧顶部 = 当前任务卡片(任务名称 + 简介,来自登录深链暂存的 copTaskDetail)+「新建对话」;
|
||||
* 右侧 = <Outlet/> 渲染选中的对话。
|
||||
*
|
||||
* - 进入 chats 索引:有历史 → 跳最新;无历史 → 跳 new(=新建,走闸门)。
|
||||
* - 新建对话 = 直接跳入:navigate 到 chats/new;切换/新建时 AgentChatPage 会停掉老对话的服务端 run。
|
||||
* - 清除任务记录:仅调 delete 接口(聊天对话保留)。
|
||||
* - 编辑 / 下一步:window.open 配置里的 B 系统地址(?id=taskId)。
|
||||
* 见 [[task-purpose]] / [[task-deeplink-config]]。
|
||||
* - 进入 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 { ArrowRight, ExternalLink, Loader2, MessageSquare, PlusSquare, Trash2 } from "lucide-react";
|
||||
import { ArrowRight, ExternalLink, Loader2, Maximize2, MessageSquare, PlusSquare, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -26,24 +28,66 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { deleteTaskPurpose, readTaskContext } from "@/core/auth/task-purpose";
|
||||
import { readCopTaskDetail } from "@/core/auth/task-deeplink";
|
||||
import {
|
||||
buildTreemapEditUrl,
|
||||
buildTreemapNextUrl,
|
||||
deleteTaskPurpose,
|
||||
readTaskContext,
|
||||
} from "@/core/auth/task-purpose";
|
||||
import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config";
|
||||
DEFAULT_TASK_DEEPLINK_CONFIG,
|
||||
fetchTaskDeeplinkConfig,
|
||||
type TaskDeeplinkConfig,
|
||||
} from "@/core/auth/task-deeplink-config";
|
||||
import {
|
||||
TASK_BUSINESSES,
|
||||
buttonsForBusiness,
|
||||
resolveTaskButtons,
|
||||
runTaskButton,
|
||||
type TaskBusiness,
|
||||
} from "@/core/auth/task-buttons";
|
||||
import { useTaskButtons } from "@/strategy-components/api/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 { TaskPurposeDialog } from "@/strategy-components/components/task-components/TaskPurposePanel";
|
||||
|
||||
function useChatsBase(taskBase: string): { taskId: string; chatsBase: string } {
|
||||
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, chatsBase: `${taskBase}/agent/${agent_id}/chats` };
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/** 从路由路径里解析当前打开的会话 id(布局组件读不到子路由的 :thread_id,故从 path 取)。 */
|
||||
@@ -53,16 +97,111 @@ function parseThreadId(pathname: string, chatsBase: string): string | null {
|
||||
return seg && seg !== "new" ? seg : null;
|
||||
}
|
||||
|
||||
/** 左侧任务对话列表 +「新建对话」。 */
|
||||
function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBase: string }) {
|
||||
/**
|
||||
* 底部「快捷跳转」栏:横跨整个工作区底部,按配置顺序渲染该业务启用的跳转按钮(按钮管理里排序)。
|
||||
* 后端为准,整张表为空才回退种子默认;地址依赖实时深链配置,故把配置拉进 state。见 [[task-buttons]]。
|
||||
*/
|
||||
function TaskJumpBar({ business, taskId }: { business: TaskBusiness; taskId: string }) {
|
||||
const navigate = useNavigate();
|
||||
const [cfg, setCfg] = useState<TaskDeeplinkConfig>(() => DEFAULT_TASK_DEEPLINK_CONFIG);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void fetchTaskDeeplinkConfig().then((c) => {
|
||||
if (alive) setCfg(c);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
const { data: storedButtons = [] } = useTaskButtons();
|
||||
const jumpButtons = useMemo(
|
||||
() => buttonsForBusiness(resolveTaskButtons(storedButtons, cfg), business),
|
||||
[storedButtons, cfg, business],
|
||||
);
|
||||
|
||||
// 目的树弹框(purpose 类型按钮)由组件层打开。
|
||||
const [purposeOpen, setPurposeOpen] = useState(false);
|
||||
// xdfx:深链原始「行动id」(与解析后的任务id 不同才算真行动id),弹框据此换回任务id 再查目的。
|
||||
const ctxActionId = readTaskContext()?.actionId;
|
||||
const purposeActionId =
|
||||
business === "xdfx" && ctxActionId && ctxActionId !== taskId ? ctxActionId : undefined;
|
||||
|
||||
if (jumpButtons.length === 0) return null;
|
||||
|
||||
const handleRun = (btn: (typeof jumpButtons)[number]) => {
|
||||
if (btn.linkType === "purpose") {
|
||||
setPurposeOpen(true);
|
||||
return;
|
||||
}
|
||||
void runTaskButton(btn, {
|
||||
taskId,
|
||||
actionId: readTaskContext()?.actionId,
|
||||
navigate,
|
||||
onError: (m) => toast.error(m),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-border/70 bg-background/90 flex shrink-0 flex-wrap items-center justify-end gap-3 border-t px-5 py-3 backdrop-blur-xl dark:border-[#1b477e]/70 dark:bg-[#061b35]/85">
|
||||
{jumpButtons.map((btn) => (
|
||||
<Button
|
||||
key={btn.id}
|
||||
className="h-11 gap-2 border-transparent bg-[var(--color-main)] px-6 text-base text-white shadow-sm hover:bg-[var(--brand-950)]"
|
||||
onClick={() => handleRun(btn)}
|
||||
title={desensitize(btn.label)}
|
||||
>
|
||||
<span className="truncate">{desensitize(btn.label)}</span>
|
||||
{btn.linkType === "url" ? (
|
||||
<ExternalLink className="h-5 w-5 shrink-0" />
|
||||
) : btn.linkType === "purpose" ? (
|
||||
<Maximize2 className="h-5 w-5 shrink-0" />
|
||||
) : (
|
||||
<ArrowRight className="h-5 w-5 shrink-0" />
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
<TaskPurposeDialog
|
||||
taskId={taskId}
|
||||
open={purposeOpen}
|
||||
onOpenChange={setPurposeOpen}
|
||||
resolveFromActionId={purposeActionId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 左侧任务对话列表:顶部「当前任务」卡片 +「新建对话」;下方对话列表(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);
|
||||
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 />
|
||||
<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>
|
||||
@@ -78,6 +217,7 @@ function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBas
|
||||
>
|
||||
<PlusSquare className="h-4 w-4" /> 新建对话
|
||||
</Button>
|
||||
{!lite && <TaskClearButton taskId={taskId} />}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
@@ -114,24 +254,18 @@ function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBas
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<TaskActionsPanel taskId={taskId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 左侧任务栏底部操作区:编辑 / 下一步 / 清除任务记录。
|
||||
* 跟随任务对话面板布局,不再覆盖右侧聊天输入框。
|
||||
* 「清除记录」按钮(rwfx):放在「新建对话」下面,仅调 delete 接口清空 B 系统目的策划数据
|
||||
* (聊天对话保留)。带二次确认弹框。
|
||||
*/
|
||||
function TaskActionsPanel({ taskId }: { taskId: string }) {
|
||||
function TaskClearButton({ 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 {
|
||||
@@ -148,44 +282,16 @@ function TaskActionsPanel({ taskId }: { taskId: string }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border-border/70 shrink-0 border-t p-3 dark:border-[#1b477e]/70">
|
||||
<div className="rounded-md border border-[var(--card-border)] bg-background/80 p-2 shadow-sm dark:border-[#2b8ffe]/45 dark:bg-[#0b2546]/72">
|
||||
<div className="text-muted-foreground mb-2 px-1 text-xs font-medium">
|
||||
任务操作
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 border-[var(--card-border)] bg-background/80 text-[var(--color-main)] hover:bg-[var(--bg-color)] hover:text-[var(--color-main)] dark:border-[#2b8ffe]/50 dark:bg-[#0b2546]/80 dark:text-[#bde9ff] dark:hover:bg-[#10375f]"
|
||||
onClick={() => openExternal(buildTreemapEditUrl(taskId), "编辑")}
|
||||
disabled={!taskId}
|
||||
title="编辑任务"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" /> 编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 bg-[var(--color-main)] text-white shadow-[0_8px_18px_-12px_var(--color-main)] hover:bg-[var(--brand-950)]"
|
||||
onClick={() => openExternal(buildTreemapNextUrl(taskId), "下一步")}
|
||||
disabled={!taskId}
|
||||
title="进入下一步"
|
||||
>
|
||||
下一步 <ArrowRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="col-span-2 h-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive dark:text-[#ccd2da] dark:hover:bg-[#5c0011]/35"
|
||||
onClick={() => setClearOpen(true)}
|
||||
disabled={!taskId}
|
||||
title="清除任务记录"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> 清除记录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
@@ -209,28 +315,44 @@ function TaskActionsPanel({ taskId }: { taskId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 任务工作区布局:左列表 + 右 Outlet。 */
|
||||
export function TaskFxWorkspace({ taskBase }: { taskBase: string }) {
|
||||
const { taskId, chatsBase } = useChatsBase(taskBase);
|
||||
// 预拉配置,保证「编辑/下一步」的同步 URL 构造能读到缓存。
|
||||
useEffect(() => {
|
||||
void fetchTaskDeeplinkConfig();
|
||||
}, []);
|
||||
/**
|
||||
* 任务工作区布局:左列表 + 右 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 overflow-hidden">
|
||||
<TaskConversationsList taskId={taskId} chatsBase={chatsBase} />
|
||||
<div className="relative min-h-0 min-w-0 flex-1">
|
||||
<Outlet />
|
||||
<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(走闸门)。 */
|
||||
/** chats 索引:有历史对话 → 跳最新;否则 → 跳 new(rwfx 走闸门 / 3qfx/xdfx 直接带开场信息)。 */
|
||||
export function TaskFxIndexResolver({ taskBase }: { taskBase: string }) {
|
||||
const { taskId, chatsBase } = useChatsBase(taskBase);
|
||||
const { taskId, agentId, chatsBase } = useChatsBase(taskBase);
|
||||
const navigate = useNavigate();
|
||||
const { isLoading, hasThreads, latestThreadId } = useTaskThreads(taskId);
|
||||
const { isLoading, hasThreads, latestThreadId } = useTaskThreads(taskId, agentId);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
@@ -248,12 +370,35 @@ export function TaskFxIndexResolver({ taskBase }: { taskBase: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 右侧对话宿主:AgentChatPage(taskGate 开启 → 新建对话顶部展示目的树闸门面板 + 老对话停止)。 */
|
||||
export function TaskFxChat({ taskBase }: { taskBase: string }) {
|
||||
/**
|
||||
* 右侧对话宿主(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 />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+109
-15
@@ -10,8 +10,8 @@
|
||||
* onStart 由父组件(AgentChatPage 闸门态)实现:发出开场消息、开始问答。
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Loader2, Maximize2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronDown, ChevronRight, Loader2, Maximize2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -30,10 +30,12 @@ import {
|
||||
type PurposeTreeNode,
|
||||
type TaskPurposeResult,
|
||||
} from "@/core/auth/task-purpose";
|
||||
import { fetchTaskIdFromAction } from "@/core/auth/task-deeplink";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** 目的树表格:拍平成行,列数随数据自适应;层级缺失补空、字段变动不崩。 */
|
||||
function PurposeTable({ tree, className }: { tree: PurposeTreeNode[]; className?: string }) {
|
||||
export function PurposeTable({ tree, className }: { tree: PurposeTreeNode[]; className?: string }) {
|
||||
const rows = useMemo(() => flattenPurposeTree(tree), [tree]);
|
||||
const headers = useMemo(() => {
|
||||
const maxDepth = rows.reduce((m, r) => Math.max(m, r.length), 0);
|
||||
@@ -57,7 +59,7 @@ function PurposeTable({ tree, className }: { tree: PurposeTreeNode[]; className?
|
||||
key={i}
|
||||
className="bg-muted/60 border-border/60 sticky top-0 border px-3 py-1.5 text-left font-medium whitespace-nowrap"
|
||||
>
|
||||
{h}
|
||||
{desensitize(h)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
@@ -71,9 +73,9 @@ function PurposeTable({ tree, className }: { tree: PurposeTreeNode[]; className?
|
||||
<td key={ci} className="border-border/60 border px-3 py-1.5">
|
||||
{cell ? (
|
||||
<>
|
||||
<span>{cell.text || "(空)"}</span>
|
||||
<span>{cell.text ? desensitize(cell.text) : "(空)"}</span>
|
||||
{cell.reason ? (
|
||||
<span className="text-muted-foreground block text-xs">理由:{cell.reason}</span>
|
||||
<span className="text-muted-foreground block text-xs">理由:{desensitize(cell.reason)}</span>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
@@ -90,26 +92,103 @@ function PurposeTable({ tree, className }: { tree: PurposeTreeNode[]; className?
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 独立的「任务目的弹框」:按需拉取目的树并放大展示。供底部「快捷跳转」栏的 ``purpose`` 类型
|
||||
* 按钮复用(任意页面可配置「弹框查看」)。打开时才拉取。
|
||||
*/
|
||||
export function TaskPurposeDialog({
|
||||
taskId,
|
||||
open,
|
||||
onOpenChange,
|
||||
resolveFromActionId,
|
||||
}: {
|
||||
taskId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** xdfx:深链原始「行动id」。提供时弹框先用它换出真正「任务id」再查目的树。 */
|
||||
resolveFromActionId?: string;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<TaskPurposeResult | null>(null);
|
||||
const [resolvedTaskId, setResolvedTaskId] = useState(taskId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
// xdfx:行动id 换任务id 再查目的树(与登录时的解析一致);无行动id 时直接用任务id。
|
||||
let tid = taskId;
|
||||
if (resolveFromActionId) {
|
||||
tid = (await fetchTaskIdFromAction(resolveFromActionId)) || taskId;
|
||||
}
|
||||
if (cancelled) return;
|
||||
setResolvedTaskId(tid);
|
||||
const r = await fetchTaskPurposeDetail(tid);
|
||||
if (!cancelled) setResult(r);
|
||||
} catch {
|
||||
if (!cancelled) setResult({ tree: [], hasData: false });
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, taskId, resolveFromActionId]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[90vw] !max-w-[90vw] sm:!max-w-[90vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>任务目的(任务 {resolvedTaskId})</DialogTitle>
|
||||
</DialogHeader>
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 py-6 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> 正在查询任务目的…
|
||||
</div>
|
||||
) : (
|
||||
<PurposeTable tree={result?.tree ?? []} className="max-h-[70vh] rounded border border-border/60" />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskPurposePanel({
|
||||
taskId,
|
||||
onStart,
|
||||
autoStartWhenEmpty,
|
||||
}: {
|
||||
taskId: string;
|
||||
/** 闸门通过(无数据 / 覆盖成功),开始问答。 */
|
||||
onStart: () => void;
|
||||
/** rwfx:查询任务目的为空数据时,自动开始第一次问答(跳过手动点「开始问答」)。 */
|
||||
autoStartWhenEmpty?: boolean;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [result, setResult] = useState<TaskPurposeResult | null>(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [zoomOpen, setZoomOpen] = useState(false);
|
||||
// 任务目的默认收起(不展开),点标题切换。
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
// rwfx 空数据自动开始:防止重复触发。
|
||||
const autoStartedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetchTaskPurposeDetail(taskId)
|
||||
.then((r) => {
|
||||
if (!cancelled) setResult(r);
|
||||
if (cancelled) return;
|
||||
setResult(r);
|
||||
// 查询任务目的为空数据 → 默认直接开始第一次问答(跳过手动「开始问答」)。
|
||||
if (autoStartWhenEmpty && !r.hasData && !autoStartedRef.current) {
|
||||
autoStartedRef.current = true;
|
||||
onStart();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setResult({ tree: [], hasData: false });
|
||||
@@ -120,6 +199,8 @@ export function TaskPurposePanel({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// onStart/autoStartWhenEmpty 在一次新建会话内稳定;仅按 taskId 重新拉取。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [taskId]);
|
||||
|
||||
const handleStart = () => {
|
||||
@@ -151,7 +232,19 @@ export function TaskPurposePanel({
|
||||
return (
|
||||
<div className="border-border/70 bg-card/60 shrink-0 border-b px-4 py-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">任务目的(任务 {taskId})</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className="flex items-center gap-1 text-sm font-medium hover:text-[var(--color-main)]"
|
||||
title={collapsed ? "展开任务目的" : "收起任务目的"}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
任务目的(任务 {taskId})
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasTree && (
|
||||
<Button size="sm" variant="outline" onClick={() => setZoomOpen(true)}>
|
||||
@@ -164,13 +257,14 @@ export function TaskPurposePanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 py-2 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> 正在查询任务目的…
|
||||
</div>
|
||||
) : (
|
||||
<PurposeTable tree={result?.tree ?? []} className="max-h-48 rounded border border-border/60" />
|
||||
)}
|
||||
{!collapsed &&
|
||||
(loading ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 py-2 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> 正在查询任务目的…
|
||||
</div>
|
||||
) : (
|
||||
<PurposeTable tree={result?.tree ?? []} className="max-h-48 rounded border border-border/60" />
|
||||
))}
|
||||
|
||||
{/* 弹框查看:放大显示完整表格 */}
|
||||
<Dialog open={zoomOpen} onOpenChange={setZoomOpen}>
|
||||
|
||||
Reference in new issue
Block a user