Feat: Initialize the data pipeline canvas. #9869 (#9870)

### What problem does this PR solve?
Feat: Initialize the data pipeline canvas. #9869

### Type of change


- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
balibabu
2025-09-02 15:47:33 +08:00
committed by GitHub
parent c2567844ea
commit cb14dafaca
196 changed files with 21201 additions and 0 deletions

View File

@ -0,0 +1,18 @@
.contextMenu {
background: rgba(255, 255, 255, 0.1);
border-style: solid;
box-shadow: 10px 19px 20px rgba(0, 0, 0, 10%);
position: absolute;
z-index: 10;
button {
border: none;
display: block;
padding: 0.5em;
text-align: left;
width: 100%;
}
button:hover {
background: rgba(255, 255, 255, 0.1);
}
}

View File

@ -0,0 +1,107 @@
import { NodeMouseHandler, useReactFlow } from '@xyflow/react';
import { useCallback, useRef, useState } from 'react';
import styles from './index.less';
export interface INodeContextMenu {
id: string;
top: number;
left: number;
right?: number;
bottom?: number;
[key: string]: unknown;
}
export function NodeContextMenu({
id,
top,
left,
right,
bottom,
...props
}: INodeContextMenu) {
const { getNode, setNodes, addNodes, setEdges } = useReactFlow();
const duplicateNode = useCallback(() => {
const node = getNode(id);
const position = {
x: node?.position?.x || 0 + 50,
y: node?.position?.y || 0 + 50,
};
addNodes({
...(node || {}),
data: node?.data,
selected: false,
dragging: false,
id: `${node?.id}-copy`,
position,
});
}, [id, getNode, addNodes]);
const deleteNode = useCallback(() => {
setNodes((nodes) => nodes.filter((node) => node.id !== id));
setEdges((edges) => edges.filter((edge) => edge.source !== id));
}, [id, setNodes, setEdges]);
return (
<div
style={{ top, left, right, bottom }}
className={styles.contextMenu}
{...props}
>
<p style={{ margin: '0.5em' }}>
<small>node: {id}</small>
</p>
<button onClick={duplicateNode} type={'button'}>
duplicate
</button>
<button onClick={deleteNode} type={'button'}>
delete
</button>
</div>
);
}
/* @deprecated
*/
export const useHandleNodeContextMenu = (sideWidth: number) => {
const [menu, setMenu] = useState<INodeContextMenu>({} as INodeContextMenu);
const ref = useRef<any>(null);
const onNodeContextMenu: NodeMouseHandler = useCallback(
(event, node) => {
// Prevent native context menu from showing
event.preventDefault();
// Calculate position of the context menu. We want to make sure it
// doesn't get positioned off-screen.
const pane = ref.current?.getBoundingClientRect();
// setMenu({
// id: node.id,
// top: event.clientY < pane.height - 200 ? event.clientY : 0,
// left: event.clientX < pane.width - 200 ? event.clientX : 0,
// right: event.clientX >= pane.width - 200 ? pane.width - event.clientX : 0,
// bottom:
// event.clientY >= pane.height - 200 ? pane.height - event.clientY : 0,
// });
setMenu({
id: node.id,
top: event.clientY - 144,
left: event.clientX - sideWidth,
// top: event.clientY < pane.height - 200 ? event.clientY - 72 : 0,
// left: event.clientX < pane.width - 200 ? event.clientX : 0,
});
},
[sideWidth],
);
// Close the context menu if it's open whenever the window is clicked.
const onPaneClick = useCallback(
() => setMenu({} as INodeContextMenu),
[setMenu],
);
return { onNodeContextMenu, menu, onPaneClick, ref };
};

View File

@ -0,0 +1,56 @@
import {
createContext,
ReactNode,
useCallback,
useContext,
useRef,
} from 'react';
interface DropdownContextType {
canShowDropdown: () => boolean;
setActiveDropdown: (type: 'handle' | 'drag') => void;
clearActiveDropdown: () => void;
}
const DropdownContext = createContext<DropdownContextType | null>(null);
export const useDropdownManager = () => {
const context = useContext(DropdownContext);
if (!context) {
throw new Error('useDropdownManager must be used within DropdownProvider');
}
return context;
};
interface DropdownProviderProps {
children: ReactNode;
}
export const DropdownProvider = ({ children }: DropdownProviderProps) => {
const activeDropdownRef = useRef<'handle' | 'drag' | null>(null);
const canShowDropdown = useCallback(() => {
const current = activeDropdownRef.current;
return !current;
}, []);
const setActiveDropdown = useCallback((type: 'handle' | 'drag') => {
activeDropdownRef.current = type;
}, []);
const clearActiveDropdown = useCallback(() => {
activeDropdownRef.current = null;
}, []);
const value: DropdownContextType = {
canShowDropdown,
setActiveDropdown,
clearActiveDropdown,
};
return (
<DropdownContext.Provider value={value}>
{children}
</DropdownContext.Provider>
);
};

View File

@ -0,0 +1,126 @@
import {
BaseEdge,
Edge,
EdgeLabelRenderer,
EdgeProps,
getBezierPath,
} from '@xyflow/react';
import { memo } from 'react';
import useGraphStore from '../../store';
import { useFetchAgent } from '@/hooks/use-agent-request';
import { cn } from '@/lib/utils';
import { useMemo } from 'react';
import { NodeHandleId, Operator } from '../../constant';
function InnerButtonEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
source,
target,
style = {},
markerEnd,
selected,
data,
sourceHandleId,
}: EdgeProps<Edge<{ isHovered: boolean }>>) {
const deleteEdgeById = useGraphStore((state) => state.deleteEdgeById);
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const selectedStyle = useMemo(() => {
return selected ? { strokeWidth: 1, stroke: 'rgba(76, 164, 231, 1)' } : {};
}, [selected]);
const onEdgeClick = () => {
deleteEdgeById(id);
};
// highlight the nodes that the workflow passes through
const { data: flowDetail } = useFetchAgent();
const graphPath = useMemo(() => {
// TODO: this will be called multiple times
const path = flowDetail?.dsl?.path ?? [];
// The second to last
const previousGraphPath: string[] = path.at(-2) ?? [];
let graphPath: string[] = path.at(-1) ?? [];
// The last of the second to last article
const previousLatestElement = previousGraphPath.at(-1);
if (previousGraphPath.length > 0 && previousLatestElement) {
graphPath = [previousLatestElement, ...graphPath];
}
return Array.isArray(graphPath) ? graphPath : [];
}, [flowDetail.dsl?.path]);
const highlightStyle = useMemo(() => {
const idx = graphPath.findIndex((x) => x === source);
if (idx !== -1) {
// The set of elements following source
const slicedGraphPath = graphPath.slice(idx + 1);
if (slicedGraphPath.some((x) => x === target)) {
return { strokeWidth: 1, stroke: 'red' };
}
}
return {};
}, [source, target, graphPath]);
const visible = useMemo(() => {
return (
data?.isHovered &&
sourceHandleId !== NodeHandleId.Tool &&
sourceHandleId !== NodeHandleId.AgentBottom && // The connection between the agent node and the tool node does not need to display the delete button
!target.startsWith(Operator.Tool)
);
}, [data?.isHovered, sourceHandleId, target]);
return (
<>
<BaseEdge
path={edgePath}
markerEnd={markerEnd}
style={{ ...style, ...selectedStyle, ...highlightStyle }}
className="text-text-secondary"
/>
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
fontSize: 12,
// everything inside EdgeLabelRenderer has no pointer events by default
// if you have an interactive element, set pointer-events: all
pointerEvents: 'all',
zIndex: 1001, // https://github.com/xyflow/xyflow/discussions/3498
}}
className="nodrag nopan"
>
<button
className={cn(
'size-3.5 border border-state-error text-state-error rounded-full leading-none',
'invisible',
{ visible },
)}
type="button"
onClick={onEdgeClick}
>
×
</button>
</div>
</EdgeLabelRenderer>
</>
);
}
export const ButtonEdge = memo(InnerButtonEdge);

View File

@ -0,0 +1,11 @@
.canvasWrapper {
position: relative;
height: calc(100% - 64px);
:global(.react-flow__node-group) {
.commonNode();
border-radius: 0 0 10px 10px;
padding: 0;
border: 0;
background-color: transparent;
}
}

View File

@ -0,0 +1,331 @@
import { useIsDarkTheme, useTheme } from '@/components/theme-provider';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useSetModalState } from '@/hooks/common-hooks';
import { cn } from '@/lib/utils';
import {
Connection,
ConnectionMode,
ControlButton,
Controls,
NodeTypes,
Position,
ReactFlow,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { NotebookPen } from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AgentBackground } from '../components/background';
import { AgentInstanceContext, HandleContext } from '../context';
import FormSheet from '../form-sheet/next';
import {
useHandleDrop,
useSelectCanvasData,
useValidateConnection,
} from '../hooks';
import { useAddNode } from '../hooks/use-add-node';
import { useBeforeDelete } from '../hooks/use-before-delete';
import { useMoveNote } from '../hooks/use-move-note';
import { useDropdownManager } from './context';
import {
useHideFormSheetOnNodeDeletion,
useShowDrawer,
} from '../hooks/use-show-drawer';
import RunSheet from '../run-sheet';
import { ButtonEdge } from './edge';
import styles from './index.less';
import { RagNode } from './node';
import { AgentNode } from './node/agent-node';
import { BeginNode } from './node/begin-node';
import { CategorizeNode } from './node/categorize-node';
import { InnerNextStepDropdown } from './node/dropdown/next-step-dropdown';
import { GenerateNode } from './node/generate-node';
import { InvokeNode } from './node/invoke-node';
import { IterationNode, IterationStartNode } from './node/iteration-node';
import { KeywordNode } from './node/keyword-node';
import { LogicNode } from './node/logic-node';
import { MessageNode } from './node/message-node';
import NoteNode from './node/note-node';
import { RelevantNode } from './node/relevant-node';
import { RetrievalNode } from './node/retrieval-node';
import { RewriteNode } from './node/rewrite-node';
import { SwitchNode } from './node/switch-node';
import { TemplateNode } from './node/template-node';
import { ToolNode } from './node/tool-node';
export const nodeTypes: NodeTypes = {
ragNode: RagNode,
categorizeNode: CategorizeNode,
beginNode: BeginNode,
relevantNode: RelevantNode,
logicNode: LogicNode,
noteNode: NoteNode,
switchNode: SwitchNode,
generateNode: GenerateNode,
retrievalNode: RetrievalNode,
messageNode: MessageNode,
rewriteNode: RewriteNode,
keywordNode: KeywordNode,
invokeNode: InvokeNode,
templateNode: TemplateNode,
// emailNode: EmailNode,
group: IterationNode,
iterationStartNode: IterationStartNode,
agentNode: AgentNode,
toolNode: ToolNode,
};
const edgeTypes = {
buttonEdge: ButtonEdge,
};
interface IProps {
drawerVisible: boolean;
hideDrawer(): void;
}
function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {
const { t } = useTranslation();
const {
nodes,
edges,
onConnect: originalOnConnect,
onEdgesChange,
onNodesChange,
onSelectionChange,
onEdgeMouseEnter,
onEdgeMouseLeave,
} = useSelectCanvasData();
const isValidConnection = useValidateConnection();
const { onDrop, onDragOver, setReactFlowInstance, reactFlowInstance } =
useHandleDrop();
const {
onNodeClick,
clickedNode,
formDrawerVisible,
hideFormDrawer,
singleDebugDrawerVisible,
hideSingleDebugDrawer,
showSingleDebugDrawer,
chatVisible,
runVisible,
hideRunOrChatDrawer,
showChatModal,
showFormDrawer,
} = useShowDrawer({
drawerVisible,
hideDrawer,
});
const { handleBeforeDelete } = useBeforeDelete();
const { addCanvasNode, addNoteNode } = useAddNode(reactFlowInstance);
const { ref, showImage, hideImage, imgVisible, mouse } = useMoveNote();
const { theme } = useTheme();
const isDarkTheme = useIsDarkTheme();
useHideFormSheetOnNodeDeletion({ hideFormDrawer });
const { visible, hideModal, showModal } = useSetModalState();
const [dropdownPosition, setDropdownPosition] = useState({ x: 0, y: 0 });
const isConnectedRef = useRef(false);
const connectionStartRef = useRef<{
nodeId: string;
handleId: string;
} | null>(null);
const preventCloseRef = useRef(false);
const { setActiveDropdown, clearActiveDropdown } = useDropdownManager();
const onPaneClick = useCallback(() => {
hideFormDrawer();
if (visible && !preventCloseRef.current) {
hideModal();
clearActiveDropdown();
}
if (imgVisible) {
addNoteNode(mouse);
hideImage();
}
}, [
hideFormDrawer,
visible,
hideModal,
imgVisible,
addNoteNode,
mouse,
hideImage,
clearActiveDropdown,
]);
const onConnect = (connection: Connection) => {
originalOnConnect(connection);
isConnectedRef.current = true;
};
const OnConnectStart = (event: any, params: any) => {
isConnectedRef.current = false;
if (params && params.nodeId && params.handleId) {
connectionStartRef.current = {
nodeId: params.nodeId,
handleId: params.handleId,
};
} else {
connectionStartRef.current = null;
}
};
const OnConnectEnd = (event: MouseEvent | TouchEvent) => {
if ('clientX' in event && 'clientY' in event) {
const { clientX, clientY } = event;
setDropdownPosition({ x: clientX, y: clientY });
if (!isConnectedRef.current) {
setActiveDropdown('drag');
showModal();
preventCloseRef.current = true;
setTimeout(() => {
preventCloseRef.current = false;
}, 300);
}
}
};
return (
<div className={styles.canvasWrapper}>
<svg
xmlns="http://www.w3.org/2000/svg"
style={{ position: 'absolute', top: 10, left: 0 }}
>
<defs>
<marker
fill="rgb(157 149 225)"
id="logo"
viewBox="0 0 40 40"
refX="8"
refY="5"
markerUnits="strokeWidth"
markerWidth="20"
markerHeight="20"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
</defs>
</svg>
<AgentInstanceContext.Provider value={{ addCanvasNode, showFormDrawer }}>
<ReactFlow
connectionMode={ConnectionMode.Loose}
nodes={nodes}
onNodesChange={onNodesChange}
edges={edges}
onEdgesChange={onEdgesChange}
fitView
onConnect={onConnect}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onDrop={onDrop}
onConnectStart={OnConnectStart}
onConnectEnd={OnConnectEnd}
onDragOver={onDragOver}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
onInit={setReactFlowInstance}
onSelectionChange={onSelectionChange}
nodeOrigin={[0.5, 0]}
isValidConnection={isValidConnection}
onEdgeMouseEnter={onEdgeMouseEnter}
onEdgeMouseLeave={onEdgeMouseLeave}
className="h-full"
colorMode={theme}
defaultEdgeOptions={{
type: 'buttonEdge',
markerEnd: 'logo',
style: {
strokeWidth: 1,
stroke: isDarkTheme
? 'rgba(91, 93, 106, 1)'
: 'rgba(151, 154, 171, 1)',
},
zIndex: 1001, // https://github.com/xyflow/xyflow/discussions/3498
}}
deleteKeyCode={['Delete', 'Backspace']}
onBeforeDelete={handleBeforeDelete}
>
<AgentBackground></AgentBackground>
<Controls position={'bottom-center'} orientation="horizontal">
<ControlButton>
<Tooltip>
<TooltipTrigger asChild>
<NotebookPen className="!fill-none" onClick={showImage} />
</TooltipTrigger>
<TooltipContent>{t('flow.note')}</TooltipContent>
</Tooltip>
</ControlButton>
</Controls>
</ReactFlow>
{visible && (
<HandleContext.Provider
value={{
nodeId: connectionStartRef.current?.nodeId || '',
id: connectionStartRef.current?.handleId || '',
type: 'source',
position: Position.Right,
isFromConnectionDrag: true,
}}
>
<InnerNextStepDropdown
hideModal={() => {
hideModal();
clearActiveDropdown();
}}
position={dropdownPosition}
>
<span></span>
</InnerNextStepDropdown>
</HandleContext.Provider>
)}
</AgentInstanceContext.Provider>
<NotebookPen
className={cn('hidden absolute size-6', { block: imgVisible })}
ref={ref}
></NotebookPen>
{formDrawerVisible && (
<AgentInstanceContext.Provider
value={{ addCanvasNode, showFormDrawer }}
>
<FormSheet
node={clickedNode}
visible={formDrawerVisible}
hideModal={hideFormDrawer}
chatVisible={chatVisible}
singleDebugDrawerVisible={singleDebugDrawerVisible}
hideSingleDebugDrawer={hideSingleDebugDrawer}
showSingleDebugDrawer={showSingleDebugDrawer}
></FormSheet>
</AgentInstanceContext.Provider>
)}
{runVisible && (
<RunSheet
hideModal={hideRunOrChatDrawer}
showModal={showChatModal}
></RunSheet>
)}
</div>
);
}
export default AgentCanvas;

View File

@ -0,0 +1,116 @@
import LLMLabel from '@/components/llm-select/llm-label';
import { IAgentNode } from '@/interfaces/database/flow';
import { Handle, NodeProps, Position } from '@xyflow/react';
import { get } from 'lodash';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { AgentExceptionMethod, NodeHandleId } from '../../constant';
import useGraphStore from '../../store';
import { isBottomSubAgent } from '../../utils';
import { CommonHandle } from './handle';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import styles from './index.less';
import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
import { ToolBar } from './toolbar';
function InnerAgentNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<IAgentNode>) {
const edges = useGraphStore((state) => state.edges);
const { t } = useTranslation();
const isHeadAgent = useMemo(() => {
return !isBottomSubAgent(edges, id);
}, [edges, id]);
const exceptionMethod = useMemo(() => {
return get(data, 'form.exception_method');
}, [data]);
const isGotoMethod = useMemo(() => {
return exceptionMethod === AgentExceptionMethod.Goto;
}, [exceptionMethod]);
return (
<ToolBar selected={selected} id={id} label={data.label}>
<NodeWrapper selected={selected}>
{isHeadAgent && (
<>
<CommonHandle
type="target"
position={Position.Left}
isConnectable={isConnectable}
style={LeftHandleStyle}
nodeId={id}
id={NodeHandleId.End}
></CommonHandle>
<CommonHandle
type="source"
position={Position.Right}
isConnectable={isConnectable}
className={styles.handle}
style={RightHandleStyle}
nodeId={id}
id={NodeHandleId.Start}
isConnectableEnd={false}
></CommonHandle>
</>
)}
<Handle
type="target"
position={Position.Top}
isConnectable={false}
id={NodeHandleId.AgentTop}
></Handle>
<Handle
type="source"
position={Position.Bottom}
isConnectable={false}
id={NodeHandleId.AgentBottom}
style={{ left: 180 }}
></Handle>
<Handle
type="source"
position={Position.Bottom}
isConnectable={false}
id={NodeHandleId.Tool}
style={{ left: 20 }}
></Handle>
<NodeHeader id={id} name={data.name} label={data.label}></NodeHeader>
<section className="flex flex-col gap-2">
<div className={'bg-bg-card rounded-sm p-1'}>
<LLMLabel value={get(data, 'form.llm_id')}></LLMLabel>
</div>
{(isGotoMethod ||
exceptionMethod === AgentExceptionMethod.Comment) && (
<div className="bg-bg-card rounded-sm p-1 flex justify-between gap-2">
<span className="text-text-secondary">{t('flow.onFailure')}</span>
<span className="truncate flex-1 text-right">
{t(`flow.${exceptionMethod}`)}
</span>
</div>
)}
</section>
{isGotoMethod && (
<CommonHandle
type="source"
position={Position.Right}
isConnectable={isConnectable}
className="!bg-state-error"
style={{ ...RightHandleStyle, top: 94 }}
nodeId={id}
id={NodeHandleId.AgentException}
isConnectableEnd={false}
></CommonHandle>
)}
</NodeWrapper>
</ToolBar>
);
}
export const AgentNode = memo(InnerAgentNode);

View File

@ -0,0 +1,62 @@
import { IBeginNode } from '@/interfaces/database/flow';
import { cn } from '@/lib/utils';
import { NodeProps, Position } from '@xyflow/react';
import get from 'lodash/get';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import {
BeginQueryType,
BeginQueryTypeIconMap,
NodeHandleId,
Operator,
} from '../../constant';
import { BeginQuery } from '../../interface';
import OperatorIcon from '../../operator-icon';
import { CommonHandle } from './handle';
import { RightHandleStyle } from './handle-icon';
import styles from './index.less';
import { NodeWrapper } from './node-wrapper';
// TODO: do not allow other nodes to connect to this node
function InnerBeginNode({ data, id, selected }: NodeProps<IBeginNode>) {
const { t } = useTranslation();
const inputs: Record<string, BeginQuery> = get(data, 'form.inputs', {});
return (
<NodeWrapper selected={selected}>
<CommonHandle
type="source"
position={Position.Right}
isConnectable
style={RightHandleStyle}
nodeId={id}
id={NodeHandleId.Start}
></CommonHandle>
<section className="flex items-center gap-2">
<OperatorIcon name={data.label as Operator}></OperatorIcon>
<div className="truncate text-center font-semibold text-sm">
{t(`flow.begin`)}
</div>
</section>
<section className={cn(styles.generateParameters, 'flex gap-2 flex-col')}>
{Object.entries(inputs).map(([key, val], idx) => {
const Icon = BeginQueryTypeIconMap[val.type as BeginQueryType];
return (
<div
key={idx}
className={cn(styles.conditionBlock, 'flex gap-1.5 items-center')}
>
<Icon className="size-4" />
<label htmlFor="">{key}</label>
<span className={styles.parameterValue}>{val.name}</span>
<span className="flex-1">{val.optional ? 'Yes' : 'No'}</span>
</div>
);
})}
</section>
</NodeWrapper>
);
}
export const BeginNode = memo(InnerBeginNode);

View File

@ -0,0 +1,57 @@
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export function CardWithForm() {
return (
<Card className="w-[350px]">
<CardHeader>
<CardTitle>Create project</CardTitle>
<CardDescription>Deploy your new project in one-click.</CardDescription>
</CardHeader>
<CardContent>
<form>
<div className="grid w-full items-center gap-4">
<div className="flex flex-col space-y-1.5">
<Label htmlFor="name">Name</Label>
<Input id="name" placeholder="Name of your project" />
</div>
<div className="flex flex-col space-y-1.5">
<Label htmlFor="framework">Framework</Label>
<Select>
<SelectTrigger id="framework">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="next">Next.js</SelectItem>
<SelectItem value="sveltekit">SvelteKit</SelectItem>
<SelectItem value="astro">Astro</SelectItem>
<SelectItem value="nuxt">Nuxt.js</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</form>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline">Cancel</Button>
<Button>Deploy</Button>
</CardFooter>
</Card>
);
}

View File

@ -0,0 +1,62 @@
import LLMLabel from '@/components/llm-select/llm-label';
import { ICategorizeNode } from '@/interfaces/database/flow';
import { NodeProps, Position } from '@xyflow/react';
import { get } from 'lodash';
import { memo } from 'react';
import { NodeHandleId } from '../../constant';
import { CommonHandle } from './handle';
import { RightHandleStyle } from './handle-icon';
import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
import { ToolBar } from './toolbar';
import { useBuildCategorizeHandlePositions } from './use-build-categorize-handle-positions';
export function InnerCategorizeNode({
id,
data,
selected,
}: NodeProps<ICategorizeNode>) {
const { positions } = useBuildCategorizeHandlePositions({ data, id });
return (
<ToolBar selected={selected} id={id} label={data.label}>
<NodeWrapper selected={selected}>
<CommonHandle
type="target"
position={Position.Left}
isConnectable
id={NodeHandleId.End}
nodeId={id}
></CommonHandle>
<NodeHeader id={id} name={data.name} label={data.label}></NodeHeader>
<section className="flex flex-col gap-2">
<div className={'bg-bg-card rounded-sm px-1'}>
<LLMLabel value={get(data, 'form.llm_id')}></LLMLabel>
</div>
{positions.map((position) => {
return (
<div key={position.uuid}>
<div className={'bg-bg-card rounded-sm p-1 truncate'}>
{position.name}
</div>
<CommonHandle
// key={position.text}
id={position.uuid}
type="source"
position={Position.Right}
isConnectable
style={{ ...RightHandleStyle, top: position.top }}
nodeId={id}
isConnectableEnd={false}
></CommonHandle>
</div>
);
})}
</section>
</NodeWrapper>
</ToolBar>
);
}
export const CategorizeNode = memo(InnerCategorizeNode);

View File

@ -0,0 +1,58 @@
import OperateDropdown from '@/components/operate-dropdown';
import { CopyOutlined } from '@ant-design/icons';
import { Flex, MenuProps } from 'antd';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Operator } from '../../constant';
import { useDuplicateNode } from '../../hooks';
import useGraphStore from '../../store';
interface IProps {
id: string;
iconFontColor?: string;
label: string;
}
const NodeDropdown = ({ id, iconFontColor, label }: IProps) => {
const { t } = useTranslation();
const deleteNodeById = useGraphStore((store) => store.deleteNodeById);
const deleteIterationNodeById = useGraphStore(
(store) => store.deleteIterationNodeById,
);
const deleteNode = useCallback(() => {
if (label === Operator.Iteration) {
deleteIterationNodeById(id);
} else {
deleteNodeById(id);
}
}, [label, deleteIterationNodeById, id, deleteNodeById]);
const duplicateNode = useDuplicateNode();
const items: MenuProps['items'] = [
{
key: '2',
onClick: () => duplicateNode(id, label),
label: (
<Flex justify={'space-between'}>
{t('common.copy')}
<CopyOutlined />
</Flex>
),
},
];
return (
<OperateDropdown
iconFontSize={22}
height={14}
deleteItem={deleteNode}
items={items}
needsDeletionValidation={false}
iconFontColor={iconFontColor}
></OperateDropdown>
);
};
export default NodeDropdown;

View File

