Files
ragflow/web/src/pages/agent/utils.ts
FatMii f341dc03b8 Feature/agent UI style optimization (#10385)
### What problem does this PR solve?

Hi team,  @ZhenhangTung @KevinHuSh  @cike8899 
About #10384 , I've completed the UI optimization adjustments for the
Agent page according to our previous discussions and the design draft
sketches provided by @Naomi. The main modifications include:

1. Adjusted the style and content of placeholder-node.
2. Adjusted the location of the dropdown (to the right of the
placeholder-node) .
3. Adjusted the tooltip position spacing when the mouse hovers in the
dropdown menu.
4. Hides the thick scroll bar on the dropdown component.
5. Highlight the connection line when dragging to generate a
placeholder-node

<img width="1323" height="509" alt="Image"
src="https://github.com/user-attachments/assets/0d366f7f-477d-4c00-bb58-d5d58b3a745f"
/>

Please review the related code modifications when you have time. Let me
know if further adjustments are needed!
Thanks!

### Type of change

- [x] Other (please describe): UI Enhancement

---------

Co-authored-by: leonlai <leonlai@futurefab.ai>
2025-10-09 11:12:12 +08:00

569 lines
15 KiB
TypeScript

import {
IAgentForm,
ICategorizeForm,
ICategorizeItem,
ICategorizeItemResult,
} from '@/interfaces/database/agent';
import { DSLComponents, RAGFlowNodeType } from '@/interfaces/database/flow';
import { removeUselessFieldsFromValues } from '@/utils/form';
import { Edge, Node, XYPosition } from '@xyflow/react';
import { FormInstance, FormListFieldData } from 'antd';
import { humanId } from 'human-id';
import { curry, get, intersectionWith, isEqual, omit, sample } from 'lodash';
import pipe from 'lodash/fp/pipe';
import isObject from 'lodash/isObject';
import {
CategorizeAnchorPointPositions,
NoDebugOperatorsList,
NodeHandleId,
Operator,
} from './constant';
import { BeginQuery, IPosition } from './interface';
function buildAgentExceptionGoto(edges: Edge[], nodeId: string) {
const exceptionEdges = edges.filter(
(x) =>
x.source === nodeId && x.sourceHandle === NodeHandleId.AgentException,
);
return exceptionEdges.map((x) => x.target);
}
const buildComponentDownstreamOrUpstream = (
edges: Edge[],
nodeId: string,
isBuildDownstream = true,
nodes: Node[],
) => {
return edges
.filter((y) => {
const node = nodes.find((x) => x.id === nodeId);
let isNotUpstreamTool = true;
let isNotUpstreamAgent = true;
let isNotExceptionGoto = true;
if (isBuildDownstream && node?.data.label === Operator.Agent) {
isNotExceptionGoto = y.sourceHandle !== NodeHandleId.AgentException;
// Exclude the tool operator downstream of the agent operator
isNotUpstreamTool = !y.target.startsWith(Operator.Tool);
// Exclude the agent operator downstream of the agent operator
isNotUpstreamAgent = !(
y.target.startsWith(Operator.Agent) &&
y.targetHandle === NodeHandleId.AgentTop
);
}
return (
y[isBuildDownstream ? 'source' : 'target'] === nodeId &&
isNotUpstreamTool &&
isNotUpstreamAgent &&
isNotExceptionGoto
);
})
.map((y) => y[isBuildDownstream ? 'target' : 'source']);
};
const removeUselessDataInTheOperator = curry(
(operatorName: string, params: Record<string, unknown>) => {
if (
operatorName === Operator.Generate ||
operatorName === Operator.Categorize
) {
return removeUselessFieldsFromValues(params, '');
}
return params;
},
);
// initialize data for operators without parameters
// const initializeOperatorParams = curry((operatorName: string, values: any) => {
// if (isEmpty(values)) {
// return initialFormValuesMap[operatorName as Operator];
// }
// return values;
// });
function buildAgentTools(edges: Edge[], nodes: Node[], nodeId: string) {
const node = nodes.find((x) => x.id === nodeId);
const params = { ...(node?.data.form ?? {}) };
if (node && node.data.label === Operator.Agent) {
const bottomSubAgentEdges = edges.filter(
(x) => x.source === nodeId && x.sourceHandle === NodeHandleId.AgentBottom,
);
(params as IAgentForm).tools = (params as IAgentForm).tools.concat(
bottomSubAgentEdges.map((x) => {
const {
params: formData,
id,
name,
} = buildAgentTools(edges, nodes, x.target);
return {
component_name: Operator.Agent,
id,
name: name as string, // Cast name to string and provide fallback
params: { ...formData },
};
}),
);
}
return { params, name: node?.data.name, id: node?.id };
}
function filterTargetsBySourceHandleId(edges: Edge[], handleId: string) {
return edges.filter((x) => x.sourceHandle === handleId).map((x) => x.target);
}
function buildCategorize(edges: Edge[], nodes: Node[], nodeId: string) {
const node = nodes.find((x) => x.id === nodeId);
const params = { ...(node?.data.form ?? {}) } as ICategorizeForm;
if (node && node.data.label === Operator.Categorize) {
const subEdges = edges.filter((x) => x.source === nodeId);
const items = params.items || [];
const nextCategoryDescription = items.reduce<
ICategorizeForm['category_description']
>((pre, val) => {
const key = val.name;
pre[key] = {
...omit(val, 'name', 'uuid'),
examples: val.examples?.map((x) => x.value) || [],
to: filterTargetsBySourceHandleId(subEdges, val.uuid),
};
return pre;
}, {});
params.category_description = nextCategoryDescription;
}
return omit(params, 'items');
}
const buildOperatorParams = (operatorName: string) =>
pipe(
removeUselessDataInTheOperator(operatorName),
// initializeOperatorParams(operatorName), // Final processing, for guarantee
);
const ExcludeOperators = [Operator.Note, Operator.Tool, Operator.Placeholder];
export function isBottomSubAgent(edges: Edge[], nodeId?: string) {
const edge = edges.find(
(x) => x.target === nodeId && x.targetHandle === NodeHandleId.AgentTop,
);
return !!edge;
}
export function hasSubAgentOrTool(edges: Edge[], nodeId?: string) {
const edge = edges.find(
(x) =>
x.source === nodeId &&
(x.sourceHandle === NodeHandleId.Tool ||
x.sourceHandle === NodeHandleId.AgentBottom),
);
return !!edge;
}
// construct a dsl based on the node information of the graph
export const buildDslComponentsByGraph = (
nodes: RAGFlowNodeType[],
edges: Edge[],
oldDslComponents: DSLComponents,
): DSLComponents => {
const components: DSLComponents = {};
nodes
?.filter(
(x) =>
!ExcludeOperators.some((y) => y === x.data.label) &&
!isBottomSubAgent(edges, x.id),
)
.forEach((x) => {
const id = x.id;
const operatorName = x.data.label;
let params = x?.data.form ?? {};
switch (operatorName) {
case Operator.Agent: {
const { params: formData } = buildAgentTools(edges, nodes, id);
params = {
...formData,
exception_goto: buildAgentExceptionGoto(edges, id),
};
break;
}
case Operator.Categorize:
params = buildCategorize(edges, nodes, id);
break;
default:
break;
}
components[id] = {
obj: {
...(oldDslComponents[id]?.obj ?? {}),
component_name: operatorName,
params: buildOperatorParams(operatorName)(params) ?? {},
},
downstream: buildComponentDownstreamOrUpstream(edges, id, true, nodes),
upstream: buildComponentDownstreamOrUpstream(edges, id, false, nodes),
parent_id: x?.parentId,
};
});
return components;
};
export const receiveMessageError = (res: any) =>
res && (res?.response.status !== 200 || res?.data?.code !== 0);
// Replace the id in the object with text
export const replaceIdWithText = (
obj: Record<string, unknown> | unknown[] | unknown,
getNameById: (id?: string) => string | undefined,
) => {
if (isObject(obj)) {
const ret: Record<string, unknown> | unknown[] = Array.isArray(obj)
? []
: {};
Object.keys(obj).forEach((key) => {
const val = (obj as Record<string, unknown>)[key];
const text = typeof val === 'string' ? getNameById(val) : undefined;
(ret as Record<string, unknown>)[key] = text
? text
: replaceIdWithText(val, getNameById);
});
return ret;
}
return obj;
};
export const isEdgeEqual = (previous: Edge, current: Edge) =>
previous.source === current.source &&
previous.target === current.target &&
previous.sourceHandle === current.sourceHandle;
export const buildNewPositionMap = (
currentKeys: string[],
previousPositionMap: Record<string, IPosition>,
) => {
// index in use
const indexesInUse = Object.values(previousPositionMap).map((x) => x.idx);
const previousKeys = Object.keys(previousPositionMap);
const intersectionKeys = intersectionWith(
previousKeys,
currentKeys,
(categoryDataKey: string, positionMapKey: string) =>
categoryDataKey === positionMapKey,
);
// difference set
const currentDifferenceKeys = currentKeys.filter(
(x) => !intersectionKeys.some((y: string) => y === x),
);
const newPositionMap = currentDifferenceKeys.reduce<
Record<string, IPosition>
>((pre, cur) => {
// take a coordinate
const effectiveIdxes = CategorizeAnchorPointPositions.map(
(x, idx) => idx,
).filter((x) => !indexesInUse.some((y) => y === x));
const idx = sample(effectiveIdxes);
if (idx !== undefined) {
indexesInUse.push(idx);
pre[cur] = { ...CategorizeAnchorPointPositions[idx], idx };
}
return pre;
}, {});
return { intersectionKeys, newPositionMap };
};
export const isKeysEqual = (currentKeys: string[], previousKeys: string[]) => {
return isEqual(currentKeys.sort(), previousKeys.sort());
};
export const getOperatorIndex = (handleTitle: string) => {
return handleTitle.split(' ').at(-1);
};
// Get the value of other forms except itself
export const getOtherFieldValues = (
form: FormInstance,
formListName: string = 'items',
field: FormListFieldData,
latestField: string,
) =>
(form.getFieldValue([formListName]) ?? [])
.map((x: any) => {
return get(x, latestField);
})
.filter(
(x: string) =>
x !== form.getFieldValue([formListName, field.name, latestField]),
);
export const generateSwitchHandleText = (idx: number) => {
return `Case ${idx + 1}`;
};
export const getNodeDragHandle = (nodeType?: string) => {
return nodeType === Operator.Note ? '.note-drag-handle' : undefined;
};
const splitName = (name: string) => {
const names = name.split('_');
const type = names.at(0);
const index = Number(names.at(-1));
return { type, index };
};
export const generateNodeNamesWithIncreasingIndex = (
name: string,
nodes: RAGFlowNodeType[],
) => {
const templateNameList = nodes
.filter((x) => {
const temporaryName = x.data.name;
const { type, index } = splitName(temporaryName);
return (
temporaryName.match(/_/g)?.length === 1 &&
type === name &&
!isNaN(index)
);
})
.map((x) => {
const temporaryName = x.data.name;
const { index } = splitName(temporaryName);
return {
idx: index,
name: temporaryName,
};
})
.sort((a, b) => a.idx - b.idx);
let index: number = 0;
for (let i = 0; i < templateNameList.length; i++) {
const idx = templateNameList[i]?.idx;
const nextIdx = templateNameList[i + 1]?.idx;
if (idx + 1 !== nextIdx) {
index = idx + 1;
break;
}
}
return `${name}_${index}`;
};
export const duplicateNodeForm = (nodeData?: RAGFlowNodeType['data']) => {
const form: Record<string, any> = { ...(nodeData?.form ?? {}) };
// Delete the downstream node corresponding to the to field of the Categorize operator
if (nodeData?.label === Operator.Categorize) {
form.category_description = Object.keys(form.category_description).reduce<
Record<string, Record<string, any>>
>((pre, cur) => {
pre[cur] = {
...form.category_description[cur],
to: undefined,
};
return pre;
}, {});
}
// Delete the downstream nodes corresponding to the yes and no fields of the Relevant operator
if (nodeData?.label === Operator.Relevant) {
form.yes = undefined;
form.no = undefined;
}
return {
...(nodeData ?? { label: '' }),
form,
};
};
export const getDrawerWidth = () => {
return window.innerWidth > 1278 ? '40%' : 470;
};
export const needsSingleStepDebugging = (label: string) => {
return !NoDebugOperatorsList.some((x) => (label as Operator) === x);
};
// Get the coordinates of the node relative to the Iteration node
export function getRelativePositionToIterationNode(
nodes: RAGFlowNodeType[],
position?: XYPosition, // relative position
) {
if (!position) {
return;
}
const iterationNodes = nodes.filter(
(node) => node.data.label === Operator.Iteration,
);
for (const iterationNode of iterationNodes) {
const {
position: { x, y },
width,
height,
} = iterationNode;
const halfWidth = (width || 0) / 2;
if (
position.x >= x - halfWidth &&
position.x <= x + halfWidth &&
position.y >= y &&
position.y <= y + (height || 0)
) {
return {
parentId: iterationNode.id,
position: { x: position.x - x + halfWidth, y: position.y - y },
};
}
}
}
export const generateDuplicateNode = (
position?: XYPosition,
label?: string,
) => {
const nextPosition = {
x: (position?.x || 0) + 50,
y: (position?.y || 0) + 50,
};
return {
selected: false,
dragging: false,
id: `${label}:${humanId()}`,
position: nextPosition,
dragHandle: getNodeDragHandle(label),
};
};
export function convertToStringArray(
list?: Array<{ value: string | number | boolean }>,
) {
if (!Array.isArray(list)) {
return [];
}
return list.map((x) => x.value);
}
export function convertToObjectArray(list: Array<string | number | boolean>) {
if (!Array.isArray(list)) {
return [];
}
return list.map((x) => ({ value: x }));
}
/**
* convert the following object into a list
*
* {
"product_related": {
"description": "The question is about product usage, appearance and how it works.",
"examples": "Why it always beaming?\nHow to install it onto the wall?\nIt leaks, what to do?",
"to": "generate:0"
}
}
*/
export const buildCategorizeListFromObject = (
categorizeItem: ICategorizeItemResult,
) => {
// Categorize's to field has two data sources, with edges as the data source.
// Changes in the edge or to field need to be synchronized to the form field.
return Object.keys(categorizeItem)
.reduce<Array<ICategorizeItem>>((pre, cur) => {
// synchronize edge data to the to field
pre.push({
name: cur,
...categorizeItem[cur],
examples: convertToObjectArray(categorizeItem[cur].examples),
});
return pre;
}, [])
.sort((a, b) => a.index - b.index);
};
/**
* Convert the list in the following form into an object
* {
"items": [
{
"name": "Categorize 1",
"description": "111",
"examples": ["ddd"],
"to": "Retrieval:LazyEelsStick"
}
]
}
*/
export const buildCategorizeObjectFromList = (list: Array<ICategorizeItem>) => {
return list.reduce<ICategorizeItemResult>((pre, cur) => {
if (cur?.name) {
pre[cur.name] = {
...omit(cur, 'name', 'examples'),
examples: convertToStringArray(cur.examples) as string[],
};
}
return pre;
}, {});
};
export function getAgentNodeTools(agentNode?: RAGFlowNodeType) {
const tools: IAgentForm['tools'] = get(agentNode, 'data.form.tools', []);
return tools;
}
export function getAgentNodeMCP(agentNode?: RAGFlowNodeType) {
const tools: IAgentForm['mcp'] = get(agentNode, 'data.form.mcp', []);
return tools;
}
export function mapEdgeMouseEvent(
edges: Edge[],
edgeId: string,
isHovered: boolean,
) {
const nextEdges = edges.map((element) =>
element.id === edgeId
? {
...element,
data: {
...element.data,
isHovered,
},
}
: element,
);
return nextEdges;
}
export function buildBeginQueryWithObject(
inputs: Record<string, BeginQuery>,
values: BeginQuery[],
) {
const nextInputs = Object.keys(inputs).reduce<Record<string, BeginQuery>>(
(pre, key) => {
const item = values.find((x) => x.key === key);
if (item) {
pre[key] = { ...item };
}
return pre;
},
{},
);
return nextInputs;
}