feat: preserve Wiki vectors across knowledge transfers and add local BGE-M3

Unify Wiki reading, preserve metadata and graph links, add asynchronous file-backed transfers, and reuse compatible vectors. Includes regression coverage and live acceptance notes.
This commit is contained in:
吉鑫升 committed 2026-09-03 19:21:10 +08:00
1 parent 49d9ced04f
commit 60e2c72514
38 files changed
+2536 -581

No files matched your search

@@ -25,6 +25,10 @@ const PHASE_LABELS: Record<string, string> = {
awaiting_review: "等待审核",
merging: "合并实体关系",
writing_wiki: "写入 Wiki",
writing_sources: "上传知识资料",
generating_wiki: "分析并生成分类 Wiki",
vectorizing_wiki: "Wiki 向量化",
syncing_assistant: "同步知识梳理总库",
activating: "激活新版本",
completed: "完成",
};
@@ -35,7 +39,7 @@ export function SkillKnowledgePanel() {
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [skillQuery, setSkillQuery] = useState("");
const [selectedTargetKey, setSelectedTargetKey] = useState<string | null>(null);
const [reviewMode, setReviewMode] = useState<"auto_high_confidence" | "off">("auto_high_confidence");
const [reviewMode, setReviewMode] = useState<"auto_high_confidence" | "off">("off");
const runtime = useLlmWikiRuntime();
const knowledgeBases = useLlmWikiKnowledgeBases("all", runtime.data?.provider === "weknora");
const bindings = useSkillKnowledgeBindings();
+354 -372
View File
@@ -1,443 +1,425 @@
import { BookOpen, Database, Download, Loader2, Plus, RefreshCw, Search, ShieldCheck, Trash2, Upload } from "lucide-react";
import {
ArrowLeft,
BookOpenText,
Database,
Loader2,
Plus,
RefreshCw,
Search,
Upload,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { KnowledgeProductNav } from "@/components/workspace/knowledge-product-nav";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { getStoredAuth } from "@/core/auth";
import { buildAppHashUrl } from "@/core/llmwiki/source-links";
import {
type AssistantKnowledgeBase,
type AssistantWikiPage,
searchAssistantWiki,
useAssistantImportJobs,
useAssistantKnowledgeActions,
useAssistantKnowledgeBases,
useAssistantKnowledgeSources,
useAssistantWikiPages,
} from "@/strategy-components/api/assistant-knowledge";
function baseSubtitle(base: AssistantKnowledgeBase) {
if (base.is_global) return "全库沉淀总库 · 所有 WeKnora 导入数据都会同步进入这里并做实体对齐";
return base.description || "自建助手知识库 · 可上传普通知识库导出的 Wiki 向量包";
}
function EmptyInitializeCard({
isAdmin,
onInitialize,
pending,
}: {
isAdmin: boolean;
onInitialize: () => void;
pending: boolean;
}) {
return (
<div className="flex flex-1 items-center justify-center p-8">
<div className="max-w-lg rounded-xl border bg-card p-8 text-center">
<Database className="mx-auto mb-4 size-10 text-primary" />
<h1 className="text-xl font-semibold">初始化助手知识库</h1>
<p className="mt-2 text-sm text-muted-foreground">
未配置 WeKnora 时会默认展示助手知识库。首次使用需要创建“知识梳理总库”,后续从 WeKnora 导入的数据都会沉淀到这里。
</p>
{isAdmin ? (
<Button className="mt-5" disabled={pending} onClick={onInitialize}>
{pending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Database className="mr-2 size-4" />}
创建知识梳理总库
</Button>
) : (
<p className="mt-5 text-sm">请联系管理员初始化。</p>
)}
</div>
</div>
);
}
import {
WikiLibraryReader,
type LibraryWikiPage,
} from "@/strategy-components/components/resource-management/llmwiki/WikiLibraryReader";
import { WikiPackageTransfers } from "@/strategy-components/components/resource-management/llmwiki/WikiPackageTransfers";
export default function AssistantKnowledgePage() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const [query, setQuery] = useState("");
const [selectedBaseId, setSelectedBaseId] = useState<string | null>(null);
const [selected, setSelected] = useState<AssistantWikiPage | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [createDraft, setCreateDraft] = useState({ name: "", description: "" });
const packageInputRef = useRef<HTMLInputElement | null>(null);
const [params, setParams] = useSearchParams();
const baseId = params.get("base_id");
const slug = params.get("wikiSlug");
const bases = useAssistantKnowledgeBases();
const actions = useAssistantKnowledgeActions();
useEffect(() => {
const values = bases.data ?? [];
if (!values.length) {
setSelectedBaseId(null);
return;
}
if (selectedBaseId && values.some((base) => base.id === selectedBaseId)) return;
setSelectedBaseId((values.find((base) => base.is_global) ?? values[0]).id);
}, [bases.data, selectedBaseId]);
useEffect(() => {
setSelected(null);
}, [selectedBaseId]);
const selectedBase = useMemo(
() => (bases.data ?? []).find((base) => base.id === selectedBaseId) ?? null,
[bases.data, selectedBaseId],
const pages = useAssistantWikiPages("", baseId);
const sources = useAssistantKnowledgeSources(baseId);
const jobs = useAssistantImportJobs(baseId);
const client = useQueryClient();
const [tab, setTab] = useState("wiki");
const [createOpen, setCreateOpen] = useState(false);
const [name, setName] = useState("");
const [query, setQuery] = useState("");
const [searching, setSearching] = useState(false);
const [searchResult, setSearchResult] = useState<Awaited<
ReturnType<typeof searchAssistantWiki>
> | null>(null);
const upload = useRef<HTMLInputElement>(null);
const base = bases.data?.find((b) => b.id === baseId);
const libraryPages = useMemo<LibraryWikiPage[]>(
() =>
(pages.data || []).map((p) => ({
...p,
content: p.content_markdown,
status: "published",
aliases: p.aliases || [],
source_refs: p.source_refs || [],
in_links: p.in_links || [],
out_links: p.out_links || [],
version: 1,
})),
[pages.data],
);
const pages = useAssistantWikiPages(query, selectedBaseId);
const sources = useAssistantKnowledgeSources(selectedBaseId);
const jobs = useAssistantImportJobs(selectedBaseId);
const activePage = selected ?? pages.data?.[0] ?? null;
const counts = useMemo(
() => ({
pages: pages.data?.length ?? selectedBase?.page_count ?? 0,
sources: sources.data?.length ?? selectedBase?.source_count ?? 0,
jobs: jobs.data?.length ?? 0,
}),
[jobs.data, pages.data, selectedBase?.page_count, selectedBase?.source_count, sources.data],
);
const initializeGlobal = () => {
actions.initialize.mutate("知识梳理总库", {
onSuccess: (base) => {
setSelectedBaseId(base.id);
toast.success("知识梳理总库已创建");
},
onError: (error) => toast.error(error.message),
const active =
libraryPages.find((p) => p.slug === slug) ||
libraryPages.find((p) => p.page_type === "index") ||
libraryPages[0] ||
null;
const navigatePage = (nextSlug: string) => {
setParams({ base_id: baseId!, wikiSlug: nextSlug });
setTab("wiki");
};
const finishedJobs = (jobs.data || [])
.filter((j) => j.status === "completed")
.map((j) => j.id)
.join(",");
useEffect(() => {
void client.invalidateQueries({
queryKey: ["assistant-knowledge", "pages"],
});
};
const createBase = async () => {
const name = createDraft.name.trim();
if (!name) {
toast.error("请输入助手知识库名称");
return;
}
void client.invalidateQueries({
queryKey: ["assistant-knowledge", "bases"],
});
}, [finishedJobs, client]);
useEffect(() => {
setSearchResult(null);
setQuery("");
}, [baseId]);
const create = async () => {
if (!name.trim()) return;
try {
const base = await actions.createBase.mutateAsync({
name,
description: createDraft.description.trim(),
});
setSelectedBaseId(base.id);
setCreateDraft({ name: "", description: "" });
const b = await actions.createBase.mutateAsync({ name: name.trim() });
setCreateOpen(false);
toast.success("助手知识库已创建");
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
setName("");
setParams({ base_id: b.id });
} catch (e) {
toast.error((e as Error).message);
}
};
const uploadPackage = async (file: File | null | undefined) => {
if (!file) return;
const search = async () => {
if (!query.trim() || !baseId) return;
setSearching(true);
try {
await actions.uploadWeKnoraPackage.mutateAsync({
file,
assistantBaseId: selectedBaseId,
});
toast.success("导出包已上传,后台正在异步导入");
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
setSearchResult(await searchAssistantWiki(query, baseId));
} catch (e) {
toast.error((e as Error).message);
} finally {
if (packageInputRef.current) packageInputRef.current.value = "";
setSearching(false);
}
};
if (bases.isLoading) {
return (
<div className="flex size-full flex-col">
<KnowledgeProductNav />
<div className="flex flex-1 items-center justify-center">
<Loader2 className="animate-spin" />
</div>
</div>
);
}
if (!bases.data?.length) {
return (
<div className="flex size-full flex-col">
<KnowledgeProductNav />
<EmptyInitializeCard
isAdmin={isAdmin}
pending={actions.initialize.isPending}
onInitialize={initializeGlobal}
/>
</div>
);
}
const refresh = () =>
void client.invalidateQueries({ queryKey: ["assistant-knowledge"] });
return (
<div className="flex size-full min-h-0 flex-col bg-background">
<KnowledgeProductNav />
<div className="grid min-h-0 flex-1 grid-cols-[280px_1fr]">
<aside className="flex min-h-0 flex-col border-r bg-muted/20">
<div className="border-b p-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold">助手知识库</h2>
<p className="mt-1 text-xs text-muted-foreground">总库置顶,自建库可上传 WeKnora 导出包</p>
</div>
{!baseId ? (
<main className="flex-1 overflow-auto p-6 lg:p-8">
<header className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">助手知识库</h1>
<p className="mt-2 text-sm text-muted-foreground">
浏览分类
Wiki、关联知识与来源。普通知识库向量化完成后自动沉淀到知识梳理。
</p>
</div>
{isAdmin && (
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 size-4" />
新建知识库
</Button>
)}
</header>
{bases.isLoading && <Loader2 className="animate-spin" />}
{bases.error && (
<p className="text-destructive">{bases.error.message}</p>
)}
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{bases.data?.map((b) => (
<button
key={b.id}
onClick={() => setParams({ base_id: b.id })}
className="group rounded-2xl border bg-card p-6 text-left shadow-sm transition hover:border-primary/40 hover:shadow-md"
>
<div className="mb-5 flex items-center justify-between">
<div className="rounded-xl bg-emerald-100 p-3 text-emerald-700 dark:bg-emerald-950">
<Database className="size-6" />
</div>
{b.is_global && (
<Badge variant="secondary">全库知识梳理</Badge>
)}
</div>
<h2 className="text-lg font-semibold group-hover:text-primary">
{b.name}
</h2>
<p className="mt-2 line-clamp-2 min-h-10 text-sm text-muted-foreground">
{b.is_global
? "汇聚普通知识库的分析成果,保留分类、标签和知识关联。"
: b.description ||
"独立的 Wiki 知识空间,支持导入、导出与向量检索。"}
</p>
<div className="mt-6 flex gap-5 text-xs text-muted-foreground">
<span>{b.page_count || 0} Wiki</span>
<span>{b.source_count || 0} 来源</span>
<span>{b.relation_count || 0} 关系</span>
</div>
</button>
))}
</div>
{!bases.isLoading && !bases.data?.length && isAdmin && (
<Button
className="mt-5"
onClick={() =>
actions.initialize.mutate("知识梳理总库", {
onError: (e) => toast.error(e.message),
})
}
>
初始化知识梳理总库
</Button>
)}
</main>
) : (
<>
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b px-5 py-4">
<Button
variant="ghost"
size="icon"
aria-label="返回助手知识库"
onClick={() => setParams({})}
>
<ArrowLeft className="size-4" />
</Button>
<div className="rounded-xl bg-emerald-100 p-2.5 text-emerald-700 dark:bg-emerald-950">
<BookOpenText className="size-5" />
</div>
<div className="min-w-0">
<h1 className="text-lg font-semibold">
{base?.name || "助手知识库"}
</h1>
<p className="mt-1 text-xs text-muted-foreground">
{base?.is_global
? "全库知识梳理 · 自动同步分析成果"
: "Wiki 知识空间"}
</p>
</div>
<div className="ml-auto flex gap-2">
<Button variant="outline" size="sm" onClick={refresh}>
<RefreshCw className="mr-2 size-4" />
刷新
</Button>
{isAdmin && (
<Button size="icon" variant="outline" onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
<Button
variant="outline"
size="sm"
disabled={actions.uploadWeKnoraPackage.isPending}
onClick={() => upload.current?.click()}
>
<Upload className="mr-2 size-4" />
导入数据包
</Button>
)}
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-2 p-3">
{(bases.data ?? []).map((base) => (
<button
key={base.id}
type="button"
onClick={() => setSelectedBaseId(base.id)}
className={`w-full rounded-lg border px-3 py-3 text-left transition ${
selectedBaseId === base.id ? "border-primary bg-primary/10 text-primary" : "bg-card hover:bg-muted"
}`}
>
<div className="flex items-center gap-2">
<Database className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate text-sm font-medium">{base.name}</span>
{base.is_global && <Badge variant="outline" className="text-[10px]">总库</Badge>}
</div>
<div className="mt-2 flex gap-3 text-[11px] text-muted-foreground">
<span>{base.page_count ?? 0} 页</span>
<span>{base.source_count ?? 0} 来源</span>
<span>{base.entity_count ?? 0} 实体</span>
</div>
</button>
))}
<input
ref={upload}
type="file"
accept=".jsonl,.ndjson"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
try {
await actions.uploadWeKnoraPackage.mutateAsync({
file,
assistantBaseId: baseId,
});
toast.success("文件已上传,后台正在导入");
} catch (err) {
toast.error((err as Error).message);
}
}}
/>
</header>
<Tabs
value={tab}
onValueChange={setTab}
className="flex min-h-0 flex-1 flex-col gap-0"
>
<div className="shrink-0 border-b px-5 py-2">
<TabsList>
<TabsTrigger value="wiki">
Wiki({base?.page_count || 0})
</TabsTrigger>
<TabsTrigger value="search">向量检索</TabsTrigger>
<TabsTrigger value="sources">来源与同步</TabsTrigger>
{isAdmin && (
<TabsTrigger value="transfer">导入 / 导出记录</TabsTrigger>
)}
</TabsList>
</div>
</ScrollArea>
</aside>
<main className="flex min-h-0 flex-col">
<header className="flex items-center gap-4 border-b px-6 py-4">
<div className="min-w-0">
<h1 className="truncate text-xl font-semibold">{selectedBase?.name}</h1>
<p className="mt-1 truncate text-xs text-muted-foreground">
{selectedBase ? baseSubtitle(selectedBase) : "请选择助手知识库"}
</p>
</div>
<Badge variant="outline">
<ShieldCheck className="mr-1 size-3" />
实体对齐
</Badge>
{isAdmin && (
<div className="ml-auto flex items-center gap-2">
<input
ref={packageInputRef}
type="file"
accept=".jsonl,.ndjson,application/x-ndjson,application/json"
className="hidden"
onChange={(event) => void uploadPackage(event.target.files?.[0])}
<TabsContent value="wiki" className="mt-0 flex min-h-0 flex-1">
<WikiLibraryReader
pages={libraryPages}
activePage={active}
onNavigate={navigatePage}
pageHref={(s) =>
buildAppHashUrl(
`/page/workspace/knowledge/assistant?${new URLSearchParams({ base_id: baseId, wikiSlug: s })}`,
)
}
mappingId={
active?.origins?.find((o) => o.mapping_id)?.mapping_id
}
/>
</TabsContent>
<TabsContent value="search" className="overflow-auto p-6">
<div className="mb-5 flex max-w-3xl gap-2">
<Input
placeholder="用自然语言检索知识,例如:如何处理设备过热?"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void search();
}}
/>
<Button
variant="outline"
disabled={actions.uploadWeKnoraPackage.isPending || !selectedBaseId}
onClick={() => packageInputRef.current?.click()}
>
{actions.uploadWeKnoraPackage.isPending ? (
<Loader2 className="mr-2 size-4 animate-spin" />
<Button disabled={searching} onClick={() => void search()}>
{searching ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Upload className="mr-2 size-4" />
<Search className="size-4" />
)}
上传 WeKnora 导出包
检索
</Button>
</div>
)}
</header>
<Tabs defaultValue="wiki" className="flex min-h-0 flex-1 flex-col">
<div className="flex items-center gap-4 border-b px-6">
<TabsList className="my-2">
<TabsTrigger value="wiki">Wiki({counts.pages})</TabsTrigger>
<TabsTrigger value="sources">来源({counts.sources})</TabsTrigger>
<TabsTrigger value="jobs">导入记录({counts.jobs})</TabsTrigger>
</TabsList>
<div className="relative ml-auto w-72">
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索标题、实体别名和正文"
className="pl-8"
/>
</div>
</div>
<TabsContent value="wiki" className="mt-0 grid min-h-0 flex-1 grid-cols-[300px_1fr]">
<ScrollArea className="border-r">
<div className="space-y-1 p-3">
{pages.isLoading && <Loader2 className="m-4 size-5 animate-spin" />}
{pages.data?.map((page) => (
<button
key={page.id}
type="button"
onClick={() => setSelected(page)}
className={`w-full rounded-md px-3 py-2 text-left ${
activePage?.id === page.id ? "bg-primary/10 text-primary" : "hover:bg-muted"
}`}
>
<div className="truncate text-sm font-medium">{page.title}</div>
<div className="mt-1 flex items-center gap-2 text-[11px] text-muted-foreground">
<span>{page.page_type}</span>
<span>{new Date(page.updated_at).toLocaleString()}</span>
</div>
</button>
))}
{!pages.isLoading && !pages.data?.length && (
<div className="p-4 text-xs text-muted-foreground">
暂无 Wiki 页面。请先到普通知识库详情页下载向量化数据包,再在当前助手知识库上传。
</div>
)}
</div>
</ScrollArea>
<ScrollArea>
<article className="mx-auto max-w-5xl p-8">
{activePage ? (
<>
<h1 className="mb-1 flex items-center gap-2 text-2xl font-semibold">
<BookOpen className="size-5 text-primary" />
{activePage.title}
</h1>
<p className="mb-6 text-xs text-muted-foreground">{activePage.slug}</p>
<MarkdownContent content={activePage.content_markdown} isLoading={false} rehypePlugins={[]} />
</>
) : (
<p className="text-sm text-muted-foreground">暂无 Wiki 页面。</p>
)}
</article>
</ScrollArea>
{searchResult && (
<>
<Badge variant="outline">
{searchResult.retrieval_mode === "vector"
? "向量检索 · 余弦相似度"
: searchResult.retrieval_mode === "empty"
? "没有匹配结果"
: "关键词检索(暂无匹配模型的向量)"}
</Badge>
<div className="mt-4 space-y-3">
{searchResult.results.map((p) => (
<button
key={p.id}
onClick={() => navigatePage(p.slug)}
className="block w-full max-w-4xl rounded-xl border p-5 text-left hover:bg-muted"
>
<div className="font-medium">
{p.title}
<span className="float-right text-xs text-muted-foreground">
{p.score?.toFixed(4)}
</span>
</div>
<p className="mt-2 line-clamp-3 text-sm text-muted-foreground">
{p.summary || p.content_markdown}
</p>
</button>
))}
</div>
</>
)}
</TabsContent>
<TabsContent value="sources" className="mt-0 min-h-0 flex-1 overflow-auto p-6">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{sources.data?.map((source) => (
<div key={source.id} className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2">
<Database className="size-4" />
<span className="truncate font-medium">{source.source_name}</span>
<Badge variant="outline" className="ml-auto">{source.source_type}</Badge>
</div>
<p className="mt-3 text-xs text-muted-foreground">业务键:{source.source_key}</p>
<TabsContent value="sources" className="overflow-auto p-6">
<div className="grid gap-4 lg:grid-cols-2">
{sources.data?.map((s) => (
<div className="rounded-xl border p-5" key={s.id}>
<h3 className="font-semibold">{s.source_name}</h3>
<p className="mt-2 text-xs text-muted-foreground">
上次同步:{new Date(s.updated_at).toLocaleString()}
</p>
{isAdmin && (
<div className="mt-4 flex justify-end gap-2">
<Button
size="sm"
variant="outline"
disabled={source.source_type !== "weknora" || actions.reimportSource.isPending}
onClick={() =>
actions.reimportSource.mutate(source.id, {
onSuccess: () => toast.success("已发起重新导入"),
onError: (error) => toast.error(error.message),
})
}
>
<RefreshCw className="mr-2 size-4" />
重新导入
</Button>
<Button
size="sm"
variant="destructive"
disabled={actions.deleteSource.isPending}
onClick={() => {
if (window.confirm(`确认删除来源“${source.source_name}”的贡献?仍被其他来源引用的知识会保留。`)) {
actions.deleteSource.mutate(source.id, {
onSuccess: () => toast.success("来源贡献已删除"),
onError: (error) => toast.error(error.message),
});
}
}}
>
<Trash2 className="mr-2 size-4" />
删除来源
</Button>
</div>
<Button
className="mt-4"
size="sm"
variant="outline"
disabled={actions.reimportSource.isPending}
onClick={() =>
actions.reimportSource.mutate(s.id, {
onSuccess: () => toast.success("已提交同步任务"),
onError: (e) => toast.error(e.message),
})
}
>
<RefreshCw className="mr-2 size-4" />
同步最新数据
</Button>
)}
</div>
))}
{!sources.data?.length && <p className="text-sm text-muted-foreground">暂无来源。</p>}
</div>
</TabsContent>
<TabsContent value="jobs" className="mt-0 min-h-0 flex-1 overflow-auto p-6">
<div className="space-y-3">
{jobs.data?.map((job) => (
<div key={job.id} className="rounded-lg border bg-card p-4">
{isAdmin && (
<TabsContent
value="transfer"
className="space-y-5 overflow-auto p-6"
>
<WikiPackageTransfers id={baseId} assistant />
<h2 className="text-base font-semibold">导入与同步记录</h2>
{jobs.data?.map((j) => (
<div key={j.id} className="rounded-xl border p-4">
<div className="flex items-center gap-3">
<Badge>{job.status}</Badge>
<span className="text-sm">{job.trigger}</span>
<span className="text-xs text-muted-foreground">阶段:{job.phase}</span>
<span className="ml-auto text-xs">{new Date(job.created_at).toLocaleString()}</span>
{isAdmin && job.package_download_url && (
<Button asChild size="sm" variant="outline">
<a href={job.package_download_url} target="_blank" rel="noreferrer">
<Download className="mr-2 size-4" />
下载导出包
</a>
</Button>
)}
<Badge variant="outline">
{{
completed: "已完成",
failed: "失败",
running: "处理中",
queued: "排队中",
}[j.status] || j.status}
</Badge>
<span className="text-xs text-muted-foreground">
{new Date(j.created_at).toLocaleString()}
</span>
</div>
<div className="mt-2 text-xs text-muted-foreground">
{Object.entries(job.counts || {}).map(([key, value]) => `${key} ${value}`).join(" · ") || "暂无统计"}
</div>
{job.last_error_message && (
<div className="mt-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
{job.last_error_message}
</div>
<p className="mt-3 text-sm">
{j.counts?.wiki_pages || 0} Wiki ·{" "}
{j.counts?.vectors || 0} 向量 · {j.counts?.entities || 0}{" "}
实体 · {j.counts?.relations || 0} 关系
</p>
{j.last_error_message && (
<p className="mt-2 text-sm text-destructive">
{j.last_error_message}
</p>
)}
</div>
))}
{!jobs.data?.length && <p className="text-sm text-muted-foreground">暂无导入记录。</p>}
</div>
</TabsContent>
</TabsContent>
)}
</Tabs>
</main>
</div>
</>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>新建助手知识库</DialogTitle>
<DialogDescription>
自建助手知识库用于承接从 WeKnora 普通知识库下载的 Wiki 向量包;导入时同一份数据也会沉淀到“知识梳理总库”。
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-medium">名称</label>
<Input
value={createDraft.name}
onChange={(event) => setCreateDraft((prev) => ({ ...prev, name: event.target.value }))}
placeholder="例如:政策资料库"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">描述</label>
<Textarea
value={createDraft.description}
onChange={(event) => setCreateDraft((prev) => ({ ...prev, description: event.target.value }))}
placeholder="可选,用于说明这个助手知识库的范围"
rows={3}
/>
</div>
</div>
<Input
placeholder="知识库名称"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
取消
</Button>
<Button disabled={actions.createBase.isPending} onClick={() => void createBase()}>
{actions.createBase.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Plus className="mr-2 size-4" />}
<Button
disabled={!name.trim() || actions.createBase.isPending}
onClick={() => void create()}
>
创建
</Button>
</DialogFooter>
@@ -30,6 +30,15 @@ export interface AssistantWikiPage {
summary: string;
current_revision_id?: string | null;
updated_at: string;
aliases?: string[];
tags?: string[];
category_path?: string[];
parent_slug?: string;
wiki_path?: string;
in_links?: string[];
out_links?: string[];
source_refs?: string[];
origins?: { mapping_id?: string; source_name: string; slug: string }[];
}
export interface AssistantWikiRevision {
@@ -88,10 +97,13 @@ export interface LlmWikiExportPackageJob {
async function readJson<T>(response: Response): Promise<T> {
if (!response.ok) {
const data = await response.json().catch(() => null);
const detail = data && typeof data === "object" && "detail" in data
? (data as { detail: unknown }).detail
: `请求失败 (${response.status})`;
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
const detail =
data && typeof data === "object" && "detail" in data
? (data as { detail: unknown }).detail
: `请求失败 (${response.status})`;
throw new Error(
typeof detail === "string" ? detail : JSON.stringify(detail),
);
}
return (await response.json()) as T;
}
@@ -101,12 +113,16 @@ function normalizeImportJob(job: AssistantImportJob): AssistantImportJob {
return url ? { ...job, package_download_url: url } : job;
}
function normalizeBackendUrl(url: string | undefined | null): string | undefined {
function normalizeBackendUrl(
url: string | undefined | null,
): string | undefined {
if (!url || /^https?:\/\//i.test(url)) return url || undefined;
return `${getBackendBaseURL()}${url.startsWith("/") ? url : `/${url}`}`;
}
function normalizeExportPackageJob(job: LlmWikiExportPackageJob): LlmWikiExportPackageJob {
function normalizeExportPackageJob(
job: LlmWikiExportPackageJob,
): LlmWikiExportPackageJob {
return {
...job,
package_download_url: normalizeBackendUrl(job.package_download_url),
@@ -115,7 +131,9 @@ function normalizeExportPackageJob(job: LlmWikiExportPackageJob): LlmWikiExportP
}
export async function getAssistantKnowledgeBase() {
const data = await readJson<{ base: AssistantKnowledgeBase | null }>(await apiFetch(`${BASE()}/base`));
const data = await readJson<{ base: AssistantKnowledgeBase | null }>(
await apiFetch(`${BASE()}/base`),
);
return data.base;
}
@@ -127,7 +145,11 @@ export async function getAssistantKnowledgeBaseById(baseId: string) {
}
export async function listAssistantKnowledgeBases() {
return (await readJson<{ bases: AssistantKnowledgeBase[] }>(await apiFetch(`${BASE()}/bases`))).bases;
return (
await readJson<{ bases: AssistantKnowledgeBase[] }>(
await apiFetch(`${BASE()}/bases`),
)
).bases;
}
export async function initializeAssistantKnowledgeBase(name = "知识梳理总库") {
@@ -141,7 +163,10 @@ export async function initializeAssistantKnowledgeBase(name = "知识梳理总
return data.base;
}
export async function createAssistantKnowledgeBase(input: { name: string; description?: string }) {
export async function createAssistantKnowledgeBase(input: {
name: string;
description?: string;
}) {
const data = await readJson<{ base: AssistantKnowledgeBase }>(
await apiFetch(`${BASE()}/bases`, {
method: "POST",
@@ -160,30 +185,96 @@ function withBaseParam(baseId?: string | null) {
export async function listAssistantWikiPages(q = "", baseId?: string | null) {
const params = withBaseParam(baseId);
params.set("limit", "500");
if (q.trim()) params.set("q", q.trim());
return (await readJson<{ pages: AssistantWikiPage[] }>(await apiFetch(`${BASE()}/wiki-pages?${params}`))).pages;
const pages: AssistantWikiPage[] = [];
let offset: number | null = 0;
do {
params.set("offset", String(offset));
const batch: { pages: AssistantWikiPage[]; next_offset?: number | null } =
await readJson(await apiFetch(`${BASE()}/wiki-pages?${params}`));
pages.push(...batch.pages);
offset = batch.next_offset ?? null;
} while (offset !== null);
return pages;
}
export async function searchAssistantWiki(q: string, baseId: string) {
return readJson<{
results: (AssistantWikiPage & { score: number; retrieval_mode: string })[];
retrieval_mode: string;
}>(
await apiFetch(
`${BASE()}/search?${new URLSearchParams({ q, base_id: baseId })}`,
),
);
}
export async function listWikiTransfers(id: string, assistant: boolean) {
const url = assistant
? `${BASE()}/bases/${encodeURIComponent(id)}/export-package`
: `${getBackendBaseURL()}/api/llmwiki/knowledge-bases/${encodeURIComponent(id)}/import-package`;
return (
await readJson<{ jobs: LlmWikiExportPackageJob[] }>(await apiFetch(url))
).jobs.map(normalizeExportPackageJob);
}
export async function createAssistantExportPackage(id: string) {
return readJson<LlmWikiExportPackageJob>(
await apiFetch(`${BASE()}/bases/${encodeURIComponent(id)}/export-package`, {
method: "POST",
}),
);
}
export async function importOrdinaryWikiPackage(id: string, file: File) {
const body = new FormData();
body.set("file", file);
return readJson<LlmWikiExportPackageJob>(
await apiFetch(
`${getBackendBaseURL()}/api/llmwiki/knowledge-bases/${encodeURIComponent(id)}/import-package`,
{ method: "POST", body },
),
);
}
export async function listAssistantKnowledgeSources(baseId?: string | null) {
const params = withBaseParam(baseId);
return (await readJson<{ sources: AssistantKnowledgeSource[] }>(await apiFetch(`${BASE()}/sources?${params}`))).sources;
return (
await readJson<{ sources: AssistantKnowledgeSource[] }>(
await apiFetch(`${BASE()}/sources?${params}`),
)
).sources;
}
export async function listAssistantImportJobs(baseId?: string | null) {
const params = withBaseParam(baseId);
return (
await readJson<{ jobs: AssistantImportJob[] }>(await apiFetch(`${BASE()}/import-jobs?${params}`))
await readJson<{ jobs: AssistantImportJob[] }>(
await apiFetch(`${BASE()}/import-jobs?${params}`),
)
).jobs.map(normalizeImportJob);
}
export async function listAssistantWikiRevisions(slug: string, baseId?: string | null) {
export async function listAssistantWikiRevisions(
slug: string,
baseId?: string | null,
) {
const params = withBaseParam(baseId);
return (await readJson<{ revisions: AssistantWikiRevision[] }>(
await apiFetch(`${BASE()}/wiki-pages/${encodeURIComponent(slug)}/revisions?${params}`),
)).revisions;
return (
await readJson<{ revisions: AssistantWikiRevision[] }>(
await apiFetch(
`${BASE()}/wiki-pages/${encodeURIComponent(slug)}/revisions?${params}`,
),
)
).revisions;
}
export async function rollbackAssistantWikiRevision(slug: string, revisionId: string, baseId?: string | null) {
export async function rollbackAssistantWikiRevision(
slug: string,
revisionId: string,
baseId?: string | null,
) {
const params = withBaseParam(baseId);
return readJson<AssistantWikiRevision>(
await apiFetch(
@@ -193,17 +284,22 @@ export async function rollbackAssistantWikiRevision(slug: string, revisionId: st
);
}
export async function exportLlmWikiToAssistant(mappingId: string, assistantBaseId?: string | null) {
return normalizeImportJob(await readJson<AssistantImportJob>(
await apiFetch(
`${getBackendBaseURL()}/api/llmwiki/knowledge-bases/${encodeURIComponent(mappingId)}/export-to-assistant`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assistant_base_id: assistantBaseId || null }),
},
export async function exportLlmWikiToAssistant(
mappingId: string,
assistantBaseId?: string | null,
) {
return normalizeImportJob(
await readJson<AssistantImportJob>(
await apiFetch(
`${getBackendBaseURL()}/api/llmwiki/knowledge-bases/${encodeURIComponent(mappingId)}/export-to-assistant`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assistant_base_id: assistantBaseId || null }),
},
),
),
));
);
}
export async function createLlmWikiExportPackage(mappingId: string) {
@@ -227,7 +323,10 @@ export async function listLlmWikiExportPackages(mappingId: string) {
).jobs.map(normalizeExportPackageJob);
}
export async function getLlmWikiExportPackageStatus(mappingId: string, jobId: string) {
export async function getLlmWikiExportPackageStatus(
mappingId: string,
jobId: string,
) {
return normalizeExportPackageJob(
await readJson<LlmWikiExportPackageJob>(
await apiFetch(
@@ -238,33 +337,47 @@ export async function getLlmWikiExportPackageStatus(mappingId: string, jobId: st
}
export async function reimportAssistantKnowledgeSource(sourceId: string) {
return normalizeImportJob(await readJson<AssistantImportJob>(
await apiFetch(`${BASE()}/sources/${encodeURIComponent(sourceId)}/reimport`, {
method: "POST",
}),
));
return normalizeImportJob(
await readJson<AssistantImportJob>(
await apiFetch(
`${BASE()}/sources/${encodeURIComponent(sourceId)}/reimport`,
{
method: "POST",
},
),
),
);
}
export function getAssistantImportPackageUrl(jobId: string) {
return `${BASE()}/import-jobs/${encodeURIComponent(jobId)}/package`;
}
export async function uploadAssistantWeKnoraPackage(input: { file: File; assistantBaseId?: string | null }) {
export async function uploadAssistantWeKnoraPackage(input: {
file: File;
assistantBaseId?: string | null;
}) {
const body = new FormData();
body.set("file", input.file);
if (input.assistantBaseId) body.set("assistant_base_id", input.assistantBaseId);
return normalizeImportJob(await readJson<AssistantImportJob>(
await apiFetch(`${BASE()}/imports/weknora-package`, {
method: "POST",
body,
}),
));
if (input.assistantBaseId)
body.set("assistant_base_id", input.assistantBaseId);
return normalizeImportJob(
await readJson<AssistantImportJob>(
await apiFetch(`${BASE()}/imports/weknora-package`, {
method: "POST",
body,
}),
),
);
}
export async function deleteAssistantKnowledgeSource(sourceId: string) {
const response = await apiFetch(`${BASE()}/sources/${encodeURIComponent(sourceId)}`, {
method: "DELETE",
});
const response = await apiFetch(
`${BASE()}/sources/${encodeURIComponent(sourceId)}`,
{
method: "DELETE",
},
);
if (!response.ok) await readJson(response);
}
@@ -273,12 +386,19 @@ const KEY = "assistant-knowledge";
export function useAssistantKnowledgeBase(baseId?: string | null) {
return useQuery({
queryKey: [KEY, "base", baseId ?? "global"],
queryFn: () => (baseId ? getAssistantKnowledgeBaseById(baseId) : getAssistantKnowledgeBase()),
queryFn: () =>
baseId
? getAssistantKnowledgeBaseById(baseId)
: getAssistantKnowledgeBase(),
});
}
export function useAssistantKnowledgeBases() {
return useQuery({ queryKey: [KEY, "bases"], queryFn: listAssistantKnowledgeBases });
export function useAssistantKnowledgeBases(enabled = true) {
return useQuery({
queryKey: [KEY, "bases"],
queryFn: listAssistantKnowledgeBases,
enabled,
});
}
export function useAssistantWikiPages(query = "", baseId?: string | null) {
@@ -288,10 +408,11 @@ export function useAssistantWikiPages(query = "", baseId?: string | null) {
});
}
export function useAssistantKnowledgeSources(baseId?: string | null) {
export function useAssistantKnowledgeSources(baseId?: string | null, enabled = true) {
return useQuery({
queryKey: [KEY, "sources", baseId ?? "global"],
queryFn: () => listAssistantKnowledgeSources(baseId),
enabled,
});
}
@@ -300,7 +421,9 @@ export function useAssistantImportJobs(baseId?: string | null) {
queryKey: [KEY, "jobs", baseId ?? "global"],
queryFn: () => listAssistantImportJobs(baseId),
refetchInterval: (query) =>
(query.state.data ?? []).some((job) => ["queued", "running"].includes(job.status))
(query.state.data ?? []).some((job) =>
["queued", "running"].includes(job.status),
)
? 3000
: false,
});
@@ -310,14 +433,36 @@ export function useAssistantKnowledgeActions() {
const client = useQueryClient();
const invalidate = () => client.invalidateQueries({ queryKey: [KEY] });
return {
initialize: useMutation({ mutationFn: initializeAssistantKnowledgeBase, onSuccess: invalidate }),
createBase: useMutation({ mutationFn: createAssistantKnowledgeBase, onSuccess: invalidate }),
uploadWeKnoraPackage: useMutation({ mutationFn: uploadAssistantWeKnoraPackage, onSuccess: invalidate }),
reimportSource: useMutation({ mutationFn: reimportAssistantKnowledgeSource, onSuccess: invalidate }),
deleteSource: useMutation({ mutationFn: deleteAssistantKnowledgeSource, onSuccess: invalidate }),
initialize: useMutation({
mutationFn: initializeAssistantKnowledgeBase,
onSuccess: invalidate,
}),
createBase: useMutation({
mutationFn: createAssistantKnowledgeBase,
onSuccess: invalidate,
}),
uploadWeKnoraPackage: useMutation({
mutationFn: uploadAssistantWeKnoraPackage,
onSuccess: invalidate,
}),
reimportSource: useMutation({
mutationFn: reimportAssistantKnowledgeSource,
onSuccess: invalidate,
}),
deleteSource: useMutation({
mutationFn: deleteAssistantKnowledgeSource,
onSuccess: invalidate,
}),
rollback: useMutation({
mutationFn: ({ slug, revisionId, baseId }: { slug: string; revisionId: string; baseId?: string | null }) =>
rollbackAssistantWikiRevision(slug, revisionId, baseId),
mutationFn: ({
slug,
revisionId,
baseId,
}: {
slug: string;
revisionId: string;
baseId?: string | null;
}) => rollbackAssistantWikiRevision(slug, revisionId, baseId),
onSuccess: invalidate,
}),
};
@@ -167,6 +167,7 @@ export interface LlmWikiGraph {
}
export interface LlmWikiWikiPage {
page_metadata?: { tags?: string[]; [key: string]: unknown };
id: string;
slug: string;
title: string;
@@ -9,6 +9,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownCodeEditor } from "@/components/wiki/MarkdownCodeEditor";
import { WikiPackageTransfers } from "./WikiPackageTransfers";
import { getBackendBaseURL } from "@/core/config";
import { getStoredAuth } from "@/core/auth";
import { buildWeKnoraWikiPagePath } from "@/core/llmwiki/source-links";
@@ -323,6 +324,7 @@ export function WeKnoraKnowledgeDetailPage() {
return (
<div className="flex h-full min-h-0 flex-col bg-background">
{canManageWiki && isAdmin && <details className="shrink-0 border-b px-4 py-2"><summary className="cursor-pointer text-sm">从助手知识库导入数据</summary><div className="py-3"><WikiPackageTransfers id={knowledgeBaseId} /></div></details>}
{canManageWiki && (
<div className="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-2 border-b px-4 py-2">
<div className="min-w-0 flex-1">
@@ -3,6 +3,7 @@ import {
BookOpenText,
ChevronRight,
Database,
Edit3,
FileText,
Globe2,
Loader2,
@@ -109,6 +110,7 @@ export function WeKnoraKnowledgeWorkspace() {
const knowledge = useLlmWikiKnowledgeBases(scope, tab !== "settings");
const actions = useLlmWikiActions();
const [createOpen, setCreateOpen] = useState(false);
const [editingKnowledge, setEditingKnowledge] = useState<LlmWikiKnowledgeBase | null>(null);
const [searchText, setSearchText] = useState("");
const [searching, setSearching] = useState(false);
const [searchResponse, setSearchResponse] = useState<LlmWikiSearchResponse | null>(null);
@@ -119,12 +121,8 @@ export function WeKnoraKnowledgeWorkspace() {
setSelectedSearchResult(null);
}, [tab]);
const busy = actions.create.isPending || actions.remove.isPending || actions.publication.isPending;
const busy = actions.create.isPending || actions.update.isPending || actions.remove.isPending || actions.publication.isPending;
const openDetails = (item: LlmWikiKnowledgeBase) => {
if (item.remote_status === "missing") {
toast.error("该知识库已从知识服务删除,只能清理当前记录");
return;
}
navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}`);
};
@@ -142,10 +140,7 @@ export function WeKnoraKnowledgeWorkspace() {
};
const removeKnowledgeBase = async (item: LlmWikiKnowledgeBase) => {
const warning = item.remote_status === "missing"
? `知识库“${item.name}”已不在知识服务中。确定清理 cmzs 中的这条失效记录吗?`
: `确定删除知识库“${item.name}”吗?知识服务中的文档和索引也会删除。`;
if (!window.confirm(warning)) return;
if (!window.confirm(`确定删除知识库“${item.name}”吗?知识服务中的文档和索引也会删除。`)) return;
try {
await actions.remove.mutateAsync(item.id);
toast.success("知识库已删除");
@@ -207,8 +202,8 @@ export function WeKnoraKnowledgeWorkspace() {
)}
</div>
<TabsContent value="personal" className="mt-5"><KnowledgeGrid rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="还没有个人知识库,点击右上角创建。" busy={busy} onOpen={openDetails} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
<TabsContent value="public" className="mt-5"><KnowledgeGrid rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="公共 LLMWiki 暂无已发布知识库。" busy={busy} onOpen={openDetails} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
<TabsContent value="personal" className="mt-5"><KnowledgeGrid rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="还没有个人知识库,点击右上角创建。" busy={busy} onOpen={openDetails} onEdit={setEditingKnowledge} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
<TabsContent value="public" className="mt-5"><KnowledgeGrid rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="公共 LLMWiki 暂无已发布知识库。" busy={busy} onOpen={openDetails} onEdit={setEditingKnowledge} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
{isAdmin && <TabsContent value="settings" className="mt-5"><ConnectionSettings /></TabsContent>}
</Tabs>
@@ -301,6 +296,24 @@ export function WeKnoraKnowledgeWorkspace() {
}}
/>
{editingKnowledge && (
<EditKnowledgeDialog
key={editingKnowledge.id}
knowledgeBase={editingKnowledge}
pending={actions.update.isPending}
onClose={() => setEditingKnowledge(null)}
onSave={async (input) => {
try {
await actions.update.mutateAsync({ id: editingKnowledge.id, ...input });
setEditingKnowledge(null);
toast.success("知识库信息已保存");
} catch (error) {
toast.error(messageOf(error));
}
}}
/>
)}
<CreateKnowledgeDialog open={createOpen} pending={actions.create.isPending} onOpenChange={setCreateOpen} onCreate={async (input) => {
try {
const created = await actions.create.mutateAsync(input);
@@ -322,6 +335,7 @@ function KnowledgeGrid({
emptyText,
busy,
onOpen,
onEdit,
onPublish,
onDelete,
}: {
@@ -331,29 +345,27 @@ function KnowledgeGrid({
emptyText: string;
busy: boolean;
onOpen: (item: LlmWikiKnowledgeBase) => void;
onEdit: (item: LlmWikiKnowledgeBase) => void;
onPublish: (item: LlmWikiKnowledgeBase) => void;
onDelete: (item: LlmWikiKnowledgeBase) => void;
}) {
if (loading) return <div className="flex h-64 items-center justify-center text-muted-foreground"><Loader2 className="mr-2 size-5 animate-spin" />正在读取知识库</div>;
if (error) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-6 text-sm text-destructive">{messageOf(error)}</div>;
if (!rows.length) return <div className="flex h-64 items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground">{emptyText}</div>;
const visibleRows = rows.filter((item) => item.remote_status !== "missing");
if (!visibleRows.length) return <div className="flex h-64 items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground">{emptyText}</div>;
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{rows.map((item) => (
{visibleRows.map((item) => (
<Card
key={item.id}
className={`group overflow-hidden rounded-2xl transition-all hover:-translate-y-0.5 hover:shadow-lg ${
item.remote_status === "missing"
? "border-amber-500/40 bg-amber-500/[0.03]"
: "border-border/70 bg-card/95 hover:border-primary/30"
}`}
className="group overflow-hidden rounded-2xl border-border/70 bg-card/95 transition-all hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-lg"
>
<div className={`h-1 ${item.publication_status === "published" ? "bg-primary" : "bg-muted"}`} />
<button type="button" disabled={item.remote_status === "missing"} className="w-full text-left outline-none transition-colors enabled:hover:bg-muted/25 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-default" onClick={() => onOpen(item)}>
<button type="button" className="w-full text-left outline-none transition-colors hover:bg-muted/25 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2" onClick={() => onOpen(item)}>
<CardHeader className="pb-3 pt-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0"><CardTitle className="truncate text-base">{item.name}</CardTitle><CardDescription className="mt-1 line-clamp-2 min-h-10">{item.description || "暂无描述"}</CardDescription></div>
<div className="flex shrink-0 flex-col items-end gap-1.5">{item.remote_status === "missing" ? <Badge variant="outline" className="border-amber-500/50 text-amber-700 dark:text-amber-300">远端已删除</Badge> : <Badge variant={item.publication_status === "published" ? "default" : "secondary"}>{publicationLabels[item.publication_status] ?? "仅自己"}</Badge>}{item.remote_status !== "missing" && item.indexing_strategy?.wiki_enabled && <Badge className="bg-violet-100 text-violet-700 hover:bg-violet-100 dark:bg-violet-950 dark:text-violet-300">Wiki</Badge>}</div>
<div className="flex shrink-0 flex-col items-end gap-1.5"><Badge variant={item.publication_status === "published" ? "default" : "secondary"}>{publicationLabels[item.publication_status] ?? "仅自己"}</Badge>{item.indexing_strategy?.wiki_enabled && <Badge className="bg-violet-100 text-violet-700 hover:bg-violet-100 dark:bg-violet-950 dark:text-violet-300">Wiki</Badge>}</div>
</div>
</CardHeader>
<div className="px-6 pb-4"><div className="grid grid-cols-3 gap-2 rounded-xl border bg-muted/35 p-3 text-center text-xs"><div><div className="text-base font-semibold text-foreground">{item.knowledge_count}</div><span className="text-muted-foreground">文档</span></div><div><div className="text-base font-semibold text-foreground">{item.chunk_count}</div><span className="text-muted-foreground">分块</span></div><div><div className="text-base font-semibold text-foreground">{item.processing_count}</div><span className="text-muted-foreground">处理中</span></div></div></div>
@@ -361,10 +373,11 @@ function KnowledgeGrid({
<CardContent className="border-t bg-muted/20 px-6 py-4">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{item.remote_status === "missing" ? <span className="text-xs text-muted-foreground">知识服务资源不存在,可清理本地记录</span> : <Button size="sm" variant="outline" className="rounded-full" onClick={() => onOpen(item)}><FileText className="mr-2 size-4" />查看详情</Button>}
{item.remote_status !== "missing" && item.is_owner && !item.is_conversation_deposit && <Button size="sm" variant="secondary" className="rounded-full" disabled={busy} onClick={() => void onPublish(item)}>{item.publication_status === "published" ? "取消公开" : "发布公共库"}</Button>}
<Button size="sm" variant="outline" className="rounded-full" onClick={() => onOpen(item)}><FileText className="mr-2 size-4" />查看详情</Button>
{item.can_write && !item.is_conversation_deposit && <Button size="sm" variant="outline" className="rounded-full" disabled={busy} onClick={() => onEdit(item)} aria-label={`编辑知识库 ${item.name}`}><Edit3 className="mr-2 size-4" />编辑</Button>}
{item.is_owner && !item.is_conversation_deposit && <Button size="sm" variant="secondary" className="rounded-full" disabled={busy} onClick={() => void onPublish(item)}>{item.publication_status === "published" ? "取消公开" : "发布公共库"}</Button>}
</div>
{item.can_write && !item.is_conversation_deposit && <Button size="icon" variant="ghost" className="size-8 shrink-0 rounded-full text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label={item.remote_status === "missing" ? `清理失效记录 ${item.name}` : `删除知识库 ${item.name}`} title={item.remote_status === "missing" ? "清理失效记录" : "删除知识库"} disabled={busy} onClick={() => void onDelete(item)}><Trash2 className="size-4" /></Button>}
{item.can_write && !item.is_conversation_deposit && <Button size="icon" variant="ghost" className="size-8 shrink-0 rounded-full text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label={`删除知识库 ${item.name}`} title="删除知识库" disabled={busy} onClick={() => void onDelete(item)}><Trash2 className="size-4" /></Button>}
</div>
</CardContent>
</Card>
@@ -373,6 +386,55 @@ function KnowledgeGrid({
);
}
function EditKnowledgeDialog({
knowledgeBase,
pending,
onClose,
onSave,
}: {
knowledgeBase: LlmWikiKnowledgeBase;
pending: boolean;
onClose: () => void;
onSave: (input: { name: string; description: string }) => Promise<void>;
}) {
const [name, setName] = useState(knowledgeBase.name);
const [description, setDescription] = useState(knowledgeBase.description);
return (
<Dialog open onOpenChange={(open) => { if (!open && !pending) onClose(); }}>
<DialogContent>
<form onSubmit={(event) => {
event.preventDefault();
if (!pending && name.trim()) {
void onSave({ name: name.trim(), description: description.trim() });
}
}}>
<DialogHeader>
<DialogTitle>编辑知识库</DialogTitle>
<DialogDescription>修改知识库名称和描述,保存后同步到知识服务。</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="llmwiki-edit-name">名称</label>
<Input id="llmwiki-edit-name" value={name} onChange={(event) => setName(event.target.value)} maxLength={255} required disabled={pending} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="llmwiki-edit-description">描述</label>
<Textarea id="llmwiki-edit-description" value={description} onChange={(event) => setDescription(event.target.value)} rows={4} maxLength={2048} disabled={pending} />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose} disabled={pending}>取消</Button>
<Button type="submit" disabled={pending || !name.trim()}>
{pending && <Loader2 className="mr-2 size-4 animate-spin" />}保存
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function CreateKnowledgeDialog({
open,
pending,
@@ -52,6 +52,8 @@ import {
usePublicLlmWikiWikiPages,
} from "@/strategy-components/api/llmwiki";
import { WikiProtectedImage } from "./WikiProtectedImage";
import { WikiLibraryReader } from "./WikiLibraryReader";
import { useAssistantKnowledgeBases, useAssistantKnowledgeSources } from "@/strategy-components/api/assistant-knowledge";
const LIST_PATH = "/page/workspace/knowledge";
const GENERATED_PUBLIC_INDEX_SLUG = "__index__";
@@ -121,6 +123,10 @@ export function WeKnoraWikiPageView({
const navigate = useNavigate();
const publicBackground = usePublicBackground();
const requestedWikiSlug = searchParams.get("wikiSlug")?.trim() || "";
const assistantBases = useAssistantKnowledgeBases(!publicEmbed);
const assistantSources = useAssistantKnowledgeSources(null, !publicEmbed);
const globalBase = assistantBases.data?.find(base => base.is_global);
const isDeposited = assistantSources.data?.some(source => source.source_key === noteId && source.status === "active");
const privateKnowledgeBase = useLlmWikiKnowledgeBase(
noteId || null,
!!noteId && !publicEmbed,
@@ -486,6 +492,7 @@ export function WeKnoraWikiPageView({
</div>
</div>
<div className="flex items-center gap-2">
{globalBase && isDeposited && <Button variant="outline" size="sm" onClick={() => navigate(`/page/workspace/knowledge/assistant?${new URLSearchParams({ base_id: globalBase.id, wikiSlug: pageData.slug })}`)}>在知识梳理中查看</Button>}
<Button variant="outline" size="sm" onClick={() => void download("md")} disabled={downloading !== null}>
{downloading === "md" ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
下载 MD
@@ -498,7 +505,7 @@ export function WeKnoraWikiPageView({
</header>
)}
<div className="flex min-h-0 flex-1 overflow-hidden">
{!publicEmbed ? <WikiLibraryReader pages={pagesList} activePage={pageData} mappingId={noteId} onNavigate={slug => navigate(buildWeKnoraWikiPagePath(noteId, slug))} pageHref={slug => buildAppHashUrl(buildWeKnoraWikiPagePath(noteId, slug))} /> : <div className="flex min-h-0 flex-1 overflow-hidden">
<aside className="flex w-64 shrink-0 flex-col border-r bg-background/90 lg:w-72">
<div className="shrink-0 border-b px-3 py-3">
<div className="flex items-center justify-between gap-3 px-1">
@@ -583,7 +590,7 @@ export function WeKnoraWikiPageView({
</section>
</article>
</main>
</div>
</div>}
</div>
);
}
@@ -0,0 +1,449 @@
import {
BookOpenText,
ChevronDown,
ChevronRight,
FileText,
Folder,
Link2,
Network,
Search,
X,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import {
buildWikiTree,
filterWikiPagesWithAncestors,
type WikiTreeNode,
} from "@/core/llmwiki/wiki-tree";
import {
renderableWikiMarkdown,
rewriteProtectedWikiImageSources,
} from "@/core/llmwiki/wiki-page-markdown";
import { reasoningPlugins } from "@/core/streamdown";
import type { LlmWikiWikiPage } from "@/strategy-components/api/llmwiki";
import { WikiProtectedImage } from "./WikiProtectedImage";
export type LibraryWikiPage = LlmWikiWikiPage & {
tags?: string[];
origins?: { mapping_id?: string; source_name: string; slug: string }[];
};
const TYPES: Record<string, string> = {
index: "索引",
summary: "摘要",
entity: "实体",
concept: "概念",
synthesis: "综合分析",
comparison: "对比分析",
article: "文章",
source: "资料",
relation: "关系",
};
export function WikiLibraryReader({
pages,
activePage,
onNavigate,
pageHref,
mappingId,
}: {
pages: LibraryWikiPage[];
activePage: LibraryWikiPage | null;
onNavigate: (slug: string) => void;
pageHref: (slug: string) => string;
mappingId?: string;
}) {
const [query, setQuery] = useState("");
const [kind, setKind] = useState("");
const [tag, setTag] = useState("");
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [graph, setGraph] = useState(false);
const [directoryOpen, setDirectoryOpen] = useState(false);
const navigate = (slug: string) => {
onNavigate(slug);
setDirectoryOpen(false);
};
const groups = useMemo(
() => [...new Set(pages.map((p) => p.page_type))],
[pages],
);
const tags = useMemo(
() =>
[
...new Set(
pages.flatMap((p) => [
...(p.tags || p.page_metadata?.tags || []),
...(p.aliases || []),
]),
),
].sort(),
[pages],
);
const tree = useMemo(
() =>
buildWikiTree(
filterWikiPagesWithAncestors(
pages.filter(
(p) =>
(!kind || p.page_type === kind) &&
(!tag ||
[
...(p.tags || p.page_metadata?.tags || []),
...(p.aliases || []),
].includes(tag)),
),
query,
),
),
[pages, kind, tag, query],
);
const bySlug = useMemo(() => new Map(pages.map((p) => [p.slug, p])), [pages]);
useEffect(() => {
const ancestors: string[] = [];
const visit = (nodes: WikiTreeNode[], parents: string[]): boolean =>
nodes.some((node) => {
if (node.page?.slug === activePage?.slug) {
ancestors.push(...parents);
return true;
}
return visit(node.children, [...parents, node.key]);
});
visit(tree.roots, []);
setCollapsed(
(current) =>
new Set([...current].filter((key) => !ancestors.includes(key))),
);
}, [activePage?.slug, tree]);
const links = useMemo(() => {
const outgoing = new Set(activePage?.out_links || []);
for (const match of (activePage?.content || "").matchAll(
/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g,
))
outgoing.add(match[1].trim());
const incoming = new Set(activePage?.in_links || []);
for (const p of pages)
if (
p.slug !== activePage?.slug &&
((p.out_links || []).includes(activePage?.slug || "") ||
p.content?.includes(`[[${activePage?.slug}`))
)
incoming.add(p.slug);
return {
outgoing: [...outgoing].filter((s) => bySlug.has(s)),
incoming: [...incoming].filter((s) => bySlug.has(s)),
};
}, [activePage, pages, bySlug]);
const renderNode = (node: WikiTreeNode, depth = 0): React.ReactNode => {
const active = activePage?.slug === node.page?.slug;
const open = !!query || !collapsed.has(node.key);
const toggle = () =>
setCollapsed((current) => {
const next = new Set(current);
if (next.has(node.key)) next.delete(node.key);
else next.add(node.key);
return next;
});
return (
<div key={node.key}>
<div
style={{ paddingLeft: 8 + depth * 14 }}
className={`flex items-center gap-1 rounded-lg pr-2 text-sm ${active ? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200" : "text-muted-foreground hover:bg-muted"}`}
>
{node.children.length ? (
<button
aria-label={open ? "收起分类" : "展开分类"}
onClick={toggle}
className="p-1"
>
{open ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</button>
) : (
<span className="w-6" />
)}
<button
onClick={() => (node.page ? navigate(node.page.slug) : toggle())}
className="flex min-w-0 flex-1 items-center gap-2 py-2.5 text-left"
aria-current={active ? "page" : undefined}
>
{node.page ? (
<FileText className="size-4 shrink-0" />
) : (
<Folder className="size-4 shrink-0 text-amber-500" />
)}
<span className="truncate">{TYPES[node.label] || node.label}</span>
</button>
</div>
{open && node.children.map((child) => renderNode(child, depth + 1))}
</div>
);
};
const renderLinks = (values: string[]) =>
values.map((slug) => (
<button
key={slug}
onClick={() => navigate(slug)}
className="block w-full rounded-lg px-2 py-2 text-left text-sm text-primary hover:bg-muted"
>
<Link2 className="mr-2 inline size-3.5" />
{bySlug.get(slug)?.title || slug}
</button>
));
return (
<div className="relative flex min-h-0 flex-1 overflow-hidden bg-muted/20">
{directoryOpen && (
<button
aria-label="关闭 Wiki 目录"
className="absolute inset-0 z-20 bg-black/30 md:hidden"
onClick={() => setDirectoryOpen(false)}
/>
)}
<aside
className={`${directoryOpen ? "flex" : "hidden"} absolute inset-y-0 left-0 z-30 w-64 shrink-0 flex-col border-r bg-background md:static md:z-auto md:flex lg:w-72`}
>
<div className="space-y-3 border-b p-4">
<div className="flex items-center gap-2 font-semibold">
<BookOpenText className="size-4 text-emerald-600" />
Wiki 目录
<span className="ml-auto text-xs font-normal text-muted-foreground">
{pages.length} 篇
</span>
<button
className="md:hidden"
aria-label="收起 Wiki 目录"
onClick={() => setDirectoryOpen(false)}
>
<X className="size-4" />
</button>
</div>
<div className="relative">
<Search className="absolute left-2 top-3 size-4 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索标题、分类或标签"
className="pl-8"
/>
</div>
<select
aria-label="Wiki 分类"
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={kind}
onChange={(e) => setKind(e.target.value)}
>
<option value="">全部类型</option>
{groups.map((k) => (
<option key={k} value={k}>
{TYPES[k] || k}({pages.filter((p) => p.page_type === k).length}
)
</option>
))}
</select>
{tags.length > 0 && (
<select
aria-label="Wiki 标签"
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={tag}
onChange={(e) => setTag(e.target.value)}
>
<option value="">全部标签</option>
{tags.map((t) => (
<option key={t}>{t}</option>
))}
</select>
)}
</div>
<nav
className="min-h-0 flex-1 overflow-auto p-2"
aria-label="Wiki 目录"
>
{tree.roots.map((node) => renderNode(node))}
{!tree.roots.length && (
<p className="p-4 text-sm text-muted-foreground">暂无匹配页面</p>
)}
</nav>
</aside>
<main className="min-w-0 flex-1 overflow-auto p-3 md:p-5 lg:p-8">
<Button
variant="outline"
size="sm"
className="mb-3 md:hidden"
onClick={() => setDirectoryOpen(true)}
>
<BookOpenText className="mr-2 size-4" />
目录与分类({pages.length})
</Button>
{activePage ? (
<article className="mx-auto max-w-5xl rounded-2xl border bg-background shadow-sm">
<header className="space-y-3 border-b p-4 md:px-7 md:py-6">
<div className="text-xs text-muted-foreground">
{(activePage.category_path || []).join(" / ") || "知识库 Wiki"}
</div>
<div className="flex items-start justify-between gap-4">
<h1 className="text-2xl font-semibold tracking-tight">
{activePage.title}
</h1>
<Badge variant="outline">
{TYPES[activePage.page_type] || activePage.page_type}
</Badge>
</div>
<div className="flex flex-wrap gap-2">
{[
...new Set([
...(activePage.tags ||
activePage.page_metadata?.tags ||
[]),
...(activePage.aliases || []),
]),
].map((t) => (
<button
key={t}
onClick={() => {
setTag(t);
setDirectoryOpen(true);
}}
>
<Badge variant="secondary">{t}</Badge>
</button>
))}
</div>
{activePage.summary && (
<p className="text-sm leading-7 text-muted-foreground">
{activePage.summary}
</p>
)}
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
{activePage.updated_at && (
<span>
更新于 {new Date(activePage.updated_at).toLocaleString()}
</span>
)}
<span>
{links.outgoing.length} 个引用 · {links.incoming.length}{" "}
个反向链接
</span>
</div>
</header>
<section className="px-4 py-6 md:px-7 md:py-8 lg:px-10">
<MarkdownContent
content={rewriteProtectedWikiImageSources(
renderableWikiMarkdown(
activePage.title,
activePage.content,
mappingId || "assistant",
(_id, slug) => pageHref(slug),
pages,
).replace(/^# [^\n]*(?:\n|$)/, ""),
)}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
components={
mappingId
? {
img: (props) => (
<WikiProtectedImage
{...props}
knowledgeBaseId={mappingId}
/>
),
}
: undefined
}
linkClassName={(href) =>
pages.some((p) => pageHref(p.slug) === href)
? "wiki-content-link !text-primary border-b border-dashed"
: undefined
}
onLinkClick={(event, href) => {
const target = pages.find(
(p) =>
pageHref(p.slug) === href ||
href === p.slug ||
href === `/${p.slug}`,
);
if (target) {
event.preventDefault();
navigate(target.slug);
}
}}
/>
</section>
<section className="border-t px-4 py-5 md:px-7">
<Button
variant="ghost"
size="sm"
onClick={() => setGraph(!graph)}
>
<Network className="mr-2 size-4" />
{graph ? "收起关系图谱" : "查看关系图谱"}
</Button>
{graph && (
<div className="my-4 grid grid-cols-3 items-center gap-3 rounded-xl bg-muted/40 p-4">
<div>
{renderLinks(links.incoming)}
{!links.incoming.length && (
<span className="text-xs text-muted-foreground">
暂无反向链接
</span>
)}
</div>
<div className="rounded-xl border border-primary/30 bg-primary/10 p-4 text-center text-sm font-medium">
→ {activePage.title} →
</div>
<div>
{renderLinks(links.outgoing)}
{!links.outgoing.length && (
<span className="text-xs text-muted-foreground">
暂无引用
</span>
)}
</div>
</div>
)}
<div className="grid gap-5 sm:grid-cols-2">
<div>
<h3 className="my-2 text-sm font-medium">引用页面</h3>
{renderLinks(links.outgoing)}
</div>
<div>
<h3 className="my-2 text-sm font-medium">被哪些页面引用</h3>
{renderLinks(links.incoming)}
</div>
</div>
{(activePage.origins || [])
.filter(
(o, index, all) =>
o.mapping_id &&
all.findIndex(
(value) =>
value.mapping_id === o.mapping_id &&
value.slug === o.slug,
) === index,
)
.map((o) => (
<a
key={`${o.mapping_id}:${o.slug}`}
className="mt-3 block text-xs text-primary hover:underline"
href={`#/page/workspace/knowledge/${encodeURIComponent(o.mapping_id!)}/wiki/view?wikiSlug=${encodeURIComponent(o.slug)}`}
>
查看普通知识库来源 · {o.source_name}
</a>
))}
</section>
</article>
) : (
<div className="py-20 text-center text-sm text-muted-foreground">
选择一个 Wiki 页面开始阅读。完成向量化的数据会自动沉淀到知识梳理。
</div>
)}
</main>
</div>
);
}
@@ -0,0 +1,126 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Download, Loader2, Upload } from "lucide-react";
import { useRef } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
createAssistantExportPackage,
importOrdinaryWikiPackage,
listWikiTransfers,
} from "@/strategy-components/api/assistant-knowledge";
export function WikiPackageTransfers({
id,
assistant = false,
}: {
id: string;
assistant?: boolean;
}) {
const input = useRef<HTMLInputElement>(null);
const client = useQueryClient();
const key = ["wiki-transfers", assistant, id];
const jobs = useQuery({
queryKey: key,
queryFn: () => listWikiTransfers(id, assistant),
refetchInterval: (q) =>
q.state.data?.some((j) => ["queued", "running"].includes(j.status))
? 3000
: false,
});
const action = useMutation({
mutationFn: (file?: File) =>
assistant
? createAssistantExportPackage(id)
: importOrdinaryWikiPackage(id, file!),
onSuccess: () => {
void client.invalidateQueries({ queryKey: key });
toast.success(
assistant
? "已创建导出记录,整理完成后可下载"
: "文件已上传,后台正在校验并导入原有 Wiki 和向量",
);
},
onError: (e) => toast.error(e.message),
});
return (
<div className="space-y-3 rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold">
{assistant ? "导出 Wiki 向量数据包" : "导入 Wiki 向量数据包"}
</h3>
<p className="mt-1 text-xs text-muted-foreground">
{assistant
? "保留正文、分类、标签、链接和原始向量;后台整理完成后下载。"
: "选择助手知识库导出的文件,导入当前空库。模型一致时直接复用向量。"}
</p>
</div>
<input
type="file"
accept=".jsonl,.ndjson"
ref={input}
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) action.mutate(f);
e.target.value = "";
}}
/>
<Button
variant="outline"
size="sm"
disabled={action.isPending}
onClick={() =>
assistant ? action.mutate(undefined) : input.current?.click()
}
>
{action.isPending ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : assistant ? (
<Download className="mr-2 size-4" />
) : (
<Upload className="mr-2 size-4" />
)}
{assistant ? "生成导出包" : "上传并导入"}
</Button>
</div>
{jobs.error && (
<p className="text-sm text-destructive">{jobs.error.message}</p>
)}
{jobs.data?.map((job) => (
<div
key={job.id}
className="flex flex-wrap items-center gap-3 border-t pt-3 text-xs"
>
<Badge variant="outline">
{{
queued: "排队中",
running: "处理中",
failed: "失败",
completed: "已完成",
}[job.status] || job.status}
</Badge>
<span>
{job.created_at && new Date(job.created_at).toLocaleString()}
</span>
<span>
{job.counts?.wiki_page || 0} Wiki · {job.counts?.vector || 0} 向量
</span>
{job.file_size_bytes ? (
<span>{(job.file_size_bytes / 1024 / 1024).toFixed(2)} MB</span>
) : null}
{job.error && <span className="text-destructive">{job.error}</span>}
{assistant &&
job.status === "completed" &&
job.file_exists &&
job.package_download_url && (
<Button asChild size="sm" variant="outline" className="ml-auto">
<a href={job.package_download_url}>下载</a>
</Button>
)}
</div>
))}
</div>
);
}