feat(agent-sidebar): 独立问答智能体列表栏增加「最近的对话」区(参考首页)

左栏改为上下双区:上半智能体列表(来源下拉+名称搜索),下半新增「最近的对话」——
跨智能体近期会话、计数徽标、历史搜索、点击打开/当前高亮/置顶标记,含重命名/置顶/
收藏/分享/导出/删除菜单。会话链接走 usePathOfThread,自动带路由命名空间与 agent_id。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 authored and fengxiangdebaozi committed 2026-06-19 16:09:01 +08:00
1 parent a6dd0fdd85
commit 8cc2650e98
1 file changed
+394 -20
@@ -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 })
</div>
</div>
{/* 智能体列表 */}
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-2 py-3">
{/* 智能体列表(上半,独立滚动) */}
<div className="flex min-h-0 flex-[3] flex-col overflow-y-auto px-2 py-3">
<h2 className="mb-1 px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 select-none">
智能体
</h2>
{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" />
@@ -186,19 +254,325 @@ export function AgentChatSidebar({ activeAgentId }: { activeAgentId?: string })
</Link>
);
})}
{/* 超出一页时引导到完整的智能体管理页(不在侧边栏静默截断) */}
{total > agents.length && (
<Link
href={`${routeBase}/agents`}
className="text-muted-foreground hover:text-foreground mt-0.5 rounded-lg px-3 py-1.5 text-center text-xs transition-colors hover:bg-accent/50"
>
共 {total} 个,查看全部 →
</Link>
)}
</div>
)}
</div>
{/* 底部:超出一页时引导到完整的智能体管理页(不在侧边栏静默截断) */}
{total > agents.length && (
<Link
href={`${routeBase}/agents`}
className="text-muted-foreground hover:text-foreground shrink-0 border-t border-border/60 px-3 py-2 text-center text-xs transition-colors"
>
共 {total} 个,查看全部 →
</Link>
)}
{/* 最近的对话(下半,独立滚动)—— 参考首页通用问答左栏 */}
<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);
// 排除调度/引导/会商类会话,与首页一致;置顶优先。
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<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 href = 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",
)}
>
<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>
);
}