@ -0,0 +1,295 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { IModalProps } from '@/interfaces/common';
import { Operator } from '@/pages/agent/constant';
import { AgentInstanceContext, HandleContext } from '@/pages/agent/context';
import OperatorIcon from '@/pages/agent/operator-icon';
import { Position } from '@xyflow/react';
import { t } from 'i18next';
import { lowerFirst } from 'lodash';
import {
PropsWithChildren,
createContext,
memo,
useContext,
useEffect,
useRef,
} from 'react';
import { useTranslation } from 'react-i18next';
type OperatorItemProps = {
operators: Operator[];
isCustomDropdown?: boolean;
mousePosition?: { x: number; y: number };
};
const HideModalContext = createContext<IModalProps<any>['showModal']>(() => {});
const OnNodeCreatedContext = createContext<
((newNodeId: string) => void) | undefined
>(undefined);
function OperatorItemList({
operators,
isCustomDropdown = false,
mousePosition,
}: OperatorItemProps) {
const { addCanvasNode } = useContext(AgentInstanceContext);
const handleContext = useContext(HandleContext);
const hideModal = useContext(HideModalContext);
const onNodeCreated = useContext(OnNodeCreatedContext);
const { t } = useTranslation();
const handleClick = (operator: Operator) => {
const contextData = handleContext || {
nodeId: '',
id: '',
type: 'source' as const,
position: Position.Right,
isFromConnectionDrag: true,
};
const mockEvent = mousePosition
? {
clientX: mousePosition.x,
clientY: mousePosition.y,
}
: undefined;
const newNodeId = addCanvasNode(operator, contextData)(mockEvent);
if (onNodeCreated && newNodeId) {
onNodeCreated(newNodeId);
}
hideModal?.();
};
const renderOperatorItem = (operator: Operator) => {
const commonContent = (
<div className="hover:bg-background-card py-1 px-3 cursor-pointer rounded-sm flex gap-2 items-center justify-start">
<OperatorIcon name={operator} />
{t(`flow.${lowerFirst(operator)}`)}
</div>
);
return (
<Tooltip key={operator}>
<TooltipTrigger asChild>
{isCustomDropdown ? (
<li onClick={() => handleClick(operator)}>{commonContent}</li>
) : (
<DropdownMenuItem
key={operator}
className="hover:bg-background-card py-1 px-3 cursor-pointer rounded-sm flex gap-2 items-center justify-start"
onClick={() => handleClick(operator)}
onSelect={() => hideModal?.()}
>
<OperatorIcon name={operator} />
{t(`flow.${lowerFirst(operator)}`)}
</DropdownMenuItem>
)}
</TooltipTrigger>
<TooltipContent side="right">
<p>{t(`flow.${lowerFirst(operator)}Description`)}</p>
</TooltipContent>
</Tooltip>
);
};
return <ul className="space-y-2">{operators.map(renderOperatorItem)}</ul>;
}
function AccordionOperators({
isCustomDropdown = false,
mousePosition,
}: {
isCustomDropdown?: boolean;
mousePosition?: { x: number; y: number };
}) {
return (
<Accordion
type="multiple"
className="px-2 text-text-title max-h-[45vh] overflow-auto"
defaultValue={['item-1', 'item-2', 'item-3', 'item-4', 'item-5']}
>
<AccordionItem value="item-1">
<AccordionTrigger className="text-xl">
{t('flow.foundation')}
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<OperatorItemList
operators={[Operator.Agent, Operator.Retrieval]}
isCustomDropdown={isCustomDropdown}
mousePosition={mousePosition}
></OperatorItemList>
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger className="text-xl">
{t('flow.dialog')}
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<OperatorItemList
operators={[Operator.Message, Operator.UserFillUp]}
isCustomDropdown={isCustomDropdown}
mousePosition={mousePosition}
></OperatorItemList>
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger className="text-xl">
{t('flow.flow')}
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<OperatorItemList
operators={[
Operator.Switch,
Operator.Iteration,
Operator.Categorize,
]}
isCustomDropdown={isCustomDropdown}
mousePosition={mousePosition}
></OperatorItemList>
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-4">
<AccordionTrigger className="text-xl">
{t('flow.dataManipulation')}
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<OperatorItemList
operators={[Operator.Code, Operator.StringTransform]}
isCustomDropdown={isCustomDropdown}
mousePosition={mousePosition}
></OperatorItemList>
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-5">
<AccordionTrigger className="text-xl">
{t('flow.tools')}
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<OperatorItemList
operators={[
Operator.TavilySearch,
Operator.TavilyExtract,
Operator.ExeSQL,
Operator.Google,
Operator.YahooFinance,
Operator.Email,
Operator.DuckDuckGo,
Operator.Wikipedia,
Operator.GoogleScholar,
Operator.ArXiv,
Operator.PubMed,
Operator.GitHub,
Operator.Invoke,
Operator.WenCai,
Operator.SearXNG,
]}
isCustomDropdown={isCustomDropdown}
mousePosition={mousePosition}
></OperatorItemList>
</AccordionContent>
</AccordionItem>
</Accordion>
);
}
export function InnerNextStepDropdown({
children,
hideModal,
position,
onNodeCreated,
}: PropsWithChildren &
IModalProps<any> & {
position?: { x: number; y: number };
onNodeCreated?: (newNodeId: string) => void;
}) {
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (position && hideModal) {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
hideModal();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}
}, [position, hideModal]);
if (position) {
return (
<div
ref={dropdownRef}
style={{
position: 'fixed',
left: position.x,
top: position.y + 10,
zIndex: 1000,
}}
onClick={(e) => e.stopPropagation()}
>
<div className="w-[300px] font-semibold bg-bg-base border border-border rounded-md shadow-lg">
<div className="px-3 py-2 border-b border-border">
<div className="text-sm font-medium">{t('flow.nextStep')}</div>
</div>
<HideModalContext.Provider value={hideModal}>
<OnNodeCreatedContext.Provider value={onNodeCreated}>
<AccordionOperators
isCustomDropdown={true}
mousePosition={position}
></AccordionOperators>
</OnNodeCreatedContext.Provider>
</HideModalContext.Provider>
</div>
</div>
);
}
return (
<DropdownMenu
open={true}
onOpenChange={(open) => {
if (!open && hideModal) {
hideModal();
}
}}
>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
onClick={(e) => e.stopPropagation()}
className="w-[300px] font-semibold"
>
<DropdownMenuLabel>{t('flow.nextStep')}</DropdownMenuLabel>
<HideModalContext.Provider value={hideModal}>
<AccordionOperators></AccordionOperators>
</HideModalContext.Provider>
</DropdownMenuContent>
</DropdownMenu>
);
}
export const NextStepDropdown = memo(InnerNextStepDropdown);

View File

@ -0,0 +1,80 @@
import { IEmailNode } from '@/interfaces/database/flow';
import { Handle, NodeProps, Position } from '@xyflow/react';
import { Flex } from 'antd';
import classNames from 'classnames';
import { memo, useState } from 'react';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import styles from './index.less';
import NodeHeader from './node-header';
export function InnerEmailNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<IEmailNode>) {
const [showDetails, setShowDetails] = useState(false);
return (
<section
className={classNames(styles.ragNode, {
[styles.selectedNode]: selected,
})}
>
<Handle
id="c"
type="source"
position={Position.Left}
isConnectable={isConnectable}
className={styles.handle}
style={LeftHandleStyle}
></Handle>
<Handle
type="source"
position={Position.Right}
isConnectable={isConnectable}
className={styles.handle}
style={RightHandleStyle}
id="b"
></Handle>
<NodeHeader id={id} name={data.name} label={data.label}></NodeHeader>
<Flex vertical gap={8} className={styles.emailNodeContainer}>
<div
className={styles.emailConfig}
onClick={() => setShowDetails(!showDetails)}
>
<div className={styles.configItem}>
<span className={styles.configLabel}>SMTP:</span>
<span className={styles.configValue}>{data.form?.smtp_server}</span>
</div>
<div className={styles.configItem}>
<span className={styles.configLabel}>Port:</span>
<span className={styles.configValue}>{data.form?.smtp_port}</span>
</div>
<div className={styles.configItem}>
<span className={styles.configLabel}>From:</span>
<span className={styles.configValue}>{data.form?.email}</span>
</div>
<div className={styles.expandIcon}>{showDetails ? '▼' : '▶'}</div>
</div>
{showDetails && (
<div className={styles.jsonExample}>
<div className={styles.jsonTitle}>Expected Input JSON:</div>
<pre className={styles.jsonContent}>
{`{
"to_email": "...",
"cc_email": "...",
"subject": "...",
"content": "..."
}`}
</pre>
</div>
)}
</Flex>
</section>
);
}
export const EmailNode = memo(InnerEmailNode);

View File

@ -0,0 +1,60 @@
import LLMLabel from '@/components/llm-select/llm-label';
import { useTheme } from '@/components/theme-provider';
import { IGenerateNode } from '@/interfaces/database/flow';
import { Handle, NodeProps, Position } from '@xyflow/react';
import classNames from 'classnames';
import { get } from 'lodash';
import { memo } from 'react';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import styles from './index.less';
import NodeHeader from './node-header';
export function InnerGenerateNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<IGenerateNode>) {
const { theme } = useTheme();
return (
<section
className={classNames(
styles.logicNode,
theme === 'dark' ? styles.dark : '',
{
[styles.selectedNode]: selected,
},
)}
>
<Handle
id="c"
type="source"
position={Position.Left}
isConnectable={isConnectable}
className={styles.handle}
style={LeftHandleStyle}
></Handle>
<Handle
type="source"
position={Position.Right}
isConnectable={isConnectable}
className={styles.handle}
style={RightHandleStyle}
id="b"
></Handle>
<NodeHeader
id={id}
name={data.name}
label={data.label}
className={styles.nodeHeader}
></NodeHeader>
<div className={styles.nodeText}>
<LLMLabel value={get(data, 'form.llm_id')}></LLMLabel>
</div>
</section>
);
}
export const GenerateNode = memo(InnerGenerateNode);

View File

@ -0,0 +1,20 @@
import { PlusOutlined } from '@ant-design/icons';
import { CSSProperties } from 'react';
export const HandleIcon = () => {
return (
<PlusOutlined
style={{ fontSize: 6, color: 'white', position: 'absolute', zIndex: 10 }}
/>
);
};
export const RightHandleStyle: CSSProperties = {
right: 0,
};
export const LeftHandleStyle: CSSProperties = {
left: 0,
};
export default HandleIcon;

View File

@ -0,0 +1,64 @@
import { useSetModalState } from '@/hooks/common-hooks';
import { cn } from '@/lib/utils';
import { Handle, HandleProps } from '@xyflow/react';
import { Plus } from 'lucide-react';
import { useMemo } from 'react';
import { HandleContext } from '../../context';
import { useDropdownManager } from '../context';
import { InnerNextStepDropdown } from './dropdown/next-step-dropdown';
export function CommonHandle({
className,
nodeId,
...props
}: HandleProps & { nodeId: string }) {
const { visible, hideModal, showModal } = useSetModalState();
const { canShowDropdown, setActiveDropdown, clearActiveDropdown } =
useDropdownManager();
const value = useMemo(
() => ({
nodeId,
id: props.id || undefined,
type: props.type,
position: props.position,
isFromConnectionDrag: false,
}),
[nodeId, props.id, props.position, props.type],
);
return (
<HandleContext.Provider value={value}>
<Handle
{...props}
className={cn(
'inline-flex justify-center items-center !bg-accent-primary !size-4 !rounded-sm !border-none ',
className,
)}
onClick={(e) => {
e.stopPropagation();
if (!canShowDropdown()) {
return;
}
setActiveDropdown('handle');
showModal();
}}
>
<Plus className="size-3 pointer-events-none text-text-title-invert" />
{visible && (
<InnerNextStepDropdown
hideModal={() => {
hideModal();
clearActiveDropdown();
}}
>
<span></span>
</InnerNextStepDropdown>
)}
</Handle>
</HandleContext.Provider>
);
}

View File

@ -0,0 +1,285 @@
.dark {
background: rgb(63, 63, 63) !important;
}
.ragNode {
.commonNode();
.nodeName {
font-size: 10px;
color: black;
}
label {
display: block;
color: #777;
font-size: 12px;
}
.description {
font-size: 10px;
}
.categorizeAnchorPointText {
position: absolute;
top: -4px;
left: 8px;
white-space: nowrap;
}
}
@lightBackgroundColor: rgba(150, 150, 150, 0.1);
@darkBackgroundColor: rgba(150, 150, 150, 0.2);
.selectedNode {
border: 1.5px solid rgb(59, 118, 244);
}
.selectedIterationNode {
border-bottom: 1.5px solid rgb(59, 118, 244);
border-left: 1.5px solid rgb(59, 118, 244);
border-right: 1.5px solid rgb(59, 118, 244);
}
.iterationHeader {
.commonNodeShadow();
}
.selectedHeader {
border-top: 1.9px solid rgb(59, 118, 244);
border-left: 1.9px solid rgb(59, 118, 244);
border-right: 1.9px solid rgb(59, 118, 244);
}
.handle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
background: rgb(59, 88, 253);
border: 1px solid white;
z-index: 1;
background-image: url('@/assets/svg/plus.svg');
background-size: cover;
background-position: center;
}
.jsonView {
word-wrap: break-word;
overflow: auto;
max-width: 300px;
max-height: 500px;
}
.logicNode {
.commonNode();
.nodeName {
font-size: 10px;
color: black;
}
label {
display: block;
color: #777;
font-size: 12px;
}
.description {
font-size: 10px;
}
.categorizeAnchorPointText {
position: absolute;
top: -4px;
left: 8px;
white-space: nowrap;
}
.relevantSourceLabel {
font-size: 10px;
}
}
.noteNode {
.commonNode();
min-width: 140px;
width: auto;
height: 100%;
padding: 8px;
border-radius: 10px;
min-height: 128px;
.noteTitle {
background-color: #edfcff;
font-size: 12px;
padding: 6px 6px 4px;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}
.noteTitleDark {
background-color: #edfcff;
font-size: 12px;
padding: 6px 6px 4px;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}
.noteForm {
margin-top: 4px;
height: calc(100% - 50px);
}
.noteName {
padding: 0px 4px;
}
.noteTextarea {
resize: none;
border: 0;
border-radius: 0;
height: 100%;
&:focus {
border: none;
box-shadow: none;
}
}
}
.iterationNode {
.commonNodeShadow();
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
}
.nodeText {
padding-inline: 0.4em;
padding-block: 0.2em 0.1em;
background: @lightBackgroundColor;
border-radius: 3px;
min-height: 22px;
.textEllipsis();
}
.nodeHeader {
padding-bottom: 12px;
}
.zeroDivider {
margin: 0 !important;
}
.conditionBlock {
border-radius: 4px;
padding: 6px;
background: @lightBackgroundColor;
}
.conditionLine {
border-radius: 4px;
padding: 0 4px;
background: @darkBackgroundColor;
.textEllipsis();
}
.conditionKey {
flex: 1;
}
.conditionOperator {
padding: 0 2px;
text-align: center;
}
.relevantLabel {
text-align: right;
}
.knowledgeNodeName {
.textEllipsis();
}
.messageNodeContainer {
overflow-y: auto;
max-height: 300px;
}
.generateParameters {
padding-top: 8px;
label {
flex: 2;
.textEllipsis();
}
.parameterValue {
flex: 3;
.conditionLine;
}
}
.emailNodeContainer {
padding: 8px;
font-size: 12px;
.emailConfig {
background: rgba(0, 0, 0, 0.02);
border-radius: 4px;
padding: 8px;
position: relative;
cursor: pointer;
&:hover {
background: rgba(0, 0, 0, 0.04);
}
.configItem {
display: flex;
align-items: center;
margin-bottom: 4px;
&:last-child {
margin-bottom: 0;
}
.configLabel {
color: #666;
width: 45px;
flex-shrink: 0;
}
.configValue {
color: #333;
word-break: break-all;
}
}
.expandIcon {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
color: #666;
font-size: 12px;
}
}
.jsonExample {
background: #f5f5f5;
border-radius: 4px;
padding: 8px;
margin-top: 4px;
animation: slideDown 0.2s ease-out;
.jsonTitle {
color: #666;
margin-bottom: 4px;
}
.jsonContent {
margin: 0;
color: #333;
font-family: monospace;
}
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@ -0,0 +1,49 @@
import { IRagNode } from '@/interfaces/database/flow';
import { NodeProps, Position } from '@xyflow/react';
import { memo } from 'react';
import { NodeHandleId } from '../../constant';
import { needsSingleStepDebugging } from '../../utils';
import { CommonHandle } from './handle';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
import { ToolBar } from './toolbar';
function InnerRagNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<IRagNode>) {
return (
<ToolBar
selected={selected}
id={id}
label={data.label}
showRun={needsSingleStepDebugging(data.label)}
>
<NodeWrapper selected={selected}>
<CommonHandle
id={NodeHandleId.End}
type="target"
position={Position.Left}
isConnectable={isConnectable}
style={LeftHandleStyle}
nodeId={id}
></CommonHandle>
<CommonHandle
type="source"
position={Position.Right}
isConnectable={isConnectable}
id={NodeHandleId.Start}
style={RightHandleStyle}
nodeId={id}
isConnectableEnd={false}
></CommonHandle>
<NodeHeader id={id} name={data.name} label={data.label}></NodeHeader>
</NodeWrapper>
</ToolBar>
);
}
export const RagNode = memo(InnerRagNode);

View File

@ -0,0 +1,62 @@
import { useTheme } from '@/components/theme-provider';
import { IInvokeNode } from '@/interfaces/database/flow';
import { Handle, NodeProps, Position } from '@xyflow/react';
import { Flex } from 'antd';
import classNames from 'classnames';
import { get } from 'lodash';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import styles from './index.less';
import NodeHeader from './node-header';
function InnerInvokeNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<IInvokeNode>) {
const { t } = useTranslation();
const { theme } = useTheme();
const url = get(data, 'form.url');
return (
<section
className={classNames(
styles.ragNode,
theme === 'dark' ? styles.dark : '',
{
[styles.selectedNode]: selected,
},
)}
>
<Handle
id="c"
type="source"
position={Position.Left}
isConnectable={isConnectable}
className={styles.handle}
style={LeftHandleStyle}
></Handle>
<Handle
type="source"
position={Position.Right}
isConnectable={isConnectable}
className={styles.handle}
id="b"
style={RightHandleStyle}
></Handle>
<NodeHeader
id={id}
name={data.name}
label={data.label}
className={styles.nodeHeader}
></NodeHeader>
<Flex vertical>
<div>{t('flow.url')}</div>
<div className={styles.nodeText}>{url}</div>
</Flex>
</section>
);
}
export const InvokeNode = memo(InnerInvokeNode);

View File

@ -0,0 +1,93 @@
import {
IIterationNode,
IIterationStartNode,
} from '@/interfaces/database/flow';
import { cn } from '@/lib/utils';
import { NodeProps, NodeResizeControl, Position } from '@xyflow/react';
import { memo } from 'react';
import { NodeHandleId, Operator } from '../../constant';
import OperatorIcon from '../../operator-icon';
import { CommonHandle } from './handle';
import { RightHandleStyle } from './handle-icon';
import styles from './index.less';
import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
import { ResizeIcon, controlStyle } from './resize-icon';
import { ToolBar } from './toolbar';
export function InnerIterationNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<IIterationNode>) {
return (
<ToolBar selected={selected} id={id} label={data.label} showRun={false}>
<section
className={cn('h-full bg-transparent rounded-b-md ', {
[styles.selectedHeader]: selected,
})}
>
<NodeResizeControl style={controlStyle} minWidth={100} minHeight={50}>
<ResizeIcon />
</NodeResizeControl>
<CommonHandle
id={NodeHandleId.End}
type="target"
position={Position.Left}
isConnectable={isConnectable}
className={styles.handle}
nodeId={id}
></CommonHandle>
<CommonHandle
id={NodeHandleId.Start}
type="source"
position={Position.Right}
isConnectable={isConnectable}
className={styles.handle}
nodeId={id}
></CommonHandle>
<NodeHeader
id={id}
name={data.name}
label={data.label}
wrapperClassName={cn(
'bg-background-header-bar p-2 rounded-t-[10px] absolute w-full top-[-44px] left-[-0.3px]',
{
[styles.selectedHeader]: selected,
},
)}
></NodeHeader>
</section>
</ToolBar>
);
}
function InnerIterationStartNode({
isConnectable = true,
id,
selected,
}: NodeProps<IIterationStartNode>) {
return (
<NodeWrapper className="w-20" selected={selected}>
<CommonHandle
type="source"
position={Position.Right}
isConnectable={isConnectable}
className={styles.handle}
style={RightHandleStyle}
isConnectableEnd={false}
id={NodeHandleId.Start}
nodeId={id}
></CommonHandle>
<div>
<OperatorIcon name={Operator.Begin}></OperatorIcon>
</div>
</NodeWrapper>
);
}
export const IterationStartNode = memo(InnerIterationStartNode);
export const IterationNode = memo(InnerIterationNode);

View File

@ -0,0 +1,60 @@
import LLMLabel from '@/components/llm-select/llm-label';
import { useTheme } from '@/components/theme-provider';
import { IKeywordNode } from '@/interfaces/database/flow';
import { Handle, NodeProps, Position } from '@xyflow/react';
import classNames from 'classnames';
import { get } from 'lodash';
import { memo } from 'react';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import styles from './index.less';
import NodeHeader from './node-header';
export function InnerKeywordNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<IKeywordNode>) {
const { theme } = useTheme();
return (
<section
className={classNames(
styles.logicNode,
theme === 'dark' ? styles.dark : '',
{
[styles.selectedNode]: selected,
},
)}
>
<Handle
id="c"
type="source"
position={Position.Left}
isConnectable={isConnectable}
className={styles.handle}
style={LeftHandleStyle}
></Handle>
<Handle
type="source"
position={Position.Right}
isConnectable={isConnectable}
className={styles.handle}
style={RightHandleStyle}
id="b"
></Handle>
<NodeHeader
id={id}
name={data.name}
label={data.label}
className={styles.nodeHeader}
></NodeHeader>
<div className={styles.nodeText}>
<LLMLabel value={get(data, 'form.llm_id')}></LLMLabel>
</div>
</section>
);
}
export const KeywordNode = memo(InnerKeywordNode);

View File

@ -0,0 +1,41 @@
import { ILogicNode } from '@/interfaces/database/flow';
import { NodeProps, Position } from '@xyflow/react';
import { memo } from 'react';
import { CommonHandle } from './handle';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
import { ToolBar } from './toolbar';
export function InnerLogicNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<ILogicNode>) {
return (
<ToolBar selected={selected} id={id} label={data.label}>
<NodeWrapper selected={selected}>
<CommonHandle
id="c"
type="source"
position={Position.Left}
isConnectable={isConnectable}
style={LeftHandleStyle}
nodeId={id}
></CommonHandle>
<CommonHandle
type="source"
position={Position.Right}
isConnectable={isConnectable}
style={RightHandleStyle}
id="b"
nodeId={id}
></CommonHandle>
<NodeHeader id={id} name={data.name} label={data.label}></NodeHeader>
</NodeWrapper>
</ToolBar>
);
}
export const LogicNode = memo(InnerLogicNode);

View File

@ -0,0 +1,65 @@
import { IMessageNode } from '@/interfaces/database/flow';
import { NodeProps, Position } from '@xyflow/react';
import { Flex } from 'antd';
import classNames from 'classnames';
import { get } from 'lodash';
import { memo } from 'react';
import { NodeHandleId } from '../../constant';
import { CommonHandle } from './handle';
import { LeftHandleStyle } from './handle-icon';
import styles from './index.less';
import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
import { ToolBar } from './toolbar';
function InnerMessageNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<IMessageNode>) {
const messages: string[] = get(data, 'form.messages', []);
return (
<ToolBar selected={selected} id={id} label={data.label}>
<NodeWrapper selected={selected}>
<CommonHandle
type="target"
position={Position.Left}
isConnectable={isConnectable}
style={LeftHandleStyle}
nodeId={id}
id={NodeHandleId.End}
></CommonHandle>
{/* <CommonHandle
type="source"
position={Position.Right}
isConnectable={isConnectable}
style={RightHandleStyle}
id={NodeHandleId.Start}
nodeId={id}
isConnectableEnd={false}
></CommonHandle> */}
<NodeHeader
id={id}
name={data.name}
label={data.label}
className={classNames({
[styles.nodeHeader]: messages.length > 0,
})}
></NodeHeader>
<Flex vertical gap={8} className={styles.messageNodeContainer}>
{messages.map((message, idx) => {
return (
<div className={styles.nodeText} key={idx}>
{message}
</div>
);
})}
</Flex>
</NodeWrapper>
</ToolBar>
);
}
export const MessageNode = memo(InnerMessageNode);

View File

@ -0,0 +1,34 @@
import { cn } from '@/lib/utils';
import { memo } from 'react';
import { Operator } from '../../constant';
import OperatorIcon from '../../operator-icon';
interface IProps {
id: string;
label: string;
name: string;
gap?: number;
className?: string;
wrapperClassName?: string;
}
const InnerNodeHeader = ({
label,
name,
className,
wrapperClassName,
}: IProps) => {
return (
<section className={cn(wrapperClassName, 'pb-4')}>
<div className={cn(className, 'flex gap-2.5')}>
<OperatorIcon name={label as Operator}></OperatorIcon>
<span className="truncate text-center font-semibold text-sm">
{name}
</span>
</div>
</section>
);
};
const NodeHeader = memo(InnerNodeHeader);
export default NodeHeader;

View File

@ -0,0 +1,18 @@
import { cn } from '@/lib/utils';
import { HTMLAttributes } from 'react';
type IProps = HTMLAttributes<HTMLDivElement> & { selected?: boolean };
export function NodeWrapper({ children, className, selected }: IProps) {
return (
<section
className={cn(
'bg-text-title-invert p-2.5 rounded-sm w-[200px] text-xs',
{ 'border border-accent-primary': selected },
className,
)}
>
{children}
</section>
);
}

View File

