651 lines
27 KiB
TypeScript
651 lines
27 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* 通用问答(全量版 / full layout)新建对话左侧栏 —— 按「样式修改.png」参考图重画。
|
||
*
|
||
* 这是**全量版专属**的独立组件:因为全量版是新功能、新需求,若继续往
|
||
* 共享的 WorkspaceNavChatList / FrequentAgentsList / RecentChatList 里塞会越来越难
|
||
* 维护,故单独成一个组件,只在 `/page/workspace/chats*` 的全量布局下挂载,互不影响。
|
||
*
|
||
* 三块结构(自上而下):
|
||
* 1. 「新对话」胶囊按钮 + 收起整列的圆形按钮;
|
||
* 2. 「常用智能体」—— 彩色图标卡(名称 + 两行简介,命中为选中态:主色描边 + 右侧主色条);
|
||
* 3. 「最近的对话」—— 计数徽标 + 折叠、历史搜索框、会话列表(悬浮「更多」菜单:
|
||
* 重命名/置顶/收藏/分享/导出/删除)、「更多最近对话」入口,底部品牌信息。
|
||
*/
|
||
|
||
import {
|
||
Bot,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
Download,
|
||
FileJson,
|
||
FileText,
|
||
MessageSquare,
|
||
MoreHorizontal,
|
||
Pencil,
|
||
Pin,
|
||
PinOff,
|
||
Plus,
|
||
Search,
|
||
Share2,
|
||
Star,
|
||
Trash2,
|
||
} from "lucide-react";
|
||
import Link from "next/link";
|
||
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
|
||
import { useCallback, useMemo, useState } from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { toast } from "sonner";
|
||
|
||
import { useTheme } from "@/contexts/ThemeContext";
|
||
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 { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||
import { useAgentsPaginated, type Agent } from "@/core/agents";
|
||
import { getAPIClient } from "@/core/api";
|
||
import { useI18n } from "@/core/i18n/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 {
|
||
isBootstrapThread,
|
||
isThreadFavorite,
|
||
isThreadPinned,
|
||
listPathOfThread,
|
||
pathOfThread,
|
||
sortThreadsPinnedFirst,
|
||
titleOfThread,
|
||
} from "@/core/threads/utils";
|
||
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
|
||
import { env } from "@/env";
|
||
import { desensitize } from "@/lib/desensitize";
|
||
import { isIMEComposing } from "@/lib/ime";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
import { agentColor } from "./agent-icon-color";
|
||
import { SidebarCollapseButton } from "./workspace-header";
|
||
|
||
const NEW_CHAT_PATH = "/page/workspace/chats/new";
|
||
/** 「更多」常用智能体跳转目标:智能体管理页。 */
|
||
const MORE_AGENTS_HREF = "/page/workspace/agents";
|
||
/** 「更多最近对话」跳转目标。 */
|
||
const MORE_CHATS_HREF = "/page/workspace/chats";
|
||
/** 常用智能体最多展示个数。 */
|
||
const MAX_FREQUENT = 4;
|
||
|
||
// 智能体图标配色(按 id 稳定散列、分主题)抽到共享模块 ./agent-icon-color,
|
||
// 与 zzJC/zzXD 模块左栏(AgentModuleSidebar)共用,保证彩色卡片配色完全一致。
|
||
|
||
/** 折叠区块标题(带 chevron 与可选计数徽标)。 */
|
||
function SectionLabel({
|
||
label,
|
||
count,
|
||
collapsed,
|
||
onToggle,
|
||
}: {
|
||
label: string;
|
||
count?: number;
|
||
collapsed: boolean;
|
||
onToggle: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onToggle}
|
||
className="flex w-full items-center justify-between px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 transition-colors hover:text-muted-foreground select-none"
|
||
>
|
||
<span className="flex items-center gap-1.5">
|
||
<span>{label}</span>
|
||
{count != null && (
|
||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
|
||
{count}
|
||
</span>
|
||
)}
|
||
</span>
|
||
<ChevronDown
|
||
className={cn(
|
||
"size-3.5 shrink-0 transition-transform duration-200",
|
||
collapsed && "-rotate-90",
|
||
)}
|
||
/>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export function GeneralQaSidebar() {
|
||
const { t } = useI18n();
|
||
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
|
||
const { theme } = useTheme();
|
||
const isDark = theme === "dark";
|
||
const pathname = usePathname();
|
||
const router = useRouter();
|
||
const navigate = useNavigate();
|
||
|
||
// ── 区块折叠状态(持久化,键独立于旧组件,避免互相影响)──
|
||
const [agentsCollapsed, setAgentsCollapsed] = useState(
|
||
() => localStorage.getItem("gqa-sidebar:agents-collapsed") === "true",
|
||
);
|
||
const [chatsCollapsed, setChatsCollapsed] = useState(
|
||
() => localStorage.getItem("gqa-sidebar:chats-collapsed") === "true",
|
||
);
|
||
const toggleAgents = () =>
|
||
setAgentsCollapsed((v) => {
|
||
localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v));
|
||
return !v;
|
||
});
|
||
const toggleChats = () =>
|
||
setChatsCollapsed((v) => {
|
||
localStorage.setItem("gqa-sidebar:chats-collapsed", String(!v));
|
||
return !v;
|
||
});
|
||
|
||
// ── 常用智能体取数:置顶 → 我的 → 广场,去重补足到 MAX_FREQUENT(沿用旧逻辑)──
|
||
const { agents: mine } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
|
||
const { agents: square } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
|
||
const frequent = useMemo<Agent[]>(() => {
|
||
const out: Agent[] = [];
|
||
const seen = new Set<string>();
|
||
const add = (agent: Agent | undefined) => {
|
||
if (!agent || out.length >= MAX_FREQUENT || seen.has(agent.id)) return;
|
||
seen.add(agent.id);
|
||
out.push(agent);
|
||
};
|
||
[...mine, ...square].filter((a) => a.is_pinned).forEach(add);
|
||
mine.forEach(add);
|
||
square.forEach(add);
|
||
return out;
|
||
}, [mine, square]);
|
||
|
||
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
|
||
const searchParams = useSearchParams();
|
||
const activeAgentId = searchParams.get("agent");
|
||
const currentSearch = searchParams.toString();
|
||
const newChatHref = buildFullChatNavigationPath(NEW_CHAT_PATH, currentSearch);
|
||
const moreAgentsHref = buildFullChatNavigationPath(MORE_AGENTS_HREF, currentSearch);
|
||
const moreChatsHref = buildFullChatNavigationPath(MORE_CHATS_HREF, currentSearch);
|
||
|
||
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
|
||
// 仅给 URL 加 ?agent=<id>,由 NewChatContent 把右侧问答区换成 AgentChatPage。
|
||
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
|
||
const handleSelectAgent = useCallback(
|
||
(agentId: string) => {
|
||
navigate(
|
||
buildFullChatNavigationPath(
|
||
`${NEW_CHAT_PATH}?agent=${encodeURIComponent(agentId)}`,
|
||
currentSearch,
|
||
),
|
||
);
|
||
},
|
||
[currentSearch, navigate],
|
||
);
|
||
|
||
// ── 最近的对话取数 + 搜索 ──
|
||
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } = useParams<{
|
||
thread_id: string;
|
||
agent_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(
|
||
() =>
|
||
// 智能体创建(bootstrap)会话也展示(带机器人图标、点击回创建页);
|
||
// 仅排除调度 / 会商类系统会话。
|
||
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;
|
||
// 未搜索时与旧版一致:最多展示 10 条
|
||
return q ? list : list.slice(0, 10);
|
||
}, [visibleThreads, query, t.pages.newChat]);
|
||
|
||
const handleNewChatClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return;
|
||
event.preventDefault();
|
||
navigate(newChatHref, { replace: true, state: { resetAt: Date.now() } });
|
||
};
|
||
|
||
const handleDelete = useCallback(
|
||
(threadId: string) => {
|
||
deleteThread({ threadId });
|
||
if (threadId === threadIdFromPath) {
|
||
const threadIndex = threads.findIndex((th) => th.thread_id === threadId);
|
||
let nextThreadPath = pathOfThread("new", { agent_id: agentIdFromPath });
|
||
if (threadIndex > -1) {
|
||
if (threads[threadIndex + 1]) {
|
||
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
|
||
} else if (threads[threadIndex - 1]) {
|
||
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
|
||
}
|
||
}
|
||
void router.push(buildFullChatNavigationPath(nextThreadPath, currentSearch));
|
||
}
|
||
},
|
||
[
|
||
agentIdFromPath,
|
||
currentSearch,
|
||
deleteThread,
|
||
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 (
|
||
// 背景走 sidebar token(浅主题=白、浅蓝主题=浅蓝);深蓝(.dark)主题下由 gq-sidebar
|
||
// 改为「深蓝主题底图 + 半透明蓝罩」玻璃面板(见 globals.css),与右侧 workspace-chat-bg
|
||
// 主区同底图、略深一档作面板区分,不要写死 bg-white。
|
||
<div className="gq-sidebar flex h-full min-h-0 flex-col bg-sidebar text-sidebar-foreground">
|
||
{/* ── 顶部:新对话胶囊 + 收起按钮 ── */}
|
||
<div className="flex items-center gap-2 px-3 pt-3 pb-2">
|
||
{/* 新对话按钮:完全自定义样式(不复用 Button variant)。borderColor / color 走
|
||
inline style,避免被全局 border-color(--border)覆盖;无阴影。主色描边 + 5px 圆角。
|
||
底色用「主色混入透明」=主色低透明度叠在当前侧栏底色上,浅/红/蓝/深蓝主题都自适应
|
||
(不要混 white,否则深蓝主题下会变成浅色块)。 */}
|
||
<Link
|
||
href={newChatHref}
|
||
onClick={handleNewChatClick}
|
||
style={{ borderColor: "var(--primary)", color: "var(--primary)" }}
|
||
className={cn(
|
||
"gq-new-chat-button flex h-10 min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[5px] text-[14px] font-semibold transition-colors",
|
||
"border border-solid",
|
||
"bg-[rgb(var(--legacy-primary-rgb)/14%)] hover:bg-[rgb(var(--legacy-primary-rgb)/24%)]",
|
||
)}
|
||
>
|
||
<Plus className="size-4 stroke-[2.5]" />
|
||
<span>{t.sidebar.newChat}</span>
|
||
</Link>
|
||
<SidebarCollapseButton className="size-9 shrink-0 rounded-full border border-border bg-card text-muted-foreground shadow-sm hover:bg-accent" />
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
||
{/* ── 常用智能体 ── */}
|
||
{frequent.length > 0 && (
|
||
<div className="pt-2">
|
||
<SectionLabel
|
||
label="常用智能体"
|
||
collapsed={agentsCollapsed}
|
||
onToggle={toggleAgents}
|
||
/>
|
||
{!agentsCollapsed && (
|
||
<div className="mt-1 flex flex-col gap-1">
|
||
{frequent.map((agent) => {
|
||
const isActive = activeAgentId === agent.id;
|
||
const { from, to, foreground } = agentColor(agent.id, theme);
|
||
return (
|
||
<button
|
||
key={agent.id}
|
||
type="button"
|
||
onClick={() => handleSelectAgent(agent.id)}
|
||
className={cn(
|
||
"group relative flex w-full items-start gap-3 rounded-xl border px-3 py-2.5 text-left 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",
|
||
)}
|
||
/>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
<Link
|
||
href={moreAgentsHref}
|
||
className="flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground"
|
||
>
|
||
<span>{t.common.more}</span>
|
||
<ChevronRight className="size-3.5 shrink-0" />
|
||
</Link>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── 最近的对话 ── */}
|
||
<div className="mt-3 border-t border-sidebar-border/60 pt-3">
|
||
<SectionLabel
|
||
label={t.sidebar.recentChats}
|
||
count={visibleThreads.length}
|
||
collapsed={chatsCollapsed}
|
||
onToggle={toggleChats}
|
||
/>
|
||
{!chatsCollapsed && (
|
||
<div className="mt-1.5">
|
||
{/* 历史会话搜索框 */}
|
||
<div className="relative mb-1.5 px-1">
|
||
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-3.5 -translate-y-1/2 text-muted-foreground/60" />
|
||
<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="px-3 py-4 text-center text-[12px] text-muted-foreground/70">
|
||
{query ? "没有匹配的会话" : t.chats.noChats}
|
||
</div>
|
||
) : (
|
||
filteredThreads.map((thread) => {
|
||
const href = buildFullChatNavigationPath(
|
||
listPathOfThread(thread),
|
||
currentSearch,
|
||
);
|
||
const isActive = href === pathname;
|
||
const isBoot = isBootstrapThread(thread);
|
||
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(
|
||
"gq-history-item relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
|
||
isActive
|
||
? "gq-history-item--active 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="智能体创建会话"
|
||
/>
|
||
) : (
|
||
<MessageSquare
|
||
className={cn(
|
||
"size-3.5 shrink-0",
|
||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||
)}
|
||
/>
|
||
)}
|
||
{pinned && (
|
||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||
)}
|
||
<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="absolute right-1 flex size-6 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-background group-hover/item:opacity-100 data-[state=open]:opacity-100"
|
||
>
|
||
<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>
|
||
);
|
||
})
|
||
)}
|
||
{!query && (
|
||
<Link
|
||
href={moreChatsHref}
|
||
className="mt-0.5 flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground"
|
||
>
|
||
<span>更多最近对话</span>
|
||
<ChevronRight className="size-3.5 shrink-0" />
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</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>
|
||
);
|
||
}
|