Files
deerflow-code/frontend-web/src/pages/ChatsPage.tsx
T
2026-08-30 01:25:20 +08:00

622 lines
26 KiB
TypeScript

"use client";
import {
ChevronLeft,
ChevronRight,
Compass,
Clock,
DownloadIcon,
MessageSquare,
PenTool,
Pin,
PinOff,
Search,
Star,
Users2,
type LucideIcon,
X,
} from "lucide-react";
import Link from "next/link";
import { motion } from "motion/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs";
import {
CompactShortcutButtons,
LayoutModeSwitcher,
ThemeSwitcher,
} from "@/components/workspace/appearance-controls";
import {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { useI18n } from "@/core/i18n/hooks";
import {
useThreadsCount,
useThreadsPage,
useToggleThreadFavorite,
useToggleThreadPin,
} from "@/core/threads/hooks";
import type { AgentThread } from "@/core/threads/types";
import {
isThreadFavorite,
isThreadPinned,
usePathOfThread,
titleOfThread,
} from "@/core/threads/utils";
import { formatTimeAgo } from "@/core/utils/datetime";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions";
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
import { useResearchSessions } from "@/strategy-components/api/deep-research";
import { RoundtableHistoryTab } from "./chats-history/RoundtableHistoryTab";
import { WritingHistoryTab } from "./chats-history/WritingHistoryTab";
import { ResearchHistoryTab } from "./chats-history/ResearchHistoryTab";
const PAGE_SIZE = 20;
/**
* 「对话历史」页的四个历史 Tab:对话(通用问答/智能体会话)之外,把多智能体
* 会商、AI写作、深度研究的记录也收纳进来(简洁模式侧栏不再展示后两者的入口,
* 统一从这里查看)。
*/
type HistoryTab = "chats" | "roundtable" | "writing" | "research";
type ChatFilter = "all" | "favorites" | "pinned";
const HISTORY_TABS: {
id: HistoryTab;
label: string;
description: string;
icon: LucideIcon;
}[] = [
{
id: "chats",
label: "对话",
description: "普通问答与智能体会话",
icon: MessageSquare,
},
{
id: "roundtable",
label: "多智能体会商",
description: "多角色协作研讨",
icon: Users2,
},
{
id: "writing",
label: "AI写作",
description: "写作任务与草稿",
icon: PenTool,
},
{
id: "research",
label: "深度研究",
description: "研究任务与报告",
icon: Compass,
},
];
/** 非对话 Tab 的搜索占位文案(对话 Tab 沿用 i18n 的 t.chats.searchChats)。 */
const TAB_SEARCH_PLACEHOLDER: Record<Exclude<HistoryTab, "chats">, string> = {
roundtable: "搜索会商记录…",
writing: "搜索写作记录…",
research: "搜索研究记录…",
};
const TAB_TOTAL_UNIT: Record<HistoryTab, string> = {
chats: "条对话",
roundtable: "条会商记录",
writing: "篇写作",
research: "项研究",
};
export default function ChatsPage({
compactChrome = false,
}: {
compactChrome?: boolean;
}) {
const pathOfThread = usePathOfThread();
const { t } = useI18n();
const { toggle: togglePin } = useToggleThreadPin();
const { toggle: toggleFavorite } = useToggleThreadFavorite();
const [activeTab, setActiveTab] = useState<HistoryTab>("chats");
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [chatFilter, setChatFilter] = useState<ChatFilter>("all");
const [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false);
// 非对话 Tab 的记录总数由 Tab 组件回传(客户端过滤后的条数)。
const [tabTotal, setTabTotal] = useState<number | null>(null);
// 顶部的数字角标与参考页一致,始终显示各类记录的实际总数,而不是当前筛选结果。
const { drafts: roundtableTabDrafts, refresh: refreshRoundtableTabDrafts } = useRoundtableDrafts();
const { sessions: writingTabSessions, refresh: refreshWritingTabSessions } = useAIWritingSessions();
const { data: researchTabData } = useResearchSessions(100);
useEffect(() => {
document.title = `${t.pages.chats} - ${t.pages.appName}`;
}, [t.pages.chats, t.pages.appName]);
useEffect(() => {
void refreshRoundtableTabDrafts();
void refreshWritingTabSessions();
}, [refreshRoundtableTabDrafts, refreshWritingTabSessions]);
// Debounce the search box so each keystroke doesn't fire a backend query.
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300);
return () => clearTimeout(timer);
}, [search]);
// Reset to the first page whenever the filtered result set changes.
useEffect(() => {
setPage(1);
}, [debouncedSearch, chatFilter]);
// 切换 Tab 时回到第一页并清空旧 Tab 上报的计数。
useEffect(() => {
setPage(1);
setTabTotal(null);
}, [activeTab]);
const handleTabTotalChange = useCallback((total: number) => {
setTabTotal(total);
}, []);
// Conversations are paginated on the backend: each page is one bounded
// query (title search + favorites filter included), so a long history is
// never fetched in one go. Pinned conversations come from their own small
// query and are surfaced in a dedicated section on the first page only,
// and dropped from the paginated rows so they don't repeat on every page.
const threadFilter = useMemo(
() =>
chatFilter === "favorites"
? { is_favorite: true }
: chatFilter === "pinned"
? { is_pinned: true }
: {},
[chatFilter],
);
const pinnedFilter = useMemo(
() => ({ ...threadFilter, is_pinned: true }),
[threadFilter],
);
const { data: pageData } = useThreadsPage({
page,
pageSize: PAGE_SIZE,
search: debouncedSearch,
metadata: threadFilter,
});
const { data: pinnedData } = useThreadsPage({
page: 1,
pageSize: 100,
search: debouncedSearch,
metadata: pinnedFilter,
});
const { data: totalCount } = useThreadsCount({
search: debouncedSearch,
metadata: threadFilter,
});
const { data: allThreadsCount } = useThreadsCount({
search: debouncedSearch,
metadata: {},
});
const { data: favoriteThreadsCount } = useThreadsCount({
search: debouncedSearch,
metadata: { is_favorite: true },
});
const { data: pinnedThreadsCount } = useThreadsCount({
search: debouncedSearch,
metadata: { is_pinned: true },
});
const pinnedThreads = chatFilter === "pinned" ? [] : (pinnedData?.threads ?? []);
const pagedThreads = useMemo(
() =>
(pageData?.threads ?? []).filter(
(thread) => chatFilter === "pinned" || !isThreadPinned(thread),
),
[chatFilter, pageData?.threads],
);
const currentPage = page;
const hasMore = pageData?.hasMore ?? false;
// Page windows are sliced over the raw (pinned-inclusive) row order, so the
// page count derives from the raw total as well.
const totalPages =
totalCount != null ? Math.max(1, Math.ceil(totalCount / PAGE_SIZE)) : null;
const renderThread = (thread: AgentThread) => {
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
return (
<div
key={thread.thread_id}
className={cn(
"group/chat-row relative flex min-w-0 items-start gap-3 rounded-[10px] border bg-card p-3 transition-[border-color,box-shadow,background-color] duration-150 sm:items-center sm:p-3.5",
pinned
? "border-red-200 bg-gradient-to-r from-red-50/50 via-white to-white shadow-xs hover:border-red-300 hover:shadow-sm"
: "border-neutral-200/80 bg-white hover:!border-red-300 hover:!bg-gradient-to-r hover:!from-red-50/50 hover:!via-white hover:!to-white hover:shadow-xs",
)}
>
<Button
variant="ghost"
size="icon-sm"
className={cn(
"mt-0.5 shrink-0 rounded-lg sm:mt-0",
favorited
? "bg-amber-50 text-amber-500 hover:bg-amber-100 hover:text-amber-600"
: "text-muted-foreground/45 hover:bg-muted hover:text-muted-foreground",
)}
title={favorited ? t.common.unfavorite : t.common.favorite}
aria-label={favorited ? t.common.unfavorite : t.common.favorite}
onClick={() => toggleFavorite(thread.thread_id, !favorited)}
>
<Star className={cn("size-4", favorited && "fill-current")} />
</Button>
<Link
href={pathOfThread(thread)}
className="min-w-0 flex-1 rounded-lg p-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<div className="flex min-w-0 items-center gap-2">
{pinned && (
<span className="inline-flex items-center gap-1 rounded border !border-red-200 bg-red-100/80 px-1.5 py-0.5 text-[10px] font-semibold text-red-700">
<Pin className="size-3 rotate-45 text-red-600" />
置顶
</span>
)}
<span className="inline-flex items-center gap-1 rounded-full border !border-red-200 bg-red-50 px-2 py-0.5 text-[11px] font-semibold text-red-700">
<MessageSquare className="size-3 text-red-600" />
对话
</span>
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-neutral-900 transition-colors group-hover/chat-row:text-red-700 sm:text-base">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
</div>
{thread.updated_at && (
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground sm:hidden">
<Clock className="size-3" />
{formatTimeAgo(thread.updated_at)}
</div>
)}
</Link>
{thread.updated_at && (
<div className="hidden shrink-0 items-center gap-1.5 text-xs text-muted-foreground sm:flex">
<Clock className="size-3" />
<span>{formatTimeAgo(thread.updated_at)}</span>
</div>
)}
<div className="flex shrink-0 items-center opacity-100 transition-opacity sm:opacity-0 sm:group-hover/chat-row:opacity-100 sm:focus-within:opacity-100">
<Button
variant="ghost"
size="icon-sm"
className="size-8"
title={pinned ? t.common.unpin : t.common.pin}
aria-label={pinned ? t.common.unpin : t.common.pin}
onClick={() => togglePin(thread.thread_id, !pinned)}
>
{pinned ? (
<PinOff className="size-4 text-muted-foreground" />
) : (
<Pin className="size-4 text-muted-foreground" />
)}
</Button>
</div>
</div>
);
};
const isEmpty = pagedThreads.length === 0 && pinnedThreads.length === 0;
// 头部计数:对话 Tab 用后端总数,其余 Tab 用组件回传的过滤后条数。
const displayTotal = activeTab === "chats" ? totalCount : tabTotal;
const activeTabConfig = HISTORY_TABS.find((tab) => tab.id === activeTab)!;
const historyTabCounts: Record<HistoryTab, number> = {
chats: allThreadsCount ?? 0,
roundtable: roundtableTabDrafts.length,
writing: writingTabSessions.length,
research: researchTabData?.total ?? 0,
};
return (
<WorkspaceContainer>
<WorkspaceHeader
rightActions={
compactChrome ? (
<div className="flex items-center gap-1">
<CompactShortcutButtons tone="content" />
<ThemeSwitcher tone="content" />
<LayoutModeSwitcher tone="content" />
</div>
) : null
}
/>
<WorkspaceBody>
<div className="flex size-full min-w-0 flex-col">
<header className="flex shrink-0 flex-col items-center gap-3 pt-8">
<div className="relative w-full max-w-5xl px-4 sm:px-6">
<div className="flex w-full flex-col items-center">
<div
id="primary-tabs-nav"
className="inline-flex rounded-2xl border border-red-100 bg-white p-1.5 shadow-xs backdrop-blur-sm"
role="tablist"
aria-label="历史记录类型"
>
{HISTORY_TABS.map((tab) => {
const isActive = activeTab === tab.id;
const Icon = tab.icon;
const count = historyTabCounts[tab.id];
return (
<button
key={tab.id}
id={`history-tab-${tab.id}`}
type="button"
className={cn(
"relative flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200 select-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30",
isActive
? "font-semibold text-red-700 shadow-xs"
: "text-neutral-600 hover:bg-red-50/50 hover:text-red-600",
)}
role="tab"
aria-selected={isActive}
aria-controls={`history-panel-${tab.id}`}
title={tab.description}
onClick={() => setActiveTab(tab.id)}
>
{isActive && (
<motion.div
layoutId="activeTabBadge"
className="absolute inset-0 rounded-xl border !border-red-200 bg-red-50/90 shadow-xs"
transition={{ type: "spring", stiffness: 450, damping: 35 }}
aria-hidden="true"
/>
)}
<span
className={cn(
"relative z-10 flex items-center gap-2",
isActive
? "font-semibold text-red-700"
: "text-neutral-600",
)}
>
<Icon className="size-4" aria-hidden="true" />
<span>{tab.label}</span>
</span>
{count > 0 && (
<span
className={cn(
"relative z-10 rounded-full px-1.5 py-[0.05rem] font-mono text-[11px] transition-colors",
isActive
? "bg-red-600 font-bold text-white"
: "bg-neutral-100 text-neutral-500",
)}
>
{count}
</span>
)}
</button>
);
})}
</div>
</div>
{activeTab === "chats" && (
<Button
variant="outline"
size="sm"
className="absolute right-4 top-1/2 -translate-y-1/2 sm:right-6"
onClick={() => setImportOpen(true)}
>
<DownloadIcon className="size-4" />
{t.threadShare.importChats}
</Button>
)}
</div>
</header>
<main className="min-h-0 flex-1">
<ScrollArea className="size-full pb-8 pt-5 sm:pt-7">
<div className="mx-auto flex w-full max-w-5xl flex-col px-4 sm:px-6">
<section
id={`history-panel-${activeTab}`}
role="tabpanel"
aria-labelledby={`history-tab-${activeTab}`}
className="space-y-4 rounded-2xl border border-red-200 bg-white p-4 shadow-xs sm:p-5"
>
{activeTab !== "roundtable" && <div className="w-full space-y-3.5">
<div className="flex items-center justify-between gap-3">
{activeTab === "chats" ? (
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 md:pb-0" role="group" aria-label="对话筛选">
<button
type="button"
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30",
chatFilter === "all"
? "border !border-red-600 bg-red-600 font-semibold text-white shadow-xs"
: "border border-neutral-200 bg-white text-neutral-600 hover:border-red-200 hover:bg-red-50/60 hover:text-red-700",
)}
aria-pressed={chatFilter === "all"}
onClick={() => setChatFilter("all")}
>
<span>全部</span>
{allThreadsCount != null && (
<span
className={cn(
"rounded-full px-1.5 py-px font-mono text-[11px]",
chatFilter === "all"
? "bg-red-700 text-white"
: "bg-neutral-100 text-neutral-500",
)}
>
{allThreadsCount}
</span>
)}
</button>
<button
type="button"
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/30",
chatFilter === "favorites"
? "border-amber-300 bg-amber-50 font-semibold text-amber-700 shadow-xs"
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900",
)}
aria-pressed={chatFilter === "favorites"}
onClick={() => setChatFilter("favorites")}
>
<Star className={cn("size-3.5", chatFilter === "favorites" ? "fill-amber-500 text-amber-500" : "text-neutral-400")} />
<span>收藏</span>
{favoriteThreadsCount != null && (
<span
className={cn(
"rounded-full px-1.5 py-px font-mono text-[11px]",
chatFilter === "favorites"
? "bg-amber-200 text-amber-900"
: "bg-neutral-100 text-neutral-500",
)}
>
{favoriteThreadsCount}
</span>
)}
</button>
{pinnedThreadsCount != null && pinnedThreadsCount > 0 && (
<button
type="button"
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30",
chatFilter === "pinned"
? "border-red-200 bg-red-50 font-semibold text-red-700 shadow-xs"
: "border-neutral-200 bg-white text-neutral-600 hover:bg-red-50/50 hover:text-red-700",
)}
aria-pressed={chatFilter === "pinned"}
onClick={() => setChatFilter("pinned")}
>
<Pin className="size-3.5 rotate-45 text-red-600" />
<span>置顶</span>
<span className="rounded-full bg-red-100 px-1.5 py-px font-mono text-[11px] font-medium text-red-800">
{pinnedThreadsCount}
</span>
</button>
)}
</div>
) : (
<div className="min-w-0 truncate text-sm font-medium text-neutral-500">
{activeTabConfig.description}
</div>
)}
<label className="relative min-w-0 flex-1 md:max-w-md">
<Search
className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-neutral-400"
aria-hidden="true"
/>
<span className="sr-only">
{activeTab === "chats"
? t.chats.searchChats
: TAB_SEARCH_PLACEHOLDER[activeTab]}
</span>
<input
type="search"
className="w-full rounded-xl border border-neutral-200 bg-neutral-50/80 py-2 pl-9 pr-16 text-sm text-neutral-900 transition-all placeholder:text-neutral-400 hover:bg-white focus:border-red-500 focus:bg-white focus:outline-hidden focus:ring-2 focus:ring-red-500/20"
placeholder={
activeTab === "chats"
? "搜索对话标题、关键词或标签…"
: TAB_SEARCH_PLACEHOLDER[activeTab]
}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 text-neutral-400 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30"
aria-label="清空搜索"
onClick={() => setSearch("")}
>
<X className="size-3.5" />
</button>
)}
</label>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-red-50 pt-1 text-xs text-neutral-500">
{displayTotal != null && (
<span className="font-medium">
共 <strong className="font-bold text-red-700">{displayTotal}</strong>{" "}
{TAB_TOTAL_UNIT[activeTab]}
</span>
)}
</div>
</div>}
{activeTab === "chats" && (
<div className="space-y-3 pt-5">
{currentPage === 1 && pinnedThreads.map(renderThread)}
{pagedThreads.map(renderThread)}
{isEmpty && (
<div className="text-muted-foreground py-16 text-center text-sm">
{chatFilter === "favorites"
? t.chats.noFavorites
: chatFilter === "pinned"
? "暂无置顶对话"
: t.chats.noChats}
</div>
)}
{(currentPage > 1 || hasMore) && (
<div className="flex items-center justify-center gap-3 border-t border-primary/10 pt-5">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
{t.chats.prevPage}
</Button>
<span className="text-muted-foreground text-sm tabular-nums">
{totalPages != null
? `${currentPage} / ${totalPages}`
: currentPage}
</span>
<Button
variant="outline"
size="sm"
disabled={!hasMore}
onClick={() => setPage((p) => p + 1)}
>
{t.chats.nextPage}
<ChevronRight className="size-4" />
</Button>
</div>
)}
</div>
)}
{activeTab === "roundtable" && (
<RoundtableHistoryTab onTotalChange={handleTabTotalChange} />
)}
{activeTab === "writing" && (
<div className="pt-5">
<WritingHistoryTab
query={search}
onTotalChange={handleTabTotalChange}
/>
</div>
)}
{activeTab === "research" && (
<div className="pt-5">
<ResearchHistoryTab
query={search}
onTotalChange={handleTabTotalChange}
/>
</div>
)}
</section>
</div>
</ScrollArea>
</main>
</div>
</WorkspaceBody>
<ImportConversationsDialog
open={importOpen}
onOpenChange={setImportOpen}
/>
</WorkspaceContainer>
);
}