feat: add artifact library and improve public wiki
This commit is contained in:
1 parent
1b43205c1b
commit
dd0ee282df
16 files changed
+4081
-98
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FilesIcon, XIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import {
|
||||
artifactPathname,
|
||||
listThreadArtifacts,
|
||||
mergeArtifactPaths,
|
||||
} from "@/core/artifacts/listing";
|
||||
@@ -87,7 +88,13 @@ const ChatBox: React.FC<{
|
||||
}) => {
|
||||
const { isMock, thread } = useThread();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const requestedArtifact = useMemo(() => {
|
||||
const value = searchParams.get("artifact")?.trim();
|
||||
return value ? artifactPathname(value) : null;
|
||||
}, [searchParams]);
|
||||
const threadIdRef = useRef(threadId);
|
||||
const openedRequestedArtifactRef = useRef<string | null>(null);
|
||||
const artifactsCountRef = useRef(0);
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
const latestReferenceBatchIdRef = useRef<string | null>(null);
|
||||
@@ -120,7 +127,7 @@ const ChatBox: React.FC<{
|
||||
const { skills } = useSkills();
|
||||
|
||||
const canScanArtifactDirectory =
|
||||
artifactDirectoryModeEnabled &&
|
||||
(artifactDirectoryModeEnabled || !!requestedArtifact) &&
|
||||
!disableArtifacts &&
|
||||
!isMock &&
|
||||
!!threadId &&
|
||||
@@ -138,17 +145,21 @@ const ChatBox: React.FC<{
|
||||
);
|
||||
const checkpointArtifactSignature = checkpointArtifacts.join("\n");
|
||||
const availableArtifacts = useMemo(
|
||||
() =>
|
||||
artifactDirectoryModeEnabled
|
||||
? mergeArtifactPaths(
|
||||
checkpointArtifacts,
|
||||
(artifactListingQuery.data?.files ?? []).map((file) => file.path),
|
||||
)
|
||||
: checkpointArtifacts,
|
||||
() => {
|
||||
if (!artifactDirectoryModeEnabled && !requestedArtifact) {
|
||||
return checkpointArtifacts;
|
||||
}
|
||||
const discoveredArtifacts = (artifactListingQuery.data?.files ?? []).map(
|
||||
(file) => file.path,
|
||||
);
|
||||
if (requestedArtifact) discoveredArtifacts.push(requestedArtifact);
|
||||
return mergeArtifactPaths(checkpointArtifacts, discoveredArtifacts);
|
||||
},
|
||||
[
|
||||
artifactDirectoryModeEnabled,
|
||||
artifactListingQuery.data?.files,
|
||||
checkpointArtifacts,
|
||||
requestedArtifact,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -191,6 +202,29 @@ const ChatBox: React.FC<{
|
||||
thread.isLoading,
|
||||
);
|
||||
}, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
|
||||
useEffect(() => {
|
||||
if (
|
||||
disableArtifacts ||
|
||||
!requestedArtifact ||
|
||||
!threadId ||
|
||||
threadId === "new"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const requestKey = `${threadId}:${artifactPathname(requestedArtifact)}`;
|
||||
if (openedRequestedArtifactRef.current === requestKey) return;
|
||||
openedRequestedArtifactRef.current = requestKey;
|
||||
selectArtifact(requestedArtifact);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
}, [
|
||||
disableArtifacts,
|
||||
requestedArtifact,
|
||||
selectArtifact,
|
||||
setArtifactsOpen,
|
||||
threadId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldFetchBackendReferenceBatches) {
|
||||
setBackendReferenceBatches([]);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export type ArtifactLibraryKind =
|
||||
| "all"
|
||||
| "document"
|
||||
| "spreadsheet"
|
||||
| "text_data"
|
||||
| "archive"
|
||||
| "other";
|
||||
|
||||
export interface ArtifactLibraryItem {
|
||||
threadId: string;
|
||||
threadTitle: string;
|
||||
agentId: string | null;
|
||||
threadType: string | null;
|
||||
path: string;
|
||||
name: string;
|
||||
sizeBytes: number;
|
||||
mimeType: string | null;
|
||||
modifiedAt: string;
|
||||
fileKind: Exclude<ArtifactLibraryKind, "all">;
|
||||
}
|
||||
|
||||
export interface ArtifactLibraryResponse {
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
items: ArtifactLibraryItem[];
|
||||
}
|
||||
|
||||
export async function fetchArtifactLibrary({
|
||||
query,
|
||||
fileKind,
|
||||
offset,
|
||||
limit,
|
||||
refresh = false,
|
||||
signal,
|
||||
}: {
|
||||
query?: string;
|
||||
fileKind: ArtifactLibraryKind;
|
||||
offset: number;
|
||||
limit: number;
|
||||
refresh?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ArtifactLibraryResponse> {
|
||||
const search = new URLSearchParams({
|
||||
file_kind: fileKind,
|
||||
offset: String(offset),
|
||||
limit: String(limit),
|
||||
});
|
||||
const normalizedQuery = query?.trim();
|
||||
if (normalizedQuery) search.set("query", normalizedQuery);
|
||||
if (refresh) search.set("refresh", "true");
|
||||
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/artifact-library?${search.toString()}`,
|
||||
{ method: "GET", signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Failed to load artifact library (${response.status})${detail ? `: ${detail}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
total?: number;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
items?: Array<{
|
||||
thread_id: string;
|
||||
thread_title: string;
|
||||
agent_id?: string | null;
|
||||
thread_type?: string | null;
|
||||
path: string;
|
||||
name: string;
|
||||
size_bytes: number;
|
||||
mime_type?: string | null;
|
||||
modified_at: string;
|
||||
file_kind: Exclude<ArtifactLibraryKind, "all">;
|
||||
}>;
|
||||
};
|
||||
|
||||
return {
|
||||
total: data.total ?? 0,
|
||||
offset: data.offset ?? offset,
|
||||
limit: data.limit ?? limit,
|
||||
items: (data.items ?? []).map((item) => ({
|
||||
threadId: item.thread_id,
|
||||
threadTitle: item.thread_title,
|
||||
agentId: item.agent_id ?? null,
|
||||
threadType: item.thread_type ?? null,
|
||||
path: item.path,
|
||||
name: item.name,
|
||||
sizeBytes: item.size_bytes,
|
||||
mimeType: item.mime_type ?? null,
|
||||
modifiedAt: item.modified_at,
|
||||
fileKind: item.file_kind,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -149,7 +149,7 @@ export function buildWikiIndexCatalogMarkdown(
|
||||
buildPageUrl: WikiPageUrlBuilder = buildWeKnoraWikiPageUrl,
|
||||
indexGroups: WikiIndexGroup[] = [],
|
||||
): string {
|
||||
if (indexGroups.length > 0) {
|
||||
if (indexGroups.some((group) => group.items.length > 0)) {
|
||||
const sections: string[] = [];
|
||||
for (const group of indexGroups) {
|
||||
if (group.items.length === 0) continue;
|
||||
|
||||
@@ -61,6 +61,7 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
icon: HelpCircle,
|
||||
children: [
|
||||
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
|
||||
{ id: "artifact-library", label: "资料库", icon: Library, path: "/page/workspace/library" },
|
||||
{
|
||||
id: "multi-agent-roundtable",
|
||||
label: "多智能体会商",
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { RouteBaseProvider } from '@/core/route-base';
|
||||
|
||||
import ChatsPage from './ChatsPage';
|
||||
import ChatPage from './ChatPage';
|
||||
import ArtifactLibraryPage from './ArtifactLibraryPage';
|
||||
import IframeChatPage from './IframeChatPage';
|
||||
import AgentsPage from './AgentsPage';
|
||||
import AgentChatPage from './AgentChatPage';
|
||||
@@ -93,6 +94,7 @@ function shouldShowCompactTopActions(pathname: string): boolean {
|
||||
pathname === '/page/workspace/agents' ||
|
||||
pathname === '/page/workspace/skills' ||
|
||||
pathname === '/page/workspace/memory' ||
|
||||
pathname === '/page/workspace/library' ||
|
||||
pathname === '/page/workspace/knowledge' ||
|
||||
pathname.startsWith('/page/workspace/knowledge/')
|
||||
);
|
||||
@@ -444,6 +446,14 @@ export default function WorkspaceRoutes({
|
||||
path="chats/:thread_id"
|
||||
element={<ChatThreadEntryRoute layoutProps={layoutProps} />}
|
||||
/>
|
||||
<Route
|
||||
path="library"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps} workspaceSidebarHidden>
|
||||
<ArtifactLibraryPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="chats_iframe/new"
|
||||
element={<IframeChatEntryRoute layoutProps={layoutProps} hasThreadParam={false} />}
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface LlmWikiKnowledgeBase {
|
||||
remote_status: "available" | "missing";
|
||||
name: string;
|
||||
description: string;
|
||||
type: "document" | "faq";
|
||||
type: "document" | "faq" | "wiki";
|
||||
publication_status: "private" | "pending" | "published" | "rejected";
|
||||
owner_user_id?: string | null;
|
||||
is_owner: boolean;
|
||||
@@ -37,6 +37,9 @@ export interface LlmWikiKnowledgeBase {
|
||||
knowledge_count: number;
|
||||
chunk_count: number;
|
||||
processing_count: number;
|
||||
share_count?: number;
|
||||
is_processing?: boolean;
|
||||
is_temporary?: boolean;
|
||||
embedding_model_id?: string | null;
|
||||
vector_store_id?: string | null;
|
||||
vector_store_name?: string | null;
|
||||
@@ -44,19 +47,65 @@ export interface LlmWikiKnowledgeBase {
|
||||
vector_store_engine_type?: string | null;
|
||||
vector_store_status?: string | null;
|
||||
chunking_config?: Record<string, unknown>;
|
||||
image_processing_config?: Record<string, unknown>;
|
||||
indexing_strategy?: Record<string, boolean>;
|
||||
capabilities?: Record<string, boolean>;
|
||||
extract_config?: Record<string, unknown>;
|
||||
faq_config?: Record<string, unknown>;
|
||||
wiki_config?: Record<string, unknown>;
|
||||
question_generation_config?: Record<string, unknown>;
|
||||
auto_tag_config?: Record<string, unknown>;
|
||||
vlm_config?: Record<string, unknown>;
|
||||
asr_config?: Record<string, unknown>;
|
||||
summary_model_id?: string | null;
|
||||
storage_provider?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
published_at?: string | null;
|
||||
remote_created_at?: string | null;
|
||||
remote_updated_at?: string | null;
|
||||
is_conversation_deposit?: boolean;
|
||||
}
|
||||
|
||||
export interface PublicLlmWikiKnowledgeBase extends LlmWikiKnowledgeBase {
|
||||
wiki: {
|
||||
status: "ready" | "empty" | "unavailable";
|
||||
page_count: number;
|
||||
loaded_page_count: number;
|
||||
stats_complete: boolean;
|
||||
page_type_counts: Record<string, number>;
|
||||
page_status_counts: Record<string, number>;
|
||||
index_mode: "explicit" | "generated" | "none" | "unknown";
|
||||
has_explicit_index: boolean;
|
||||
index_slug: string | null;
|
||||
default_slug: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
embed_route: string;
|
||||
};
|
||||
local_index: {
|
||||
enabled: boolean;
|
||||
wiki_index_enabled: boolean;
|
||||
state: LlmWikiWikiIndexStatus["state"];
|
||||
fully_vectorized: boolean;
|
||||
retrieval_mode: "vector" | "wiki_api";
|
||||
remote_page_count: number;
|
||||
local_page_count: number;
|
||||
ready_page_count: number;
|
||||
failed_page_count: number;
|
||||
vector_count: number;
|
||||
last_started_at?: string | null;
|
||||
last_completed_at?: string | null;
|
||||
last_success_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PublicLlmWikiKnowledgeBaseList {
|
||||
knowledge_bases: PublicLlmWikiKnowledgeBase[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface LlmWikiDocument {
|
||||
id: string;
|
||||
type?: string;
|
||||
@@ -742,6 +791,13 @@ export async function getPublicLlmWikiKnowledgeBase(
|
||||
);
|
||||
}
|
||||
|
||||
export async function listPublicLlmWikiKnowledgeBases(): Promise<PublicLlmWikiKnowledgeBaseList> {
|
||||
return readJson(
|
||||
await apiFetch(`${PUBLIC_API()}/knowledge-bases`),
|
||||
"读取公开知识库列表",
|
||||
);
|
||||
}
|
||||
|
||||
export async function listPublicLlmWikiWikiPages(
|
||||
knowledgeBaseId: string,
|
||||
page: number,
|
||||
|
||||
+58
-6
@@ -54,6 +54,7 @@ import {
|
||||
import { WikiProtectedImage } from "./WikiProtectedImage";
|
||||
|
||||
const LIST_PATH = "/page/workspace/knowledge";
|
||||
const GENERATED_PUBLIC_INDEX_SLUG = "__index__";
|
||||
|
||||
function cleanText(value?: string | null) {
|
||||
return (value || "").replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
||||
@@ -144,13 +145,40 @@ export function WeKnoraWikiPageView({
|
||||
!!noteId && publicEmbed,
|
||||
);
|
||||
const pages = publicEmbed ? publicPages : privatePages;
|
||||
const pagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]);
|
||||
const indexPage = useMemo(
|
||||
() => (publicEmbed ? pagesList.find(isWikiIndexPage) : undefined),
|
||||
[pagesList, publicEmbed],
|
||||
const remotePagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]);
|
||||
const explicitIndexPage = useMemo(
|
||||
() => (publicEmbed ? remotePagesList.find(isWikiIndexPage) : undefined),
|
||||
[publicEmbed, remotePagesList],
|
||||
);
|
||||
const generatedIndexPage = useMemo<LlmWikiWikiPage | undefined>(
|
||||
() => publicEmbed && remotePagesList.length > 0 && !explicitIndexPage
|
||||
? {
|
||||
id: "__generated_public_index__",
|
||||
slug: GENERATED_PUBLIC_INDEX_SLUG,
|
||||
title: "索引",
|
||||
page_type: "index",
|
||||
status: "generated",
|
||||
content: "# 索引\n\n知识库页面目录",
|
||||
summary: "",
|
||||
aliases: [],
|
||||
source_refs: [],
|
||||
in_links: [],
|
||||
out_links: [],
|
||||
version: 0,
|
||||
}
|
||||
: undefined,
|
||||
[explicitIndexPage, publicEmbed, remotePagesList.length],
|
||||
);
|
||||
const indexPage = explicitIndexPage || generatedIndexPage;
|
||||
const pagesList = useMemo(
|
||||
() => generatedIndexPage ? [generatedIndexPage, ...remotePagesList] : remotePagesList,
|
||||
[generatedIndexPage, remotePagesList],
|
||||
);
|
||||
const landingPage = useMemo(() => selectDefaultWikiPage(pagesList), [pagesList]);
|
||||
const wikiSlug = requestedWikiSlug || (publicEmbed ? landingPage?.slug || "" : "");
|
||||
const generatedIndexActive = generatedIndexPage?.slug === wikiSlug;
|
||||
const generatedIndexRequested =
|
||||
publicEmbed && wikiSlug === GENERATED_PUBLIC_INDEX_SLUG && !explicitIndexPage;
|
||||
const privatePage = useLlmWikiWikiPage(
|
||||
noteId || null,
|
||||
wikiSlug || null,
|
||||
@@ -159,7 +187,11 @@ export function WeKnoraWikiPageView({
|
||||
const publicPage = usePublicLlmWikiWikiPage(
|
||||
noteId || null,
|
||||
wikiSlug || null,
|
||||
!!noteId && !!wikiSlug && publicEmbed,
|
||||
!!noteId &&
|
||||
!!wikiSlug &&
|
||||
publicEmbed &&
|
||||
!generatedIndexActive &&
|
||||
!generatedIndexRequested,
|
||||
);
|
||||
const page = publicEmbed ? publicPage : privatePage;
|
||||
const publicWikiIndex = usePublicLlmWikiWikiIndex(
|
||||
@@ -170,7 +202,9 @@ export function WeKnoraWikiPageView({
|
||||
const [directoryQuery, setDirectoryQuery] = useState("");
|
||||
const [collapsedDirectories, setCollapsedDirectories] = useState<Set<string>>(() => new Set());
|
||||
const routedPage = (location.state as { wikiPage?: LlmWikiWikiPage } | null)?.wikiPage;
|
||||
const pageData = page.data || (routedPage?.slug === wikiSlug ? routedPage : undefined);
|
||||
const pageData = generatedIndexActive
|
||||
? generatedIndexPage
|
||||
: page.data || (routedPage?.slug === wikiSlug ? routedPage : undefined);
|
||||
const filteredPages = useMemo(
|
||||
() => filterWikiPagesWithAncestors(pagesList, directoryQuery),
|
||||
[directoryQuery, pagesList],
|
||||
@@ -345,6 +379,24 @@ export function WeKnoraWikiPageView({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (publicEmbed && pages.error) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||
<BookOpenText className="size-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">无法读取 Wiki 目录</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{pages.error instanceof Error ? pages.error.message : "公开 Wiki 服务暂时不可用"}
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => void pages.refetch()}>
|
||||
<RefreshCw className="mr-2 size-4" />重试
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 text-sm text-muted-foreground">
|
||||
{publicEmbed ? "该知识库暂无可展示的 Wiki 页面" : "当前链接缺少 Wiki 页面标识"}
|
||||
|
||||
Reference in new issue
Block a user