mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-24 09:00:13 +08:00
The tool operators that allow the agent to be collapsed and hidden (#18288)
This commit is contained in:
@@ -71,8 +71,14 @@ function InnerAgentNode({
|
||||
id={NodeHandleId.AgentBottom}
|
||||
left={180}
|
||||
visible={hasSubAgent(edges, id)}
|
||||
nodeId={id}
|
||||
/>
|
||||
<BottomHandle
|
||||
id={NodeHandleId.Tool}
|
||||
left={20}
|
||||
visible={hasTools}
|
||||
nodeId={id}
|
||||
/>
|
||||
<BottomHandle id={NodeHandleId.Tool} left={20} visible={hasTools} />
|
||||
<NodeHeader id={id} name={data.name} label={data.label}></NodeHeader>
|
||||
<section className="flex flex-col gap-2">
|
||||
<LLMLabelCard llmId={get(data, 'form.llm_id')}></LLMLabelCard>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useSetModalState } from '@/hooks/common-hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Handle, HandleProps, Position } from '@xyflow/react';
|
||||
import { Plus, ChevronDown } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, type MouseEvent } from 'react';
|
||||
import { NodeHandleId } from '../../constant';
|
||||
import { HandleContext } from '../../context';
|
||||
import { useIsPipeline } from '../../hooks/use-is-pipeline';
|
||||
@@ -105,24 +105,47 @@ export function BottomHandle({
|
||||
id,
|
||||
left,
|
||||
visible,
|
||||
nodeId,
|
||||
}: {
|
||||
id: NodeHandleId;
|
||||
left: number;
|
||||
visible: boolean;
|
||||
nodeId: string;
|
||||
}) {
|
||||
const toggleBottomCollapse = useGraphStore(
|
||||
(state) => state.toggleBottomCollapse,
|
||||
);
|
||||
const collapsed = useGraphStore(
|
||||
(state) => state.collapsedBottomHandles[nodeId]?.includes(id) ?? false,
|
||||
);
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toggleBottomCollapse(nodeId, id);
|
||||
};
|
||||
|
||||
return (
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
isConnectable={false}
|
||||
// With pointer events re-enabled above, keep pointerdown on the handle
|
||||
// from starting a connection drag
|
||||
isConnectableStart={false}
|
||||
id={id}
|
||||
style={{ left }}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'!size-3.5 !bg-text-disabled !border-border-button inline-flex items-center justify-center invisible',
|
||||
'!size-3.5 !bg-text-disabled !border-border-button inline-flex items-center justify-center invisible !pointer-events-auto cursor-pointer',
|
||||
{ visible },
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="size-2.5 text-bg-base pointer-events-none" />
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-2.5 text-bg-base pointer-events-none transition-transform',
|
||||
{ '-rotate-90': collapsed },
|
||||
)}
|
||||
/>
|
||||
</Handle>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,3 +159,140 @@ describe('useGraphStore.deleteIterationNodeById', () => {
|
||||
expect(state.edges.map((edge) => edge.id)).toEqual(['branch-edge']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useGraphStore.toggleBottomCollapse', () => {
|
||||
const buildSubgraph = () => {
|
||||
const nodes = [
|
||||
createNode('agent:0', Operator.Agent),
|
||||
createNode('agent:1', Operator.Agent),
|
||||
createNode('tool:0', Operator.Tool),
|
||||
createNode('tool:1', Operator.Tool),
|
||||
createNode('message:0', Operator.Message),
|
||||
];
|
||||
|
||||
const edges = [
|
||||
createEdge('e-sub', 'agent:0', 'agent:1', {
|
||||
sourceHandle: NodeHandleId.AgentBottom,
|
||||
targetHandle: NodeHandleId.AgentTop,
|
||||
}),
|
||||
createEdge('e-tool-0', 'agent:0', 'tool:0', {
|
||||
sourceHandle: NodeHandleId.Tool,
|
||||
}),
|
||||
createEdge('e-tool-1', 'agent:1', 'tool:1', {
|
||||
sourceHandle: NodeHandleId.Tool,
|
||||
}),
|
||||
createEdge('e-goto', 'agent:1', 'message:0', {
|
||||
sourceHandle: NodeHandleId.AgentException,
|
||||
}),
|
||||
createEdge('e-main', 'agent:0', 'message:0', {
|
||||
sourceHandle: NodeHandleId.Start,
|
||||
}),
|
||||
];
|
||||
|
||||
return { nodes, edges };
|
||||
};
|
||||
|
||||
const hiddenIds = (items: { id: string; hidden?: boolean }[]) =>
|
||||
items
|
||||
.filter((x) => x.hidden)
|
||||
.map((x) => x.id)
|
||||
.sort();
|
||||
|
||||
beforeEach(() => {
|
||||
useGraphStore.setState({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
selectedNodeIds: [],
|
||||
selectedEdgeIds: [],
|
||||
clickedNodeId: '',
|
||||
clickedToolId: '',
|
||||
collapsedBottomHandles: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the handle subtree and restores it on a second toggle', () => {
|
||||
const { nodes, edges } = buildSubgraph();
|
||||
|
||||
useGraphStore.setState({
|
||||
nodes,
|
||||
edges,
|
||||
selectedNodeIds: ['agent:1'],
|
||||
selectedEdgeIds: ['e-tool-1'],
|
||||
});
|
||||
|
||||
useGraphStore
|
||||
.getState()
|
||||
.toggleBottomCollapse('agent:0', NodeHandleId.AgentBottom);
|
||||
|
||||
let state = useGraphStore.getState();
|
||||
expect(state.collapsedBottomHandles).toEqual({
|
||||
'agent:0': [NodeHandleId.AgentBottom],
|
||||
});
|
||||
// The sub-agent's whole subtree is hidden, including its tool node
|
||||
expect(hiddenIds(state.nodes)).toEqual(['agent:1', 'tool:1']);
|
||||
// The goto edge from a hidden node is hidden too,
|
||||
// while the right-side main-flow edge stays visible
|
||||
expect(hiddenIds(state.edges)).toEqual(['e-goto', 'e-sub', 'e-tool-1']);
|
||||
expect(state.selectedNodeIds).toEqual([]);
|
||||
expect(state.selectedEdgeIds).toEqual([]);
|
||||
|
||||
useGraphStore
|
||||
.getState()
|
||||
.toggleBottomCollapse('agent:0', NodeHandleId.AgentBottom);
|
||||
|
||||
state = useGraphStore.getState();
|
||||
expect(state.collapsedBottomHandles).toEqual({});
|
||||
expect(state.nodes.every((x) => !x.hidden)).toBe(true);
|
||||
expect(state.edges.every((x) => !x.hidden)).toBe(true);
|
||||
});
|
||||
|
||||
it('collapses the two bottom handles independently', () => {
|
||||
const { nodes, edges } = buildSubgraph();
|
||||
|
||||
useGraphStore.setState({ nodes, edges });
|
||||
|
||||
useGraphStore
|
||||
.getState()
|
||||
.toggleBottomCollapse('agent:0', NodeHandleId.Tool);
|
||||
|
||||
let state = useGraphStore.getState();
|
||||
// Only the tool node is hidden; the sub-agent subtree stays visible
|
||||
expect(hiddenIds(state.nodes)).toEqual(['tool:0']);
|
||||
expect(hiddenIds(state.edges)).toEqual(['e-tool-0']);
|
||||
|
||||
useGraphStore
|
||||
.getState()
|
||||
.toggleBottomCollapse('agent:0', NodeHandleId.AgentBottom);
|
||||
|
||||
state = useGraphStore.getState();
|
||||
expect(hiddenIds(state.nodes)).toEqual(['agent:1', 'tool:0', 'tool:1']);
|
||||
expect(hiddenIds(state.edges)).toEqual([
|
||||
'e-goto',
|
||||
'e-sub',
|
||||
'e-tool-0',
|
||||
'e-tool-1',
|
||||
]);
|
||||
|
||||
// Expanding one handle keeps the other handle's subtree hidden
|
||||
useGraphStore
|
||||
.getState()
|
||||
.toggleBottomCollapse('agent:0', NodeHandleId.Tool);
|
||||
|
||||
state = useGraphStore.getState();
|
||||
expect(hiddenIds(state.nodes)).toEqual(['agent:1', 'tool:1']);
|
||||
expect(hiddenIds(state.edges)).toEqual(['e-goto', 'e-sub', 'e-tool-1']);
|
||||
});
|
||||
|
||||
it('does nothing when there is no bottom subtree', () => {
|
||||
useGraphStore.setState({
|
||||
nodes: [createNode('agent:0', Operator.Agent)],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
useGraphStore
|
||||
.getState()
|
||||
.toggleBottomCollapse('agent:0', NodeHandleId.AgentBottom);
|
||||
|
||||
expect(useGraphStore.getState().collapsedBottomHandles).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,12 @@ import {
|
||||
mapEdgeMouseEvent,
|
||||
} from './utils';
|
||||
import { deleteAllDownstreamAgentsAndTool } from './utils/delete-node';
|
||||
import {
|
||||
CollapsedBottomHandles,
|
||||
filterBottomSubtreeNodeIds,
|
||||
filterCollapsedHiddenIds,
|
||||
toggleCollapsedBottomHandle,
|
||||
} from './utils/filter-downstream-nodes';
|
||||
|
||||
type IAgentTool = IAgentForm['tools'][number];
|
||||
|
||||
@@ -142,6 +148,8 @@ export type RFState = {
|
||||
edges: Edge[];
|
||||
selectedNodeIds: string[];
|
||||
selectedEdgeIds: string[];
|
||||
// Collapsed bottom handles per node: nodeId -> collapsed handle ids
|
||||
collapsedBottomHandles: CollapsedBottomHandles;
|
||||
clickedNodeId: string; // currently selected node
|
||||
clickedToolId: string; // currently selected tool id
|
||||
onNodesChange: OnNodesChange<RAGFlowNodeType>;
|
||||
@@ -200,6 +208,7 @@ export type RFState = {
|
||||
) => void; // Deleting a condition of a classification operator will delete the related edge
|
||||
findAgentToolNodeById: (id: string | null) => string | undefined;
|
||||
selectNodeIds: (nodeIds: string[]) => void;
|
||||
toggleBottomCollapse: (nodeId: string, handleId: NodeHandleId) => void;
|
||||
hasDownstreamNode: (nodeId: string) => boolean;
|
||||
hasUpstreamNode: (nodeId: string) => boolean;
|
||||
};
|
||||
@@ -212,6 +221,7 @@ const useGraphStore = create<RFState>()(
|
||||
edges: [] as Edge[],
|
||||
selectedNodeIds: [] as string[],
|
||||
selectedEdgeIds: [] as string[],
|
||||
collapsedBottomHandles: {} as CollapsedBottomHandles,
|
||||
clickedNodeId: '',
|
||||
clickedToolId: '',
|
||||
onNodesChange: (changes) => {
|
||||
@@ -737,6 +747,47 @@ const useGraphStore = create<RFState>()(
|
||||
})),
|
||||
);
|
||||
},
|
||||
toggleBottomCollapse: (nodeId, handleId) => {
|
||||
const { edges, collapsedBottomHandles } = get();
|
||||
if (filterBottomSubtreeNodeIds(edges, nodeId, handleId).length === 0) {
|
||||
return;
|
||||
}
|
||||
const nextCollapsed = toggleCollapsedBottomHandle(
|
||||
collapsedBottomHandles,
|
||||
nodeId,
|
||||
handleId,
|
||||
);
|
||||
const { hiddenNodeIds, hiddenEdgeIds } = filterCollapsedHiddenIds(
|
||||
edges,
|
||||
nextCollapsed,
|
||||
);
|
||||
|
||||
set((state) => {
|
||||
state.collapsedBottomHandles = nextCollapsed;
|
||||
for (const node of state.nodes) {
|
||||
if (hiddenNodeIds.has(node.id)) {
|
||||
node.hidden = true;
|
||||
node.selected = false;
|
||||
} else if (node.hidden) {
|
||||
node.hidden = false;
|
||||
}
|
||||
}
|
||||
for (const edge of state.edges) {
|
||||
if (hiddenEdgeIds.has(edge.id)) {
|
||||
edge.hidden = true;
|
||||
edge.selected = false;
|
||||
} else if (edge.hidden) {
|
||||
edge.hidden = false;
|
||||
}
|
||||
}
|
||||
state.selectedNodeIds = state.selectedNodeIds.filter(
|
||||
(x) => !hiddenNodeIds.has(x),
|
||||
);
|
||||
state.selectedEdgeIds = state.selectedEdgeIds.filter(
|
||||
(x) => !hiddenEdgeIds.has(x),
|
||||
);
|
||||
});
|
||||
},
|
||||
hasDownstreamNode: (nodeId) => {
|
||||
const { edges } = get();
|
||||
return edges.some((edge) => edge.source === nodeId);
|
||||
|
||||
@@ -42,6 +42,68 @@ export function filterAllDownstreamAgentAndToolNodeIds(
|
||||
);
|
||||
}
|
||||
|
||||
// Get the node ids hanging below one specific bottom handle of an agent node:
|
||||
// the handle's own children, plus their whole sub-agent/tool subtree
|
||||
export function filterBottomSubtreeNodeIds(
|
||||
edges: Edge[],
|
||||
nodeId: string,
|
||||
handleId: string,
|
||||
) {
|
||||
return filterAllDownstreamNodeIds(edges, [nodeId], (edge: Edge) =>
|
||||
edge.source === nodeId
|
||||
? edge.sourceHandle === handleId
|
||||
: edge.sourceHandle === NodeHandleId.AgentBottom ||
|
||||
edge.sourceHandle === NodeHandleId.Tool,
|
||||
);
|
||||
}
|
||||
|
||||
export type CollapsedBottomHandles = Record<string, string[]>;
|
||||
|
||||
// Toggle one bottom handle in the collapsed map, dropping the node entry
|
||||
// when it has no collapsed handles left
|
||||
export function toggleCollapsedBottomHandle(
|
||||
collapsed: CollapsedBottomHandles,
|
||||
nodeId: string,
|
||||
handleId: string,
|
||||
): CollapsedBottomHandles {
|
||||
const handleIds = collapsed[nodeId] ?? [];
|
||||
const nextHandleIds = handleIds.includes(handleId)
|
||||
? handleIds.filter((x) => x !== handleId)
|
||||
: handleIds.concat(handleId);
|
||||
|
||||
const next = { ...collapsed };
|
||||
if (nextHandleIds.length > 0) {
|
||||
next[nodeId] = nextHandleIds;
|
||||
} else {
|
||||
delete next[nodeId];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// Union of the node/edge ids hidden by all collapsed subtrees, so expanding
|
||||
// one handle keeps nodes hidden by another (e.g. nested) collapse hidden
|
||||
export function filterCollapsedHiddenIds(
|
||||
edges: Edge[],
|
||||
collapsed: CollapsedBottomHandles,
|
||||
) {
|
||||
const hiddenNodeIds = new Set<string>();
|
||||
Object.entries(collapsed).forEach(([nodeId, handleIds]) => {
|
||||
handleIds.forEach((handleId) => {
|
||||
filterBottomSubtreeNodeIds(edges, nodeId, handleId).forEach((x) =>
|
||||
hiddenNodeIds.add(x),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const hiddenEdgeIds = new Set(
|
||||
edges
|
||||
.filter((x) => hiddenNodeIds.has(x.source) || hiddenNodeIds.has(x.target))
|
||||
.map((x) => x.id),
|
||||
);
|
||||
|
||||
return { hiddenNodeIds, hiddenEdgeIds };
|
||||
}
|
||||
|
||||
// Get all downstream agent operators of the current agent operator
|
||||
export function filterAllDownstreamAgentNodeIds(
|
||||
edges: Edge[],
|
||||
|
||||
Reference in New Issue
Block a user