@ -0,0 +1,104 @@
import { NodeProps, NodeResizeControl } from '@xyflow/react';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { INoteNode } from '@/interfaces/database/flow';
import { zodResolver } from '@hookform/resolvers/zod';
import { NotebookPen } from 'lucide-react';
import { memo } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { NodeWrapper } from '../node-wrapper';
import { ResizeIcon, controlStyle } from '../resize-icon';
import { useWatchFormChange, useWatchNameFormChange } from './use-watch-change';
const FormSchema = z.object({
text: z.string(),
});
const NameFormSchema = z.object({
name: z.string(),
});
function NoteNode({ data, id, selected }: NodeProps<INoteNode>) {
const { t } = useTranslation();
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: data.form,
});
const nameForm = useForm<z.infer<typeof NameFormSchema>>({
resolver: zodResolver(NameFormSchema),
defaultValues: { name: data.name },
});
useWatchFormChange(id, form);
useWatchNameFormChange(id, nameForm);
return (
<NodeWrapper
className="p-0 w-full h-full flex flex-col"
selected={selected}
>
<NodeResizeControl minWidth={190} minHeight={128} style={controlStyle}>
<ResizeIcon />
</NodeResizeControl>
<section className="p-2 flex gap-2 bg-background-note items-center note-drag-handle rounded-t">
<NotebookPen className="size-4" />
<Form {...nameForm}>
<form className="flex-1">
<FormField
control={nameForm.control}
name="name"
render={({ field }) => (
<FormItem className="h-full">
<FormControl>
<Input
placeholder={t('flow.notePlaceholder')}
{...field}
type="text"
className="bg-transparent border-none focus-visible:outline focus-visible:outline-text-sub-title"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</section>
<Form {...form}>
<form className="flex-1 p-1">
<FormField
control={form.control}
name="text"
render={({ field }) => (
<FormItem className="h-full">
<FormControl>
<Textarea
placeholder={t('flow.notePlaceholder')}
className="resize-none rounded-none p-1 h-full overflow-auto bg-transparent focus-visible:ring-0 border-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</NodeWrapper>
);
}
export default memo(NoteNode);

View File

@ -0,0 +1,30 @@
import useGraphStore from '@/pages/agent/store';
import { useEffect } from 'react';
import { UseFormReturn, useWatch } from 'react-hook-form';
export function useWatchFormChange(id?: string, form?: UseFormReturn<any>) {
let values = useWatch({ control: form?.control });
const updateNodeForm = useGraphStore((state) => state.updateNodeForm);
useEffect(() => {
// Manually triggered form updates are synchronized to the canvas
if (id) {
values = form?.getValues() || {};
let nextValues: any = values;
updateNodeForm(id, nextValues);
}
}, [id, updateNodeForm, values]);
}
export function useWatchNameFormChange(id?: string, form?: UseFormReturn<any>) {
let values = useWatch({ control: form?.control });
const updateNodeName = useGraphStore((state) => state.updateNodeName);
useEffect(() => {
// Manually triggered form updates are synchronized to the canvas
if (id) {
updateNodeName(id, values.name);
}
}, [id, updateNodeName, values]);
}

View File

@ -0,0 +1,121 @@
import get from 'lodash/get';
import React, { MouseEventHandler, useCallback, useMemo } from 'react';
import JsonView from 'react18-json-view';
import 'react18-json-view/src/style.css';
import { useReplaceIdWithText } from '../../hooks';
import { useTheme } from '@/components/theme-provider';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useTranslate } from '@/hooks/common-hooks';
import { useFetchAgent } from '@/hooks/use-agent-request';
import { useGetComponentLabelByValue } from '../../hooks/use-get-begin-query';
interface IProps extends React.PropsWithChildren {
nodeId: string;
name?: string;
}
export function NextNodePopover({ children, nodeId, name }: IProps) {
const { t } = useTranslate('flow');
const { data } = useFetchAgent();
const { theme } = useTheme();
const component = useMemo(() => {
return get(data, ['dsl', 'components', nodeId], {});
}, [nodeId, data]);
const inputs: Array<{ component_id: string; content: string }> = get(
component,
['obj', 'inputs'],
[],
);
const output = get(component, ['obj', 'output'], {});
const { replacedOutput } = useReplaceIdWithText(output);
const stopPropagation: MouseEventHandler = useCallback((e) => {
e.stopPropagation();
}, []);
const getLabel = useGetComponentLabelByValue(nodeId);
return (
<Popover>
<PopoverTrigger onClick={stopPropagation} asChild>
{children}
</PopoverTrigger>
<PopoverContent
align={'start'}
side={'right'}
sideOffset={20}
onClick={stopPropagation}
className="w-[400px]"
>
<div className="mb-3 font-semibold text-[16px]">
{name} {t('operationResults')}
</div>
<div className="flex w-full gap-4 flex-col">
<div className="flex flex-col space-y-1.5">
<span className="font-semibold text-[14px]">{t('input')}</span>
<div
style={
theme === 'dark'
? {
backgroundColor: 'rgba(150, 150, 150, 0.2)',
}
: {}
}
className={`bg-gray-100 p-1 rounded`}
>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('componentId')}</TableHead>
<TableHead className="w-[60px]">{t('content')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{inputs.map((x, idx) => (
<TableRow key={idx}>
<TableCell>{getLabel(x.component_id)}</TableCell>
<TableCell className="truncate">{x.content}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<div className="flex flex-col space-y-1.5">
<span className="font-semibold text-[14px]">{t('output')}</span>
<div
style={
theme === 'dark'
? {
backgroundColor: 'rgba(150, 150, 150, 0.2)',
}
: {}
}
className="bg-gray-100 p-1 rounded"
>
<JsonView
src={replacedOutput}
displaySize={30}
className="w-full max-h-[300px] break-words overflow-auto"
/>
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}

View File

@ -0,0 +1,73 @@
import { Handle, NodeProps, Position } from '@xyflow/react';
import { Flex } from 'antd';
import classNames from 'classnames';
import { RightHandleStyle } from './handle-icon';
import { useTheme } from '@/components/theme-provider';
import { IRelevantNode } from '@/interfaces/database/flow';
import { get } from 'lodash';
import { memo } from 'react';
import { useReplaceIdWithName } from '../../hooks';
import styles from './index.less';
import NodeHeader from './node-header';
function InnerRelevantNode({ id, data, selected }: NodeProps<IRelevantNode>) {
const yes = get(data, 'form.yes');
const no = get(data, 'form.no');
const replaceIdWithName = useReplaceIdWithName();
const { theme } = useTheme();
return (
<section
className={classNames(
styles.logicNode,
theme === 'dark' ? styles.dark : '',
{
[styles.selectedNode]: selected,
},
)}
>
<Handle
type="target"
position={Position.Left}
isConnectable
className={styles.handle}
id={'a'}
></Handle>
<Handle
type="source"
position={Position.Right}
isConnectable
className={styles.handle}
id={'yes'}
style={{ ...RightHandleStyle, top: 57 + 20 }}
></Handle>
<Handle
type="source"
position={Position.Right}
isConnectable
className={styles.handle}
id={'no'}
style={{ ...RightHandleStyle, top: 115 + 20 }}
></Handle>
<NodeHeader
id={id}
name={data.name}
label={data.label}
className={styles.nodeHeader}
></NodeHeader>
<Flex vertical gap={10}>
<Flex vertical>
<div className={styles.relevantLabel}>Yes</div>
<div className={styles.nodeText}>{replaceIdWithName(yes)}</div>
</Flex>
<Flex vertical>
<div className={styles.relevantLabel}>No</div>
<div className={styles.nodeText}>{replaceIdWithName(no)}</div>
</Flex>
</Flex>
</section>
);
}
export const RelevantNode = memo(InnerRelevantNode);

View File

@ -0,0 +1,32 @@
export function ResizeIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="rgba(76, 164, 231, 1)"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
style={{
position: 'absolute',
right: 5,
bottom: 5,
}}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="16 20 20 20 20 16" />
<line x1="14" y1="14" x2="20" y2="20" />
<polyline points="8 4 4 4 4 8" />
<line x1="4" y1="4" x2="10" y2="10" />
</svg>
);
}
export const controlStyle = {
background: 'transparent',
border: 'none',
cursor: 'nwse-resize',
};

View File

@ -0,0 +1,84 @@
import { RAGFlowAvatar } from '@/components/ragflow-avatar';
import { useFetchKnowledgeList } from '@/hooks/knowledge-hooks';
import { IRetrievalNode } from '@/interfaces/database/flow';
import { NodeProps, Position } from '@xyflow/react';
import classNames from 'classnames';
import { get } from 'lodash';
import { memo } from 'react';
import { NodeHandleId } from '../../constant';
import { useGetVariableLabelByValue } from '../../hooks/use-get-begin-query';
import { CommonHandle } from './handle';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import styles from './index.less';
import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
import { ToolBar } from './toolbar';
function InnerRetrievalNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<IRetrievalNode>) {
const knowledgeBaseIds: string[] = get(data, 'form.kb_ids', []);
const { list: knowledgeList } = useFetchKnowledgeList(true);
const getLabel = useGetVariableLabelByValue(id);
return (
<ToolBar selected={selected} id={id} label={data.label}>
<NodeWrapper selected={selected}>
<CommonHandle
id={NodeHandleId.End}
type="target"
position={Position.Left}
isConnectable={isConnectable}
className={styles.handle}
style={LeftHandleStyle}
nodeId={id}
></CommonHandle>
<CommonHandle
id={NodeHandleId.Start}
type="source"
position={Position.Right}
isConnectable={isConnectable}
className={styles.handle}
style={RightHandleStyle}
nodeId={id}
isConnectableEnd={false}
></CommonHandle>
<NodeHeader
id={id}
name={data.name}
label={data.label}
className={classNames({
[styles.nodeHeader]: knowledgeBaseIds.length > 0,
})}
></NodeHeader>
<section className="flex flex-col gap-2">
{knowledgeBaseIds.map((id) => {
const item = knowledgeList.find((y) => id === y.id);
const label = getLabel(id);
return (
<div className={styles.nodeText} key={id}>
<div className="flex items-center gap-1.5">
<RAGFlowAvatar
className="size-6 rounded-lg"
avatar={id}
name={item?.name || (label as string) || 'CN'}
isPerson={true}
/>
<div className={'truncate flex-1'}>{label || item?.name}</div>
</div>
</div>
);
})}
</section>
</NodeWrapper>
</ToolBar>
);
}
export const RetrievalNode = memo(InnerRetrievalNode);

View File

@ -0,0 +1,60 @@
import LLMLabel from '@/components/llm-select/llm-label';
import { useTheme } from '@/components/theme-provider';
import { IRewriteNode } from '@/interfaces/database/flow';
import { Handle, NodeProps, Position } from '@xyflow/react';
import classNames from 'classnames';
import { get } from 'lodash';
import { memo } from 'react';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import styles from './index.less';
import NodeHeader from './node-header';
function InnerRewriteNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<IRewriteNode>) {
const { theme } = useTheme();
return (
<section
className={classNames(
styles.logicNode,
theme === 'dark' ? styles.dark : '',
{
[styles.selectedNode]: selected,
},
)}
>
<Handle
id="c"
type="source"
position={Position.Left}
isConnectable={isConnectable}
className={styles.handle}
style={LeftHandleStyle}
></Handle>
<Handle
type="source"
position={Position.Right}
isConnectable={isConnectable}
className={styles.handle}
style={RightHandleStyle}
id="b"
></Handle>
<NodeHeader
id={id}
name={data.name}
label={data.label}
className={styles.nodeHeader}
></NodeHeader>
<div className={styles.nodeText}>
<LLMLabel value={get(data, 'form.llm_id')}></LLMLabel>
</div>
</section>
);
}
export const RewriteNode = memo(InnerRewriteNode);

View File

@ -0,0 +1,118 @@
import { Card, CardContent } from '@/components/ui/card';
import { ISwitchCondition, ISwitchNode } from '@/interfaces/database/flow';
import { NodeProps, Position } from '@xyflow/react';
import { memo, useCallback } from 'react';
import { NodeHandleId, SwitchOperatorOptions } from '../../constant';
import { LogicalOperatorIcon } from '../../form/switch-form';
import { useGetVariableLabelByValue } from '../../hooks/use-get-begin-query';
import { CommonHandle } from './handle';
import { RightHandleStyle } from './handle-icon';
import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
import { ToolBar } from './toolbar';
import { useBuildSwitchHandlePositions } from './use-build-switch-handle-positions';
const getConditionKey = (idx: number, length: number) => {
if (idx === 0 && length !== 1) {
return 'If';
} else if (idx === length - 1) {
return 'Else';
}
return 'ElseIf';
};
const ConditionBlock = ({
condition,
nodeId,
}: { condition: ISwitchCondition } & { nodeId: string }) => {
const items = condition?.items ?? [];
const getLabel = useGetVariableLabelByValue(nodeId);
const renderOperatorIcon = useCallback((operator?: string) => {
const item = SwitchOperatorOptions.find((x) => x.value === operator);
if (item) {
return (
<LogicalOperatorIcon
icon={item?.icon}
value={item?.value}
></LogicalOperatorIcon>
);
}
return <></>;
}, []);
return (
<Card>
<CardContent className="p-0 divide-y divide-background-card">
{items.map((x, idx) => (
<div key={idx}>
<section className="flex justify-between gap-2 items-center text-xs p-1">
<div className="flex-1 truncate text-accent-primary">
{getLabel(x?.cpn_id)}
</div>
<span>{renderOperatorIcon(x?.operator)}</span>
<div className="flex-1 truncate">{x?.value}</div>
</section>
</div>
))}
</CardContent>
</Card>
);
};
function InnerSwitchNode({ id, data, selected }: NodeProps<ISwitchNode>) {
const { positions } = useBuildSwitchHandlePositions({ data, id });
return (
<ToolBar selected={selected} id={id} label={data.label} showRun={false}>
<NodeWrapper selected={selected}>
<CommonHandle
type="target"
position={Position.Left}
isConnectable
nodeId={id}
id={NodeHandleId.End}
></CommonHandle>
<NodeHeader id={id} name={data.name} label={data.label}></NodeHeader>
<section className="gap-2.5 flex flex-col">
{positions.map((position, idx) => {
return (
<div key={idx}>
<section className="flex flex-col text-xs">
<div className="text-right">
<span>{getConditionKey(idx, positions.length)}</span>
<div className="text-text-secondary">
{idx < positions.length - 1 && position.text}
</div>
</div>
<span className="text-accent-primary">
{idx < positions.length - 1 &&
position.condition?.logical_operator?.toUpperCase()}
</span>
{position.condition && (
<ConditionBlock
condition={position.condition}
nodeId={id}
></ConditionBlock>
)}
</section>
<CommonHandle
key={position.text}
id={position.text}
type="source"
position={Position.Right}
isConnectable
style={{ ...RightHandleStyle, top: position.top }}
nodeId={id}
isConnectableEnd={false}
></CommonHandle>
</div>
);
})}
</section>
</NodeWrapper>
</ToolBar>
);
}
export const SwitchNode = memo(InnerSwitchNode);

View File

@ -0,0 +1,78 @@
import { useTheme } from '@/components/theme-provider';
import { Handle, NodeProps, Position } from '@xyflow/react';
import { Flex } from 'antd';
import classNames from 'classnames';
import { get } from 'lodash';
import { useGetComponentLabelByValue } from '../../hooks/use-get-begin-query';
import { IGenerateParameter } from '../../interface';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import NodeHeader from './node-header';
import { ITemplateNode } from '@/interfaces/database/flow';
import { memo } from 'react';
import styles from './index.less';
function InnerTemplateNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<ITemplateNode>) {
const parameters: IGenerateParameter[] = get(data, 'form.parameters', []);
const getLabel = useGetComponentLabelByValue(id);
const { theme } = useTheme();
return (
<section
className={classNames(
styles.logicNode,
theme === 'dark' ? styles.dark : '',
{
[styles.selectedNode]: selected,
},
)}
>
<Handle
id="c"
type="source"
position={Position.Left}
isConnectable={isConnectable}
className={styles.handle}
style={LeftHandleStyle}
></Handle>
<Handle
type="source"
position={Position.Right}
isConnectable={isConnectable}
className={styles.handle}
style={RightHandleStyle}
id="b"
></Handle>
<NodeHeader
id={id}
name={data.name}
label={data.label}
className={styles.nodeHeader}
></NodeHeader>
<Flex gap={8} vertical className={styles.generateParameters}>
{parameters.map((x) => (
<Flex
key={x.id}
align="center"
gap={6}
className={styles.conditionBlock}
>
<label htmlFor="">{x.key}</label>
<span className={styles.parameterValue}>
{getLabel(x.component_id)}
</span>
</Flex>
))}
</Flex>
</section>
);
}
export const TemplateNode = memo(InnerTemplateNode);

View File

@ -0,0 +1,83 @@
import { IAgentForm, IToolNode } from '@/interfaces/database/agent';
import { Handle, NodeProps, Position } from '@xyflow/react';
import { get } from 'lodash';
import { MouseEventHandler, memo, useCallback } from 'react';
import { NodeHandleId, Operator } from '../../constant';
import { ToolCard } from '../../form/agent-form/agent-tools';
import { useFindMcpById } from '../../hooks/use-find-mcp-by-id';
import OperatorIcon from '../../operator-icon';
import useGraphStore from '../../store';
import { NodeWrapper } from './node-wrapper';
function InnerToolNode({
id,
isConnectable = true,
selected,
}: NodeProps<IToolNode>) {
const { edges, getNode } = useGraphStore((state) => state);
const upstreamAgentNodeId = edges.find((x) => x.target === id)?.source;
const upstreamAgentNode = getNode(upstreamAgentNodeId);
const { findMcpById } = useFindMcpById();
const handleClick = useCallback(
(operator: string): MouseEventHandler<HTMLLIElement> =>
(e) => {
if (operator === Operator.Code) {
e.preventDefault();
e.stopPropagation();
}
},
[],
);
const tools: IAgentForm['tools'] = get(
upstreamAgentNode,
'data.form.tools',
[],
);
const mcpList: IAgentForm['mcp'] = get(
upstreamAgentNode,
'data.form.mcp',
[],
);
return (
<NodeWrapper selected={selected}>
<Handle
id={NodeHandleId.End}
type="target"
position={Position.Top}
isConnectable={isConnectable}
></Handle>
<ul className="space-y-2">
{tools.map((x) => (
<ToolCard
key={x.component_name}
onClick={handleClick(x.component_name)}
className="cursor-pointer"
data-tool={x.component_name}
>
<div className="flex gap-1 items-center pointer-events-none">
<OperatorIcon name={x.component_name as Operator}></OperatorIcon>
{x.component_name}
</div>
</ToolCard>
))}
{mcpList.map((x) => (
<ToolCard
key={x.mcp_id}
onClick={handleClick(x.mcp_id)}
className="cursor-pointer"
data-tool={x.mcp_id}
>
{findMcpById(x.mcp_id)?.name}
</ToolCard>
))}
</ul>
</NodeWrapper>
);
}
export const ToolNode = memo(InnerToolNode);

View File

@ -0,0 +1,88 @@
import {
TooltipContent,
TooltipNode,
TooltipTrigger,
} from '@/components/xyflow/tooltip-node';
import { Position } from '@xyflow/react';
import { Copy, Play, Trash2 } from 'lucide-react';
import {
HTMLAttributes,
MouseEventHandler,
PropsWithChildren,
useCallback,
} from 'react';
import { Operator } from '../../constant';
import { useDuplicateNode } from '../../hooks';
import useGraphStore from '../../store';
function IconWrapper({ children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div className="p-1.5 bg-text-title rounded-sm cursor-pointer" {...props}>
{children}
</div>
);
}
type ToolBarProps = {
selected?: boolean | undefined;
label: string;
id: string;
showRun?: boolean;
} & PropsWithChildren;
export function ToolBar({
selected,
children,
label,
id,
showRun = true,
}: ToolBarProps) {
const deleteNodeById = useGraphStore((store) => store.deleteNodeById);
const deleteIterationNodeById = useGraphStore(
(store) => store.deleteIterationNodeById,
);
const deleteNode: MouseEventHandler<HTMLDivElement> = useCallback(
(e) => {
e.stopPropagation();
if (label === Operator.Iteration) {
deleteIterationNodeById(id);
} else {
deleteNodeById(id);
}
},
[deleteIterationNodeById, deleteNodeById, id, label],
);
const duplicateNode = useDuplicateNode();
const handleDuplicate: MouseEventHandler<HTMLDivElement> = useCallback(
(e) => {
e.stopPropagation();
duplicateNode(id, label);
},
[duplicateNode, id, label],
);
return (
<TooltipNode selected={selected}>
<TooltipTrigger>{children}</TooltipTrigger>
<TooltipContent position={Position.Top}>
<section className="flex gap-2 items-center">
{showRun && (
<IconWrapper>
<Play className="size-3.5" data-play />
</IconWrapper>
)}{' '}
<IconWrapper onClick={handleDuplicate}>
<Copy className="size-3.5" />
</IconWrapper>
<IconWrapper onClick={deleteNode}>
<Trash2 className="size-3.5" />
</IconWrapper>
</section>
</TooltipContent>
</TooltipNode>
);
}

View File

@ -0,0 +1,48 @@
import { RAGFlowNodeType } from '@/interfaces/database/flow';
import { useUpdateNodeInternals } from '@xyflow/react';
import { get } from 'lodash';
import { useEffect, useMemo } from 'react';
import { z } from 'zod';
import { useCreateCategorizeFormSchema } from '../../form/categorize-form/use-form-schema';
export const useBuildCategorizeHandlePositions = ({
data,
id,
}: {
id: string;
data: RAGFlowNodeType['data'];
}) => {
const updateNodeInternals = useUpdateNodeInternals();
const FormSchema = useCreateCategorizeFormSchema();
type FormSchemaType = z.infer<typeof FormSchema>;
const items: Required<FormSchemaType['items']> = useMemo(() => {
return get(data, `form.items`, []);
}, [data]);
const positions = useMemo(() => {
const list: Array<{
top: number;
name: string;
uuid: string;
}> &
Required<FormSchemaType['items']> = [];
items.forEach((x, idx) => {
list.push({
...x,
top: idx === 0 ? 86 : list[idx - 1].top + 8 + 24,
});
});
return list;
}, [items]);
useEffect(() => {
updateNodeInternals(id);
}, [id, updateNodeInternals, items]);
return { positions };
};

View File

@ -0,0 +1,59 @@
import { ISwitchCondition, RAGFlowNodeType } from '@/interfaces/database/flow';
import { useUpdateNodeInternals } from '@xyflow/react';
import get from 'lodash/get';
import { useEffect, useMemo } from 'react';
import { SwitchElseTo } from '../../constant';
import { generateSwitchHandleText } from '../../utils';
export const useBuildSwitchHandlePositions = ({
data,
id,
}: {
id: string;
data: RAGFlowNodeType['data'];
}) => {
const updateNodeInternals = useUpdateNodeInternals();
const conditions: ISwitchCondition[] = useMemo(() => {
return get(data, 'form.conditions', []);
}, [data]);
const positions = useMemo(() => {
const list: Array<{
text: string;
top: number;
idx: number;
condition?: ISwitchCondition;
}> = [];
[...conditions, ''].forEach((x, idx) => {
let top = idx === 0 ? 53 : list[idx - 1].top + 10 + 14 + 16 + 16; // case number (Case 1) height + flex gap
if (idx >= 1) {
const previousItems = conditions[idx - 1]?.items ?? [];
if (previousItems.length > 0) {
// top += 12; // ConditionBlock padding
top += previousItems.length * 26; // condition variable height
// top += (previousItems.length - 1) * 25; // operator height
}
}
list.push({
text:
idx < conditions.length
? generateSwitchHandleText(idx)
: SwitchElseTo,
idx,
top,
condition: typeof x === 'string' ? undefined : x,
});
});
return list;
}, [conditions]);
useEffect(() => {
updateNodeInternals(id);
}, [id, updateNodeInternals, conditions]);
return { positions };
};

View File

@ -0,0 +1,13 @@
import { useIsDarkTheme } from '@/components/theme-provider';
import { Background } from '@xyflow/react';
export function AgentBackground() {
const isDarkTheme = useIsDarkTheme();
return (
<Background
color={isDarkTheme ? 'rgba(255,255,255,0.15)' : '#A8A9B3'}
bgColor={isDarkTheme ? 'rgba(11, 11, 12, 1)' : 'rgba(0, 0, 0, 0.05)'}
/>
);
}

View File

@ -0,0 +1,947 @@
import {
initialKeywordsSimilarityWeightValue,
initialSimilarityThresholdValue,
} from '@/components/similarity-slider';
import {
AgentGlobals,
CodeTemplateStrMap,
ProgrammingLanguage,
} from '@/constants/agent';
export enum AgentDialogueMode {
Conversational = 'conversational',
Task = 'task',
}
import {
ChatVariableEnabledField,
variableEnabledFieldMap,
} from '@/constants/chat';
import { ModelVariableType } from '@/constants/knowledge';
import i18n from '@/locales/config';
import { setInitialChatVariableEnabledFieldValue } from '@/utils/chat';
import { t } from 'i18next';
// DuckDuckGo's channel options
export enum Channel {
Text = 'text',
News = 'news',
}
export enum PromptRole {
User = 'user',
Assistant = 'assistant',
}
import {
Circle,
CircleSlash2,
CloudUpload,
ListOrdered,
OptionIcon,
TextCursorInput,
ToggleLeft,
WrapText,
} from 'lucide-react';
export const BeginId = 'begin';
export enum Operator {
Begin = 'Begin',
Retrieval = 'Retrieval',
Categorize = 'Categorize',
Message = 'Message',
Relevant = 'Relevant',
RewriteQuestion = 'RewriteQuestion',
KeywordExtract = 'KeywordExtract',
Baidu = 'Baidu',
DuckDuckGo = 'DuckDuckGo',
Wikipedia = 'Wikipedia',
PubMed = 'PubMed',
ArXiv = 'ArXiv',
Google = 'Google',
Bing = 'Bing',
GoogleScholar = 'GoogleScholar',
DeepL = 'DeepL',
GitHub = 'GitHub',
BaiduFanyi = 'BaiduFanyi',
QWeather = 'QWeather',
ExeSQL = 'ExeSQL',
Switch = 'Switch',
WenCai = 'WenCai',
AkShare = 'AkShare',
YahooFinance = 'YahooFinance',
Jin10 = 'Jin10',
Concentrator = 'Concentrator',
TuShare = 'TuShare',
Note = 'Note',
Crawler = 'Crawler',
Invoke = 'Invoke',
Email = 'Email',
Iteration = 'Iteration',
IterationStart = 'IterationItem',
Code = 'CodeExec',
WaitingDialogue = 'WaitingDialogue',
Agent = 'Agent',
Tool = 'Tool',
TavilySearch = 'TavilySearch',
TavilyExtract = 'TavilyExtract',
UserFillUp = 'UserFillUp',
StringTransform = 'StringTransform',
SearXNG = 'SearXNG',
}
export const SwitchLogicOperatorOptions = ['and', 'or'];
export const CommonOperatorList = Object.values(Operator).filter(
(x) => x !== Operator.Note,
);
export const AgentOperatorList = [
Operator.Retrieval,
Operator.Categorize,
Operator.Message,
Operator.RewriteQuestion,
Operator.KeywordExtract,
Operator.Switch,
Operator.Concentrator,
Operator.Iteration,
Operator.WaitingDialogue,
Operator.Note,
Operator.Agent,
];
export const componentMenuList = [
{
name: Operator.Retrieval,
},
{
name: Operator.Categorize,
},
{
name: Operator.Message,
},
{
name: Operator.RewriteQuestion,
},
{
name: Operator.KeywordExtract,
},
{
name: Operator.Switch,
},
{
name: Operator.Concentrator,
},
{
name: Operator.Iteration,
},
{
name: Operator.Code,
},
{
name: Operator.WaitingDialogue,
},
{
name: Operator.Agent,
},
{
name: Operator.Note,
},
{
name: Operator.DuckDuckGo,
},
{
name: Operator.Baidu,
},
{
name: Operator.Wikipedia,
},
{
name: Operator.PubMed,
},
{
name: Operator.ArXiv,
},
{
name: Operator.Google,
},
{
name: Operator.Bing,
},
{
name: Operator.GoogleScholar,
},
{
name: Operator.DeepL,
},
{
name: Operator.GitHub,
},
{
name: Operator.BaiduFanyi,
},
{
name: Operator.QWeather,
},
{
name: Operator.ExeSQL,
},
{
name: Operator.WenCai,
},
{
name: Operator.AkShare,
},
{
name: Operator.YahooFinance,
},
{
name: Operator.Jin10,
},
{
name: Operator.TuShare,
},
{
name: Operator.Crawler,
},
{
name: Operator.Invoke,
},
{
name: Operator.Email,
},
{
name: Operator.SearXNG,
},
];
export const SwitchOperatorOptions = [
{ value: '=', label: 'equal', icon: 'equal' },
{ value: '≠', label: 'notEqual', icon: 'not-equals' },
{ value: '>', label: 'gt', icon: 'Less' },
{ value: '≥', label: 'ge', icon: 'Greater-or-equal' },
{ value: '<', label: 'lt', icon: 'Less' },
{ value: '≤', label: 'le', icon: 'less-or-equal' },
{ value: 'contains', label: 'contains', icon: 'Contains' },
{ value: 'not contains', label: 'notContains', icon: 'not-contains' },
{ value: 'start with', label: 'startWith', icon: 'list-start' },
{ value: 'end with', label: 'endWith', icon: 'list-end' },
{
value: 'empty',
label: 'empty',
icon: <Circle className="size-4" />,
},
{
value: 'not empty',
label: 'notEmpty',
icon: <CircleSlash2 className="size-4" />,
},
];
export const SwitchElseTo = 'end_cpn_ids';
const initialQueryBaseValues = {
query: [],
};
export const initialRetrievalValues = {
query: AgentGlobals.SysQuery,
top_n: 8,
top_k: 1024,
kb_ids: [],
rerank_id: '',
empty_response: '',
...initialSimilarityThresholdValue,
...initialKeywordsSimilarityWeightValue,
use_kg: false,
cross_languages: [],
outputs: {
formalized_content: {
type: 'string',
value: '',
},
},
};
export const initialBeginValues = {
mode: AgentDialogueMode.Conversational,
prologue: `Hi! I'm your assistant. What can I do for you?`,
};
export const variableCheckBoxFieldMap = Object.keys(
variableEnabledFieldMap,
).reduce<Record<string, boolean>>((pre, cur) => {
pre[cur] = setInitialChatVariableEnabledFieldValue(
cur as ChatVariableEnabledField,
);
return pre;
}, {});
const initialLlmBaseValues = {
...variableCheckBoxFieldMap,
temperature: 0.1,
top_p: 0.3,
frequency_penalty: 0.7,
presence_penalty: 0.4,
max_tokens: 256,
};
export const initialGenerateValues = {
...initialLlmBaseValues,
prompt: i18n.t('flow.promptText'),
cite: true,
message_history_window_size: 12,
parameters: [],
};
export const initialRewriteQuestionValues = {
...initialLlmBaseValues,
language: '',
message_history_window_size: 6,
};
export const initialRelevantValues = {
...initialLlmBaseValues,
};
export const initialCategorizeValues = {
...initialLlmBaseValues,
query: AgentGlobals.SysQuery,
parameter: ModelVariableType.Precise,
message_history_window_size: 1,
items: [],
outputs: {
category_name: {
type: 'string',
},
},
};
export const initialMessageValues = {
content: [''],
};
export const initialKeywordExtractValues = {
...initialLlmBaseValues,
top_n: 3,
...initialQueryBaseValues,
};
export const initialDuckValues = {
top_n: 10,
channel: Channel.Text,
query: AgentGlobals.SysQuery,
outputs: {
formalized_content: {
value: '',
type: 'string',
},
json: {
value: [],
type: 'Array<Object>',
},
},
};
export const initialSearXNGValues = {
top_n: '10',
searxng_url: '',
query: AgentGlobals.SysQuery,
outputs: {
formalized_content: {
value: '',
type: 'string',
},
json: {
value: [],
type: 'Array<Object>',
},
},
};
export const initialBaiduValues = {
top_n: 10,
...initialQueryBaseValues,
};
export const initialWikipediaValues = {
top_n: 10,
language: 'en',
query: AgentGlobals.SysQuery,
outputs: {
formalized_content: {
value: '',
type: 'string',
},
},
};
export const initialPubMedValues = {
top_n: 12,
email: '',
query: AgentGlobals.SysQuery,
outputs: {
formalized_content: {
value: '',
type: 'string',
},
},
};
export const initialArXivValues = {
top_n: 12,
sort_by: 'relevance',
query: AgentGlobals.SysQuery,
outputs: {
formalized_content: {
value: '',
type: 'string',
},
},
};
export const initialGoogleValues = {
q: AgentGlobals.SysQuery,
start: 0,
num: 12,
api_key: '',
country: 'us',
language: 'en',
outputs: {
formalized_content: {
value: '',
type: 'string',
},
json: {
value: [],
type: 'Array<Object>',
},
},
};
export const initialBingValues = {
top_n: 10,
channel: 'Webpages',
api_key:
'YOUR_API_KEY (obtained from https://www.microsoft.com/en-us/bing/apis/bing-web-search-api)',
country: 'CH',
language: 'en',
query: '',
};
export const initialGoogleScholarValues = {
top_n: 12,
sort_by: 'relevance',
patents: true,
query: AgentGlobals.SysQuery,
year_low: undefined,
year_high: undefined,
outputs: {
formalized_content: {
value: '',
type: 'string',
},
json: {
value: [],
type: 'Array<Object>',
},
},
};
export const initialDeepLValues = {
top_n: 5,
auth_key: 'relevance',
};
export const initialGithubValues = {
top_n: 5,
query: AgentGlobals.SysQuery,
outputs: {
formalized_content: {
value: '',
type: 'string',
},
json: {
value: [],
type: 'Array<Object>',
},
},
};
export const initialBaiduFanyiValues = {
appid: 'xxx',
secret_key: 'xxx',
trans_type: 'translate',
...initialQueryBaseValues,
};
export const initialQWeatherValues = {
web_apikey: 'xxx',
type: 'weather',
user_type: 'free',
time_period: 'now',
...initialQueryBaseValues,
};
export const initialExeSqlValues = {
sql: '',
db_type: 'mysql',
database: '',
username: '',
host: '',
port: 3306,
password: '',
max_records: 1024,
outputs: {
formalized_content: {
value: '',
type: 'string',
},
json: {
value: [],
type: 'Array<Object>',
},
},
};
export const initialSwitchValues = {
conditions: [
{
logical_operator: SwitchLogicOperatorOptions[0],
items: [
{
operator: SwitchOperatorOptions[0].value,
},
],
to: [],
},
],
[SwitchElseTo]: [],
};
export const initialWenCaiValues = {
top_n: 20,
query_type: 'stock',
query: AgentGlobals.SysQuery,
outputs: {
report: {
value: '',
type: 'string',
},
},
};
export const initialAkShareValues = { top_n: 10, ...initialQueryBaseValues };
export const initialYahooFinanceValues = {
stock_code: '',
info: true,
history: false,
financials: false,
balance_sheet: false,
cash_flow_statement: false,
news: true,
outputs: {
report: {
value: '',
type: 'string',
},
},
};
export const initialJin10Values = {
type: 'flash',
secret_key: 'xxx',
flash_type: '1',
contain: '',
filter: '',
...initialQueryBaseValues,
};
export const initialConcentratorValues = {};
export const initialTuShareValues = {
token: 'xxx',
src: 'eastmoney',
start_date: '2024-01-01 09:00:00',
...initialQueryBaseValues,
};
export const initialNoteValues = {
text: '',
};
export const initialCrawlerValues = {
extract_type: 'markdown',
query: '',
};
export const initialInvokeValues = {
url: '',
method: 'GET',
timeout: 60,
headers: `{
"Accept": "*/*",
"Cache-Control": "no-cache",
"Connection": "keep-alive"
}`,
proxy: '',
clean_html: false,
variables: [],
outputs: {
result: {
value: '',
type: 'string',
},
},
};
export const initialTemplateValues = {
content: '',
parameters: [],
};
export const initialEmailValues = {
smtp_server: '',
smtp_port: 465,
email: '',
password: '',
sender_name: '',
to_email: '',
cc_email: '',
subject: '',
content: '',
outputs: {
success: {
value: true,
type: 'boolean',
},
},
};
export const initialIterationValues = {
items_ref: '',
outputs: {},
};
export const initialIterationStartValues = {
outputs: {
item: {
type: 'unkown',
},
index: {
type: 'integer',
},
},
};
export const initialCodeValues = {
lang: ProgrammingLanguage.Python,
script: CodeTemplateStrMap[ProgrammingLanguage.Python],
arguments: {
arg1: '',
arg2: '',
},
outputs: {},
};
export const initialWaitingDialogueValues = {};
export const initialAgentValues = {
...initialLlmBaseValues,
description: '',
user_prompt: '',
sys_prompt: t('flow.sysPromptDefultValue'),
prompts: [{ role: PromptRole.User, content: `{${AgentGlobals.SysQuery}}` }],
message_history_window_size: 12,
max_retries: 3,
delay_after_error: 1,
visual_files_var: '',
max_rounds: 1,
exception_method: '',
exception_goto: [],
exception_default_value: '',
tools: [],
mcp: [],
cite: true,
outputs: {
// structured_output: {
// topic: {
// type: 'string',
// description:
// 'default:general. The category of the search.news is useful for retrieving real-time updates, particularly about politics, sports, and major current events covered by mainstream media sources. general is for broader, more general-purpose searches that may include a wide range of sources.',
// enum: ['general', 'news'],
// default: 'general',
// },
// },
content: {
type: 'string',
value: '',
},
},
};
export const initialUserFillUpValues = {
enable_tips: true,
tips: '',
inputs: [],
outputs: {},
};
export enum StringTransformMethod {
Merge = 'merge',
Split = 'split',
}
export enum StringTransformDelimiter {
Comma = ',',
Semicolon = ';',
Period = '.',
LineBreak = '\n',
Tab = '\t',
Space = ' ',
}
export const initialStringTransformValues = {
method: StringTransformMethod.Merge,
split_ref: '',
script: '',
delimiters: [StringTransformDelimiter.Comma],
outputs: {
result: {
type: 'string',
},
},
};
export enum TavilySearchDepth {
Basic = 'basic',
Advanced = 'advanced',
}
export enum TavilyTopic {
News = 'news',
General = 'general',
}
export const initialTavilyValues = {
api_key: '',
query: AgentGlobals.SysQuery,
search_depth: TavilySearchDepth.Basic,
topic: TavilyTopic.General,
max_results: 5,
days: 7,
include_answer: false,
include_raw_content: true,
include_images: false,
include_image_descriptions: false,
include_domains: [],
exclude_domains: [],
outputs: {
formalized_content: {
value: '',
type: 'string',
},
json: {
value: [],
type: 'Array<Object>',
},
},
};
export enum TavilyExtractDepth {
Basic = 'basic',
Advanced = 'advanced',
}
export enum TavilyExtractFormat {
Text = 'text',
Markdown = 'markdown',
}
export const initialTavilyExtractValues = {
urls: '',
extract_depth: TavilyExtractDepth.Basic,
format: TavilyExtractFormat.Markdown,
outputs: {
formalized_content: {
value: '',
type: 'string',
},
json: {
value: [],
type: 'Array<Object>',
},
},
};
export const CategorizeAnchorPointPositions = [
{ top: 1, right: 34 },
{ top: 8, right: 18 },
{ top: 15, right: 10 },
{ top: 24, right: 4 },
{ top: 31, right: 1 },
{ top: 38, right: -2 },
{ top: 62, right: -2 }, //bottom
{ top: 71, right: 1 },
{ top: 79, right: 6 },
{ top: 86, right: 12 },
{ top: 91, right: 20 },
{ top: 98, right: 34 },
];
// key is the source of the edge, value is the target of the edge
// no connection lines are allowed between key and value
export const RestrictedUpstreamMap = {
[Operator.Begin]: [Operator.Relevant],
[Operator.Categorize]: [Operator.Begin, Operator.Categorize],
[Operator.Retrieval]: [Operator.Begin, Operator.Retrieval],
[Operator.Message]: [
Operator.Begin,
Operator.Message,
Operator.Retrieval,
Operator.RewriteQuestion,
Operator.Categorize,
],
[Operator.Relevant]: [Operator.Begin],
[Operator.RewriteQuestion]: [
Operator.Begin,
Operator.Message,
Operator.RewriteQuestion,
Operator.Relevant,
],
[Operator.KeywordExtract]: [
Operator.Begin,
Operator.Message,
Operator.Relevant,
],
[Operator.Baidu]: [Operator.Begin, Operator.Retrieval],
[Operator.DuckDuckGo]: [Operator.Begin, Operator.Retrieval],
[Operator.Wikipedia]: [Operator.Begin, Operator.Retrieval],
[Operator.PubMed]: [Operator.Begin, Operator.Retrieval],
[Operator.ArXiv]: [Operator.Begin, Operator.Retrieval],
[Operator.Google]: [Operator.Begin, Operator.Retrieval],
[Operator.Bing]: [Operator.Begin, Operator.Retrieval],
[Operator.GoogleScholar]: [Operator.Begin, Operator.Retrieval],
[Operator.DeepL]: [Operator.Begin, Operator.Retrieval],
[Operator.GitHub]: [Operator.Begin, Operator.Retrieval],
[Operator.BaiduFanyi]: [Operator.Begin, Operator.Retrieval],
[Operator.QWeather]: [Operator.Begin, Operator.Retrieval],
[Operator.SearXNG]: [Operator.Begin, Operator.Retrieval],
[Operator.ExeSQL]: [Operator.Begin],
[Operator.Switch]: [Operator.Begin],
[Operator.WenCai]: [Operator.Begin],
[Operator.AkShare]: [Operator.Begin],
[Operator.YahooFinance]: [Operator.Begin],
[Operator.Jin10]: [Operator.Begin],
[Operator.Concentrator]: [Operator.Begin],
[Operator.TuShare]: [Operator.Begin],
[Operator.Crawler]: [Operator.Begin],
[Operator.Note]: [],
[Operator.Invoke]: [Operator.Begin],
[Operator.Email]: [Operator.Begin],
[Operator.Iteration]: [Operator.Begin],
[Operator.IterationStart]: [Operator.Begin],
[Operator.Code]: [Operator.Begin],
[Operator.WaitingDialogue]: [Operator.Begin],
[Operator.Agent]: [Operator.Begin],
[Operator.TavilySearch]: [Operator.Begin],
[Operator.TavilyExtract]: [Operator.Begin],
[Operator.StringTransform]: [Operator.Begin],
[Operator.UserFillUp]: [Operator.Begin],
[Operator.Tool]: [Operator.Begin],
};
export const NodeMap = {
[Operator.Begin]: 'beginNode',
[Operator.Categorize]: 'categorizeNode',
[Operator.Retrieval]: 'retrievalNode',
[Operator.Message]: 'messageNode',
[Operator.Relevant]: 'relevantNode',
[Operator.RewriteQuestion]: 'rewriteNode',
[Operator.KeywordExtract]: 'keywordNode',
[Operator.DuckDuckGo]: 'ragNode',
[Operator.Baidu]: 'ragNode',
[Operator.Wikipedia]: 'ragNode',
[Operator.PubMed]: 'ragNode',
[Operator.ArXiv]: 'ragNode',
[Operator.Google]: 'ragNode',
[Operator.Bing]: 'ragNode',
[Operator.GoogleScholar]: 'ragNode',
[Operator.DeepL]: 'ragNode',
[Operator.GitHub]: 'ragNode',
[Operator.BaiduFanyi]: 'ragNode',
[Operator.QWeather]: 'ragNode',
[Operator.SearXNG]: 'ragNode',
[Operator.ExeSQL]: 'ragNode',
[Operator.Switch]: 'switchNode',
[Operator.Concentrator]: 'logicNode',
[Operator.WenCai]: 'ragNode',
[Operator.AkShare]: 'ragNode',
[Operator.YahooFinance]: 'ragNode',
[Operator.Jin10]: 'ragNode',
[Operator.TuShare]: 'ragNode',
[Operator.Note]: 'noteNode',
[Operator.Crawler]: 'ragNode',
[Operator.Invoke]: 'ragNode',
[Operator.Email]: 'ragNode',
[Operator.Iteration]: 'group',
[Operator.IterationStart]: 'iterationStartNode',
[Operator.Code]: 'ragNode',
[Operator.WaitingDialogue]: 'ragNode',
[Operator.Agent]: 'agentNode',
[Operator.Tool]: 'toolNode',
[Operator.TavilySearch]: 'ragNode',
[Operator.UserFillUp]: 'ragNode',
[Operator.StringTransform]: 'ragNode',
[Operator.TavilyExtract]: 'ragNode',
};
export enum BeginQueryType {
Line = 'line',
Paragraph = 'paragraph',
Options = 'options',
File = 'file',
Integer = 'integer',
Boolean = 'boolean',
}
export const BeginQueryTypeIconMap = {
[BeginQueryType.Line]: TextCursorInput,
[BeginQueryType.Paragraph]: WrapText,
[BeginQueryType.Options]: OptionIcon,
[BeginQueryType.File]: CloudUpload,
[BeginQueryType.Integer]: ListOrdered,
[BeginQueryType.Boolean]: ToggleLeft,
};
export const NoDebugOperatorsList = [
Operator.Begin,
Operator.Concentrator,
Operator.Message,
Operator.RewriteQuestion,
Operator.Switch,
Operator.Iteration,
Operator.UserFillUp,
Operator.IterationStart,
];
export enum NodeHandleId {
Start = 'start',
End = 'end',
Tool = 'tool',
AgentTop = 'agentTop',
AgentBottom = 'agentBottom',
AgentException = 'agentException',
}
export enum VariableType {
String = 'string',
Array = 'array',
File = 'file',
}
export enum AgentExceptionMethod {
Comment = 'comment',
Goto = 'goto',
}

View File

@ -0,0 +1,50 @@
import { RAGFlowNodeType } from '@/interfaces/database/flow';
import { HandleType, Position } from '@xyflow/react';
import { createContext } from 'react';
import { useAddNode } from './hooks/use-add-node';
import { useCacheChatLog } from './hooks/use-cache-chat-log';
import { useShowFormDrawer, useShowLogSheet } from './hooks/use-show-drawer';
export const AgentFormContext = createContext<RAGFlowNodeType | undefined>(
undefined,
);
type AgentInstanceContextType = Pick<
ReturnType<typeof useAddNode>,
'addCanvasNode'
> &
Pick<ReturnType<typeof useShowFormDrawer>, 'showFormDrawer'>;
export const AgentInstanceContext = createContext<AgentInstanceContextType>(
{} as AgentInstanceContextType,
);
type AgentChatContextType = Pick<
ReturnType<typeof useShowLogSheet>,
'showLogSheet'
> & { setLastSendLoadingFunc: (loading: boolean, messageId: string) => void };
export const AgentChatContext = createContext<AgentChatContextType>(
{} as AgentChatContextType,
);
type AgentChatLogContextType = Pick<
ReturnType<typeof useCacheChatLog>,
'addEventList' | 'setCurrentMessageId'
>;
export const AgentChatLogContext = createContext<AgentChatLogContextType>(
{} as AgentChatLogContextType,
);
export type HandleContextType = {
nodeId?: string;
id?: string;
type: HandleType;
position: Position;
isFromConnectionDrag: boolean;
};
export const HandleContext = createContext<HandleContextType>(
{} as HandleContextType,
);

View File

@ -0,0 +1,260 @@
import { ButtonLoading } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { RAGFlowSelect } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { IMessage } from '@/pages/chat/interface';
import { zodResolver } from '@hookform/resolvers/zod';
import React, { ReactNode, useCallback, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { BeginQueryType } from '../constant';
import { BeginQuery } from '../interface';
import { FileUploadDirectUpload } from './uploader';
const StringFields = [
BeginQueryType.Line,
BeginQueryType.Paragraph,
BeginQueryType.Options,
];
interface IProps {
parameters: BeginQuery[];
message?: IMessage;
ok(parameters: any[]): void;
isNext?: boolean;
loading?: boolean;
submitButtonDisabled?: boolean;
btnText?: ReactNode;
}
const DebugContent = ({
parameters,
message,
ok,
isNext = true,
loading = false,
submitButtonDisabled = false,
btnText,
}: IProps) => {
const { t } = useTranslation();
const formSchemaValues = useMemo(() => {
const obj = parameters.reduce<{
schema: Record<string, z.ZodType>;
values: Record<string, any>;
}>(
(pre, cur, idx) => {
const type = cur.type;
let fieldSchema;
let value;
if (StringFields.some((x) => x === type)) {
fieldSchema = z.string().trim().min(1);
} else if (type === BeginQueryType.Boolean) {
fieldSchema = z.boolean();
value = false;
} else if (type === BeginQueryType.Integer || type === 'float') {
fieldSchema = z.coerce.number();
} else {
fieldSchema = z.record(z.any());
}
if (cur.optional) {
fieldSchema = fieldSchema.optional();
}
const index = idx.toString();
pre.schema[index] = fieldSchema;
pre.values[index] = value;
return pre;
},
{ schema: {}, values: {} },
);
return { schema: z.object(obj.schema), values: obj.values };
}, [parameters]);
const form = useForm<z.infer<typeof formSchemaValues.schema>>({
defaultValues: formSchemaValues.values,
resolver: zodResolver(formSchemaValues.schema),
});
const submittable = true;
const renderWidget = useCallback(
(q: BeginQuery, idx: string) => {
const props = {
key: idx,
label: q.name ?? q.key,
name: idx,
};
const BeginQueryTypeMap = {
[BeginQueryType.Line]: (
<FormField
control={form.control}
name={props.name}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>{props.label}</FormLabel>
<FormControl>
<Input {...field}></Input>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
),
[BeginQueryType.Paragraph]: (
<FormField
control={form.control}
name={props.name}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>{props.label}</FormLabel>
<FormControl>
<Textarea rows={1} {...field}></Textarea>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
),
[BeginQueryType.Options]: (
<FormField
control={form.control}
name={props.name}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>{props.label}</FormLabel>
<FormControl>
<RAGFlowSelect
allowClear
options={
q.options?.map((x) => ({
label: x,
value: x as string,
})) ?? []
}
{...field}
></RAGFlowSelect>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
),
[BeginQueryType.File]: (
<React.Fragment key={idx}>
<FormField
control={form.control}
name={props.name}
render={({ field }) => (
<div className="space-y-6">
<FormItem className="w-full">
<FormLabel>{t('assistantAvatar')}</FormLabel>
<FormControl>
<FileUploadDirectUpload
value={field.value}
onChange={field.onChange}
></FileUploadDirectUpload>
</FormControl>
<FormMessage />
</FormItem>
</div>
)}
/>
</React.Fragment>
),
[BeginQueryType.Integer]: (
<FormField
control={form.control}
name={props.name}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>{props.label}</FormLabel>
<FormControl>
<Input type="number" {...field}></Input>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
),
[BeginQueryType.Boolean]: (
<FormField
control={form.control}
name={props.name}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>{props.label}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
></Switch>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
),
};
return (
BeginQueryTypeMap[q.type as BeginQueryType] ??
BeginQueryTypeMap[BeginQueryType.Paragraph]
);
},
[form, t],
);
const onSubmit = useCallback(
(values: z.infer<typeof formSchemaValues.schema>) => {
const nextValues = Object.entries(values).map(([key, value]) => {
const item = parameters[Number(key)];
return { ...item, value };
});
ok(nextValues);
},
[formSchemaValues, ok, parameters],
);
return (
<>
<section>
{message?.data?.tips && <div className="mb-2">{message.data.tips}</div>}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{parameters.map((x, idx) => {
return <div key={idx}>{renderWidget(x, idx.toString())}</div>;
})}
<div>
<ButtonLoading
type="submit"
loading={loading}
disabled={!submittable || submitButtonDisabled}
className="w-full mt-1"
>
{btnText || t(isNext ? 'common.next' : 'flow.run')}
</ButtonLoading>
</div>
</form>
</Form>
</section>
</>
);
};
export default DebugContent;

View File

@ -0,0 +1,103 @@
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent } from '@/components/ui/popover';
import { useParseDocument } from '@/hooks/document-hooks';
import { IModalProps } from '@/interfaces/common';
import { zodResolver } from '@hookform/resolvers/zod';
import { PropsWithChildren } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
const reg =
/^(((ht|f)tps?):\/\/)?([^!@#$%^&*?.\s-]([^!@#$%^&*?.\s]{0,63}[^!@#$%^&*?.\s])?\.)+[a-z]{2,6}\/?/;
const FormSchema = z.object({
url: z.string(),
result: z.any(),
});
const values = {
url: '',
result: null,
};
export const PopoverForm = ({
children,
visible,
switchVisible,
}: PropsWithChildren<IModalProps<any>>) => {
const form = useForm({
defaultValues: values,
resolver: zodResolver(FormSchema),
});
const { parseDocument, loading } = useParseDocument();
const { t } = useTranslation();
// useResetFormOnCloseModal({
// form,
// visible,
// });
async function onSubmit(values: z.infer<typeof FormSchema>) {
const val = values.url;
if (reg.test(val)) {
const ret = await parseDocument(val);
if (ret?.data?.code === 0) {
form.setValue('result', ret?.data?.data);
}
}
}
const content = (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name={`url`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input
{...field}
// onPressEnter={(e) => e.preventDefault()}
placeholder={t('flow.pasteFileLink')}
// suffix={
// <Button
// type="primary"
// onClick={onOk}
// size={'small'}
// loading={loading}
// >
// {t('common.submit')}
// </Button>
// }
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`result`}
render={() => <></>}
/>
</form>
</Form>
);
return (
<Popover open={visible} onOpenChange={switchVisible}>
{children}
<PopoverContent>{content}</PopoverContent>
</Popover>
);
};

View File

@ -0,0 +1,116 @@
'use client';
import {
FileUpload,
FileUploadDropzone,
FileUploadItem,
FileUploadItemDelete,
FileUploadItemMetadata,
FileUploadItemPreview,
FileUploadItemProgress,
FileUploadList,
FileUploadTrigger,
type FileUploadProps,
} from '@/components/file-upload';
import { Button } from '@/components/ui/button';
import { useUploadCanvasFile } from '@/hooks/use-agent-request';
import { Upload, X } from 'lucide-react';
import * as React from 'react';
import { toast } from 'sonner';
type FileUploadDirectUploadProps = {
value: Record<string, any>;
onChange(value: Record<string, any>): void;
};
export function FileUploadDirectUpload({
onChange,
}: FileUploadDirectUploadProps) {
const [files, setFiles] = React.useState<File[]>([]);
const { uploadCanvasFile } = useUploadCanvasFile();
const onUpload: NonNullable<FileUploadProps['onUpload']> = React.useCallback(
async (files, { onSuccess, onError }) => {
try {
const uploadPromises = files.map(async (file) => {
const handleError = (error?: any) => {
onError(
file,
error instanceof Error ? error : new Error('Upload failed'),
);
};
try {
const ret = await uploadCanvasFile([file]);
if (ret.code === 0) {
onSuccess(file);
onChange(ret.data);
} else {
handleError();
}
} catch (error) {
handleError(error);
}
});
// Wait for all uploads to complete
await Promise.all(uploadPromises);
} catch (error) {
// This handles any error that might occur outside the individual upload processes
console.error('Unexpected error during upload:', error);
}
},
[onChange, uploadCanvasFile],
);
const onFileReject = React.useCallback((file: File, message: string) => {
toast(message, {
description: `"${file.name.length > 20 ? `${file.name.slice(0, 20)}...` : file.name}" has been rejected`,
});
}, []);
return (
<FileUpload
value={files}
onValueChange={setFiles}
onUpload={onUpload}
onFileReject={onFileReject}
maxFiles={1}
className="w-full"
multiple={false}
>
<FileUploadDropzone>
<div className="flex flex-col items-center gap-1 text-center">
<div className="flex items-center justify-center rounded-full border p-2.5">
<Upload className="size-6 text-muted-foreground" />
</div>
<p className="font-medium text-sm">Drag & drop files here</p>
<p className="text-muted-foreground text-xs">
Or click to browse (max 2 files)
</p>
</div>
<FileUploadTrigger asChild>
<Button variant="outline" size="sm" className="mt-2 w-fit">
Browse files
</Button>
</FileUploadTrigger>
</FileUploadDropzone>
<FileUploadList>
{files.map((file, index) => (
<FileUploadItem key={index} value={file} className="flex-col">
<div className="flex w-full items-center gap-2">
<FileUploadItemPreview />
<FileUploadItemMetadata />
<FileUploadItemDelete asChild>
<Button variant="ghost" size="icon" className="size-7">
<X />
</Button>
</FileUploadItemDelete>
</div>
<FileUploadItemProgress />
</FileUploadItem>
))}
</FileUploadList>
</FileUpload>
);
}

View File

@ -0,0 +1,19 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { PropsWithChildren } from 'react';
import { useTranslation } from 'react-i18next';
export const RunTooltip = ({ children }: PropsWithChildren) => {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger>{children}</TooltipTrigger>
<TooltipContent>
<p>{t('flow.testRun')}</p>
</TooltipContent>
</Tooltip>
);
};

View File

@ -0,0 +1,43 @@
import { useTranslate } from '@/hooks/common-hooks';
import { useCallback, useMemo } from 'react';
import { Operator, RestrictedUpstreamMap } from './constant';
import useGraphStore from './store';
export const useBuildFormSelectOptions = (
operatorName: Operator,
selfId?: string, // exclude the current node
) => {
const nodes = useGraphStore((state) => state.nodes);
const buildCategorizeToOptions = useCallback(
(toList: string[]) => {
const excludedNodes: Operator[] = [
Operator.Note,
...(RestrictedUpstreamMap[operatorName] ?? []),
];
return nodes
.filter(
(x) =>
excludedNodes.every((y) => y !== x.data.label) &&
x.id !== selfId &&
!toList.some((y) => y === x.id), // filter out selected values in other to fields from the current drop-down box options
)
.map((x) => ({ label: x.data.name, value: x.id }));
},
[nodes, operatorName, selfId],
);
return buildCategorizeToOptions;
};
export const useBuildSortOptions = () => {
const { t } = useTranslate('flow');
const options = useMemo(() => {
return ['data', 'relevance'].map((x) => ({
value: x,
label: t(x),
}));
}, [t]);
return options;
};

View File

@ -0,0 +1,165 @@
import { Operator } from '../constant';
import AgentForm from '../form/agent-form';
import AkShareForm from '../form/akshare-form';
import ArXivForm from '../form/arxiv-form';
import BaiduFanyiForm from '../form/baidu-fanyi-form';
import BaiduForm from '../form/baidu-form';
import BeginForm from '../form/begin-form';
import BingForm from '../form/bing-form';
import CategorizeForm from '../form/categorize-form';
import CodeForm from '../form/code-form';
import CrawlerForm from '../form/crawler-form';
import DeepLForm from '../form/deepl-form';
import DuckDuckGoForm from '../form/duckduckgo-form';
import EmailForm from '../form/email-form';
import ExeSQLForm from '../form/exesql-form';
import GithubForm from '../form/github-form';
import GoogleForm from '../form/google-form';
import GoogleScholarForm from '../form/google-scholar-form';
import InvokeForm from '../form/invoke-form';
import IterationForm from '../form/iteration-form';
import IterationStartForm from '../form/iteration-start-from';
import Jin10Form from '../form/jin10-form';
import KeywordExtractForm from '../form/keyword-extract-form';
import MessageForm from '../form/message-form';
import PubMedForm from '../form/pubmed-form';
import QWeatherForm from '../form/qweather-form';
import RelevantForm from '../form/relevant-form';
import RetrievalForm from '../form/retrieval-form/next';
import RewriteQuestionForm from '../form/rewrite-question-form';
import SearXNGForm from '../form/searxng-form';
import StringTransformForm from '../form/string-transform-form';
import SwitchForm from '../form/switch-form';
import TavilyExtractForm from '../form/tavily-extract-form';
import TavilyForm from '../form/tavily-form';
import TuShareForm from '../form/tushare-form';
import UserFillUpForm from '../form/user-fill-up-form';
import WenCaiForm from '../form/wencai-form';
import WikipediaForm from '../form/wikipedia-form';
import YahooFinanceForm from '../form/yahoo-finance-form';
export const FormConfigMap = {
[Operator.Begin]: {
component: BeginForm,
},
[Operator.Retrieval]: {
component: RetrievalForm,
},
[Operator.Categorize]: {
component: CategorizeForm,
},
[Operator.Message]: {
component: MessageForm,
},
[Operator.Relevant]: {
component: RelevantForm,
},
[Operator.RewriteQuestion]: {
component: RewriteQuestionForm,
},
[Operator.Code]: {
component: CodeForm,
},
[Operator.WaitingDialogue]: {
component: CodeForm,
},
[Operator.Agent]: {
component: AgentForm,
},
[Operator.Baidu]: {
component: BaiduForm,
},
[Operator.DuckDuckGo]: {
component: DuckDuckGoForm,
},
[Operator.KeywordExtract]: {
component: KeywordExtractForm,
},
[Operator.Wikipedia]: {
component: WikipediaForm,
},
[Operator.PubMed]: {
component: PubMedForm,
},
[Operator.ArXiv]: {
component: ArXivForm,
},
[Operator.Google]: {
component: GoogleForm,
},
[Operator.Bing]: {
component: BingForm,
},
[Operator.GoogleScholar]: {
component: GoogleScholarForm,
},
[Operator.DeepL]: {
component: DeepLForm,
},
[Operator.GitHub]: {
component: GithubForm,
},
[Operator.BaiduFanyi]: {
component: BaiduFanyiForm,
},
[Operator.QWeather]: {
component: QWeatherForm,
},
[Operator.ExeSQL]: {
component: ExeSQLForm,
},
[Operator.Switch]: {
component: SwitchForm,
},
[Operator.WenCai]: {
component: WenCaiForm,
},
[Operator.AkShare]: {
component: AkShareForm,
},
[Operator.YahooFinance]: {
component: YahooFinanceForm,
},
[Operator.Jin10]: {
component: Jin10Form,
},
[Operator.TuShare]: {
component: TuShareForm,
},
[Operator.Crawler]: {
component: CrawlerForm,
},
[Operator.Invoke]: {
component: InvokeForm,
},
[Operator.SearXNG]: {
component: SearXNGForm,
},
[Operator.Concentrator]: {
component: () => <></>,
},
[Operator.Note]: {
component: () => <></>,
},
[Operator.Email]: {
component: EmailForm,
},
[Operator.Iteration]: {
component: IterationForm,
},
[Operator.IterationStart]: {
component: IterationStartForm,
},
[Operator.TavilySearch]: {
component: TavilyForm,
},
[Operator.UserFillUp]: {
component: UserFillUpForm,
},
[Operator.StringTransform]: {
component: StringTransformForm,
},
[Operator.TavilyExtract]: {
component: TavilyExtractForm,
},
};

View File

@ -0,0 +1,134 @@
import { Input } from '@/components/ui/input';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { useTranslate } from '@/hooks/common-hooks';
import { IModalProps } from '@/interfaces/common';
import { RAGFlowNodeType } from '@/interfaces/database/flow';
import { cn } from '@/lib/utils';
import { lowerFirst } from 'lodash';
import { Play, X } from 'lucide-react';
import { useMemo } from 'react';
import { BeginId, Operator } from '../constant';
import { AgentFormContext } from '../context';
import { RunTooltip } from '../flow-tooltip';
import { useHandleNodeNameChange } from '../hooks/use-change-node-name';
import OperatorIcon from '../operator-icon';
import useGraphStore from '../store';
import { needsSingleStepDebugging } from '../utils';
import { FormConfigMap } from './form-config-map';
import SingleDebugSheet from './single-debug-sheet';
interface IProps {
node?: RAGFlowNodeType;
singleDebugDrawerVisible: IModalProps<any>['visible'];
hideSingleDebugDrawer: IModalProps<any>['hideModal'];
showSingleDebugDrawer: IModalProps<any>['showModal'];
chatVisible: boolean;
}
const EmptyContent = () => <div></div>;
const FormSheet = ({
visible,
hideModal,
node,
singleDebugDrawerVisible,
chatVisible,
hideSingleDebugDrawer,
showSingleDebugDrawer,
}: IModalProps<any> & IProps) => {
const operatorName: Operator = node?.data.label as Operator;
const clickedToolId = useGraphStore((state) => state.clickedToolId);
const currentFormMap = FormConfigMap[operatorName];
const OperatorForm = currentFormMap?.component ?? EmptyContent;
const { name, handleNameBlur, handleNameChange } = useHandleNodeNameChange({
id: node?.id,
data: node?.data,
});
const isMcp = useMemo(() => {
return (
operatorName === Operator.Tool &&
Object.values(Operator).every((x) => x !== clickedToolId)
);
}, [clickedToolId, operatorName]);
const { t } = useTranslate('flow');
return (
<Sheet open={visible} modal={false}>
<SheetContent
className={cn('top-20 p-0 flex flex-col pb-20 ', {
'right-[620px]': chatVisible,
})}
closeIcon={false}
>
<SheetHeader>
<SheetTitle className="hidden"></SheetTitle>
<section className="flex-col border-b py-2 px-5">
<div className="flex items-center gap-2 pb-3">
<OperatorIcon name={operatorName}></OperatorIcon>
{isMcp ? (
<div className="flex-1">MCP Config</div>
) : (
<div className="flex items-center gap-1 flex-1">
<label htmlFor="">{t('title')}</label>
{node?.id === BeginId ? (
<span>{t(BeginId)}</span>
) : (
<Input
value={name}
onBlur={handleNameBlur}
onChange={handleNameChange}
></Input>
)}
</div>
)}
{needsSingleStepDebugging(operatorName) && (
<RunTooltip>
<Play
className="size-5 cursor-pointer"
onClick={showSingleDebugDrawer}
/>
</RunTooltip>
)}
<X onClick={hideModal} />
</div>
{isMcp || (
<span>
{t(
`${lowerFirst(operatorName === Operator.Tool ? clickedToolId : operatorName)}Description`,
)}
</span>
)}
</section>
</SheetHeader>
<section className="pt-4 overflow-auto flex-1">
{visible && (
<AgentFormContext.Provider value={node}>
<OperatorForm node={node} key={node?.id}></OperatorForm>
</AgentFormContext.Provider>
)}
</section>
</SheetContent>
{singleDebugDrawerVisible && (
<SingleDebugSheet
visible={singleDebugDrawerVisible}
hideModal={hideSingleDebugDrawer}
componentId={node?.id}
></SingleDebugSheet>
)}
</Sheet>
);
};
export default FormSheet;

View File

@ -0,0 +1,89 @@
import CopyToClipboard from '@/components/copy-to-clipboard';
import { Sheet, SheetContent, SheetHeader } from '@/components/ui/sheet';
import { useDebugSingle, useFetchInputForm } from '@/hooks/use-agent-request';
import { IModalProps } from '@/interfaces/common';
import { cn } from '@/lib/utils';
import { isEmpty } from 'lodash';
import { X } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import JsonView from 'react18-json-view';
import 'react18-json-view/src/style.css';
import DebugContent from '../../debug-content';
import { transferInputsArrayToObject } from '../../form/begin-form/use-watch-change';
import { buildBeginInputListFromObject } from '../../form/begin-form/utils';
interface IProps {
componentId?: string;
}
const SingleDebugSheet = ({
componentId,
visible,
hideModal,
}: IModalProps<any> & IProps) => {
const { t } = useTranslation();
const inputForm = useFetchInputForm(componentId);
const { debugSingle, data, loading } = useDebugSingle();
const list = useMemo(() => {
return buildBeginInputListFromObject(inputForm);
}, [inputForm]);
const onOk = useCallback(
(nextValues: any[]) => {
if (componentId) {
debugSingle({
component_id: componentId,
params: transferInputsArrayToObject(nextValues),
});
}
},
[componentId, debugSingle],
);
const content = JSON.stringify(data, null, 2);
return (
<Sheet open={visible} modal={false}>
<SheetContent className="top-20 p-0" closeIcon={false}>
<SheetHeader className="py-2 px-5">
<div className="flex justify-between ">
{t('flow.testRun')}
<X onClick={hideModal} className="cursor-pointer" />
</div>
</SheetHeader>
<section className="overflow-y-auto pt-4 px-5">
<DebugContent
parameters={list}
ok={onOk}
isNext={false}
loading={loading}
submitButtonDisabled={list.length === 0}
></DebugContent>
{!isEmpty(data) ? (
<div
className={cn('mt-4 rounded-md border', {
[`border-state-error`]: !isEmpty(data._ERROR),
})}
>
<div className="flex justify-between p-2">
<span>JSON</span>
<CopyToClipboard text={content}></CopyToClipboard>
</div>
<JsonView
src={data}
displaySize
collapseStringsAfterLength={100000000000}
className="w-full h-[800px] break-words overflow-auto p-2"
dark
/>
</div>
) : null}
</section>
</SheetContent>
</Sheet>
);
};
export default SingleDebugSheet;

View File

@ -0,0 +1,191 @@
import { BlockButton } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { Position } from '@xyflow/react';
import { t } from 'i18next';
import { PencilLine, X } from 'lucide-react';
import {
MouseEventHandler,
PropsWithChildren,
useCallback,
useContext,
useMemo,
} from 'react';
import { Operator } from '../../constant';
import { AgentInstanceContext } from '../../context';
import { useFindMcpById } from '../../hooks/use-find-mcp-by-id';
import { INextOperatorForm } from '../../interface';
import OperatorIcon from '../../operator-icon';
import useGraphStore from '../../store';
import { filterDownstreamAgentNodeIds } from '../../utils/filter-downstream-nodes';
import { ToolPopover } from './tool-popover';
import { useDeleteAgentNodeMCP } from './tool-popover/use-update-mcp';
import { useDeleteAgentNodeTools } from './tool-popover/use-update-tools';
import { useGetAgentMCPIds, useGetAgentToolNames } from './use-get-tools';
export function ToolCard({
children,
className,
...props
}: PropsWithChildren & React.HTMLAttributes<HTMLLIElement>) {
const element = useMemo(() => {
return (
<li
{...props}
className={cn(
'flex bg-bg-card p-1 rounded-sm justify-between',
className,
)}
>
{children}
</li>
);
}, [children, className, props]);
if (children === Operator.Code) {
return (
<Tooltip>
<TooltipTrigger asChild>{element}</TooltipTrigger>
<TooltipContent>
<p>It doesn't have any config.</p>
</TooltipContent>
</Tooltip>
);
}
return element;
}
type ActionButtonProps<T> = {
record: T;
deleteRecord(record: T): void;
edit: MouseEventHandler<HTMLOrSVGElement>;
};
function ActionButton<T>({ deleteRecord, record, edit }: ActionButtonProps<T>) {
const handleDelete = useCallback(() => {
deleteRecord(record);
}, [deleteRecord, record]);
return (
<div className="flex items-center gap-2 text-text-secondary">
<PencilLine
className="size-4 cursor-pointer"
data-tool={record}
onClick={edit}
/>
<X className="size-4 cursor-pointer" onClick={handleDelete} />
</div>
);
}
export function AgentTools() {
const { toolNames } = useGetAgentToolNames();
const { deleteNodeTool } = useDeleteAgentNodeTools();
const { mcpIds } = useGetAgentMCPIds();
const { findMcpById } = useFindMcpById();
const { deleteNodeMCP } = useDeleteAgentNodeMCP();
const { showFormDrawer } = useContext(AgentInstanceContext);
const { clickedNodeId, findAgentToolNodeById, selectNodeIds } = useGraphStore(
(state) => state,
);
const handleEdit: MouseEventHandler<SVGSVGElement> = useCallback(
(e) => {
const toolNodeId = findAgentToolNodeById(clickedNodeId);
if (toolNodeId) {
selectNodeIds([toolNodeId]);
showFormDrawer(e, toolNodeId);
}
},
[clickedNodeId, findAgentToolNodeById, selectNodeIds, showFormDrawer],
);
return (
<section className="space-y-2.5">
<span className="text-text-secondary">{t('flow.tools')}</span>
<ul className="space-y-2">
{toolNames.map((x) => (
<ToolCard key={x}>
<div className="flex gap-2 items-center">
<OperatorIcon name={x as Operator}></OperatorIcon>
{x}
</div>
<ActionButton
record={x}
deleteRecord={deleteNodeTool(x)}
edit={handleEdit}
></ActionButton>
</ToolCard>
))}
{mcpIds.map((id) => (
<ToolCard key={id}>
{findMcpById(id)?.name}
<ActionButton
record={id}
deleteRecord={deleteNodeMCP(id)}
edit={handleEdit}
></ActionButton>
</ToolCard>
))}
</ul>
<ToolPopover>
<BlockButton>{t('flow.addTools')}</BlockButton>
</ToolPopover>
</section>
);
}
export function Agents({ node }: INextOperatorForm) {
const { addCanvasNode } = useContext(AgentInstanceContext);
const { deleteAgentDownstreamNodesById, edges, getNode, selectNodeIds } =
useGraphStore((state) => state);
const { showFormDrawer } = useContext(AgentInstanceContext);
const handleEdit = useCallback(
(nodeId: string): MouseEventHandler<SVGSVGElement> =>
(e) => {
selectNodeIds([nodeId]);
showFormDrawer(e, nodeId);
},
[selectNodeIds, showFormDrawer],
);
const subBottomAgentNodeIds = useMemo(() => {
return filterDownstreamAgentNodeIds(edges, node?.id);
}, [edges, node?.id]);
return (
<section className="space-y-2.5">
<span className="text-text-secondary">{t('flow.agent')}</span>
<ul className="space-y-2">
{subBottomAgentNodeIds.map((id) => {
const currentNode = getNode(id);
return (
<ToolCard key={id}>
{currentNode?.data.name}
<ActionButton
record={id}
deleteRecord={deleteAgentDownstreamNodesById}
edit={handleEdit(id)}
></ActionButton>
</ToolCard>
);
})}
</ul>
<BlockButton
onClick={addCanvasNode(Operator.Agent, {
nodeId: node?.id,
position: Position.Bottom,
})}
>
{t('flow.addAgent')}
</BlockButton>
</section>
);
}

View File

@ -0,0 +1,93 @@
import { BlockButton, Button } from '@/components/ui/button';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { RAGFlowSelect } from '@/components/ui/select';
import { X } from 'lucide-react';
import { memo } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { PromptRole } from '../../constant';
import { PromptEditor } from '../components/prompt-editor';
const options = [
{ label: 'User', value: PromptRole.User },
{ label: 'Assistant', value: PromptRole.Assistant },
];
const DynamicPrompt = () => {
const { t } = useTranslation();
const form = useFormContext();
const name = 'prompts';
const { fields, append, remove } = useFieldArray({
name: name,
control: form.control,
});
return (
<FormItem>
<FormLabel tooltip={t('flow.msgTip')}>{t('flow.msg')}</FormLabel>
<div className="space-y-4">
{fields.map((field, index) => (
<div key={field.id} className="flex">
<div className="space-y-2 flex-1">
<FormField
control={form.control}
name={`${name}.${index}.role`}
render={({ field }) => (
<FormItem className="w-1/3">
<FormLabel />
<FormControl>
<RAGFlowSelect
{...field}
options={options}
></RAGFlowSelect>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`${name}.${index}.content`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<section>
<PromptEditor
{...field}
showToolbar={false}
></PromptEditor>
</section>
</FormControl>
</FormItem>
)}
/>
</div>
<Button
type="button"
variant={'ghost'}
onClick={() => remove(index)}
>
<X />
</Button>
</div>
))}
</div>
<FormMessage />
<BlockButton
onClick={() => append({ content: '', role: PromptRole.User })}
>
Add
</BlockButton>
</FormItem>
);
};
export default memo(DynamicPrompt);

View File

@ -0,0 +1,63 @@
import { BlockButton, Button } from '@/components/ui/button';
import {
FormControl,
FormField,
FormItem,
FormMessage,
} from '@/components/ui/form';
import { X } from 'lucide-react';
import { memo } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { PromptEditor } from '../components/prompt-editor';
const DynamicTool = () => {
const form = useFormContext();
const name = 'tools';
const { fields, append, remove } = useFieldArray({
name: name,
control: form.control,
});
return (
<FormItem>
<div className="space-y-4">
{fields.map((field, index) => (
<div key={field.id} className="flex">
<div className="space-y-2 flex-1">
<FormField
control={form.control}
name={`${name}.${index}.component_name`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<section>
<PromptEditor
{...field}
showToolbar={false}
></PromptEditor>
</section>
</FormControl>
</FormItem>
)}
/>
</div>
<Button
type="button"
variant={'ghost'}
onClick={() => remove(index)}
>
<X />
</Button>
</div>
))}
</div>
<FormMessage />
<BlockButton onClick={() => append({ component_name: '' })}>
Add
</BlockButton>
</FormItem>
);
};
export default memo(DynamicTool);

View File

@ -0,0 +1,280 @@
import { Collapse } from '@/components/collapse';
import { FormContainer } from '@/components/form-container';
import {
LargeModelFilterFormSchema,
LargeModelFormField,
} from '@/components/large-model-form-field';
import { LlmSettingSchema } from '@/components/llm-setting-items/next';
import { MessageHistoryWindowSizeFormField } from '@/components/message-history-window-size-item';
import { SelectWithSearch } from '@/components/originui/select-with-search';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/form';
import { Input, NumberInput } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { LlmModelType } from '@/constants/knowledge';
import { useFindLlmByUuid } from '@/hooks/use-llm-request';
import { zodResolver } from '@hookform/resolvers/zod';
import { memo, useEffect, useMemo } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import {
AgentExceptionMethod,
NodeHandleId,
VariableType,
initialAgentValues,
} from '../../constant';
import { INextOperatorForm } from '../../interface';
import useGraphStore from '../../store';
import { isBottomSubAgent } from '../../utils';
import { buildOutputList } from '../../utils/build-output-list';
import { DescriptionField } from '../components/description-field';
import { FormWrapper } from '../components/form-wrapper';
import { Output } from '../components/output';
import { PromptEditor } from '../components/prompt-editor';
import { QueryVariable } from '../components/query-variable';
import { AgentTools, Agents } from './agent-tools';
import { useValues } from './use-values';
import { useWatchFormChange } from './use-watch-change';
const FormSchema = z.object({
sys_prompt: z.string(),
description: z.string().optional(),
user_prompt: z.string().optional(),
prompts: z.string().optional(),
// prompts: z
// .array(
// z.object({
// role: z.string(),
// content: z.string(),
// }),
// )
// .optional(),
message_history_window_size: z.coerce.number(),
tools: z
.array(
z.object({
component_name: z.string(),
}),
)
.optional(),
...LlmSettingSchema,
max_retries: z.coerce.number(),
delay_after_error: z.coerce.number().optional(),
visual_files_var: z.string().optional(),
max_rounds: z.coerce.number().optional(),
exception_method: z.string().optional(),
exception_goto: z.array(z.string()).optional(),
exception_default_value: z.string().optional(),
...LargeModelFilterFormSchema,
cite: z.boolean().optional(),
});
const outputList = buildOutputList(initialAgentValues.outputs);
function AgentForm({ node }: INextOperatorForm) {
const { t } = useTranslation();
const { edges, deleteEdgesBySourceAndSourceHandle } = useGraphStore(
(state) => state,
);
const defaultValues = useValues(node);
const ExceptionMethodOptions = Object.values(AgentExceptionMethod).map(
(x) => ({
label: t(`flow.${x}`),
value: x,
}),
);
const isSubAgent = useMemo(() => {
return isBottomSubAgent(edges, node?.id);
}, [edges, node?.id]);
const form = useForm<z.infer<typeof FormSchema>>({
defaultValues: defaultValues,
resolver: zodResolver(FormSchema),
});
const llmId = useWatch({ control: form.control, name: 'llm_id' });
const findLlmByUuid = useFindLlmByUuid();
const exceptionMethod = useWatch({
control: form.control,
name: 'exception_method',
});
useEffect(() => {
if (exceptionMethod !== AgentExceptionMethod.Goto) {
if (node?.id) {
deleteEdgesBySourceAndSourceHandle(
node?.id,
NodeHandleId.AgentException,
);
}
}
}, [deleteEdgesBySourceAndSourceHandle, exceptionMethod, node?.id]);
useWatchFormChange(node?.id, form);
return (
<Form {...form}>
<FormWrapper>
<FormContainer>
{isSubAgent && <DescriptionField></DescriptionField>}
<LargeModelFormField showSpeech2TextModel></LargeModelFormField>
{findLlmByUuid(llmId)?.model_type === LlmModelType.Image2text && (
<QueryVariable
name="visual_files_var"
label="Visual Input File"
type={VariableType.File}
></QueryVariable>
)}
</FormContainer>
<FormContainer>
<FormField
control={form.control}
name={`sys_prompt`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>{t('flow.systemPrompt')}</FormLabel>
<FormControl>
<PromptEditor
{...field}
placeholder={t('flow.messagePlaceholder')}
showToolbar={false}
></PromptEditor>
</FormControl>
</FormItem>
)}
/>
</FormContainer>
{isSubAgent || (
<FormContainer>
{/* <DynamicPrompt></DynamicPrompt> */}
<FormField
control={form.control}
name={`prompts`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>{t('flow.userPrompt')}</FormLabel>
<FormControl>
<section>
<PromptEditor
{...field}
showToolbar={false}
></PromptEditor>
</section>
</FormControl>
</FormItem>
)}
/>
</FormContainer>
)}
<FormContainer>
<AgentTools></AgentTools>
<Agents node={node}></Agents>
</FormContainer>
<Collapse title={<div>{t('flow.advancedSettings')}</div>}>
<FormContainer>
<MessageHistoryWindowSizeFormField></MessageHistoryWindowSizeFormField>
<FormField
control={form.control}
name={`cite`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel tooltip={t('flow.citeTip')}>
{t('flow.cite')}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
></Switch>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`max_retries`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>{t('flow.maxRetries')}</FormLabel>
<FormControl>
<NumberInput {...field} max={8}></NumberInput>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`delay_after_error`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>{t('flow.delayEfterError')}</FormLabel>
<FormControl>
<NumberInput {...field} max={5} step={0.1}></NumberInput>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`max_rounds`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>{t('flow.maxRounds')}</FormLabel>
<FormControl>
<NumberInput {...field}></NumberInput>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`exception_method`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>{t('flow.exceptionMethod')}</FormLabel>
<FormControl>
<SelectWithSearch
{...field}
options={ExceptionMethodOptions}
allowClear
/>
</FormControl>
</FormItem>
)}
/>
{exceptionMethod === AgentExceptionMethod.Comment && (
<FormField
control={form.control}
name={`exception_default_value`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>{t('flow.ExceptionDefaultValue')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
</FormItem>
)}
/>
)}
</FormContainer>
</Collapse>
<Output list={outputList}></Output>
</FormWrapper>
</Form>
);
}
export default memo(AgentForm);

