434 lines
16 KiB
TypeScript
434 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
ArrowDownWideNarrow,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Download,
|
|
FileX2,
|
|
Library,
|
|
Loader2,
|
|
MessageSquareShare,
|
|
RefreshCw,
|
|
Search,
|
|
} from "lucide-react";
|
|
import Link from "next/link";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import {
|
|
WorkspaceBody,
|
|
WorkspaceContainer,
|
|
WorkspaceHeader,
|
|
} from "@/components/workspace/workspace-container";
|
|
import { downloadArtifactFile } from "@/core/artifacts/download";
|
|
import {
|
|
fetchArtifactLibrary,
|
|
type ArtifactLibraryItem,
|
|
type ArtifactLibraryKind,
|
|
} from "@/core/artifacts/library";
|
|
import { useI18n } from "@/core/i18n/hooks";
|
|
import { bootstrapResumePath, pathOfThread } from "@/core/threads/utils";
|
|
import { formatDateTime } from "@/core/utils/datetime";
|
|
import {
|
|
getFileExtensionDisplayName,
|
|
getFileIcon,
|
|
} from "@/core/utils/files";
|
|
import { desensitize } from "@/lib/desensitize";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const PAGE_SIZE = 50;
|
|
|
|
const FILTERS: Array<{ value: ArtifactLibraryKind; label: string }> = [
|
|
{ value: "all", label: "全部" },
|
|
{ value: "document", label: "文档" },
|
|
{ value: "spreadsheet", label: "表格" },
|
|
{ value: "text_data", label: "文本与数据" },
|
|
{ value: "archive", label: "压缩包" },
|
|
{ value: "other", label: "其他" },
|
|
];
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
|
const units = ["B", "KB", "MB", "GB"];
|
|
const index = Math.min(
|
|
Math.floor(Math.log(bytes) / Math.log(1024)),
|
|
units.length - 1,
|
|
);
|
|
const value = bytes / 1024 ** index;
|
|
const digits = index === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 2;
|
|
return `${value.toFixed(digits)} ${units[index]}`;
|
|
}
|
|
|
|
function sourceConversationPath(item: ArtifactLibraryItem): string {
|
|
const basePath =
|
|
item.threadType === "bootstrap"
|
|
? bootstrapResumePath(item.threadId)
|
|
: pathOfThread({
|
|
thread_id: item.threadId,
|
|
metadata: item.agentId ? { agent_id: item.agentId } : undefined,
|
|
});
|
|
const search = new URLSearchParams({ artifact: item.path });
|
|
return `${basePath}?${search.toString()}`;
|
|
}
|
|
|
|
function FileRow({
|
|
item,
|
|
downloading,
|
|
onDownload,
|
|
}: {
|
|
item: ArtifactLibraryItem;
|
|
downloading: boolean;
|
|
onDownload: (item: ArtifactLibraryItem) => void;
|
|
}) {
|
|
const sourcePath = sourceConversationPath(item);
|
|
const displayName = desensitize(item.name);
|
|
const sourceTitle = desensitize(item.threadTitle || "新对话");
|
|
|
|
return (
|
|
<tr className="group border-b transition-colors hover:bg-muted/35">
|
|
<td className="min-w-0 py-3 pr-4 pl-1">
|
|
<Link
|
|
href={sourcePath}
|
|
className="flex min-w-0 items-center gap-3 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
title={`打开原对话并查看 ${displayName}`}
|
|
>
|
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground/75">
|
|
{getFileIcon(item.name, "size-5")}
|
|
</span>
|
|
<span className="min-w-0">
|
|
<span className="block truncate text-sm font-medium text-foreground group-hover:text-primary">
|
|
{displayName}
|
|
</span>
|
|
<span className="mt-1 block truncate text-xs text-muted-foreground lg:hidden">
|
|
{getFileExtensionDisplayName(item.name)} · 来自「{sourceTitle}」
|
|
</span>
|
|
<span className="mt-1 hidden text-xs text-muted-foreground lg:block">
|
|
{getFileExtensionDisplayName(item.name)} 文件
|
|
</span>
|
|
</span>
|
|
</Link>
|
|
</td>
|
|
<td className="hidden min-w-0 px-3 py-3 lg:table-cell">
|
|
<Link
|
|
href={sourcePath}
|
|
className="block truncate text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
|
title={`打开原对话:${sourceTitle}`}
|
|
>
|
|
{sourceTitle}
|
|
</Link>
|
|
</td>
|
|
<td className="whitespace-nowrap px-3 py-3 text-sm text-muted-foreground">
|
|
{formatDateTime(item.modifiedAt)}
|
|
</td>
|
|
<td className="hidden whitespace-nowrap px-3 py-3 text-sm text-muted-foreground md:table-cell">
|
|
{formatBytes(item.sizeBytes)}
|
|
</td>
|
|
<td className="py-3 pr-1 pl-3 text-right">
|
|
<div className="flex items-center justify-end gap-1">
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
variant="ghost"
|
|
className="size-8"
|
|
disabled={downloading}
|
|
onClick={() => onDownload(item)}
|
|
title={`下载 ${displayName}`}
|
|
aria-label={`下载 ${displayName}`}
|
|
>
|
|
{downloading ? (
|
|
<Loader2 className="size-4 animate-spin" />
|
|
) : (
|
|
<Download className="size-4" />
|
|
)}
|
|
</Button>
|
|
<Button
|
|
asChild
|
|
size="icon"
|
|
variant="ghost"
|
|
className="size-8"
|
|
title={`打开原对话:${sourceTitle}`}
|
|
>
|
|
<Link href={sourcePath} aria-label={`打开原对话:${sourceTitle}`}>
|
|
<MessageSquareShare className="size-4" />
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
function LoadingRows() {
|
|
return Array.from({ length: 5 }, (_, index) => (
|
|
<tr className="border-b" key={index}>
|
|
<td className="py-3 pr-4 pl-1">
|
|
<div className="flex items-center gap-3">
|
|
<Skeleton className="size-10 shrink-0" />
|
|
<div className="min-w-0 flex-1 space-y-2">
|
|
<Skeleton className="h-4 w-2/3" />
|
|
<Skeleton className="h-3 w-1/3" />
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="hidden px-3 lg:table-cell"><Skeleton className="h-4 w-3/4" /></td>
|
|
<td className="px-3"><Skeleton className="h-4 w-24" /></td>
|
|
<td className="hidden px-3 md:table-cell"><Skeleton className="h-4 w-16" /></td>
|
|
<td className="pr-1 pl-3"><Skeleton className="ml-auto size-8" /></td>
|
|
</tr>
|
|
));
|
|
}
|
|
|
|
export default function ArtifactLibraryPage() {
|
|
const { t } = useI18n();
|
|
const [search, setSearch] = useState("");
|
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
|
const [fileKind, setFileKind] = useState<ArtifactLibraryKind>("all");
|
|
const [page, setPage] = useState(1);
|
|
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
|
|
const forceRefreshRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
document.title = `资料库 - ${t.pages.appName}`;
|
|
}, [t.pages.appName]);
|
|
|
|
useEffect(() => {
|
|
const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
|
return () => window.clearTimeout(timer);
|
|
}, [search]);
|
|
|
|
useEffect(() => {
|
|
setPage(1);
|
|
}, [debouncedSearch, fileKind]);
|
|
|
|
const offset = (page - 1) * PAGE_SIZE;
|
|
const libraryQuery = useQuery({
|
|
queryKey: ["artifact-library", debouncedSearch, fileKind, page],
|
|
queryFn: async ({ signal }) => {
|
|
const refresh = forceRefreshRef.current;
|
|
try {
|
|
return await fetchArtifactLibrary({
|
|
query: debouncedSearch,
|
|
fileKind,
|
|
offset,
|
|
limit: PAGE_SIZE,
|
|
refresh,
|
|
signal,
|
|
});
|
|
} finally {
|
|
forceRefreshRef.current = false;
|
|
}
|
|
},
|
|
retry: false,
|
|
});
|
|
|
|
const total = libraryQuery.data?.total ?? 0;
|
|
const items = libraryQuery.data?.items ?? [];
|
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
const hasPrevious = page > 1;
|
|
const hasNext = page < totalPages;
|
|
|
|
useEffect(() => {
|
|
if (page > totalPages) setPage(totalPages);
|
|
}, [page, totalPages]);
|
|
|
|
const itemKeys = useMemo(
|
|
() => new Set(items.map((item) => `${item.threadId}:${item.path}`)),
|
|
[items],
|
|
);
|
|
useEffect(() => {
|
|
if (downloadingKey && !itemKeys.has(downloadingKey)) setDownloadingKey(null);
|
|
}, [downloadingKey, itemKeys]);
|
|
|
|
const handleDownload = async (item: ArtifactLibraryItem) => {
|
|
const key = `${item.threadId}:${item.path}`;
|
|
if (downloadingKey) return;
|
|
setDownloadingKey(key);
|
|
try {
|
|
await downloadArtifactFile({ filepath: item.path, threadId: item.threadId });
|
|
} catch (error) {
|
|
console.error(error);
|
|
toast.error(error instanceof Error ? error.message : "下载失败");
|
|
} finally {
|
|
setDownloadingKey(null);
|
|
}
|
|
};
|
|
|
|
const handleRefresh = () => {
|
|
forceRefreshRef.current = true;
|
|
void libraryQuery.refetch();
|
|
};
|
|
|
|
return (
|
|
<WorkspaceContainer>
|
|
<WorkspaceHeader />
|
|
<WorkspaceBody className="items-stretch">
|
|
<div className="flex min-h-0 w-full flex-1 flex-col">
|
|
<header className="shrink-0 border-b bg-card/40 px-5 py-6 sm:px-8">
|
|
<div className="mx-auto flex w-full max-w-6xl flex-col gap-5">
|
|
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-start">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<Library className="size-6 text-primary" />
|
|
<h1 className="text-2xl font-semibold tracking-tight">资料库</h1>
|
|
</div>
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
汇总当前账号在对话中生成的文件,不包含上传文件、图片、音频和视频。
|
|
</p>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleRefresh}
|
|
disabled={libraryQuery.isFetching}
|
|
>
|
|
<RefreshCw className={cn("size-4", libraryQuery.isFetching && "animate-spin")} />
|
|
刷新
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="relative max-w-xl">
|
|
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
type="search"
|
|
className="h-11 pl-9"
|
|
placeholder="搜索文件名或来源对话"
|
|
value={search}
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex flex-wrap gap-1" aria-label="文件类型筛选">
|
|
{FILTERS.map((filter) => (
|
|
<Button
|
|
key={filter.value}
|
|
type="button"
|
|
size="sm"
|
|
variant={fileKind === filter.value ? "secondary" : "ghost"}
|
|
aria-pressed={fileKind === filter.value}
|
|
onClick={() => setFileKind(filter.value)}
|
|
>
|
|
{filter.label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<ArrowDownWideNarrow className="size-4" />
|
|
最近生成
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<ScrollArea className="min-h-0 flex-1">
|
|
<main className="mx-auto w-full max-w-6xl px-5 py-5 sm:px-8">
|
|
<div className="mb-2 text-xs text-muted-foreground tabular-nums">
|
|
{libraryQuery.isLoading ? "正在统计文件…" : `共 ${total} 个文件`}
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full table-fixed border-collapse">
|
|
<thead>
|
|
<tr className="border-b text-left text-xs font-normal text-muted-foreground">
|
|
<th className="w-[48%] py-3 pr-4 pl-1 font-normal lg:w-[38%]">文件名</th>
|
|
<th className="hidden w-[27%] px-3 py-3 font-normal lg:table-cell">来源对话</th>
|
|
<th className="w-[34%] px-3 py-3 font-normal md:w-[22%] lg:w-[17%]">生成时间</th>
|
|
<th className="hidden w-[13%] px-3 py-3 font-normal md:table-cell">大小</th>
|
|
<th className="w-[18%] py-3 pr-1 pl-3 text-right font-normal md:w-[10%]">
|
|
<span className="sr-only">操作</span>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{libraryQuery.isLoading ? (
|
|
<LoadingRows />
|
|
) : (
|
|
items.map((item) => {
|
|
const key = `${item.threadId}:${item.path}`;
|
|
return (
|
|
<FileRow
|
|
key={key}
|
|
item={item}
|
|
downloading={downloadingKey === key}
|
|
onDownload={(target) => void handleDownload(target)}
|
|
/>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{!libraryQuery.isLoading && libraryQuery.isError && (
|
|
<div className="flex flex-col items-center gap-3 py-16 text-center">
|
|
<FileX2 className="size-9 text-muted-foreground" />
|
|
<div>
|
|
<p className="text-sm font-medium">资料库加载失败</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{libraryQuery.error instanceof Error
|
|
? libraryQuery.error.message
|
|
: "请稍后重试"}
|
|
</p>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
|
重新加载
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{!libraryQuery.isLoading && !libraryQuery.isError && items.length === 0 && (
|
|
<div className="flex flex-col items-center gap-3 py-20 text-center">
|
|
<FileX2 className="size-9 text-muted-foreground" />
|
|
<div>
|
|
<p className="text-sm font-medium">
|
|
{debouncedSearch || fileKind !== "all"
|
|
? "没有找到匹配的文件"
|
|
: "还没有生成文件"}
|
|
</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
对话中生成的文档、表格和数据文件会自动出现在这里。
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!libraryQuery.isLoading && !libraryQuery.isError && totalPages > 1 && (
|
|
<div className="flex items-center justify-center gap-3 py-7">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={!hasPrevious}
|
|
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
|
>
|
|
<ChevronLeft className="size-4" />
|
|
上一页
|
|
</Button>
|
|
<span className="text-sm text-muted-foreground tabular-nums">
|
|
{page} / {totalPages}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={!hasNext}
|
|
onClick={() => setPage((current) => current + 1)}
|
|
>
|
|
下一页
|
|
<ChevronRight className="size-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</ScrollArea>
|
|
</div>
|
|
</WorkspaceBody>
|
|
</WorkspaceContainer>
|
|
);
|
|
}
|