Files
ragflow/web/src/pages/agent/hooks.tsx
RuyXu 209b731541 Feat: add SearXNG search tool to Agent (frontend + backend, i18n) (#9699)
### What problem does this PR solve?

This PR integrates SearXNG as a new search tool for Agents. It adds
corresponding form/config UI on the frontend and a new tool
implementation on the backend, enabling aggregated web searches via a
self-hosted SearXNG instance within chats/workflows. It also adds
multilingual copy to support internationalized presentation and
configuration guidance.

### Type of change

- [x] New Feature (non-breaking change which adds functionality)

### What’s Changed
- Frontend: new SearXNG tool configuration, forms, and command wiring
  - Main changes under `web/src/pages/agent/`
- New components and form entries are connected to Agent tool selection
and workflow node configuration
- Backend: new tool implementation
- `agent/tools/searxng.py`: connects to a SearXNG instance and performs
search based on the provided instance URL and query parameters
- i18n updates
- Added/updated keys under `web/src/locales/`: `searXNG` and
`searXNGDescription`
- English reference in
[web/src/locales/en.ts](cci:7://file:///c:/Users/ruy_x/Work/CRSC/2025/Software_Development/2025.8/ragflow-pr/ragflow/web/src/locales/en.ts:0:0-0:0):
    - `searXNG: 'SearXNG'`
- `searXNGDescription: 'A component that searches via your provided
SearXNG instance URL. Specify TopN and the instance URL.'`
- Other languages have `searXNG` and `searXNGDescription` added as well,
but accuracy is only guaranteed for English, Simplified Chinese, and
Traditional Chinese.

---------

Co-authored-by: xurui <xurui@crscd.com.cn>
2025-08-29 14:15:40 +08:00

485 lines
14 KiB
TypeScript

import {
Connection,
Edge,
getOutgoers,
Node,
Position,
ReactFlowInstance,
} from '@xyflow/react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
// import { shallow } from 'zustand/shallow';
import { settledModelVariableMap } from '@/constants/knowledge';
import { useFetchModelId } from '@/hooks/logic-hooks';
import { RAGFlowNodeType } from '@/interfaces/database/flow';
import { humanId } from 'human-id';
import { get, lowerFirst, omit } from 'lodash';
import { UseFormReturn } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import {
initialAgentValues,
initialAkShareValues,
initialArXivValues,
initialBaiduFanyiValues,
initialBaiduValues,
initialBeginValues,
initialBingValues,
initialCategorizeValues,
initialCodeValues,
initialConcentratorValues,
initialCrawlerValues,
initialDeepLValues,
initialDuckValues,
initialEmailValues,
initialExeSqlValues,
initialGithubValues,
initialGoogleScholarValues,
initialGoogleValues,
initialInvokeValues,
initialIterationValues,
initialJin10Values,
initialKeywordExtractValues,
initialMessageValues,
initialNoteValues,
initialPubMedValues,
initialQWeatherValues,
initialRelevantValues,
initialRetrievalValues,
initialRewriteQuestionValues,
initialSearXNGValues,
initialStringTransformValues,
initialSwitchValues,
initialTavilyExtractValues,
initialTavilyValues,
initialTuShareValues,
initialUserFillUpValues,
initialWaitingDialogueValues,
initialWenCaiValues,
initialWikipediaValues,
initialYahooFinanceValues,
NodeMap,
Operator,
RestrictedUpstreamMap,
} from './constant';
import useGraphStore, { RFState } from './store';
import {
buildCategorizeObjectFromList,
generateNodeNamesWithIncreasingIndex,
getNodeDragHandle,
getRelativePositionToIterationNode,
replaceIdWithText,
} from './utils';
const selector = (state: RFState) => ({
nodes: state.nodes,
edges: state.edges,
onNodesChange: state.onNodesChange,
onEdgesChange: state.onEdgesChange,
onConnect: state.onConnect,
setNodes: state.setNodes,
onSelectionChange: state.onSelectionChange,
onEdgeMouseEnter: state.onEdgeMouseEnter,
onEdgeMouseLeave: state.onEdgeMouseLeave,
});
export const useSelectCanvasData = () => {
// return useStore(useShallow(selector)); // throw error
// return useStore(selector, shallow);
return useGraphStore(selector);
};
export const useInitializeOperatorParams = () => {
const llmId = useFetchModelId();
const initialFormValuesMap = useMemo(() => {
return {
[Operator.Begin]: initialBeginValues,
[Operator.Retrieval]: initialRetrievalValues,
[Operator.Categorize]: { ...initialCategorizeValues, llm_id: llmId },
[Operator.Relevant]: { ...initialRelevantValues, llm_id: llmId },
[Operator.RewriteQuestion]: {
...initialRewriteQuestionValues,
llm_id: llmId,
},
[Operator.Message]: initialMessageValues,
[Operator.KeywordExtract]: {
...initialKeywordExtractValues,
llm_id: llmId,
},
[Operator.DuckDuckGo]: initialDuckValues,
[Operator.Baidu]: initialBaiduValues,
[Operator.Wikipedia]: initialWikipediaValues,
[Operator.PubMed]: initialPubMedValues,
[Operator.ArXiv]: initialArXivValues,
[Operator.Google]: initialGoogleValues,
[Operator.Bing]: initialBingValues,
[Operator.GoogleScholar]: initialGoogleScholarValues,
[Operator.DeepL]: initialDeepLValues,
[Operator.SearXNG]: initialSearXNGValues,
[Operator.GitHub]: initialGithubValues,
[Operator.BaiduFanyi]: initialBaiduFanyiValues,
[Operator.QWeather]: initialQWeatherValues,
[Operator.ExeSQL]: { ...initialExeSqlValues, llm_id: llmId },
[Operator.Switch]: initialSwitchValues,
[Operator.WenCai]: initialWenCaiValues,
[Operator.AkShare]: initialAkShareValues,
[Operator.YahooFinance]: initialYahooFinanceValues,
[Operator.Jin10]: initialJin10Values,
[Operator.Concentrator]: initialConcentratorValues,
[Operator.TuShare]: initialTuShareValues,
[Operator.Note]: initialNoteValues,
[Operator.Crawler]: initialCrawlerValues,
[Operator.Invoke]: initialInvokeValues,
[Operator.Email]: initialEmailValues,
[Operator.Iteration]: initialIterationValues,
[Operator.IterationStart]: initialIterationValues,
[Operator.Code]: initialCodeValues,
[Operator.WaitingDialogue]: initialWaitingDialogueValues,
[Operator.Agent]: { ...initialAgentValues, llm_id: llmId },
[Operator.TavilySearch]: initialTavilyValues,
[Operator.TavilyExtract]: initialTavilyExtractValues,
[Operator.Tool]: {},
[Operator.UserFillUp]: initialUserFillUpValues,
[Operator.StringTransform]: initialStringTransformValues,
};
}, [llmId]);
const initializeOperatorParams = useCallback(
(operatorName: Operator) => {
return initialFormValuesMap[operatorName];
},
[initialFormValuesMap],
);
return initializeOperatorParams;
};
export const useHandleDrag = () => {
const handleDragStart = useCallback(
(operatorId: string) => (ev: React.DragEvent<HTMLDivElement>) => {
ev.dataTransfer.setData('application/@xyflow/react', operatorId);
ev.dataTransfer.effectAllowed = 'move';
},
[],
);
return { handleDragStart };
};
export const useGetNodeName = () => {
const { t } = useTranslation();
return (type: string) => {
const name = t(`flow.${lowerFirst(type)}`);
return name;
};
};
export const useHandleDrop = () => {
const addNode = useGraphStore((state) => state.addNode);
const nodes = useGraphStore((state) => state.nodes);
const [reactFlowInstance, setReactFlowInstance] =
useState<ReactFlowInstance<any, any>>();
const initializeOperatorParams = useInitializeOperatorParams();
const getNodeName = useGetNodeName();
const onDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}, []);
const onDrop = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
const type = event.dataTransfer.getData('application/@xyflow/react');
// check if the dropped element is valid
if (typeof type === 'undefined' || !type) {
return;
}
// reactFlowInstance.project was renamed to reactFlowInstance.screenToFlowPosition
// and you don't need to subtract the reactFlowBounds.left/top anymore
// details: https://@xyflow/react.dev/whats-new/2023-11-10
const position = reactFlowInstance?.screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
const newNode: Node<any> = {
id: `${type}:${humanId()}`,
type: NodeMap[type as Operator] || 'ragNode',
position: position || {
x: 0,
y: 0,
},
data: {
label: `${type}`,
name: generateNodeNamesWithIncreasingIndex(getNodeName(type), nodes),
form: initializeOperatorParams(type as Operator),
},
sourcePosition: Position.Right,
targetPosition: Position.Left,
dragHandle: getNodeDragHandle(type),
};
if (type === Operator.Iteration) {
newNode.width = 500;
newNode.height = 250;
const iterationStartNode: Node<any> = {
id: `${Operator.IterationStart}:${humanId()}`,
type: 'iterationStartNode',
position: { x: 50, y: 100 },
// draggable: false,
data: {
label: Operator.IterationStart,
name: Operator.IterationStart,
form: {},
},
parentId: newNode.id,
extent: 'parent',
};
addNode(newNode);
addNode(iterationStartNode);
} else {
const subNodeOfIteration = getRelativePositionToIterationNode(
nodes,
position,
);
if (subNodeOfIteration) {
newNode.parentId = subNodeOfIteration.parentId;
newNode.position = subNodeOfIteration.position;
newNode.extent = 'parent';
}
addNode(newNode);
}
},
[reactFlowInstance, getNodeName, nodes, initializeOperatorParams, addNode],
);
return { onDrop, onDragOver, setReactFlowInstance, reactFlowInstance };
};
export const useHandleFormValuesChange = (
operatorName: Operator,
id?: string,
form?: UseFormReturn,
) => {
const updateNodeForm = useGraphStore((state) => state.updateNodeForm);
const handleValuesChange = useCallback(
(changedValues: any, values: any) => {
let nextValues: any = values;
// Fixed the issue that the related form value does not change after selecting the freedom field of the model
if (
Object.keys(changedValues).length === 1 &&
'parameter' in changedValues &&
changedValues['parameter'] in settledModelVariableMap
) {
nextValues = {
...values,
...settledModelVariableMap[
changedValues['parameter'] as keyof typeof settledModelVariableMap
],
};
}
if (id) {
updateNodeForm(id, nextValues);
}
},
[updateNodeForm, id],
);
useEffect(() => {
const subscription = form?.watch((value, { name, type, values }) => {
if (id && name) {
console.log(
'🚀 ~ useEffect ~ value:',
name,
type,
values,
operatorName,
);
let nextValues: any = value;
// Fixed the issue that the related form value does not change after selecting the freedom field of the model
if (
name === 'parameter' &&
value['parameter'] in settledModelVariableMap
) {
nextValues = {
...value,
...settledModelVariableMap[
value['parameter'] as keyof typeof settledModelVariableMap
],
};
}
const categoryDescriptionRegex = /items\.\d+\.name/g;
if (
operatorName === Operator.Categorize &&
categoryDescriptionRegex.test(name)
) {
nextValues = {
...omit(value, 'items'),
category_description: buildCategorizeObjectFromList(value.items),
};
}
// Manually triggered form updates are synchronized to the canvas
if (type) {
updateNodeForm(id, nextValues);
}
}
});
return () => subscription?.unsubscribe();
}, [form, form?.watch, id, operatorName, updateNodeForm]);
return { handleValuesChange };
};
export const useValidateConnection = () => {
const { getOperatorTypeFromId, getParentIdById, edges, nodes } =
useGraphStore((state) => state);
const isSameNodeChild = useCallback(
(connection: Connection | Edge) => {
const sourceParentId = getParentIdById(connection.source);
const targetParentId = getParentIdById(connection.target);
if (sourceParentId || targetParentId) {
return sourceParentId === targetParentId;
}
return true;
},
[getParentIdById],
);
const hasCanvasCycle = useCallback(
(connection: Connection | Edge) => {
const target = nodes.find((node) => node.id === connection.target);
const hasCycle = (node: RAGFlowNodeType, visited = new Set()) => {
if (visited.has(node.id)) return false;
visited.add(node.id);
for (const outgoer of getOutgoers(node, nodes, edges)) {
if (outgoer.id === connection.source) return true;
if (hasCycle(outgoer, visited)) return true;
}
};
if (target?.id === connection.source) return false;
return target ? !hasCycle(target) : false;
},
[edges, nodes],
);
// restricted lines cannot be connected successfully.
const isValidConnection = useCallback(
(connection: Connection | Edge) => {
// node cannot connect to itself
const isSelfConnected = connection.target === connection.source;
// limit the connection between two nodes to only one connection line in one direction
// const hasLine = edges.some(
// (x) => x.source === connection.source && x.target === connection.target,
// );
const ret =
!isSelfConnected &&
RestrictedUpstreamMap[
getOperatorTypeFromId(connection.source) as Operator
]?.every((x) => x !== getOperatorTypeFromId(connection.target)) &&
isSameNodeChild(connection) &&
hasCanvasCycle(connection);
return ret;
},
[getOperatorTypeFromId, hasCanvasCycle, isSameNodeChild],
);
return isValidConnection;
};
export const useReplaceIdWithName = () => {
const getNode = useGraphStore((state) => state.getNode);
const replaceIdWithName = useCallback(
(id?: string) => {
return getNode(id)?.data.name;
},
[getNode],
);
return replaceIdWithName;
};
export const useReplaceIdWithText = (output: unknown) => {
const getNameById = useReplaceIdWithName();
return {
replacedOutput: replaceIdWithText(output, getNameById),
getNameById,
};
};
export const useDuplicateNode = () => {
const duplicateNodeById = useGraphStore((store) => store.duplicateNode);
const getNodeName = useGetNodeName();
const duplicateNode = useCallback(
(id: string, label: string) => {
duplicateNodeById(id, getNodeName(label));
},
[duplicateNodeById, getNodeName],
);
return duplicateNode;
};
export const useCopyPaste = () => {
const nodes = useGraphStore((state) => state.nodes);
const duplicateNode = useDuplicateNode();
const onCopyCapture = useCallback(
(event: ClipboardEvent) => {
if (get(event, 'srcElement.tagName') !== 'BODY') return;
event.preventDefault();
const nodesStr = JSON.stringify(
nodes.filter((n) => n.selected && n.data.label !== Operator.Begin),
);
event.clipboardData?.setData('agent:nodes', nodesStr);
},
[nodes],
);
const onPasteCapture = useCallback(
(event: ClipboardEvent) => {
const nodes = JSON.parse(
event.clipboardData?.getData('agent:nodes') || '[]',
) as RAGFlowNodeType[] | undefined;
if (Array.isArray(nodes) && nodes.length) {
event.preventDefault();
nodes.forEach((n) => {
duplicateNode(n.id, n.data.label);
});
}
},
[duplicateNode],
);
useEffect(() => {
window.addEventListener('copy', onCopyCapture);
return () => {
window.removeEventListener('copy', onCopyCapture);
};
}, [onCopyCapture]);
useEffect(() => {
window.addEventListener('paste', onPasteCapture);
return () => {
window.removeEventListener('paste', onPasteCapture);
};
}, [onPasteCapture]);
};