Files
deerflow-code/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx
T

665 lines
26 KiB
TypeScript

"use client";
import {
Bot,
ChevronDown,
Download,
FileJson,
FileText,
Loader2,
MessageSquare,
MoreHorizontal,
Pencil,
Pin,
PinOff,
Search,
Share2,
Star,
Trash2,
X,
} from "lucide-react";
import Link from "next/link";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { agentColor } from "@/components/workspace/agent-icon-color";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import { useTheme } from "@/contexts/ThemeContext";
import { useAgentsPaginated } from "@/core/agents";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks";
import { useRouteBase } from "@/core/route-base";
import { usePublishSquares } from "@/core/system-settings/hooks";
import {
exportThreadAsJSON,
exportThreadAsMarkdown,
} from "@/core/threads/export";
import {
useDeleteThread,
useRenameThread,
useThreads,
useToggleThreadFavorite,
useToggleThreadPin,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import {
bootstrapResumePath,
isBootstrapThread,
isThreadFavorite,
isThreadPinned,
sortThreadsPinnedFirst,
titleOfThread,
usePathOfThread,
} from "@/core/threads/utils";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
/** 「我的」作用域的下拉选项值;其余选项值为各发布广场(publish square)的 id。 */
const MINE_SCOPE = "mine";
// 列表一次拉取的条数。超出时底部出现「查看全部」入口,跳到完整的智能体管理页,
// 避免静默截断。侧边栏只做快速切换,不在此分页。
const LIST_PAGE_SIZE = 50;
// 「最近的对话」未搜索时最多展示条数(与首页「通用问答」左栏一致)。
const MAX_RECENT_CHATS = 10;
/**
* 智能体问答页左侧「智能体列表 + 最近对话」栏(参考首页通用问答左栏)。
*
* 自上而下:
* 1. 顶部下拉:选「我的」(默认,= 我创建/收藏的)或任一「广场」(发布广场),
* 与「智能体管理」页主标签一致;下方为智能体名称模糊搜索框。
* 2. 智能体列表:所选作用域下的智能体;点击进入该智能体的新对话。当前智能体高亮。
* 3. 最近的对话:跨智能体的近期会话(参考首页),点击打开;支持重命名/置顶/收藏/
* 分享/导出/删除。当前会话高亮。
*
* 仅在独立的智能体问答整页展示;任务工作区 / zzJC·zzXD 模块 / 嵌入态不展示。
*/
export function AgentChatSidebar({ activeAgentId }: { activeAgentId?: string }) {
const { t } = useI18n();
const routeBase = useRouteBase();
const { theme } = useTheme();
const isDark = theme === "dark";
const { data: publishSquares } = usePublishSquares();
// 每个发布广场对应一个下拉选项;后端至少返回一个内置默认广场。
const squares = useMemo(
() => publishSquares?.squares ?? [],
[publishSquares?.squares],
);
// 作用域:MINE_SCOPE 或某个广场 id。默认「我的」。
const [scope, setScope] = useState<string>(MINE_SCOPE);
// 模糊搜索智能体名称:防抖 300ms 后走服务端 search(后端按原始名 / 脱敏名匹配),
// 在当前来源(我的 / 某广场)内过滤,能搜全量而非仅当前 50 条。
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
useEffect(() => {
const timer = setTimeout(() => setSearch(searchInput.trim()), 300);
return () => clearTimeout(timer);
}, [searchInput]);
// 「智能体」区可收起(持久化);收起后只留标题栏,下方「最近的对话」占满剩余空间。
const [agentsCollapsed, setAgentsCollapsed] = useState(
() =>
typeof window !== "undefined" &&
localStorage.getItem("agent-chat-sidebar:agents-collapsed") === "true",
);
const toggleAgents = () =>
setAgentsCollapsed((v) => {
localStorage.setItem("agent-chat-sidebar:agents-collapsed", String(!v));
return !v;
});
// 若所选广场被管理员删除,回退到「我的」,避免指向不存在的广场。
useEffect(() => {
if (
scope !== MINE_SCOPE &&
squares.length > 0 &&
!squares.some((sq) => sq.id === scope)
) {
setScope(MINE_SCOPE);
}
}, [scope, squares]);
const isMine = scope === MINE_SCOPE;
const { agents, total, isLoading, isFetching } = useAgentsPaginated({
scope: isMine ? "mine" : "square",
sort: "time_desc",
page: 1,
pageSize: LIST_PAGE_SIZE,
square: isMine ? undefined : scope,
search: search || undefined,
});
return (
// 配色与 AgentModuleSidebar 一致:浅色白底,深蓝主题半透明深蓝玻璃面板。
<aside className="flex h-full w-(--sidebar-width) shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground backdrop-blur-md dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80">
{/* 智能体(上半,可收起)。展开时:标题栏 + 来源下拉/名称搜索 + 列表;
收起时只留标题栏,下方「最近的对话」占满剩余空间。 */}
<div
className={cn(
"flex flex-col",
agentsCollapsed ? "shrink-0" : "min-h-0 flex-[3]",
)}
>
<button
type="button"
onClick={toggleAgents}
className="text-foreground/80 hover:bg-accent/40 flex shrink-0 items-center justify-between border-b border-border/60 px-3 py-2.5 text-[12px] font-semibold transition-colors select-none"
title={agentsCollapsed ? "展开智能体" : "收起智能体"}
>
<span>智能体</span>
<ChevronDown
className={cn(
"text-muted-foreground size-4 shrink-0 transition-transform duration-200",
agentsCollapsed && "-rotate-90",
)}
/>
</button>
{!agentsCollapsed && (
<>
{/* 来源下拉(我的 / 各广场)+ 智能体名称模糊搜索 */}
<div className="shrink-0 space-y-2 border-b border-border/60 px-3 py-2.5">
<Select value={scope} onValueChange={setScope}>
<SelectTrigger className="h-9 w-full text-sm" title="切换智能体来源">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value={MINE_SCOPE}>我的</SelectItem>
{squares.map((sq) => (
<SelectItem key={sq.id} value={sq.id}>
{sq.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="搜索智能体名称"
className="h-9 pl-8 pr-8 text-sm"
aria-label="搜索智能体名称"
/>
{searchInput && (
<button
type="button"
onClick={() => setSearchInput("")}
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-2 -translate-y-1/2"
title="清除"
aria-label="清除搜索"
>
<X className="size-3.5" />
</button>
)}
</div>
</div>
{/* 智能体列表(独立滚动) */}
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-2 py-3">
{isLoading && agents.length === 0 ? (
<div className="text-muted-foreground flex h-24 items-center justify-center gap-2 text-xs">
<Loader2 className="size-3.5 animate-spin" />
加载中…
</div>
) : agents.length === 0 ? (
<p className="px-3 py-1.5 text-xs text-muted-foreground/70">
{search
? "未找到匹配的智能体"
: isMine
? "暂无智能体"
: "该广场暂无智能体"}
</p>
) : (
<div
className={cn(
"flex flex-col gap-1 transition-opacity",
isFetching && "opacity-60",
)}
>
{agents.map((agent) => {
const href = `${routeBase}/agents/${agent.id}/chats/new`;
const isActive = agent.id === activeAgentId;
const { from, to } = agentColor(agent.id, isDark);
return (
<Link
key={agent.id}
href={href}
className={cn(
"group relative flex w-full items-start gap-3 rounded-xl border px-3 py-2.5 transition-all duration-150",
isActive
? isDark
? "border-[#2b8ffe] bg-[#0b4fae]/65 shadow-[0_0_0_1px_rgba(43,143,254,0.5),0_12px_28px_rgba(37,128,255,0.25)]"
: "agent-sidebar-card--active border-primary/50 bg-primary/10 shadow-sm"
: isDark
? "border-[#2b8ffe]/20 bg-[#042b59]/35 hover:border-[#2b8ffe]/60 hover:bg-[#063b77]/60"
: "border-transparent hover:border-border hover:bg-accent/50",
)}
>
<span
className={cn(
"mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-lg text-white shadow-sm",
isDark &&
isActive &&
"ring-2 ring-[#83c1ff]/35 shadow-[0_0_18px_rgba(43,143,254,0.45)]",
)}
style={{
backgroundImage: `linear-gradient(135deg, ${from}, ${to})`,
}}
>
<Bot className="size-[19px]" />
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
"block truncate text-[16px] leading-5 font-bold",
isActive
? isDark
? "text-white"
: "text-primary"
: isDark
? "text-white/95"
: "text-foreground",
)}
>
{desensitize(agent.name)}
</span>
<span
className={cn(
"mt-1 block truncate text-[13px] leading-[1.35]",
isDark
? isActive
? "text-[#d6eaff]"
: "text-[#9ebfe2]"
: "text-muted-foreground",
)}
>
{agent.description
? desensitize(agent.description)
: "智能体助手"}
</span>
</span>
{isActive && (
<span
className={cn(
"absolute top-1/2 right-1.5 h-8 w-1.5 -translate-y-1/2 rounded-full",
isDark
? "bg-[#2b8ffe] shadow-[0_0_14px_rgba(43,143,254,0.85)]"
: "bg-primary",
)}
/>
)}
</Link>
);
})}
{/* 超出一页时引导到完整的智能体管理页(不在侧边栏静默截断) */}
{total > agents.length && (
<Link
href={`${routeBase}/agents`}
className="text-muted-foreground hover:text-foreground hover:bg-accent/50 mt-0.5 rounded-lg px-3 py-1.5 text-center text-xs transition-colors"
>
共 {total} 个,查看全部 →
</Link>
)}
</div>
)}
</div>
</>
)}
</div>
{/* 最近的对话(下半,独立滚动)—— 参考首页通用问答左栏 */}
<RecentChatsPane activeAgentId={activeAgentId} />
</aside>
);
}
/**
* 「最近的对话」面板(参考首页 GeneralQaSidebar 的「最近的对话」区块)。
* 跨智能体的近期会话,点击打开;支持重命名/置顶/收藏/分享/导出/删除。
*/
function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
const { t } = useI18n();
const pathname = usePathname();
const router = useRouter();
const pathOf = usePathOfThread();
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const { data: threads = [] } = useThreads();
const { mutate: deleteThread } = useDeleteThread();
const { mutate: renameThread } = useRenameThread();
const { toggle: togglePin } = useToggleThreadPin();
const { toggle: toggleFavorite } = useToggleThreadFavorite();
const [query, setQuery] = useState("");
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
const [renameThreadId, setRenameThreadId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [shareThreadId, setShareThreadId] = useState<string | null>(null);
// 排除调度/会商类系统会话,与首页一致;智能体创建(bootstrap)会话保留展示
// (带机器人图标、点击回创建页);置顶优先。
const visibleThreads = useMemo(
() =>
sortThreadsPinnedFirst(
threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "roundtable",
),
),
[threads],
);
const filteredThreads = useMemo(() => {
const q = query.trim().toLowerCase();
const list = q
? visibleThreads.filter((thread) =>
titleOfThread(thread, t.pages.newChat).toLowerCase().includes(q),
)
: visibleThreads;
return q ? list : list.slice(0, MAX_RECENT_CHATS);
}, [visibleThreads, query, t.pages.newChat]);
const handleDelete = useCallback(
(threadId: string) => {
deleteThread({ threadId });
if (threadId === threadIdFromPath) {
const threadIndex = threads.findIndex((th) => th.thread_id === threadId);
let nextThreadPath = pathOf("new", { agent_id: activeAgentId });
if (threadIndex > -1) {
if (threads[threadIndex + 1]) {
nextThreadPath = pathOf(threads[threadIndex + 1]!);
} else if (threads[threadIndex - 1]) {
nextThreadPath = pathOf(threads[threadIndex - 1]!);
}
}
void router.push(nextThreadPath);
}
},
[activeAgentId, deleteThread, pathOf, router, threadIdFromPath, threads],
);
const handleRenameClick = useCallback(
(threadId: string, currentTitle: string) => {
setRenameThreadId(threadId);
setRenameValue(currentTitle);
setRenameDialogOpen(true);
},
[],
);
const handleRenameSubmit = useCallback(() => {
if (renameThreadId && renameValue.trim()) {
renameThread({ threadId: renameThreadId, title: renameValue.trim() });
setRenameDialogOpen(false);
setRenameThreadId(null);
setRenameValue("");
}
}, [renameThread, renameThreadId, renameValue]);
const handleExport = useCallback(
async (thread: AgentThread, format: "markdown" | "json") => {
try {
const apiClient = getAPIClient();
const state = await apiClient.threads.getState<AgentThreadState>(
thread.thread_id,
);
const messages = state.values?.messages ?? [];
if (messages.length === 0) {
toast.error(t.conversation.noMessages);
return;
}
if (format === "markdown") exportThreadAsMarkdown(thread, messages);
else exportThreadAsJSON(thread, messages);
toast.success(t.common.exportSuccess);
} catch {
toast.error("Failed to export conversation");
}
},
[t],
);
const isStatic = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
return (
<div className="flex min-h-0 flex-[2] flex-col overflow-y-auto border-t border-border/60 px-2 py-3">
<h2 className="mb-1 flex items-center gap-1.5 px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 select-none">
<span>{t.sidebar.recentChats}</span>
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
{visibleThreads.length}
</span>
</h2>
{/* 历史会话搜索框 */}
<div className="relative mb-1.5 px-1">
<Search className="text-muted-foreground/60 pointer-events-none absolute top-1/2 left-3.5 size-3.5 -translate-y-1/2" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索历史会话…"
className="h-8 rounded-lg border-border bg-card pl-8 text-[12px]"
/>
</div>
<div className="flex flex-col gap-0.5">
{filteredThreads.length === 0 ? (
<div className="text-muted-foreground/70 px-3 py-4 text-center text-[12px]">
{query ? "没有匹配的会话" : t.chats.noChats}
</div>
) : (
filteredThreads.map((thread) => {
const isBoot = isBootstrapThread(thread);
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
const isActive = href === pathname;
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
return (
<div
key={thread.thread_id}
className="group/item relative flex items-center"
>
<Link
href={href}
className={cn(
"relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
isActive
? "border border-primary/40 bg-primary/5 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
{isBoot ? (
<Bot
className={cn(
"size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
aria-label="智能体创建会话"
/>
) : (
<MessageSquare
className={cn(
"size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
/>
)}
{pinned && (
<Pin className="text-muted-foreground/70 size-3 shrink-0" />
)}
<span className="truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
{isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
)}
</Link>
{!isStatic && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:bg-background group-hover/item:opacity-100 data-[state=open]:opacity-100 absolute right-1 flex size-6 items-center justify-center rounded-md opacity-0 transition-opacity"
>
<MoreHorizontal className="size-4" />
<span className="sr-only">{t.common.more}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side="right"
align="start"
>
<DropdownMenuItem
onSelect={() =>
handleRenameClick(
thread.thread_id,
titleOfThread(thread, t.pages.newChat),
)
}
>
<Pencil className="text-muted-foreground" />
<span>{t.common.rename}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => togglePin(thread.thread_id, !pinned)}
>
{pinned ? (
<PinOff className="text-muted-foreground" />
) : (
<Pin className="text-muted-foreground" />
)}
<span>{pinned ? t.common.unpin : t.common.pin}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
toggleFavorite(thread.thread_id, !favorited)
}
>
<Star
className={
favorited
? "fill-amber-400 text-amber-400"
: "text-muted-foreground"
}
/>
<span>
{favorited ? t.common.unfavorite : t.common.favorite}
</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setShareThreadId(thread.thread_id)}
>
<Share2 className="text-muted-foreground" />
<span>{t.common.share}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Download className="text-muted-foreground" />
<span>{t.common.export}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onSelect={() => handleExport(thread, "markdown")}
>
<FileText className="text-muted-foreground" />
<span>{t.common.exportAsMarkdown}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => handleExport(thread, "json")}
>
<FileJson className="text-muted-foreground" />
<span>{t.common.exportAsJSON}</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => handleDelete(thread.thread_id)}
>
<Trash2 className="text-muted-foreground" />
<span>{t.common.delete}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
})
)}
</div>
{/* 重命名弹窗 */}
<Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t.common.rename}</DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
placeholder={t.common.rename}
onKeyDown={(e) => {
if (e.key === "Enter" && !isIMEComposing(e)) {
e.preventDefault();
handleRenameSubmit();
}
}}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRenameDialogOpen(false)}>
{t.common.cancel}
</Button>
<Button onClick={handleRenameSubmit}>{t.common.save}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 分享弹窗 */}
<ShareConversationDialog
threadId={shareThreadId}
open={shareThreadId !== null}
onOpenChange={(o) => {
if (!o) setShareThreadId(null);
}}
/>
</div>
);
}