diff --git a/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx b/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx index 03fddad8..6863e4c4 100644 --- a/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx +++ b/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx @@ -1,10 +1,46 @@ "use client"; -import { Bot, Loader2, Search, X } from "lucide-react"; +import { + Bot, + Download, + FileJson, + FileText, + Loader2, + MessageSquare, + MoreHorizontal, + Pencil, + Pin, + PinOff, + Search, + Share2, + Star, + Trash2, + X, +} from "lucide-react"; import Link from "next/link"; -import { useEffect, useMemo, useState } from "react"; +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, @@ -13,11 +49,35 @@ import { 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 { + 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。 */ @@ -27,18 +87,23 @@ const MINE_SCOPE = "mine"; // 避免静默截断。侧边栏只做快速切换,不在此分页。 const LIST_PAGE_SIZE = 50; +// 「最近的对话」未搜索时最多展示条数(与首页「通用问答」左栏一致)。 +const MAX_RECENT_CHATS = 10; + /** - * 智能体问答页左侧「智能体列表」栏。 + * 智能体问答页左侧「智能体列表 + 最近对话」栏(参考首页通用问答左栏)。 * - * - 顶部下拉:选「我的」(默认,= 我创建/收藏的)或任一「广场」(发布广场 publish square), - * 与「智能体管理」页的主标签一致。 - * - 下方:所选作用域下的智能体列表;点击进入该智能体的新对话(保持当前路由命名空间)。 - * - 当前正在对话的智能体高亮。 + * 自上而下: + * 1. 顶部下拉:选「我的」(默认,= 我创建/收藏的)或任一「广场」(发布广场), + * 与「智能体管理」页主标签一致;下方为智能体名称模糊搜索框。 + * 2. 智能体列表:所选作用域下的智能体;点击进入该智能体的新对话。当前智能体高亮。 + * 3. 最近的对话:跨智能体的近期会话(参考首页),点击打开;支持重命名/置顶/收藏/ + * 分享/导出/删除。当前会话高亮。 * - * 仅在独立的智能体问答整页(/page/strategy|workspace/agents/:id/chats/:thread)展示; - * 任务工作区 / zzJC·zzXD 模块 / 嵌入态不展示(它们各有自己的左栏)。 + * 仅在独立的智能体问答整页展示;任务工作区 / zzJC·zzXD 模块 / 嵌入态不展示。 */ export function AgentChatSidebar({ activeAgentId }: { activeAgentId?: string }) { + const { t } = useI18n(); const routeBase = useRouteBase(); const { theme } = useTheme(); const isDark = theme === "dark"; @@ -124,8 +189,11 @@ export function AgentChatSidebar({ activeAgentId }: { activeAgentId?: string }) - {/* 智能体列表 */} -
+ {/* 智能体列表(上半,独立滚动) */} +
+

+ 智能体 +

{isLoading && agents.length === 0 ? (
@@ -186,19 +254,325 @@ export function AgentChatSidebar({ activeAgentId }: { activeAgentId?: string }) ); })} + {/* 超出一页时引导到完整的智能体管理页(不在侧边栏静默截断) */} + {total > agents.length && ( + + 共 {total} 个,查看全部 → + + )}
)}
- {/* 底部:超出一页时引导到完整的智能体管理页(不在侧边栏静默截断) */} - {total > agents.length && ( - - 共 {total} 个,查看全部 → - - )} + {/* 最近的对话(下半,独立滚动)—— 参考首页通用问答左栏 */} + ); } + +/** + * 「最近的对话」面板(参考首页 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); + + // 排除调度/引导/会商类会话,与首页一致;置顶优先。 + const visibleThreads = useMemo( + () => + sortThreadsPinnedFirst( + threads.filter( + (thread) => + thread.metadata?.thread_type !== "scheduler" && + thread.metadata?.thread_type !== "bootstrap" && + 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 href = pathOf(thread); + const isActive = href === pathname; + const pinned = isThreadPinned(thread); + const favorited = isThreadFavorite(thread); + return ( +
+ + + {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); + }} + /> +
+ ); +}