The tool operators that allow the agent to be collapsed and hidden (#18288)

This commit is contained in:
balibabu
2026-08-14 18:22:06 +08:00
committed by GitHub
parent 2c775d0549
commit 6445390c24
9 changed files with 364 additions and 13 deletions

View File

@@ -0,0 +1,14 @@
// Stub for @/components/layout-recognize-form-field: the real module drags in
// the app shell (llm hooks, routes, react-router), which jsdom cannot host.
// Only the ParseDocumentType values matter to constant/pipeline.tsx.
module.exports = {
__esModule: true,
ParseDocumentType: {
DeepDOC: 'DeepDOC',
PlainText: 'Plain Text',
Docling: 'Docling',
OpenDataLoader: 'OpenDataLoader',
TCADPParser: 'TCADP Parser',
},
LayoutRecognizeFormField: () => null,
};

View File

@@ -0,0 +1,44 @@
// Jest transformer wrapping esbuild with the same options esbuild-jest was
// configured with, plus `define: { 'import.meta.env': '{}' }` — esbuild-jest@0.5
// does not forward esbuild's `define` option, and source files read Vite's
// import.meta.env at module scope, which crashes under jest's cjs runtime.
// Files containing jest.mock still go through esbuild-jest for its babel-based
// mock hoisting.
const path = require('node:path');
const esbuild = require('esbuild');
const esbuildJest = require('esbuild-jest');
const esbuildJestTransformer = esbuildJest.createTransformer({
sourcemap: true,
loaders: { '.ts': 'tsx' },
});
const supportedLoaders = ['js', 'jsx', 'ts', 'tsx', 'json'];
module.exports = {
createTransformer() {
return {
process(content, filename, config, opts) {
if (content.indexOf('ock(') >= 0) {
return esbuildJestTransformer.process(content, filename, config, opts);
}
const ext = path.extname(filename).slice(1);
const loader = ext === 'ts' ? 'tsx' : supportedLoaders.includes(ext) ? ext : 'text';
const result = esbuild.transformSync(content, {
loader,
format: 'cjs',
target: 'es2018',
sourcemap: true,
sourcesContent: false,
sourcefile: filename,
define: {
'import.meta.env': '{}',
// Vite's glob import; jestImportMetaGlob is set in jest-setup.ts
'import.meta.glob': 'jestImportMetaGlob',
},
});
return { code: result.code, map: JSON.parse(result.map) };
},
};
},
};

View File

@@ -5,3 +5,20 @@ import React from 'react';
// while source files rely on the automatic runtime and never import React.
// Expose React globally so rendering components in tests works.
(globalThis as Record<string, unknown>).React = React;
// jsdom does not provide these, but react-router reads them at module scope
if (typeof globalThis.TextEncoder === 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { TextDecoder, TextEncoder } = require('node:util');
Object.assign(globalThis, { TextDecoder, TextEncoder });
}
// Vite's import.meta.glob is rewritten to this stub by jest-esbuild-transformer.cjs
(globalThis as Record<string, unknown>).jestImportMetaGlob = () => ({});
// jsdom does not expose fetch; some modules call it at import time and
// handle the rejection themselves (e.g. utils/backend-runtime.ts)
if (typeof globalThis.fetch === 'undefined') {
(globalThis as Record<string, unknown>).fetch = () =>
Promise.reject(new Error('fetch is not available in tests'));
}

View File

@@ -3,17 +3,14 @@ import type { Config } from 'jest';
const config: Config = {
testEnvironment: 'jsdom',
transform: {
'^.+\\.(ts|tsx|js|jsx)$': [
'esbuild-jest',
{
sourcemap: true,
loaders: {
'.ts': 'tsx',
},
},
],
// Local wrapper around esbuild-jest that also defines import.meta.env;
// see jest-esbuild-transformer.cjs
'^.+\\.(ts|tsx|js|jsx)$': '<rootDir>/jest-esbuild-transformer.cjs',
},
moduleNameMapper: {
// Drags the app shell (routes/react-router) into jsdom; see __mocks__
'^@/components/layout-recognize-form-field$':
'<rootDir>/__mocks__/layout-recognize-form-field.js',
'^@/(.*)$': '<rootDir>/src/$1',
'^human-id$': '<rootDir>/__mocks__/human-id.js',
'\\.(css|less|scss|sass)$': '<rootDir>/__mocks__/styleMock.js',

View File

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

View File

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

View File

@@ -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({});
});
});

View File

@@ -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);

View File

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