688 lines
27 KiB
TypeScript
688 lines
27 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 {
|
|
MyProjectThreadBadge,
|
|
MyProjectThreadIcon,
|
|
} from "@/components/workspace/my-project-thread-indicator";
|
|
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 { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
|
|
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
|
|
import {
|
|
bootstrapResumePath,
|
|
isBootstrapThread,
|
|
isHiddenFromRecentChats,
|
|
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, foreground } = agentColor(agent.id, theme);
|
|
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})`,
|
|
color: foreground,
|
|
}}
|
|
>
|
|
<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) => !isHiddenFromRecentChats(thread))),
|
|
[threads],
|
|
);
|
|
const projectThreadNames = useProjectThreadNames(visibleThreads);
|
|
|
|
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 projectInfo = myProjectConversationInfoOfThread(
|
|
thread,
|
|
projectThreadNames,
|
|
);
|
|
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",
|
|
projectInfo && "h-auto min-h-12 py-1.5",
|
|
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="智能体创建会话"
|
|
/>
|
|
) : projectInfo ? (
|
|
<MyProjectThreadIcon type={projectInfo.type} />
|
|
) : (
|
|
<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={cn(
|
|
"min-w-0 flex-1",
|
|
projectInfo && "flex flex-col items-start gap-0.5",
|
|
)}
|
|
>
|
|
<span className="w-full truncate">
|
|
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
|
</span>
|
|
{projectInfo && (
|
|
<MyProjectThreadBadge
|
|
className="mr-4 max-w-[calc(100%_-_1rem)]"
|
|
projectName={projectInfo.projectName}
|
|
type={projectInfo.type}
|
|
/>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|