feat(knowledge): 知识库 wiki/知识图谱重构,新增发布广场选择弹窗

- 知识库页拆分出 wiki 组件(侧边栏/目录树/Markdown 阅读器等)
- 知识图谱页拆分出 knowledge-graph 组件并增强交互
- 新增发布广场选择弹窗 publish-square-picker-dialog
- config.yaml 新增 MiniMax-M2.5 / Ling-flash-2.0 / DeepSeek-V4-Pro 模型

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-11 14:52:25 +08:00
1 parent f0aec4cd7f
commit e684de30bd
30 files changed
+4827 -682

No files matched your search

+167 -478
View File
@@ -1,24 +1,8 @@
import {
BookOpen,
Copy,
ExternalLink,
Loader2,
Network,
Pencil,
Plus,
RefreshCw,
Save,
Search,
Sparkles,
Trash2,
Upload,
X,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Loader2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -30,14 +14,18 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { EmptyDocumentState } from "@/components/wiki/EmptyDocumentState";
import { KnowledgeSidebar } from "@/components/wiki/KnowledgeSidebar";
import { TocPanel } from "@/components/wiki/TocPanel";
import { WikiContent } from "@/components/wiki/WikiContent";
import { WikiHeader } from "@/components/wiki/WikiHeader";
import {
buildKnowledgeTree,
downloadMarkdown,
extractToc,
findAncestorFolderIds,
} from "@/components/wiki/wikiUtils";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import {
useKnowledgeActions,
@@ -53,47 +41,6 @@ import MarkdownRenderer, {
type WikiLinkTarget,
} from "@/strategy-components/MarkdownRenderer";
const SOURCE_TYPE_LABELS: Record<string, string> = {
thread: "对话",
search: "检索",
tool: "工具",
file: "文件",
manual: "手动",
report: "报告",
};
const STATUS_LABELS: Record<string, string> = {
draft: "草稿",
approved: "已批准",
archived: "已归档",
};
/** Human label for an audit actor: prefer the resolved name (email), else a short id. */
function actorLabel(name?: string | null, id?: string | null): string {
if (name && name.trim()) return name.trim();
if (id && id.trim()) return `${id.trim().slice(0, 8)}…`;
return "—";
}
const ENTITY_TYPE_META: Record<string, { label: string; className: string }> = {
person: { label: "人物", className: "bg-rose-50 text-rose-700 border-rose-200 dark:bg-rose-500/15 dark:text-rose-300 dark:border-rose-500/30" },
org: { label: "机构", className: "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30" },
product: { label: "产品", className: "bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30" },
technology: { label: "技术", className: "bg-cyan-50 text-cyan-700 border-cyan-200 dark:bg-cyan-500/15 dark:text-cyan-300 dark:border-cyan-500/30" },
concept: { label: "概念", className: "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30" },
region: { label: "地区", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" },
country: { label: "国家", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" },
};
function entityMeta(type?: string | null) {
return (
ENTITY_TYPE_META[(type || "").toLowerCase()] ?? {
label: "其他",
className: "bg-slate-100 text-slate-600 border-slate-200 dark:bg-slate-700/40 dark:text-slate-300 dark:border-slate-600",
}
);
}
function useDebounced<T>(value: T, delay = 350): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
@@ -103,54 +50,6 @@ function useDebounced<T>(value: T, delay = 350): T {
return debounced;
}
function NoteListItem({
note,
active,
onClick,
}: {
note: KnowledgeNote;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full flex-col gap-1 rounded-md border p-3 text-left transition-colors",
active
? "border-primary bg-primary/5"
: "border-border/70 hover:bg-muted/50",
)}
>
<div className="flex items-center justify-between gap-2">
<span className="line-clamp-1 text-sm font-medium">{note.title}</span>
<Badge variant="outline" className="shrink-0 text-[10px]">
{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
</Badge>
</div>
{note.summary ? (
<span className="text-muted-foreground line-clamp-2 text-xs">
{note.summary}
</span>
) : null}
<div className="mt-1 flex flex-wrap items-center gap-1">
{note.tags.slice(0, 4).map((tag) => (
<span
key={tag}
className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]"
>
#{tag}
</span>
))}
<span className="text-muted-foreground ml-auto text-[10px]">
{note.updated_at?.slice(0, 10)}
</span>
</div>
</button>
);
}
function CreateNoteDialog({
open,
onOpenChange,
@@ -550,13 +449,93 @@ function VaultPageDialog({
);
}
function NoteDetail({ noteId }: { noteId: string }) {
export default function KnowledgeBasePage() {
const navigate = useNavigate();
const { data: note, isLoading } = useKnowledgeNote(noteId);
const { data: wikiLinks } = useNoteWikilinks(noteId);
const { update, remove } = useKnowledgeActions();
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);
// --- 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);
// --- reading state ---
const [activeHeadingId, setActiveHeadingId] = 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);
const tocItems = useMemo(
() => (note ? extractToc(note.content_md) : []),
[note],
);
// 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 handleTocClick = (id: string) => {
setActiveHeadingId(id);
document
.getElementById(id)
?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const handleWikiLink = (link: WikiLinkTarget) => {
if (link.kind === "note" && link.note_id) {
navigate(`/page/workspace/knowledge/${link.note_id}`);
@@ -564,235 +543,81 @@ function NoteDetail({ noteId }: { noteId: string }) {
setVaultPath(link.vault_path);
}
};
const [editing, setEditing] = useState(false);
const [draftTitle, setDraftTitle] = useState("");
const [draftTags, setDraftTags] = useState("");
const [draftContent, setDraftContent] = useState("");
const [draftStatus, setDraftStatus] = useState("approved");
useEffect(() => {
setEditing(false);
setVaultPath(null);
}, [noteId]);
useEffect(() => {
if (note) {
setDraftTitle(note.title);
setDraftTags(note.tags.join(", "));
setDraftContent(note.content_md);
setDraftStatus(note.status);
}
}, [note]);
if (isLoading || !note) {
return (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
{isLoading ? "加载中..." : "知识不存在"}
</div>
);
}
const handleSave = async () => {
try {
await update.mutateAsync({
noteId: note.id,
data: {
title: draftTitle.trim() || note.title,
content_md: draftContent,
tags: draftTags
.split(/[,,\s]+/)
.map((t) => t.trim())
.filter(Boolean),
status: draftStatus,
},
});
toast.success("已保存");
setEditing(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const handleArchive = async () => {
try {
await remove.mutateAsync({ noteId: note.id });
toast.success("已归档");
navigate("/page/workspace/knowledge");
} catch (error) {
toast.error(error instanceof Error ? error.message : "归档失败");
}
};
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between gap-2 border-b p-3">
<div className="flex items-center gap-2">
<Badge variant="outline">
{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
</Badge>
<Badge variant="secondary">{STATUS_LABELS[note.status] ?? note.status}</Badge>
</div>
<div className="flex items-center gap-2">
{editing ? (
<>
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>
<X className="size-4" /> 取消
</Button>
<Button size="sm" onClick={() => void handleSave()} disabled={update.isPending}>
<Save className="size-4" /> 保存
</Button>
</>
) : (
<>
<Button size="sm" variant="ghost" onClick={() => setEditing(true)}>
<Pencil className="size-4" /> 编辑
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => void handleArchive()}
disabled={remove.isPending}
>
<Trash2 className="size-4" /> 归档
</Button>
</>
<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="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[300px_minmax(0,1fr)] xl:grid-cols-[300px_minmax(0,1fr)_280px]">
{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}
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",
)}
</div>
/>
{!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}
toc={tocItems}
wikiLinks={wikiLinks}
onWikiLinkClick={handleWikiLink}
onActiveHeadingChange={setActiveHeadingId}
/>
)}
<TocPanel
items={tocItems}
activeId={activeHeadingId}
onItemClick={handleTocClick}
className="hidden xl:block"
/>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="p-4">
{editing ? (
<div className="flex flex-col gap-3">
<Input
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
placeholder="标题"
/>
<div className="flex items-center gap-2">
<Input
value={draftTags}
onChange={(e) => setDraftTags(e.target.value)}
placeholder="标签,逗号分隔"
/>
<Select value={draftStatus} onValueChange={setDraftStatus}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">草稿</SelectItem>
<SelectItem value="approved">已批准</SelectItem>
<SelectItem value="archived">已归档</SelectItem>
</SelectContent>
</Select>
</div>
<Textarea
rows={20}
value={draftContent}
onChange={(e) => setDraftContent(e.target.value)}
className="font-mono text-sm"
/>
</div>
) : (
<>
<h1 className="mb-3 text-xl font-semibold">{note.title}</h1>
{note.tags.length > 0 ? (
<div className="mb-2 flex flex-wrap items-center gap-1.5">
{note.tags.map((tag) => (
<span
key={tag}
className="border-primary/20 bg-primary/5 text-primary/90 hover:bg-primary/10 inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors"
>
#{tag}
</span>
))}
</div>
) : null}
{note.entities && note.entities.length > 0 ? (
<div className="mb-4 flex flex-wrap items-center gap-1.5">
<span className="text-muted-foreground mr-0.5 inline-flex items-center gap-1 text-xs">
<Sparkles className="size-3" /> 实体
</span>
{note.entities.map((ent) => {
const meta = entityMeta(ent.entity_type);
return (
<span
key={`${ent.name}-${ent.entity_type ?? ""}`}
title={meta.label}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium",
meta.className,
)}
>
<span className="size-1.5 shrink-0 rounded-full bg-current opacity-70" />
<span>{ent.name}</span>
<span className="rounded-full bg-current/15 px-1.5 py-0.5 text-[10px] leading-none opacity-90">
{meta.label}
</span>
</span>
);
})}
</div>
) : null}
<MarkdownRenderer
content={note.content_md}
wikiLinks={wikiLinks}
onWikiLinkClick={handleWikiLink}
/>
{note.sources && note.sources.length > 0 ? (
<div className="mt-6 border-t pt-4">
<h2 className="mb-2 text-sm font-semibold">来源</h2>
<ul className="flex flex-col gap-2">
{note.sources.map((src) => (
<li key={src.id} className="text-sm">
{src.url ? (
<a
href={src.url}
target="_blank"
rel="noreferrer"
className="text-primary inline-flex items-center gap-1 hover:underline"
>
{src.title || src.url}
<ExternalLink className="size-3" />
</a>
) : src.thread_id ? (
<button
type="button"
className="text-primary hover:underline"
onClick={() =>
navigate(`/page/workspace/chats/${src.thread_id}`)
}
>
{src.title || `对话 ${src.thread_id.slice(0, 8)}`}
</button>
) : (
<span>{src.title || src.tool_name || "来源"}</span>
)}
{src.snippet ? (
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs">
{src.snippet}
</p>
) : null}
</li>
))}
</ul>
</div>
) : null}
<div className="text-muted-foreground mt-6 border-t pt-3 text-xs">
创建人 {actorLabel(note.created_by_name, note.created_by)} · 更新人{" "}
{actorLabel(note.updated_by_name, note.updated_by)} · 更新于{" "}
{note.updated_at?.slice(0, 19).replace("T", " ")}
</div>
</>
)}
</div>
</ScrollArea>
<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) => {
@@ -802,139 +627,3 @@ function NoteDetail({ noteId }: { noteId: string }) {
</div>
);
}
export default function KnowledgeBasePage() {
const navigate = useNavigate();
const { noteId } = useParams<{ noteId: string }>();
const [keyword, setKeyword] = useState("");
const debouncedKeyword = useDebounced(keyword);
const [sourceType, setSourceType] = useState("all");
const [status, setStatus] = useState("all");
const [createOpen, setCreateOpen] = useState(false);
const [batchOpen, setBatchOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [dedupOpen, setDedupOpen] = useState(false);
const params = useMemo(
() => ({
keyword: debouncedKeyword || undefined,
source_type: sourceType === "all" ? undefined : sourceType,
status: status === "all" ? undefined : status,
limit: 50,
}),
[debouncedKeyword, sourceType, status],
);
const { data, isLoading } = useKnowledgeNotes(params);
const notes = data?.items ?? [];
return (
<div className="flex size-full min-h-0 flex-col">
<header className="flex flex-wrap items-center gap-2 border-b p-3">
<BookOpen className="size-5" />
<h1 className="mr-2 text-base font-semibold">知识库</h1>
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-1/2 size-4 -translate-y-1/2" />
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索标题 / 摘要 / 正文 / 标签"
className="w-64 pl-8"
/>
</div>
<Select value={sourceType} onValueChange={setSourceType}>
<SelectTrigger className="w-28">
<SelectValue placeholder="来源" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部来源</SelectItem>
{Object.entries(SOURCE_TYPE_LABELS).map(([v, label]) => (
<SelectItem key={v} value={v}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-28">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="draft">草稿</SelectItem>
<SelectItem value="approved">已批准</SelectItem>
<SelectItem value="archived">已归档</SelectItem>
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<Upload className="size-4" /> 导入文件
</Button>
<Button variant="outline" size="sm" onClick={() => setBatchOpen(true)}>
<RefreshCw className="size-4" /> 批量导入
</Button>
<Button variant="outline" size="sm" onClick={() => setDedupOpen(true)}>
<Copy className="size-4" /> 查重
</Button>
<Button
variant="outline"
size="sm"
onClick={() => navigate("/page/workspace/knowledge/graph")}
>
<Network className="size-4" /> 图谱
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="size-4" /> 新建
</Button>
</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[360px_minmax(0,1fr)]">
<div className="flex min-h-0 flex-col border-r">
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-2 p-3">
{isLoading ? (
<div className="text-muted-foreground flex items-center gap-2 p-4 text-sm">
<Loader2 className="size-4 animate-spin" /> 加载中...
</div>
) : notes.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-6 text-center text-sm">
暂无知识。点击「新建」或在对话页「保存到知识库」。
</div>
) : (
notes.map((note) => (
<NoteListItem
key={note.id}
note={note}
active={note.id === noteId}
onClick={() => navigate(`/page/workspace/knowledge/${note.id}`)}
/>
))
)}
</div>
</ScrollArea>
{data ? (
<div className="text-muted-foreground border-t p-2 text-center text-xs">
共 {data.total} 条知识
</div>
) : null}
</div>
<div className="min-h-0">
{noteId ? (
<NoteDetail noteId={noteId} />
) : (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
从左侧选择一条知识查看详情
</div>
)}
</div>
</div>
<CreateNoteDialog open={createOpen} onOpenChange={setCreateOpen} />
<ImportFileDialog open={importOpen} onOpenChange={setImportOpen} />
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
<DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} />
</div>
);
}
+393 -70
View File
@@ -1,87 +1,410 @@
import { ArrowLeft, Loader2, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Loader2, Network, Tags } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { useTheme } from "@/contexts/ThemeContext";
import { knowledgeExportFileUrl } from "@/core/knowledge/api";
import { useKnowledgeActions } from "@/core/knowledge/hooks";
import {
GraphCanvas,
type GraphCanvasHandle,
} from "@/components/knowledge-graph/GraphCanvas";
import { GraphDetailPanel } from "@/components/knowledge-graph/GraphDetailPanel";
import { GraphFooter } from "@/components/knowledge-graph/GraphFooter";
import { GraphHeader } from "@/components/knowledge-graph/GraphHeader";
import { GraphSidebar } from "@/components/knowledge-graph/GraphSidebar";
import { GraphTooltip } from "@/components/knowledge-graph/GraphTooltip";
import type {
LayoutType,
NodeKind,
TooltipState,
} from "@/components/knowledge-graph/graphTypes";
import {
computeDegrees,
computeGraphStats,
getEdgeLabel,
getNodeKind,
getNodeTitle,
} from "@/components/knowledge-graph/graphUtils";
import "@/components/knowledge-graph/graphStyles.css";
import { useKnowledgeGraph, useKnowledgeNote } from "@/core/knowledge/hooks";
export default function KnowledgeGraphPage() {
const navigate = useNavigate();
const { theme } = useTheme();
const { exportGraph } = useKnowledgeActions();
// Keep the served base URL (without the theme) so a theme toggle can re-skin
// the iframe without regenerating the graph on the backend.
const [baseSrc, setBaseSrc] = useState<string | null>(null);
const [stats, setStats] = useState<{ nodes: number; edges: number } | null>(null);
const pageRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<GraphCanvasHandle>(null);
const regenerate = useCallback(async () => {
try {
const result = await exportGraph.mutateAsync();
setStats(result.stats);
// Cache-bust the iframe so it reflects the freshly generated graph.
setBaseSrc(`${knowledgeExportFileUrl("graph.html")}?t=${Date.now()}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成图谱失败");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
const { data, isLoading, isError, error, refetch, isFetching } =
useKnowledgeGraph();
// ------------------------------------------------------------------ state
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [searchKeyword, setSearchKeyword] = useState("");
const [disabledNodeKinds, setDisabledNodeKinds] = useState<Set<NodeKind>>(
new Set(),
);
const [disabledEdgeLabels, setDisabledEdgeLabels] = useState<Set<string>>(
new Set(),
);
const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<string>>(new Set());
const [layoutType, setLayoutType] = useState<LayoutType>("force");
const [showLabels, setShowLabels] = useState(true);
const [showSidebar, setShowSidebar] = useState(true);
const [showDetail, setShowDetail] = useState(true);
const [zoomLevel, setZoomLevel] = useState(1);
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
// Auto-collapse the side panels on narrow viewports.
useEffect(() => {
const detailQuery = window.matchMedia("(max-width: 1280px)");
const sidebarQuery = window.matchMedia("(max-width: 900px)");
const apply = () => {
setShowDetail(!detailQuery.matches);
setShowSidebar(!sidebarQuery.matches);
};
apply();
detailQuery.addEventListener("change", apply);
sidebarQuery.addEventListener("change", apply);
return () => {
detailQuery.removeEventListener("change", apply);
sidebarQuery.removeEventListener("change", apply);
};
}, []);
useEffect(() => {
void regenerate();
}, [regenerate]);
// ---------------------------------------------------------------- derived
const allNodes = useMemo(() => data?.nodes ?? [], [data]);
const allEdges = useMemo(() => data?.edges ?? [], [data]);
// The graph HTML lives in an iframe and can't read the app's CSS theme, so we
// pass the active day/night mode via a query param; toggling re-renders it.
const src = baseSrc ? `${baseSrc}&theme=${theme}` : null;
const nodeById = useMemo(
() => new Map(allNodes.map((n) => [n.id, n])),
[allNodes],
);
const degrees = useMemo(
() => computeDegrees(allNodes, allEdges),
[allNodes, allEdges],
);
const stats = useMemo(
() => computeGraphStats(allNodes, allEdges),
[allNodes, allEdges],
);
const nodeKindCounts = useMemo(() => {
const counts = new Map<NodeKind, number>();
for (const n of allNodes) {
const kind = getNodeKind(n);
counts.set(kind, (counts.get(kind) ?? 0) + 1);
}
return counts;
}, [allNodes]);
const edgeLabelCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const e of allEdges) {
const label = getEdgeLabel(e);
counts.set(label, (counts.get(label) ?? 0) + 1);
}
return counts;
}, [allEdges]);
const filteredNodes = useMemo(
() =>
allNodes.filter(
(n) => !disabledNodeKinds.has(getNodeKind(n)) && !hiddenNodeIds.has(n.id),
),
[allNodes, disabledNodeKinds, hiddenNodeIds],
);
const filteredEdges = useMemo(() => {
const visible = new Set(filteredNodes.map((n) => n.id));
return allEdges.filter(
(e) =>
visible.has(e.source) &&
visible.has(e.target) &&
!disabledEdgeLabels.has(getEdgeLabel(e)),
);
}, [allEdges, filteredNodes, disabledEdgeLabels]);
const searchMatchIds = useMemo(() => {
const keyword = searchKeyword.trim().toLowerCase();
if (!keyword) return null;
const matches = new Set<string>();
for (const n of filteredNodes) {
if (getNodeTitle(n).toLowerCase().includes(keyword)) matches.add(n.id);
}
return matches;
}, [searchKeyword, filteredNodes]);
const topNodes = useMemo(
() =>
[...allNodes]
.map((node) => ({ node, degree: degrees.get(node.id) ?? 0 }))
.sort((a, b) => b.degree - a.degree)
.slice(0, 8),
[allNodes, degrees],
);
const selectedNode = selectedNodeId ? (nodeById.get(selectedNodeId) ?? null) : null;
const selectedNeighbors = useMemo(() => {
if (!selectedNodeId) return [];
const items: Array<{ node: (typeof allNodes)[number]; relation: string }> = [];
const seen = new Set<string>();
for (const e of filteredEdges) {
const otherId =
e.source === selectedNodeId
? e.target
: e.target === selectedNodeId
? e.source
: null;
if (!otherId || seen.has(otherId)) continue;
const other = nodeById.get(otherId);
if (!other) continue;
seen.add(otherId);
items.push({ node: other, relation: getEdgeLabel(e) });
}
return items.sort(
(a, b) => (degrees.get(b.node.id) ?? 0) - (degrees.get(a.node.id) ?? 0),
);
}, [selectedNodeId, filteredEdges, nodeById, degrees, allNodes]);
// Note detail for note-type nodes (summary / tags / timestamps / status).
const noteId =
selectedNode && (selectedNode.type || "").toLowerCase() === "note"
? selectedNode.id
: undefined;
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
// --------------------------------------------------------------- handlers
const handleSelectNode = useCallback((id: string | null) => {
setSelectedNodeId(id);
}, []);
const handlePickNode = useCallback((id: string) => {
setSelectedNodeId(id);
canvasRef.current?.focusNode(id);
}, []);
const handleSearchEnter = useCallback(() => {
if (!searchMatchIds || searchMatchIds.size === 0) return;
const first = searchMatchIds.values().next().value;
if (first) handlePickNode(first);
}, [searchMatchIds, handlePickNode]);
const handleToggleNodeKind = useCallback((kind: NodeKind) => {
setDisabledNodeKinds((prev) => {
const next = new Set(prev);
if (next.has(kind)) next.delete(kind);
else next.add(kind);
return next;
});
}, []);
const handleToggleEdgeLabel = useCallback((label: string) => {
setDisabledEdgeLabels((prev) => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
return next;
});
}, []);
const handleHideNode = useCallback((id: string) => {
setHiddenNodeIds((prev) => new Set(prev).add(id));
setSelectedNodeId((current) => (current === id ? null : current));
}, []);
const handleRestoreHidden = useCallback(() => {
setHiddenNodeIds(new Set());
}, []);
const handleRefresh = useCallback(() => {
void refetch().then((result) => {
if (result.isError) {
toast.error(
result.error instanceof Error ? result.error.message : "刷新图谱失败",
);
}
});
}, [refetch]);
const handleFullscreen = useCallback(() => {
const el = pageRef.current;
if (!el) return;
if (document.fullscreenElement) void document.exitFullscreen();
else void el.requestFullscreen();
}, []);
const handleViewSource = useCallback(
(id: string) => {
navigate(`/page/workspace/knowledge/${encodeURIComponent(id)}`);
},
[navigate],
);
// ---------------------------------------------------------------- overlay
let overlay: React.ReactNode = null;
if (isLoading) {
overlay = (
<div className="kg-canvas-status">
<Loader2 size={26} className="animate-spin" />
<div className="kg-canvas-status-title">正在加载知识图谱...</div>
</div>
);
} else if (isError) {
overlay = (
<div className="kg-canvas-status">
<Network size={30} />
<div className="kg-canvas-status-title">图谱加载失败</div>
<div className="kg-canvas-status-desc">
{error instanceof Error ? error.message : "请求失败,请稍后重试。"}
</div>
<button type="button" className="kg-icon-button" onClick={handleRefresh}>
重试
</button>
</div>
);
} else if (allNodes.length === 0) {
overlay = (
<div className="kg-canvas-status">
<Network size={30} />
<div className="kg-canvas-status-title">暂无知识图谱数据</div>
<div className="kg-canvas-status-desc">
请先导入知识文档,或从知识库中抽取实体、标签与关系后再查看图谱。
</div>
</div>
);
} else if (filteredNodes.length === 0) {
overlay = (
<div className="kg-canvas-status">
<Network size={30} />
<div className="kg-canvas-status-title">当前筛选没有可显示的节点</div>
<div className="kg-canvas-status-desc">
请在左侧勾选更多节点类型,或恢复已隐藏的节点。
</div>
</div>
);
}
const toolbar = (
<>
<div className="kg-floating-pill">
<span className="kg-pill-label">布局</span>
<button
type="button"
className={`kg-pill-button${layoutType === "force" ? " kg-pill-button-active" : ""}`}
onClick={() => setLayoutType("force")}
>
力导向
</button>
<button
type="button"
className={`kg-pill-button${layoutType === "circular" ? " kg-pill-button-active" : ""}`}
onClick={() => setLayoutType("circular")}
>
环形
</button>
</div>
<div className="kg-floating-pill">
<button
type="button"
className={`kg-pill-button${showLabels ? " kg-pill-button-active" : ""}`}
onClick={() => setShowLabels((v) => !v)}
>
<Tags size={13} /> 标签 {showLabels ? "开" : "关"}
</button>
</div>
</>
);
return (
<div className="flex size-full min-h-0 flex-col">
<header className="flex items-center gap-2 border-b p-3">
<Button
variant="ghost"
size="sm"
onClick={() => navigate("/page/workspace/knowledge")}
>
<ArrowLeft className="size-4" /> 返回
</Button>
<h1 className="text-base font-semibold">知识图谱</h1>
{stats ? (
<span className="text-muted-foreground text-xs">
{stats.nodes} 个节点 · {stats.edges} 条关系
</span>
) : null}
<Button
variant="outline"
size="sm"
className="ml-auto"
onClick={() => void regenerate()}
disabled={exportGraph.isPending}
>
{exportGraph.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
重新生成
</Button>
</header>
<div className="min-h-0 flex-1">
{src ? (
<iframe
src={src}
title="知识图谱"
className="size-full border-0"
sandbox="allow-scripts allow-same-origin"
<div className="kg-page" ref={pageRef}>
<GraphHeader
nodeCount={filteredNodes.length}
edgeCount={filteredEdges.length}
totalNodes={allNodes.length}
totalEdges={allEdges.length}
refreshing={isFetching}
showSidebar={showSidebar}
showDetail={showDetail}
onBack={() => navigate("/page/workspace/knowledge")}
onRelayout={() => canvasRef.current?.relayout()}
onFitView={() => canvasRef.current?.fitView()}
onZoomIn={() => canvasRef.current?.zoomIn()}
onZoomOut={() => canvasRef.current?.zoomOut()}
onExport={() => canvasRef.current?.exportImage()}
onFullscreen={handleFullscreen}
onRefresh={handleRefresh}
onToggleSidebar={() => setShowSidebar((v) => !v)}
onToggleDetail={() => setShowDetail((v) => !v)}
/>
<div
className={[
"kg-body",
showSidebar ? "" : "kg-hide-left",
showDetail ? "" : "kg-hide-right",
]
.filter(Boolean)
.join(" ")}
>
{showSidebar ? (
<GraphSidebar
searchKeyword={searchKeyword}
searchMatchCount={searchMatchIds ? searchMatchIds.size : null}
nodeKindCounts={nodeKindCounts}
edgeLabelCounts={edgeLabelCounts}
disabledNodeKinds={disabledNodeKinds}
disabledEdgeLabels={disabledEdgeLabels}
stats={stats}
topNodes={topNodes}
hiddenCount={hiddenNodeIds.size}
onSearchChange={setSearchKeyword}
onSearchEnter={handleSearchEnter}
onToggleNodeKind={handleToggleNodeKind}
onToggleEdgeLabel={handleToggleEdgeLabel}
onRestoreHidden={handleRestoreHidden}
onPickNode={handlePickNode}
/>
) : (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
<Loader2 className="mr-2 size-4 animate-spin" /> 生成图谱中...
</div>
)}
) : null}
<GraphCanvas
ref={canvasRef}
nodes={filteredNodes}
edges={filteredEdges}
degrees={degrees}
selectedNodeId={selectedNodeId}
searchMatchIds={searchMatchIds}
layoutType={layoutType}
showLabels={showLabels}
onSelectNode={handleSelectNode}
onTooltip={setTooltip}
onZoomChange={setZoomLevel}
toolbar={toolbar}
overlay={overlay}
/>
{showDetail ? (
<GraphDetailPanel
node={selectedNode}
degree={selectedNodeId ? (degrees.get(selectedNodeId) ?? 0) : 0}
neighbors={selectedNeighbors}
note={noteId ? note : undefined}
noteLoading={Boolean(noteId) && noteLoading}
onViewSource={handleViewSource}
onLocate={(id) => canvasRef.current?.focusNode(id)}
onHide={handleHideNode}
onPickNeighbor={handlePickNode}
/>
) : null}
</div>
<GraphFooter
nodeKindCounts={nodeKindCounts}
edgeLabels={[...edgeLabelCounts.keys()]}
zoomLevel={zoomLevel}
selectedTitle={selectedNode ? getNodeTitle(selectedNode) : null}
visibleNodes={filteredNodes.length}
visibleEdges={filteredEdges.length}
/>
<GraphTooltip tooltip={tooltip} />
</div>
);
}