Feat: Flatten the request schema of the webhook #10427 (#11917)

### What problem does this PR solve?

Feat: Flatten the request schema of the webhook #10427

### Type of change


- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
balibabu
2025-12-12 09:59:54 +08:00
committed by GitHub
parent 74afb8d710
commit a6bd765a02
10 changed files with 276 additions and 135 deletions

View File

@ -2008,6 +2008,9 @@ Important structured information may include: names, dates, locations, events, k
basic: 'Basic', basic: 'Basic',
bearer: 'Bearer', bearer: 'Bearer',
apiKey: 'Api Key', apiKey: 'Api Key',
queryParameters: 'Query parameters',
headerParameters: 'Header parameters',
requestBodyParameters: 'Request body parameters',
}, },
}, },
llmTools: { llmTools: {

View File

@ -1802,6 +1802,9 @@ Tokenizer 会根据所选方式将内容存储为对应的数据结构。`,
basic: '基础认证', basic: '基础认证',
bearer: '承载令牌', bearer: '承载令牌',
apiKey: 'API密钥', apiKey: 'API密钥',
queryParameters: '查询参数',
headerParameters: '请求头参数',
requestBodyParameters: '请求体参数',
}, },
}, },
footer: { footer: {

View File

@ -12,18 +12,17 @@ import { RAGFlowSelect } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { FormTooltip } from '@/components/ui/tooltip'; import { FormTooltip } from '@/components/ui/tooltip';
import { WebhookAlgorithmList } from '@/constants/agent';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { t } from 'i18next'; import { t } from 'i18next';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { memo, useEffect, useRef } from 'react'; import { memo, useEffect, useRef } from 'react';
import { useForm, useWatch } from 'react-hook-form'; import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { AgentDialogueMode } from '../../constant'; import { AgentDialogueMode } from '../../constant';
import { INextOperatorForm } from '../../interface'; import { INextOperatorForm } from '../../interface';
import { ParameterDialog } from './parameter-dialog'; import { ParameterDialog } from './parameter-dialog';
import { QueryTable } from './query-table'; import { QueryTable } from './query-table';
import { BeginFormSchema } from './schema';
import { useEditQueryRecord } from './use-edit-query'; import { useEditQueryRecord } from './use-edit-query';
import { useHandleModeChange } from './use-handle-mode-change'; import { useHandleModeChange } from './use-handle-mode-change';
import { useValues } from './use-values'; import { useValues } from './use-values';
@ -36,55 +35,6 @@ const ModeOptions = [
{ value: AgentDialogueMode.Webhook, label: t('flow.webhook.name') }, { value: AgentDialogueMode.Webhook, label: t('flow.webhook.name') },
]; ];
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(),
methods: z.string().optional(),
content_types: z.string().optional(),
security: z
.object({
auth_type: z.string(),
ip_whitelist: z.array(z.object({ value: z.string() })),
rate_limit: z.object({
limit: z.number(),
per: z.string().optional(),
}),
max_body_size: z.string(),
jwt: z
.object({
algorithm: z.string().default(WebhookAlgorithmList[0]).optional(),
})
.optional(),
})
.optional(),
schema: z.record(z.any()).optional(),
response: z
.object({
status: z.number(),
headers_template: z.array(
z.object({ key: z.string(), value: z.string() }),
),
body_template: z.array(z.object({ key: z.string(), value: z.string() })),
})
.optional(),
execution_mode: z.string().optional(),
});
export type BeginFormSchemaType = z.infer<typeof FormSchema>;
function BeginForm({ node }: INextOperatorForm) { function BeginForm({ node }: INextOperatorForm) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -92,7 +42,7 @@ function BeginForm({ node }: INextOperatorForm) {
const form = useForm({ const form = useForm({
defaultValues: values, defaultValues: values,
resolver: zodResolver(FormSchema), resolver: zodResolver(BeginFormSchema),
}); });
useWatchFormChange(node?.id, form); useWatchFormChange(node?.id, form);

View File

@ -0,0 +1,81 @@
import { WebhookAlgorithmList } from '@/constants/agent';
import { z } from 'zod';
export const BeginFormSchema = 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(),
methods: z.array(z.string()).optional(),
content_types: z.string().optional(),
security: z
.object({
auth_type: z.string(),
ip_whitelist: z.array(z.object({ value: z.string() })),
rate_limit: z.object({
limit: z.number(),
per: z.string().optional(),
}),
max_body_size: z.string(),
jwt: z
.object({
algorithm: z.string().default(WebhookAlgorithmList[0]).optional(),
})
.optional(),
})
.optional(),
schema: z
.object({
query: z
.array(
z.object({
key: z.string(),
type: z.string(),
required: z.boolean(),
}),
)
.optional(),
headers: z
.array(
z.object({
key: z.string(),
type: z.string(),
required: z.boolean(),
}),
)
.optional(),
body: z
.array(
z.object({
key: z.string(),
type: z.string(),
required: z.boolean(),
}),
)
.optional(),
})
.optional(),
response: z
.object({
status: z.number(),
headers_template: z.array(
z.object({ key: z.string(), value: z.string() }),
),
body_template: z.array(z.object({ key: z.string(), value: z.string() })),
})
.optional(),
execution_mode: z.string().optional(),
});
export type BeginFormSchemaType = z.infer<typeof BeginFormSchema>;

View File

@ -3,62 +3,21 @@ import { UseFormReturn } from 'react-hook-form';
import { import {
AgentDialogueMode, AgentDialogueMode,
RateLimitPerList, RateLimitPerList,
WebhookContentType,
WebhookExecutionMode, WebhookExecutionMode,
WebhookMaxBodySize, WebhookMaxBodySize,
WebhookMethod,
WebhookSecurityAuthType, WebhookSecurityAuthType,
} from '../../constant'; } from '../../constant';
// const WebhookSchema = {
// query: {
// type: 'object',
// required: [],
// properties: {
// // debug: { type: 'boolean' },
// // event: { type: 'string' },
// },
// },
// headers: {
// type: 'object',
// required: [],
// properties: {
// // 'X-Trace-ID': { type: 'string' },
// },
// },
// body: {
// type: 'object',
// required: [],
// properties: {
// id: { type: 'string' },
// payload: { type: 'object' },
// },
// },
// };
const schema = {
properties: {
query: {
type: 'object',
description: '',
},
headers: {
type: 'object',
description: '',
},
body: {
type: 'object',
description: '',
},
},
};
const initialFormValuesMap = { const initialFormValuesMap = {
schema: schema, methods: [WebhookMethod.Get],
schema: {},
'security.auth_type': WebhookSecurityAuthType.Basic, 'security.auth_type': WebhookSecurityAuthType.Basic,
'security.rate_limit.per': RateLimitPerList[0], 'security.rate_limit.per': RateLimitPerList[0],
'security.max_body_size': WebhookMaxBodySize[0], 'security.max_body_size': WebhookMaxBodySize[0],
execution_mode: WebhookExecutionMode.Immediately, execution_mode: WebhookExecutionMode.Immediately,
content_types: WebhookContentType.ApplicationJson,
}; };
export function useHandleModeChange(form: UseFormReturn<any>) { export function useHandleModeChange(form: UseFormReturn<any>) {

View File

@ -1,9 +1,10 @@
import { omit } from 'lodash'; import { isEmpty, omit } from 'lodash';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { UseFormReturn, useWatch } from 'react-hook-form'; import { UseFormReturn, useWatch } from 'react-hook-form';
import { AgentDialogueMode } from '../../constant'; import { AgentDialogueMode } from '../../constant';
import { BeginQuery } from '../../interface'; import { BeginQuery } from '../../interface';
import useGraphStore from '../../store'; import useGraphStore from '../../store';
import { BeginFormSchemaType } from './schema';
export function transferInputsArrayToObject(inputs: BeginQuery[] = []) { export function transferInputsArrayToObject(inputs: BeginQuery[] = []) {
return inputs.reduce<Record<string, Omit<BeginQuery, 'key'>>>((pre, cur) => { return inputs.reduce<Record<string, Omit<BeginQuery, 'key'>>>((pre, cur) => {
@ -13,6 +14,20 @@ export function transferInputsArrayToObject(inputs: BeginQuery[] = []) {
}, {}); }, {});
} }
function transformRequestSchemaToOutput(schema: BeginFormSchemaType['schema']) {
const outputs: Record<string, any> = {};
Object.entries(schema || {}).forEach(([key, value]) => {
if (Array.isArray(value)) {
for (const cur of value) {
outputs[`${key}.${cur.key}`] = { type: cur.type };
}
}
});
return outputs;
}
export function useWatchFormChange(id?: string, form?: UseFormReturn) { export function useWatchFormChange(id?: string, form?: UseFormReturn) {
let values = useWatch({ control: form?.control }); let values = useWatch({ control: form?.control });
const updateNodeForm = useGraphStore((state) => state.updateNodeForm); const updateNodeForm = useGraphStore((state) => state.updateNodeForm);
@ -27,9 +42,9 @@ export function useWatchFormChange(id?: string, form?: UseFormReturn) {
// Each property (body, headers, query) should be able to show secondary menu // Each property (body, headers, query) should be able to show secondary menu
if ( if (
values.mode === AgentDialogueMode.Webhook && values.mode === AgentDialogueMode.Webhook &&
values.schema?.properties !isEmpty(values.schema)
) { ) {
outputs = { ...values.schema.properties }; outputs = transformRequestSchemaToOutput(values.schema);
} }
const nextValues = { const nextValues = {

View File

@ -0,0 +1,103 @@
import { KeyInput } from '@/components/key-input';
import { SelectWithSearch } from '@/components/originui/select-with-search';
import { RAGFlowFormItem } from '@/components/ragflow-form';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { loader } from '@monaco-editor/react';
import { X } from 'lucide-react';
import { ReactNode } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { TypesWithArray } from '../../../constant';
import { buildConversationVariableSelectOptions } from '../../../utils';
import { DynamicFormHeader } from '../../components/dynamic-fom-header';
loader.config({ paths: { vs: '/vs' } });
type SelectKeysProps = {
name: string;
label: ReactNode;
tooltip?: string;
keyField?: string;
operatorField?: string;
requiredField?: string;
nodeId?: string;
};
const VariableTypeOptions = buildConversationVariableSelectOptions();
export function DynamicRequest({
name,
label,
tooltip,
keyField = 'key',
operatorField = 'type',
requiredField = 'required',
}: SelectKeysProps) {
const form = useFormContext();
const { fields, remove, append } = useFieldArray({
name: name,
control: form.control,
});
return (
<section className="space-y-2">
<DynamicFormHeader
label={label}
tooltip={tooltip}
onClick={() =>
append({
[keyField]: '',
[operatorField]: TypesWithArray.String,
[requiredField]: false,
})
}
></DynamicFormHeader>
<div className="space-y-5">
{fields.map((field, index) => {
const keyFieldAlias = `${name}.${index}.${keyField}`;
const operatorFieldAlias = `${name}.${index}.${operatorField}`;
const requiredFieldAlias = `${name}.${index}.${requiredField}`;
return (
<section key={field.id} className="flex gap-2">
<div className="flex-1 space-y-3 min-w-0">
<div className="flex items-center gap-2">
<RAGFlowFormItem name={keyFieldAlias} className="flex-1 ">
<KeyInput></KeyInput>
</RAGFlowFormItem>
<Separator className="w-2" />
<RAGFlowFormItem name={operatorFieldAlias} className="flex-1">
{(field) => (
<SelectWithSearch
value={field.value}
onChange={(val) => {
field.onChange(val);
}}
options={VariableTypeOptions}
></SelectWithSearch>
)}
</RAGFlowFormItem>
<Separator className="w-2" />
<RAGFlowFormItem name={requiredFieldAlias}>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
></Switch>
)}
</RAGFlowFormItem>
</div>
</div>
<Button variant={'ghost'} onClick={() => remove(index)}>
<X />
</Button>
</section>
);
})}
</div>
</section>
);
}

View File

@ -1,9 +1,8 @@
import { Collapse } from '@/components/collapse'; import { Collapse } from '@/components/collapse';
import { SelectWithSearch } from '@/components/originui/select-with-search'; import { SelectWithSearch } from '@/components/originui/select-with-search';
import { RAGFlowFormItem } from '@/components/ragflow-form'; import { RAGFlowFormItem } from '@/components/ragflow-form';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator'; import { MultiSelect } from '@/components/ui/multi-select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { buildOptions } from '@/utils/form'; import { buildOptions } from '@/utils/form';
import { useFormContext, useWatch } from 'react-hook-form'; import { useFormContext, useWatch } from 'react-hook-form';
@ -17,10 +16,8 @@ import {
WebhookSecurityAuthType, WebhookSecurityAuthType,
} from '../../../constant'; } from '../../../constant';
import { DynamicStringForm } from '../../components/dynamic-string-form'; import { DynamicStringForm } from '../../components/dynamic-string-form';
import { SchemaDialog } from '../../components/schema-dialog';
import { SchemaPanel } from '../../components/schema-panel';
import { useShowSchemaDialog } from '../use-show-schema-dialog';
import { Auth } from './auth'; import { Auth } from './auth';
import { WebhookRequestSchema } from './request-schema';
import { WebhookResponse } from './response'; import { WebhookResponse } from './response';
const RateLimitPerOptions = buildOptions(RateLimitPerList); const RateLimitPerOptions = buildOptions(RateLimitPerList);
@ -34,21 +31,19 @@ export function WebHook() {
name: 'execution_mode', name: 'execution_mode',
}); });
const {
showSchemaDialog,
schemaDialogVisible,
hideSchemaDialog,
handleSchemaDialogOk,
} = useShowSchemaDialog(form);
const schema = form.getValues('schema');
return ( return (
<> <>
<RAGFlowFormItem name="methods" label={t('flow.webhook.methods')}> <RAGFlowFormItem name="methods" label={t('flow.webhook.methods')}>
<SelectWithSearch {(field) => (
<MultiSelect
options={buildOptions(WebhookMethod)} options={buildOptions(WebhookMethod)}
></SelectWithSearch> placeholder={t('fileManager.pleaseSelect')}
maxCount={100}
onValueChange={field.onChange}
defaultValue={field.value}
modalPopover
/>
)}
</RAGFlowFormItem> </RAGFlowFormItem>
<RAGFlowFormItem <RAGFlowFormItem
name="content_types" name="content_types"
@ -95,6 +90,7 @@ export function WebHook() {
></DynamicStringForm> ></DynamicStringForm>
</section> </section>
</Collapse> </Collapse>
<WebhookRequestSchema></WebhookRequestSchema>
<RAGFlowFormItem <RAGFlowFormItem
name="schema" name="schema"
label={t('flow.webhook.schema')} label={t('flow.webhook.schema')}
@ -113,22 +109,6 @@ export function WebHook() {
{executionMode === WebhookExecutionMode.Immediately && ( {executionMode === WebhookExecutionMode.Immediately && (
<WebhookResponse></WebhookResponse> <WebhookResponse></WebhookResponse>
)} )}
<Separator></Separator>
<section className="flex justify-between items-center">
Schema
<Button variant={'ghost'} onClick={showSchemaDialog}>
{t('flow.structuredOutput.configuration')}
</Button>
</section>
<SchemaPanel value={schema}></SchemaPanel>
{schemaDialogVisible && (
<SchemaDialog
initialValues={schema}
hideModal={hideSchemaDialog}
onOk={handleSchemaDialogOk}
pattern={''}
></SchemaDialog>
)}
</> </>
); );
} }

View File

@ -0,0 +1,26 @@
import { Collapse } from '@/components/collapse';
import { useTranslation } from 'react-i18next';
import { DynamicRequest } from './dynamic-request';
export function WebhookRequestSchema() {
const { t } = useTranslation();
return (
<Collapse title={<div>{t('flow.webhook.schema')}</div>}>
<section className="space-y-4">
<DynamicRequest
name="schema.query"
label={t('flow.webhook.queryParameters')}
></DynamicRequest>
<DynamicRequest
name="schema.headers"
label={t('flow.webhook.headerParameters')}
></DynamicRequest>
<DynamicRequest
name="schema.body"
label={t('flow.webhook.requestBodyParameters')}
></DynamicRequest>
</section>
</Collapse>
);
}

View File

@ -35,7 +35,7 @@ import {
Operator, Operator,
TypesWithArray, TypesWithArray,
} from './constant'; } from './constant';
import { BeginFormSchemaType } from './form/begin-form'; import { BeginFormSchemaType } from './form/begin-form/schema';
import { DataOperationsFormSchemaType } from './form/data-operations-form'; import { DataOperationsFormSchemaType } from './form/data-operations-form';
import { ExtractorFormSchemaType } from './form/extractor-form'; import { ExtractorFormSchemaType } from './form/extractor-form';
import { HierarchicalMergerFormSchemaType } from './form/hierarchical-merger-form'; import { HierarchicalMergerFormSchemaType } from './form/hierarchical-merger-form';
@ -326,10 +326,31 @@ export function transformArrayToObject(
}, {}); }, {});
} }
function transformRequestSchemaToJsonschema(
schema: BeginFormSchemaType['schema'],
) {
const jsonSchema: Record<string, any> = {};
Object.entries(schema || {}).forEach(([key, value]) => {
if (Array.isArray(value)) {
jsonSchema[key] = {
type: 'object',
required: value.filter((x) => x.required).map((x) => x.key),
properties: value.reduce<Record<string, any>>((pre, cur) => {
pre[cur.key] = { type: cur.type };
return pre;
}, {}),
};
}
});
return jsonSchema;
}
function transformBeginParams(params: BeginFormSchemaType) { function transformBeginParams(params: BeginFormSchemaType) {
if (params.mode === AgentDialogueMode.Webhook) { if (params.mode === AgentDialogueMode.Webhook) {
return { return {
...params, ...params,
schema: transformRequestSchemaToJsonschema(params.schema),
security: { security: {
...params.security, ...params.security,
ip_whitelist: params.security?.ip_whitelist.map((x) => x.value), ip_whitelist: params.security?.ip_whitelist.map((x) => x.value),