"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(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 一致:浅色白底,深蓝主题半透明深蓝玻璃面板。 ); } /** * 「最近的对话」面板(参考首页 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(null); const [renameValue, setRenameValue] = useState(""); const [shareThreadId, setShareThreadId] = useState(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( 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 (

{t.sidebar.recentChats} {visibleThreads.length}

{/* 历史会话搜索框 */}
setQuery(e.target.value)} placeholder="搜索历史会话…" className="h-8 rounded-lg border-border bg-card pl-8 text-[12px]" />
{filteredThreads.length === 0 ? (
{query ? "没有匹配的会话" : t.chats.noChats}
) : ( 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 (
{isBoot ? ( ) : ( )} {pinned && ( )} {desensitize(titleOfThread(thread, t.pages.newChat))} {isActive && ( )} {!isStatic && ( handleRenameClick( thread.thread_id, titleOfThread(thread, t.pages.newChat), ) } > {t.common.rename} togglePin(thread.thread_id, !pinned)} > {pinned ? ( ) : ( )} {pinned ? t.common.unpin : t.common.pin} toggleFavorite(thread.thread_id, !favorited) } > {favorited ? t.common.unfavorite : t.common.favorite} setShareThreadId(thread.thread_id)} > {t.common.share} {t.common.export} handleExport(thread, "markdown")} > {t.common.exportAsMarkdown} handleExport(thread, "json")} > {t.common.exportAsJSON} handleDelete(thread.thread_id)} > {t.common.delete} )}
); }) )}
{/* 重命名弹窗 */} {t.common.rename}
setRenameValue(e.target.value)} placeholder={t.common.rename} onKeyDown={(e) => { if (e.key === "Enter" && !isIMEComposing(e)) { e.preventDefault(); handleRenameSubmit(); } }} />
{/* 分享弹窗 */} { if (!o) setShareThreadId(null); }} />
); }