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

+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>