- 知识图谱画布、侧边栏、详情面板交互优化,新增图谱相关 API 与 hooks - Agent 卡片与详情面板调整,对话页面重构 - 线程元数据持久化增强(base/memory/sql),knowledge 仓储与导出补充 - 网关路由 agents/knowledge/threads 同步更新 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
293 lines
10 KiB
TypeScript
293 lines
10 KiB
TypeScript
import { ChevronDown, ChevronRight, Eraser, Search, X } from "lucide-react";
|
|
import { useState } from "react";
|
|
|
|
import type { GraphBlacklistItem } from "@/core/knowledge/api";
|
|
|
|
import type { GraphNode, GraphStats, NodeKind } from "./graphTypes";
|
|
import {
|
|
getEdgeInfo,
|
|
getNodeTitle,
|
|
NODE_KIND_META,
|
|
NODE_KIND_ORDER,
|
|
} from "./graphUtils";
|
|
|
|
interface GraphSidebarProps {
|
|
searchKeyword: string;
|
|
searchMatchCount: number | null;
|
|
nodeKindCounts: Map<NodeKind, number>;
|
|
edgeLabelCounts: Map<string, number>;
|
|
disabledNodeKinds: Set<NodeKind>;
|
|
disabledEdgeLabels: Set<string>;
|
|
stats: GraphStats;
|
|
topNodes: Array<{ node: GraphNode; degree: number }>;
|
|
hiddenCount: number;
|
|
blacklistItems: GraphBlacklistItem[];
|
|
blacklistPending: boolean;
|
|
junkCandidateCount: number;
|
|
onSearchChange: (keyword: string) => void;
|
|
onSearchEnter: () => void;
|
|
onToggleNodeKind: (kind: NodeKind) => void;
|
|
onToggleEdgeLabel: (label: string) => void;
|
|
onRestoreHidden: () => void;
|
|
onRemoveBlacklist: (entryId: string) => void;
|
|
onCleanJunk: () => void;
|
|
onPickNode: (id: string) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
type SectionKey =
|
|
| "nodeTypes"
|
|
| "edgeTypes"
|
|
| "graphStats"
|
|
| "coreNodes"
|
|
| "blacklist";
|
|
|
|
interface FilterSectionProps {
|
|
title: string;
|
|
extra?: string;
|
|
collapsed: boolean;
|
|
onToggle: () => void;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
function FilterSection(props: FilterSectionProps) {
|
|
return (
|
|
<section className="kg-filter-section">
|
|
<button
|
|
type="button"
|
|
className="kg-filter-section-header"
|
|
onClick={props.onToggle}
|
|
aria-expanded={!props.collapsed}
|
|
>
|
|
<span>{props.title}</span>
|
|
<span className="kg-filter-section-extra">
|
|
{props.extra}
|
|
{props.collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
|
</span>
|
|
</button>
|
|
{!props.collapsed ? (
|
|
<div className="kg-filter-section-body">{props.children}</div>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function GraphSidebar(props: GraphSidebarProps) {
|
|
// 节点类型 / 关系类型默认收起,统计与核心节点默认展开。
|
|
const [collapsedSections, setCollapsedSections] = useState<
|
|
Record<SectionKey, boolean>
|
|
>({
|
|
nodeTypes: true,
|
|
edgeTypes: true,
|
|
graphStats: false,
|
|
coreNodes: false,
|
|
blacklist: true,
|
|
});
|
|
|
|
const toggleFilterSection = (key: SectionKey) => {
|
|
setCollapsedSections((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
};
|
|
|
|
const kinds = NODE_KIND_ORDER.filter((k) => (props.nodeKindCounts.get(k) ?? 0) > 0);
|
|
const edgeLabels = [...props.edgeLabelCounts.entries()].sort((a, b) => b[1] - a[1]);
|
|
|
|
return (
|
|
<aside className="kg-panel kg-sidebar">
|
|
<div className="kg-panel-header">
|
|
<span className="kg-panel-title">搜索与筛选</span>
|
|
<div className="kg-panel-header-actions">
|
|
{props.hiddenCount > 0 ? (
|
|
<button
|
|
type="button"
|
|
className="kg-text-button"
|
|
onClick={props.onRestoreHidden}
|
|
>
|
|
恢复 {props.hiddenCount} 个隐藏节点
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
className="kg-panel-close-btn"
|
|
title="收起筛选面板"
|
|
onClick={props.onClose}
|
|
>
|
|
<X size={15} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="kg-panel-content">
|
|
<div className="kg-search-wrap">
|
|
<Search size={14} />
|
|
<input
|
|
className="kg-search"
|
|
placeholder="搜索节点标题..."
|
|
value={props.searchKeyword}
|
|
onChange={(e) => props.onSearchChange(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") props.onSearchEnter();
|
|
}}
|
|
/>
|
|
</div>
|
|
{props.searchMatchCount !== null ? (
|
|
<div
|
|
className={`kg-search-hint${props.searchMatchCount === 0 ? " kg-search-hint-warn" : ""}`}
|
|
>
|
|
{props.searchMatchCount === 0
|
|
? "没有匹配的节点,换个关键词试试"
|
|
: `匹配 ${props.searchMatchCount} 个节点,回车定位`}
|
|
</div>
|
|
) : null}
|
|
|
|
<FilterSection
|
|
title="节点类型"
|
|
extra={`${kinds.length} 类`}
|
|
collapsed={collapsedSections.nodeTypes}
|
|
onToggle={() => toggleFilterSection("nodeTypes")}
|
|
>
|
|
{kinds.map((kind) => (
|
|
<label key={kind} className="kg-check-row">
|
|
<span className="kg-check-row-main">
|
|
<input
|
|
type="checkbox"
|
|
checked={!props.disabledNodeKinds.has(kind)}
|
|
onChange={() => props.onToggleNodeKind(kind)}
|
|
/>
|
|
<span
|
|
className="kg-legend-dot"
|
|
style={{ background: NODE_KIND_META[kind].color }}
|
|
/>
|
|
<span className="kg-check-label">{NODE_KIND_META[kind].label}</span>
|
|
</span>
|
|
<span className="kg-count">{props.nodeKindCounts.get(kind)}</span>
|
|
</label>
|
|
))}
|
|
</FilterSection>
|
|
|
|
{edgeLabels.length > 0 ? (
|
|
<FilterSection
|
|
title="关系类型"
|
|
extra={`${edgeLabels.length} 类`}
|
|
collapsed={collapsedSections.edgeTypes}
|
|
onToggle={() => toggleFilterSection("edgeTypes")}
|
|
>
|
|
{edgeLabels.map(([label, count]) => {
|
|
const info = getEdgeInfo(label);
|
|
return (
|
|
<label key={label} className="kg-check-row">
|
|
<span className="kg-check-row-main">
|
|
<input
|
|
type="checkbox"
|
|
checked={!props.disabledEdgeLabels.has(label)}
|
|
onChange={() => props.onToggleEdgeLabel(label)}
|
|
/>
|
|
<span
|
|
className="kg-legend-line"
|
|
style={{
|
|
borderTopColor: info.color,
|
|
borderTopStyle: info.dashed ? "dashed" : "solid",
|
|
}}
|
|
/>
|
|
<span className="kg-check-label">{info.zh}</span>
|
|
</span>
|
|
<span className="kg-count">{count}</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</FilterSection>
|
|
) : null}
|
|
|
|
<FilterSection
|
|
title="图谱统计"
|
|
collapsed={collapsedSections.graphStats}
|
|
onToggle={() => toggleFilterSection("graphStats")}
|
|
>
|
|
<div className="kg-stats-grid">
|
|
<div className="kg-stat-card">
|
|
<div className="kg-stat-card-value">{props.stats.nodes}</div>
|
|
<div className="kg-stat-card-label">节点</div>
|
|
</div>
|
|
<div className="kg-stat-card">
|
|
<div className="kg-stat-card-value">{props.stats.edges}</div>
|
|
<div className="kg-stat-card-label">关系</div>
|
|
</div>
|
|
<div className="kg-stat-card">
|
|
<div className="kg-stat-card-value">
|
|
{props.stats.avgDegree.toFixed(2)}
|
|
</div>
|
|
<div className="kg-stat-card-label">平均度</div>
|
|
</div>
|
|
<div className="kg-stat-card">
|
|
<div className="kg-stat-card-value">{props.stats.largestComponent}</div>
|
|
<div className="kg-stat-card-label">最大连通分量</div>
|
|
</div>
|
|
</div>
|
|
</FilterSection>
|
|
|
|
{props.topNodes.length > 0 ? (
|
|
<FilterSection
|
|
title="核心节点"
|
|
extra={`${props.topNodes.length} 个`}
|
|
collapsed={collapsedSections.coreNodes}
|
|
onToggle={() => toggleFilterSection("coreNodes")}
|
|
>
|
|
{props.topNodes.map(({ node, degree }, i) => (
|
|
<div
|
|
key={node.id}
|
|
className="kg-rank-item"
|
|
onClick={() => props.onPickNode(node.id)}
|
|
>
|
|
<span className="kg-rank-index">{i + 1}</span>
|
|
<span className="kg-rank-title">{getNodeTitle(node)}</span>
|
|
<span className="kg-rank-degree">{degree} 度</span>
|
|
</div>
|
|
))}
|
|
</FilterSection>
|
|
) : null}
|
|
|
|
<FilterSection
|
|
title="图谱黑名单"
|
|
extra={`${props.blacklistItems.length} 个`}
|
|
collapsed={collapsedSections.blacklist}
|
|
onToggle={() => toggleFilterSection("blacklist")}
|
|
>
|
|
<div className="kg-search-hint">
|
|
黑名单中的主题 / 节点不会出现在图谱中。可在节点详情里加入黑名单。
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="kg-icon-button"
|
|
disabled={props.blacklistPending || props.junkCandidateCount === 0}
|
|
title="将单字符、纯数字符号、通用泛词等低价值主题批量加入黑名单"
|
|
onClick={props.onCleanJunk}
|
|
>
|
|
<Eraser size={13} /> 清理低价值主题
|
|
{props.junkCandidateCount > 0 ? `(${props.junkCandidateCount})` : ""}
|
|
</button>
|
|
{props.blacklistItems.length === 0 ? (
|
|
<div className="kg-search-hint">暂无黑名单条目。</div>
|
|
) : (
|
|
props.blacklistItems.map((item) => (
|
|
<div key={item.id} className="kg-check-row">
|
|
<span className="kg-check-row-main">
|
|
<span className="kg-check-label" title={item.label}>
|
|
{item.label}
|
|
</span>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="kg-text-button"
|
|
disabled={props.blacklistPending}
|
|
title="移出黑名单,重新显示该节点"
|
|
onClick={() => props.onRemoveBlacklist(item.id)}
|
|
>
|
|
移除
|
|
</button>
|
|
</div>
|
|
))
|
|
)}
|
|
</FilterSection>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|