mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-15 13:14:28 +08:00
The tool operators that allow the agent to be collapsed and hidden (#18288)
This commit is contained in:
14
web/__mocks__/layout-recognize-form-field.js
Normal file
14
web/__mocks__/layout-recognize-form-field.js
Normal 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,
|
||||
};
|
||||
44
web/jest-esbuild-transformer.cjs
Normal file
44
web/jest-esbuild-transformer.cjs
Normal 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) };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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