feat(agent-sidebar): 独立问答智能体列表栏增加「最近的对话」区(参考首页)
左栏改为上下双区:上半智能体列表(来源下拉+名称搜索),下半新增「最近的对话」—— 跨智能体近期会话、计数徽标、历史搜索、点击打开/当前高亮/置顶标记,含重命名/置顶/ 收藏/分享/导出/删除菜单。会话链接走 usePathOfThread,自动带路由命名空间与 agent_id。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user