View File

@ -0,0 +1,89 @@
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Operator } from '@/pages/agent/constant';
import { AgentFormContext, AgentInstanceContext } from '@/pages/agent/context';
import useGraphStore from '@/pages/agent/store';
import { Position } from '@xyflow/react';
import { t } from 'i18next';
import { PropsWithChildren, useCallback, useContext, useEffect } from 'react';
import { useGetAgentMCPIds, useGetAgentToolNames } from '../use-get-tools';
import { MCPCommand, ToolCommand } from './tool-command';
import { useUpdateAgentNodeMCP } from './use-update-mcp';
import { useUpdateAgentNodeTools } from './use-update-tools';
enum ToolType {
Common = 'common',
MCP = 'mcp',
}
export function ToolPopover({ children }: PropsWithChildren) {
const { addCanvasNode } = useContext(AgentInstanceContext);
const node = useContext(AgentFormContext);
const { updateNodeTools } = useUpdateAgentNodeTools();
const { toolNames } = useGetAgentToolNames();
const deleteAgentToolNodeById = useGraphStore(
(state) => state.deleteAgentToolNodeById,
);
const { mcpIds } = useGetAgentMCPIds();
const { updateNodeMCP } = useUpdateAgentNodeMCP();
const handleChange = useCallback(
(value: string[]) => {
if (Array.isArray(value) && node?.id) {
updateNodeTools(value);
}
},
[node?.id, updateNodeTools],
);
useEffect(() => {
const total = toolNames.length + mcpIds.length;
if (node?.id) {
if (total > 0) {
addCanvasNode(Operator.Tool, {
position: Position.Bottom,
nodeId: node?.id,
})();
} else {
deleteAgentToolNodeById(node.id);
}
}
}, [
addCanvasNode,
deleteAgentToolNodeById,
mcpIds.length,
node?.id,
toolNames.length,
]);
return (
<Popover>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent className="w-80 p-4">
<Tabs defaultValue={ToolType.Common}>
<TabsList>
<TabsTrigger value={ToolType.Common} className="bg-bg-card">
{t('flow.builtIn')}
</TabsTrigger>
<TabsTrigger value={ToolType.MCP} className="bg-bg-card">
MCP
</TabsTrigger>
</TabsList>
<TabsContent value={ToolType.Common}>
<ToolCommand
onChange={handleChange}
value={toolNames}
></ToolCommand>
</TabsContent>
<TabsContent value={ToolType.MCP}>
<MCPCommand value={mcpIds} onChange={updateNodeMCP}></MCPCommand>
</TabsContent>
</Tabs>
</PopoverContent>
</Popover>
);
}

