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:
@@ -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 { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
import { BlockEnum } from '@/app/components/workflow/types'
|
||||||
import type { NodeDefault, ToolWithProvider } from '../../types'
|
import type { NodeDefault, ToolWithProvider, Var } from '../../types'
|
||||||
import type { ToolNodeType } from './types'
|
import type { ToolNodeType } from './types'
|
||||||
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
|
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
|
||||||
import { TOOL_OUTPUT_STRUCT } from '../../constants'
|
import { TOOL_OUTPUT_STRUCT } from '../../constants'
|
||||||
import { CollectionType } from '@/app/components/tools/types'
|
import { CollectionType } from '@/app/components/tools/types'
|
||||||
import { canFindTool } from '@/utils'
|
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'
|
const i18nPrefix = 'workflow.errorMsg'
|
||||||
|
|
||||||
@@ -88,32 +89,26 @@ const nodeDefault: NodeDefault<ToolNodeType> = {
|
|||||||
const currCollection = currentTools.find(item => canFindTool(item.id, provider_id))
|
const currCollection = currentTools.find(item => canFindTool(item.id, provider_id))
|
||||||
const currTool = currCollection?.tools.find(tool => tool.name === payload.tool_name)
|
const currTool = currCollection?.tools.find(tool => tool.name === payload.tool_name)
|
||||||
const output_schema = currTool?.output_schema
|
const output_schema = currTool?.output_schema
|
||||||
let res: any[] = []
|
let res: Var[] = []
|
||||||
if (!output_schema || !output_schema.properties) {
|
if (!output_schema || !output_schema.properties) {
|
||||||
res = TOOL_OUTPUT_STRUCT
|
res = TOOL_OUTPUT_STRUCT
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const outputSchema: any[] = []
|
const outputSchema: Var[] = []
|
||||||
Object.keys(output_schema.properties).forEach((outputKey) => {
|
Object.keys(output_schema.properties).forEach((outputKey) => {
|
||||||
const output = output_schema.properties[outputKey]
|
const output = output_schema.properties[outputKey]
|
||||||
const dataType = output.type
|
const { type, schemaType } = resolveVarType(output, schemaTypeDefinitions)
|
||||||
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
|
|
||||||
|
|
||||||
outputSchema.push({
|
outputSchema.push({
|
||||||
variable: outputKey,
|
variable: outputKey,
|
||||||
type,
|
type,
|
||||||
description: output.description,
|
des: output.description,
|
||||||
schemaType,
|
schemaType,
|
||||||
children: output.type === 'object' ? {
|
children: output.type === 'object' ? {
|
||||||
schema: {
|
schema: {
|
||||||
type: 'object',
|
type: Type.object,
|
||||||
properties: output.properties,
|
properties: output.properties,
|
||||||
|
additionalProperties: false,
|
||||||
},
|
},
|
||||||
} : undefined,
|
} : undefined,
|
||||||
})
|
})
|
||||||
|
|||||||
101
web/app/components/workflow/nodes/tool/output-schema-utils.ts
Normal file
101
web/app/components/workflow/nodes/tool/output-schema-utils.ts
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user