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:
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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
+2
@@ -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">
|
||||
|
||||
+85
-23
@@ -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,
|
||||
|
||||
+9
-2
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+449
@@ -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>
|
||||
);
|
||||
}
|
||||
+126
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user