View File

@ -0,0 +1,178 @@
import { CheckIcon } from 'lucide-react';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { useListMcpServer } from '@/hooks/use-mcp-request';
import { cn } from '@/lib/utils';
import { Operator } from '@/pages/agent/constant';
import OperatorIcon from '@/pages/agent/operator-icon';
import { t } from 'i18next';
import { lowerFirst } from 'lodash';
import { PropsWithChildren, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
const Menus = [
{
label: t('flow.search'),
list: [
Operator.TavilySearch,
Operator.TavilyExtract,
Operator.Google,
// Operator.Bing,
Operator.DuckDuckGo,
Operator.Wikipedia,
Operator.SearXNG,
Operator.YahooFinance,
Operator.PubMed,
Operator.GoogleScholar,
Operator.ArXiv,
Operator.WenCai,
],
},
{
label: t('flow.communication'),
list: [Operator.Email],
},
// {
// label: 'Productivity',
// list: [],
// },
{
label: t('flow.developer'),
list: [Operator.GitHub, Operator.ExeSQL, Operator.Code, Operator.Retrieval],
},
];
type ToolCommandProps = {
value?: string[];
onChange?(values: string[]): void;
};
type ToolCommandItemProps = {
toggleOption(id: string): void;
id: string;
isSelected: boolean;
} & ToolCommandProps;
function ToolCommandItem({
toggleOption,
id,
isSelected,
children,
}: ToolCommandItemProps & PropsWithChildren) {
return (
<CommandItem className="cursor-pointer" onSelect={() => toggleOption(id)}>
<div
className={cn(
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
isSelected
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible',
)}
>
<CheckIcon className="h-4 w-4" />
</div>
{children}
</CommandItem>
);
}
function useHandleSelectChange({ onChange, value }: ToolCommandProps) {
const [currentValue, setCurrentValue] = useState<string[]>([]);
const toggleOption = useCallback(
(option: string) => {
const newSelectedValues = currentValue.includes(option)
? currentValue.filter((value) => value !== option)
: [...currentValue, option];
setCurrentValue(newSelectedValues);
onChange?.(newSelectedValues);
},
[currentValue, onChange],
);
useEffect(() => {
if (Array.isArray(value)) {
setCurrentValue(value);
}
}, [value]);
return {
toggleOption,
currentValue,
};
}
export function ToolCommand({ value, onChange }: ToolCommandProps) {
const { t } = useTranslation();
const { toggleOption, currentValue } = useHandleSelectChange({
onChange,
value,
});
return (
<Command>
<CommandInput placeholder={t('flow.typeCommandOrsearch')} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{Menus.map((x) => (
<CommandGroup heading={x.label} key={x.label}>
{x.list.map((y) => {
const isSelected = currentValue.includes(y);
return (
<ToolCommandItem
key={y}
id={y}
toggleOption={toggleOption}
isSelected={isSelected}
>
<>
<OperatorIcon name={y as Operator}></OperatorIcon>
<span>{t(`flow.${lowerFirst(y)}`)}</span>
</>
</ToolCommandItem>
);
})}
</CommandGroup>
))}
</CommandList>
</Command>
);
}
export function MCPCommand({ onChange, value }: ToolCommandProps) {
const { data } = useListMcpServer();
const { toggleOption, currentValue } = useHandleSelectChange({
onChange,
value,
});
return (
<Command>
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{data.mcp_servers.map((item) => {
const isSelected = currentValue.includes(item.id);
return (
<ToolCommandItem
key={item.id}
id={item.id}
isSelected={isSelected}
toggleOption={toggleOption}
>
{item.name}
</ToolCommandItem>
);
})}
</CommandList>
</Command>
);
}

View File

@ -0,0 +1,74 @@
import { useListMcpServer } from '@/hooks/use-mcp-request';
import { IAgentForm } from '@/interfaces/database/agent';
import { AgentFormContext } from '@/pages/agent/context';
import useGraphStore from '@/pages/agent/store';
import { get } from 'lodash';
import { useCallback, useContext, useMemo } from 'react';
export function useGetNodeMCP() {
const node = useContext(AgentFormContext);
return useMemo(() => {
const mcp: IAgentForm['mcp'] = get(node, 'data.form.mcp');
return mcp;
}, [node]);
}
export function useUpdateAgentNodeMCP() {
const { updateNodeForm } = useGraphStore((state) => state);
const node = useContext(AgentFormContext);
const mcpList = useGetNodeMCP();
const { data } = useListMcpServer();
const mcpServers = data.mcp_servers;
const findMcpTools = useCallback(
(mcpId: string) => {
const mcp = mcpServers.find((x) => x.id === mcpId);
return mcp?.variables.tools;
},
[mcpServers],
);
const updateNodeMCP = useCallback(
(value: string[]) => {
if (node?.id) {
const nextValue = value.reduce<IAgentForm['mcp']>((pre, cur) => {
const mcp = mcpList.find((x) => x.mcp_id === cur);
const tools = findMcpTools(cur);
if (mcp) {
pre.push(mcp);
} else if (tools) {
pre.push({
mcp_id: cur,
tools: {},
});
}
return pre;
}, []);
updateNodeForm(node?.id, nextValue, ['mcp']);
}
},
[node?.id, updateNodeForm, mcpList, findMcpTools],
);
return { updateNodeMCP };
}
export function useDeleteAgentNodeMCP() {
const { updateNodeForm } = useGraphStore((state) => state);
const mcpList = useGetNodeMCP();
const node = useContext(AgentFormContext);
const deleteNodeMCP = useCallback(
(value: string) => () => {
const nextMCP = mcpList.filter((x) => x.mcp_id !== value);
if (node?.id) {
updateNodeForm(node?.id, nextMCP, ['mcp']);
}
},
[node?.id, mcpList, updateNodeForm],
);
return { deleteNodeMCP };
}

View File

@ -0,0 +1,66 @@
import { IAgentForm } from '@/interfaces/database/agent';
import { Operator } from '@/pages/agent/constant';
import { AgentFormContext } from '@/pages/agent/context';
import { useAgentToolInitialValues } from '@/pages/agent/hooks/use-agent-tool-initial-values';
import useGraphStore from '@/pages/agent/store';
import { get } from 'lodash';
import { useCallback, useContext, useMemo } from 'react';
export function useGetNodeTools() {
const node = useContext(AgentFormContext);
return useMemo(() => {
const tools: IAgentForm['tools'] = get(node, 'data.form.tools');
return tools;
}, [node]);
}
export function useUpdateAgentNodeTools() {
const { updateNodeForm } = useGraphStore((state) => state);
const node = useContext(AgentFormContext);
const tools = useGetNodeTools();
const { initializeAgentToolValues } = useAgentToolInitialValues();
const updateNodeTools = useCallback(
(value: string[]) => {
if (node?.id) {
const nextValue = value.reduce<IAgentForm['tools']>((pre, cur) => {
const tool = tools.find((x) => x.component_name === cur);
pre.push(
tool
? tool
: {
component_name: cur,
name: cur,
params: initializeAgentToolValues(cur as Operator),
},
);
return pre;
}, []);
updateNodeForm(node?.id, nextValue, ['tools']);
}
},
[initializeAgentToolValues, node?.id, tools, updateNodeForm],
);
return { updateNodeTools };
}
export function useDeleteAgentNodeTools() {
const { updateNodeForm } = useGraphStore((state) => state);
const tools = useGetNodeTools();
const node = useContext(AgentFormContext);
const deleteNodeTool = useCallback(
(value: string) => () => {
const nextTools = tools.filter((x) => x.component_name !== value);
if (node?.id) {
updateNodeForm(node?.id, nextTools, ['tools']);
}
},
[node?.id, tools, updateNodeForm],
);
return { deleteNodeTool };
}

View File

@ -0,0 +1,26 @@
import { IAgentForm } from '@/interfaces/database/agent';
import { get } from 'lodash';
import { useContext, useMemo } from 'react';
import { AgentFormContext } from '../../context';
export function useGetAgentToolNames() {
const node = useContext(AgentFormContext);
const toolNames = useMemo(() => {
const tools: IAgentForm['tools'] = get(node, 'data.form.tools', []);
return tools.map((x) => x.component_name);
}, [node]);
return { toolNames };
}
export function useGetAgentMCPIds() {
const node = useContext(AgentFormContext);
const mcpIds = useMemo(() => {
const ids: IAgentForm['mcp'] = get(node, 'data.form.mcp', []);
return ids.map((x) => x.mcp_id);
}, [node]);
return { mcpIds };
}

View File

@ -0,0 +1,33 @@
import { useFetchModelId } from '@/hooks/logic-hooks';
import { RAGFlowNodeType } from '@/interfaces/database/flow';
import { get, isEmpty } from 'lodash';
import { useMemo } from 'react';
import { initialAgentValues } from '../../constant';
export function useValues(node?: RAGFlowNodeType) {
const llmId = useFetchModelId();
const defaultValues = useMemo(
() => ({
...initialAgentValues,
llm_id: llmId,
prompts: '',
}),
[llmId],
);
const values = useMemo(() => {
const formData = node?.data?.form;
if (isEmpty(formData)) {
return defaultValues;
}
return {
...formData,
prompts: get(formData, 'prompts.0.content', ''),
};
}, [defaultValues, node?.data?.form]);
return values;
}

View File

@ -0,0 +1,22 @@
import { useEffect } from 'react';
import { UseFormReturn, useWatch } from 'react-hook-form';
import { PromptRole } from '../../constant';
import useGraphStore from '../../store';
export function useWatchFormChange(id?: string, form?: UseFormReturn<any>) {
let values = useWatch({ control: form?.control });
const updateNodeForm = useGraphStore((state) => state.updateNodeForm);
useEffect(() => {
// Manually triggered form updates are synchronized to the canvas
if (id && form?.formState.isDirty) {
values = form?.getValues();
let nextValues: any = {
...values,
prompts: [{ role: PromptRole.User, content: values.prompts }],
};
updateNodeForm(id, nextValues);
}
}, [form?.formState.isDirty, id, updateNodeForm, values]);
}

View File

@ -0,0 +1,22 @@
import { TopNFormField } from '@/components/top-n-item';
import { Form } from '@/components/ui/form';
import { INextOperatorForm } from '../../interface';
import { DynamicInputVariable } from '../components/next-dynamic-input-variable';
const AkShareForm = ({ form, node }: INextOperatorForm) => {
return (
<Form {...form}>
<form
className="space-y-6"
onSubmit={(e) => {
e.preventDefault();
}}
>
<DynamicInputVariable node={node}></DynamicInputVariable>
<TopNFormField max={99}></TopNFormField>
</form>
</Form>
);
};
export default AkShareForm;

View File

@ -0,0 +1,96 @@
import { FormContainer } from '@/components/form-container';
import { SelectWithSearch } from '@/components/originui/select-with-search';
import { TopNFormField } from '@/components/top-n-item';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { useTranslate } from '@/hooks/common-hooks';
import { zodResolver } from '@hookform/resolvers/zod';
import { memo, useMemo } from 'react';
import { useForm, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { initialArXivValues } from '../../constant';
import { useFormValues } from '../../hooks/use-form-values';
import { useWatchFormChange } from '../../hooks/use-watch-form-change';
import { INextOperatorForm } from '../../interface';
import { buildOutputList } from '../../utils/build-output-list';
import { FormWrapper } from '../components/form-wrapper';
import { Output } from '../components/output';
import { QueryVariable } from '../components/query-variable';
export const ArXivFormPartialSchema = {
top_n: z.number(),
sort_by: z.string(),
};
export const FormSchema = z.object({
...ArXivFormPartialSchema,
query: z.string(),
});
export function ArXivFormWidgets() {
const form = useFormContext();
const { t } = useTranslate('flow');
const options = useMemo(() => {
return ['submittedDate', 'lastUpdatedDate', 'relevance'].map((x) => ({
value: x,
label: t(x),
}));
}, [t]);
return (
<>
<TopNFormField></TopNFormField>
<FormField
control={form.control}
name={`sort_by`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>{t('sortBy')}</FormLabel>
<FormControl>
<SelectWithSearch {...field} options={options}></SelectWithSearch>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
}
const outputList = buildOutputList(initialArXivValues.outputs);
function ArXivForm({ node }: INextOperatorForm) {
const defaultValues = useFormValues(initialArXivValues, node);
const form = useForm<z.infer<typeof FormSchema>>({
defaultValues,
resolver: zodResolver(FormSchema),
});
useWatchFormChange(node?.id, form);
return (
<Form {...form}>
<FormWrapper>
<FormContainer>
<QueryVariable></QueryVariable>
</FormContainer>
<FormContainer>
<ArXivFormWidgets></ArXivFormWidgets>
</FormContainer>
</FormWrapper>
<div className="p-5">
<Output list={outputList}></Output>
</div>
</Form>
);
}
export default memo(ArXivForm);

View File

@ -0,0 +1,71 @@
import { useTranslate } from '@/hooks/common-hooks';
import { Form, Input, Select } from 'antd';
import { useMemo } from 'react';
import { IOperatorForm } from '../../interface';
import {
BaiduFanyiDomainOptions,
BaiduFanyiSourceLangOptions,
} from '../../options';
import DynamicInputVariable from '../components/dynamic-input-variable';
const BaiduFanyiForm = ({ onValuesChange, form, node }: IOperatorForm) => {
const { t } = useTranslate('flow');
const options = useMemo(() => {
return ['translate', 'fieldtranslate'].map((x) => ({
value: x,
label: t(`baiduSecretKeyOptions.${x}`),
}));
}, [t]);
const baiduFanyiOptions = useMemo(() => {
return BaiduFanyiDomainOptions.map((x) => ({
value: x,
label: t(`baiduDomainOptions.${x}`),
}));
}, [t]);
const baiduFanyiSourceLangOptions = useMemo(() => {
return BaiduFanyiSourceLangOptions.map((x) => ({
value: x,
label: t(`baiduSourceLangOptions.${x}`),
}));
}, [t]);
return (
<Form
name="basic"
autoComplete="off"
form={form}
onValuesChange={onValuesChange}
layout={'vertical'}
>
<DynamicInputVariable node={node}></DynamicInputVariable>
<Form.Item label={t('appid')} name={'appid'}>
<Input></Input>
</Form.Item>
<Form.Item label={t('secretKey')} name={'secret_key'}>
<Input></Input>
</Form.Item>
<Form.Item label={t('transType')} name={'trans_type'}>
<Select options={options}></Select>
</Form.Item>
<Form.Item noStyle dependencies={['model_type']}>
{({ getFieldValue }) =>
getFieldValue('trans_type') === 'fieldtranslate' && (
<Form.Item label={t('domain')} name={'domain'}>
<Select options={baiduFanyiOptions}></Select>
</Form.Item>
)
}
</Form.Item>
<Form.Item label={t('sourceLang')} name={'source_lang'}>
<Select options={baiduFanyiSourceLangOptions}></Select>
</Form.Item>
<Form.Item label={t('targetLang')} name={'target_lang'}>
<Select options={baiduFanyiSourceLangOptions}></Select>
</Form.Item>
</Form>
);
};
export default BaiduFanyiForm;

View File

@ -0,0 +1,22 @@
import { TopNFormField } from '@/components/top-n-item';
import { Form } from '@/components/ui/form';
import { INextOperatorForm } from '../../interface';
import { DynamicInputVariable } from '../components/next-dynamic-input-variable';
const BaiduForm = ({ form, node }: INextOperatorForm) => {
return (
<Form {...form}>
<form
className="space-y-6"
onSubmit={(e) => {
e.preventDefault();
}}
>
<DynamicInputVariable node={node}></DynamicInputVariable>
<TopNFormField></TopNFormField>
</form>
</Form>
);
};
export default BaiduForm;

View File

@ -0,0 +1,57 @@
'use client';
import { BlockButton, Button } from '@/components/ui/button';
import {
FormControl,
FormField,
FormItem,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { X } from 'lucide-react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
export function BeginDynamicOptions() {
const { t } = useTranslation();
const form = useFormContext();
const name = 'options';
const { fields, remove, append } = useFieldArray({
name: name,
control: form.control,
});
return (
<div className="space-y-5">
{fields.map((field, index) => {
const typeField = `${name}.${index}.value`;
return (
<div key={field.id} className="flex items-center gap-2">
<FormField
control={form.control}
name={typeField}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input
{...field}
placeholder={t('common.pleaseInput')}
></Input>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button variant={'ghost'} onClick={() => remove(index)}>
<X className="text-text-sub-title-invert " />
</Button>
</div>
);
})}
<BlockButton onClick={() => append({ value: '' })} type="button">
{t('flow.addField')}
</BlockButton>
</div>
);
}

View File

@ -0,0 +1,205 @@
import { Collapse } from '@/components/collapse';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { RAGFlowSelect } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { FormTooltip } from '@/components/ui/tooltip';
import { zodResolver } from '@hookform/resolvers/zod';
import { t } from 'i18next';
import { Plus } from 'lucide-react';
import { memo, useEffect, useRef } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { AgentDialogueMode } from '../../constant';
import { INextOperatorForm } from '../../interface';
import { ParameterDialog } from './parameter-dialog';
import { QueryTable } from './query-table';
import { useEditQueryRecord } from './use-edit-query';
import { useValues } from './use-values';
import { useWatchFormChange } from './use-watch-change';
const ModeOptions = [
{ value: AgentDialogueMode.Conversational, label: t('flow.conversational') },
{ value: AgentDialogueMode.Task, label: t('flow.task') },
];
function BeginForm({ node }: INextOperatorForm) {
const { t } = useTranslation();
const values = useValues(node);
const FormSchema = z.object({
enablePrologue: z.boolean().optional(),
prologue: z.string().trim().optional(),
mode: z.string(),
inputs: z
.array(
z.object({
key: z.string(),
type: z.string(),
value: z.string(),
optional: z.boolean(),
name: z.string(),
options: z.array(z.union([z.number(), z.string(), z.boolean()])),
}),
)
.optional(),
});
const form = useForm({
defaultValues: values,
resolver: zodResolver(FormSchema),
});
useWatchFormChange(node?.id, form);
const inputs = useWatch({ control: form.control, name: 'inputs' });
const mode = useWatch({ control: form.control, name: 'mode' });
const enablePrologue = useWatch({
control: form.control,
name: 'enablePrologue',
});
const previousModeRef = useRef(mode);
useEffect(() => {
if (
previousModeRef.current === AgentDialogueMode.Task &&
mode === AgentDialogueMode.Conversational
) {
form.setValue('enablePrologue', true);
}
previousModeRef.current = mode;
}, [mode, form]);
const {
ok,
currentRecord,
visible,
hideModal,
showModal,
otherThanCurrentQuery,
handleDeleteRecord,
} = useEditQueryRecord({
form,
node,
});
return (
<section className="px-5 space-y-5">
<Form {...form}>
<FormField
control={form.control}
name={'mode'}
render={({ field }) => (
<FormItem>
<FormLabel tooltip={t('flow.modeTip')}>
{t('flow.mode')}
</FormLabel>
<FormControl>
<RAGFlowSelect
placeholder={t('common.pleaseSelect')}
options={ModeOptions}
{...field}
></RAGFlowSelect>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{mode === AgentDialogueMode.Conversational && (
<FormField
control={form.control}
name={'enablePrologue'}
render={({ field }) => (
<FormItem>
<FormLabel tooltip={t('flow.openingSwitchTip')}>
{t('flow.openingSwitch')}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{mode === AgentDialogueMode.Conversational && enablePrologue && (
<FormField
control={form.control}
name={'prologue'}
render={({ field }) => (
<FormItem>
<FormLabel tooltip={t('chat.setAnOpenerTip')}>
{t('flow.openingCopy')}
</FormLabel>
<FormControl>
<Textarea
rows={5}
{...field}
placeholder={t('common.pleaseInput')}
></Textarea>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{/* Create a hidden field to make Form instance record this */}
<FormField
control={form.control}
name={'inputs'}
render={() => <div></div>}
/>
<Collapse
title={
<div>
{t('flow.input')}
<FormTooltip tooltip={t('flow.beginInputTip')}></FormTooltip>
</div>
}
rightContent={
<Button
variant={'ghost'}
onClick={(e) => {
e.preventDefault();
showModal();
}}
>
<Plus />
</Button>
}
>
<QueryTable
data={inputs}
showModal={showModal}
deleteRecord={handleDeleteRecord}
></QueryTable>
</Collapse>
{visible && (
<ParameterDialog
hideModal={hideModal}
initialValue={currentRecord}
otherThanCurrentQuery={otherThanCurrentQuery}
submit={ok}
></ParameterDialog>
)}
</Form>
</section>
);
}
export default memo(BeginForm);

View File

@ -0,0 +1,226 @@
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { RAGFlowSelect, RAGFlowSelectOptionType } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { useTranslate } from '@/hooks/common-hooks';
import { IModalProps } from '@/interfaces/common';
import { zodResolver } from '@hookform/resolvers/zod';
import { isEmpty } from 'lodash';
import { ChangeEvent, useEffect, useMemo } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { BeginQueryType, BeginQueryTypeIconMap } from '../../constant';
import { BeginQuery } from '../../interface';
import { BeginDynamicOptions } from './begin-dynamic-options';
type ModalFormProps = {
initialValue: BeginQuery;
otherThanCurrentQuery: BeginQuery[];
submit(values: any): void;
};
const FormId = 'BeginParameterForm';
function ParameterForm({
initialValue,
otherThanCurrentQuery,
submit,
}: ModalFormProps) {
const { t } = useTranslate('flow');
const FormSchema = z.object({
type: z.string(),
key: z
.string()
.trim()
.min(1)
.refine(
(value) =>
!value || !otherThanCurrentQuery.some((x) => x.key === value),
{ message: 'The key cannot be repeated!' },
),
optional: z.boolean(),
name: z.string().trim().min(1),
options: z
.array(z.object({ value: z.string().or(z.boolean()).or(z.number()) }))
.optional(),
});
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
mode: 'onChange',
defaultValues: {
type: BeginQueryType.Line,
optional: false,
key: '',
name: '',
options: [],
},
});
const options = useMemo(() => {
return Object.values(BeginQueryType).reduce<RAGFlowSelectOptionType[]>(
(pre, cur) => {
const Icon = BeginQueryTypeIconMap[cur];
return [
...pre,
{
label: (
<div className="flex items-center gap-2">
<Icon
className={`size-${cur === BeginQueryType.Options ? 4 : 5}`}
></Icon>
{t(cur.toLowerCase())}
</div>
),
value: cur,
},
];
},
[],
);
}, []);
const type = useWatch({
control: form.control,
name: 'type',
});
useEffect(() => {
if (!isEmpty(initialValue)) {
form.reset({
...initialValue,
options: initialValue.options?.map((x) => ({ value: x })),
});
}
}, [form, initialValue]);
function onSubmit(data: z.infer<typeof FormSchema>) {
const values = { ...data, options: data.options?.map((x) => x.value) };
console.log('🚀 ~ onSubmit ~ values:', values);
submit(values);
}
const handleKeyChange = (e: ChangeEvent<HTMLInputElement>) => {
const name = form.getValues().name || '';
form.setValue('key', e.target.value.trim());
if (!name) {
form.setValue('name', e.target.value.trim());
}
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id={FormId}
className="space-y-5"
autoComplete="off"
>
<FormField
name="type"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>{t('type')}</FormLabel>
<FormControl>
<RAGFlowSelect {...field} options={options} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="key"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>{t('key')}</FormLabel>
<FormControl>
<Input {...field} autoComplete="off" onBlur={handleKeyChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>{t('name')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="optional"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>{t('optional')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{type === BeginQueryType.Options && (
<BeginDynamicOptions></BeginDynamicOptions>
)}
</form>
</Form>
);
}
export function ParameterDialog({
initialValue,
hideModal,
otherThanCurrentQuery,
submit,
}: ModalFormProps & IModalProps<BeginQuery>) {
const { t } = useTranslation();
return (
<Dialog open onOpenChange={hideModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('flow.variableSettings')}</DialogTitle>
</DialogHeader>
<ParameterForm
initialValue={initialValue}
otherThanCurrentQuery={otherThanCurrentQuery}
submit={submit}
></ParameterForm>
<DialogFooter>
<Button type="submit" form={FormId}>
{t('modal.okText')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,199 @@
'use client';
import {
ColumnDef,
ColumnFiltersState,
SortingState,
VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import { Pencil, Trash2 } from 'lucide-react';
import * as React from 'react';
import { TableEmpty } from '@/components/table-skeleton';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import { BeginQuery } from '../../interface';
interface IProps {
data: BeginQuery[];
deleteRecord(index: number): void;
showModal(index: number, record: BeginQuery): void;
}
export function QueryTable({ data = [], deleteRecord, showModal }: IProps) {
const { t } = useTranslation();
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[],
);
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const columns: ColumnDef<BeginQuery>[] = [
{
accessorKey: 'key',
header: t('flow.key'),
meta: { cellClassName: 'max-w-30' },
cell: ({ row }) => {
const key: string = row.getValue('key');
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="truncate ">{key}</div>
</TooltipTrigger>
<TooltipContent>
<p>{key}</p>
</TooltipContent>
</Tooltip>
);
},
},
{
accessorKey: 'name',
header: t('flow.name'),
meta: { cellClassName: 'max-w-30' },
cell: ({ row }) => {
const name: string = row.getValue('name');
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="truncate">{name}</div>
</TooltipTrigger>
<TooltipContent>
<p>{name}</p>
</TooltipContent>
</Tooltip>
);
},
},
{
accessorKey: 'type',
header: t('flow.type'),
cell: ({ row }) => (
<div>
{t(`flow.${(row.getValue('type')?.toString() || '').toLowerCase()}`)}
</div>
),
},
{
accessorKey: 'optional',
header: t('flow.optional'),
cell: ({ row }) => <div>{row.getValue('optional') ? 'Yes' : 'No'}</div>,
},
{
id: 'actions',
enableHiding: false,
header: t('common.action'),
cell: ({ row }) => {
const record = row.original;
const idx = row.index;
return (
<div>
<Button
className="bg-transparent text-foreground hover:bg-muted-foreground hover:text-foreground"
onClick={() => showModal(idx, record)}
>
<Pencil />
</Button>
<Button
className="bg-transparent text-foreground hover:bg-muted-foreground hover:text-foreground"
onClick={() => deleteRecord(idx)}
>
<Trash2 />
</Button>
</div>
);
},
},
];
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
state: {
sorting,
columnFilters,
columnVisibility,
},
});
return (
<div className="w-full">
<div className="rounded-md border">
<Table rootClassName="rounded-md">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={cn(cell.column.columnDef.meta?.cellClassName)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableEmpty columnsLength={columns.length}></TableEmpty>
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@ -0,0 +1,67 @@
import { useSetModalState } from '@/hooks/common-hooks';
import { useSetSelectedRecord } from '@/hooks/logic-hooks';
import { useCallback, useMemo, useState } from 'react';
import { UseFormReturn, useWatch } from 'react-hook-form';
import { BeginQuery, INextOperatorForm } from '../../interface';
export const useEditQueryRecord = ({
form,
}: INextOperatorForm & { form: UseFormReturn }) => {
const { setRecord, currentRecord } = useSetSelectedRecord<BeginQuery>();
const { visible, hideModal, showModal } = useSetModalState();
const [index, setIndex] = useState(-1);
const inputs: BeginQuery[] = useWatch({
control: form.control,
name: 'inputs',
});
const otherThanCurrentQuery = useMemo(() => {
return inputs.filter((item, idx) => idx !== index);
}, [index, inputs]);
const handleEditRecord = useCallback(
(record: BeginQuery) => {
const inputs: BeginQuery[] = form?.getValues('inputs') || [];
const nextQuery: BeginQuery[] =
index > -1 ? inputs.toSpliced(index, 1, record) : [...inputs, record];
form.setValue('inputs', nextQuery);
hideModal();
},
[form, hideModal, index],
);
const handleShowModal = useCallback(
(idx?: number, record?: BeginQuery) => {
setIndex(idx ?? -1);
setRecord(record ?? ({} as BeginQuery));
showModal();
},
[setRecord, showModal],
);
const handleDeleteRecord = useCallback(
(idx: number) => {
const inputs = form?.getValues('inputs') || [];
const nextInputs = inputs.filter(
(item: BeginQuery, index: number) => index !== idx,
);
form.setValue('inputs', nextInputs);
},
[form],
);
return {
ok: handleEditRecord,
currentRecord,
setRecord,
visible,
hideModal,
showModal: handleShowModal,
otherThanCurrentQuery,
handleDeleteRecord,
};
};

View File

@ -0,0 +1,34 @@
import { RAGFlowNodeType } from '@/interfaces/database/flow';
import { isEmpty } from 'lodash';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { AgentDialogueMode } from '../../constant';
import { buildBeginInputListFromObject } from './utils';
export function useValues(node?: RAGFlowNodeType) {
const { t } = useTranslation();
const defaultValues = useMemo(
() => ({
enablePrologue: true,
prologue: t('chat.setAnOpenerInitial'),
mode: AgentDialogueMode.Conversational,
inputs: [],
}),
[t],
);
const values = useMemo(() => {
const formData = node?.data?.form;
if (isEmpty(formData)) {
return defaultValues;
}
const inputs = buildBeginInputListFromObject(formData?.inputs);
return { ...(formData || {}), inputs };
}, [defaultValues, node?.data?.form]);
return values;
}

View File

@ -0,0 +1,31 @@
import { omit } from 'lodash';
import { useEffect } from 'react';
import { UseFormReturn, useWatch } from 'react-hook-form';
import { BeginQuery } from '../../interface';
import useGraphStore from '../../store';
export function transferInputsArrayToObject(inputs: BeginQuery[] = []) {
return inputs.reduce<Record<string, Omit<BeginQuery, 'key'>>>((pre, cur) => {
pre[cur.key] = omit(cur, 'key');
return pre;
}, {});
}
export function useWatchFormChange(id?: string, form?: UseFormReturn) {
let values = useWatch({ control: form?.control });
const updateNodeForm = useGraphStore((state) => state.updateNodeForm);
useEffect(() => {
if (id) {
values = form?.getValues() || {};
const nextValues = {
...values,
inputs: transferInputsArrayToObject(values.inputs),
};
updateNodeForm(id, nextValues);
}
}, [form?.formState.isDirty, id, updateNodeForm, values]);
}

View File

@ -0,0 +1,14 @@
import { BeginQuery } from '../../interface';
export function buildBeginInputListFromObject(
inputs: Record<string, Omit<BeginQuery, 'key'>>,
) {
return Object.entries(inputs || {}).reduce<BeginQuery[]>(
(pre, [key, value]) => {
pre.push({ ...(value || {}), key });
return pre;
},
[],
);
}

View File

@ -0,0 +1,131 @@
import { SelectWithSearch } from '@/components/originui/select-with-search';
import { TopNFormField } from '@/components/top-n-item';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { useTranslate } from '@/hooks/common-hooks';
import { zodResolver } from '@hookform/resolvers/zod';
import { memo, useMemo } from 'react';
import { useForm, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { initialBingValues } from '../../constant';
import { useFormValues } from '../../hooks/use-form-values';
import { useWatchFormChange } from '../../hooks/use-watch-form-change';
import { INextOperatorForm } from '../../interface';
import { BingCountryOptions, BingLanguageOptions } from '../../options';
import { FormWrapper } from '../components/form-wrapper';
import { QueryVariable } from '../components/query-variable';
export const BingFormSchema = {
channel: z.string(),
api_key: z.string(),
country: z.string(),
language: z.string(),
top_n: z.number(),
};
export const FormSchema = z.object({
query: z.string().optional(),
...BingFormSchema,
});
export function BingFormWidgets() {
const form = useFormContext();
const { t } = useTranslate('flow');
const options = useMemo(() => {
return ['Webpages', 'News'].map((x) => ({ label: x, value: x }));
}, []);
return (
<>
<TopNFormField></TopNFormField>
<FormField
control={form.control}
name="channel"
render={({ field }) => (
<FormItem>
<FormLabel>{t('channel')}</FormLabel>
<FormControl>
<SelectWithSearch {...field} options={options}></SelectWithSearch>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>{t('apiKey')}</FormLabel>
<FormControl>
<Input {...field}></Input>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="country"
render={({ field }) => (
<FormItem>
<FormLabel>{t('country')}</FormLabel>
<FormControl>
<SelectWithSearch
{...field}
options={BingCountryOptions}
></SelectWithSearch>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>{t('language')}</FormLabel>
<FormControl>
<SelectWithSearch
{...field}
options={BingLanguageOptions}
></SelectWithSearch>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
}
function BingForm({ node }: INextOperatorForm) {
const defaultValues = useFormValues(initialBingValues, node);
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues,
});
useWatchFormChange(node?.id, form);
return (
<Form {...form}>
<FormWrapper>
<QueryVariable></QueryVariable>
<BingFormWidgets></BingFormWidgets>
</FormWrapper>
</Form>
);
}
export default memo(BingForm);

View File

@ -0,0 +1,249 @@
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { BlurTextarea } from '@/components/ui/textarea';
import { useTranslate } from '@/hooks/common-hooks';
import { PlusOutlined } from '@ant-design/icons';
import { useUpdateNodeInternals } from '@xyflow/react';
import humanId from 'human-id';
import trim from 'lodash/trim';
import { ChevronsUpDown, X } from 'lucide-react';
import {
ChangeEventHandler,
FocusEventHandler,
memo,
useCallback,
useEffect,
useState,
} from 'react';
import { UseFormReturn, useFieldArray, useFormContext } from 'react-hook-form';
import { v4 as uuid } from 'uuid';
import { z } from 'zod';
import useGraphStore from '../../store';
import DynamicExample from './dynamic-example';
import { useCreateCategorizeFormSchema } from './use-form-schema';
interface IProps {
nodeId?: string;
}
interface INameInputProps {
value?: string;
onChange?: (value: string) => void;
otherNames?: string[];
validate(error?: string): void;
}
const getOtherFieldValues = (
form: UseFormReturn,
formListName: string = 'items',
index: number,
latestField: string,
) =>
(form.getValues(formListName) ?? [])
.map((x: any) => x[latestField])
.filter(
(x: string) =>
x !== form.getValues(`${formListName}.${index}.${latestField}`),
);
const InnerNameInput = ({
value,
onChange,
otherNames,
validate,
}: INameInputProps) => {
const [name, setName] = useState<string | undefined>();
const { t } = useTranslate('flow');
const handleNameChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(e) => {
const val = e.target.value;
setName(val);
const trimmedVal = trim(val);
// trigger validation
if (otherNames?.some((x) => x === trimmedVal)) {
validate(t('nameRepeatedMsg'));
} else if (trimmedVal === '') {
validate(t('nameRequiredMsg'));
} else {
validate('');
}
},
[otherNames, validate, t],
);
const handleNameBlur: FocusEventHandler<HTMLInputElement> = useCallback(
(e) => {
const val = e.target.value;
if (otherNames?.every((x) => x !== val) && trim(val) !== '') {
onChange?.(val);
}
},
[onChange, otherNames],
);
useEffect(() => {
setName(value);
}, [value]);
return (
<Input
value={name}
onChange={handleNameChange}
onBlur={handleNameBlur}
></Input>
);
};
const NameInput = memo(InnerNameInput);
const InnerFormSet = ({ index }: IProps & { index: number }) => {
const form = useFormContext();
const { t } = useTranslate('flow');
const buildFieldName = useCallback(
(name: string) => {
return `items.${index}.${name}`;
},
[index],
);
return (
<section className="space-y-4">
<FormField
control={form.control}
name={buildFieldName('name')}
render={({ field }) => (
<FormItem>
<FormLabel>{t('categoryName')}</FormLabel>
<FormControl>
<NameInput
{...field}
otherNames={getOtherFieldValues(form, 'items', index, 'name')}
validate={(error?: string) => {
const fieldName = buildFieldName('name');
if (error) {
form.setError(fieldName, { message: error });
} else {
form.clearErrors(fieldName);
}
}}
></NameInput>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={buildFieldName('description')}
render={({ field }) => (
<FormItem>
<FormLabel>{t('description')}</FormLabel>
<FormControl>
<BlurTextarea {...field} rows={3} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Create a hidden field to make Form instance record this */}
<FormField
control={form.control}
name={'uuid'}
render={() => <div></div>}
/>
<DynamicExample name={buildFieldName('examples')}></DynamicExample>
</section>
);
};
const FormSet = memo(InnerFormSet);
const DynamicCategorize = ({ nodeId }: IProps) => {
const updateNodeInternals = useUpdateNodeInternals();
const FormSchema = useCreateCategorizeFormSchema();
const deleteCategorizeCaseEdges = useGraphStore(
(state) => state.deleteEdgesBySourceAndSourceHandle,
);
const form = useFormContext<z.infer<typeof FormSchema>>();
const { t } = useTranslate('flow');
const { fields, remove, append } = useFieldArray({
name: 'items',
control: form.control,
});
const handleAdd = useCallback(() => {
append({
name: humanId(),
description: '',
uuid: uuid(),
examples: [{ value: '' }],
});
if (nodeId) updateNodeInternals(nodeId);
}, [append, nodeId, updateNodeInternals]);
const handleRemove = useCallback(
(index: number) => () => {
remove(index);
if (nodeId) {
const uuid = fields[index].uuid;
deleteCategorizeCaseEdges(nodeId, uuid);
}
},
[deleteCategorizeCaseEdges, fields, nodeId, remove],
);
return (
<div className="flex flex-col gap-4 ">
{fields.map((field, index) => (
<Collapsible key={field.id} defaultOpen>
<div className="flex items-center justify-between space-x-4">
<h4 className="font-bold">
{form.getValues(`items.${index}.name`)}
</h4>
<CollapsibleTrigger asChild>
<div className="flex gap-4">
<Button
variant="ghost"
size="sm"
className="w-9 p-0"
onClick={handleRemove(index)}
>
<X className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="w-9 p-0">
<ChevronsUpDown className="h-4 w-4" />
<span className="sr-only">Toggle</span>
</Button>
</div>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<FormSet nodeId={nodeId} index={index}></FormSet>
</CollapsibleContent>
</Collapsible>
))}
<Button type={'button'} onClick={handleAdd}>
<PlusOutlined />
{t('addCategory')}
</Button>
</div>
);
};
export default memo(DynamicCategorize);

View File

@ -0,0 +1,68 @@
import { Button } from '@/components/ui/button';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Textarea } from '@/components/ui/textarea';
import { Plus, X } from 'lucide-react';
import { memo } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
type DynamicExampleProps = { name: string };
const DynamicExample = ({ name }: DynamicExampleProps) => {
const { t } = useTranslation();
const form = useFormContext();
const { fields, append, remove } = useFieldArray({
name: name,
control: form.control,
});
return (
<FormItem>
<FormLabel tooltip={t('flow.msgTip')}>{t('flow.examples')}</FormLabel>
<div className="space-y-4">
{fields.map((field, index) => (
<div key={field.id} className="flex items-start gap-2">
<FormField
control={form.control}
name={`${name}.${index}.value`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Textarea {...field}> </Textarea>
</FormControl>
</FormItem>
)}
/>
{index === 0 ? (
<Button
type="button"
variant={'ghost'}
onClick={() => append({ value: '' })}
>
<Plus />
</Button>
) : (
<Button
type="button"
variant={'ghost'}
onClick={() => remove(index)}
>
<X />
</Button>
)}
</div>
))}
</div>
<FormMessage />
</FormItem>
);
};
export default memo(DynamicExample);

View File

@ -0,0 +1,48 @@
import { FormContainer } from '@/components/form-container';
import { LargeModelFormField } from '@/components/large-model-form-field';
import { MessageHistoryWindowSizeFormField } from '@/components/message-history-window-size-item';
import { Form } from '@/components/ui/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { memo } from 'react';
import { useForm } from 'react-hook-form';
import { initialCategorizeValues } from '../../constant';
import { INextOperatorForm } from '../../interface';
import { buildOutputList } from '../../utils/build-output-list';
import { FormWrapper } from '../components/form-wrapper';
import { Output } from '../components/output';
import { QueryVariable } from '../components/query-variable';
import DynamicCategorize from './dynamic-categorize';
import { useCreateCategorizeFormSchema } from './use-form-schema';
import { useValues } from './use-values';
import { useWatchFormChange } from './use-watch-change';
const outputList = buildOutputList(initialCategorizeValues.outputs);
function CategorizeForm({ node }: INextOperatorForm) {
const values = useValues(node);
const FormSchema = useCreateCategorizeFormSchema();
const form = useForm({
defaultValues: values,
resolver: zodResolver(FormSchema),
});
useWatchFormChange(node?.id, form);
return (
<Form {...form}>
<FormWrapper>
<FormContainer>
<QueryVariable></QueryVariable>
<LargeModelFormField></LargeModelFormField>
</FormContainer>
<MessageHistoryWindowSizeFormField></MessageHistoryWindowSizeFormField>
<DynamicCategorize nodeId={node?.id}></DynamicCategorize>
<Output list={outputList}></Output>
</FormWrapper>
</Form>
);
}
export default memo(CategorizeForm);

View File

@ -0,0 +1,32 @@
import { LlmSettingSchema } from '@/components/llm-setting-items/next';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
export function useCreateCategorizeFormSchema() {
const { t } = useTranslation();
const FormSchema = z.object({
query: z.string().optional(),
parameter: z.string().optional(),
...LlmSettingSchema,
message_history_window_size: z.coerce.number(),
items: z.array(
z
.object({
name: z.string().min(1, t('flow.nameMessage')).trim(),
description: z.string().optional(),
uuid: z.string(),
examples: z
.array(
z.object({
value: z.string(),
}),
)
.optional(),
})
.optional(),
),
});
return FormSchema;
}

View File

@ -0,0 +1,34 @@
import { ModelVariableType } from '@/constants/knowledge';
import { RAGFlowNodeType } from '@/interfaces/database/flow';
import { isEmpty, isPlainObject } from 'lodash';
import { useMemo } from 'react';
const defaultValues = {
parameter: ModelVariableType.Precise,
message_history_window_size: 1,
temperatureEnabled: true,
topPEnabled: true,
presencePenaltyEnabled: true,
frequencyPenaltyEnabled: true,
maxTokensEnabled: true,
items: [],
};
export function useValues(node?: RAGFlowNodeType) {
const values = useMemo(() => {
const formData = node?.data?.form;
if (isEmpty(formData)) {
return defaultValues;
}
if (isPlainObject(formData)) {
// const nextValues = {
// ...omit(formData, 'category_description'),
// items,
// };
return formData;
}
}, [node]);
return values;
}

View File

@ -0,0 +1,17 @@
import { useEffect } from 'react';
import { UseFormReturn, useWatch } from 'react-hook-form';
import useGraphStore from '../../store';
export function useWatchFormChange(id?: string, form?: UseFormReturn<any>) {
let values = useWatch({ control: form?.control });
const updateNodeForm = useGraphStore((state) => state.updateNodeForm);
useEffect(() => {
// Manually triggered form updates are synchronized to the canvas
if (id) {
values = form?.getValues();
updateNodeForm(id, { ...values, items: values.items?.slice() || [] });
}
}, [id, updateNodeForm, values]);
}

View File

@ -0,0 +1,168 @@
import Editor, { loader } from '@monaco-editor/react';
import { INextOperatorForm } from '../../interface';
import { FormContainer } from '@/components/form-container';
import { useIsDarkTheme } from '@/components/theme-provider';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { RAGFlowSelect } from '@/components/ui/select';
import { ProgrammingLanguage } from '@/constants/agent';
import { ICodeForm } from '@/interfaces/database/agent';
import { zodResolver } from '@hookform/resolvers/zod';
import { memo } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { buildOutputList } from '../../utils/build-output-list';
import { FormWrapper } from '../components/form-wrapper';
import { Output } from '../components/output';
import {
DynamicInputVariable,
TypeOptions,
VariableTitle,
} from './next-variable';
import { FormSchema, FormSchemaType } from './schema';
import { useValues } from './use-values';
import {
useHandleLanguageChange,
useWatchFormChange,
} from './use-watch-change';
loader.config({ paths: { vs: '/vs' } });
const options = [
ProgrammingLanguage.Python,
ProgrammingLanguage.Javascript,
].map((x) => ({ value: x, label: x }));
const DynamicFieldName = 'outputs';
function CodeForm({ node }: INextOperatorForm) {
const formData = node?.data.form as ICodeForm;
const { t } = useTranslation();
const values = useValues(node);
const isDarkTheme = useIsDarkTheme();
const form = useForm<FormSchemaType>({
defaultValues: values,
resolver: zodResolver(FormSchema),
});
useWatchFormChange(node?.id, form);
const handleLanguageChange = useHandleLanguageChange(node?.id, form);
return (
<Form {...form}>
<FormWrapper>
<DynamicInputVariable
node={node}
title={t('flow.input')}
isOutputs={false}
></DynamicInputVariable>
<FormField
control={form.control}
name="script"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
Code
<FormField
control={form.control}
name="lang"
render={({ field }) => (
<FormItem>
<FormControl>
<RAGFlowSelect
{...field}
onChange={(val) => {
field.onChange(val);
handleLanguageChange(val);
}}
options={options}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</FormLabel>
<FormControl>
<Editor
height={300}
theme={isDarkTheme ? 'vs-dark' : 'vs'}
language={formData.lang}
options={{
minimap: { enabled: false },
automaticLayout: true,
}}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{formData.lang === ProgrammingLanguage.Python ? (
<DynamicInputVariable
node={node}
title={'Return Values'}
name={DynamicFieldName}
isOutputs
></DynamicInputVariable>
) : (
<div>
<VariableTitle title={'Return Values'}></VariableTitle>
<FormContainer className="space-y-5">
<FormField
control={form.control}
name={`${DynamicFieldName}.name`}
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t('common.pleaseInput')}
></Input>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`${DynamicFieldName}.type`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Type</FormLabel>
<FormControl>
<RAGFlowSelect
placeholder={t('common.pleaseSelect')}
options={TypeOptions}
{...field}
></RAGFlowSelect>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</FormContainer>
</div>
)}
</FormWrapper>
<div className="p-5">
<Output list={buildOutputList(formData.outputs)}></Output>
</div>
</Form>
);
}
export default memo(CodeForm);

View File

@ -0,0 +1,128 @@
'use client';
import { FormContainer } from '@/components/form-container';
import { SelectWithSearch } from '@/components/originui/select-with-search';
import { BlockButton, Button } from '@/components/ui/button';
import {
FormControl,
FormField,
FormItem,
FormMessage,
} from '@/components/ui/form';
import { BlurInput } from '@/components/ui/input';
import { RAGFlowSelect } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { RAGFlowNodeType } from '@/interfaces/database/flow';
import { X } from 'lucide-react';
import { ReactNode } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useBuildQueryVariableOptions } from '../../hooks/use-get-begin-query';
interface IProps {
node?: RAGFlowNodeType;
name?: string;
isOutputs: boolean;
}
export const TypeOptions = [
'String',
'Number',
'Boolean',
'Array<String>',
'Array<Number>',
'Object',
].map((x) => ({ label: x, value: x }));
export function DynamicVariableForm({ name = 'arguments', isOutputs }: IProps) {
const { t } = useTranslation();
const form = useFormContext();
const { fields, remove, append } = useFieldArray({
name: name,
control: form.control,
});
const nextOptions = useBuildQueryVariableOptions();
return (
<div className="space-y-5">
{fields.map((field, index) => {
const typeField = `${name}.${index}.name`;
return (
<div key={field.id} className="flex w-full items-center gap-2">
<FormField
control={form.control}
name={typeField}
render={({ field }) => (
<FormItem className="flex-1 overflow-hidden">
<FormControl>
<BlurInput
{...field}
placeholder={t('common.pleaseInput')}
></BlurInput>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Separator className="w-3 text-text-secondary" />
<FormField
control={form.control}
name={`${name}.${index}.type`}
render={({ field }) => (
<FormItem className="flex-1 overflow-hidden">
<FormControl>
{isOutputs ? (
<RAGFlowSelect
placeholder={t('common.pleaseSelect')}
options={TypeOptions}
{...field}
></RAGFlowSelect>
) : (
<SelectWithSearch
options={nextOptions}
{...field}
></SelectWithSearch>
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button variant={'ghost'} onClick={() => remove(index)}>
<X className="text-text-sub-title-invert " />
</Button>
</div>
);
})}
<BlockButton onClick={() => append({ name: '', type: undefined })}>
{t('flow.addVariable')}
</BlockButton>
</div>
);
}
export function VariableTitle({ title }: { title: ReactNode }) {
return <div className="font-medium text-text-primary pb-2">{title}</div>;
}
export function DynamicInputVariable({
node,
name,
title,
isOutputs = false,
}: IProps & { title: ReactNode }) {
return (
<section>
<VariableTitle title={title}></VariableTitle>
<FormContainer>
<DynamicVariableForm
node={node}
name={name}
isOutputs={isOutputs}
></DynamicVariableForm>
</FormContainer>
</section>
);
}

View File

@ -0,0 +1,14 @@
import { ProgrammingLanguage } from '@/constants/agent';
import { z } from 'zod';
export const FormSchema = z.object({
lang: z.enum([ProgrammingLanguage.Python, ProgrammingLanguage.Javascript]),
script: z.string(),
arguments: z.array(z.object({ name: z.string(), type: z.string() })),
outputs: z.union([
z.array(z.object({ name: z.string(), type: z.string() })).optional(),
z.object({ name: z.string(), type: z.string() }),
]),
});
export type FormSchemaType = z.infer<typeof FormSchema>;

View File

@ -0,0 +1,47 @@
import { ProgrammingLanguage } from '@/constants/agent';
import { ICodeForm } from '@/interfaces/database/agent';
import { RAGFlowNodeType } from '@/interfaces/database/flow';
import { isEmpty } from 'lodash';
import { useMemo } from 'react';
import { initialCodeValues } from '../../constant';
function convertToArray(args: Record<string, string>) {
return Object.entries(args).map(([key, value]) => ({
name: key,
type: value,
}));
}
type OutputsFormType = { name: string; type: string };
function convertOutputsToArray({ lang, outputs = {} }: ICodeForm) {
if (lang === ProgrammingLanguage.Python) {
return Object.entries(outputs).map(([key, val]) => ({
name: key,
type: val.type,
}));
}
return Object.entries(outputs).reduce<OutputsFormType>((pre, [key, val]) => {
pre.name = key;
pre.type = val.type;
return pre;
}, {} as OutputsFormType);
}
export function useValues(node?: RAGFlowNodeType) {
const values = useMemo(() => {
const formData = node?.data?.form;
if (isEmpty(formData)) {
return initialCodeValues;
}
return {
...formData,
arguments: convertToArray(formData.arguments),
outputs: convertOutputsToArray(formData),
};
}, [node?.data?.form]);
return values;
}

View File

@ -0,0 +1,95 @@
import { CodeTemplateStrMap, ProgrammingLanguage } from '@/constants/agent';
import { ICodeForm } from '@/interfaces/database/agent';
import { isEmpty } from 'lodash';
import { useCallback, useEffect } from 'react';
import { UseFormReturn, useWatch } from 'react-hook-form';
import useGraphStore from '../../store';
import { FormSchemaType } from './schema';
function convertToObject(list: FormSchemaType['arguments'] = []) {
return list.reduce<Record<string, string>>((pre, cur) => {
pre[cur.name] = cur.type;
return pre;
}, {});
}
type ArrayOutputs = Extract<FormSchemaType['outputs'], Array<any>>;
type ObjectOutputs = Exclude<FormSchemaType['outputs'], Array<any>>;
function convertOutputsToObject({ lang, outputs }: FormSchemaType) {
if (lang === ProgrammingLanguage.Python) {
return (outputs as ArrayOutputs).reduce<ICodeForm['outputs']>(
(pre, cur) => {
pre[cur.name] = {
value: '',
type: cur.type,
};
return pre;
},
{},
);
}
const outputsObject = outputs as ObjectOutputs;
if (isEmpty(outputsObject)) {
return {};
}
return {
[outputsObject.name]: {
value: '',
type: outputsObject.type,
},
};
}
export function useWatchFormChange(
id?: string,
form?: UseFormReturn<FormSchemaType>,
) {
let values = useWatch({ control: form?.control });
const updateNodeForm = useGraphStore((state) => state.updateNodeForm);
useEffect(() => {
// Manually triggered form updates are synchronized to the canvas
if (id) {
values = form?.getValues() || {};
let nextValues: any = {
...values,
arguments: convertToObject(
values?.arguments as FormSchemaType['arguments'],
),
outputs: convertOutputsToObject(values as FormSchemaType),
};
updateNodeForm(id, nextValues);
}
}, [form?.formState.isDirty, id, updateNodeForm, values]);
}
export function useHandleLanguageChange(
id?: string,
form?: UseFormReturn<FormSchemaType>,
) {
const updateNodeForm = useGraphStore((state) => state.updateNodeForm);
const handleLanguageChange = useCallback(
(lang: string) => {
if (id) {
const script = CodeTemplateStrMap[lang as ProgrammingLanguage];
form?.setValue('script', script);
form?.setValue(
'outputs',
(lang === ProgrammingLanguage.Python
? []
: {}) as FormSchemaType['outputs'],
);
updateNodeForm(id, script, ['script']);
}
},
[form, id, updateNodeForm],
);
return handleLanguageChange;
}

View File

@ -0,0 +1,32 @@
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { t } from 'i18next';
import { useFormContext } from 'react-hook-form';
interface IApiKeyFieldProps {
placeholder?: string;
}
export function ApiKeyField({ placeholder }: IApiKeyFieldProps) {
const form = useFormContext();
return (
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>{t('flow.apiKey')}</FormLabel>
<FormControl>
<Input type="password" {...field} placeholder={placeholder}></Input>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}

View File

@ -0,0 +1,27 @@
import {
FormControl,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/form';
import { Textarea } from '@/components/ui/textarea';
import { t } from 'i18next';
import { useFormContext } from 'react-hook-form';
export function DescriptionField() {
const form = useFormContext();
return (
<FormField
control={form.control}
name={`description`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>{t('flow.description')}</FormLabel>
<FormControl>
<Textarea {...field}></Textarea>
</FormControl>
</FormItem>
)}
/>
);
}

View File

@ -0,0 +1,127 @@
import { RAGFlowNodeType } from '@/interfaces/database/flow';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { Button, Collapse, Flex, Form, Input, Select } from 'antd';
import { PropsWithChildren, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useBuildVariableOptions } from '../../hooks/use-get-begin-query';
import styles from './index.less';
interface IProps {
node?: RAGFlowNodeType;
}
enum VariableType {
Reference = 'reference',
Input = 'input',
}
const getVariableName = (type: string) =>
type === VariableType.Reference ? 'component_id' : 'value';
const DynamicVariableForm = ({ node }: IProps) => {
const { t } = useTranslation();
const valueOptions = useBuildVariableOptions(node?.id, node?.parentId);
const form = Form.useFormInstance();
const options = [
{ value: VariableType.Reference, label: t('flow.reference') },
{ value: VariableType.Input, label: t('flow.text') },
];
const handleTypeChange = useCallback(
(name: number) => () => {
setTimeout(() => {
form.setFieldValue(['query', name, 'component_id'], undefined);
form.setFieldValue(['query', name, 'value'], undefined);
}, 0);
},
[form],
);
return (
<Form.List name="query">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Flex key={key} gap={10} align={'baseline'}>
<Form.Item
{...restField}
name={[name, 'type']}
className={styles.variableType}
>
<Select
options={options}
onChange={handleTypeChange(name)}
></Select>
</Form.Item>
<Form.Item noStyle dependencies={[name, 'type']}>
{({ getFieldValue }) => {
const type = getFieldValue(['query', name, 'type']);
return (
<Form.Item
{...restField}
name={[name, getVariableName(type)]}
className={styles.variableValue}
>
{type === VariableType.Reference ? (
<Select
placeholder={t('common.pleaseSelect')}
options={valueOptions}
></Select>
) : (
<Input placeholder={t('common.pleaseInput')} />
)}
</Form.Item>
);
}}
</Form.Item>
<MinusCircleOutlined onClick={() => remove(name)} />
</Flex>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => add({ type: VariableType.Reference })}
block
icon={<PlusOutlined />}
className={styles.addButton}
>
{t('flow.addVariable')}
</Button>
</Form.Item>
</>
)}
</Form.List>
);
};
export function FormCollapse({
children,
title,
}: PropsWithChildren<{ title: string }>) {
return (
<Collapse
className={styles.dynamicInputVariable}
defaultActiveKey={['1']}
items={[
{
key: '1',
label: <span className={styles.title}>{title}</span>,
children,
},
]}
/>
);
}
const DynamicInputVariable = ({ node }: IProps) => {
const { t } = useTranslation();
return (
<FormCollapse title={t('flow.input')}>
<DynamicVariableForm node={node}></DynamicVariableForm>
</FormCollapse>
);
};
export default DynamicInputVariable;

View File

@ -0,0 +1,16 @@
type FormProps = React.ComponentProps<'form'>;
export function FormWrapper({ children, ...props }: FormProps) {
return (
<form
className="space-y-6 p-4"
autoComplete="off"
onSubmit={(e) => {
e.preventDefault();
}}
{...props}
>
{children}
</form>
);
}

View File

@ -0,0 +1,22 @@
.dynamicInputVariable {
background-color: #ebe9e950;
:global(.ant-collapse-content) {
background-color: #f6f6f657;
}
margin-bottom: 20px;
.title {
font-weight: 600;
font-size: 16px;
}
.variableType {
width: 30%;
}
.variableValue {
flex: 1;
}
.addButton {
color: rgb(22, 119, 255);
font-weight: 600;
}
}

View File

@ -0,0 +1,135 @@
'use client';
import { SideDown } from '@/assets/icon/next-icon';
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { RAGFlowSelect } from '@/components/ui/select';
import { RAGFlowNodeType } from '@/interfaces/database/flow';
import { Plus, Trash2 } from 'lucide-react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useBuildVariableOptions } from '../../hooks/use-get-begin-query';
interface IProps {
node?: RAGFlowNodeType;
}
enum VariableType {
Reference = 'reference',
Input = 'input',
}
const getVariableName = (type: string) =>
type === VariableType.Reference ? 'component_id' : 'value';
export function DynamicVariableForm({ node }: IProps) {
const { t } = useTranslation();
const form = useFormContext();
const { fields, remove, append } = useFieldArray({
name: 'query',
control: form.control,
});
const valueOptions = useBuildVariableOptions(node?.id, node?.parentId);
const options = [
{ value: VariableType.Reference, label: t('flow.reference') },
{ value: VariableType.Input, label: t('flow.text') },
];
return (
<div>
{fields.map((field, index) => {
const typeField = `query.${index}.type`;
const typeValue = form.watch(typeField);
return (
<div key={field.id} className="flex items-center gap-1">
<FormField
control={form.control}
name={typeField}
render={({ field }) => (
<FormItem className="w-2/5">
<FormDescription />
<FormControl>
<RAGFlowSelect
{...field}
placeholder={t('common.pleaseSelect')}
options={options}
onChange={(val) => {
field.onChange(val);
form.resetField(`query.${index}.value`);
form.resetField(`query.${index}.component_id`);
}}
></RAGFlowSelect>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`query.${index}.${getVariableName(typeValue)}`}
render={({ field }) => (
<FormItem className="flex-1">
<FormDescription />
<FormControl>
{typeValue === VariableType.Reference ? (
<RAGFlowSelect
placeholder={t('common.pleaseSelect')}
{...field}
options={valueOptions}
></RAGFlowSelect>
) : (
<Input placeholder={t('common.pleaseInput')} {...field} />
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Trash2
className="cursor-pointer mx-3 size-4 text-colors-text-functional-danger"
onClick={() => remove(index)}
/>
</div>
);
})}
<Button onClick={append} className="mt-4" variant={'outline'} size={'sm'}>
<Plus />
{t('flow.addVariable')}
</Button>
</div>
);
}
export function DynamicInputVariable({ node }: IProps) {
const { t } = useTranslation();
return (
<Collapsible defaultOpen className="group/collapsible">
<CollapsibleTrigger className="flex justify-between w-full pb-2">
<span className="font-bold text-2xl text-colors-text-neutral-strong">
{t('flow.input')}
</span>
<Button variant={'icon'} size={'icon'}>
<SideDown />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<DynamicVariableForm node={node}></DynamicVariableForm>
</CollapsibleContent>
</Collapsible>
);
}

View File

@ -0,0 +1,35 @@
import { t } from 'i18next';
export type OutputType = {
title: string;
type?: string;
};
type OutputProps = {
list: Array<OutputType>;
};
export function transferOutputs(outputs: Record<string, any>) {
return Object.entries(outputs).map(([key, value]) => ({
title: key,
type: value?.type,
}));
}
export function Output({ list }: OutputProps) {
return (
<section className="space-y-2">
<div>{t('flow.output')}</div>
<ul>
{list.map((x, idx) => (
<li
key={idx}
className="bg-background-highlight text-accent-primary rounded-sm px-2 py-1"
>
{x.title}: <span className="text-text-secondary">{x.type}</span>
</li>
))}
</ul>
</section>
);
}

View File

@ -0,0 +1 @@
export const ProgrammaticTag = 'programmatic';

View File

@ -0,0 +1,76 @@
.typeahead-popover {
background: #fff;
box-shadow: 0px 5px 10px rgba(0, 0, 0, 0.3);
border-radius: 8px;
position: fixed;
z-index: 1000;
}
.typeahead-popover ul {
list-style: none;
margin: 0;
max-height: 200px;
overflow-y: scroll;
}
.typeahead-popover ul::-webkit-scrollbar {
display: none;
}
.typeahead-popover ul {
-ms-overflow-style: none;
scrollbar-width: none;
}
.typeahead-popover ul li {
margin: 0;
min-width: 180px;
font-size: 14px;
outline: none;
cursor: pointer;
border-radius: 8px;
}
.typeahead-popover ul li.selected {
background: #eee;
}
.typeahead-popover li {
margin: 0 8px 0 8px;
color: #050505;
cursor: pointer;
line-height: 16px;
font-size: 15px;
display: flex;
align-content: center;
flex-direction: row;
flex-shrink: 0;
background-color: #fff;
border: 0;
}
.typeahead-popover li.active {
display: flex;
width: 20px;
height: 20px;
background-size: contain;
}
.typeahead-popover li .text {
display: flex;
line-height: 20px;
flex-grow: 1;
min-width: 150px;
}
.typeahead-popover li .icon {
display: flex;
width: 20px;
height: 20px;
user-select: none;
margin-right: 8px;
line-height: 16px;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}

View File

@ -0,0 +1,181 @@
import { CodeHighlightNode, CodeNode } from '@lexical/code';
import {
InitialConfigType,
LexicalComposer,
} from '@lexical/react/LexicalComposer';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
import {
$getRoot,
$getSelection,
EditorState,
Klass,
LexicalNode,
} from 'lexical';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { Variable } from 'lucide-react';
import { ReactNode, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PasteHandlerPlugin } from './paste-handler-plugin';
import theme from './theme';
import { VariableNode } from './variable-node';
import { VariableOnChangePlugin } from './variable-on-change-plugin';
import VariablePickerMenuPlugin from './variable-picker-plugin';
// Catch any errors that occur during Lexical updates and log them
// or throw them as needed. If you don't throw them, Lexical will
// try to recover gracefully without losing user data.
function onError(error: Error) {
console.error(error);
}
const Nodes: Array<Klass<LexicalNode>> = [
HeadingNode,
QuoteNode,
CodeHighlightNode,
CodeNode,
VariableNode,
];
type PromptContentProps = { showToolbar?: boolean; multiLine?: boolean };
type IProps = {
value?: string;
onChange?: (value?: string) => void;
placeholder?: ReactNode;
} & PromptContentProps;
function PromptContent({
showToolbar = true,
multiLine = true,
}: PromptContentProps) {
const [editor] = useLexicalComposerContext();
const [isBlur, setIsBlur] = useState(false);
const { t } = useTranslation();
const insertTextAtCursor = useCallback(() => {
editor.update(() => {
const selection = $getSelection();
if (selection !== null) {
selection.insertText(' /');
}
});
}, [editor]);
const handleVariableIconClick = useCallback(() => {
insertTextAtCursor();
}, [insertTextAtCursor]);
const handleBlur = useCallback(() => {
setIsBlur(true);
}, []);
const handleFocus = useCallback(() => {
setIsBlur(false);
}, []);
return (
<section
className={cn('border rounded-sm ', { 'border-blue-400': !isBlur })}
>
{showToolbar && (
<div className="border-b px-2 py-2 justify-end flex">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block cursor-pointer cursor p-0.5 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-sm">
<Variable size={16} onClick={handleVariableIconClick} />
</span>
</TooltipTrigger>
<TooltipContent>
<p>{t('flow.insertVariableTip')}</p>
</TooltipContent>
</Tooltip>
</div>
)}
<ContentEditable
className={cn(
'relative px-2 py-1 focus-visible:outline-none max-h-[50vh] overflow-auto',
{
'min-h-40': multiLine,
},
)}
onBlur={handleBlur}
onFocus={handleFocus}
/>
</section>
);
}
export function PromptEditor({
value,
onChange,
placeholder,
showToolbar,
multiLine = true,
}: IProps) {
const { t } = useTranslation();
const initialConfig: InitialConfigType = {
namespace: 'PromptEditor',
theme,
onError,
nodes: Nodes,
};
const onValueChange = useCallback(
(editorState: EditorState) => {
editorState?.read(() => {
// const listNodes = $nodesOfType(VariableNode); // to be removed
// const allNodes = $dfs();
const text = $getRoot().getTextContent();
onChange?.(text);
});
},
[onChange],
);
return (
<div className="relative">
<LexicalComposer initialConfig={initialConfig}>
<RichTextPlugin
contentEditable={
<PromptContent
showToolbar={showToolbar}
multiLine={multiLine}
></PromptContent>
}
placeholder={
<div
className={cn(
'absolute top-1 left-2 text-text-secondary pointer-events-none',
{
'truncate w-[90%]': !multiLine,
'translate-y-10': multiLine,
},
)}
>
{placeholder || t('common.promptPlaceholder')}
</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<VariablePickerMenuPlugin value={value}></VariablePickerMenuPlugin>
<PasteHandlerPlugin />
<VariableOnChangePlugin
onChange={onValueChange}
></VariableOnChangePlugin>
</LexicalComposer>
</div>
);
}

View File

@ -0,0 +1,83 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import {
$createParagraphNode,
$createTextNode,
$getSelection,
$isRangeSelection,
PASTE_COMMAND,
} from 'lexical';
import { useEffect } from 'react';
function PasteHandlerPlugin() {
const [editor] = useLexicalComposerContext();
useEffect(() => {
const removeListener = editor.registerCommand(
PASTE_COMMAND,
(clipboardEvent: ClipboardEvent) => {
const clipboardData = clipboardEvent.clipboardData;
if (!clipboardData) {
return false;
}
const text = clipboardData.getData('text/plain');
if (!text) {
return false;
}
// Check if text contains line breaks
if (text.includes('\n')) {
editor.update(() => {
const selection = $getSelection();
if (selection && $isRangeSelection(selection)) {
// Normalize line breaks, merge multiple consecutive line breaks into a single line break
const normalizedText = text.replace(/\n{2,}/g, '\n');
// Clear current selection
selection.removeText();
// Create a paragraph node to contain all content
const paragraph = $createParagraphNode();
// Split text by line breaks
const lines = normalizedText.split('\n');
// Process each line
lines.forEach((lineText, index) => {
// Add line text (if any)
if (lineText) {
const textNode = $createTextNode(lineText);
paragraph.append(textNode);
}
// If not the last line, add a line break
if (index < lines.length - 1) {
const lineBreak = $createTextNode('\n');
paragraph.append(lineBreak);
}
});
// Insert paragraph
selection.insertNodes([paragraph]);
}
});
// Prevent default paste behavior
clipboardEvent.preventDefault();
return true;
}
// If no line breaks, use default behavior
return false;
},
4,
);
return () => {
removeListener();
};
}, [editor]);
return null;
}
export { PasteHandlerPlugin };

View File

@ -0,0 +1,43 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
export default {
code: 'editor-code',
heading: {
h1: 'editor-heading-h1',
h2: 'editor-heading-h2',
h3: 'editor-heading-h3',
h4: 'editor-heading-h4',
h5: 'editor-heading-h5',
},
image: 'editor-image',
link: 'editor-link',
list: {
listitem: 'editor-listitem',
nested: {
listitem: 'editor-nested-listitem',
},
ol: 'editor-list-ol',
ul: 'editor-list-ul',
},
ltr: 'ltr',
paragraph: 'editor-paragraph',
placeholder: 'editor-placeholder',
quote: 'editor-quote',
rtl: 'rtl',
text: {
bold: 'editor-text-bold',
code: 'editor-text-code',
hashtag: 'editor-text-hashtag',
italic: 'editor-text-italic',
overflowed: 'editor-text-overflowed',
strikethrough: 'editor-text-strikethrough',
underline: 'editor-text-underline',
underlineStrikethrough: 'editor-text-underlineStrikethrough',
},
};

View File

@ -0,0 +1,91 @@
import { BeginId } from '@/pages/flow/constant';
import { DecoratorNode, LexicalNode, NodeKey } from 'lexical';
import { ReactNode } from 'react';
const prefix = BeginId + '@';
export class VariableNode extends DecoratorNode<ReactNode> {
__value: string;
__label: string;
key?: NodeKey;
__parentLabel?: string | ReactNode;
__icon?: ReactNode;
static getType(): string {
return 'variable';
}
static clone(node: VariableNode): VariableNode {
return new VariableNode(
node.__value,
node.__label,
node.__key,
node.__parentLabel,
node.__icon,
);
}
constructor(
value: string,
label: string,
key?: NodeKey,
parent?: string | ReactNode,
icon?: ReactNode,
) {
super(key);
this.__value = value;
this.__label = label;
this.__parentLabel = parent;
this.__icon = icon;
}
createDOM(): HTMLElement {
const dom = document.createElement('span');
dom.className = 'mr-1';
return dom;
}
updateDOM(): false {
return false;
}
decorate(): ReactNode {
let content: ReactNode = (
<div className="text-blue-600">{this.__label}</div>
);
if (this.__parentLabel) {
content = (
<div className="flex items-center gap-1 text-text-primary ">
<div>{this.__icon}</div>
<div>{this.__parentLabel}</div>
<div className="text-text-disabled mr-1">/</div>
{content}
</div>
);
}
return (
<div className="bg-gray-200 dark:bg-gray-400 text-sm inline-flex items-center rounded-md px-2 py-1">
{content}
</div>
);
}
getTextContent(): string {
return `{${this.__value}}`;
}
}
export function $createVariableNode(
value: string,
label: string,
parentLabel: string | ReactNode,
icon?: ReactNode,
): VariableNode {
return new VariableNode(value, label, undefined, parentLabel, icon);
}
export function $isVariableNode(
node: LexicalNode | null | undefined,
): node is VariableNode {
return node instanceof VariableNode;
}

View File

@ -0,0 +1,35 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { EditorState, LexicalEditor } from 'lexical';
import { useEffect } from 'react';
import { ProgrammaticTag } from './constant';
interface IProps {
onChange: (
editorState: EditorState,
editor?: LexicalEditor,
tags?: Set<string>,
) => void;
}
export function VariableOnChangePlugin({ onChange }: IProps) {
// Access the editor through the LexicalComposerContext
const [editor] = useLexicalComposerContext();
// Wrap our listener in useEffect to handle the teardown and avoid stale references.
useEffect(() => {
// most listeners return a teardown function that can be called to clean them up.
return editor.registerUpdateListener(
({ editorState, tags, dirtyElements }) => {
// Check if there is a "programmatic" tag
const isProgrammaticUpdate = tags.has(ProgrammaticTag);
// The onchange event is only triggered when the data is manually updated
// Otherwise, the content will be displayed incorrectly.
if (dirtyElements.size > 0 && !isProgrammaticUpdate) {
onChange(editorState);
}
},
);
}, [editor, onChange]);
return null;
}

View File

@ -0,0 +1,297 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import {
LexicalTypeaheadMenuPlugin,
MenuOption,
useBasicTypeaheadTriggerMatch,
} from '@lexical/react/LexicalTypeaheadMenuPlugin';
import {
$createParagraphNode,
$createTextNode,
$getRoot,
$getSelection,
$isRangeSelection,
TextNode,
} from 'lexical';
import React, {
ReactElement,
ReactNode,
useCallback,
useEffect,
useRef,
} from 'react';
import * as ReactDOM from 'react-dom';
import { $createVariableNode } from './variable-node';
import { useBuildQueryVariableOptions } from '@/pages/agent/hooks/use-get-begin-query';
import { ProgrammaticTag } from './constant';
import './index.css';
class VariableInnerOption extends MenuOption {
label: string;
value: string;
parentLabel: string | JSX.Element;
icon?: ReactNode;
constructor(
label: string,
value: string,
parentLabel: string | JSX.Element,
icon?: ReactNode,
) {
super(value);
this.label = label;
this.value = value;
this.parentLabel = parentLabel;
this.icon = icon;
}
}
class VariableOption extends MenuOption {
label: ReactElement | string;
title: string;
options: VariableInnerOption[];
constructor(
label: ReactElement | string,
title: string,
options: VariableInnerOption[],
) {
super(title);
this.label = label;
this.title = title;
this.options = options;
}
}
function VariablePickerMenuItem({
index,
option,
selectOptionAndCleanUp,
}: {
index: number;
option: VariableOption;
selectOptionAndCleanUp: (
option: VariableOption | VariableInnerOption,
) => void;
}) {
return (
<li
key={option.key}
tabIndex={-1}
ref={option.setRefElement}
role="option"
id={'typeahead-item-' + index}
>
<div>
<span className="text text-slate-500">{option.title}</span>
<ul className="pl-2 py-1">
{option.options.map((x) => (
<li
key={x.value}
onClick={() => selectOptionAndCleanUp(x)}
className="hover:bg-slate-300 p-1"
>
{x.label}
</li>
))}
</ul>
</div>
</li>
);
}
export default function VariablePickerMenuPlugin({
value,
}: {
value?: string;
}): JSX.Element {
const [editor] = useLexicalComposerContext();
const isFirstRender = useRef(true);
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
minLength: 0,
});
const [queryString, setQueryString] = React.useState<string | null>('');
const options = useBuildQueryVariableOptions();
const buildNextOptions = useCallback(() => {
let filteredOptions = options;
if (queryString) {
const lowerQuery = queryString.toLowerCase();
filteredOptions = options
.map((x) => ({
...x,
options: x.options.filter(
(y) =>
y.label.toLowerCase().includes(lowerQuery) ||
y.value.toLowerCase().includes(lowerQuery),
),
}))
.filter((x) => x.options.length > 0);
}
const nextOptions: VariableOption[] = filteredOptions.map(
(x) =>
new VariableOption(
x.label,
x.title,
x.options.map((y) => {
return new VariableInnerOption(y.label, y.value, x.label, y.icon);
}),
),
);
return nextOptions;
}, [options, queryString]);
const findItemByValue = useCallback(
(value: string) => {
const children = options.reduce<
Array<{
label: string;
value: string;
parentLabel?: string | ReactNode;
icon?: ReactNode;
}>
>((pre, cur) => {
return pre.concat(cur.options);
}, []);
return children.find((x) => x.value === value);
},
[options],
);
const onSelectOption = useCallback(
(
selectedOption: VariableOption | VariableInnerOption,
nodeToRemove: TextNode | null,
closeMenu: () => void,
) => {
editor.update(() => {
const selection = $getSelection();
if (!$isRangeSelection(selection) || selectedOption === null) {
return;
}
if (nodeToRemove) {
nodeToRemove.remove();
}
const variableNode = $createVariableNode(
(selectedOption as VariableInnerOption).value,
selectedOption.label as string,
selectedOption.parentLabel as string | ReactNode,
selectedOption.icon as ReactNode,
);
selection.insertNodes([variableNode]);
closeMenu();
});
},
[editor],
);
const parseTextToVariableNodes = useCallback(
(text: string) => {
const paragraph = $createParagraphNode();
// Regular expression to match content within {}
const regex = /{([^}]*)}/g;
let match;
let lastIndex = 0;
while ((match = regex.exec(text)) !== null) {
const { 1: content, index, 0: template } = match;
// Add the previous text part (if any)
if (index > lastIndex) {
const textNode = $createTextNode(text.slice(lastIndex, index));
paragraph.append(textNode);
}
// Add variable node or text node
const nodeItem = findItemByValue(content);
if (nodeItem) {
paragraph.append(
$createVariableNode(
content,
nodeItem.label,
nodeItem.parentLabel,
nodeItem.icon,
),
);
} else {
paragraph.append($createTextNode(template));
}
// Update index
lastIndex = regex.lastIndex;
}
// Add the last part of text (if any)
if (lastIndex < text.length) {
const textNode = $createTextNode(text.slice(lastIndex));
paragraph.append(textNode);
}
$getRoot().clear().append(paragraph);
if ($isRangeSelection($getSelection())) {
$getRoot().selectEnd();
}
},
[findItemByValue],
);
useEffect(() => {
if (editor && value && isFirstRender.current) {
isFirstRender.current = false;
editor.update(
() => {
parseTextToVariableNodes(value);
},
{ tag: ProgrammaticTag },
);
}
}, [parseTextToVariableNodes, editor, value]);
return (
<LexicalTypeaheadMenuPlugin<VariableOption | VariableInnerOption>
onQueryChange={setQueryString}
onSelectOption={onSelectOption}
triggerFn={checkForTriggerMatch}
options={buildNextOptions()}
menuRenderFn={(anchorElementRef, { selectOptionAndCleanUp }) => {
const nextOptions = buildNextOptions();
return anchorElementRef.current && nextOptions.length
? ReactDOM.createPortal(
<div className="typeahead-popover w-[200px] p-2">
<ul className="overflow-y-auto !scrollbar-thin overflow-x-hidden">
{nextOptions.map((option, i: number) => (
<VariablePickerMenuItem
index={i}
key={option.key}
option={option}
selectOptionAndCleanUp={selectOptionAndCleanUp}
/>
))}
</ul>
</div>,
anchorElementRef.current,
)
: null;
}}
/>
);
}

View File

@ -0,0 +1,66 @@
import { SelectWithSearch } from '@/components/originui/select-with-search';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { toLower } from 'lodash';
import { ReactNode, useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { VariableType } from '../../constant';
import { useBuildQueryVariableOptions } from '../../hooks/use-get-begin-query';
type QueryVariableProps = {
name?: string;
type?: VariableType;
label?: ReactNode;
};
export function QueryVariable({
name = 'query',
type,
label,
}: QueryVariableProps) {
const { t } = useTranslation();
const form = useFormContext();
const nextOptions = useBuildQueryVariableOptions();
const finalOptions = useMemo(() => {
return type
? nextOptions.map((x) => {
return {
...x,
options: x.options.filter((y) => toLower(y.type).includes(type)),
};
})
: nextOptions;
}, [nextOptions, type]);
return (
<FormField
control={form.control}
name={name}
render={({ field }) => (
<FormItem>
{label || (
<FormLabel tooltip={t('flow.queryTip')}>
{t('flow.query')}
</FormLabel>
)}
<FormControl>
<SelectWithSearch
options={finalOptions}
{...field}
allowClear
></SelectWithSearch>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}

Some files were not shown because too many files have changed in this diff Show More