Feat: Search for timeline nodes. (#17996)

This commit is contained in:
balibabu
2026-08-07 17:06:19 +08:00
committed by GitHub
parent 2eb1ef6c67
commit ec09d4e3bd
6 changed files with 38 additions and 60 deletions

View File

@@ -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<TreeDataItem[]>((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 {

View File

@@ -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<TreeDataItem[]>(() => {
const treeData = useMemo<TreeDataItem[]>(() => {
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 (
<div className="mt-6 overflow-auto scrollbar-auto">
<TreeView
data={filteredTreeData}
data={treeData}
expandAll
onSelectChange={handleTreeItemClick}
/>
@@ -150,7 +141,7 @@ export function RepresentationRenderer({
return (
<div className="mt-6 overflow-auto scrollbar-auto">
<TreeView
data={filteredTreeData}
data={treeData}
expandAll
onSelectChange={handleTreeItemClick}
/>

View File

@@ -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,
};

View File

@@ -8,7 +8,14 @@ export function useSelectedTemplate(templates: IStructureGraphTemplate[]) {
const [selectedTemplateId, setSelectedTemplateId] = useState<string>('');
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(() => {

View File

@@ -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) {
) : (
<ExpandableSearchInput
value={searchKeyword}
onChange={handleSearchChange}
onChange={handleSearchKeywordChange}
placeholder={t('chunk.search', 'Search')}
/>
)}
@@ -103,7 +100,6 @@ function Representation({ onNodeClick }: RepresentationProps) {
{!(loading && !data) && templates.length > 0 && (
<RepresentationRenderer
template={selectedTemplate}
searchKeyword={searchKeyword}
onNodeClick={handleNodeClick}
highlightNodeId={highlightNodeId}
/>

View File

@@ -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,