- 新增 POST /threads/count:与 /threads/search 同一套过滤(标题搜索、
系统会话排除),仅返回 {total};ThreadMetaStore 增加 count 方法
(SQL 走 COUNT(*),JSON 过滤分批扫描计数)
- 对话页面搜索框下常驻显示「共 N 条对话」,分页条显示 当前页/总页数
(useThreadsCount,仅随筛选条件变化,翻页不重复请求)
- 智能体右侧 SOUL 预览改为 40vh 上限自适应展开(不再全部展开);
详情面板/卡片编辑/创建页的 SOUL 输入框统一为适中高度 + 可拖拽
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
275 lines
9.6 KiB
TypeScript
275 lines
9.6 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
DownloadIcon,
|
|
Pin,
|
|
PinOff,
|
|
Star,
|
|
} from "lucide-react";
|
|
import Link from "next/link";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs";
|
|
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";
|
|
|
|
const PAGE_SIZE = 20;
|
|
|
|
export default function ChatsPage() {
|
|
const pathOfThread = usePathOfThread();
|
|
const { t } = useI18n();
|
|
const { toggle: togglePin } = useToggleThreadPin();
|
|
const { toggle: toggleFavorite } = useToggleThreadFavorite();
|
|
const [search, setSearch] = useState("");
|
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
|
const [onlyFavorites, setOnlyFavorites] = useState(false);
|
|
const [page, setPage] = useState(1);
|
|
const [importOpen, setImportOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
document.title = `${t.pages.chats} - ${t.pages.appName}`;
|
|
}, [t.pages.chats, t.pages.appName]);
|
|
|
|
// 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, onlyFavorites]);
|
|
|
|
// 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 favoriteFilter = useMemo(
|
|
() => (onlyFavorites ? { is_favorite: true } : {}),
|
|
[onlyFavorites],
|
|
);
|
|
const pinnedFilter = useMemo(
|
|
() => ({ ...favoriteFilter, is_pinned: true }),
|
|
[favoriteFilter],
|
|
);
|
|
const { data: pageData } = useThreadsPage({
|
|
page,
|
|
pageSize: PAGE_SIZE,
|
|
search: debouncedSearch,
|
|
metadata: favoriteFilter,
|
|
});
|
|
const { data: pinnedData } = useThreadsPage({
|
|
page: 1,
|
|
pageSize: 100,
|
|
search: debouncedSearch,
|
|
metadata: pinnedFilter,
|
|
});
|
|
const { data: totalCount } = useThreadsCount({
|
|
search: debouncedSearch,
|
|
metadata: favoriteFilter,
|
|
});
|
|
|
|
const pinnedThreads = pinnedData?.threads ?? [];
|
|
const pagedThreads = useMemo(
|
|
() => (pageData?.threads ?? []).filter((t) => !isThreadPinned(t)),
|
|
[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="group/chat-row relative flex flex-col gap-2 border-b"
|
|
>
|
|
<Link href={pathOfThread(thread)} className="block p-4 pr-24">
|
|
<div className="flex items-center gap-1.5">
|
|
{pinned && (
|
|
<Pin className="size-3.5 shrink-0 text-muted-foreground/70" />
|
|
)}
|
|
<span className="truncate">
|
|
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
|
</span>
|
|
</div>
|
|
{thread.updated_at && (
|
|
<div className="text-muted-foreground mt-2 text-sm">
|
|
{formatTimeAgo(thread.updated_at)}
|
|
</div>
|
|
)}
|
|
</Link>
|
|
{/* Row actions — kept out of the Link so clicks don't navigate. */}
|
|
<div className="absolute right-3 top-3 flex items-center gap-1 opacity-0 transition-opacity group-hover/chat-row:opacity-100 focus-within:opacity-100">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-8"
|
|
title={favorited ? t.common.unfavorite : t.common.favorite}
|
|
onClick={() => toggleFavorite(thread.thread_id, !favorited)}
|
|
>
|
|
<Star
|
|
className={cn(
|
|
"size-4",
|
|
favorited
|
|
? "fill-amber-400 text-amber-400"
|
|
: "text-muted-foreground",
|
|
)}
|
|
/>
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-8"
|
|
title={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;
|
|
|
|
return (
|
|
<WorkspaceContainer>
|
|
<WorkspaceHeader></WorkspaceHeader>
|
|
<WorkspaceBody>
|
|
<div className="flex size-full flex-col">
|
|
<header className="flex shrink-0 flex-col items-center gap-3 pt-8">
|
|
<div className="flex w-full max-w-(--container-width-md) items-center justify-between gap-2">
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant={onlyFavorites ? "outline" : "secondary"}
|
|
size="sm"
|
|
onClick={() => setOnlyFavorites(false)}
|
|
>
|
|
{t.chats.allChats}
|
|
</Button>
|
|
<Button
|
|
variant={onlyFavorites ? "secondary" : "outline"}
|
|
size="sm"
|
|
onClick={() => setOnlyFavorites(true)}
|
|
>
|
|
<Star className="size-4" />
|
|
{t.chats.favorites}
|
|
</Button>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setImportOpen(true)}
|
|
>
|
|
<DownloadIcon className="size-4" />
|
|
{t.threadShare.importChats}
|
|
</Button>
|
|
</div>
|
|
<Input
|
|
type="search"
|
|
className="h-12 w-full max-w-(--container-width-md) text-xl"
|
|
placeholder={t.chats.searchChats}
|
|
autoFocus
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
{totalCount != null && (
|
|
<div className="text-muted-foreground w-full max-w-(--container-width-md) text-right text-xs tabular-nums">
|
|
共 {totalCount} 条对话
|
|
</div>
|
|
)}
|
|
</header>
|
|
<main className="min-h-0 flex-1">
|
|
<ScrollArea className="size-full py-4">
|
|
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
|
|
{currentPage === 1 && pinnedThreads.length > 0 && (
|
|
<>
|
|
<div className="text-muted-foreground/70 px-4 pb-1 pt-2 text-xs font-semibold tracking-wider uppercase">
|
|
{t.chats.pinnedSection}
|
|
</div>
|
|
{pinnedThreads.map(renderThread)}
|
|
</>
|
|
)}
|
|
{pagedThreads.map(renderThread)}
|
|
{isEmpty && (
|
|
<div className="text-muted-foreground py-16 text-center text-sm">
|
|
{onlyFavorites ? t.chats.noFavorites : t.chats.noChats}
|
|
</div>
|
|
)}
|
|
{(currentPage > 1 || hasMore) && (
|
|
<div className="flex items-center justify-center gap-3 py-6">
|
|
<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>
|
|
</ScrollArea>
|
|
</main>
|
|
</div>
|
|
</WorkspaceBody>
|
|
<ImportConversationsDialog
|
|
open={importOpen}
|
|
onOpenChange={setImportOpen}
|
|
/>
|
|
</WorkspaceContainer>
|
|
);
|
|
}
|