Files
deerflow-code/frontend-web/src/pages/KnowledgeBasePage.tsx
T

637 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Loader2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import { EmptyDocumentState } from "@/components/wiki/EmptyDocumentState";
import { KnowledgeSidebar } from "@/components/wiki/KnowledgeSidebar";
import { WikiContent } from "@/components/wiki/WikiContent";
import { WikiHeader } from "@/components/wiki/WikiHeader";
import {
buildKnowledgeTree,
downloadMarkdown,
findAncestorFolderIds,
} from "@/components/wiki/wikiUtils";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import {
useKnowledgeActions,
useKnowledgeDuplicates,
useKnowledgeNote,
useKnowledgeNotes,
useNoteWikilinks,
useVaultPage,
} from "@/core/knowledge/hooks";
import type { KnowledgeNote } from "@/core/knowledge/types";
import { cn } from "@/lib/utils";
import MarkdownRenderer, {
type WikiLinkTarget,
} from "@/strategy-components/MarkdownRenderer";
const SIDEBAR_COLLAPSED_KEY = "wiki.sidebar.collapsed";
function readSidebarCollapsed(): boolean {
try {
return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1";
} catch {
return false;
}
}
function useDebounced<T>(value: T, delay = 350): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = window.setTimeout(() => setDebounced(value), delay);
return () => window.clearTimeout(id);
}, [value, delay]);
return debounced;
}
function CreateNoteDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { create } = useKnowledgeActions();
const navigate = useNavigate();
const [title, setTitle] = useState("");
const [tags, setTags] = useState("");
const [content, setContent] = useState("");
const reset = () => {
setTitle("");
setTags("");
setContent("");
};
const handleSubmit = async () => {
if (!title.trim()) {
toast.error("请输入标题");
return;
}
try {
const note = await create.mutateAsync({
title: title.trim(),
content_md: content || `# ${title.trim()}\n\n`,
tags: tags
.split(/[,,\s]+/)
.map((t) => t.trim())
.filter(Boolean),
status: "approved",
});
toast.success("知识已创建");
onOpenChange(false);
reset();
navigate(`/page/workspace/knowledge/${note.id}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建失败");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>新建知识</DialogTitle>
<DialogDescription>手动创建一条知识笔记,写入共享知识库 vault。</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<Input
placeholder="标题"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<Input
placeholder="标签,使用逗号分隔"
value={tags}
onChange={(e) => setTags(e.target.value)}
/>
<Textarea
placeholder="Markdown 正文"
rows={12}
value={content}
onChange={(e) => setContent(e.target.value)}
className="font-mono text-sm"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={() => void handleSubmit()} disabled={create.isPending}>
{create.isPending ? "创建中..." : "创建"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function BatchImportDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { batchIngest } = useKnowledgeActions();
const [limit, setLimit] = useState(50);
const [skipExisting, setSkipExisting] = useState(true);
// Optional model override for extraction (null = configured default).
const [model, setModel] = useState<string | null>(null);
const handleRun = async () => {
try {
const result = await batchIngest.mutateAsync({
limit,
skip_existing: skipExisting,
status: "approved",
include_sources: true,
model_name: model,
});
const base = `批量导入完成:新增 ${result.created},跳过 ${result.skipped},失败 ${result.failed}`;
if (result.llm_fallback && result.llm_fallback > 0) {
// The model failed on some threads — they were saved via rule-based
// extraction instead. Say so explicitly per the requirement.
toast.warning(
`${base};其中 ${result.llm_fallback} 条因大模型异常改用了规则抽取(草稿)`,
);
} else {
toast.success(base);
}
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "批量导入失败");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>批量导入历史线程</DialogTitle>
<DialogDescription>
扫描你最近的对话线程并沉淀为知识库内容。已沉淀的线程可自动跳过。
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<label className="flex items-center justify-between gap-2 text-sm">
处理数量上限
<Input
type="number"
min={1}
max={500}
value={limit}
onChange={(e) => setLimit(Number(e.target.value) || 1)}
className="w-28"
/>
</label>
<label className="flex items-center justify-between gap-2 text-sm">
跳过已沉淀线程
<input
type="checkbox"
checked={skipExisting}
onChange={(e) => setSkipExisting(e.target.checked)}
className="size-4"
/>
</label>
<label className="flex items-center justify-between gap-2 text-sm">
提炼大模型
<ModelNameSelect
value={model}
onChange={setModel}
disabled={batchIngest.isPending}
triggerClassName="h-9 w-[11rem]"
/>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={() => void handleRun()} disabled={batchIngest.isPending}>
{batchIngest.isPending ? "导入中..." : "开始导入"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ImportFileDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { ingestFile } = useKnowledgeActions();
const navigate = useNavigate();
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
const [tags, setTags] = useState("");
const [model, setModel] = useState<string | null>(null);
const reset = () => {
setFile(null);
setTitle("");
setTags("");
setModel(null);
};
const handleImport = async () => {
if (!file) {
toast.error("请选择文件");
return;
}
try {
const result = await ingestFile.mutateAsync({
file,
opts: {
title: title.trim() || undefined,
tags: tags.trim() || undefined,
status: "approved",
model_name: model,
},
});
const count = result.notes?.length ?? 1;
if (result.llm_fallback) {
// The model produced nothing usable — the file was stored verbatim as a
// draft instead of distilled into a wiki note.
toast.warning(
`已导入 ${count} 条(大模型异常,改用规则抽取并存为草稿)`,
);
} else {
toast.success(`已从文件生成 ${count} 条知识`);
}
onOpenChange(false);
reset();
if (result.note?.id) {
navigate(`/page/workspace/knowledge/${result.note.id}`);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "文件导入失败");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>导入文件生成 Wiki</DialogTitle>
<DialogDescription>
上传文档(Markdown / TXT / PDF / Word / PPT / Excel),自动提炼为知识库 wiki 词条并写入 vault。
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<label className="flex flex-col gap-1.5 text-sm">
选择文件
<Input
type="file"
accept=".md,.markdown,.txt,.pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
className="cursor-pointer"
/>
{file ? (
<span className="text-muted-foreground truncate text-xs">
{file.name} · {(file.size / 1024).toFixed(0)} KB
</span>
) : null}
</label>
<Input
placeholder="标题(可选,留空则由模型生成)"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<Input
placeholder="标签,逗号分隔(可选)"
value={tags}
onChange={(e) => setTags(e.target.value)}
/>
<label className="flex items-center justify-between gap-2 text-sm">
提炼大模型
<ModelNameSelect
value={model}
onChange={setModel}
disabled={ingestFile.isPending}
triggerClassName="h-9 w-[11rem]"
/>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={() => void handleImport()} disabled={ingestFile.isPending || !file}>
{ingestFile.isPending ? "导入中..." : "导入并生成"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function DedupDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { data: groups = [], isLoading } = useKnowledgeDuplicates(open);
const { merge } = useKnowledgeActions();
const handleMerge = async (primaryId: string, duplicateIds: string[]) => {
try {
await merge.mutateAsync({ primaryId, duplicateIds });
toast.success("已合并重复知识");
} catch (error) {
toast.error(error instanceof Error ? error.message : "合并失败");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>查重与合并</DialogTitle>
<DialogDescription>
基于标题/摘要相似度与内容哈希检测重复知识;合并会把重复项归档并把来源并入主条目。
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] overflow-auto">
{isLoading ? (
<div className="text-muted-foreground p-4 text-sm">检测中...</div>
) : groups.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-6 text-center text-sm">
未发现明显重复的知识。
</div>
) : (
<ul className="flex flex-col gap-3">
{groups.map((g) => (
<li key={g.primary.id} className="rounded-md border p-3">
<div className="mb-2 text-sm font-medium">
保留:{g.primary.title || g.primary.id}
</div>
<ul className="mb-2 flex flex-col gap-1">
{g.duplicates.map((d) => (
<li key={d.id} className="text-muted-foreground text-xs">
合并:{d.title || d.id}(相似度 {Math.round(d.score * 100)}%)
</li>
))}
</ul>
<Button
size="sm"
variant="outline"
onClick={() =>
void handleMerge(
g.primary.id,
g.duplicates.map((d) => d.id),
)
}
disabled={merge.isPending}
>
合并到主条目
</Button>
</li>
))}
</ul>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function VaultPageDialog({
path,
onOpenChange,
}: {
path: string | null;
onOpenChange: (open: boolean) => void;
}) {
const { data: page, isLoading } = useVaultPage(path ?? undefined);
return (
<Dialog open={!!path} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{page?.title || "知识库页面"}</DialogTitle>
<DialogDescription>{path}</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[60vh]">
<div className="p-1">
{isLoading ? (
<div className="text-muted-foreground p-4 text-sm">加载中...</div>
) : page ? (
<MarkdownRenderer content={page.content_md} />
) : (
<div className="text-muted-foreground p-4 text-sm">页面不存在</div>
)}
</div>
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function KnowledgeBasePage() {
const navigate = useNavigate();
const { noteId } = useParams<{ noteId: string }>();
// --- list / tree state ---
const [keyword, setKeyword] = useState("");
const debouncedKeyword = useDebounced(keyword);
const [sourceType, setSourceType] = useState("all");
const [status, setStatus] = useState("all");
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const treeInitializedRef = useRef(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(readSidebarCollapsed);
// --- dialogs ---
const [createOpen, setCreateOpen] = useState(false);
const [batchOpen, setBatchOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [dedupOpen, setDedupOpen] = useState(false);
const [vaultPath, setVaultPath] = useState<string | null>(null);
const params = useMemo(
() => ({
keyword: debouncedKeyword || undefined,
source_type: sourceType === "all" ? undefined : sourceType,
status: status === "all" ? undefined : status,
limit: 500,
}),
[debouncedKeyword, sourceType, status],
);
const { data, isLoading } = useKnowledgeNotes(params);
const notes = data?.items ?? [];
const tree = useMemo(() => buildKnowledgeTree(notes), [notes]);
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
const { data: wikiLinks } = useNoteWikilinks(noteId);
// First load: expand the root-level folders so the tree reads as a directory.
useEffect(() => {
if (treeInitializedRef.current || tree.length === 0) return;
treeInitializedRef.current = true;
setExpandedIds(
new Set(tree.filter((n) => n.type === "folder").map((n) => n.id)),
);
}, [tree]);
// Keep the selected document's ancestor folders open (deep links included).
useEffect(() => {
if (!noteId || tree.length === 0) return;
const ancestors = findAncestorFolderIds(tree, noteId);
if (!ancestors || ancestors.length === 0) return;
setExpandedIds((prev) => {
if (ancestors.every((id) => prev.has(id))) return prev;
const next = new Set(prev);
for (const id of ancestors) next.add(id);
return next;
});
}, [noteId, tree]);
const handleToggleFolder = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleSelectNote = (item: KnowledgeNote) => {
setMobileSidebarOpen(false);
navigate(`/page/workspace/knowledge/${item.id}`);
};
const handleToggleSidebarCollapse = () => {
setSidebarCollapsed((prev) => {
const next = !prev;
try {
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, next ? "1" : "0");
} catch {
// localStorage unavailable (private mode) — state stays session-only.
}
return next;
});
};
const handleWikiLink = (link: WikiLinkTarget) => {
if (link.kind === "note" && link.note_id) {
navigate(`/page/workspace/knowledge/${link.note_id}`);
} else if (link.kind === "vault" && link.vault_path) {
setVaultPath(link.vault_path);
}
};
return (
<div className="flex size-full min-h-0 flex-col bg-slate-50 dark:bg-slate-950">
<WikiHeader
onToggleSidebar={() => setMobileSidebarOpen((v) => !v)}
onImportFile={() => setImportOpen(true)}
onBatchImport={() => setBatchOpen(true)}
onDedup={() => setDedupOpen(true)}
onGraph={() => navigate("/page/workspace/knowledge/graph")}
onCreate={() => setCreateOpen(true)}
/>
<div
className={cn(
"grid min-h-0 flex-1 grid-cols-1",
sidebarCollapsed && !mobileSidebarOpen
? "md:grid-cols-[44px_minmax(0,1fr)]"
: "md:grid-cols-[288px_minmax(0,1fr)]",
)}
>
{mobileSidebarOpen ? (
<div
className="fixed inset-0 z-40 bg-black/30 md:hidden"
onClick={() => setMobileSidebarOpen(false)}
aria-hidden
/>
) : null}
<KnowledgeSidebar
keyword={keyword}
onKeywordChange={setKeyword}
sourceType={sourceType}
onSourceTypeChange={setSourceType}
status={status}
onStatusChange={setStatus}
tree={tree}
expandedIds={expandedIds}
onToggleFolder={handleToggleFolder}
selectedId={noteId}
onSelect={handleSelectNote}
onExportNote={downloadMarkdown}
total={data?.total ?? 0}
isLoading={isLoading}
collapsed={sidebarCollapsed && !mobileSidebarOpen}
onToggleCollapse={handleToggleSidebarCollapse}
className={cn(
"max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:z-50 max-md:w-[300px] max-md:shadow-xl max-md:transition-transform max-md:duration-200",
mobileSidebarOpen ? "max-md:translate-x-0" : "max-md:-translate-x-full",
)}
/>
{!noteId ? (
<EmptyDocumentState />
) : noteLoading || !note ? (
<div className="flex h-full items-center justify-center bg-white text-sm text-slate-400 dark:bg-slate-900">
{noteLoading ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="size-4 animate-spin" /> 加载中...
</span>
) : (
"知识不存在"
)}
</div>
) : (
<WikiContent
note={note}
wikiLinks={wikiLinks}
onWikiLinkClick={handleWikiLink}
/>
)}
</div>
<CreateNoteDialog open={createOpen} onOpenChange={setCreateOpen} />
<ImportFileDialog open={importOpen} onOpenChange={setImportOpen} />
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
<DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} />
<VaultPageDialog
path={vaultPath}
onOpenChange={(open) => {
if (!open) setVaultPath(null);
}}
/>
</div>
);
}