feat: update knowledge and agent UI

This commit is contained in:
吉鑫升 committed 2026-06-11 16:18:38 +08:00
1 parent bebe4feccf
commit 7efd811e75
27 files changed
+2949 -782

No files matched your search

@@ -20,6 +20,7 @@ import type {
import {
applyCircularLayout,
ForceSimulation,
getEdgeId,
getEdgeInfo,
getEdgeLabel,
getLinkDistance,
@@ -56,8 +57,20 @@ interface GraphCanvasProps {
onSelectNode: (id: string | null) => void;
onTooltip: (tooltip: TooltipState | null) => void;
onZoomChange: (k: number) => void;
/**
* Explore-mode rendering context; null/undefined renders the normal full view.
* preview = no root picked yet: nodes are shown faded as pickable起点 candidates.
*/
explore?: {
preview: boolean;
rootId: string | null;
nodeDepthMap: Map<string, number>;
expandedNodeIds: Set<string>;
} | null;
/** Floating pills rendered top-left over the canvas. */
toolbar?: ReactNode;
/** Small floating hint rendered bottom-center over the canvas. */
hint?: ReactNode;
/** Centered overlay (empty / loading states). */
overlay?: ReactNode;
}
@@ -153,7 +166,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
if (!source || !target) continue;
const label = getEdgeLabel(e);
simEdges.push({
id: e.id ?? `${e.source}->${e.target}:${label}`,
id: getEdgeId(e),
source,
target,
label,
@@ -213,7 +226,13 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
// Redraw on highlight-affecting prop changes.
useEffect(() => {
requestDraw();
}, [props.selectedNodeId, props.searchMatchIds, props.showLabels, requestDraw]);
}, [
props.selectedNodeId,
props.searchMatchIds,
props.showLabels,
props.explore,
requestDraw,
]);
// ------------------------------------------------------------ transform
@@ -361,6 +380,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
const neighbors = activeId ? adjacencyRef.current.get(activeId) : undefined;
const search = p.searchMatchIds;
const k = t.k || 1;
const explore = p.explore;
// ---- edges
for (const e of sim.edges) {
@@ -373,6 +393,13 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
alpha = search.has(e.source.id) || search.has(e.target.id) ? 0.75 : 0.06;
else alpha = 0.38;
if (explore && !explore.preview) {
const sourceDepth = explore.nodeDepthMap.get(e.source.id) ?? 0;
const targetDepth = explore.nodeDepthMap.get(e.target.id) ?? 0;
const edgeDepth = Math.max(sourceDepth, targetDepth);
alpha *= Math.max(0.48, 1 - edgeDepth * 0.12);
}
ctx.globalAlpha = alpha;
ctx.strokeStyle = e.info.color;
ctx.lineWidth = (touchesActive ? 2.4 : 1.2) / k;
@@ -405,33 +432,73 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
const isActive = n.id === activeId;
const isSelected = n.id === p.selectedNodeId;
const isNeighbor = neighbors?.has(n.id) ?? false;
const depth = explore?.nodeDepthMap.get(n.id);
const isExploreRoot = explore?.rootId === n.id;
const isExpanded = explore?.expandedNodeIds.has(n.id) ?? false;
let alpha: number;
if (activeId) alpha = isActive || isNeighbor ? 1 : 0.14;
else if (search) alpha = search.has(n.id) ? 1 : 0.14;
else alpha = 1;
const r = n.radius * (n.id === hover?.id ? 1.15 : 1);
let baseRadius = n.radius;
let labelByExplore = false;
let strokeWidth = 1.4 / k;
let strokeColor = "rgba(2, 6, 23, 0.55)";
let fillColor = n.color;
if (explore?.preview) {
alpha *= 0.42;
baseRadius = Math.max(8, n.radius * 0.86);
labelByExplore = isActive || isSelected;
} else if (explore && depth != null) {
baseRadius = depth === 0 ? 34 : Math.max(14, 28 - depth * 4);
alpha *= Math.max(0.45, 1 - depth * 0.12);
labelByExplore = depth <= 2 || isActive || isSelected;
strokeWidth = (depth === 0 ? 4 : isExpanded ? 3 : 1.5) / k;
strokeColor = depth === 0 ? "#e0f2fe" : isExpanded ? "#7dd3fc" : "rgba(2, 6, 23, 0.55)";
if (isExploreRoot) fillColor = "#38bdf8";
}
const r = baseRadius * (n.id === hover?.id ? 1.15 : 1);
ctx.globalAlpha = alpha;
// Glow for hubs and the active node.
if (alpha > 0.5 && (isActive || isSelected || n.degree >= labelThreshold)) {
ctx.shadowColor = n.color;
ctx.shadowBlur = isActive || isSelected ? 22 : 12;
if (
alpha > 0.5 &&
(isActive || isSelected || isExploreRoot || isExpanded || n.degree >= labelThreshold)
) {
ctx.shadowColor = fillColor;
ctx.shadowBlur = isActive || isSelected || isExploreRoot ? 24 : 12;
} else {
ctx.shadowBlur = 0;
}
ctx.beginPath();
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
ctx.fillStyle = n.color;
ctx.fillStyle = fillColor;
ctx.fill();
ctx.shadowBlur = 0;
// Rim.
ctx.lineWidth = 1.4 / k;
ctx.strokeStyle = "rgba(2, 6, 23, 0.55)";
ctx.lineWidth = strokeWidth;
ctx.strokeStyle = strokeColor;
ctx.stroke();
if (isExploreRoot) {
ctx.beginPath();
ctx.arc(n.x, n.y, r + 8 / k, 0, Math.PI * 2);
ctx.lineWidth = 1.4 / k;
ctx.strokeStyle = "rgba(125, 211, 252, 0.9)";
ctx.stroke();
} else if (isExpanded) {
ctx.beginPath();
ctx.arc(n.x + r * 0.58, n.y - r * 0.58, Math.max(4 / k, 3), 0, Math.PI * 2);
ctx.fillStyle = "#7dd3fc";
ctx.fill();
ctx.lineWidth = 1 / k;
ctx.strokeStyle = "rgba(2, 6, 23, 0.8)";
ctx.stroke();
}
// Selection: double ring.
if (isSelected) {
ctx.beginPath();
@@ -465,6 +532,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
isActive ||
isSelected ||
isNeighbor ||
labelByExplore ||
(search?.has(n.id) ?? false) ||
(p.showLabels && n.degree >= labelThreshold && k >= 0.45);
if (wantsLabel && alpha > 0.5) {
@@ -775,6 +843,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
{props.toolbar ? (
<div className="kg-canvas-toolbar">{props.toolbar}</div>
) : null}
{props.hint}
{minimapVisible ? (
<canvas ref={minimapRef} className="kg-minimap" width={180} height={120} />
) : null}
@@ -1,4 +1,13 @@
import { Crosshair, EyeOff, FileText, Loader2, MousePointerClick } from "lucide-react";
import {
Compass,
Crosshair,
EyeOff,
FileText,
Loader2,
MousePointerClick,
Waypoints,
X,
} from "lucide-react";
import type { KnowledgeNote } from "@/core/knowledge/types";
@@ -37,10 +46,20 @@ interface GraphDetailPanelProps {
/** Note detail (only for note-type nodes; undefined while absent/loading). */
note: KnowledgeNote | undefined;
noteLoading: boolean;
/** Explore-mode context; null when in full mode. */
explore: {
/** Depth of this node in the explore graph; null = not yet visible. */
depth: number | null;
expanded: boolean;
isRoot: boolean;
} | null;
onViewSource: (noteId: string) => void;
onLocate: (id: string) => void;
onHide: (id: string) => void;
onPickNeighbor: (id: string) => void;
onExpandNode: (id: string) => void;
onSetExploreRoot: (id: string) => void;
onClose: () => void;
}
function formatTime(value: string | null | undefined): string {
@@ -57,6 +76,14 @@ export function GraphDetailPanel(props: GraphDetailPanelProps) {
<aside className="kg-panel kg-detail">
<div className="kg-panel-header">
<span className="kg-panel-title">节点详情</span>
<button
type="button"
className="kg-panel-close-btn"
title="收起详情面板"
onClick={props.onClose}
>
<X size={15} />
</button>
</div>
{!node ? (
<div className="kg-detail-empty">
@@ -104,6 +131,28 @@ function DetailBody(props: GraphDetailPanelProps & { node: GraphNode }) {
>
度数 {props.degree}
</span>
{props.explore && props.explore.depth !== null ? (
<>
<span
className="kg-detail-type"
style={{ background: "rgba(56, 189, 248, 0.14)", color: "#7dd3fc" }}
>
{props.explore.isRoot
? "探索起点"
: `第 ${props.explore.depth} 层`}
</span>
<span
className="kg-detail-type"
style={
props.explore.expanded
? { background: "rgba(52, 211, 153, 0.14)", color: "#6ee7b7" }
: { background: "rgba(251, 191, 36, 0.14)", color: "#fcd34d" }
}
>
{props.explore.expanded ? "已展开" : "可展开"}
</span>
</>
) : null}
</div>
{props.noteLoading ? (
@@ -171,6 +220,31 @@ function DetailBody(props: GraphDetailPanelProps & { node: GraphNode }) {
>
<EyeOff size={14} /> 隐藏节点
</button>
{props.explore ? (
<>
<button
type="button"
className="kg-icon-button"
disabled={props.explore.depth === null || props.explore.expanded}
title={
props.explore.expanded
? "该节点的一跳邻居已全部展开"
: "展开该节点的一跳邻居"
}
onClick={() => props.onExpandNode(node.id)}
>
<Waypoints size={14} /> 展开一跳邻居
</button>
<button
type="button"
className="kg-icon-button"
title="清空当前探索路径,以此节点为新起点"
onClick={() => props.onSetExploreRoot(node.id)}
>
<Compass size={14} /> 以此为新起点
</button>
</>
) : null}
</div>
<h3 className="kg-detail-section-title">
@@ -3,8 +3,6 @@ import {
Download,
Loader2,
Maximize2,
PanelLeft,
PanelRight,
RefreshCw,
Scan,
Shuffle,
@@ -18,8 +16,6 @@ interface GraphHeaderProps {
totalNodes: number;
totalEdges: number;
refreshing: boolean;
showSidebar: boolean;
showDetail: boolean;
onBack: () => void;
onRelayout: () => void;
onFitView: () => void;
@@ -28,8 +24,6 @@ interface GraphHeaderProps {
onExport: () => void;
onFullscreen: () => void;
onRefresh: () => void;
onToggleSidebar: () => void;
onToggleDetail: () => void;
}
export function GraphHeader(props: GraphHeaderProps) {
@@ -124,23 +118,6 @@ export function GraphHeader(props: GraphHeaderProps) {
<RefreshCw size={15} />
)}
</button>
<div className="kg-header-divider" />
<button
type="button"
className={`kg-icon-button${props.showSidebar ? " kg-icon-button-active" : ""}`}
title="筛选面板"
onClick={props.onToggleSidebar}
>
<PanelLeft size={15} />
</button>
<button
type="button"
className={`kg-icon-button${props.showDetail ? " kg-icon-button-active" : ""}`}
title="详情面板"
onClick={props.onToggleDetail}
>
<PanelRight size={15} />
</button>
</div>
</header>
);
@@ -1,4 +1,5 @@
import { Search } from "lucide-react";
import { ChevronDown, ChevronRight, Search, X } from "lucide-react";
import { useState } from "react";
import type { GraphNode, GraphStats, NodeKind } from "./graphTypes";
import {
@@ -24,9 +25,56 @@ interface GraphSidebarProps {
onToggleEdgeLabel: (label: string) => void;
onRestoreHidden: () => void;
onPickNode: (id: string) => void;
onClose: () => void;
}
type SectionKey = "nodeTypes" | "edgeTypes" | "graphStats" | "coreNodes";
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,
});
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]);
@@ -34,15 +82,25 @@ export function GraphSidebar(props: GraphSidebarProps) {
<aside className="kg-panel kg-sidebar">
<div className="kg-panel-header">
<span className="kg-panel-title">搜索与筛选</span>
{props.hiddenCount > 0 ? (
<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-text-button"
onClick={props.onRestoreHidden}
className="kg-panel-close-btn"
title="收起筛选面板"
onClick={props.onClose}
>
恢复 {props.hiddenCount} 个隐藏节点
<X size={15} />
</button>
) : null}
</div>
</div>
<div className="kg-panel-content">
<div className="kg-search-wrap">
@@ -67,8 +125,12 @@ export function GraphSidebar(props: GraphSidebarProps) {
</div>
) : null}
<section className="kg-filter-section">
<div className="kg-filter-title">节点类型</div>
<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">
@@ -86,11 +148,15 @@ export function GraphSidebar(props: GraphSidebarProps) {
<span className="kg-count">{props.nodeKindCounts.get(kind)}</span>
</label>
))}
</section>
</FilterSection>
{edgeLabels.length > 0 ? (
<section className="kg-filter-section">
<div className="kg-filter-title">关系类型</div>
<FilterSection
title="关系类型"
extra={`${edgeLabels.length} 类`}
collapsed={collapsedSections.edgeTypes}
onToggle={() => toggleFilterSection("edgeTypes")}
>
{edgeLabels.map(([label, count]) => {
const info = getEdgeInfo(label);
return (
@@ -114,11 +180,14 @@ export function GraphSidebar(props: GraphSidebarProps) {
</label>
);
})}
</section>
</FilterSection>
) : null}
<section className="kg-filter-section">
<div className="kg-filter-title">图谱统计</div>
<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>
@@ -139,11 +208,15 @@ export function GraphSidebar(props: GraphSidebarProps) {
<div className="kg-stat-card-label">最大连通分量</div>
</div>
</div>
</section>
</FilterSection>
{props.topNodes.length > 0 ? (
<section className="kg-filter-section">
<div className="kg-filter-title">核心节点</div>
<FilterSection
title="核心节点"
extra={`${props.topNodes.length} 个`}
collapsed={collapsedSections.coreNodes}
onToggle={() => toggleFilterSection("coreNodes")}
>
{props.topNodes.map(({ node, degree }, i) => (
<div
key={node.id}
@@ -155,7 +228,7 @@ export function GraphSidebar(props: GraphSidebarProps) {
<span className="kg-rank-degree">{degree} 度</span>
</div>
))}
</section>
</FilterSection>
) : null}
</div>
</aside>
@@ -205,12 +205,39 @@
flex-shrink: 0;
}
.kg-panel-header-actions {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.kg-panel-title {
font-size: 14px;
font-weight: 700;
color: var(--kg-text-main);
}
.kg-panel-close-btn {
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(15, 23, 42, 0.65);
color: #94a3b8;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
}
.kg-panel-close-btn:hover {
color: #ffffff;
background: rgba(56, 189, 248, 0.14);
border-color: rgba(56, 189, 248, 0.35);
}
.kg-panel-content {
padding: 14px;
overflow-y: auto;
@@ -267,15 +294,44 @@
}
.kg-filter-section {
margin-top: 18px;
border-top: 1px solid rgba(148, 163, 184, 0.1);
padding: 10px 0;
}
.kg-filter-title {
font-size: 12px;
.kg-filter-section:first-of-type {
margin-top: 12px;
}
.kg-filter-section-header {
width: 100%;
height: 34px;
border: none;
background: transparent;
color: #cbd5e1;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding: 0;
font-size: 13px;
font-weight: 700;
color: var(--kg-text-secondary);
margin-bottom: 8px;
letter-spacing: 0.02em;
}
.kg-filter-section-header:hover {
color: #ffffff;
}
.kg-filter-section-extra {
color: #64748b;
font-size: 12px;
font-weight: 400;
display: inline-flex;
align-items: center;
gap: 4px;
}
.kg-filter-section-body {
padding-top: 8px;
}
.kg-check-row {
@@ -421,6 +477,14 @@
overflow: hidden;
min-width: 0;
min-height: 0;
width: 100%;
height: 100%;
}
.kg-canvas-shell {
position: relative;
min-width: 0;
min-height: 0;
}
.kg-canvas {
@@ -485,6 +549,82 @@
color: #ffffff;
}
.kg-explore-button {
height: 32px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(15, 23, 42, 0.72);
color: #cbd5e1;
font-size: 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
backdrop-filter: blur(12px);
}
.kg-explore-button:hover {
background: rgba(56, 189, 248, 0.14);
color: #ffffff;
}
.kg-explore-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.kg-canvas-hint {
position: absolute;
left: 50%;
bottom: 22px;
z-index: 11;
transform: translateX(-50%);
max-width: min(560px, calc(100% - 48px));
padding: 8px 14px;
border-radius: 999px;
border: 1px solid rgba(56, 189, 248, 0.22);
background: rgba(15, 23, 42, 0.78);
color: #bae6fd;
font-size: 12px;
text-align: center;
backdrop-filter: blur(12px);
pointer-events: none;
}
.kg-side-rail-toggle {
position: absolute;
top: 50%;
z-index: 20;
width: 32px;
min-height: 88px;
border-radius: 12px;
border: 1px solid rgba(56, 189, 248, 0.28);
background: rgba(15, 23, 42, 0.86);
color: #bae6fd;
display: flex;
align-items: center;
justify-content: center;
writing-mode: vertical-rl;
letter-spacing: 4px;
cursor: pointer;
backdrop-filter: blur(12px);
transform: translateY(-50%);
}
.kg-side-rail-toggle-left {
left: 12px;
}
.kg-side-rail-toggle-right {
right: 12px;
}
.kg-side-rail-toggle:hover {
background: rgba(56, 189, 248, 0.16);
color: #ffffff;
}
.kg-minimap {
position: absolute;
right: 14px;
@@ -43,6 +43,18 @@ export type NodeKind =
export type LayoutType = "force" | "circular";
/** Graph display mode: full graph vs. click-to-expand exploration. */
export type GraphMode = "full" | "explore";
/** One incremental expansion step in explore mode (for undo / rebuild). */
export interface ExploreStep {
nodeId: string;
depth: number;
addedNodeIds: string[];
addedEdgeIds: string[];
timestamp: number;
}
/** Style metadata of an edge label. */
export interface EdgeInfo {
color: string;
@@ -76,6 +76,10 @@ export function truncateLabel(text: string, max = 14): string {
export const getEdgeLabel = (edge: GraphEdge): string =>
edge.label || edge.type || edge.relation || "related";
/** Stable edge identity shared by explore-mode visibility sets and the canvas. */
export const getEdgeId = (edge: GraphEdge): string =>
edge.id ?? `${edge.source}->${edge.target}:${getEdgeLabel(edge)}`;
const KNOWN_EDGE_INFO: Record<string, EdgeInfo> = {
related: { color: "#94a3b8", zh: "相关", dashed: false },
links: { color: "#a78bfa", zh: "引用", dashed: true },