mirror of https://github.com/langgenius/dify.git
fix(workflow): tool plugin output_schema array type not selectable in subsequent nodes (#29035)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
02122907e5
commit
63624dece1
|
|
@ -0,0 +1,280 @@
|
|||
import { VarType } from '@/app/components/workflow/types'
|
||||
import {
|
||||
normalizeJsonSchemaType,
|
||||
pickItemSchema,
|
||||
resolveVarType,
|
||||
} from '../output-schema-utils'
|
||||
|
||||
// Mock the getMatchedSchemaType dependency
|
||||
jest.mock('../../_base/components/variable/use-match-schema-type', () => ({
|
||||
getMatchedSchemaType: (schema: any) => {
|
||||
// Return schema_type or schemaType if present
|
||||
return schema?.schema_type || schema?.schemaType || undefined
|
||||
},
|
||||
}))
|
||||
|
||||
describe('output-schema-utils', () => {
|
||||
describe('normalizeJsonSchemaType', () => {
|
||||
it('should return undefined for null or undefined schema', () => {
|
||||
expect(normalizeJsonSchemaType(null)).toBeUndefined()
|
||||
expect(normalizeJsonSchemaType(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return the type directly for simple string type', () => {
|
||||
expect(normalizeJsonSchemaType({ type: 'string' })).toBe('string')
|
||||
expect(normalizeJsonSchemaType({ type: 'number' })).toBe('number')
|
||||
expect(normalizeJsonSchemaType({ type: 'boolean' })).toBe('boolean')
|
||||
expect(normalizeJsonSchemaType({ type: 'object' })).toBe('object')
|
||||
expect(normalizeJsonSchemaType({ type: 'array' })).toBe('array')
|
||||
expect(normalizeJsonSchemaType({ type: 'integer' })).toBe('integer')
|
||||
})
|
||||
|
||||
it('should handle array type with nullable (e.g., ["string", "null"])', () => {
|
||||
expect(normalizeJsonSchemaType({ type: ['string', 'null'] })).toBe('string')
|
||||
expect(normalizeJsonSchemaType({ type: ['null', 'number'] })).toBe('number')
|
||||
expect(normalizeJsonSchemaType({ type: ['object', 'null'] })).toBe('object')
|
||||
})
|
||||
|
||||
it('should handle oneOf schema', () => {
|
||||
expect(normalizeJsonSchemaType({
|
||||
oneOf: [
|
||||
{ type: 'string' },
|
||||
{ type: 'null' },
|
||||
],
|
||||
})).toBe('string')
|
||||
})
|
||||
|
||||
it('should handle anyOf schema', () => {
|
||||
expect(normalizeJsonSchemaType({
|
||||
anyOf: [
|
||||
{ type: 'number' },
|
||||
{ type: 'null' },
|
||||
],
|
||||
})).toBe('number')
|
||||
})
|
||||
|
||||
it('should handle allOf schema', () => {
|
||||
expect(normalizeJsonSchemaType({
|
||||
allOf: [
|
||||
{ type: 'object' },
|
||||
],
|
||||
})).toBe('object')
|
||||
})
|
||||
|
||||
it('should infer object type from properties', () => {
|
||||
expect(normalizeJsonSchemaType({
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
},
|
||||
})).toBe('object')
|
||||
})
|
||||
|
||||
it('should infer array type from items', () => {
|
||||
expect(normalizeJsonSchemaType({
|
||||
items: { type: 'string' },
|
||||
})).toBe('array')
|
||||
})
|
||||
|
||||
it('should return undefined for empty schema', () => {
|
||||
expect(normalizeJsonSchemaType({})).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickItemSchema', () => {
|
||||
it('should return undefined for null or undefined schema', () => {
|
||||
expect(pickItemSchema(null)).toBeUndefined()
|
||||
expect(pickItemSchema(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined if no items property', () => {
|
||||
expect(pickItemSchema({ type: 'array' })).toBeUndefined()
|
||||
expect(pickItemSchema({})).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return items directly if items is an object', () => {
|
||||
const itemSchema = { type: 'string' }
|
||||
expect(pickItemSchema({ items: itemSchema })).toBe(itemSchema)
|
||||
})
|
||||
|
||||
it('should return first item if items is an array (tuple schema)', () => {
|
||||
const firstItem = { type: 'string' }
|
||||
const secondItem = { type: 'number' }
|
||||
expect(pickItemSchema({ items: [firstItem, secondItem] })).toBe(firstItem)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveVarType', () => {
|
||||
describe('primitive types', () => {
|
||||
it('should resolve string type', () => {
|
||||
const result = resolveVarType({ type: 'string' })
|
||||
expect(result.type).toBe(VarType.string)
|
||||
})
|
||||
|
||||
it('should resolve number type', () => {
|
||||
const result = resolveVarType({ type: 'number' })
|
||||
expect(result.type).toBe(VarType.number)
|
||||
})
|
||||
|
||||
it('should resolve integer type', () => {
|
||||
const result = resolveVarType({ type: 'integer' })
|
||||
expect(result.type).toBe(VarType.integer)
|
||||
})
|
||||
|
||||
it('should resolve boolean type', () => {
|
||||
const result = resolveVarType({ type: 'boolean' })
|
||||
expect(result.type).toBe(VarType.boolean)
|
||||
})
|
||||
|
||||
it('should resolve object type', () => {
|
||||
const result = resolveVarType({ type: 'object' })
|
||||
expect(result.type).toBe(VarType.object)
|
||||
})
|
||||
})
|
||||
|
||||
describe('array types', () => {
|
||||
it('should resolve array of strings to arrayString', () => {
|
||||
const result = resolveVarType({
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
})
|
||||
expect(result.type).toBe(VarType.arrayString)
|
||||
})
|
||||
|
||||
it('should resolve array of numbers to arrayNumber', () => {
|
||||
const result = resolveVarType({
|
||||
type: 'array',
|
||||
items: { type: 'number' },
|
||||
})
|
||||
expect(result.type).toBe(VarType.arrayNumber)
|
||||
})
|
||||
|
||||
it('should resolve array of integers to arrayNumber', () => {
|
||||
const result = resolveVarType({
|
||||
type: 'array',
|
||||
items: { type: 'integer' },
|
||||
})
|
||||
expect(result.type).toBe(VarType.arrayNumber)
|
||||
})
|
||||
|
||||
it('should resolve array of booleans to arrayBoolean', () => {
|
||||
const result = resolveVarType({
|
||||
type: 'array',
|
||||
items: { type: 'boolean' },
|
||||
})
|
||||
expect(result.type).toBe(VarType.arrayBoolean)
|
||||
})
|
||||
|
||||
it('should resolve array of objects to arrayObject', () => {
|
||||
const result = resolveVarType({
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(result.type).toBe(VarType.arrayObject)
|
||||
})
|
||||
|
||||
it('should resolve array without items to generic array', () => {
|
||||
const result = resolveVarType({ type: 'array' })
|
||||
expect(result.type).toBe(VarType.array)
|
||||
})
|
||||
})
|
||||
|
||||
describe('complex schema - user scenario (tags field)', () => {
|
||||
it('should correctly resolve tags array with object items', () => {
|
||||
// This is the exact schema from the user's issue
|
||||
const tagsSchema = {
|
||||
type: 'array',
|
||||
description: '标签数组',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: '标签ID',
|
||||
},
|
||||
k: {
|
||||
type: 'number',
|
||||
description: '标签类型',
|
||||
},
|
||||
group: {
|
||||
type: 'number',
|
||||
description: '标签分组',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const result = resolveVarType(tagsSchema)
|
||||
expect(result.type).toBe(VarType.arrayObject)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nullable types', () => {
|
||||
it('should handle nullable string type', () => {
|
||||
const result = resolveVarType({ type: ['string', 'null'] })
|
||||
expect(result.type).toBe(VarType.string)
|
||||
})
|
||||
|
||||
it('should handle nullable array type', () => {
|
||||
const result = resolveVarType({
|
||||
type: ['array', 'null'],
|
||||
items: { type: 'string' },
|
||||
})
|
||||
expect(result.type).toBe(VarType.arrayString)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unknown types', () => {
|
||||
it('should resolve unknown type to any', () => {
|
||||
const result = resolveVarType({ type: 'unknown_type' })
|
||||
expect(result.type).toBe(VarType.any)
|
||||
})
|
||||
|
||||
it('should resolve empty schema to any', () => {
|
||||
const result = resolveVarType({})
|
||||
expect(result.type).toBe(VarType.any)
|
||||
})
|
||||
})
|
||||
|
||||
describe('file types via schemaType', () => {
|
||||
it('should resolve object with file schemaType to file', () => {
|
||||
const result = resolveVarType({
|
||||
type: 'object',
|
||||
schema_type: 'file',
|
||||
})
|
||||
expect(result.type).toBe(VarType.file)
|
||||
expect(result.schemaType).toBe('file')
|
||||
})
|
||||
|
||||
it('should resolve array of files to arrayFile', () => {
|
||||
const result = resolveVarType({
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
schema_type: 'file',
|
||||
},
|
||||
})
|
||||
expect(result.type).toBe(VarType.arrayFile)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nested arrays', () => {
|
||||
it('should handle array of arrays as generic array', () => {
|
||||
const result = resolveVarType({
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
})
|
||||
// Nested arrays fall back to generic array type
|
||||
expect(result.type).toBe(VarType.array)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
import { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import type { NodeDefault, ToolWithProvider } from '../../types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import type { NodeDefault, ToolWithProvider, Var } from '../../types'
|
||||
import type { ToolNodeType } from './types'
|
||||
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
|
||||
import { TOOL_OUTPUT_STRUCT } from '../../constants'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { canFindTool } from '@/utils'
|
||||
import { getMatchedSchemaType } from '../_base/components/variable/use-match-schema-type'
|
||||
import { Type } from '../llm/types'
|
||||
import { resolveVarType } from './output-schema-utils'
|
||||
|
||||
const i18nPrefix = 'workflow.errorMsg'
|
||||
|
||||
|
|
@ -88,32 +89,26 @@ const nodeDefault: NodeDefault<ToolNodeType> = {
|
|||
const currCollection = currentTools.find(item => canFindTool(item.id, provider_id))
|
||||
const currTool = currCollection?.tools.find(tool => tool.name === payload.tool_name)
|
||||
const output_schema = currTool?.output_schema
|
||||
let res: any[] = []
|
||||
let res: Var[] = []
|
||||
if (!output_schema || !output_schema.properties) {
|
||||
res = TOOL_OUTPUT_STRUCT
|
||||
}
|
||||
else {
|
||||
const outputSchema: any[] = []
|
||||
const outputSchema: Var[] = []
|
||||
Object.keys(output_schema.properties).forEach((outputKey) => {
|
||||
const output = output_schema.properties[outputKey]
|
||||
const dataType = output.type
|
||||
const schemaType = getMatchedSchemaType(output, schemaTypeDefinitions)
|
||||
let type = dataType === 'array'
|
||||
? `Array[${output.items?.type ? output.items.type.slice(0, 1).toLocaleLowerCase() + output.items.type.slice(1) : 'Unknown'}]`
|
||||
: `${output.type ? output.type.slice(0, 1).toLocaleLowerCase() + output.type.slice(1) : 'Unknown'}`
|
||||
|
||||
if (type === VarType.object && schemaType === 'file')
|
||||
type = VarType.file
|
||||
const { type, schemaType } = resolveVarType(output, schemaTypeDefinitions)
|
||||
|
||||
outputSchema.push({
|
||||
variable: outputKey,
|
||||
type,
|
||||
description: output.description,
|
||||
des: output.description,
|
||||
schemaType,
|
||||
children: output.type === 'object' ? {
|
||||
schema: {
|
||||
type: 'object',
|
||||
type: Type.object,
|
||||
properties: output.properties,
|
||||
additionalProperties: false,
|
||||
},
|
||||
} : undefined,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
import { VarType } from '@/app/components/workflow/types'
|
||||
import { getMatchedSchemaType } from '../_base/components/variable/use-match-schema-type'
|
||||
import type { SchemaTypeDefinition } from '@/service/use-common'
|
||||
|
||||
/**
|
||||
* Normalizes a JSON Schema type to a simple string type.
|
||||
* Handles complex schemas with oneOf, anyOf, allOf.
|
||||
*/
|
||||
export const normalizeJsonSchemaType = (schema: any): string | undefined => {
|
||||
if (!schema) return undefined
|
||||
const { type, properties, items, oneOf, anyOf, allOf } = schema
|
||||
|
||||
if (Array.isArray(type))
|
||||
return type.find((item: string | null) => item && item !== 'null') || type[0]
|
||||
|
||||
if (typeof type === 'string')
|
||||
return type
|
||||
|
||||
const compositeCandidates = [oneOf, anyOf, allOf]
|
||||
.filter((entry): entry is any[] => Array.isArray(entry))
|
||||
.flat()
|
||||
|
||||
for (const candidate of compositeCandidates) {
|
||||
const normalized = normalizeJsonSchemaType(candidate)
|
||||
if (normalized)
|
||||
return normalized
|
||||
}
|
||||
|
||||
if (properties)
|
||||
return 'object'
|
||||
|
||||
if (items)
|
||||
return 'array'
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the items schema from an array schema.
|
||||
*/
|
||||
export const pickItemSchema = (schema: any) => {
|
||||
if (!schema || !schema.items)
|
||||
return undefined
|
||||
return Array.isArray(schema.items) ? schema.items[0] : schema.items
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a JSON Schema to a VarType enum value.
|
||||
* Properly handles array types by inspecting item types.
|
||||
*/
|
||||
export const resolveVarType = (
|
||||
schema: any,
|
||||
schemaTypeDefinitions?: SchemaTypeDefinition[],
|
||||
): { type: VarType; schemaType?: string } => {
|
||||
const schemaType = getMatchedSchemaType(schema, schemaTypeDefinitions)
|
||||
const normalizedType = normalizeJsonSchemaType(schema)
|
||||
|
||||
switch (normalizedType) {
|
||||
case 'string':
|
||||
return { type: VarType.string, schemaType }
|
||||
case 'number':
|
||||
return { type: VarType.number, schemaType }
|
||||
case 'integer':
|
||||
return { type: VarType.integer, schemaType }
|
||||
case 'boolean':
|
||||
return { type: VarType.boolean, schemaType }
|
||||
case 'object':
|
||||
if (schemaType === 'file')
|
||||
return { type: VarType.file, schemaType }
|
||||
return { type: VarType.object, schemaType }
|
||||
case 'array': {
|
||||
const itemSchema = pickItemSchema(schema)
|
||||
if (!itemSchema)
|
||||
return { type: VarType.array, schemaType }
|
||||
|
||||
const { type: itemType, schemaType: itemSchemaType } = resolveVarType(itemSchema, schemaTypeDefinitions)
|
||||
const resolvedSchemaType = schemaType || itemSchemaType
|
||||
|
||||
if (itemSchemaType === 'file')
|
||||
return { type: VarType.arrayFile, schemaType: resolvedSchemaType }
|
||||
|
||||
switch (itemType) {
|
||||
case VarType.string:
|
||||
return { type: VarType.arrayString, schemaType: resolvedSchemaType }
|
||||
case VarType.number:
|
||||
case VarType.integer:
|
||||
return { type: VarType.arrayNumber, schemaType: resolvedSchemaType }
|
||||
case VarType.boolean:
|
||||
return { type: VarType.arrayBoolean, schemaType: resolvedSchemaType }
|
||||
case VarType.object:
|
||||
return { type: VarType.arrayObject, schemaType: resolvedSchemaType }
|
||||
case VarType.file:
|
||||
return { type: VarType.arrayFile, schemaType: resolvedSchemaType }
|
||||
default:
|
||||
return { type: VarType.array, schemaType: resolvedSchemaType }
|
||||
}
|
||||
}
|
||||
default:
|
||||
return { type: VarType.any, schemaType }
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue