feat: add local LLMWiki wiki vector index

This commit is contained in:
Administrator committed 2026-08-24 00:49:31 +08:00
1 parent 4c9f75f31e
commit 3785345353
48 files changed
+3650 -152

No files matched your search

@@ -73,6 +73,9 @@ export function SourceCard({
: null;
const meta = [
source.source,
previewTarget?.wikiSlug && source.raw?.matched_sections
? `Wiki 章节 ${String((source.raw.matched_sections as Array<{ heading?: string }>)[0]?.heading || "")}`
: null,
source.author ? `作者 ${source.author}` : null,
source.type && source.type !== "result" ? source.type : null,
source.time,
@@ -132,7 +135,7 @@ export function SourceCard({
previewTarget ? (
<span className="text-muted-foreground bg-muted mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium">
<FileTextIcon className="size-3.5" />
知识库文档
{previewTarget.wikiSlug ? "Wiki 页面" : "知识库文档"}
</span>
) : (
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
@@ -151,7 +154,7 @@ export function SourceCard({
}}
>
<ExternalLinkIcon className="mr-1.5 size-3.5" />
查看详情
{previewTarget.wikiSlug ? "查看 Wiki" : "查看详情"}
</Button>
</div>
)}
@@ -44,7 +44,7 @@ export function parseWeKnoraPreviewUrl(url?: string | null): WeKnoraSourceTarget
params.get("wikiSlug")?.trim() ||
params.get("slug")?.trim() ||
"";
return knowledgeBaseId && chunkId
return knowledgeBaseId && (wikiSlug || chunkId || documentId)
? {
knowledgeBaseId,
chunkId,
@@ -75,6 +75,7 @@ export function resolveWeKnoraSourceTarget(source: KnowledgeSourceLike): WeKnora
fromPreview?.documentId ||
"";
const chunkId = source.chunk_id || source.chunkId || fromPreview?.chunkId || "";
if (!wikiSlug && !chunkId && !documentId) return null;
return {
knowledgeBaseId,
chunkId: chunkId || undefined,
@@ -143,6 +143,22 @@ export interface LlmWikiWikiPageList {
total_pages: number;
}
export interface LlmWikiWikiIndexStatus {
knowledge_base_id: string;
state: "not_synced" | "idle" | "running" | "partial" | "failed" | "rebuilding" | "rebuild_required";
local_index_enabled: boolean;
wiki_index_enabled: boolean;
fully_vectorized: boolean;
retrieval_mode: "vector" | "wiki_api";
local_page_count?: number;
ready_page_count?: number;
failed_page_count?: number;
vector_count?: number;
last_error?: string | null;
last_started_at?: string | null;
last_completed_at?: string | null;
}
export interface LlmWikiSourceContext {
knowledge_base_name?: string;
document_title?: string;
@@ -206,9 +222,13 @@ export interface LlmWikiWikiPageUpdate {
async function readJson<T>(response: Response, context: string): Promise<T> {
if (!response.ok) {
const payload = await response.json().catch(() => null as unknown);
const detail =
payload && typeof payload === "object" && "detail" in payload
? String((payload as { detail: unknown }).detail)
const rawDetail = payload && typeof payload === "object" && "detail" in payload
? (payload as { detail: unknown }).detail
: null;
const detail = rawDetail && typeof rawDetail === "object" && "message" in rawDetail
? String((rawDetail as { message: unknown }).message)
: rawDetail
? String(rawDetail)
: `${context}失败(${response.status})`;
throw new Error(detail);
}
@@ -412,6 +432,66 @@ export async function getLlmWikiWikiPage(
);
}
export async function getLlmWikiWikiIndexStatus(
knowledgeBaseId: string,
): Promise<LlmWikiWikiIndexStatus> {
return readJson(
await apiFetch(
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki-index-status`,
),
"读取 Wiki 向量化状态",
);
}
export async function vectorizeLlmWikiKnowledgeBase(
knowledgeBaseId: string,
): Promise<{ job_id: string; status: string; knowledge_base_id: string }> {
return readJson(
await apiFetch(
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/vectorize`,
{ method: "POST" },
),
"触发 Wiki 向量化",
);
}
export async function exportLlmWikiWikiExcel(
knowledgeBaseId: string,
knowledgeBaseName = "LLMWiki",
): Promise<void> {
const response = await apiFetch(
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/export.xlsx`,
);
if (!response.ok) {
await readJson<void>(response, "导出 Wiki Excel");
return;
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objectUrl;
link.download = `${knowledgeBaseName.replace(/[\\/:*?"<>|]/g, "_") || "LLMWiki"}-Wiki.xlsx`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(objectUrl);
}
/** Read the DeerFlow-local Wiki mirror used by new vector citations. */
export async function getLocalLlmWikiWikiPage(
knowledgeBaseId: string,
slug: string,
): Promise<LlmWikiWikiPage> {
const safeSlug = slug.split("/").map(encodeURIComponent).join("/");
const page = await readJson<LlmWikiWikiPage & { page_version?: number }>(
await apiFetch(
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/local-wiki/pages/${safeSlug}`,
),
"读取本地 Wiki 页面详情",
);
return { ...page, version: page.version ?? page.page_version ?? 0 };
}
export async function createLlmWikiWikiPage(
knowledgeBaseId: string,
input: LlmWikiWikiPageCreate,
@@ -755,6 +835,22 @@ export function useLlmWikiWikiPage(
});
}
export function useLlmWikiWikiIndexStatus(
knowledgeBaseId: string | null,
enabled = true,
) {
const { userId } = useLiveAccount();
return useQuery({
queryKey: ["llmwiki", "wiki-index-status", knowledgeBaseId, userId ?? "anonymous"],
queryFn: () => getLlmWikiWikiIndexStatus(knowledgeBaseId!),
enabled: enabled && !!knowledgeBaseId,
refetchInterval: (query) => {
const state = query.state.data?.state;
return state === "running" || state === "rebuilding" ? 2_000 : 10_000;
},
});
}
export function useLlmWikiSettings(enabled = true) {
const { userId } = useLiveAccount();
return useQuery({
@@ -822,6 +918,14 @@ export function useLlmWikiActions() {
deleteLlmWikiWikiPage(id, slug),
onSuccess: invalidateKnowledge,
}),
vectorizeWiki: useMutation({
mutationFn: ({ id }: { id: string }) => vectorizeLlmWikiKnowledgeBase(id),
onSuccess: invalidateKnowledge,
}),
exportWikiExcel: useMutation({
mutationFn: ({ id, name }: { id: string; name?: string }) =>
exportLlmWikiWikiExcel(id, name),
}),
publication: useMutation({
mutationFn: ({ id, publish }: { id: string; publish: boolean }) =>
setLlmWikiPublication(id, publish),
@@ -20,7 +20,7 @@ import { reasoningPlugins } from "@/core/streamdown";
import {
getLlmWikiDocument,
getLlmWikiDocumentPreview,
getLlmWikiWikiPage,
getLocalLlmWikiWikiPage,
listLlmWikiDocumentChunks,
type LlmWikiChunk,
type LlmWikiDocument,
@@ -528,12 +528,17 @@ export function WeKnoraSourceDrawer({
if (detailTarget.wikiSlug) {
tasks.push(
getLlmWikiWikiPage(detailTarget.knowledgeBaseId, detailTarget.wikiSlug)
getLocalLlmWikiWikiPage(detailTarget.knowledgeBaseId, detailTarget.wikiSlug)
.then((page) => {
loadedWikiPage = page;
if (!cancelled) setWikiPage(page);
})
.catch(() => undefined),
.catch((error) => {
// New Wiki citations have no legacy document/chunk fallback, so
// surface mirror/index failures instead of rendering an empty drawer.
if (!detailTarget.documentId) throw error;
return undefined;
}),
);
}
@@ -649,7 +654,7 @@ export function WeKnoraSourceDrawer({
)}
>
<SheetTitle className="sr-only">{resolvedTitle}</SheetTitle>
<SheetDescription className="sr-only">知识库文档详情</SheetDescription>
<SheetDescription className="sr-only">知识库 Wiki 页面详情</SheetDescription>
<div className="relative flex h-full min-h-0 flex-col bg-background">
<div className="shrink-0 border-b px-5 py-4 pr-64">
<div className="flex min-w-0 items-center gap-3">
@@ -690,7 +695,7 @@ export function WeKnoraSourceDrawer({
{loading && (
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-5 animate-spin" />
正在加载完整文档预览
正在加载 Wiki 页面
</div>
)}
@@ -1,4 +1,4 @@
import { ArrowLeft, ChevronDown, ChevronRight, FilePlus2, FileText, Folder, Loader2, RefreshCw, Save, Search, Trash2 } from "lucide-react";
import { ArrowLeft, ChevronDown, ChevronRight, Cpu, Download, FilePlus2, FileText, Folder, Loader2, RefreshCw, Save, Search, Trash2 } from "lucide-react";
import { type Dispatch, type SetStateAction, useEffect, useMemo, useState } from "react";
import { Navigate, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
@@ -13,6 +13,7 @@ import {
useLlmWikiActions,
useLlmWikiKnowledgeBase,
useLlmWikiRuntime,
useLlmWikiWikiIndexStatus,
useLlmWikiWikiPage,
useLlmWikiWikiPages,
} from "@/strategy-components/api/llmwiki";
@@ -298,6 +299,7 @@ export function WeKnoraWikiManagementPage() {
const canWrite = !!details.data?.can_write && !details.data?.is_conversation_deposit;
const pages = useLlmWikiWikiPages(noteId || null, 1, 500, "", !!noteId);
const indexStatus = useLlmWikiWikiIndexStatus(noteId || null, !!noteId);
const detail = useLlmWikiWikiPage(
noteId || null,
selectedSlug || null,
@@ -561,6 +563,34 @@ export function WeKnoraWikiManagementPage() {
}
};
const vectorize = async () => {
if (!canWrite) return;
try {
await actions.vectorizeWiki.mutateAsync({ id: noteId });
toast.success("已提交整库 Wiki 向量化任务");
await indexStatus.refetch();
} catch (error) {
toast.error(error instanceof Error ? error.message : "触发向量化失败");
}
};
const exportExcel = async () => {
try {
await actions.exportWikiExcel.mutateAsync({ id: noteId, name: title });
toast.success("Wiki 已全部导出为 Excel");
} catch (error) {
toast.error(error instanceof Error ? error.message : "导出 Wiki Excel 失败");
}
};
const vectorStatusText = !indexStatus.data?.local_index_enabled
? "本地向量未启用,检索使用 Wiki 接口"
: indexStatus.data.fully_vectorized
? `整库已向量化(${indexStatus.data.vector_count ?? 0} 个向量章节)`
: indexStatus.data.state === "running" || indexStatus.data.state === "rebuilding"
? "整库向量化进行中,完成前检索使用 Wiki 接口"
: "整库尚未完成向量化,检索使用 Wiki 接口";
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<header className="flex min-h-14 shrink-0 flex-wrap items-center justify-between gap-3 border-b px-4 py-2">
@@ -571,9 +601,26 @@ export function WeKnoraWikiManagementPage() {
</Button>
<h1 className="truncate text-base font-semibold text-foreground">{title}</h1>
</div>
<p className="ml-9 mt-0.5 text-xs text-muted-foreground">Wiki 文档新增、编辑、删除</p>
<p className="ml-9 mt-0.5 text-xs text-muted-foreground">Wiki 文档新增、编辑、删除 · {vectorStatusText}</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void vectorize()}
disabled={!canWrite || actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding"}
>
{actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding" ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Cpu className="mr-2 size-4" />
)}
手动向量化
</Button>
<Button variant="outline" size="sm" onClick={() => void exportExcel()} disabled={actions.exportWikiExcel.isPending}>
{actions.exportWikiExcel.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Download className="mr-2 size-4" />}
导出 Excel
</Button>
<Button variant="outline" size="sm" onClick={() => void pages.refetch()}>
<RefreshCw className="mr-2 size-4" />
刷新