feat: add local LLMWiki wiki vector index
This commit is contained in:
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),
|
||||
|
||||
+10
-5
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
+49
-2
@@ -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" />
|
||||
刷新
|
||||
|
||||
Reference in new issue
Block a user