From ec09d4e3bd4794af40f23332b5aa233f9e3ae79e Mon Sep 17 00:00:00 2001 From: balibabu Date: Fri, 7 Aug 2026 17:06:19 +0800 Subject: [PATCH] Feat: Search for timeline nodes. (#17996) --- .../components/structure-graph/adapters.ts | 31 ------------------- .../representation-renderer.tsx | 21 ++++--------- .../hooks/use-graph-entity-search.ts | 19 +++++++++++- .../hooks/use-selected-template.ts | 9 +++++- web/src/pages/chunk/representation/index.tsx | 12 +++---- .../dataset/compilation/utils/skill-tree.ts | 6 ++-- 6 files changed, 38 insertions(+), 60 deletions(-) diff --git a/web/src/components/structure-graph/adapters.ts b/web/src/components/structure-graph/adapters.ts index efefc94b8b..8cf17fdab4 100644 --- a/web/src/components/structure-graph/adapters.ts +++ b/web/src/components/structure-graph/adapters.ts @@ -153,37 +153,6 @@ export function adaptTreeToTreeData( return buildTreeDataItems(template.entities, template.relations, ['child']); } -function filterTreeDataItems( - items: TreeDataItem[], - keyword: string, -): TreeDataItem[] { - const lowerKeyword = keyword.toLowerCase(); - - return items.reduce((acc, item) => { - const children = item.children - ? filterTreeDataItems(item.children, keyword) - : []; - const matches = item.name.toLowerCase().includes(lowerKeyword); - - if (matches || children.length > 0) { - acc.push({ - ...item, - children: children.length > 0 ? children : item.children, - }); - } - - return acc; - }, []); -} - -export function filterTreeDataByKeyword( - data: TreeDataItem[], - keyword: string, -): TreeDataItem[] { - if (!keyword.trim()) return data; - return filterTreeDataItems(data, keyword); -} - export function adaptKnowledgeGraphToForceGraph( template: IStructureGraphTemplate, ): IArtifactGraph { diff --git a/web/src/components/structure-graph/representation-renderer.tsx b/web/src/components/structure-graph/representation-renderer.tsx index 613ef7c822..8fa516e61a 100644 --- a/web/src/components/structure-graph/representation-renderer.tsx +++ b/web/src/components/structure-graph/representation-renderer.tsx @@ -16,7 +16,6 @@ import { adaptPageIndexToTreeData, adaptTimelineToX6Data, adaptTreeToTreeData, - filterTreeDataByKeyword, } from './adapters'; import MindMapG6Graph from './mindmap-g6-graph'; import TimelineX6Graph from './timeline-x6-graph'; @@ -31,7 +30,6 @@ const EmptyForceGraphData: IArtifactGraph = { entities: [], relations: [] }; interface RepresentationRendererProps { template?: IStructureGraphTemplate; - searchKeyword?: string; onNodeClick?: (node: ClickableNode) => void; highlightNodeId?: string | null; } @@ -51,7 +49,6 @@ function UnsupportedPlaceholder({ kind }: { kind: StructureTemplateKind }) { export function RepresentationRenderer({ template, - searchKeyword = '', onNodeClick, highlightNodeId, }: RepresentationRendererProps) { @@ -104,22 +101,16 @@ export function RepresentationRenderer({ [], ); - const filteredTreeData = useMemo(() => { + const treeData = useMemo(() => { if (!template) return []; if (template.kind === CompilationTemplateKind.PageIndex) { - const data = adaptPageIndexToTreeData(template); - return searchKeyword.trim() - ? filterTreeDataByKeyword(data, searchKeyword) - : data; + return adaptPageIndexToTreeData(template); } if (template.kind === CompilationTemplateKind.Tree) { - const data = adaptTreeToTreeData(template); - return searchKeyword.trim() - ? filterTreeDataByKeyword(data, searchKeyword) - : data; + return adaptTreeToTreeData(template); } return []; - }, [template, searchKeyword]); + }, [template]); // Keep a stable reference across re-renders so the memoized ArtifactForceGraph // does not restart its force simulation when only highlightNodeId changes @@ -140,7 +131,7 @@ export function RepresentationRenderer({ return (
@@ -150,7 +141,7 @@ export function RepresentationRenderer({ return (
diff --git a/web/src/pages/chunk/representation/hooks/use-graph-entity-search.ts b/web/src/pages/chunk/representation/hooks/use-graph-entity-search.ts index 3f3e3e6811..7b39df3b09 100644 --- a/web/src/pages/chunk/representation/hooks/use-graph-entity-search.ts +++ b/web/src/pages/chunk/representation/hooks/use-graph-entity-search.ts @@ -3,6 +3,7 @@ import { getEntityDisplayName } from '@/components/structure-graph/adapters'; import { type ClickableNode } from '@/components/structure-graph/representation-renderer'; import { CompilationTemplateKind } from '@/constants/compilation'; import { useFetchDocumentStructureGraph } from '@/hooks/use-document-request'; +import { useDebounce } from 'ahooks'; import { useCallback, useMemo, useState } from 'react'; import { useSelectedTemplate } from './use-selected-template'; @@ -10,9 +11,16 @@ export function useGraphEntitySearch( onNodeClick?: (node: ClickableNode) => void, ) { const [graphKeywords, setGraphKeywords] = useState(''); + const [searchKeyword, setSearchKeyword] = useState(''); // ExpandableSearchInput value const [selectedNodeId, setSelectedNodeId] = useState(''); // entity name - const { data, loading } = useFetchDocumentStructureGraph(graphKeywords); + // Non-graph kinds search server-side with the same ?keywords= query the + // graph-kind Enter search uses; the two inputs never show at once, and the + // handlers below keep at most one of the two keyword sources non-empty. + const debouncedSearchKeyword = useDebounce(searchKeyword, { wait: 500 }); + const { data, loading } = useFetchDocumentStructureGraph( + debouncedSearchKeyword || graphKeywords, + ); const templates = useMemo(() => data?.templates ?? [], [data?.templates]); const { selectedTemplateId, @@ -49,6 +57,7 @@ export function useGraphEntitySearch( (name: string) => { if (!name) { setGraphKeywords(''); + setSearchKeyword(''); setSelectedNodeId(''); return; } @@ -69,9 +78,14 @@ export function useGraphEntitySearch( const handleNoMatchEnter = useCallback((keywords: string) => { setGraphKeywords(keywords); + setSearchKeyword(''); setSelectedNodeId(''); }, []); + const handleSearchKeywordChange = useCallback((value: string) => { + setSearchKeyword(value); + }, []); + // Two-way binding: clicking a graph node selects it in the dropdown, // then forwards to chunk navigation like before const handleNodeClick = useCallback( @@ -89,6 +103,7 @@ export function useGraphEntitySearch( (templateId: string) => { setSelectedTemplateId(templateId); setGraphKeywords(''); + setSearchKeyword(''); setSelectedNodeId(''); }, [setSelectedTemplateId], @@ -102,10 +117,12 @@ export function useGraphEntitySearch( selectedTemplate, isGraphKind, entityOptions, + searchKeyword, graphSelectValue: selectedEntityName || graphKeywords, highlightNodeId: selectedEntityName || null, handleSelectEntity, handleNoMatchEnter, + handleSearchKeywordChange, handleTemplateChange, handleNodeClick, }; diff --git a/web/src/pages/chunk/representation/hooks/use-selected-template.ts b/web/src/pages/chunk/representation/hooks/use-selected-template.ts index eeaf2857de..f25bf3bda8 100644 --- a/web/src/pages/chunk/representation/hooks/use-selected-template.ts +++ b/web/src/pages/chunk/representation/hooks/use-selected-template.ts @@ -8,7 +8,14 @@ export function useSelectedTemplate(templates: IStructureGraphTemplate[]) { const [selectedTemplateId, setSelectedTemplateId] = useState(''); const effectiveSelectedTemplateId = useMemo(() => { - return selectedTemplateId || templates[0]?.template_id || ''; + // Keywords search returns a single bucket that may not be the selected + // template — fall back to the first template instead of rendering nothing. + if ( + templates.some((template) => template.template_id === selectedTemplateId) + ) { + return selectedTemplateId; + } + return templates[0]?.template_id || ''; }, [selectedTemplateId, templates]); const selectedTemplate = useMemo(() => { diff --git a/web/src/pages/chunk/representation/index.tsx b/web/src/pages/chunk/representation/index.tsx index a69cf193de..ccc78d94f1 100644 --- a/web/src/pages/chunk/representation/index.tsx +++ b/web/src/pages/chunk/representation/index.tsx @@ -5,7 +5,7 @@ import { SkeletonCard } from '@/components/skeleton-card'; import { Button } from '@/components/ui/button'; import { useDeleteDocumentStructureGraph } from '@/hooks/use-document-request'; import { Trash2 } from 'lucide-react'; -import { memo, useCallback, useState } from 'react'; +import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { type ClickableNode, @@ -22,7 +22,6 @@ function Representation({ onNodeClick }: RepresentationProps) { const { t } = useTranslation(); const { deleteDocumentStructureGraph, loading: deleting } = useDeleteDocumentStructureGraph(); - const [searchKeyword, setSearchKeyword] = useState(''); const { data, @@ -32,18 +31,16 @@ function Representation({ onNodeClick }: RepresentationProps) { selectedTemplate, isGraphKind, entityOptions, + searchKeyword, graphSelectValue, highlightNodeId, handleSelectEntity, handleNoMatchEnter, + handleSearchKeywordChange, handleTemplateChange, handleNodeClick, } = useGraphEntitySearch(onNodeClick); - const handleSearchChange = useCallback((value: string) => { - setSearchKeyword(value); - }, []); - const handleDelete = useCallback(async () => { if (!selectedTemplateId) return; await deleteDocumentStructureGraph(selectedTemplateId); @@ -71,7 +68,7 @@ function Representation({ onNodeClick }: RepresentationProps) { ) : ( )} @@ -103,7 +100,6 @@ function Representation({ onNodeClick }: RepresentationProps) { {!(loading && !data) && templates.length > 0 && ( diff --git a/web/src/pages/dataset/compilation/utils/skill-tree.ts b/web/src/pages/dataset/compilation/utils/skill-tree.ts index 4eca4afc72..a5751b96f2 100644 --- a/web/src/pages/dataset/compilation/utils/skill-tree.ts +++ b/web/src/pages/dataset/compilation/utils/skill-tree.ts @@ -24,10 +24,8 @@ export function buildSkillTreeData( }); } -// Same prune logic as filterTreeDataByKeyword in -// pages/chunk/representation/utils/adapters.ts — that module carries -// chunk-specific TreeDataItem augmentation, so a feature-local copy is kept -// here instead of a cross-feature import. +// Prunes the skill tree to nodes matching the keyword, keeping the +// ancestors of matched nodes so the tree stays navigable. export function filterSkillTreeData( items: TreeDataItem[], keyword: string,